第二讲---线性表的链式表示
-
复习
上次我们讲到了线性表的顺序表示,这里我们做一个简单的复习:
线性表的顺序表示有哪些优点?
- 访问方面
- 存储方面
- 不同操作的复杂度方面
- 元素个数咋样,元素之间咋样,每个元素大小咋样,元素之间的顺序和元素的内容有没有关系。
- 线性表的结构体创建方式
- 线性表有几种表示方式
- 线性表的顺序表示是顺序存取还是随机存取
- LineInsert、LineDelete分别有哪些操作过程
- 什么是逻辑结构,什么是存储结构
- 如果我们要删除一个线性表中,所有数据元素是“4”的元素,应该怎么办?
一、线性表的链式表示
由于线性表的顺序表示中,对于插入和删除需要移动大量元素。所以我们引入了线性表的链式表示。链式存储线性表示后,不需要使用地址连续的存储单元,即它不要求在逻辑上相邻的两个元素在物理上也相邻。对链表的插入和删除只需要修改指针!
举个栗子
上次我们讲到唐僧五人成立了一个公司,公司给分配集体房屋。但是,由于这帮人没有经验,公司赔的就差卖金箍棒了。唐僧想了想,最近P2P(传销)好像挺赚钱的,他打算重新成立家传销公司。
但是呢,大家都懂的,这玩意不能公开透明的去搞,而且呢还得发展下线,为了防止乱套,唐僧定了如下规矩:
1.一个人只能发展一个下线,通过和下线的电话号码联系。
2.下线不能知道上线是谁,因为怕出事以后,把上线供出来,连锁反应后大家都得进去。
OK~公司制度定下来了,唐僧是公司的头头,公司就叫大唐实业(注册号为999)吧(大气~),所以我们现在有这样一个记录:
唐僧作为公司的第一个人加入,其电话号码是666,所以有:
刚开始只有唐僧,他还没有发展下线,所以下线电话号占时是空(NULL)。他在某个公用电话亭上面给猴哥打了个电话(之前有猴哥的电话是222)的方式鼓动猴哥去当他的下线(不当就念紧箍咒~),所以现在公司成了这个样子:
然后猴哥发展了八戒(电话号111),八戒发展了沙僧(电话号777),沙僧发展了小白龙(电话号000)。一个庞大的制度清晰的传销帝国的雏形就成立了:
可以看到:
1.大唐实业的注册号位999,工商局以及外人想查找这个公司,需要通过999这个号码来查询
(通常用头指针来标识一个单链表)
2.唐僧是公司的第一人,但是大唐实业是第一个结点,大唐实业是整个传销的标识,工商局通过大唐实业来联系到整个公司。为什么不采取通过唐僧来联系整个公司呢,因为这样如果唐僧离开了公司,这样工商局就找不到这个公司了。所以我们会在之前加一个结点。
(为了操作方便,在单链表的第一个结点之前附加一个结点)
3.头结点和头指针的区分:头指针是指的联系到这个公司的号码,可以是999,也可以是666。但是999这样的方式会更加的方便。头结点指的是第一个结点。也就是上面链表中的第一个元素。
4.引入头结点的两个优点:
a)讲究!在查找时候,所有人都是下线(唐僧可以理解为是公司的下线),方便一点。
b)这样就算公司人都跑了,公司还存在,这样方便以后再加人。如果不用头结点,都跑光以后,公司就不晓得哪里去 了。
二、单链表上基本操作实现
1.与前一讲一样,我们先定义一下单链表的结构体
typedef struct LNode{
int data;
struct LNode *next;
}LNode,*LinkList;
这里面LNode是结构体名字,*LinkList是结构体指针,至于为什么这么创建......(也不会考,睁一只眼闭一只眼就过去了,细究反而会浪费很多时间)。
下面会多次用到 LNode 和LinkList,其实LNode * = LinkList,我们一般用LNode * 去表示链表中的一个结点,用LinkList去表示一个链表,二者互换了也不会报错,但是最好这么写。
2.采用头插法建立单链表
//头插法--每一次插入一个人,这个人插在公司这个头结点后面,相当于每次发展一个总负责人
//之前的总负责人做他的下线
void CreatListHead(LinkList &L){
LinkList s; //创建待发展的新人
int x; //待发展新人的名字
L = (LinkList)malloc(sizeof(LNode)); //创建公司999
L->next = NULL; //公司总负责人(第一个下线)置为空,还没加呢
scanf("%d",&x); //创建新人的名字(第一个下线)
while(x!=-1){ //如果一个新人的名字是-1的话,意思就是不再发展了,结束了
//1.申请空间---手机号
s = (LinkList)malloc(sizeof(LNode)); //给新人创建个手机号,这里s的地址相当于手机号
//也就是这个人新人的联系方式
//2.初始化名字
s->data = x; //加入新人的名字
//3.处理一下他的下线
s->next = L->next; //把之前的总负责人当做这个新人的下线
//4.上链
L->next = s;//新人成功上位为总负责人
scanf("%d",&x);//再读入下一个新人
}
}
头插法相当于每次插入一个新人,这个新人当做公司的第一人(总负责人),也就是唐僧的位置,之前的唐僧将会变成新人的下线。逻辑上面不好理解,是我们正常发展下线的过程的逆过程,最后的结果是 公司->小白龙->沙僧->八戒->猴哥->唐僧
3.采用尾插法建立单链表
void CreatListTail(LinkList &L){
LinkList s; //创建待发展的新人
int x; //待发展新人的名字
L = (LinkList)malloc(sizeof(LNode)); //创建公司
L->next = NULL; //公司总负责人(第一个下线)置为空,还没加呢
//为什么会单独创建tail,而不是用L去操作
LinkList tail;
tail = L;
scanf("%d",&x); //创建新人的名字(第一个下线)
while(x!=-1){ //如果一个新人的名字是-1的话,意思就是不再发展了,结束了
//1.申请空间---手机号
s = (LinkList)malloc(sizeof(LNode)); //给新人创建个手机号,这里s的地址相当于手机号
//也就是这个人新人的联系方式
//2.初始化名字
s->data = x; //加入新人的名字
//3.处理一下他的下线--注意这里和头插法的区别
//之所以有这样的差别,是应为他们发展下线的方式不同,所以不要死记代码
//要记住为什么这么做
s->next = NULL;
//4.上链
tail->next = s;
//5.更新尾部
tail = s;
scanf("%d",&x);//再读入下一个新人
}
}
尾插法就是咱正常发展公司下线的方法,这里面有几个注意的点:
a).为什么申请一个tail?
因为每次都需要在尾部插入一个人,所以我们需要一个变量去保存当前的尾部,这样才能把新人的手机号给这个当前的尾部。
b).为什么需要更新尾部?
因为我们采用的是尾插法,意思就是在当前链表的最后一个元素后面再插入一个新人,如果一个新人插入后,尾部应该就是这个新人了,不是原来的尾部了。所以我们要在插入一个新人以后更新尾部。
c).为什么不用L代替tail?
我们在头插法中,一直是在L的后面插元素。如果尾插法,每次把尾部更新为L,最后我们得到的L地址就是最后一个元素的地址,前面的完全丢了。所以我们需要最先把头结点的地址给L,然后申请一个tail让他当尾部。这样不管怎么加人,L始终指向的是头结点。
4.遍历链表中所有结点操作
void PrintList(LinkList L){
/*我们一般不直接对L进行操作,虽然这里L传入的不是地址,里面操作了无所谓
但是有些操作需要传入L的地址,如果直接让L=L->next的话,会破坏整个列表*/
LNode *p = L->next; //我们只打印出人,最开始是头结点,先跳过
while(p!=NULL){
printf("%d ",p->data);
p = p->next;
}
printf("\n");
}
5.按序号查找结点值
LNode* GetElem(LinkList L,int i){
//同样,我们先检查下i的范围对不对,由于链表中,我们不能知道当前链表一共有多少人
//所以只能检查一下i的下界,上界的话等遍历时候再检查
if(i<=0)
return NULL; //之前错误一般返回false,但是这里返回的类型是结构体,所以错误是NULL
LNode *p = L->next; //我们只打印出人,最开始是头结点,先跳过
int j = 1; //表示现在我们p指向的是第一个人
//你也可以定义j = 0 代表第一个下标,都可以,但是后面需要i-1
//所以就看怎么定义了,只要最后的操作对就行
//如果p为空了但是j还是小于i,那么说明i超出了上界,我们最后也会返回一个NULL
while(p!=NULL){
if(j == i){
return p; //找到了
}
p = p->next;
j++;
}
return NULL;
}
6.按值查找表结点
LNode* LocateElem(LinkList L,int e){
/*我们一般不直接对L进行操作,虽然这里L传入的不是地址,里面操作了无所谓
但是有些操作需要传入L的地址,如果直接让L=L->next的话,会破坏整个列表*/
LNode *p = L->next; //我们只打印出人,最开始是头结点,先跳过
while(p!=NULL){
if(p->data == e){
return p;
}
p = p->next;
}
return NULL;
}
7.插入节点操作(在单链表的第i个位置上插入新的结点)
(找到第i个人的上司,让第i个人变成新人的下线,让新人变成上司的下线,顺序不能变)
bool ListInsert(LinkList &L,int i,int e){
//同样,先检查下下界
if(i<=0){
return false; //为什么这里是false了
//因为我不需要返回一个结构体,只需要返回是否成功,所以返回值类型是bool
}
LNode *p = L; //同样,我们一般不直接对L进行处理
int j = 0;
//这里,我们需要考虑一下,为什么j是从0开始的,并且p = L而不是P = L->next
//因为我们执行的是插入操作,那么就有可能插入到第一个位子,也就是i = 1
//如果P = L->next,j =1 那么相当于我们跳过了第一位,这样i=1这样合理的诉求就满足不了了
//而之前查找时候,为什么P = L->next呢?
//因为查找嘛!
//我们在插入时候,需要找到i前面那个人
//前面那个人原来的下线是第i个人,现在我们让他的下线变为待插入的新人
//然后让原来第i个人成为新人的下线
//p!=NULL可以辅助我们检测i是否超过上界
while(p!=NULL){
if(j==i-1){ //找到i前面那个人
LNode *newGuy = (LinkList)malloc(sizeof(LNode));//给新人开辟一个空间
newGuy->data = e;
newGuy->next = p->next;
p->next = newGuy;
//这俩顺序一定不能变,如果先p->next = newGuy
//那么原来第i个人的手机号就被这个新人覆盖掉了
//接下来新人是需要把原来第i个人当成下线的,这时候,谁告诉他第i个人的手机号?
//可以想想p->next = newGuy,newGuy->next = p->next的后果有多么美丽(恐怖)
return true;
}
p = p->next;
j++;
}
return false;
}
8.删除结点的操作(找到待删除结点的上司,让他的下线变成上司的下线,这样他就被删掉了)
bool ListDelete(LinkList &L,int i){
//同样,先检查下下界
if(i<=0){
return false; //为什么这里是false了
//因为我不需要返回一个结构体,只需要返回是否成功,所以返回值类型是bool
}
LNode *p = L; //同样,我们一般不直接对L进行处理
int j = 0;
//与插入相同,注意上面p和j的初始值
//我们在删除时候,需要找到i前面那个人
//前面那个人原来的下线是第i个人,现在我们让他的下线变为待插入的新人
//然后让第i个人就地爆炸
//p!=NULL可以辅助我们检测i是否超过上界
while(p!=NULL){
if(j==i-1){ //找到i前面那个人
LNode *d = p->next;//待删除的那个人
p->next = d->next;//让待删除的那个人的下线变成他原来上司的下线
//如果你想骚一点的话可以写成:
//p->next = p->next->next;
free(d);//你也可以不执行这一步,当然这样的习惯会不好
//如果这里面有密码什么的,你也没有free掉,它就一直在里面,会被别人利用
return true;
}
p = p->next;
j++;
}
return false;
}
三、双链表
公安局觉得唐僧这个只有上司知道下线,下线不知道上司的理念太牛B了。他们不知道如何把这个公司一锅端,所以他们强制唐僧修改公司理念(一旦找不到解决问题的方法,就解决掉了制造问题的人)。
当然,这是含有头结点的双链表,双链表中主要考的是插入和删除操作,先看下双链表的结构体定义吧:
typedef struct DNode{
int data;
struct DNode *prior,*next;
}DNode,*DLinkList;
这里面多了一个*prior,我们可以把*prior和*next理解为一个人的左右手,并且整个链表中的人都在悬崖上面一个吊着一个,一旦一个人右手(*next)松开了,那么下面的人都没了,除非在他松开前,有另外一个人把他下面那个人拽住。如果能理解这一点,插入删除就很好处理了。
首先我们看插入
举个栗子:
唐僧和猴哥现在处于相互拉着的状态,唐僧的右手(*next)拉着猴哥,猴哥的左手(*prior)拉着唐僧,现在丘比特想插进来。有如下操作
1.唐僧右手拉丘比特
2.丘比特左手拉唐僧
3.丘比特右手拉猴哥
4.猴哥左手拉丘比特
我们可以想一想,这第一步和第三步应该先做哪一步。如果先执行第一步,那么猴哥就死掉了。如果先执行第三步,这时候猴哥的左手被两个人拉着,然后执行第一步,唐僧松开右手,这时候有人会问,丘比特的左手是空的,那不丘比特和猴哥一起掉下去了(因为丘比特会飞..我尽力举一个看似恰当的例子了.......在计算机中,是因为我们有变量去保存带插入结点(丘比特)的地址,所以即使唐僧松开,我们还是有办法找到丘比特和猴哥)。然后,唐僧右手把丘比特左手一拉,就插进来了。
qiuBt->next = houGe; //丘比特的右手拉着猴哥
houGe->prior = qiuBt; //猴哥左手拉着丘比特
qiuBt->prior = TangSeng; //丘比特左手拉着唐僧
TangSeng->next = qiuBt; //唐僧右手拉着丘比特
当然,我们可以先让丘比特左手拉唐僧,顺序有很多种,我们只需要保证:
1.猴哥不掉下去
2.最后大家左右手都有人拉
就OK!
然后我们看删除:
举个栗子:
现在是唐僧,丘比特,猴哥这三个人手拉着手,如果丘比特要出去的话,唐僧最后右手抓猴哥,猴哥左手抓唐僧,我们想一下一下几个顺序:
1.唐僧的右手抓猴哥
2.猴哥的左手抓唐僧
我们可以发现,两个顺序都不会导致出问题。所以这里先后顺序无所谓。
四、循环链表
循环链表可以分为循环单链表和循环双链表,其特性就是,最后一个结点的*next指针指的是头结点,每一次在插入和删除时候需要额外考虑一下,也没什么难度,只要理解了就行。
五、课后习题
6.在一个单链表中,一直q所知节点是p所知结点的前驱结点,若在q和p之间插入节点s,需要执行:
7.给定有n个元素的一维数组,建立一个有序的单链表的最低时间复杂度是
这个题中,有序的话我们得先排序,最好的排序算法的时间复杂度是O(nlog_2n),而建立一个单链表的复杂度是O(n
),我们知道复杂度主要看的是最复杂的那一项,所以是O(nlog_2n)
14.在双链表中向p所指的结点之前插入一个结点q的操作为
六、总结
在做链表的题时候,其本身比较抽象,做的时候一定要在纸上画呀画。线性表的顺序表示和链式表示有一定的区别,这里也比较爱考,我做一下总结:
内容 | 顺序表 | 链表 |
存取方式 | 随机存取/顺序存取 | 顺序存取 |
逻辑结构与物理结构 | 逻辑结构相邻的两个元素其对应存储位置(物理结构)也相邻(门牌号的关系) | 逻辑结构相邻的两个元素其对应存储位置(物理结构)不一定相邻(电话号的关系) |
按序查找 | O(1) | O(n) |
插入、删除 | O(n) | O(1) |
优点 | 方便查找 | 方便插入删除 |