21 KiB
Conclusion on Chapter Two: Vector
Vector是一种抽象数据类型
辨析抽象数据类型(ADT, Abstract Data Type)与数据结构之间的区别与联系。
从定义上来说,
- 抽象数据类型 = 数据模型 + 定义在该模型上的一组操作
- 数据结构 = 基于某种特定的语言,实现ADT的一整套算法
这个定义,就连我邓公也是看不懂的。但是可以做一些说明:
考虑基本的数据类型,如int, float, char, 这些数据类型都定义自己相应的操作,如加减乘除,如比对,比较,赋值等。我们在使用这些数据类型的时候,并不关心这些操作的底层是怎么实现的,并且对于不同的语言,int型都具有相同的+操作,因此我们可以不加区别地放心使用+操作来处理int型数据。这些定义在数据模型(int, float, etc)上的抽象的操作,就构成了抽象数据类型。
再考虑汽车。我们可以驾驶不同公司生产的不同型号的汽车,我们并不会在每开一辆新的汽车之前,都需要重新学习如何驾驶这辆汽车,因为所有汽车的驾驶方式都是大致相同的。汽车的驾驶方式也就是汽车对外提供的接口,这个接口是规范的,抽象的,所有的汽车生产商都要按照这个接口设计生产他们的汽车,这种接口就是抽象数据类型。而不同汽车生产商生产的具有相同接口的汽车,其内部实现显然是不同,这种具体的实现就是数据结构。
为什么说Vector是一种抽象数据类型?
Vector是一种最基本的线性结构,是对线性存储数据的一种抽象。而这种抽象,前人早就帮你做好了,他们已经定义了Vector对外需要提供哪些接口,并且这些接口满足怎样的接口规范。
我们这里要实现的是一种具体的Vector,就是要按照定义好的接口规范定制我们自己的算法。只要这套算法是满足接口规范的,用户就可以不加区分地使用我们自定义的Vector,也就是像使用基本数据类型int一样使用。
int n;
float x;
char c;
Vector myVector;
Vector定义的接口规范有哪些?
多还是有点多的。但是这里我们只讨论有点难度的几个:
- 可扩充向量。要求Vector在数据满了以后,可以自动扩充空间。从而让用户感觉Vector的存储空间是无限的。
- 有序向量的
- 唯一化操作
- 查找
- 排序
可扩充向量
实现可扩充向量的一个基本想法是,当需要插入到当前向量中时,如果检测到向量空间已经满了,就进行扩容。但是问题在于,这里有不同的扩容策略。
有哪些扩容策略?
最容易想到的肯定是容量递增策略。他的基本思想是每次扩容都增加一个固定的大小INCREMENT,代码实现如下:
void Vector<T>::expand(){
if(_size < _capacity) return;//判断是否需要扩容
//else
T* tmp = _elem;
_elem = new T[_capacity += INCREMENT];//增加一个固定的大小
//copy from tmp to new _elem
for(int ix = 0; ix != _size; ++ix) _elem[ix] = tmp[ix];
delete[]tmp;//cpp 手动释放内存
}
此外,还有一种策略是容量加倍策略。也就是每次扩容都将当前Vector的容量扩大一倍。代码实现如下:
void Vector<T>::expand(){
if(_size < _capacity) return;//判断是否需要扩容
//else
T* tmp = _elem;
_elem = new T[_capacity <<= 1];//将_capacity扩大一倍
//copy from tmp to new _elem
for(int ix = 0; ix != _size; ++ix) _elem[ix] = tmp[ix];
delete[]tmp;//cpp 手动释放内存
}
这里,我们应该可以更好地理解ADT与数据结构的联系。上面两段代码都是针对同一种ADT的算法,他们都遵守相同的规范,在给用户使用时,他们都可以完成用户期望的功能,但是这两种算法的内部实现显然是不一样的,因此他们也会具有不同的性能。
递增策略与加倍策略性能比较。
如果可以忽略重新分配内存的时间的话,每次扩容的主要时间开销是将数据从原来的区域,拷贝到新分配的区域。显然,对于两种算法而已,这个操作的时间复杂度是相同的,都是$O(n)$。所以,直接比较两个算法每次扩容的时间性能,是没有意义的。
所以这里需要引入分摊复杂度的概念。所谓分摊复杂度,是指对数据结构连续地实施足够多的次的操作后,所需总体成本分摊到单次操作的时间复杂度。它与平均复杂度是有本质区别的(但是平均复杂度,目前我还没有一个比较深入的理解,只有以后再比较)。下面,我们分别考察两种扩容策略的分摊复杂度。
对于递增策略,考虑最坏的情况下,我们连续做了n次插入操作,每次扩容都是递增一个固定的大小K。这样,每次扩容所需要的时间成本为
0, K, 2K, ..., n
注意到,这是一个线性级数。因此n次操作的总时间成本为
T(n) = O(n^2)
分摊到每次操作的时间成本为O(n)
同样,接下来再考虑加倍策略。同样是最坏情况下,连续进行了n次插入操作。假定初始的容量为1,每次扩容所需要的时间成本为:
1, 2, 4, 8, ..., n
这是一个等比级数,因此n次操作的总时间成本为
T(n) = O(n)
这样,分摊到每次操作的时间成本为O(1)
有序向量的唯一化操作
有序向量的唯一化与无序向量相比,有什么优势?
首先还是考虑无序向量的唯一化。其实这就比较简单,因为你也不能做什么优化。对于当前的第k个元素,总是保证前k-1个元素是唯一化的,然后在前k-1个元素中遍历以查找当前元素。如果查找成功,则删除当前元素(后继元素全部向前移位),若查找失败,则唯一化序列长度加1,处理第k+1个元素。
上面的算法,显然具有$O(n^2)$的时间复杂度。当然可以借助set或者排序来实现$O(nlogn)$的时间复杂度,但不是我们这里讨论的范围。
那么,对于有序向量,其和无序向量相比有什么不同呢?
首要的第一个,显然是对于有序向量,相同的元素排列在一起。也就是你不需要遍历就可以判断当前元素是否存在:只需要和前面一个元素比较就可以了。这就得到了第一版的算法:
int Vector<int>::unique(){
int oldSize = _size;
for(int ix = 1; ix != _size;){
if(_elem[ix] == _elem[ix - 1]) pop(ix);
else ix++;
}
return oldSize - _size;
}
和无序向量的唯一化相比,有序向量不需要进行遍历操作,因此会节省一些开销。那么究竟节省了多少开销呢?
考虑上面的算法,在最坏情况下,所有的元素都是相等的。这样,每次循环的开销分别是:
n - 2, n - 3, ..., 1, 0
所以,该算法的时间复杂度为$O(n^2)$。可以看到,这个时间复杂度和无序向量的唯一化是一样的,就比较糟糕。
$O(n)$时间复杂度的有序向量唯一化算法
关于这个算法,我还思考了挺久的。很自然的想法是相同的元素凑在一起一起删,因为上面算法低效的元素就是频繁的删除操作,而实际上这些元素的前移有大量的冗余。但是这种算法在最坏情况下(总是成对存在的相同元素)仍然是$O(n^2)$的时间复杂度。
然后我就想啊,这样删可能还是前移操作太冗余了,不如我全部凑在一起删!这样就只需要两次遍历,一次标记各个元素需要前移的量,一次进行实质的删除工作。但是问题在于需要保存各个元素前移量的信息,需要$O(n)$的空间,感觉还是不太好,于是一筹莫展。
然后我邓公点醒了我,所以我还是觉得就我这智商,可能不适合读书给了一种双指针的算法。代码如下:
int Vector<int>::unique(){
int i = 0, j = 0;
while(++j != _size) if(_elem[j] != _elem[i]) _elem[++i] = _elem[j];
_size = ++i;
return j - i;
}
所以就这么几行代码,就实现了$O(n)$的时间复杂度和$O(1)$的空间复杂度。所以说,代码不在于量的多少,而在于思考与逻辑。这个算法里面,跟我的思路比起来,根本就没有用到删除操作。而是用i指示当前唯一化序列的最后一个元素,用j指示当前全局遍历的元素。
- 如果
j指示的是一个重复出现的元素,则直接将它忽略掉。 - 而如果
j指示元素是一个新的唯一化元素,就把它添加到i的后面。
这样,只需要$O(n)$的时间复杂度就完成有序向量的唯一化操作。可见,我的问题在于没有跳出删除操作的限制,从而看不到优化的途径。
有序向量的查找操作
search函数的接口
有必要定义一下查找函数的接口是怎么样的,因为可能存在多种情况。例如如果Vector中存在若干个相同的元素,是应该返回哪个元素的位置?又比如如果没有查找到目标元素,是应该返回一个错误码,还是返回距离目标元素最近的元素,诸如此类,都需要预先在ADT中进行定义。
所以我们这里用到的接口如下:
- 如果存在若干相同元素,则返回位置最后的一个元素(返回第一个也不是不可以,只是一种定义)
- 如果没有找到目标元素,则返回小于目标元素的最后一个元素
上面两条可以总结为,返回不大于目标元素的最后一个元素,也就是upper_bound。
这样的接口会使其他操作变得简单。比如插入操作,考虑一种情况,如果在Vector中没有找到目标元素e,则插入目标元素。在上面的接口定义下,可以轻易地找到目标元素应该插入的位置,从而简化插入操作。
int pos = v.search(e);//e不在v当中
v.insert(pos + 1, e);
二分查找
对于有序向量进行二分查找应该是基本操作吧。所以这里直接给出代码,但是目前为了简单起见,还没有完全满足上面的接口定义。
int Vector<T>::search(T e, int lo, int hi){
int mid;
while(lo < hi){
mid = (lo + hi) >> 1;
if(e < _elem[mid]) hi = mid;
else if(e > _elem[mid]) lo = mid + 1;
else return mid;
}
return -1;
}
整个操作非常简单,并且是$O(logn)$的时间复杂度。
但是邓公说,对于这种经常要用到的算法,还需要考察它复杂度的系数。所以实际上它的时间复杂度是$O(1.50logn)$,这个证明暂时还不会。我们对这个算法进行一个细致的分析:
可以看到,这个算法虽然是二分查找,可是实际上左右两边的操作时间并不是平衡的,进入右边需要更高的时间代价。这是因为进入左边只需要一次比较,而进入右边需要两次比较,所以右边的时间代价更高。左右两边不平衡会导致这个算法的运行速度变慢,也意味着还有可以优化的办法。
斐波拉契查找
上面说二分查找右边的代价高于左边的代价,作为一种优化的思路,可以想到应该尽量查找进入左边,从而减少高代价操作的次数,这个思想和哈夫曼树是一样的。斐波拉契查找就是这样一种算法。
斐波拉契查找的基本思想是不使用平均二分来划分左右两个子问题,而是使用黄金分割比来划分左右两个子区域,其中左边的区域会大于右边的区域,从而减少右边高代价操作的次数。这里要利用到一个Fib类,利用它可以在$O(logn)$的时间内创建最大值小于n的斐波拉契序列。其代码如下:
int Vector<T>::search(T e, int lo, int hi){
Fib fib(hi - lo);
int mid;
while(lo < hi){
while(hi - lo < fib.get()) fib.prev();
mid = lo + fib.get() - 1;
if(e < _elem[mid]) hi = mid;
else if(e > _elem[mid]) lo = mid + 1;
else return mid;
}
return -1;
}
可以证明(可是目前还不会),当左右划分比例恰好是黄金分割比时,即按照斐波拉契数进行分割,可以达到最优的时间复杂度,为O(1.44logn)
二分查找平衡版本
上面的斐波拉契查找的最终目的就是实现左右操作的平衡,所以它是表面不平衡的分割,却实现了平衡的左右时间代价。但为什么这么麻烦呢,有没有可能我们可以改进二分查找,让它可以只进行一次比较,即非左即右的比较?在这种情况下,我们要忽略元素相同的情况,并且把它归入到左右的一种情况。代码如下:
int Vector<T>::search(T e, int lo, int hi){
int mid;
while(lo < hi - 1){//注意不是lo < hi的判断条件了
mid = (lo + hi) >> 1;
(e < _elem[mid])? hi = mid: lo = mid;//注意是lo = mid
}
if(_elem[lo] == e) return lo;
return -1;
}
由于查找区间是[lo,hi),因此我把相同的情况归入到右边的情况,否则hi = mid会直接将相同的元素排除在外。
以及由于右边的情况是lo = mid,如果循环条件是while(lo < hi)的话,则有可能出现死循环,所以需要改成while(lo < hi - 1)。
这个版本的二分查找虽然在比较相同的情况下也还需要继续查找,但是整体效率更加稳定,平均情况下的时间复杂度更好。
二分查找的ADT版本
上面说了那么多,还没有实现一开始定义的ADT...所以现在基于二分查找的平衡版本实现一下,基本思想都是一样的,只是有些细节很值得考虑:
int Vector<T>::search(T e, int lo, int hi){
int mid;
while(lo < hi){
mid = (lo + hi) >> 1;
(e < _elem[mid])? hi = mid: lo = mid + 1;
}
return --lo;
}
可以看到,同样实现地非常简练,但是其内部逻辑其实非常完备和复杂。
- 这里左边的操作又变成了
lo = mid + 1,也就是说直接忽略相等的情况。这样,退出循环时lo总是指向大于目标元素的第一个元素。因此返回值应该是lo的前面一个元素(--lo) - 由于左边操作变成了
lo = mid + 1,循环条件又回到了while(lo < hi),因为此时每一步操作都会缩小问题的规模,不会出现死循环了。
插值查找
考虑我们查字典的时候,如果要查algorithm,肯定不会先二分查找到normal,而是直接就在最前面的地方进行查找。这是因为我们对于字典各个元素的位置分布已经有了一定的认识,因此可以快速定位。
插值查找也是基于这一思想,是首先对于Vector的元素分布具有一定的认识,如是线性分布,或者二次分布,然后就可以利用相关的函数以及首末位置元素的值,估计出目标元素的位置,并且不断迭代,直到找到或者找不到目标元素。
比较常用的就是假定目标元素是线性分布,然后通过线性插值,找到目标元素的位置。所以叫插值查找。代码如下:
int Vector<T>::interpolation_search(T const &elem, int lo, int hi){
int mid;
while(lo < hi){
mid = lo + (elem - _elem[lo])*(hi - lo - 1) / (_elem[hi - 1] - _elem[lo]);
if (mid < lo || mid >= hi) break;
if (elem < _elem[mid]) hi = mid;
else if (_elem[mid] < elem) lo = mid + 1;
else return mid;
}
return lo - 1;
}
插值查找在平均情况下,每经过一次比较,规模从$n$缩减到\sqrt{n},其平均时间复杂度为$O(loglog n)$(需要证明,但是目前不会)。这个结果虽然看起来很理想,但是在目前人类的数据量看来,和$O(logn)$相比并不占多大优势(考虑$2^{64}$的数据量)。
此外,插值查找在大数据量的输入下,表现会比较好。但是,一旦数据量缩小到一定范围,就容易受到局部扰动的影响,从而耽误大量的时间,例如整体的数据分布是线性分布,却又一个小局部不满足线性分布,是二次分布的。所以,理想的查找方法是
- 首先利用插值查找,以缩小查找的范围
- 利用二分查找完成查找操作
排序
冒泡排序
从最简单的冒泡排序开始吧。虽然我一开始听邓公讲课的时候也觉得这有什么好讲的?然后我错了。
基本冒泡排序算法如下:
void Vector<T>::bubble_sort(int lo, int hi){
while(!bubble(lo, hi--));
}
bool Vector<T>::bubble(int lo, int hi){
bool sorted = true;
while(++lo < hi){
if(_elem[lo] < _elem[lo - 1]){
swap(_elem[lo], _elem[lo - 1]);
sorted = false;
}
}
return sorted;
}
这里用到一个整体有序标志sorted来判断Vector是否已经全局有序,以防止额外的操作。
对上面版本的冒泡排序进行优化
考虑一种情况,即一个规模为n的Vector中,已经是局部有序了,只有前面k个元素是无序的。使用上面的算法的话,在最坏情况下,时间复杂度为$O(n * k)。假定 k = \sqrt{n}$,则时间复杂度为$O(n^{3/2})$。
但是实际上,它根本不需要这么高的时间复杂度。因为要是可以检测到该序列的局部有序性,只给前面的k个元素排序的话,就只需要$O(k^2) = O(n)$的时间代价就可以完成。
因此,我们需要知道每一次bubble最后进行的交换操作。而这并不难做到,只需要将sorted标志位改成最后一次交换的位置就可以了。根据这个位置信息,就可以知道当前序列的局部有序程度,从而对上面的算法进行优化。代码如下:
void Vector<T>::bubble_sort(int lo, int hi){
while(lo < (hi = bubble(lo, hi--));
}
int Vector<T>::bubble(int lo, int hi){
int sorted = lo;
while(++lo < hi){
if(_elem[lo] < _elem[lo - 1]){
swap(_elem[lo], _elem[lo - 1]);
sorted = lo;
}
}
return sorted;
}
虽然做了这样的优化,但是冒泡排序还是很渣。在最坏的条件下,仍然是$O(n^2)$的时间复杂度。与第一个版本相比还是没有本质上的提升。
比较树
什么是比较树?
基于比较的算法(CBA, Comparison Based Algorithm),如排序,找三个元素中的最大值这种,都可以描述成比较树的形式。比较树给出了一个算法所有的可能情况,其树根对应了算法入口的起始状态,内部结点对应算法过程中的某一步计算,叶节点对应了计算后的终止状态。这样一来,从根节点到叶节点的一条路径,就是算法的一个可能运行过程。
所以比较树有什么用?
可以用于估计问题的复杂度下界(即最坏情况下的最低计算成本),从而可以判断一个算法是否已经达到了极限。一旦某一个算法的性能达到这一下界,即意味着它已经是最坏情况下的最优算法了。要想进行优化,就需要采用不同的算法思想,比如采用非基于比较的算法。
如何估计问题的复杂度下界?
这里是一个比较粗略的估计。假定比较树有N个叶节点,在最优的情况下,即这棵树是完全平衡的,同时它还是一棵完美二叉树,则树高不可能低于$log_2^N$。这样,问题的规模也就不可能小于$O(log N)$。
例如对于排序问题,对于任意输入的一组序列,其结果可能有$n!$(n的全排)个。这样,比较树的树高不会低于$log_2^{n!} = O(nlogn)$,所以基于比较的排序的最优时间复杂度(复杂度下界)即$O(nlogn)$。
这里有一个误区,即认为实际中排序算法的比较树总是不平衡的,例如冒泡排序在最优情况下的路径长度为$O(n)$,而最坏情况下的输入的路径长度为$O(n^2)$。但这是不一定的,也许以后(或许现在已经)会有算法,可以做到排序问题的所有情况都具有相同的路径长度,即一种完全平衡的状态。问题的复杂度下界就是这样的一种情况。
归并排序
关于归并排序其实想说的不多,就关于归并操作写点东西。
归并操作的实现并不困难,但是问题在于归并的操作中需要用到额外的空间,所以还是要尽量让空间复杂度尽量小。邓公给出的代码如下:
void Vector<T>::merge(int lo, int mid, int hi){
int leftLen = mid - lo;
T* tmp = new T[leftLen];
for (int ix = 0; ix != leftLen; ++ix) tmp[ix] = _elem[ix + lo];
for (int pos = lo, ix = 0, jx = mid; ix != leftLen;) {
if (hi <= jx || tmp[ix] <= _elem[jx]) _elem[pos++] = tmp[ix++];
if (jx < hi && _elem[jx] < tmp[ix]) _elem[pos++] = _elem[jx++];
}
}
在上面的代码中,并不是将原数组中的所有元素都拷贝出来,而是只建立了前半部分的拷贝。这是因为后半部分不会被归并的过程所覆盖,所以可以既用作数据的源,也作为数据拷贝的终点。
关于这个代码还有要注意的地方在于循环的退出条件:该循环在前半部分索引完毕就可以退出,而没有考虑后半部分。这是因为,倘若前半部分已经读取完毕,而后半部分还有元素的话,后半部分的元素就直接在它们的目标位置了,因此不需要做任何操作。