从零开始的数据结构Ep2-向量简介与分摊分析

零、前言

📕欢迎访问

个人主页:conqueror712.github.io/

Github主页:github.com/Conqueror71...

笔者注

本文,乃至本系列是我用于辅助自己学习的产物,难免会有些地方写的不如书本上那么精确,更多是对于知识结构的梳理以及自己的理解,毕竟不是要做一个"电子版的书本",不会面面俱到。不过也请感兴趣的读者们放心,省略掉的重要内容,文中都会有标识,如今无论是搜索引擎还是语言模型,想必都可以很快地解决疑惑,感兴趣的话可以自行查找。

另外,我会将我自己梳理的知识结构图附上,以便大家能更加一目了然地理解知识点之间存在的关系,在这其中可能会有一些理解不到位,或者错误的内容,烦请大家指出,这样一来也能更好地帮助其他有需要的人。关于每章节的课后习题,我打算在有需要的时候做成视频讲解放在 Bilibili 上,供有需要的读者朋友们参考。


数据结构是数据项的结构化 集合,而此处的结构性 表现为数据项之间的相互联系及作用,即数据项之间的某种逻辑次序

按照逻辑次序的复杂程度,可以将数据结构划分为三大类:

  • 线性结构
  • 半线性结构
  • 非线性结构

在这里,我们首先讨论线性结构的最基本形式------序列(sequence),而序列又可以按照其中存放的数据项的逻辑地址与物理地址关系,分为两种:

  • 向量(vector):所有数据项的物理位置与其逻辑次序,即秩(rank),完全吻合。
  • 列表(list):采用间接定址的方式,通过封装后的位置相互引用。

一、数组与向量

数组(array)是一种很简单的线性结构,也可以作为向量的前身来看,即更特殊的向量。

对于数组而言,我们需要知道以下几个名词的含义(内容比较基础,此处不做过多介绍):

  • 前驱(predecessor)
  • 后继(successor)
  • 直接前驱(immediate predecessor)
  • 直接后继(immediate successor)
  • 前缀(prefix)
  • 后缀(suffix)

遗憾的是,一般来说,一个数组只能存放同一类型的数据,倘若我们想要存放多种类型的数据,这时候我们就需要对其进行推广,得到所谓的向量,其中的元素分别由秩来相互区分,彼此互异。通过秩 r,可以唯一确定 e = V[r],这是向量特有的元素访问方式,即循秩访问(call-by-rank)。其实在这里,向量的秩和数组的下标起的作用差不多,我们可以一起来理解。

另外,既然同一向量可以存放不同类型的数据,那么就不能保证它们之间可以互相比较大小。


二、Vector 模板类

C++ 复制代码
typedef int Rank;	// 秩,或写成 using Rank = unsigned int;
#define DEFAULT_CAPACITY  3 // 默认的初始容量(实际应用中可设置为更大)

