零、前言
📕欢迎访问:
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
也没有)。构造函数在对象创建时自动调用,用于完成对象的初始化工作。对于向量来说,构造函数创建一个新的向量对象,并初始化其内部的元素数组、容量和规模等属性,使得向量对象可以被正确地使用。
过程如下:(花费常数时间)
- 根据指定的初始容量,向系统申请空间,以创建内部私有数组
elem[]
,若不指定则使用DEFAULT_CAPACITY
- 因为初生向量(笑)还没有元素,所以用于指示规模的变量
_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)
时间)- 释放用于存放元素的内部数组
_elem[]
,将其占用的空间交还给 OS - 其余内部变量无需处理,将作为向量对象自身的一部分被系统回收
需要说明的是,如果需要析构的内容包括动态分配的空间,因为这里没有重载,所以按照"谁申请谁释放"的原则来进行预处理(先于析构进行),至于要释放还是保留,由上层调用者来决定。
- 释放用于存放元素的内部数组
四、动态空间管理、分摊分析
使用构造函数创建的 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)