3-4 deduplicate ()分析

template <typename T> Rank List<T>::dedup() {
	if( _size < 2) return 0; // 平凡列表自然无重复
	Rank oldSize = _size; // 记录原规模
	ListNodePosi<T> p = first(); // 从首节点开始
	for ( Rank r = 0; p != trailer; p = p->succ ) //O(n)
		if ( ListNodePosi<T> q = find(p->data, r, p) )//在p的r个前驱中查找雷同者
			remove(q); //此时q与p雷同,但删除前者更为简明
		else r++; //r为无重前缀的长度
	return oldSize - _size; //删除元素总数
}
  1. 给出循环体具有的不变性和数学归纳证明 这里的不变性是:在迭代过程中的任意时刻,当前节点 p 的所有前驱互不相同。 算法启动之初,p 没有前驱,以上命题自然成立。 以下假定在当前迭代之后,不变性依然成立,考查随后的下一步迭代。

在此步迭代过程中,首先转至下一节点 p,并通过 find ()接口,在其前驱中查找与之雷同者。既然此前不变性是满足的,则与 p 雷同的元素至多仅有一个。这个雷同元素 q 若果真存在,则必然会被找到,并随即通过 remove (q)接口被剔除。于是无论如何,以上不变性必然再次成立。

因此根据数学归纳原理,这一不变性将始终保持,直至算法结束。那时,p 即是列表的尾哨兵元素,其余元素均为它的前驱,由不变性可知它们必然互异。由此可见,该算法是正确的。

  1. 举例说明,最好情况是什么,时间复杂度又是多少? 当所有元素均彼此雷同时,即属于该算法的最好情况。此时,deduplicate ()算法依然需要执行 O (n)步迭代,但可以证明每一步只需 O (1)时间。 实际上,根据以上所指出的不变性,当前节点 p 始终只有 1 个前驱(并且与之雷同)。因此,每步迭代中的 find ()操作仅需常数时间。 由此也可顺便得出最坏情况——所有元素彼此互异。在此种情况下,当前节点 p 的前驱数目(亦即各次 find ()操作所对应的时间)将随着迭代的推进线性地递增,平均为 O (n),算法总体的时间复杂度为 O (n^2)。

  2. 改进算法到 O (nlogn) 最简明的一种改进方法是:首先调用 sort ()接口,借助高效的算法在 O (nlogn)时间内将其转换为有序列表;进而调用 uniquify ()接口,在 O (n)时间内剔除所有雷同元素。

3-6 自调整列表

描述:对数据结构的操作,往往都集中于数据元素的一个较小子集(局部性原理)。因此对列表而言,若能将每次被访问的节点及时转移至查找长度更短的前端,则整体效率必将大为提高。这种自调整列表(self-adjusting list)如何实现?

解答: 1)新元素总是作为首节点被插入; 2)已有的元素一旦接受访问,也随即将其转移至最前端(作为首元素)。 通常的应用环境都具有较强甚至极强的数据局部性(data locality)——在其生命期的某一区间内,对列表结构的访问往往集中甚至限定于某一特定的元素子集。

引入以上策略之后,子集中元素所对应的节点,很快会“自适应地”集中至列表的前端。在此后相当长的一段时间内,其余的元素几乎可以忽略。于是,在此期间此类列表的访问效率,将主要取决于该子集(而非整个全集)的规模,因此上述改进的实际效果非常好。—— Splay Tree

3-10 插入排序性能分析

背景:序列中 n 个元素的数值为独立均匀地随机分布。证明:

  1. ==列表的插入排序算法平均做 (n^2)/4=O (n^2)次元素比较操作== 首先,平均意义下的比较操作次数,也就是概率意义下比较操作的期望次数。 该算法共需执行 O (n)步迭代,故根据期望值的线性律(linearity of expectation),比较操作总次数的期望值,应该等于各步迭代中比较操作次数的期望值之和。

该算法中的比较操作,主要消耗于对有序子列表的 search ()查找过程。 由 3.4.2 节的分析结论,search ()接口具有线性的平均复杂度。这就意味着,各步迭代内的 search ()过程所涉及的比较操作次数,应从 0 到 n - 1 按算术级数线性递增,故其总和应为:

  1. ==向量插入排序算法平均做 (n^2)/4=O (n^2)次元素移动操作== 与列表不同,向量的插入排序中 search ()查找接口可以采用二分查找之类的算法,从而使其复杂度从线性降低至 O (logn)。 然而另一方面,在确定适当位置之后为将新元素插入已排序的子序列,尽管列表只需 O (1)时间,但向量在最坏情况下我们不得移动 O (n)个节点,而且平均而言亦是如此。故与 1) 同理,总体而言,平均共需执行 O (n^2)次元素移动操作。

