什么是链表?
链表是线性表的除顺序存储外的另一种存储方式,其主要是用来改善顺序存储插入删除操作过于缓慢的问题。
在实现链表时,我们往往将元素定义成结构体(或者类),其中包含两个变量:一为数据域,用来存放数据;二为指针域,用来指向下一个存储结点。大致结构如下:
当我们需要插入结点时,仅需修改前一个结点的指针域指向本结点和本结点的指针域指向下一结点;删除结点时,仅需修改前一结点的指针域跳过本结点指向下一结点,再将本结点指针域指空,随后释放内存即可。相比于顺序存储,无需元素的大量移动,从而提高了插入删除的效率。
和其他基础数据结构一样,在C++STL模板库中也给出了链表的实现:list。
#include<cstdio>
#include<list> //使用list要包含头文件
using namespace std;
list<int> ls;
list<int> ls_ex;
int main(){
int x=0,y=1;
ls.push_back(y); //向尾部添加元素
ls.push_front(x);//在头部添加元素
printf("%d",ls.front());//获取头结点
printf("%d",ls.back()); //获取尾结点
ls.pop_back(); //删除尾结点
ls.pop_front();//删除头结点
for(int i=0;i<5;++i)
ls_ex.push_back(x);
ls_ex.remove(x); //删除所有=x的元素
ls_ex.push_front(y);
//遍历打印整个list
list<int>::iterator it = ls_ex.begin();
for(;it!=ls_ex.end();++it)
printf("%d ",*it);
ls.splice(ls.end(),ls_ex);//拼接两个list,前一个参数代表拼接的位置,拼接后被操作的list变为空
ls.reverse(); //反转整个list,注意这个函数很慢
return 0;
}
但是list有个小小的问题,它的某些函数太慢了。典型的如它的翻转reverse()与大小size()函数的时间复杂度均是O(n)的!这样的话直接使用STL的list去做哪些对时间复杂度要求很高的题是会TLE掉的。我们需要找寻另外的解决方案,这些将会在下面例题中详细说明。
链表的应用
使用链表能够快速实现元素的插入删除,因此当遇到此类涉及大量元素插入删除的题目时,我们应首选使用链表。
示例一:悲剧文本(https://vjudge.net/problem/UVA-11988)
本题涉及到字符的反复插入,直接使用数组绝对是超时的,所以首选链表。
解决此题最简单的方法就是直接使用list容器,利用迭代器可以轻松模拟光标移动到首位与末尾的情形,此处只给出核心的代码:
list<char> ls;
list<char>::iterator it = ls.begin(); //使用迭代器
for(int i=0; i<len; ++i) {
if(data[i]=='[') it = ls.begin(); //模拟光标移动到首位
else if(data[i]==']') it = ls.end();//模拟光标移动到末尾
else
ls.insert(it, data[i]); //插入字符
}
另一种方法是使用数组来模拟链表,同时用tail记录每一次键入时字符串的末尾位置,用cur记录光标的位置,完整代码如下:
#include<cstdio>
#include<cstring>
using namespace std;
const int N = 100010;
int cur, tail;
char data[N];
int next[N];
int main(){
while(scanf("%s",data)!=EOF){
int n=strlen(data);
cur=0, tail=0;
next[0]=0; //此处初值只能为0,否则将会出现死循环
for(int i=1;i<=n;++i){
if(data[i-1]=='[') cur=0; //模拟光标移动到首位
else if(data[i-1]==']') cur=tail;//模拟光标移动到末尾
else{
next[i]=next[cur];//这里实际上处理的是光标移到首位后,将新键入的字符指针域指向原字符串的首位
next[cur]=i;
if(cur==tail) tail=i;//当光标移动到首位后,cur与tail将不在相等,tail的位置固定不变
cur=i;
}
}
for(int i=next[0];i!=0;i=next[i])
printf("%c",data[i-1]);
printf("\n");//注意换行
}
return 0;
}
示例二:双向链表练习题(https://ac.nowcoder.com/acm/contest/1099/K)
题目名字就这么直白了,不过这题是19年湖南省赛题,AC的人其实不是特别多。对于这个翻转操作,链表确实是好处理的,尤其是使用了双向链表。
正如上文中所说,本题涉及到翻转操作,若直接使用list中的reverse()方法,此题将会超时。为此,我们需要转换下思路,将翻转操作处理掉。如何处理?我们看题目中翻转操作的要求:将两个列表拼接后再翻转,其实可以等效为将两个列表分别翻转后再拼接。为便于理解,举个例子,有列表 1 2 3 和列表 4 5 6,其拼接后再翻转得到的列表为6 5 4 3 2 1,若先翻转则得到3 2 1 与 6 5 4,此时改变拼接方式将第一个列表拼接到第二个列表后同样得到了6 5 4 3 2 1,由此我们得到结论,reverse(a+b) = reverse(b) + reverse(a)。那么你可能会问,得到了这个结论又有什么用处呢,先翻转后翻转有什么区别吗?其实,如果这些列表中的元素个数一开始是不定的,那么这个结论确实没有用处,但是本题特殊之处在于,它给出的列表初始时只含有一个元素!只有一个元素时初始列表就是它的翻转列表,也就是可以直接使用上述结论的得到拼接后的翻转列表,此时,这个新的列表的翻转列表就是原两个列表的拼接,当这个新列表需要和其它列表进行拼接翻转操作时,可以直接用它两个原列表拼接而成的列表带入上述结论求解,从而每一步都不需要真正的把列表翻转,其形式类似于一个递推过程。如果不理解这段话,我们再举一个例子,初始有4个列表: {1} 、{2} 、{3} 、{4} ,将列表{1} 、{2} 拼接后翻转,利用结论可得reverse( {1} +{2})=reverse({2})+reverse( {1})={2}+{1}={2,1},同理列表{3} 、{4}拼接后翻转得{4,3},接着重点来了,现在要把列表{2,1}与{4,3}拼接翻转,利用上述结论我们只需要让它们的翻转列表拼接,即reverse({2,1}+{4,3})=reverse({4,3})+reverse({2,1})={3,4}+{1,2}={3,4,1,2},在得到最终结果的过程中可以看到,我们没有一次真正的翻转列表,仅仅进行了拼接操作,而该操作时间复杂度仅为O(1)。由此我们已经得到了此题的解法,我们需要创立两个数组,数组每个元素是一个list,其中一个数组保存我们按题意操作后得到的列表设为num[N],另一个则保存其对应的翻转列表设为rev_num[N],两个数组初始值相同。对列表num[1]=a,num[2]=b执行拼接翻转操作时,只需要让原列表拼接:num[1]=a+b,num[2]=null,再让其翻转列表拼接:rev_num[1]=0,rev_num[2]=reverse(b) + reverse(a),此时rev_num[2]保存就是拼接翻转操作后的结果,但我们需要让num[1]保存真正的结果,于是将num[1]与rev_num[2]的值交换,同时rev_num[1]应该保存num[1]的翻转列表,所以再将rev_num[1]与rev_num[2]的值交换,所有交换操作可以直接调用swap函数完成。示例代码如下:
#include<cstdio>
#include<list>
#include<vector>
using namespace std;
vector< list<int> > num; //原序列
vector< list<int> > rev_num;//翻转序列
int main(){
int n,m;
list<int> temp;
while(scanf("%d%d",&n,&m)!=EOF){
num.clear();
rev_num.clear();
for(int i=0;i<n;++i){
temp.push_back(i+1);
num.push_back(temp); //输入原序列
rev_num.push_back(temp);//刚开始原序列只有一个元素时的翻转序列与原序列相同
temp.clear();
}
int a,b;
while(m--){
scanf("%d%d",&a,&b);
num[a-1].splice(num[a-1].end(),num[b-1]);//原序列拼接
rev_num[b-1].splice(rev_num[b-1].end(),rev_num[a-1]);//翻转序列拼接
swap(num[a-1],rev_num[b-1]);//翻转序列拼接的结果就是原序列拼接后再翻转的结果
swap(rev_num[a-1],rev_num[b-1]);//原序列的拼接结果则成为现序列的翻转序列
}
printf("%d ",num[0].size());//打印L1的长度
list<int>::iterator it = num[0].begin();
for(;it!=num[0].end();++it)
printf("%d ",*it);//打印L1的元素
printf("\n");
}
return 0;
}
示例三:移动盒子(https://vjudge.net/problem/UVA-12657)
该题的难点实际上和上题类似,均在于翻转操作的处理上,想要取得满意的时间复杂度,是不可能真正去执行翻转操作的,为此我们需要找出一个等效的操作,类比上题的过程,我们思考若事先不执行翻转操作将会对后续盒子的移动造成什么影响?以1 2 3 4 5 6为例,将2移动到5的左边,之后再进行翻转操作,序列变为6 5 2 4 3 1,若先执行翻转操作,序列变为6 5 4 3 2 1,此时如果想得到之前的序列,则需要将2移动到5的右边。由此我们得到一个重要的结论:将X移动到Y的左/右边后再翻转等效为序列翻转后再将X移动到Y的右/左边。所以,如果有以下操作:左移翻转右移翻转右移翻转,则等效为:左移左移翻转翻转翻转左移,可以看到,多个翻转操作被放在了一起执行,这里的翻转操作是针对整个序列的,而我们知道:奇数次的翻转等于只翻转了一次,偶数次的翻转等于没有翻转,由此令我们头疼的翻转操作就得以解决了,而关于翻转操作出现的奇偶,我们不需要统计其出现的次数,只需要定义一个bool变量flag,初始值赋false代表偶数次出现,此后每出现一次翻转操作就对该变量取非(!flag),即可达到目的,而当flag为真时,左/右移操作则需要变化,否则保持不变。示例代码如下:
#include<cstdio>
using namespace std;
int right[100005],left[100005];
void link(int l,int r){//模拟链表的链接
right[l]=r;
left[r]=l;
}
void swap(int &a,int &b){//swap函数,当然你也可以使用iostream头文件中的swap函数,但是如果你使用了iostream头文件,数组的命名就要特别注意,例如right、left等都是会报错的,因为在命名空间中冲突了
int temp=a;
a=b;
b=temp;
}
int main(){
int tap=0,n,m;
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,flag=0;
while(m--){
scanf("%d",&op);
if(op==4) flag=!flag;//每出现一次翻转就改变一次flag
else{
scanf("%d%d",&x,&y);
if(op!=3 && flag) op=3-op;//若当前需要进行翻转,则操作1、2需要互换
if(op==1 && x==left[y]) continue; //处理相邻的特殊情况
if(op==2 && x==right[y]) continue;//处理相邻的特殊情况
if(op==3 && right[y]==x) swap(x,y);//一定要写在保存初始值前,否则保存的初始值就是错误的
int lx=left[x],rx=right[x],ly=left[y],ry=right[y];//此处必须保存初始值,后续执行link操作后所有值都会发生改变
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){//处理相邻的特殊情况,这里不能用rx代替!
link(lx,y);
link(y,x);
link(x,ry);
}
else{//一般情况
link(lx,y);
link(y,rx);
link(ly,x);
link(x,ry);
}
}
}
}
int pos=0;long long ans=0;
for(int i=1;i<=n;i++){//获取奇数位置的元素和
pos=right[pos];
if(i&1) ans+=pos;
}
if(flag && (n&1)==0) ans=(long long)n*(n+1)/2-ans;//若总共的盒子数是偶数,翻转后的奇数位置元素和等于翻转前的偶数位置元素和
printf("Case %d: %lld\n",++tap,ans);//打印结果
}
return 0;
}
链表的一些拓展
1.块状链表
块状链表这里我不详细描述了,同理与之对应的还有一个块状数组,区别不大。主要是通过分块,将原来O(n)的查找时间复杂度优化到了O(),不过插入删除时间复杂度也从O(1)退化到了O(
),但总体时间复杂度还是很优秀。至于原理,这里直接指路分块查找的原理:根号算法:分块,在分块查找的基础上,将分好的每一块都当做为一个结点放入到链表中,这样就利用链表的插入删除与分块查找结合成了这样一个数据结构。
2.跳跃链表
跳表(skiplist),是一种十分优秀的数据结构,其查找和插入删除的时间复杂度都仅为O(logn)!这与平衡二叉树的时间复杂度相同,而跳表我们也称它为一种基于概率选择的平衡树。由于编程简单,跳表在很大程度上能够替代许多编程复杂的平衡二叉树。
在链表中查找元素,当然是从头结点开始,一个一个的向后搜索,因为元素的地址不连续,所以没办法二分什么的,时间复杂度为O(n)。
如上图所示,我们要查找9,很显然,不得不经过10个结点。但是我们是否有办法减少查找的结点个数呢?当然是有的,如下图,我们在这一层链表上再加一层:
如果我们走上一层去查找,则就只用经过6个结点就能成功查找。那么,再继续添加层数呢?毫无疑问,查找次数会更少。随着层数的增加,最终将形成下面的这样一种数据结构:
而此时,我们只需查找3次就能找到元素9的位置,时间复杂度为O(logn)级别。
值得注意的是,我们要如何去维护一个这样的数据结构?我们知道,向跳表内添加或者删除元素,很有可能会改变它的层数,删除操作比较好处理,直接将该元素对应的所有层删除即可。问题就在于添加操作,我们应该怎样来确定该结点要添加几层呢?
此时,思考一下上文中提到的“跳表也称作一种基于概率选择的平衡树”,其实之所以跳表会被如此称呼,就是因为它的添加操作中引入了随机化算法。元素添加的位置肯定是固定的,因为我们要维护整个链表有序,但其层数不定。我们规定一个最大允许层数,然后对要插入的结点进行一次随机化判断,这个过程类似于抛硬币:如果是正面,则层数加1且继续抛,直到到达最大允许层数或者结果为反面。具体到算法实现可以采用产生随机数的方法:生成一个0到1之间的随机数,若大于0.5则记作正面,否则记作反面。
由于和ACM关系不大,本文对跳跃链表的讨论也就到这里浅尝辄止了,只是简单提及了一下跳表的概念,想深入学习的话还请自行去网上搜索相关资料。