template <typename T> class Vector { // 向量模板类
protected:
	Rank _size; Rank _capacity;  T* _elem; // 规模、容量、数据区
	void copyFrom ( T const* A, Rank lo, Rank hi ); // 复制数组区间A[lo, hi)
	void expand(); // 空间不足时扩容
	void shrink(); // 装填因子过小时压缩
	bool bubble ( Rank lo, Rank hi ); 			// 扫描交换
	void bubbleSort ( Rank lo, Rank hi ); 		// 起泡排序算法
	Rank maxItem ( Rank lo, Rank hi ); 			// 选取最大元素
	void selectionSort ( Rank lo, Rank hi ); 	// 选择排序算法
	void merge ( Rank lo, Rank mi, Rank hi ); 	// 归并算法
	void mergeSort ( Rank lo, Rank hi ); 		// 归并排序算法
	void heapSort ( Rank lo, Rank hi ); 		// 堆排序(稍后结合完全堆讲解)
	Rank partition ( Rank lo, Rank hi ); 		// 轴点构造算法
	void quickSort ( Rank lo, Rank hi ); 		// 快速排序算法
	void shellSort ( Rank lo, Rank hi ); 		// 希尔排序算法
public:
// 构造函数
	Vector ( Rank c = DEFAULT_CAPACITY, Rank s = 0, T v = 0 ) // 容量为c、规模为s、所有元素初始为v
	{ _elem = new T[_capacity = c]; for ( _size = 0; _size < s; _elem[_size++] = v ); } // s <= c
	Vector ( T const* A, Rank n ) { copyFrom ( A, 0, n ); } // 数组整体复制
	Vector ( T const* A, Rank lo, Rank hi ) { copyFrom ( A, lo, hi ); } // 区间
	Vector ( Vector<T> const& V ) { copyFrom ( V._elem, 0, V._size ); } // 向量整体复制
	Vector ( Vector<T> const& V, Rank lo, Rank hi ) { copyFrom ( V._elem, lo, hi ); } // 区间
// 析构函数
	~Vector() { delete [] _elem; } // 释放内部空间
// 只读访问接口
	Rank size() const { return _size; } 	// 规模
	bool empty() const { return !_size; } 	// 判空
	Rank find ( T const& e ) const { return find ( e, 0, _size ); } // 无序向量整体查找
	Rank find ( T const& e, Rank lo, Rank hi ) const; // 无序向量区间查找
	Rank select( Rank k ) { return quickSelect( _elem, _size, k ); } // 从无序向量中找到第k大的元素
	Rank search( T const& e ) const // 有序向量整体查找
	{ return ( 0 >= _size ) ? -1 : search ( e, 0, _size ); }
	Rank search ( T const& e, Rank lo, Rank hi ) const; // 有序向量区间查找
// 可写访问接口
	T& operator[] ( Rank r ); // 重载下标操作符,可以类似于数组形式引用各元素
	const T& operator[] ( Rank r ) const; // 仅限于做右值的重载版本
	Vector<T> & operator= ( Vector<T> const& ); // 重载赋值操作符,以便直接克隆向量
	T remove ( Rank r ); // 删除秩为r的元素
	Rank remove ( Rank lo, Rank hi ); // 删除秩在区间[lo, hi)之内的元素
	Rank insert ( Rank r, T const& e ); // 插入元素
	Rank insert ( T const& e ) { return insert ( _size, e ); } // 默认作为末元素插入
	void sort ( Rank lo, Rank hi ); // 对[lo, hi)排序
	void sort() { sort ( 0, _size ); } // 整体排序
	void unsort ( Rank lo, Rank hi ); // 对[lo, hi)置乱
	void unsort() { unsort ( 0, _size ); } // 整体置乱
	Rank dedup(); // 无序去重
	Rank uniquify(); // 有序去重
// 遍历
	void traverse ( void (* ) ( T& ) ); // 遍历(使用函数指针,只读或局部性修改)
	template <typename VST> void traverse ( VST& ); // 遍历(使用函数对象,可全局性修改)
};

这样,我们就可以用 Vector<int>, Vector<float>, Vector<Vector<char>> 之类的形式了。

在代码的第 6 行,即 Rank _size; Rank _capacity; T* _elem; // 规模、容量、数据区,我们在 Vector 的内部维护一个元素类型为 T 的私有数组 _elem[],其容量由 _capacity 指定,其作用是存储向量的元素,这也是为什么我们称之为数据区的原因。而向量当前的实际规模,即有效元素的数量则是由 _size 指定。

另外,补充一条对于内部数组 _elem[] 的说明:V[r] 对应 _elem[r],其物理地址为 _elem + r


三、构造与析构