由这个例子,可清楚地看出两种数据访问方式各自的长处:循秩访问的方式更适宜于静态的查找操作,但在频繁动态修改的场合却显得效率低下;循位置访问的方式更适宜于动态修改,却不能高效地支持静态查找。

  1. 序列的插入排序算法过程中平均有 expected-O (logn)个元素无需移动 同样地,既然该算法由多步迭代构成,故其间无需移动的元素的期望数目,就应该等于各步迭代中,待插入元素无需移动的概率总和。 根据该算法的原理,对于任意 ,在第 k 步迭代启动之初,当前元素 A[k]的 k 个前驱应该业已构成一个有序的子序列 A[0, k)。不难看出,若 A[k]无需移动即使得 A[0, k]仍为有序子序列,则其充要条件是,A[k]在 A[0, k]中为最大元素。

既然假定所有元素都符合独立且均匀的随机分布,故作为前 k + 1 个输入元素中的普通一员,A[k]在其中为最大元素的概率应与其它元素均等,都是 1/(k +1)。于是,这一概率的总和即为:

3-11 逆序对与插入排序分析

template <typename T> void List<T>::insertionSort( ListNodePosi<T> p, Rank n ) { 
	for ( Rank r = 0; r < n; r++ ) { //逐一引入各节点,由Sr得到Sr+1
		insert( search( p->data, r, p ), p->data ); //查找 + 插入
		p = p->succ; remove( p->pred ); //转向下一节点
	} //n次迭代,每次O(r + 1)
} //仅使用O(1)辅助空间,属于就地算法
  1. 若所有逆序对的间距不超过 k,则插入排序的运行时间为 O (k * n) 算法进入到 A[j]所对应的那步迭代时,该元素(在输入序列中)的所有前驱应该业已构成一个有序子序列 A[0, j)。既然其中至多只有 k 个元素与 A[j]构成逆序对,故查找过程 search ()至多扫描其中的 k 个元素,即可确定适当的插入位置,对应的时间不超过 O (k)。 实际上,每一步迭代均具有如上性质,故累计运行时间不超过 O (k * n)。

在教材 12.3 节对希尔排序高效性的论证中,这一结论将至关重要。

  1. 若共有 I 个逆序对,则关键码的比较次数不超过 O (I) 将 1) 中的分析方法作一般化推广,即不难看出:每个元素所涉及比较操作的次数,应恰好等于其逆序前驱的数目;整个算法过程中所执行的比较操作的总数,应恰好等于所有元素的逆序前驱的数目总和,亦即 I。

  2. 若共有 I 个逆序对,则运行时间为 O (n+I) 由以上分析,算法过程中消耗于比较操作的时间可由 O (I)界定,而消耗于移动操作的时间可由 O (n)界定,二者累计即为 O (n + I)。

既然此处实际的运行时间更多地取决于逆序对的数目,而不仅仅是输入序列的长度,故插入排序亦属于所谓输入敏感的(input sensitive)算法。 实际上更为精确地,每步迭代中的查找都是以失败告终或者找到不大于当前元素者,或者抵达 A[-1]\越界。若将这两类操作也归入比较操作的范畴,则还有一个 O (n)项。好在就渐进意义而言,这一因素可以忽略。

3-12 插入算法的比较次数

若输入列表为{ 61, 60, 59, ..., 5, 4, 3, 2, 0, 1, 2 },则共需要做多少次关键码比较? 这里的关键在于,如何统计出查找过程 search ()(注意是从后往前)在该算法各步迭代中所执行的比较操作次数。

具体如表所示,列表结构中的头、尾哨兵,分别等效于元素-∞和+∞;已排序的子序列,用阴影表示;在当前迭代步经查找并归入排序子序列的元素,用方框注明。 就此例而言,共计 63 个元素,故对应于 63 步迭代,依次从 0 至 62 编号,各对应于一行。 由该表可以看出,第 0 步迭代经过一次(当前元素 61 与头哨兵-∞的)比较,即可确定其适当的插入位置——当然,此步的插入操作其实可以省略,但为简化控制逻辑,算法中不妨统一处理。 实际上,从第 0 步至第 60 步迭代所插入的元素,在当时都是最小的,故每一查找过程 search ()都会终止于头哨兵-无穷大,而新元素都会被转移至最前端作为首元素。由此可见,这些迭代步所对应的比较次数,应从 1 至 61 逐步递增。

