第三章:列表
接口与实现
数据结构操作:
静态:仅读取,不修改数据结构,比如get,search操作。
动态:需写入,数据结构内容会改变,比如insert,remove。
与操作对应的数据结构存储方式同样有两种:
静态:数据元素的逻辑地址和物理地址一一对应,支持高效的静态操作,比如向量。
动态:动态分配元素的内存,逻辑上连续,物理可能不连续,支持高效的动态操作,比如列表。
向量采用寻秩访问的方式,而列表则采用寻位置访问的方式,利用节点之间的相互引用,找到特定的节点。
列表的初始化:
template <typename T> void List<T>::init() { //列表初始化,在创建列表对象时统一调用
header = new ListNode<T>; //创建头哨兵节点
trailer = new ListNode<T>; //创建尾哨兵节点
header->succ = trailer; header->pred = NULL;
trailer->pred = header; trailer->succ = NULL;
_size = 0; //记录规模
}
无序列表
可仿照向量重载[]运算符,实现寻秩访问,但效率较低,不常用。
查找操作:
插入操作:
基于复制的构造:
删除操作:
析构操作:
无序列表去重:
template <typename T> int List<T>::deduplicate() { //剔除无序列表中的重复节点
if ( _size < 2 ) return 0; //平凡列表自然无重复
int oldSize = _size; //记录原规模
ListNodePosi(T) p = header; Rank r = 0; //p从首节点开始
while ( trailer != ( p = p->succ ) ) { //依次直到末节点
ListNodePosi(T) q = find ( p->data, r, p ); //在p的r个(真)前驱中查找雷同者
q ? remove ( q ) : r++; //若的确存在,则删除之;否则秩加一
} //assert: 循环过程中的任意时刻,p的所有前驱互不相同
return oldSize - _size; //列表规模变化量,即被删除元素总数
}
这里的去重操作用一个指针从头结点往后逐个遍历,遍历到某结点时,搜索其真前驱中有没有与p指向元素相同的,有则删除。删除的是前驱中的雷同者,如果删除了p本身,则无法继续遍历。
无序列表遍历:
有序列表:
唯一化
回忆下有序向量的去重操作,低效的版本是发现相同的一个个删除,导致元素频繁的移动;高效的版本是不显式删除,将不重复的元素前移覆盖掉重复的元素,最后修改去重后的向量长度。
对于列表而言,只需从前往后遍历,一旦某元素与其后继结点相同,则删除后继结点,否则继续遍历即可。
template <typename T> int List<T>::uniquify() { //成批剔除重复元素,效率更高
if ( _size < 2 ) return 0; //平凡列表自然无重复
int oldSize = _size; //记录原规模
ListNodePosi(T) p = first(); ListNodePosi(T) q; //p为各区段起点,q为其后继
while ( trailer != ( q = p->succ ) ) //反复考查紧邻的节点对(p, q)
if ( p->data != q->data ) p = q; //若互异,则转向下一区段
else remove ( q ); //否则(雷同),删除后者
return oldSize - _size; //列表规模变化量,即被删除元素总数
}
由于列表是动态存储的数据结构,删除操作的时间复杂度是O(1),不必移动元素,所以该算法的时间复杂度是O(n)。为什么不仿照有序向量的去重操作高效的版本呢?个人认为,一方面是没有必要,此算法时间复杂度已经是O(n)了,如果直接把某结点的后继接到下一个与它不相同结点上,时间复杂度没有提高;另一方面是中间没有显示删除的结点会出现野指针,这是不被允许的。
查找
有序列表在插入和删除方面有着优势,在查找操作上自然存在劣势。由于逻辑地址与物理地址不一一对应,我们无法利用其有序的性质进行二分查找,只能采用顺序查找的方式。
template <typename T> //在有序列表内节点p(可能是trailer)的n个(真)前驱中,找到不大于e的最后者
ListNodePosi(T) List<T>::search ( T const& e, int n, ListNodePosi(T) p ) const {
// assert: 0 <= n <= rank(p) < _size
/*DSA*/printf ( "searching for " ); print ( e ); printf ( " :\n" );
while ( 0 <= n-- ) //对于p的最近的n个前驱,从右向左逐个比较
if ( ( ( p = p->pred )->data ) <= e ) break; //直至命中、数值越界或范围越界
return p; //返回查找终止的位置
} //失败时,返回区间左边界的前驱(可能是header)——调用者可通过valid()判断成功与否
列表选择排序
起泡排序的时间消耗在每次扫描交换都需要O(n)次比较,O(n)次交换,比较操作必不可少,但是交换操作可能没有必要。
选择排序的思路:从列表中找到最大元素的位置,将最大元素插入到无序序列的末尾,重复操作直至所有元素皆有序。
代码:
template <typename T> //列表的选择排序算法:对起始于位置p的n个元素排序
void List<T>::selectionSort ( ListNodePosi(T) p, int n ) { //valid(p) && rank(p) + n <= size
ListNodePosi(T) head = p->pred; ListNodePosi(T) tail = p;
for ( int i = 0; i < n; i++ ) tail = tail->succ; //待排序区间为(head, tail)
while ( 1 < n ) { //在至少还剩两个节点之前,在待排序区间内
ListNodePosi(T) max = selectMax ( head->succ, n ); //找出最大者(歧义时后者优先)
insertB ( tail, remove ( max ) ); //将其移至无序区间末尾(作为有序区间新的首元素)
/*DSA*///swap(tail->pred->data, selectMax( head->succ, n )->data );
tail = tail->pred; n--;
}
}
主要思路为:初始head指向头指针,tail指向尾指针,在tail的前面找到最大者,删除它并插入到tail指向的位置,即无序区间的末尾,同时tail前移一个位置。之后重复在head到tail的区间中重复该操作。
鉴于删除结点和插入结点皆需要动态内存分配或者回收,这里将最大元素删除并插入到末尾的操作可以简化为将最大元素与无序区间末尾元素的数据域交换,从而实现目的。
selectMax函数如下:
template <typename T> //从起始于位置p的n个元素中选出最大者
ListNodePosi(T) List<T>::selectMax ( ListNodePosi(T) p, int n ) {
ListNodePosi(T) max = p; //最大者暂定为首节点p
for ( ListNodePosi(T) cur = p; 1 < n; n-- ) //从首节点p出发,将后续节点逐一与max比较
if ( !lt ( ( cur = cur->succ )->data, max->data ) ) //若当前元素不小于max,则
max = cur; //更新最大元素位置记录
return max; //返回最大节点位置
}
出于对选择排序稳定性的考虑,当扫描到的元素值不小于之前记录的最大元素,即修改最大元素位置。
性能分析:
起泡排序对输入比较敏感,而选择排序则在各种情况下的复杂度均为平方级别,每次查找最大值操作的比较次数之和是算术级数。尽管复杂度级别没有提高,但是在复杂度前面常数系数上,选择排序却远优于起泡排序。这是因为选择排序元素移动的次数要远小于起泡排序,其时间消耗在元素的比较操作上,比较操作耗时是要少于移动元素耗时的。
改进:如果想要将选择排序的复杂度优化至O(nlogn),可以用大根堆来维护序列的最大值,每次在logn时间内即可完成最大值的查找操作。
循环节
PS:没有视频真的要自己理解半天啊。
循环节在邓公的习题解析上有定义,百度得到的是无限循环小数重复的小数部分叫循环节。在kmp算法中像abcabc这样重复的子串叫循环节,不太清楚这里定义的循环节是否和之前印象里的有啥联系。
结合上面的循环节定义以及实例应该可以理解循环节是啥了。
概括下就是一个序列A,排成有序后为S,比如
rank 0 1 2 3 4
A 3 1 2 4 5
S 1 2 3 4 5
r 2 0 1 3 4
其中r[A[k]]表示A[k]的秩,A中的元素3,排序后位于S的秩为2的位置,也就是说,A中某元素的秩,应该就是它在有序序列中最终应属于的位置。
再来说下循环节,3 本该在秩为2的位置,可是它却不在,排序过程中早晚得交换到秩为2的位置,既然3没有在它本该所在的位置,那么现在A中秩为2的元素肯定是鸠占鹊巢,理应和它处在一个循环节内。A中秩为2的位置的元素是2,它在S中的秩是1,也就是说1占了它的位置,而A[1]=1在S中的秩为0,A[0] = 3。综上可见,A的前三个数3 1 2之所以失序,是因为3占了1的位置,1占了2的位置,而2又占了3的位置,排序的过程就是让他们回到原本应该在的位置。对于4和5,以4为例,A[3] = 4,S[3] = 4,4已经处在它最终的位置了,故它的循环节就他一个元素。
每次迭代,最大的元素M都到达了它最终的位置,自成一个长度为1的循环节,对应的,其原来所在循环节的长度就要减一。
在选择排序中,采用先删除再插入的办法会导致当一个元素已经就位后还要执行这种多余的操作,那么如何知道一个元素在交换前已经到达最终位置了呢,也就是这个元素在A和S中的秩相等。设序列中有a b c,e f两个循环节,循环节长度不为1,所以一开始没有元素在它最终的位置,如果交换了a b,使得a归位了,那么b是否也归位了呢?显然不会,因为如果交换一次让循环节里的两个元素都归位了,那么此时循环节里肯定也就这两个元素了。继续交换a c,如果c归位了,那么a必然也就归位了,a就成了那个在还没主动将它归位就自动就位的元素,故有多少个循环节,就会出现几次无须交换的情况,极端的情况下是原始序列有序,存在n个循环节,期望值为logn个。说明无须交换的元素所占比例并不大。
列表插入排序
将序列右侧无序的元素依次插入左侧有序的序列中,直至整体有序。
template <typename T> //列表的插入排序算法:对起始于位置p的n个元素排序
void List<T>::insertionSort ( ListNodePosi(T) p, int n ) { //valid(p) && rank(p) + n <= size
/*DSA*/printf ( "InsertionSort ...\n" );
for ( int r = 0; r < n; r++ ) { //逐一为各节点
insertA ( search ( p->data, r, p ), p->data ); //查找适当的位置并插入
p = p->succ; remove ( p->pred ); //转向下一节点
}
}
插入排序属于就地算法,在线算法,输入敏感算法。
最好情况下线性的时间复杂度,最坏情况下平方级别的时间复杂度。
采用列表实现的插入排序,时间主要消耗在查找插入位置的比较上。
如果采用列表实现,固然可以二分的进行查找,但是插入操作的时间复杂度转而变成了O(n),所以无法有效的降低时间复杂度。
逆序对
在起泡排序中,我们曾用相邻逆序对的数目来衡量序列的无序长度。
例如5 3 1 4 2。 5前面没有元素与之构成逆序对,3前面只有5与之构成逆序对,以此类推,与1构成逆序对的有2个,与4构成逆序对的有1个,与2构成逆序对的有3个,一共0 + 1 + 2 + 1 + 3 = 7个逆序对。
在插入排序中,插入某元素r需要的比较次数,恰好等于前面元素与之构成的逆序对数。若一共包含I个逆序对,加上遍历的时间,插入排序的时间复杂度就为O(I + n)。
如何统计逆序对的数目,见AcWing 788 逆序对的数量。
列表归并排序
template <typename T> //列表的归并排序算法:对起始于位置p的n个元素排序
void List<T>::mergeSort ( ListNodePosi(T) & p, int n ) { //valid(p) && rank(p) + n <= size
/*DSA*/printf ( "\tMERGEsort [%3d]\n", n );
if ( n < 2 ) return; //若待排序范围已足够小,则直接返回;否则...
int m = n >> 1; //以中点为界
ListNodePosi(T) q = p; for ( int i = 0; i < m; i++ ) q = q->succ; //均分列表
mergeSort ( p, m ); mergeSort ( q, n - m ); //对前、后子列表分别排序
merge ( p, m, *this, q, n - m ); //归并
} //注意:排序后,p依然指向归并后区间的(新)起点
template <typename T> //有序列表的归并:当前列表中自p起的n个元素,与列表L中自q起的m个元素归并
void List<T>::merge ( ListNodePosi(T) & p, int n, List<T>& L, ListNodePosi(T) q, int m ) {
// assert: this.valid(p) && rank(p) + n <= size && this.sorted(p, n)
// L.valid(q) && rank(q) + m <= L._size && L.sorted(q, m)
// 注意:在归并排序之类的场合,有可能 this == L && rank(p) + n = rank(q)
ListNodePosi(T) pp = p->pred; //借助前驱(可能是header),以便返回前 ...
while ( 0 < m ) //在q尚未移出区间之前
if ( ( 0 < n ) && ( p->data <= q->data ) ) //若p仍在区间内且v(p) <= v(q),则
{ if ( q == ( p = p->succ ) ) break; n--; } //p归入合并的列表,并替换为其直接后继
else //若p已超出右界或v(q) < v(p),则
{ insertB ( p, L.remove ( ( q = q->succ )->pred ) ); m--; } //将q转移至p之前
p = pp->succ; //确定归并后区间的(新)起点
}