向量对象的构造与析构,主要围绕上述私有变量和数据区的初始化与销毁展开,我们有两种常用的构造方法(默认构造方法、基于复制的构造方法)以及一种析构方法。

  • 默认构造方法

    与所有对象一样,向量在使用前也需要先被系统创建,即借助**构造函数(constructor)**来初始化。

    因为向量是一种动态数据结构,其内部存储空间的管理需要在使用之前进行合适的初始化,所以需要使用构造函数来初始化,以确保向量在被创建后具有合适的内部状态,包括容量、规模和元素值等。

    构造函数是一种特殊的成员函数 ,其用处就是如上文所述------在对象创建时对其进行初始化。其名称与类名相同,没有返回类型(void 也没有)。

    构造函数在对象创建时自动调用,用于完成对象的初始化工作。对于向量来说,构造函数创建一个新的向量对象,并初始化其内部的元素数组、容量和规模等属性,使得向量对象可以被正确地使用。

    过程如下:(花费常数时间)

    1. 根据指定的初始容量,向系统申请空间,以创建内部私有数组 elem[],若不指定则使用 DEFAULT_CAPACITY
    2. 因为初生向量(笑)还没有元素,所以用于指示规模的变量 _size 被初始化为 0
  • 基于复制的构造方法

    简单来说,就是以某个已有的向量(包括数组)作为蓝本,进行(局部 or 整体)复制,以下给出统一的 copyFrom()

    C++ 复制代码
    template <typename T>	// 元素类型
    // 以数组区间A[lo, hi)为蓝本复制
    void Vector<T>::copyFrom (T const* A, Rank lo, Rank hi){ 
        // 分配空间,规模清零
        _elem = new T[_capacity = 2 * (hi - lo)];
        _size = 0;
        
        while (lo < hi){	// 逐一复制
            _elem[_size++] = A[lo++];
        }
    }
    
    // 由于向量内部含有动态分配的空间,默认的"="运算符不足以支持赋值,所以需要重载:
    template <typename T> Vector<T>& Vector<T>::operator = (Vector<T> const& V){
        if (_elem){
            delete [] _elem; // 释放原有内容
        }
        copyFrom(V.elem, 0, V.size()); // 整体复制
        return *this; // 返回当前对象的引用,以便链式赋值
    }
  • 析构方法:

    与所有对象一样,不再需要的向量应该借助析构函数(destructor)及时清理,毕竟系统资源很宝贵。与构造函数不同,同一对象只能有一个析构函数,不能重载

    过程如下:(花费 O(1) 时间)

    1. 释放用于存放元素的内部数组 _elem[],将其占用的空间交还给 OS
    2. 其余内部变量无需处理,将作为向量对象自身的一部分被系统回收

    需要说明的是,如果需要析构的内容包括动态分配的空间,因为这里没有重载,所以按照"谁申请谁释放"的原则来进行预处理(先于析构进行),至于要释放还是保留,由上层调用者来决定。


四、动态空间管理、分摊分析

使用构造函数创建的 Vector 对象有自己的初始容量,但倘若容量一直固定,难免会遇到上溢(overflow)的情况,即装填因子(load factor)大于 1 了( <math xmlns="http://www.w3.org/1998/Math/MathML"> 装填因子 = _ s i z e / _ c a p a c i t y 装填因子=\_size/\_capacity </math>装填因子=_size/_capacity)。

此时,使用可扩充向量就可以有效避免这种问题,其扩充策略是这样的:当原有数组满了的时候,会申请一个新的且容量更大的数组,并把原有数组的内容复制过去,最后释放原有数组,这样可以保证数组内部空间的连续性。我们可以用如下代码来表示这种 expand() 扩容算法:

C++ 复制代码
template <typename T> void Vector<T>::expand(){
    if (_size < _capacity){
        return;
    }
	if (_capacity < DEFAULT_CAPACITY){	// 保证不低于最小容量
        _capacity = DEFAULT_CAPACITY;
    }
    T* oldElem = _elem;
    _elem = new T[_capacity <<= 1];	// 常见的是容量加倍,当然也可以是别的倍数
    for (int i = 0; i < _size; i++){
        _elem[i] = oldElem[i];	// 复制
    }
    delete [] oldElem;	// 释放
}

