6.2 链表
到目前为止,已经大量地使用过了数组及其不定长版本——vector,使用的方法大都是随机存取和往末尾添加/删除元素。但有时也需要向数组中插入元素,下面便是一例。
例题6-4 破损的键盘(又名:悲剧文本)(Broken Keyboard(a.k.a. Beiju Text), UVa 11988)
你有一个破损的键盘。键盘上的所有键都可以正常工作,但有时Home键或者End键会自动按下。你并不知道键盘存在这一问题,而是专心地打稿子,甚至连显示器都没打开。当你打开显示器之后,展现在你面前的是一段悲剧的文本。你的任务是在打开显示器之前计算出这段悲剧文本。
输入包含多组数据。每组数据占一行,包含不超过100000个字母、下划线、字符“[”或者“]”。其中字符“[”表示Home键,“]”表示End键。输入结束标志为文件结束符(EOF)。输入文件不超过5MB。对于每组数据,输出一行,即屏幕上的悲剧文本。
样例输入:
This_is_a_[Beiju]_text
[[]][][]Happy_Birthday_to_Tsinghua_University
样例输出:
BeijuThis_is_a__text
Happy_Birthday_to_Tsinghua_University
【分析】
最简单的想法便是用数组来保存这段文本,然后用一个变量pos保存“光标位置”。这样,输入一个字符相当于在数组中插入一个字符(需要先把后面的字符全部右移,给新字符腾出位置)。
很可惜,这样的代码会超时。为什么?因为每输入一个字符都可能会引起大量字符移动。在极端情况下,例如,2500000个a和“[”交替出现,则一共需要0+1+2+…+2499999=6*1012次字符移动。
解决方案是采用链表(linked list)。每输入一个字符就把它存起来,设输入字符串是s[1~n],则可以用next[i]表示在当前显示屏中s[i]右边的字符编号(即在s中的下标)(1)。
提示6-3:在数组中频繁移动元素是很低效的,如有可能,可以使用链表。
为了方便起见,假设字符串s的最前面还有一个虚拟的s[0],则next[0]就可以表示显示屏中最左边的字符。再用一个变量cur表示光标位置:即当前光标位于s[cur]的右边。cur=0说明光标位于“虚拟字符”s[0]的右边,即显示屏的最左边。
提示6-4:为了方便起见,常常在链表的第一个元素之前放一个虚拟结点。
为了移动光标,还需要用一个变量last表示显示屏的最后一个字符是s[last]。代码如下:
#include<cstdio>
#include<cstring>
const int maxn = 100000 + 5;
int last, cur, next[maxn]; //光标位于cur号字符的后面
char s[maxn];
int main() {
while(scanf("%s", s+1) == 1) {
int n = strlen(s+1); //输入保存在s[1],s[2]...中
last = cur = 0;
next[0] = 0;
for(int i = 1; i <= n; i++) {
char ch = s[i];
if(ch == '[') cur = 0;
else if(ch == ']') cur = last;
else {
next[i] = next[cur];
next[cur] = i;
if(cur == last) last = i; //更新"最后一个字符"编号
cur = i; //移动光标
}
}
for(int i = next[0]; i != 0; i = next[i])
printf("%c", s[i]);
printf("\n");
}
return 0;
}
例题6-5 移动盒子(Boxes in a Line, UVa 12657)
你有一行盒子,从左到右依次编号为1, 2, 3,…, n。可以执行以下4种指令:
指令保证合法,即X不等于Y。例如,当n=6时在初始状态下执行114后,盒子序列为2 3 1 4 5 6。接下来执行2 3 5,盒子序列变成2 1 4 5 3 6。再执行3 1 6,得到2 6 4 5 3 1。最终执行4,得到1 3 5 4 6 2。
输入包含不超过10组数据,每组数据第一行为盒子个数n和指令条数m(1≤n,m≤100000),以下m行每行包含一条指令。每组数据输出一行,即所有奇数位置的盒子编号之和。位置从左到右编号为1~n。
样例输入:
6 4
1 1 4
2 3 5
3 1 6
4
6 3
1 1 4
2 3 5
3 1 6
100000 1
4
样例输出:
Case 1: 12
Case 2: 9
Case 3: 2500050000
【分析】
根据前面的经验,如果用数组来保存盒子,肯定会超时,但如果像例题6-4那样只保存一个next值,似乎又不够,怎么办?
解决方法是采用双向链表(doubly linked list):用left[i]和right[i]分别表示编号为i的盒子左边和右边的盒子编号(如果是0,表示不存在),则下面的过程可以让两个结点相互连接:
void link(int L, int R) {
right[L] = R; left[R] = L;
}
提示6-5:在双向链表这样的复杂链式结构中,往往会编写一些辅助函数用来设置链接关系。
有了这个代码,可以先记录好操作之前X和Y两边的结点,然后用link函数按照某种顺序把它们连起来。操作4比较特殊,为了避免一次修改所有元素的指针,此处增加一个标记inv,表示有没有执行过操作4(如果inv=1时再执行一次操作4,则inv变为0)。这样,当op为1和2且inv=1时,只需把op变成3-op(注意操作3不受inv影响)即可。最终输出时要根据inv的值进行不同处理。
提示6-6:如果数据结构上的某一个操作很耗时,有时可以用加标记的方式处理,而不需要真的执行那个操作。但同时,该数据结构的所有其他操作都要考虑这个标记。
下面的核心代码里还有一些可以借鉴的细节处理,请读者仔细阅读:
int main() {
int m, kase = 0;
while(scanf("%d%d", &n, &m) == 2) {
for(int i = 1; i <= n; i++) {
left[i] = i-1;
right[i] = (i+1) % (n+1);
}
right[0] = 1; left[0] = n;
int op, X, Y, inv = 0;
while(m--) {
scanf("%d", &op);
if(op == 4) inv = !inv;
else {
scanf("%d%d", &X, &Y);
if(op == 3 && right[Y] == X) swap(X, Y);
if(op != 3 && inv) op = 3 - op;
if(op == 1 && X == left[Y]) continue;
if(op == 2 && X == right[Y]) continue;
int LX = left[X], RX = right[X], LY = left[Y], RY = right[Y];
if(op == 1) {
link(LX, RX); link(LY, X); link(X, Y);
}
else if(op == 2) {
link(LX, RX); link(Y, X); link(X, RY);
}
else if(op == 3) {
if(right[X] == Y) { link(LX, Y); link(Y, X); link(X, RY); }
else { link(LX, Y); link(Y, RX); link(LY, X); link(X, RY); }
}
}
}
int b = 0;
long long ans = 0;
for(int i = 1; i <= n; i++) {
b = right[b];
if(i % 2 == 1) ans += b;
}
if(inv && n % 2 == 0) ans = (long long)n*(n+1)/2 - ans;
printf("Case %d: %lld\n", ++kase, ans);
}
return 0;
}
如果读者曾独立编写过上面的程序,可能会花费较长的时间进行调试。又或者,自以为正确的程序提交到UVa上之后却得到WA甚至RE或者TLE。在链式结构中,这样的情况是时常发生的,我们需要具备一定的调试和测试能力。
提示6-7:复杂的链式数据结构往往较容易写错。在包含多道题目的算法竞赛中,这一特点可以是选题的依据之一。
简单地说,测试的任务就是检查一份代码是否正确。如果找到了错误,最好还能提供一个让它错误的数据。有了错误数据之后,接下来的任务便是调试:看看程序为什么是错的。如果找到了错误,最好把它改对——至少对于刚才的错误数据能得到正确的结果。改对一组数据之后,可能还有其他错误,因此需要进一步测试;即使以前曾经正确的数据,也可能因为多次改动之后反而变错了,需要再次调试。总之,在编码结束后,为了确保程序的正确性,测试和调试往往要交替进行。
提示6-8:测试的任务就是检查一份代码是否正确。如果找到了错误,最好还能提供一个让它出错的数据;调试的任务是找到错误原因并改正。改正一个错误之后有可能引入新的错误,因此调试和测试往往要交替进行。
如何测试上述代码的正确性呢?一个行之有效的方法是:再找一份完成同样功能的代码与之对比。对于本题来说,可以先写一个基于数组的版本。虽然这个版本会很慢,但正确性比较容易保证。接下来编写一个数据生成器(在第5章中曾介绍过这一技巧),并且反复执行下面的操作:生成随机数据,分别执行两个程序,比较它们的结果(俗称“对拍”)。合理地使用操作系统提供的脚本功能,可以自动完成对比测试,具体方法请读者参见附录A。
提示6-9:测试数据结构程序的常用方法是对拍:写一个功能相同但速度较慢的简易版本,再写一个数据生成器,不停对比快慢两个程序的输出。简易版本的代码越简单越好,因为重点不在效率,而在正确性。
如果发现让两个程序答案不一致的数据,最好别急着对它进行调试。可以尝试着减小数据生成器中的n和m,试图找到一组尽量简单的错误数据。一般来说,数据越简单,越容易调试。如果发现只有很大的数据才会出错,通常意味着程序在处理极限数据方面有问题,例如,is_prime中遇到了“过大的n”,或者数组开得不够大等。这些都是很实用的技巧,建议读者多多积累。
提示6-10:数据的复杂性会大大影响调试的难度,因此在找到让程序出错的数据之后最好别急着调试,而应尝试简化数据,或者直接用更小的参数调用数据生成器,以找到更简单的错误数据。
“对拍”也是命题者采用的常用技巧——为了保证官方测试数据的正确性,命题者通常会请几个“验题者”编写程序。这些验题者往往还会故意编写错误或者速度较慢的程序,以确保这些程序会得到错误的结果,或者超时。对于一道算法竞赛的题目,正确性只是测试数据的最低要求,一套优秀的测试数据还要能全面地测出选手程序在正确性和效率上的缺陷,否则对辛辛苦苦写出正确程序的选手不公平。