Files
912-notes/thu_dsa/chp2/chp2.md
2019-05-16 18:31:13 +08:00

21 KiB
Raw Blame History

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++];
	}
}

在上面的代码中,并不是将原数组中的所有元素都拷贝出来,而是只建立了前半部分的拷贝。这是因为后半部分不会被归并的过程所覆盖,所以可以既用作数据的源,也作为数据拷贝的终点。

关于这个代码还有要注意的地方在于循环的退出条件:该循环在前半部分索引完毕就可以退出,而没有考虑后半部分。这是因为,倘若前半部分已经读取完毕,而后半部分还有元素的话,后半部分的元素就直接在它们的目标位置了,因此不需要做任何操作。