与之相对的,当装填因子小于某一阈值的时候,数组可能会发生下溢(underflow),这虽然是个不太常见的名词,但是如果说"发生浪费"就很好理解了,这在有些注重空间利用率的场合里面还是很重要的,为此,我们给出与 expand() 扩容算法类似的 shrink() 缩容算法,思路是一样的,就不过多赘述:

C++ 复制代码
template <typename T> void Vector<T>::shrink(){
    if (_capacity < DEFAULT_CAPACITY << 1){	// 防止收缩到默认值以下
        return;
    }
    if (_size << 2 > _capacity){	// 以25%为阈值
        return;
    }
	T* oldElem = _elem;
    _elem = new T[_capacity >>= 1];	// 常见的是容量减半
    for (int i = 0; i < _size; i++){
        _elem[i] = oldElem[i];	// 复制
    }
    delete [] oldElem;	// 释放
}

在动态空间管理的过程中,分摊分析是保证运行效率的一个重要工作。

  • 分摊运行时间(amortized running time)

    假设对可扩充向量进行足够多次连续操作,并将期间消耗的时间分摊至所有的操作,如此一来得到的单次操作的平均时间成本就是分摊运行时间。

    这里先直接给出一个结论:单次扩容或缩容所需的分摊运行时间是 O(1),证明过程请见文末习题。

  • 平均运行时间(average running time),也称期望运行时间:

    按照某种假定的概率分布,对各种情况下所需要的执行时间的加权平均。


✍️相关练习讲解视频(即将上线)

Question: 对 expand() 扩容算法进行分摊分析。

C++ 复制代码
// 代码重现
template <typename T> void Vector<T>::expand(){
    if (_size < _capacity){
        return;
    }
	if (_capacity < DEFAULT_CAPACITY){	// 保证不低于最小容量
        _capacity = DEFAULT_CAPACITY;
    }
    T* oldElem = _elem;
    _elem = new T[_capacity <<= 1];	// 常见的是容量加倍,当然也可以是别的倍数
    for (int i = 0; i < _size; i++){
        _elem[i] = oldElem[i];	// 复制
    }
    delete [] oldElem;	// 释放
}

解:

考察最坏情况------连续 <math xmlns="http://www.w3.org/1998/Math/MathML"> n n </math>n 次扩充操作, <math xmlns="http://www.w3.org/1998/Math/MathML"> n → ∞ n→∞ </math>n→∞,设数组的初始容量为常数 <math xmlns="http://www.w3.org/1998/Math/MathML"> N , ( n > > N ) N,\ (n >> N) </math>N, (n>>N)

欲估计复杂度上界,不妨设向量的初始规模 = 初始容量 = <math xmlns="http://www.w3.org/1998/Math/MathML"> N N </math>N,也就是即将溢出的情况

定义如下函数:

markdown 复制代码
size(n) = 连续插入n个元素后的向量规模
capacity(n) = 连续插入n个元素后的数组容量
time(n) = 为了连续插入n个元素,而花费在扩容上的时间

易得 <math xmlns="http://www.w3.org/1998/Math/MathML"> s i z e ( n ) = N + n size(n)=N+n </math>size(n)=N+n

由算法特点,得 <math xmlns="http://www.w3.org/1998/Math/MathML"> 50 % ≤ 装填因子 ≤ 100 % 50\% ≤ 装填因子 ≤ 100\% </math>50%≤装填因子≤100%,即 <math xmlns="http://www.w3.org/1998/Math/MathML"> 50 % ≤ s i z e ( i ) c a p a c i t y ( i ) ≤ 100 % 50\% ≤ \frac{size(i)}{capacity(i)} ≤ 100\% </math>50%≤capacity(i)size(i)≤100%

∴有 <math xmlns="http://www.w3.org/1998/Math/MathML"> s i z e ( n ) ≤ c a p a c i t y ( i ) ≤ 2 s i z e ( n ) size(n)≤capacity(i)≤2size(n) </math>size(n)≤capacity(i)≤2size(n),满足 <math xmlns="http://www.w3.org/1998/Math/MathML"> c 1 ⋅ h ( n ) ≤ T ( n ) ≤ c 2 ⋅ h ( n ) c_1·h(n) ≤ T(n) ≤ c_2·h(n) </math>c1⋅h(n)≤T(n)≤c2⋅h(n),即大 Θ 记号定义

