一 线性表的链式存储
之前我们了解了线性表这一逻辑结构,并且用几个小节深入学习了顺序表。顺序表是采用顺序存储结构实现的线性表。而在这个小节之后,我们将探讨如何运用链式存储结构来实现线性表。采用链式存储实现的线性表,我们统称为链表。链表又能具体细分为单链表、双链表、循环链表和静态链表。
1 单链表的定义
在这个小节,我们首先聚焦于单链表,研究如何实现一个单链表,后续还会有相关的深入探讨。本小节,我们会先介绍单链表,接着用代码对其进行定义。单链表有两种实现方式,分别是带头节点和不带头节点的。
1.1 问题1:单链表为什么要称为单链表
现在,让我们来深入认识单链表。通过理论学习,大家看到这张图就能明白单链表的含义。单链表中的每个节点,不仅要存放数据元素,还需包含一个指向下一个节点的指针。由于每个节点仅包含这一个指针,所以它被称为单链表。
1.2 问题2:单链表和顺序表实现线性表的优缺点
回顾之前学过的顺序表,它具有可以随机存取、存储密度高的优点。然而,它也存在明显的缺点。因为各个数据元素要求在物理上连续存放,所以若采用顺序表来实现线性结构,就需要大片的连续空间。 如果要拓展顺序表,会十分不便。而单链表能很好地解决这个问题。单链表的各个节点在物理上可离散存放,当需要拓展单链表长度时,只需在内存中找一小块区域存放新节点即可。因此,采用链式存储方式,改变容量十分方便。
不过,若采用这种方式,要找到某个指定节点,只能从第一个节点开始,依据指针信息依次向后查找,直至找到目标节点。也就是说,单链表不支持随机存取。
1.3 问题3:如何用代码定义单链表
接下来,看看如何用代码定义单链表。显然,单链表由一个个节点组成。每个节点需有一片空间存放数据元素,还有一片空间存放指向下一个节点的指针。所以,我们可以定义一个 stract类型的结构体来表示节点。该节点中有一个名为 data 的变量用于存放数据元素,我们称之为数据域;另外,还需定义一个指向下一个节点的指针,这个指针变量名为 next,我们称之为指针域。
有了这个结构体的定义后,如果我们想要往这个单链表中增加新的内容。 那么,我们使用 malloc
函数申请一片用于存储节点的空间,并用指针 P
接收 malloc
函数的返回值,使其指向该节点的起始地址,之后,是否就能设计一些代码逻辑,将 P
节点插入到这个单链表当中.
1.4 typedef关键字的用法
按照我们目前的写法,以后若要定义一个新节点,或者定义一个指向节点的指针,每次都得写 struct Lnode
,也就是每次都要带上 struct这个关键字,这样写颇为麻烦。因此,教材里使用了 C 语言的 typedef
关键字,借助这个关键字可以对数据类型进行重命名,将其名称缩短、简化。其实在之前的小结中我们也用过 typedef
关键字,只是一直未着重强调。结合本节内容,希望能让跨考的同学们真正理解这个关键字。
它的用法十分简单,只需在代码里写上这个关键字,后面跟上想要重命名的数据类型,空一格后,写入想给它取的别名即可。例如,可以把 int
这种数据类型另命名为“整数”;当然,也能用类似方式,将指向 int
型变量的指针重新命名为“整数指针”。 好,添加这两行代码后,以前定义 int
型变量和指向 int
型的指针是一种方式,现在则可以用另一种方式来定义。
同理,我们能否给 struct Lnode
这种数据类型取个别名,就叫 LNode
,把名字缩短呢?这样,之后写代码时就无需再写 struct
,直接写 LNode
就行,代码会更简洁。
我们这里是先定义了 struct Lnode
,再单独写语句对其重命名。但教材采用了更简洁的方式,课本中的这段代码其实等同于先定义一个名为 struct Lnode
的数据类型,然后将 struct Lnode
重命名为 LNode
,并用 LinkList
表示指向 struct Lnode
的指针。
当要表示一个单链表时,只需声明一个头指针 L
,它指向单链表的第一个节点。由于各个节点通过 next
指针依次相连,所以找到第一个节点,就相当于找到了整个单链表。既然指针 L
指向某一节点,那么定义 L
时可以用 LNode *L
的方式。根据前面的重命名,LNode *L
其实等价于 LinkList L
。这两种定义方式效果相同,不过采用后一种方式更佳。 声明头指针能增强代码的可读性。
举个例子,后续我们会学习一个基本操作 GetElem
,即从链表 L
中取出第二个节点并返回。在这段代码里,既用到了 LNode
,也用到了 LinkList
。尽管这两种表示方式本质上等价,但在这个函数中,其返回值类型被定义为 LNode
,这是为了强调返回的是一个节点;而参数 L
被定义为 LinkList
,是想强调它代表一个单链表,我们要从这个单链表中找出第二个节点。
虽然也可以用 LNode *L
的方式定义参数 L
,但此处强调的并非 L
是一个节点,而是将其视为一个单链表,所以采用了这种命名方式。大家可以仔细体会,因为课本中的很多示例代码会同时出现这两种表示方式。若此处理解不透,看代码时可能会困惑,为何有的地方用这种方式,有的地方用那种方式。其实主要是代码想要强调的重点不同
大家可以看看课本中头建立单链表的代码,就能有所感悟。 这个小细节容易让人烦恼,值得大家注意。在后续学习的数据结构中,比如树这种数据结构,也会采用类似的重命名方式。希望大家好好体会这一点
接下来,我们看看如何初始化一个单链表。先从不带头节点的单链表说起,首先声明一个指向单链表的指针 L。本质上,这个指针指向某一个节点,但这里我们想强调它指向的是一个单链表,所以用 LinkList
这个别名来定义。
执行这一句后,内存中会开辟一小片空间用于存放头指针 L。接着执行初始化函数,这个函数很简单,就是把 L 的值设为 NULL
,以此表示当前是一个空表。这样做是为了防止这片内存中存在以前遗留的脏数据。
另外,传入指针变量时,我们传入的是它的引用。因为如果没有引用符号,在函数里修改的所谓 L,其实是头指针 L 的一个复制品。
对于这种不带头节点的单链表,判断其是否为空的依据就是看头指针 L 是否等于 NULL
。若等于 NULL
,则说明此时链表为空。当然,代码还可以写得更简洁,直接 return L == NULL
,因为这个条件判断的运算结果本身就是 true
或者 false
。 回顾一下前面不带头节点的情况,
接下来我们看看带头节点的单链表。同样,先声明一个指针 L 指向单链表。在初始化函数里,使用 malloc
函数申请一片足以存储一个节点的空间,并将 malloc
返回的地址赋值给 L,此时头指针 L 便指向了这个节点。随后,要把 L 所指向节点的 Next
指针域设为 NULL
。需要注意的是,这个头节点并不存储数据,添加它是为了让后续实现某些基本操作更加便捷。
对于带头节点的单链表,判断其是否为空,只需看头节点的 next
指针域是否为 NULL
,若为 NULL
,则链表为空。
那么,单链表这两种实现方式有何区别呢?简单来讲,不带头节点时编写代码会更繁琐,而带头节点则会让代码编写更轻松。因此,大多数情况下,我们会采用带头节点的方式来实现代码。至于为何如此,下一小节我们会通过具体代码让大家切实感受。在本小节,我们要明确:不带头节点时,头指针指向的下一个节点才是实际存放数据的节点;而带头节点时,头指针指向的节点就是头节点。 头节点并不存放实际的数据元素,只有头节点的下一个节点才用于存放数据。
在本小节中,我们学习了单链表的定义。单链表采用链式存储的方式实现线性结构,各个数据元素的先后顺序通过节点的指针域来体现。
需要注意的是,单链表的实现方式分为带头节点和不带头节点两种,它们对于空表的判断条件有所不同。
对于C语言基础薄弱以及跨考的同学,希望大家通过本小节理解typedef
关键字的含义。在阅读代码时,要留意何时使用linked list
,何时使用l note*
,尽管二者等价,但在学习初期,透彻理解这些细节,有助于后续知识的吸收。
二 单链表的插入和删除
各位同学大家好,从这个小节开始,我们会学习怎么实现单链表的某一些基本操作,那这个小节我们要学习的是如何实现插入和删除,我们会分别探讨,如果一个单链表,它带头节点或者不带头节点的话。那怎么实现按位序插入这个功能?那除了按位序插入之外,我们还需要掌握,如果给定一个结点,那么怎么在这个结点之后和之前插入一个新的结点?删除操作也是类似的,给你一个位序,你要知道怎么删除这些节点,或者直接给你这个节点的指针,你也要知道怎么把它删除。
那在之前的学习中,为了帮助大家慢慢的补上基础,所以我们在给这些图示的时候,都会给大家展示这些数据,它在内存当中到底是怎么样?然后像malloc函数,还有free函数这些,它背后是一个什么样的过程,相信通过之前的那些学习,大家已经能够掌握这些比较基础的东西了,所以之后我们再给这些图示的时候会稍微简化一下。我们不会再画出这些结点,它在内存当中到底是怎么样分布的好.
2.1 问题一:那下面探讨第一个问题,如果这个单链表它是带头结点的话,那么怎么实现按位序插入这个操作?
我们要在第二个位置插入一个指定的元素e,那这样的话,我们是不是需要找到第二减一个结点,因为我们肯定需要把第二减一个结点的next指针给修改。比如说如果I=2,也就是说我们要在第二个位置插入一个新的结点的话,那么我们就需要先找到第一个结点,然后用malloc申请一个新的结点。往这个结点里存入数据元素e。接下来,对指针进行修改,这样的话,这个新的结点是不是就变成了第二个结点诶?那如果我们想要在第一个位置,也就是i=1的时候想在这个地方插入一个新的结点的话,怎么办呢?那这个地方大家就能够体会到带头结点的这种单链表的好处,我们可以把这个头结点看成是第零个结点。所以当i=1的时候,刚才我们分析的这一套处理逻辑其实也是适用的
来看一下具体的代码,实现这个函数当中传入了一个呃单链表。然后这个单链表是带头结点的。然后指定了此次要插入的位置位序i,并且给出了新结点e,当中要存放的数据元素。好,
那来分析一下,刚才我们说的这种情况,如果i=1,也就是要在表头的位置插入一个新元素的话。首先,这个地方有一个判断,如果i小于一的话,那说明这个i的值不合法,因为i表示的是位序嘛,位序是从一开始的,所以如果传入的这个参数本身不合法的话,直接return false。表示插入失败好,那由于此时我们的i=1,因此这儿会声明一个指针p。然后这个指针p它是指向了和i相同的位置,也就是指向了这个头结点。然后这儿我们定义了一个变量j,j表示的是当前p它指向的是第几个结点。那之前说了头结点,我们可以把它看成是第零个结点,所以此时j的值应该是零注意啊,虽然这儿我们把它杜撰出了一个所谓的第零个结点。但其实单链表当中实际存放的是后面这些节点对吧。而这些节点的编号,它是从一开始的,所以我们不允许i值小于一。
好,那接下来就会开始执行这个while循环,此时p不等于null,这是满足的,但是j小于i- 1这个条件不满足,因此这个循环不会执行,它会直接跳到下面。好,那这一句会申请一个新的节点空间。然后把参数e存到新结点里面,接下来这一句它会让s指向的这个结点的next这个指针。让它等于p结点的next指针指向的这个位置,也就是指向这儿最后这一句,它会让p结点的next指针。指向这个新的结点s也就是这样子好,那这样的话,我们是不是就实现了在第一个位置插入数据元素e这个事情?那由于i=1这个while循环直接被跳过了,所以这种情况下只需要o(1)这样的时间复杂度就可以执行完成。
那这段代码想提醒大家注意的是,最后的这两句是不能颠倒的,如果先执行黄色箭头这一句,再执行绿色箭头,这一句那是会出问题的,来看一下。经过前面的一系列执行,现在状态是这个样子,然后如果我们先执行黄色这一句,也就是先让p结点的next,指向s这个结点。然后再执行绿色这一句,也就是让s结点的next指针和p结点的next指针指向同一个位置。
那这样的话,这个指针就会指向它自己,所以你看这两句代码写错的话,这个链表就变得奇奇怪怪的,然后后面这些小朋友就都走丢了。这个链已经断了,所以这两句代码大家一定要注意好
接下来分析另一种情况,如果i=3的话,那此时i- 1就应该是二。那前面的这些操作会导致j的值等于零,然后p指向头结点,然后接下来开始这个while循环。此时p不等于null同时j也小于二,所以会执行这个循环里边的代码,
让p=p的next,也就是让它指向下一个节点。所以j的值就变成了一,那由于此时j依然是小于二的,所以第二轮循环的条件也是满足的,因此还会再执行这个循环,里边的代码。p会指向再下一个节点,同时j的值会再加一,也就是变成二,那由于这个条件,此时已经不满足了,所以这个循环结束会执行后续的语句。那再往后的话,是不是和刚才一样,就是申请一个节点,然后把这个指针给依次修改?这样的话,我们就在第三个位置插入了数据元素e。
好再下一种情况,如果i的值等于五的话,那么首先p同样是会先指向这个头结点,然后i- 1的值就应该是四。j的值刚开始也是零第一次执行这个循环p会往后移一位,然后j的值变为一第二次循环p又会指向再下一个节点,然后j的值变为二。第三次。是这样,第四次。是这样。
好,那当j=4的时候,这个条件不满足了,对吧?然后就可以跳出循环执行之后的语句。对了,再次强调这个j的值,表示的是当前p指向的是第几个结点,而我们执行这个循环的本质原因,最终的目的是想要找到第i- 1。一个结点,因为我们要插入的是第二个结点,所以只要我们找到了第二减一个结点,然后我们就可以用后续的这些代码把新结点连到第二减一个结点之后就可以了。所以大家在看代码的时候,除了能够看懂每一句它是什么意思之外,也应该有一个全局的宏观的思维,比如你要知道这整篇代码,其实它就是要找到第i- 1个结点。然后后续的这些代码就是要在第i- 1个结点之后再插入一个新的结点,这样的话,你的这个算法逻辑是不是就很清晰了?好继续往后分析这儿的malloc会申请一个新的节点,然后把数据元素填到里边,接下来会让s的next指向p的next。而由于p结点以前是指向null的,所以经过这一步,运算s结点也会指向null,最后再让p的next指向新的这个结点。那这样的话,我们就在第五个位置,也就是表尾的位置加入了一个新的结点。那由于此次要找的这个结点是最后一个结点,所以这个while循环的循环次数是最多的,因此把新结点插到表尾,这种操作就是最坏的情况,这种情况下它的时间复杂度就应该是o(n)。这儿的问题规模n指的是这个表的长度。
最后再来分析i=6的情况,此时这个表它的实际长度其实只有四,对吧?所以在这种情况下,如果要插入新结点的话,那最多是插入第五个结点,不可能直接插入第六个结点。来看一下这段代码怎么处理这个问题,首先还是一样的p是指向头结点,然后j的值刚开始是零。然后i- 1的值是五,经过第一次循环p会往下移一位,然后j的值变成一,第二次循环p再往后移一位j的值变为二。第三轮循环j的值变为三,第四轮循环j的值变为四好,此时p不等于null,这个条件是满足的。同时,四也小于五,这个条件也是满足的,所以还会进行第五轮循环,第五轮循环p指针会指向当前结点的next。也就是指向这个位置,然后j的值变为五好,再往后的条件检查p,此时是不是等于null这个条件就已经不满足了,对吧?所以while循环结束,就可以执行这个if语句。那p等于null是不是说明第二减一个结点是不存在的,现在连第二减一个结点我都找不到,那我怎么可能插入第二个结点呢?所以就直接return一个False。因此,如果这个i的值太大的话,那么最终会因为这个条件不满足而跳出循环。
因此,如果这个i的值太大的话,那么最终会因为这个条件不满足而跳出循环。好,那这就是带头节点的单链表,按位插入的,实现基础不好的同学需要好好看一下这个循环怎么用?这个循环实现让p指针依次往后扫描这个事情。同时,我们设置了一个变量j,用j的值来标记p指针,此时指向的是第几个结点?另外还有这个地方,这两个语句不能颠倒好,那经过之前顺序表相关的那些小节的学习不难看出呃,如果要按位序插入的话,那平均时间复杂度应该是o(n)这个数量级。这就不再展开。
那接下来我们要看的是,
2.2 问题2:如果说这个单链表它不带头节点的话,这个操作的实现有没有什么区别?
其实基本的思路是一样的,你可以先找到第二减一个结点,然后把新结点放到这,第二减一个结点之后。就可以了,只不过由于不带头结点,所以不存在所谓的第零个结点,因此如果此时我们是要在第一个位置插入这个元素的话,那么我们需要对这种情况进行特殊的处理。
那具体的代码实现是这个样子,这儿我们对i=1的情况进行了特殊的处理,好在这种情况下,首先也是会malloc先申请一个新的节点。接下来把e写到里边,再往后会让新结点的next指针指向L所指向的这个结点。最后,需要修改头指针L,让L指向这个新的节点。然后return true表示插入成功。所以可以看到,如果这个单链表它是不带头结点的,单链表的话,那么当我们插入或者当我们删除第一个结点,第一个元素的时候,我们肯定需要更改这个头指针的指向。但是如果带头结点的话,那头指针肯定永远都是指向那个头结点的,所以由于不带头结点的情况下,对第一个结点的操作。需要专门写一段逻辑来处理,所以到这儿大家应该就能够体会到为什么说不带头节点的单链表,它的那个代码写起来一般会更麻烦一些。
好继续往后,如果i不等于1。i大于一的话,那其实后续的这些处理是不是和带头节点的那种情况是一样的,唯一需要注意的是这儿我们把这个j的值把它设置为一。表示p指针刚开始指向的这个节点是第一个节点,那后续的代码逻辑都是一样的,就不再分析。那推荐大家自己写代码的时候使用带头节点的这种方式,我们在之后的讲解当中,除非特别声明,不然我们默认使用带头节点的这种方式来实现代码。当然,考试当中两种情况都有可能考察,那大家在做题的时候就一定需要注意审题,别看错了条件。
2.3 问题3:那接下来要探讨的是怎么实现后插操作
也就是说给定一个节点,在这个节点之后插入一个数据元素e,那由于单链表的这个链接指针只能往后寻找,所以如果给定一个节点p的话,那其实p之后的那些节点我们都是可知的。我们都可以用循环的方式把它们都给找出来,但是p结点之前的那些结点我们是不是就没办法知道了?好,那我们要实现的是在p结点之后插入数据元素e。
首先还是一样的,malloc申请一片空间,然后这儿我们加了一个条件判断,如果malloc函数执行的结果,它返回的值是一个null的话,那么说明此次内存分配失败。这种情况其实是有可能出现的,比如说内存已经满了之类的,这个小细节我们之前没有说过,当然了,考试手写代码的时候你不写这个,其实也没有关系。不过大家还是要知道,有可能发生这样的情况好了,如果此次内存分配成功的话,那会接着执行后续的代码,把数据元素e填到这个新节点当中。然后就像之前那样修改这个指针,这样的话就完成了,把数据元素e插入到p节点之后这个事情。显然,这段代码并没有循环什么的,它的时间复杂度就是o(1)
好。那之前我们在第二个位置插入数据元素e这个基本操作,它是不是做的就是先找到第二减一个结点?然后就是在这个结点之后插入数据元素e啊,所以在实现后插操作之后,我们就可以把这一整段代码就直接通过调用这个函数就可以完成了
好,接下来要看的是前叉操作,
也就是给定一个结点p,你要在这个结点p之前插入一个新的数据元素e。诶,是不是出问题了?我们的这些单链表,它只能往后找,不能往前找,所以对于给定的结点p它。它之前有哪些节点,我们是看不到的,这片区域是被打了马赛克的,就跟大家看视频的时候,有一些地方打上了马赛克,你想看却又看不到。好,那怎么办呢?我们想看的是高清无码的一片区域,对吧?那我们其实可以传入一个头指针,如果给了头指针的话,那整个链表的所有信息我们就都可以知道了。那这样的话,我们要在节点p之前插入一个新的元素,那我们是不是可以依次遍历各个节点,然后找到这个节点p的前驱节点?然后在这个前驱节点之后插入数据元素e,这样就可以了,对吧?显然如果用这种方式实现的话,那时间复杂度应该是o(n)这个数量级。
但是有时候没办法,你想看的这个区域,可能他真的就是看不到,也就是说如果他不给你传这个头指针的话,那刚才的这个思路是不是就?没办法,实现了
好,那接下来看另一种实现方式,要在p的前面插入一个数据元素e,首先申请一个新的结点,然后把这个结点作为p结点的后继结点。也就是连成这个样子好,接下来重点来了,我的节点不能跑路,但是我节点里面的数据可以跑路,对吧?这句代码会把p结点当中以前存放的这个数据元素x给复制过来,然后再下一句会把这次要新插入的数据元素e。把它放到p这个结点里边诶,你看虽然我们没办法找到p结点的前驱结点,但是用这样的方式是不是逻辑上也可以实现同样的效果啊?并且这种实现方式,它的时间复杂度是o(1),而前面那种思路就是要找它前驱节点的那种思路,时间复杂度是o(n),因为你必须循环遍历各个节点。所以这个偷天换日的操作还是挺骚的。那我们这儿实现的这个功能是只给出了这个数据元素e,然后我们在这个函数里面自己一个新的节点.
那王道书里给的代码是这样的。它是直接给传了这个结点s,也就是新的要插入的这个结点处理的方法是一样的,找不到p的前驱结点,那么就先把s这个结点先连到p之后。然后声明一个temp变量,先把p结点的内容保存下来,接下来把s结点的内容复制到p里边,也就是这里边就变成了e。最后再把temp的值复制到s里边,也就是x复制到了这个地方,那这样的话就实现了结点的前叉操作。
2.4 问题4:好,那以上就是和插入相关的所有的操作的实现,那接下来我们要探讨的是怎么删除一个节点
如果此次要删除第二个位置的元素的话,那我们是不是也需要找到它的前驱节点?因为我们需要更改它前驱节点的next指针,那对于删除操作的实现,我们只探讨带头节点的这种情况。同样的头结点,我们把它看作第零个结点,所以如果我们要删除的是第一个结点的话,那么按照刚才我们提出的这种思路,我们要找到第i- 1个结点,也就是第零个结点。然后把第零个节点的next指针,把它指向再往后的一个节点,然后我们还需要用free函数把第二个节点给释放掉
那接下来我们来看一下具体的代码,实现来分析一下,如果i=4的话,那么我们首先需要根据之前的这些逻辑,找到第三个结点。也就是此次要删除的这个结点的前驱节点,那这些逻辑是不是和插入操作其实是一样的?所以我们就不再慢慢分析,总之最后p会指向第三个结点。好,接下来会定义一个指针q,q指针指向了p结点的next,也就是指向了第二个结点。接下来会把q结点的这个数据元素复制到变量e里边,注意这个变量e需要把此次删除的这个结点的值给带回到这个函数的调用者那儿,所以e这个参数是引用类型的。好再往后p的next要指向q的next,也就是指向null。然后最后调用free函数,把q结点给释放掉。那这样的话,我们就删除了第四个结点,那由于需要依次循环的来找到第二减一个结点。所以这个算法的最坏时间复杂度和平均时间复杂度应该是o(n)这个量级。而如果此次要删除的是第一个结点的话,那么是不需要进行这个循环的。所以最好的情况,时间复杂度应该是o(1)这个数量级。好了,那大家还是需要思考一下,如果不带头节点的话,那么删除第一个数据元素的时候,是不是需要特殊处理呢?
如果不带头节点的话,那么删除第一个数据元素的时候,是不是需要特殊处理呢?可以和这个代码进行一个对比好,
2.5 问题5:那接下来我们来看一下怎么删除指定的给定的一个节点。
那按照之前的思路,如果要删除节点p的话,是不是还需要修改它的前驱节点的next指针?那同样的问题发生了,我们没办法找到它的前驱节点。除非像刚才一样,要么就是传入一个头指针,然后从链表头依次往后寻找p的前驱节点。
第二种方法呢,就类似于刚才前叉操作的实现。
我们此次要删除的是结点p,首先声明一个指针q指向p的后继结点。然后我们把p的后继结点的这个数据,这个数据元素把它复制到p结点的这个数据域里边,也就是这个样子。然后再让p结点的next指针指向q结点之后的那个位置。当然q结点之后有可能是一个实际的结点,也有可能是null,但是无所谓好了,最后还需要把这个q结点释放掉,把这个内存归还给系统。那这种实现方式,它的时间复杂度也是o(1)。有没有感觉自己还挺聪明的?
那接下来问题来了,我们来考虑一种极限情况,如果此次要删除的这个p结点它。它刚好就是这个单链表的最后一个节点的话,
那看一下这个代码,首先q指针是指向了p的next。也就指向null那再往后执行到这一句的时候,是不是就会出错了?因为此时q结点它并没有指向某一个具体的结点。所以想在q结点里边取得它的data域,是不是就会出现空指针的错误啊?所以这段代码其实它是有bug的。我们的王道书上给的也是这小段代码,那如果说p结点刚好是最后一个结点的话,那。
呃,我们只能从表头开始,依次往后寻找,找到它的前驱,就是用之前提的那种比较土的思路来解决这个问题。当然,如果考试的时候迫不得已,你只能这么写的话,那估计最多扣你一分,甚至不扣你的分,那这个时候大家是不是体会到这个单链表它?它只能单向的来搜索,来检索各个节点,而不能逆向的检索,是不是有时候会确实不太方便?
那如果说各个结点,还有往前的这个指针呢?情况是不是就不一样了?那像这种可以双向寻找,双向检索的链表就是双链表,这个我们会用之后的小节来学习
好的,那这个小节中我们学习了和单链表插入删除相关的这一系列操作的实现,其中在讨论按位序插入的时候,我们具体探讨了带头节点和不带头节点的代码实现。从代码中,大家应该能够体会到这两种情况的区别,同时就能理解上一小节提到的那个点。带头节点写代码会更方便一些。当然,
两种情况代码都要会写,大家在做题的时候一定要注意审题,是不是带头节点的?那这个小节的这些操作的实现都是十分重要的。另外,不知道大家有没有体会到我们之前花了不少的时间来讲什么?malloc函数free函数,然后画了各种各样的内存。但是当我们在前期花时间把这些基础打牢了之后,我们在学习后续这些内容的时候,是不是理解起来会越来越快?所以前期的这些内容,我们学慢一点没关系,因为很多代码里边遇到的问题都是相通的,你学之后的内容也会遇到,那这些问题如果我们能在刚开始的时候就把它解决。那之后的理解才会越来越深,并且会学的越来越快,
另外在这个小节当中,希望大家能够用我们的这个示例代码来体会一下封装的好处。我们不是实现了一个后插操作吗?实现了这个函数之后,我们按位序插入数据元素e,这段代码是不是就变得很简洁并且清晰明了?当我们要在第二个位置插入数据元素e的时候,我们首先是要先找到第i- 1个结点,然后在这个结点之后。插入新的数据元素e。你看把这个小功能模块把它封装成一个函数之后,是不是你的代码逻辑就变得更清晰了?
当然了,前面这个部分我们也可以把它封装成一个函数,一个基本操作,这个我们会在下一小节当中进行介绍。好的,那以上就是这个小节的全部内容。
三 单链表的查找
3.1 问题6:那首先来看,按位查找怎么实现?
那所谓按位查找,就是你要找到l这个单链表当中的第二个节点,把这个节点给返回好,那上一小节中,我们在按位插入和按位删除这两个基本操作里边。
其实已经实现了按位查找相关的代码逻辑,只不过上一小节当中,我们是找到第二减一个结点。那代码是不是类似的?如果我们要找的是第二个节点的话,把这个地方改成i不就行了
好,那所以要找到一个单链表i当中的第二个节点就很简单了。首先要判断一下这个i的值是否小于零,如果小于零的话,那返回一个null,因为我们这儿讨论的是带头结点的这种情况,所以我们可以把头结点认为是第零个结点。因此,如果此次传入的参数i=0的话,那么首先经过上面这一系列执行p指针会指向头结点。而j的值等于0。i的值,此时也是零,所以这个条件是不满足的,因此会直接跳过这个循环,直接返回当前p指向的这个结点,也就是返回这个头结点。好,那这是一种极端情况,
再来看另一种极端情况,如果i的值大于链表的实际长度,比如说i=8的话,那么来分析一下这个代码,首先这个p它是指向了头结点。然后j的值刚开始是零,第一轮循环之后p指针指向下一个结点j的值变为一,第二轮循环p再往后指一个结点j的值变为二。第三轮循环p指向第三个结点,然后j的值变成三,第四个循环是这样,第五个循环是这样子。好,那接下来是不是循环的这个条件不满足,于是循环结束返回p指针,此时指向的这个值,也就是返回一个null。所以你看,当i值不合法的时候,它最终返回的值就是一个null。因此,如果别人调用你这个基本操作的话,那它是不是只需要判断一下此次的返回值是不是等于null?它就可以知道这次的按位查找操作到底是否执行成功了,那这些边界情况都是我们在写程序的时候必须考虑到的,要让我们的这个算法具有健壮性。
好,那接下来如果i=3的话,那这个分析起来也是一样的,即便是初学者,只要耐下性子来,肯定可以分析出这个代码执行的过程。那很显然,按位查找这个操作,它的平均时间复杂度应该是o(n)这个数量级,那所谓平均情况就是指我们此次输入的这个i值。它取得合法范围内的任何一个数字的概率都等可能的这种情况,那具体的算法其实和顺序表的按位查找那种分析的方法是一样的,这儿就不再赘述。
那王道书里给的按位查找它的代码,实现和我们这儿有一点点不同,但是实现的最终效果都是一样的。课本里面的这个代码,首先是把j的值设为一,也就是说p结点,它刚开始并不是指向第零个结点,而是指向了第一个数据结点。然后接下来才判断,如果i的值等于零的话,那给它返回L,也就是给它返回头结点,那之后的处理逻辑都是一样的,让p指针依次往后移。然后j的值也依次递增。那大家可以暂停来理解一下,这段代码
那既然我们在这儿实现了按位查找的基本操作,那上一小节当中按位插入和按位删除是不是就可以直接调用我们的基本操作来实现?因为在这两个地方,我们都是要找到第i- 1个结点嘛,所以在这个地方,大家应该又可以体会到这种封装,或者说实现一些基本操作,它有什么好处了?当我们把一些常用的功能封装起来之后,我们可以避免重复代码,
并且我们的代码会更简洁,而且更易维护。那代码更简洁,很容易理解,更易维护是什么意思呢?好来想一下,如果我们没有实现这个基本操作,而是按照之前的那种方式来找到第二减一个结点的话,那么在插入这个函数里边,我们需要写一份这个代码。在删除这个函数里边,我们还需要再写一份这个代码,那假设某一天你发现你写的这一段代码,它本身逻辑就是有问题的,有bug的。
那这个时候你要维护你的这个代码,是不是你既要修改这个函数里边的,也需要修改这个函数里边的?而如果你把最常用的这些操作都把它封装成函数,封装成一个基本操作的话,那么如果你发现你的基本操作代码实现有问题,那你只要改了这个getElem函数里面的代码,是不是所有的?的地方都会受到影响,就都可以得到修正,所以写代码不多的同学可以好好体会一下啊,这儿提到的这些思想。虽然考试不考,但是这些东西肯定是大家以后能够用到的。
那如果再加上我们上个小节当中实现的这个后插操作的话,
我们的这个按位插入是不是只需要调用这样的两个函数就可以完成了?第一个函数找到了第i- 1个结点,也就是此次要插入的这个位置的前驱结点。然后第二个函数会在这个前驱节点之后插入数据元素e那上一小节当中,当我们在实现后插操作的时候,我们在这儿对p指针进行了一个判断。如果p指针等于null的话,会返回一个FALSE。不知道有没有同学注意到,可能在学上一小节的时候,也许会有同学有疑问,会觉得说怎么可能有人在调用这个函数的时候给你。故意传入一个空指针呢。写这段代码有必要吗?但是如果在左边这种情况下,
这个条件判断就会显得十分重要。因为刚才我们说了,如果说此次传入的这个i值不合法的话,那么getElem这个函数的返回值应该是会返回一个null,对吧?所以p指针有可能是指向null的,那接下来你再往这个函数里传入p指针的话,是不是就说明其实这种情况是有可能发生的?当p=null的时候,说明第i- 1个结点是不存在的。在这种情况下,直接返回一个FALSE表示后插操作失败。那这个按位插入的操作,直接return这个函数的返回值,也就是说它也会返回一个FALSE,
那是不是就意味着这个按位插入也失败了?所以你看尽可能的提高代码的健壮性,其实是很有必要的,不要觉得这些边边角角的判断很麻烦。这些边界情况才是我们的程序最容易出bug的地方。那希望大家好好体会封装和健壮性的重要性
3.2问题7: 如何按值查找
那接下来我们要学习怎么实现按值插入,也就是给你一个数据元素e,然后看一下在你的这个单列表当中有没有哪个结点的值是等于e的,那这个代码很简单,那我们假设这个我们这儿的所谓ElemType,它是int型的变量。然后这次传入的e这个变量,它等于八的话,那首先会让一个p指针指向头结点的下一个结点。也就是指向第一个数据结点之后进行while循环,此时p不等于null是满足的,并且p这个结点的数据域它的值不等于e的值,也就是不等于八,因此会让p指针指向下一个节点,接下来要进行第二轮循环。但是由于p节点当中存储的这个数据。它的值和e的值是相等的,所以循环条件不满足,因此会执行之后的依据,也就是return这个p指针。因此,会跳出循环,把这个p结点给返回,这样的话,我们就找到了一个和给定的元素值相等的结点。好,再来看一个不能找到结点的情况
如果e的值此次传入的是六的话,那么和刚才一样,通过这个while循环的执行p指针会一次一次的往后移。一直移到最后面这个位置,当p指向null的时候,这个条件得不到满足,于是跳出while循环,然后返回p。也就是返回一个null那当这个函数的调用者接收到null的时候就说明并不存在数据域等于六的结点。
那刚才我们是假设这个ElemType数据元素的类型是int类型,那我们对struct类型的呃相等的判断是不是就不能用这个操作符?这一点我们在之前的小节中强调过,大家可以再回忆一下如何比较两个struct类型,它是否相等。这儿就不再重复好,那由于按值查找操作,只能从第一个节点开始用这个循环依次的往后扫描p指针,所以很显然这个算法它的时间复杂度应该是o(n)这个数量级。那这是按值查找
最后我们再来看一下怎么求一个单链表的长度,其实实现的核心是不是一样的?就是让这个p指针依次往后移,然后我们用一个变量依次累加来记录这个表到底有多长。最后再返回这个值,
那大家可以快速的思考一下,如果不带头结点的话,怎么统计这个表的长度代码的实现,会不会有哪些不同?那由于求表长这个操作也需要用while循环,让p指针从头到尾依次扫描,因此它的时间复杂度肯定也是o(n)这个数量级
那这个小节我们学习了,怎么实现单链表的查找操作,分为按位查找和按值查找,然后如果要求单链表的长度的话,其实核心的代码和上面的这两个查找操作是一样的。最重要的就是要会写那个循环,让p指针从头到尾依次扫描各个节点,那大家学了单链表的按位查找之后,可以回忆一下。顺序表的按位查找它是怎么实现的?由于单链表不具备随机访问的特性,所以对单链表的查找操作,它只能依次的从头往后扫描。因此,时间复杂度肯定都是o(n),当然这儿我们指的是平均情况和最坏情况。好的,那以上就是这个小节的全部内容。
四 单链表的建立
在这个小节中我们会学习单链表的两种建立方法,分别是尾插法和头插法。那概括来说,这一小节要探讨的问题就是,如果给你很多个数据元素,也就很多个ElemType,那么让你把它们存到一个单链表里,你应该怎么处理呢?其实很简单,第一步肯定是要从无到有,先创建一个单链表,对吧?也就是说先初始化一个单链表,然后接下来每一次取一个数据元素,
然后把这个数据元素插到表尾的位置,或者每一次都插到表头的位置,所以这两种方法就分别对应所谓的尾插法和头插法。那这个小节我们探讨的是带头节点的单链表哦,好,那首先我们要探讨的是尾插法,第一步是不是要先初始化一个单链表
4.1 问题8:怎么初始化一个带头节点的单链表啊?
这个相关的操作我们已经在单链表的定义,那个小节当中有过。详细的介绍这儿就不再赘述好
4.2 问题9:用尾插法建立一个单链表(不太好的实现方式)
那现在已经有了一个单链表,接下来我们每一次取一个数据元素,插到这个单链表的尾部。那这个操作我们是不是可以用之前已经实现的按位序插入这个基本操作来实现,那由于我们每一次都是要把数据元素插入到这个单链表的表尾。所以我们可以设置一个变量叫length,用这个变量来记录单链表的当前长度。然后再写一个while循环,每次取出一个数据元素e,然后调用按位序插入这个基本操作,每一次都把这个数据元素e插入到第length+1个位置。像下面这个例子length+1应该等于四,所以应该就是插入到这个位置,也就是表尾的位置,而每一次插入一个新的元素之后,都会导致单链表的长度length+1。那是不是这样的方式就可以实现用尾插法,建立一个单链表?
不过如果用这种方式实现的话,那么当你每一次要在表尾的位置插入一个元素的时候,它都会用这个循环从表头的位置开始,依次往后遍历。直到找到最后一个结点,按照这个逻辑,当我们要插入第一个元素的时候,也就是只有一个头结点的时候,这个while循环可以直接跳过,也就是循环次数是零次。而当我们要插入第二个元素的时候,while循环需要循环一次,要插入第三个元素的时候,需要循环两次好,以此类推。所以如果我们要插入n个元素的话,那么当插入第n个元素的时候,总共需要循环n- 1次。因此,循环的次数总共就应该是零+1+2,一直加加加到n- 1算。算出来应该是o的n方,这样的一个时间复杂度。
这个时间复杂度还是很高的,那其实我们根本没有必要每一次都从头开始往后寻找,那其实我们是不是可以设置一个指针,让这个指针指向表尾的最后一个数据结点?然后当我们要在尾部插入一个新的数据元素的时候,是不是只需要对r这个节点(就是数据元素是27的节点)做一个后插操作就可以了
4.3 问题10:如何实现后插操作?
我们之前也具体聊过,就是这个函数。对表尾的这个结点执行后插操作就是这个样子。那当后插操作完成之后,是不是还需要把这个表尾指针往后移,就指向新的这个表尾元素,这样的话,你再插入下一个数据的时候,是不是还是对r进行后插操作就可以了
好,那来看一下我们课本里给的代码。在这个代码里边,首先声明了一个局部变量x。然后用malloc申请了一个头节点,也就说它这里边其实做了初始化一个单链表的操作。只不过我们自己初始化一个单链表的时候,会把这个头结点的指针先把它设为null。但是它这个地方没有做这样的操作,因为头结点的这个指针会在后面被修改,那继续往后在这儿声明了两个指针。s和r这两个指针都是指向了头节点,然后这个地方调用了scanf,也就是让用户从键盘里面输入一个整数。这个整数x就是此次要插入单链表当中的数据元素。也就说我们这儿的ElemType,也就是数据元素类型,它就是整型的变量,那假设此次输入的整数是十,那接下来while循环,它首先会检查x的值。是否不等于九九九?它这儿设置了一个这样的数字,其实就是取一个比较特殊的值当用户,输入九九九的时候,认为这个单链表的建立已经结束。所以你不要觉得奇怪,这儿为什么选九九九?其实就是随便挑选的一个比较特殊的数字,你要愿意的话也可以改成其他的数字好,那由于此时x的值是十,它不等于这个特殊的值。因此,会开始执行循环里边的这些代码,后面这两句会申请一个新的结点,然后让s这个指针指向新结点,并且把新结点的这个数值设为x,也就是此次输入的这个数字。接下来把r结点的next指针指向s这个结点,也就是这样子,最后再让r指针指向s这个结点。接下来就可以输入下一个数据元素,
那假设我们此次输入的数字是16,那由于16不等于这个特殊的数值。所以这个循环的条件满足,因此接下来还会申请一个新的节点,并且把这个新的数据元素放到这个节点当中。再往后让r结点的next指针指向s之后,再让r指针指向s这个结点,接下来又可以输入再下一个数字好,那由于这次输入的数字依然不等于九九九。所以还会进行一次循环,那处理的过程和刚才是一样的,总之r这个指针永远要让它指向表尾的那个数据结点。然后每一次取得一个新的数据元素的时候,都把这个新的数据元素存到一个新的结点当中,并且把它连到表尾结点之后。
好,那接下来如果用户输入的是九九九,那么这个循环条件不满足,于是就可以跳过这个循环,然后执行这一句。让r结点的next指向none。最后再给调用者返回这个头指针,也就是返回这个单链表,所以在我们学习了之前的那些基本操作之后,这个代码的实现应该就很简单了。在前面这个地方,无非就是做了一个初始化空表,然后在这个while循环里,其实就是做了一个指定节点的后插操作。虽然和我们实现的后插操作有那么一丢丢区别,但是最起码后插操作的实现思想就可以直接迁移到这个地方。唯一需要注意的就是这个r指针,
我们要保证这个指针它永远是指向最后一个结点的。显然,如果要插入n个结点的话,那么这个循环的次数也是n次,所以这个算法它的时间复杂度应该是o(n)这个数量级。那同学们可以暂停思考一下,如果不带头结点的单链表,它的尾插法实现会有什么不同呢
4.4 头插法建立单链表
接下来我们再来看第二种方法头插法。顾名思义,就是说我每一次取得一个新的数据元素的时候,我都把它插入到这个单链表的表头这个位置。就是这个样子。那其实实现这个算法的核心是不是和刚才一样,它也是一个对指定结点的后插操作?每插入一个数据元素,其实就是对这个头结点执行一次后插操作嘛,所以用头插法,建立单链表也很简单,就是先初始化一个单链表。然后循环里边每一次取一个数据元素,然后每取得一个数据元素之后都调用一次,我们这儿实现的后插操作每一次都是对头节点进行后插操作。插入新的数据元素e,
可以看一下课本里边给的这段代码,这里边它每次取得数据元素,依然是用这个scanf。也就是让用户用键盘输入一个整数,作为此次要插入的新的数据元素,那你看这两句是不是实现了对单链表的初始化?然后while循环里边这个部分的代码,其实就是实现了一个后插操作嘛,只不过它每一次执行后插操作的指定节点都是指定了头节点。这两段代码本质上没有任何区别诶,那同学们可能会注意到在头插法里边,当它在初始化头结点的时候,它把头结点的next指针指向了null。但是尾插法里面没有好,那大家可以暂停思考一下,如果我们像尾插法那样把这一句代码给它去掉呢?会发生什么情况?首先,前面这句代码会申请一个新的结点,那如果我们不执行这句代码的话,
那是不是头结点的这个指针有可能指向内存当中的某一片神秘的区域啊?因为之前和大家强调过,其实这种动态分配申请的这片内存空间里面,它以前可能是会有脏数据的。你不知道以前这个数据是什么,所以说如果你不把它初始化的话,那么这个指针它有可能是指向某一个你不可知的地方的。好呢,再往后执行的话,申请一个新的结点s,这个s节点的data域把它设成x,再往后的话s节点的next指针指向了i节点的next指针这个地方。也就是指向了这片地方,然后头节点再指向这个新的节点,也就是这样子好,那如果还有别的新的节点陆续插入的话,是不是情况是类似的?最后一个结点的next指针,它最终肯定都会指向这个,我们都不知道是什么地方的地方。所以这个地方我们必须初始化。
其实,不管是在头插法的代码里边,还是尾插法的代码里边,我给大家的建议是,以后如果你要初始化一个单链表的话,那大家最好是养成这种。写代码的好习惯,反正初始化的时候,你把这句加上,肯定不会错,但如果你习惯不好,忘了写这一句的话,那之后可能会出现一些你预料不到的问题。所以即便是在刚才我们介绍的尾插法的实现里边,其实大家最好还是形成这种习惯,把这句加上加上总不会错嘛。那这种好习惯,大家最好是在平时做题的时候就刻意的去培养好,
那回到这个处理逻辑本身,如果按照我们刚才的那个输入顺序,也就先输入十,再输入16。再输入27,最后输入九九九的话,那么按照头插法的这种规则,我们最终形成的这个单链表就应该是二七十六十。刚好是这些输入元素的逆序。其实头插法的这种性质是十分重要的,大家做课后习题的时候就会发现,其实在很多题目当中都会用得到这种单链表的逆制,这样的操作。那像这段代码当中,我们是用scanf取得一个一个的数据元素,那如果现在给你一个单链表L,让你把这个单链表逆置的话,其实核心的代码逻辑是不变的。只不过你取数据元素的时候,并不是用scanf的方式取得的,而是可以用一个指针循环着扫描,按顺序从这个i当中依次取得这个数据元素,对吧?然后当你取出一个数据元素之后,又用头插法,把它插入到另一个新的链表当中,那你用这种方式建立的新链表是不是就相当于把这个以前的老链表给逆置了?
当然,你也可以每一次从这个链表当中取下一个结点,然后把这个取下的结点又重新插回到这个头结点之后。这样的话,你就不需要建立一个新的链表,而是把l这个链表原地逆置。所以当大家在做题或者考试的时候,呃,看到这种需要把链表拟制的问题,你就可以尝试用头插法的这种方式。来实现它。好,那同样的大家也需要暂停思考一下,如果不带头结点的话,那么怎么实现这个单链表的头插法?并且建议跨考的基础不好的同学一定要动手写一写。
好的,那这个小节我们介绍了用头插法和尾插法来建立单链表,不管是哪种方法,其实实现的核心都是我们之前已经聊过的初始化操作和指定节点的后插操作,这两个基本操作。只不过,在实现尾插法的时候,需要设置一个呃指向表尾节点的指针。那通过这个小节的学习,大家应该更能够体会到为什么我们一直在强调说基本操作很重要。虽然说考试的时候几乎不可能直接考察,你就是某一种基本操作怎么实现,但是你只要掌握了这些基本操作的实现精髓之后,其实这些思想可以迁移。可以用于实现其他的更复杂的一些算法和操作,所以为什么我们在学习一个数据结构的时候非要探讨它的逻辑结构,物理结构,还有基本操作,也就是数据的运算呢?相信通过这个小节,大家应该会有一些更感性的体会。好,
那最后再次强调头插法,这种策略可以用于链表的逆置,这个考点是很重要的,经常考察。那大家可以不要急着往后学习,可以暂停一下,看一下给你一个单链表L,然后你怎么实现逆置?好的,那以上就是这个小节的全部内容。
五 双链表
各位同学大家好,在这个小节中我们会学习双链表相关的内容,那之前的小节中我们学习了单链表,在单链表当中由于每一个节点只包含。指向它的后继结点的指针,所以如果给定一个结点p的话,那么想要找到它的前驱结点是很麻烦的,那双链表呢?就是在单链表的基础上再增加一个指针域。这个指针prior是指向这个结点的前驱节点,那prior这个单词的意思是先前的还有一个很有趣的意思,叫南修道院副院长。好,那我们的一个双链表当中的节点,我们把它命名为DNode这的d,其实指的是double
5.1问题11:那来看一下怎么从无到有创建一个双链表,
那这个地方我们讨论的是带头结点的情况,首先这个地方声明了一个指向头结点的指针L。然后调用双链表的这个初始化函数,这句代码会申请一片空间用来存放头结点。并且让指针L指向这个头结点,然后接下来需要把这个头结点的前向指针和后向指针都设为null。这个头结点之前肯定不会再有其他的结点了,所以这个头结点的prior指针域肯定是永远都指向null的。
好,那这个地方我们做了类似于单链表那样的啊,类型的重命名,那这儿和单链表那边类似,有的地方使用DLinklist是想强调这个东西,它是一个链表。而有的地方,我们使用DNode*是想强调这个东西,它是一个节点。好,那这块内容我们在单链表的定义那一小节中重点强调过,所以这个地方就不再展开。好,那对于带头结点的这种双链表来说,如果要判断它是否为空的话,是不是只需要判断这个头结点的next指针是?是否等于null就可以了,如果等于null的话,说明这个表此时暂时还没有存入任何数据元素
5.2 问题12:那接下来看一下双链表的插入怎么实现
我们的课本当中给出了这样的几行代码,也就是说要在p结点之后插入s这个结点。好第一步会把s结点的next指针指向p结点的下一个结点,也就是这个样子,第二步会把p结点的后继结点它的前向指针。指向此次新插入的s,这个结点也就是这个样子,第三步把s结点的前向指针指向p结点也就是这样。第四步,再把p结点的后向指针指向s结点,也就是这样,这就完成了p结点的后插操作,在它的后面插入s结点。
不过呢,如果这个p结点,它刚好是双链表的最后一个结点的话,那第二句执行是不是会出现问题啊?因为p的next是null嘛,所以这一句肯定会有一个空指针的错误。
因此,这段代码其实我们可以把它写的更严谨一点,这儿加一个if语句来处理p结点,没有后继结点的这种情况好,那来看一下,假设现在p结点是最后一个结点。那第一句代码会让s结点的next指针指向p结点的next指针相同的位置,也就是同样是指向null。然后第二句先判断p结点,此时还有没有后继结点,如果它没有后继结点的话,那当然就不需要再修改它后继结点的前项指针。因此,在这种情况下,if条件不满足,会跳到之后一句执行让s结点的前向指针。指向p结点。第四句再把p结点的后向指针指向新插入的s结点,那加了这句代码之后是不是后插操作就没有问题了。
那大家自己写代码的时候需要注意修改这些指针的一个语句的顺序修改指针的这些语句,如果语序不合理的话,那么有可能会导致一些错误。比如说如果我们写代码的时候没注意把四这一句写在了前面,先执行四再执行一的话,那么首先四这一句会让p结点的next指针指向此次的新结点s。接下来执行第一句会让s结点的next指针和p结点的next指针指向同一个位置,也就是会指向s结点它自己。那这种情况肯定是错误的,所以大家自己写代码的时候修改这些指针的时候啊,需要注意一下,特别是跨考的同学。
好,那这个地方我们介绍双链表的插入,其实指的是后插操作对吧?就是在p结点之后。插入s这个节点,其实只要搞定了后插操作,那么按位去插入,或者一个节点的前插操作,这些都很容易实现。如果我们想要按位序插入一个新的节点的话,那么我们是不是只需要从头节点开始找到某一个位序的前驱节点,然后对这个前驱节点执行?后插操作就可以了,而如果我们想要在某一个节点前面进行一个前插操作的话,那么由于双链表的这种特性,我们可以很方便的找到给定节点的前驱节点,然后再对它的前驱节点执行后插操作,这样的话我们就可以实现所谓的前插操作,也就是其他的这些插入操作,其实最终都可以转换成用这个后插来实现。
5.3 问题13:接下来看怎么实现双链表的删除,
假设我们此次要删除的是指定结点p的后继结点q结点。第一句代码会让p结点的next指针指向q结点的next指针相同的位置,也就是指向q的后继结点。第二句代码会把q结点的后继结点的前向指针指向p结点,也就是这个样子。第三句代码再释放q结点,这样的话就完成了对q结点的删除,
不过和刚才一样,这个代码也是有一点点问题的,如果此次要删除的结点q刚好是双链表的最后一个结点的话。那么,第二句代码同样是会出现一个空指针的错误。所以我们可以增加一些条件判断,用来提升这个代码的健壮性好。
假设此次我们要删除的是给定结点p的后继结点的话,那么首先。这儿会声明,让q指针指向p的后继结点,如果q=null的话,那说明p结点是没有后继结点的。这种情况下,返回FALSE。好,那接下来首先是让p结点的next指针指向q结点的next指针相同的位置,也就是这样子。再往后,我们需要先判断一下q结点还有没有后继结点,如果q结点有后继结点的话,那么我们才会尝试修改它后继结点的前向指针。
那像这种情况q结点之后已经没有其他结点了,所以这个if的条件不满足,因此就会直接把q结点释放掉。好,
5.4 问题14:如何销毁一个双链表
那实现了这个删除操作之后,如果我们想要销毁一个双链表的话,那我们是不是可以用一个while循环每一次都删除这个头结点的后继结点?依次把这些结点占用的空间给释放掉,直到头结点之后再无其他结点,也就是说这个表变空了。最后再把这个头结点占的空间也给释放掉,然后让头指针指向,那这样的话,我们是不是就可以销毁一个双链表
5.5 问题15:最后来看一下双链表的遍历怎么实现,
其实很简单,就是这样一个while循环,每一次循环让p指针指向下一个结点。然后在这个循环的内部,可以对此次p指针指向的节点做相应的处理,比如说要打印出这个节点的数值啊之类的。那前向遍历是不是也一样的?给你一个指定的结点p,然后每一次你让这个p指针往前移一位。然后在这个while循环里对结点p做相应的处理就可以了。好,那如果你在这个循环里边,只想处理那些数据结点,并不想处理头结点的话,那只需要把这个while循环的条件给改一下就行了。
如果说p结点的前项指针已经等于null的话,那么说明此时p结点指向的就已经是头结点了。这样的话,while循环条件不满足,也就不会对当前p结点指向的这个头结点进行处理。好,那只要知道怎么遍历一个双链表,那本质上按位查找,按值查找这些操作核心代码就是这样一个便利,对吧?如果你要实现按位查找的话,那么在这循环里边,你只需要累加一个计数器用于记录,此时我指向的是哪个位序的元素就可以了?
而如果你要按值查找的话,在这个循环里边是不是你只需要对当前指向的这个结点进行一个值的对比就可以?好,那由于双链表,它并没有随机存取的特性,所以这种查找操作时间复杂度就是o(n)这个量级。因为你只能用这种循环的方式,一个一个对比,依次往后找
那这个小节的内容很简单,其实对双链表的这些操作的实现和单链表本质上没有太大的区别。只不过双链表,它就是多了一个前向的指针嘛,所以大家在写代码的时候,只需要注意这些指针它怎么修改,然后不要出错就可以了。然后当我们实现插入和删除相关的代码的时候,大家可能要注意一下,如果此次插入的是最后一个位置或者被删除的节点是最后一个节点的话。那严格一点来说,书上给的那个代码逻辑是有一点点bug的。那基础不好的同学也需要注意便利操作,它的循环应该怎么实现怎么写。
六 循环链表
各位同学大家好,在这个小节中我们会学习循环链表,其实就是在单链表和双链表的基础上加一个小小的改进,然后就变成了。相对应的循环单链表和循环双链表,所以这个小节的内容其实十分简单,
链表和循环双链表,所以这个小节的内容其实十分简单,看一个图就明白了,之前我们学习的单链表,它最后一个节点的next指针是。是指向null,但是循环单链表当中最后一个结点的next指针是指回了这个头结点,那这个很好理解。
那我们需要注意的是,在我们初始化一个循环单链表的时候,我们需要把头结点的next指针指向头结点它自己。那相应的,如果要判断它是否为空的话,你只需要检查它的头结点的next指针是否,是指向它自己就可以了。
6.1 问题16:如何判断循环链表是否为空
相比之下,我们之前学习的普通的单链表,当它为空的时候,头结点的next指针是指向空的,也就指向null。那刚才也说了,如果这个循环单链表不为空的话,就需要把最后一个结点的next指针指向头结点。那相应的,如果你要判断某一个结点p,它是否是循环单链表的表尾结点的话,你只需要看一下这个p结点的下一个结点是否是头结点。所以当大家写代码遍历这个循环单链表的时候,判断这个扫描指针p 是否到达表尾的条件 和普通的单链表,也是有一些不一样。相信这些都不难理解
那之前我们提到过,对于一个普通的单链表,如果说给你一个结点p,那么其实你只能知道这个结点p后续的这些结点。对于它前面的这些结点是什么情况,那你是不可知的,除非你也能获得头结点的指针,但是对于循环单链表来说,只要给你一个结点p,那你肯定可以找到整个循环单链表当中的任意一个结点。那这种特性还是有一些作用的,
比如之前我们说过,如果现在要让你实现一个功能,让你删除节点p,那删除这个节点之后肯定需要修改它的前驱节点的next指针,但是对于这种普通的单链表,你只知道结点p的指针,你肯定找不到它的前驱节点。而对于循环单链表来说,你可以顺着这条链依次往后找,然后直到找到此次要删除的结点p,它的前驱节点,然后修改它前驱节点的next指针。这样的话就可以完成删除结点p的工作。所以可以循环遍历各个节点,这个特性还是有一些作用的好,那之前我们在讲单链表的时候呃,大家应该有体会
那之前我们在讲单链表的时候呃,大家应该有体会,很多时候我们对链表的操作都是在链表的头部或者链表的尾部。比如说我们之前提到的用头插法,建立链表或者用尾插法,建立链表,那如果这个单链表它是普通的单链表,也就是最后一个结点它的next指针是指向null的话,此时如果我们只知道这个链表的头结点的话,那么要从头结点开始,找到最后一个表尾的结点,我们只能写一个循环,依次往后扫描。直到找到最后一个结点,所以找到最后一个结点的时间复杂度是o(n)这个数量级,
而对于循环单链表来说,如果我们让这个单链表的指针i不是指向头结点,而是指向尾部的这个结点,那么从这个尾结点出发,找到头结点,只需要o(1)的复杂度,因为只要往后找一个结点就可以了。而由于i这个指针是指向尾部的,所以当我们需要对链表的尾部进行操作的时候,也可以在o(1)的时间复杂度内就直接找到我们要操作的那个位置。
而不需要像刚才说的这样,从头往后依次循环遍历。所以如果在你的应用场景当中,需要经常对表头或者表尾进行操作的话,那么当你使用循环单链表的时候,你可以让你的这个呃单链表的指针L。让它指向最表尾的这个元素,当然如果你要这么做的话,当你在表尾插入和删除一个元素的时候,就需要修改这个指针i的指向。那这儿就不再展开好
那接下来要看的是循环双链表,其实循环双链表也很简单,表尾结点的next指针,它会指向这个头结点。然后头结点的prior指针,它又会指向尾结点,也就是说所有的这些next指针,它其实是形成了一个闭环,一个循环。而所有的这些prior指针,它也形成了另外一个方向的闭环循环,那这就是循环双链表。
那当我们在初始化一个空的循环双链表的时候,我们需要让这个头结点的前指针和后指针都指向头结点自己。而普通的双链表,它们都是指向null对吧?所以循环双链表的判空也有一点点区别,就是判断此刻这个头结点的next指针是否是指向了它自身?如果满足这个条件的话,那么说明此时这个循环双链表,它是一个空表,就return一个true。
而对于循环双链表,它的最后一个结点的next指针是指向了头结点,所以当我们在判断一个结点p它是不是循环双链表表尾结点的时候。啊,判断的条件应该是这个结点的next指针是否指向了头结点,如果满足的话,那么说明这个结点它就是尾部的结点。那按照这个逻辑,当循环双链表为空表的时候,呃,这个头结点它的next指针也是指向了头结点本身。所以这个头结点,它既是第一个结点,也是最后一个结点,我们这儿的逻辑依然是正确的,
6.2 问题17:接下来看一下循环双链表和普通的双链表,它们在实现基本操作的时候有哪些区别?
那之前我们提到过课本当中给了这样的一小段代码,这段代码实现了在结点p之后插入一个结点s。如果用这小段代码处理这种普通的双链表的话,当p结点,它刚好是表尾结点的时候。第二句代码的执行会出现错误,因为p结点它没有后继结点,所以我们就无法修改所谓的它的后继结点的前向指针。
但如果我们用的是循环双链表的话,那这个逻辑其实就是正确的。因为即便p结点,它是表尾的最后一个结点,但是它的next指针依然是非空的,这个指针指向了头结点。当我们插入一个新结点的时候,把这个头结点的prior也就是前向指针,让它指向这个新的结点,其实是没有问题的。这小段代码大家可以自己捋一捋
6.3那对双链表的删除也是一样的,
我们之前也提到过课本当中给的这小段代码,第二句其实是有一点点问题的,如果说我们此次要删除的结点q,它刚好是最后一个结点的话。那么和刚才一样q结点没有它的后继结点,所以在执行第二句代码的时候就会出现一个空指针的错误。
而如果使用的是循环双链表的话,第一句代码首先会把p结点的next指针指向q结点的next,也就是指向这个头结点的位置。接下来第二句,它会把q结点的next也就是这个头结点,它的prior指针前向指针指向p结点。也就是变成这样。最后第三句,再把q结点给释放掉,所以如果采用循环双链表的话,那课本当中给的这一小段代码逻辑就是没有问题的。
那这个小节的内容很简单,所谓循环单链表,其实就是把单链表当中最后一个元素的next指针指向头节点。所以,当循环单链表为空的时候,就有点像是你单手抱住了空虚的自己。而对于循环双链表来说,当它为空的时候,头结点的后向指针和前向指针都是指向了它自己。就像你用双手抱住了空虚的自己,而对于普通双链表来说,当表为空的时候,它的前向指针和后向指针都是指向null的,所以。所以就有点像是这个样子。
那到这儿我们就学完了,所有的用指针实现的链表,不管是对单链表,双链表,还是这儿提到的循环链表,大家在写这些链表的具体代码的时候都需要关注这样的几个方面。首先是怎么判断呃一个表,它是否为空,你知道一个空表的状态,你是不是就可以知道这个表应该怎么初始化?另外,还可以思考怎么判断一个结点p,它是表尾的结点还是表头的结点。当你知道这个条件怎么判断之后,你是不是就可以知道你怎么写前向遍历和后向遍历了?
因为所有的这些链表它都不具备随机访问的特性,所以当你要寻找其中的某一个节点的时候,核心肯定都是要实现一个遍历的逻辑。也就是一个while循环。而while循环,停在什么地方,其实无非就是停在表头或者停在表尾,然后最后大家再尝试插入或者删除一个结点的时候。可以考虑,如果你此次插入或者删除的这个节点,它在表头需不需要特殊的处理,在表中应该怎么处理,在表尾的时候又是否需要特殊的处理?当你考虑到所有的这三种情况的时候,你基本上就可以覆盖那些比较容易出错的边界条件了,所以这是所有的链表都值得关注的一些代码的问题。
七 静态链表
各位同学大家好,在这个小节中我们会学习静态链表相关的内容,我们会介绍什么是静态链表,怎么用代码定义一个静态链表?并且会简单的介绍怎么实现静态链表相关的那些基本操作,
7.1问题18:那首先来看一下什么是静态链表,
那之前我们已经学过单链表。单链表中的各个节点,它是离散的分布在内存中的各个角落。每一个节点会存放一个数据元素,还有指向下一个节点的指针,也就是下一个节点在内存当中的存放地址。
而静态链表是要分配一整片的,连续的内存空间。各个数据元素存放在这一整片空间中的其中某些位置。静态链表中的每一个结点包含了数据元素,还有下一个结点的数组下标,来看一下什么意思?在静态链表中,数组下标为零的这个结点。它充当了头结点的角色,也就是说,这个结点当中,它是不存放实际的数据元素的,而头结点的下一个结点,它是存放在数组下标为二的这个位置。也就是说,这个结点就是第一个数据结点,也就是位序为一的结点,那再往后的结点就是数组下标为一的结点,也就是上面这个结点。所以静态链表中的这个数组下标或者呃也有的地方把它称为游标,这个东西它充当的角色其实和单链表当中的指针是差不多的,只不过指针是指明了具体的内存地址,而这个地方的游标只是指明了下一个元素,它的数组下标。
那单链表的表尾元素,它的next指针是指向null的,在静态链表当中,如果要表示这个结点,它是最后一个结点的话,那么它的这个游标的值可以设为负一。这就表示,在这个结点之后,已经没有其他结点了。
好,那由于静态链表当中存放各个节点的这些空间,它们是连续的,所以如果说一个静态链表,它的这个数据元素占四个字节,游标也占四个字节。也就是说,这一整个的呃节点,它需要占八个字节的话,那假设静态链表的起始存放地址是这个地址。那数组下标为二的那个结点,它的存放地址就应该是零号结点的存放地址,加上每一个结点的大小乘以2就是接下来要寻找的这个节点的数组下标。那用这样的方式,是不是就可以把静态链表当中的游标,或者说数组下标把它映射成某一个数组下标所对应结点的实际存放地址。那这就是静态链表的一个基本原理。
7.2问题19:那怎么用代码定义一个静态链表呢?
其实应该很容易想到我们可以定义一个结构体啊,叫做node,然后每一个node,也就是每一个节点里边,它包含了一个数据元素data,还包含了下一个节点的游标,或者说数组下标。那这个数组下标可以用一个int型来表示。
好,那这是静态链表的一个结点,那我们需要的是多个连续存放的结点,那我们可以用数组的方式来定义,来声明它。就像这样,我们定义一个数组a,这个数组a有max size,也就是有十个数组元素,然后每一个数组元素的类型都是struct node也就是我们上面定义的这样的一个节点,所以用这样的方式声明一个数组的话,那么这个数组a其实就是占用了内存当中这样的一整片连续的存储空间。那这是我们自己比较容易想到的一种定义方法
7.3 问题20:课本中是如何对静态链表进行初始化的
我们的课本当中给了另外一种写法,课本上给的这种代码的写法还挺少见的,我刚开始看的时候也没弄明白它这个是什么意思?它这用了type def,然后在这个部分说明了你的struct结构体里边包含了哪些字段,一个data和一个next。然后后面跟了一个看起来像是数组的一个东西。后来我自己写了一些代码之后,发现它这种定义方式其实就等价于说你先定义一个结构体,这个结构体的名字叫struct node。然后你再把这个结构体struct node用type def,给它重命名。不过这个地方,它跟的是一个看起来像是一个数组的东西,用这儿给出的这种写法,
你之后用s link list去定义一个呃变量a的话。那么,当你声明这个变量a的时候,其实这儿的a你是声明了一个数组,这个数组的元素个数有max size这么多,也就是有十个元素。而每一个数组元素就是这样的一个struct node,也就是你这定义的这样的一个结构体,它等价于你用这样的方式声明a这个数组。反正我问了身边好几个经常用C加加的同学,很少有人见过这样的一种定义方式啊,
这儿希望大家可以暂停来体会一下。诶,那为什么课本中要用这样的方式来定义静态链表,用我们熟悉的这种数组的方式来定义静态链表,难道它不香吗?其实这个地方和我们之前学习单链表的时候提到过的linked list,还有Lnode*是完全相通的,因为其实我们在这个地方想定义的所谓的a这个东西。它是一个静态链表,但是如果用下面的这种方式来定义a的话,那么我们从代码的这个角度来看,会觉得说a它看起来是一个呃node型的数组。而上面的这种定义方式,虽然效果上和下面这种是等价的,不过上面这种定义方式,你一看这个代码,你就知道a这个东西,它是一个静态链表。所以这个地方这种比较少见的type def的用法,也希望大家能够了解它的原理是什么
我写了一小段程序验证过刚才的那种猜想。这我用了,我们比较容易想到的那种方法来定义了一个struct node,里面包含了两个int型的变量,然后下面这个地方我用了课本中的那种方式。使用了type def,然后后面跟了一个类似于数组一样的东西。max size的值是十,然后在这个函数里边首先声明了一个struct node,也就是一个结点x。然后在这个地方打印输出一个结点的大小,也就是用size of关键字,然后可以看到一个结点,它是八个字节这么大。
然后在这个地方,我们用我们比较熟悉的方式定义了一个数组a,这个数组a的大小是max size,也就是有十个元素,然后每一个元素,它就是一个struct node。那由于每一个元素的大小是八个字节,所以数组a的大小用size of测出来就应该是80个字节这么大小。然后在最后这个地方,我声明了一个变量b这个b的类型是s link list,然后也用size of测出了b的大小啊,发现它也是80,
也就说它和a是一样的。所以这就说明,当我们声明这个变量b的时候,其实它在背后它是一个大小为十的数组,并且数组元素每一个数组元素是这样的一个struct,其中包含了一个data和一个next,那由于每个int型的变量都是四个字节。所以这样的一个struct,它应该是八个字节的大小,和我们这测出来的结果是一样的。那这小段程序的这个运行结果就证明了刚才提出的那种猜想
好,那现在我们已经声明了一个静态链表,
7.4问题21:接下来来看一下这个静态链表相关的一些基本操作,应该做一些什么事情?
首先声明了它之后肯定需要对它进行初始化。那么,我们在单链表当中初始化一个单链表的时候,需要把这个头节点的next指针指向null,所以对应到这个静态链表里面的话,我们在初始化的时候肯定需要把。这个头结点也就是a0这个结点的next,把它设为负一,因为负一其实等价于null嘛,就是它没有指向任何一个元素。
好,那在静态链表当中,如果我们要查找某一个位序的结点的话,那我们肯定只能从这个头结点出发,通过这个游标记录的这些线索。依次的往后寻找后一个结点,然后直到找到我们想要的那个结点为止。
所以在这种静态链表当中,如果你要找到某一个位序的结点的话,那么时间复杂度应该是o(n)这个数量级。注意,我们这儿说的是某一个位序的节点,而不是某一个数组下标的节点。位序指的是各个节点在逻辑上的顺序,而这儿的数组下标其实只是反映了各个节点在物理上的一个顺序。好,那这是查找操作,
接下来看一下,如果要在位序为i的这个地方插入一个结点的话。那不难想到,第一步肯定是要找到一片空闲的空间,用来存放这个新的节点,
对吧?所以像这个图当中,四五七八九这些地方都是空闲的,所以可以按照某一种算法,比如说从上到下扫描啊,或者从下到上扫。扫描之类的就是找到一个此时空闲的节点,用于存放此次要存入的这个新的数据元素。第二步,既然要插入位序为i的结点,那么我们肯定需要把位序为i- 1的这个结点。它的后向指针或者说它的游标。给改了这个,
其实和单链表很类似,比如说在左边这种情况下,此时已经有四个数据元素了,那假设我们要插入位序为五的数据元素。也就是说,要在表尾插入一个新的数据元素,假设是在这个位置,那这个位置作为新的表尾,那按照第二步,我们是不是需要找到它的前驱节点,也就是第四个元素。把它的后项指针next改成四,也就是指向我们这个新结点的存放位置,然后接下来还需要把我们新结点的next。设成负一那这儿我们只是简单的提一个思路,感兴趣的同学可以自己动手实现一下代码诶,
那细心的同学可能会发现刚才我们找一个空结点的时候。我们是直接用肉眼的方式就可以看到这几个地方,他们是没有存数据的,但是从计算机的视角来看,其实内存当中的任何一个地方肯定都会有数据,只不过这些数据是脏数据而已。所以,为了让计算机识别出哪些节点,它此时暂时没有存放数据。其实,我们还应该在初始化的时候,把这些空闲节点的next把它设置为某一个特殊的值,比如说你可以设置为负二。那这样的话,你写代码的时候是不是只需要判断一下,如果它是负二的话,那么就说明此时这个结点是空闲的。你就可以用这个节点来存放新的数据元素。所以刚才讲初始化的时候,其实我们还漏了这样的一个步骤,你要在初始化的时候把这些空闲的节点给它标记出来。
好,那相应的在删除一个节点的时候,除了你要修改这些游标,这些东西之外,你是不是也需要把此次删除的那个节点把它的next给它设置为负二?用这样的方式表示,这个节点中,此时已经没有数据了,已经被回收了好。那静态链表相关的内容,我们就提这么多啊,这个考点其实考的不是特别多,并且很少考察它的代码实现。只不过我们想通过静态链表的这些地方啊,再给大家慢慢的补充这些语言相关的一些知识点。至少你要能看得懂书上给的这种代码,它在背后到底是一个什么样的逻辑,什么样的意思
好,那总结一下静态链表呢,它其实就是用数组的这种方式实现的一个链表。虽然说静态链表的这个存储空间,它是一整片的连续存储空间,但是在这一片空间内,各个逻辑上,相邻的数据元素也可以在物理上不相邻。各个元素之间的先后关系,这种逻辑关系是用呃这儿的游标,或者说数组下标来表示的。那在静态链表中,如果你要增加或者删除一个数据元素的话,你并不需要像顺序表那样大量的移动元素,你只需要修改相关节点的游标就可以。那静态链表和单链表一样,它也不能支持随机存取,每次只能从头结点,依次往后开始查找,另外还有一个缺点。静态链表,它的容量是固定的,不变的,只要你声明了一个静态链表,那么它所能存放的最大容量就已经被定死了。不可以拓展。所以静态链表现在用的相对来说少一些,就早期的一些不支持指针的低级语言会用静态链表,这样的方式实现和单链表同样的功能。另外,如果在你的这个应用场景当中,数据元素的数量几乎是固定不变的,在这种情况下,用静态链表还是比较合适的。比如说大家之后学习操作系统的文件管理那一章的时候,会学到一个东西,叫做文件分配表fat。
其实本质上fat它就是一个静态链表现在大家还没有学到这个地方,所以可以先在你的操作系统的课本上做一个笔记。等你学到文件管理的时候,可以再回头来体会一下静态链表,它到底有什么作用?好的,那以上就是这个小节的全部内容。
八 顺序表vs链表
各位同学大家好,在这个小节中,我们会对顺序表和链表相关的特性做一个对比,也算是对这个章节的内容做一个总结,那我们之前说过,当我们在聊起一个数据结构的时候,我们应该关注数据结构的三要素。也就是逻辑结构,物理结构,还有数据的运算,所以这个小节的复习我们依然会按照这样的一个思路来对相关的内容分别进行一个回忆。那最后我们还需要探讨在什么时候,我们应该使用顺序表,什么时候又应该使用链表好,首先来看数据元素的第一个要素逻辑结构。
8.1 逻辑结构
其实,不管是顺序表还是链表,它们在逻辑上看,其实都是线性结构的。也就是说,它们都属于线性表。各个数据元素之间有这样的一对一的关系
8.2存储结构
再来看第二个方面,存储结构顺序表是采用了这样顺序存储的方式,实现了这个线性表。那由于采用了顺序存储,并且各个数据元素的大小是相等的,因此我们只需要知道这个顺序表的起始地址,那么我们就可以立即找到第二个元素存放的位置。也就是说,顺序表拥有随机存取的特性,另一个方面,顺序表当中的各个节点只需要存储数据元素,本身不需要存储其他的融余信息。因此,顺序表的存储密度也会更高。另一个方面,顺序存储的这种呃存储结构要求系统给它分配一整片的连续的存储空间。所以在给顺序表分配空间的时候会比较不方便,并且如果我们想要改变顺序表的容量,也会很不方便。
而如果我们采用链表,也就是链式存储的方式来实现这种线性结构的话,那么由于各个节点可以离散的存放在不同的空间当中,所以我们每次要添加一个节点的时候,只需要用malloc函数,动态的申请一小片的空间就可以了,同时由于各个节点的存储空间不要求连续,因此改变容量也会方便一些,那链式存储带来的问题是当我们要找到第二个节点的时候,我们只能从第一个节点表头的这个节点开始依次往后寻找,所以链式存储是不可随机存取的。另外,由于各个节点当中除了存储数据元素之外,还需要花费一定的空间来存储这个指针。所以它的存储密度也会更低一些。那这就是顺序表和链表在存储结构方面的不同点
8.3 基本操作(数据结构的三要素)
好,接下来再来看数据结构的第三个要素,
也就是基本操作或者说基本运算,我们用之前提到的这种思路来依次回忆。对于任何一个数据结构的基本操作,最重要的无非就是这样的几种。创销增删改查。首先,你得知道如何初始化,也就是如何创建一个数据结构,还需要知道如何销毁一个数据结构,那如何销毁这个问题。其实,在考研当中考察的并不多,但是我们实际写代码的时候。销毁数据结构相关的基本操作肯定也是十分重要的,那在之前的课程内容当中,我们着重强调的是如何创建,如何初始化,然后如何增加一个元素,如何删除一个元素和如何查找一个元素。如何更改一个数据元素,其实很简单,只要你能查到,你想要更改的那个数据元素,那么要更改这个数据元素的值,无非就是一个赋值操作,所以增删改查的改,我们之前一直都没有聊。那销毁操作,我们之前强调的也不是特别多,一会儿我们会快速的带过一下,
好接下来我们按照这样的思路来依次回忆
8.3.1 问题22:首先来看一下,当我们创建,也就是当我们初始化一个顺序表。或者初始化一个链表的时候,需要做的事情有什么不同?
由于顺序表要求给它分配的是这样的一整片的连续空间。所以,当我们在初始化一个顺序表的时候,我们就需要给这个顺序表预分配大片的连续空间。那如果刚开始给它分配的空间太小,那我们之后想要拓展这个顺序表的长度会很不容易,而如果我们刚开始给它分配的空间过大的话,那么又会有大量的空间是长时间处于空闲的状态,也就是会导致内存资源的利用率不高,浪费内存这样的一个现象。
而对于链表来说,当我们在初始化,也就是当我们在创建一个链表的时候,其实只需要声明一个头指针,并且分配一个头结点所需要的空间就可以了,当然我们也可以让这个链表没有头结点,那无论是有头结点还是没有头结点,对于一个链表来说,当它之后想要拓展这个链表的容量的时候,其实很方便的。每次需要拓展的时候,只需要用malloc再申请一小片新的空间,然后再用指针的方式把它连到这个链表里面就可以了。所以对于存储容量的弹性或者说灵活性肯定是链表会更胜一筹,如果我们的顺序表是采用静态分配的方式实现的话,那么我们顺序表的这个容量就是不可更改的。而即便顺序表采用动态分配的方式来实现,虽然它的容量可以更改,但是更改它的容量也需要移动大量的元素,所以时间代价也会很高。因此,从顺序表和链表的创建,这个基本操作出发,我们可以联想到的是关于存储空间的灵活性方面的问题。在这个方面,显然链表是更胜一筹的
8.3.2 销毁操作
接下来要回忆的是,销毁操作,
先来看链表。其实我们在聊链表的基本操作的时候,我们是不是聊过如何删除链表当中的某一个节点?那如果要销毁一个链表的话,那无非就是把链表当中的各个节点都依次的删除,所以对链表的销毁操作,它的核心其实就是一个free函数。你可以写一个循环,然后依次扫描各个节点,把各个节点都给free掉,这样的话就可以把链表占用的这些节点空间都依次回收。
那对于顺序表来说,如果你觉得它之后没用了,
需要销毁,那首先你需要把它length值改为零,也就是表示这个顺序表当前已经变成了一个空表。那这一步操作只是在逻辑上把这个顺序表把它标记为了一个空表,但是顺序表它所占用的这片存储空间应该怎么回收呢?分两种情况,如果说你的顺序表是用静态分配的方式实现的话,那么也就意味着你的顺序表所占用的这片存储空间是你通过啊,声明一个静态数组的方式。来请求系统分配的,那在这种情况下,这片存储空间的回收是由系统自动进行的,当你定义的这个静态数组,它的生命周期结束之后,系统会自动的把这片空间给回收。
也就是说,如果你采用的是静态分配的方式,那么对于空间回收的问题,你是不需要管的,你只需要把lens的值改为零就可以了。那如果你采用的是动态分配的方式,也就是说你的这个动态数组是用malloc函数申请的一片空间。那在这种情况下,你就需要手动的把这片空间给free掉,由malloc函数申请的内存空间是属于内存当中的堆区。在堆区的内存空间不会由系统自动的回收,也就说在你实际写代码的时候啊,你的程序里边malloc和free这两个函数肯定是成对出现的。对于链表也是一样,任何一个节点,我们都是用malloc函数来申请的,所以当我们销毁这个链表的时候,也相应的需要对每一个节点执行free操作。
那什么叫内存中的堆区?什么叫内存中的栈区?这些我们现在暂时不展开,在这儿大家只需要记住这样一个结论,你用malloc申请的空间肯定需要你手动的free。而如果你用声明一个数组或者声明一个变量,这样的方式申请的内存空间会由系统自动的帮你完成回收工作,你不需要管。好,这是顺序表和链表的销毁操作
8.3.3那这是增删这两个基本操作
我们首先来看增加或者说插入一个数据元素和删除一个数据元素。那插入和删除这两个操作,它们之间的联系比较紧密,所以把它们放在一起来回忆,
首先来回忆一下顺序表的插入和删除,需要做的是什么事?那这就需要联系到它的存储特性,那由于顺序存储,这样的存储结构要求各个数据元素在内存里边是相邻的,并且是有序的。所以当我们在插入和删除一个数据元素的时候,都需要把我们此次插入的这个位置之后的那些元素都给后移或者前移,如果插入一个元素的话,那么就需要后移,如果删除一个元素。元素的话就需要前移,
而相比之下,对于链表的插入和删除就会更简单一些,我们只需要修改相应的指针就可以。不需要像顺序表那样大量的移动元素的存储位置,对于顺序表来说,插入和删除这两个操作的最坏时间复杂度和平均时间复杂度都是o(n)这个数量级。这个时间开销主要是来自于移动元素所需要的时间开销,那链表的插入和删除它的时间复杂度也是o(n),不过链表的这个时间开销主要是来自于查找目标元素。就是你需要从第一个元素开始,依次往后寻找,直到找到你想要插入的那个位置,或者找到你想要删除的那个数据结点。那从这个角度来看,
虽然说啊,顺序表和链表的插入删除它的时间复杂度都是o(n)这个数量级,但是考虑到有的时候我们的这个数据元素,它可能很大。比如说一个数据元素,它就占1M个字节,那么也许你移动这么多的数据,你就需要用十毫秒左右的时间。那如果要移动n个数据元素的话,那所花的这个时间代价其实还是很高的,而对于链表来说,通过一个节点找到下一个节点,这样的时间开销很显然要比这种移动大量的数据所带来的时间。开销要更短很多,比如说假设每往后找一个节点,只需要花一微秒的时间,那么即便往后找n个节点,所需要花费的时间很显然也远小于。移动元素所带来的时间开销,所以虽然说从大o表示法这样的角度来看,顺序表和链表的插入删除它们的时间复杂度都是o(n)这样的一个数量级。但是当我们结合考虑一些现实因素的时候,不难发现,其实对于插入一个数据元素或者删除一个数据元素这样的基本操作来说,链表的效率。肯定要比顺序表要高得多好,那这是增删这两个基本操作,
8.3.4 查找操作
接下来看的是查找操作,那我们在学习这个章节的时候查找操作,我们探讨过按位查找和按值查找。
对于顺序表来说,你想要找到某一个位序的元素,所存放的位置只需要o(1)的时间,复杂度,也就是说它具有随机存取的特性。而链表只能从第一个元素开始,依次往后查找,所以它的按位查找时间复杂度是o(n)这些数量级,那对于按值查找这些操作来说,如果顺序表当中各个数据元素的排列。本来就是无序的,那么我们就只能从第一个元素开始,依次往后对比,所以时间复杂度是o(n)这个数量级,而如果说这个顺序表中的元素,
它是有序的。那我们就可以用一些查找算法,比如说像折半查找这样的算法,可以在O(log n)这样的时间复杂度内就可以找到目标元素。那对于链表来说,无论它里面的这些数据元素是有序还是无序,当我们在进行按值查找的时候,都只能从第一个元素开始,依次往后遍历。所以,无论数据元素,它有序还是无序啊,在链表当中按值查找,肯定都是o(n)这样的时间复杂度。所以对于查找相关的操作肯定顺序表的效率会高很多,
8.4 什么时候使用顺序表。什么时候使用链表
好,那最后根据顺序表和链表各自不同的特性,我们就可以知道什么时候应该使用顺序表,什么时候应该使用链表。如果在你的应用场景当中,你的线性表表长难以估计,并且经常会使用插入和删除这样的基本操作的话,那很显然使用链表会让你更开心一些。比如你要开发一个小程序,这个小程序是要让奶茶店实现排队取号或者叫号,这样的功能。那你是不是很难估计你的这个店里边到底会有多少顾客来取号,同时当有一个新顾客取号的时候,你就需要增加一个数据元素。当一个顾客取到餐之后,你就需要删除一个数据元素,所以在这种应用场景下,使用链表肯定是很合适的。
那相反,如果你的应用场景当中,你的线性表的表长是可预估的,比较稳定的,并且查询操作会比较多,那你用顺序表的方式来实现,肯定会效率更高。比如说让你开发一个小程序,用于实现课堂上呃学生点名,这样的事情,那我们知道每一个班级的学生基本上就是固定的。在一个学期之内,基本上一个班里边有几个学生,这个事情是可以预估的,基本不会改变的。而你要实现课堂点名这样的功能的话,那很显然就是需要搜索或者说需要查询这个表里边各个数据元素嘛。那在这种情况下,你用顺序表来实现,肯定会有更好的效率
好的,那这个小节中我们用数据元素的三要素作为我们总体的思路地图,对顺序表和链表相关的内容进行了一个回顾和对比。当大家在考试的时候,遇到一些开放性的问题,其实也可以用这样的思路来让自己的这个答题逻辑更加清晰。现在有很多自主命题的学校都喜欢考察这种简答题,
并且分值还不低,所以如果你在考卷当中遇到一个题目,一个简答题,它占六分。那你答题的时候,这个逻辑的清晰度其实就很重要了,就比如说你可以像我们刚才说的那种,你可以先探讨一下逻辑结构。这个方面来看是怎么样的,然后存储结构方面来看是怎么样的,然后最后再探讨一些比较重要的呃,基本操作。它的实现效率又分别是什么样的?最终再得出结论,到底是顺序表好还是链表好?
那在这个地方,想给大家强调的就是这种框架性的思维。我们很多理科生其实很怕遇到这种开放式的问题,都不知道从哪儿答起。但是如果你的脑子里边有这样的一个。思路的导航地图,那么当你在面对这种开放式的问题的时候,你的答题思路就会比别人更清晰很多,你的分数肯定也会比你的对手更好。也不是说所有的这些东西都需要答上去,但是至少你可以根据这样的思路来回忆,来分析,然后你自己再来决定到底要把哪些内容给答上去。你的思路是清晰的,那除了答题有用之外,
这种框架性的思维其实也有助于大家自己回忆复习。好的,那第二章的学习到此结束,希望大家不要急着进入第三章的学习,先把我们的课后习题都给做一做。一定要把基础打牢好的,