最后两步迭代原理一样,但过程与结果略有区别。第 61 步迭代中的查找过程 search ()需做 61 次比较,最后终止于节点 0。第 62 步迭代中的查找过程 search ()需做 60 次比较,最后终止于节点 2。请特别留意,最后一步迭代对两个雷同元素 2 的处理方式——既然 search ()算法是稳定的,故后一元素 2 应被插入于前一元素 2 之后。 累计以上各步迭代,比较操作的总次数应为: (1 + 2 + 3 + … + 60 + 61) + 61 + 60 = 2012

利用此前“插入排序算法复杂度主要取决于逆序对总数”的结论,也可得到同一结果。诚如前言,我们不难验证:以上各步迭代中所做比较操作的次数,恰好就是(在输入序列中)与当前元素构成逆序对的前驱总数。具体地,对于前 61 个元素: { 61, 60, 59, …, 3, 2, 0 } 而言,逆序前驱数依次为: { 0, 1, 2, …, 58, 59, 60 } 而对于最后两个元素: { 1, 2 } 而言,逆序前驱数分别为: { 60, 59 } 因此,原输入序列所含逆序对的总数应为: I = (0 + 1 + 2 + … + 59 + 60) + 60 + 59 = 1949 再计入每个元素所对应的最后一次失败的比较,该算法累计执行的比较操作次数应为:I + n = 1949 + 63 = 2012

3-14 循环节的应用

循环节 背景List::selectionSort() 算法,通过 selectMax() 在前缀子序列中定位最大元素 max,有可能恰好就是 tail 的前驱——自然,此时“二者”无需交换。针对这一“问题”,你可能会考虑做些“优化”,以期避免上述不必要的交换,比如将 insertB ( tail, remove (max) ); 一句改为 if ( tailpred != max ) insertB ( tail, remove (max) );

  1. 以序列{1980,1981,1982,..., 2011,2022,0,1,2,..., 1978,1979}为例,这种情况会发生多少次? 以本题所给的序列为例,不难验证有:
A[1980] = 1947 = S[1947]
A[1947] = 1914 = S[1914]
A[1914] = 1881 = S[1881]
...
A[ 66] = 33 = S[ 33]
A[ 33] = 0 = S[ 0]
A[ 0] = 1980 = S[1980]

因此相对于该序列,以下即为一个循环节: { 1980, 1947, 1914, …, 66, 33, 0 } 这是一个等差数列,公差为 33,总计的项数(即该循环节的长度)为: d = (1980 - 0)/33 + 1 = 61

不难理解,每个元素都应属于某个循环节(长度可能为 1),但不可能同时属于两个循环节。这就意味着,按照上述定义,任何序列都可以唯一地分解为若干个彼此独立的循环节

接下来,我们重新审视选择排序算法 List::selectionSort() 的每一步迭代,假设被选出的最大元为 A[m]。不难看出,将 m 转移至 tail 之前的效果,等同于该元素所属的循环节长度减一;而其它元素所属循环节的长度不变。当然,若该循环节的长度减至 0,则意味着该循环节消失。

特别地,若题中所建议的“优化”能够生效,则此时的 A[m] 就是排序序列中的 S[m];这就意味着,A[m] 必然自成一个(长度为 1 的)循环节。反之,一旦 A[m] 所属循环节的长度缩减至 1,则“优化”也必然生效。由此可见,该“优化”措施恰好对每个循环节生效一次;而在整个算法过程中生效的总次数,应恰好等于输入序列所含循环节的数目。