又∵ <math xmlns="http://www.w3.org/1998/Math/MathML"> N N </math>N 是常数,所以由 <math xmlns="http://www.w3.org/1998/Math/MathML"> T ( n ) = Θ ( h ( n ) ) T(n)=Θ(h(n)) </math>T(n)=Θ(h(n)) 得 <math xmlns="http://www.w3.org/1998/Math/MathML"> c a p a c i t y ( n ) = Θ ( s i z e ( n ) ) = Θ ( n + N ) = Θ ( n ) capacity(n)=Θ(size(n))=Θ(n+N)=Θ(n) </math>capacity(n)=Θ(size(n))=Θ(n+N)=Θ(n)

∵ <math xmlns="http://www.w3.org/1998/Math/MathML"> n n </math>n 的增长速度是 <math xmlns="http://www.w3.org/1998/Math/MathML"> 2 x 2^x </math>2x,即 <math xmlns="http://www.w3.org/1998/Math/MathML"> + 2 , + 4 , + 8 , + 16... +2, +4, +8,+16... </math>+2,+4,+8,+16...

∴容量的增长速度也是 <math xmlns="http://www.w3.org/1998/Math/MathML"> 2 x 2^x </math>2x,故在容量达到 <math xmlns="http://www.w3.org/1998/Math/MathML"> c a p a c i t y ( n ) capacity(n) </math>capacity(n) 之前,会做 <math xmlns="http://www.w3.org/1998/Math/MathML"> Θ ( log ⁡ 2 n ) Θ(\log_2n) </math>Θ(log2n) 次扩容

又∵每次扩容的时间开销 <math xmlns="http://www.w3.org/1998/Math/MathML"> ∝ ∝ </math>∝ 当时的规模(也就是容量,因为那时候相等)

∴ <math xmlns="http://www.w3.org/1998/Math/MathML"> t i m e ( n ) = 2 N + 4 N + 8 N + 16 N + . . . + c a p a c i t y ( n ) ≤ 2 c a p a c i t y ( n ) = Θ ( n ) time(n)=2N+4N+8N+16N+...+capacity(n)≤2capacity(n)=Θ(n) </math>time(n)=2N+4N+8N+16N+...+capacity(n)≤2capacity(n)=Θ(n),(该放缩可用等比数列求和公式得到)

∴分摊到每次操作上需要的时间开销就是 <math xmlns="http://www.w3.org/1998/Math/MathML"> Θ ( n ) n = Θ ( 1 ) \frac{Θ(n)}{n}=Θ(1) </math>nΘ(n)=Θ(1)

相关推荐
readmancynn几秒前
二分基本实现
数据结构·算法
萝卜兽编程2 分钟前
优先级队列
c++·算法
Bucai_不才4 分钟前
【数据结构】树——链式存储二叉树的基础
数据结构·二叉树
盼海10 分钟前
排序算法(四)--快速排序
数据结构·算法·排序算法
一直学习永不止步26 分钟前
LeetCode题练习与总结:最长回文串--409
java·数据结构·算法·leetcode·字符串·贪心·哈希表
Rstln1 小时前
【DP】个人练习-Leetcode-2019. The Score of Students Solving Math Expression
算法·leetcode·职场和发展
芜湖_1 小时前
【山大909算法题】2014-T1
算法·c·单链表
珹洺1 小时前
C语言数据结构——详细讲解 双链表
c语言·开发语言·网络·数据结构·c++·算法·leetcode
几窗花鸢2 小时前
力扣面试经典 150(下)
数据结构·c++·算法·leetcode
.Cnn2 小时前
用邻接矩阵实现图的深度优先遍历
c语言·数据结构·算法·深度优先·图论