现在,我们再回到本题。根据上述分析结论,我们只需统计出题中所给序列中的循环节总数。实际上就这一序列而言,[0, 2012] 范围内每一个公差为 33 的等差数列,均构成一个循环节。而且,因为有: 2013 = 33 x 61 所以与上面所指出的那个循环节一样,每个循环节的长度均为 61,共计 33 个循环节。 更精细地考查代码可以发现,在处理到最后一个循环节的最后一个元素(当时的 A[0] = 0)时,该算法会直接退出而不会继续选取最大元并做移动,故上述“优化”生效的实际次数为: 33 - 1 = 32

  1. ==试证明各元素等概率独立分布的情况下,这种情况发生的概率仅为 lnn/n ~ 0,也就是说就渐进意义而言上述优化得不偿失== 继续考查列表的选择排序算法后不难发现,在 A[m] 所属循环节消失之前的瞬间,A[m] 应为 A[0, m] 中的最大元。鉴于此前的 A[0, m] 一直符合独立且均匀的随机分布,故发生这一事件的概率应与区间 [0, m] 的长度成反比。 进一步地,再次根据期望值的线性律(linearity of expectation),在 n 次循环中发生这种情况的期望次数,应等于各步迭代中发生这一事件的概率总和,亦即: 1/n + 1/(n - 1) + 1/(n - 2) + … + 1/3 + 1/2 + 1/1 = Θ(lnn)

3-15 selectMax 中判断条件对稳定性的影响

题目:若将 selectMax 中判断条件由 !lt ((cur = cur->succ)->data, max->data) 改为 lt(max->data, (cur=cur->succ)->data),则 selectionSort() 算法的输出有何变化?

解答: 教材所给的算法,是按从前(左)向后(右)的次序扫描各元素,再将选出来的当前最大元后移,并归入已排序的后缀子序列。就其语义而言,题中的两个逻辑表达式都旨在指示“当前的最大元记录需要更新”,故都能保证正确地挑选出最大元。然而在有多个雷同的最大元时,二者之间却又存在着细微而本质的差异。

按照前一逻辑表达式,在同时存在多个最大元时,算法总是会选出其中的最靠后(右)者。因此,原序列中雷同元素之间的相对次序,将在排序后的序列中得以延续。反之,若调整为后一逻辑表达式,则在同时存在多个最大元时,算法总是会选出其中的最靠前(左)者。如此,雷同元素在最终输出序列中的次序,将会完全颠倒。 比如,对于如下输入序列: { 5, 3a, 9, 3b, 3c, 2 } 若采用后一逻辑表达式,则对应的输出序列将是: { 2, 3c, 3b, 3a, 5, 9 } 而若采用前一逻辑表达式,则对应的输出序列将是: { 2, 3a, 3b, 3c, 5, 9 } 由上可见,就对雷同元素的处理方式而言,教材所给的算法实现更为精细和恰当,可以保证以 selectMax ()为基础的选择排序算法 selectionSort ()是稳定的。

3-16 mergeSort 划分的影响

// 主算法
template <typename T> void List<T>::mergeSort( ListNodePosi<T> & p, Rank n ) {
	if ( n < 2 ) return; //待排序范围足够小时直接返回,否则...
	ListNodePosi<T> q = p; Rank m = n >> 1; //以中点为界
	for ( Rank i = 0; i < m; i++ ) q = q->succ; //均分列表:O(m) = O(n)
	mergeSort( p, m ); mergeSort( q, n – m ); //子序列分别排序
	p = merge( p, m, *this, q, n – m ); //归并
} //若归并可在线性时间内完成,则总体运行时间亦为O(nlogn)
Link to original

  1. ==若为节省每次子列表的划分时间,直接令 m=min (c, n/2),c 为较小常数如 5,则总体复杂度反而上升至 O (n^2)==

做如此调整之后,在切分出来的两个子列表中,必有其一的长度不超过 c,而另一个的长度不小于 n - c。尽管如此可以在 O (c)时间内完成子任务的划分,但二路归并仍需 O (n)时间,因此其对应的递推方程应为: T (n) = T (c) + T (n - c) + O (n) 解之可得: T (n) = O (n^2)

  1. ==当 c=1 时,该算法等效地退化为插入排序==

此时,参与二路归并的每一对子列表中,总有一个长度为 1。故就实际效果而言,原算法的二路递归将退化为单分支的线性递归,“归并”操作将等同于将单个元素插入至另一子列表中。因此,整个计算过程及效果均完全等同于插入排序。

3-18 实现列表逆置

逐对颠倒前驱、后继

template <typename T> void List<T>::reverse() { //前后倒置
	if ( _size < 2 ) return; //平凡情况
	ListNodePosi(T) p; ListNodePosi(T) q;
	for ( p = header, q = p->succ; p != trailer; p = q, q = p->succ )
		p->pred = q; //自前向后,依次颠倒各节点的前驱指针
	trailer->pred = NULL; //单独设置尾节点的前驱指针
	for ( p = header, q = p->pred; p != trailer; p = q, q = p->pred )
		q->succ = p; //自前向后,依次颠倒各节点的后继指针
	header->succ = NULL; //单独讴置头节点癿后继指针
	swap ( header, trailer ); //头、尾节点互换
} 

这里,借助两个指针逐一处理相邻的各对节点。首先通过一趟遍历,自前向后地依次颠倒各节点的前驱指针(使之指向当前的后继)。接下来再通过一趟遍历,自前向后地依次颠倒各节点的后继指针(使之指向此前的前驱)。当然,在两趟遍历之后,还需要单独地设置尾节点的前驱指针、头结点的后继指针。至此,所有节点的排列次序均已完全颠倒,故最后只需交换头、尾节点的指针,即可实现整体倒置的效果。

逐个颠倒前驱、后继指针

template <typename T> void List<T>::reverse() { //前后倒置
	if ( _size < 2 ) return; //平凡情况
	for ( ListNodePosi(T) p = header; p; p = p->pred ) //自前向后,依次
		swap ( p->pred, p->succ ); //交换各节点的前驱、后继指针
	swap ( header, trailer ); //头、尾节点互换
}

这里仅需使用一个指针 p。借助该指针,自前向后地对整个列表做一趟遍历,并令每个节点的前驱、后继指针互换。同样地,最后还需令头、尾节点的指针互换。

首尾捉对交换数据域

template <typename T> void List<T>::reverse() { //前后倒置
	ListNodePosi(T) p = header; ListNodePosi(T) q = trailer; //头、尾节点
	for ( int i = 1; i < _size; i += 2 ) //(从首、末节点开始)由外而内,捉对地
		swap ( ( p = p->succ )->data, ( q = q->pred )->data ); //交换对称节点癿数据项
}

这一实现方式的思路与策略,与教材代码 1.10 中的倒置算法如出一辙。具体地,这里通过一轮迭代,从首、末节点开始由外而内,捉对地交换各对称节点的数据项。 指针 p 和 q 始终指向一对位置对称的节点。请注意,尽管其初值分别为头、尾哨兵节点,但进入迭代之后,它们所指向的都是对外可见的有效节点。

// 教材代码1.10
void reverse ( int* A, int lo, int hi ) { 
	//数组倒置
	while ( lo < hi ) //用while替换跳转标志和if,完全等效
		swap ( A[lo++], A[hi--] ); //交换A[lo]和A[hi],收缩待倒置区间
} //O(hi - lo + 1)

性能分析

无论以上何种实现,计算过程无非都是一或两趟遍历迭代,每一步迭代都只涉及常数次基本操作,因此整体的时间复杂度均为 O (n)。另外,除了列表本身,这些实现方式均只需常数的辅助空间,故也都属于就地算法。

综合而言,最后一种实现方式的形式更为简明,但若数据类型 T 本身较为复杂,则节点 data 项的直接交换可能导致耗时的构造、析构运算。而前两种实现方式虽形式略嫌复杂,但因为仅涉及指针赋值,故在基础类型 T 较为复杂的场合将更为高效。

3-19 Josephus 环问题

描述:一个刚出锅的山芋,在围成一圈的 n 个孩子间传递。大家一起数数,每数一次,当前拿着山芋的孩子就把山芋转交给紧邻其右的孩子。一旦数到事先约定的某个数 k,拿着山芋的孩子即退出,并仍该位置起重新数数。如此反复,最后剩下的那个孩子就是幸运者。

  1. 如何实现? 在本章所实现列表结构的基础上做扩展,使之成为所谓的循环列表(Circular list)。也就是说,首节点的前驱取作末节点,末节点的后继取作首节点。于是,无论是通过前驱还是后继引用,都可以反复地遍历整个列表。

初始时,将 n 个孩子组织为一个循环列表。此后借助后继引用,不难找到下一出列的孩子;其出列的动作,及对应于删除与之对应的节点。如此不断反复。

  1. 该算法时间、空间复杂度是多少? 整个算法所需的空间主要消耗于循环列表,其最大规模不过 O (n)。因为这里无需使用前驱引用,故实际上空间消耗还可进一步减少,但渐进地依然是 O (n)。 算法的执行时间,主要消耗于对游戏过程的模拟。每个孩子出列之前,都需沿着后继引用前进 k 步,故累计需要 O (n∙k)时间。 当然,在 n < k 时,可以通过取“k %= n”,进一步加快模拟过程。如此,时间复杂度应为:O (n∙mod (k, n)) = O (n^2)。