单向链表与单向循环链表
目录
背景
链表在逻辑结构上是一个线性表,符合线性表的特征。数据在逻辑结构上看是排列在一起的,看上去是一条线。但是链表在物理结构中与顺序表不同,保存数据的区域并不都是连续的,甚至每一个都不是连续的,而是分散在内存各处。
在线性表的分类中,顺序表在末尾插入删除中已经足够快了,并且支持随机访问。那么,为什么还需要链表呢?
假设一个进程的虚拟地址空间中的堆区域的大小只有100M,而产生了四次分配,第一次分配了30M,第二次分配了20M,第三次分配了40M,第四次分配了10M,如下图所示:

假设所处30M和40M的业务还未执行结束,内存尚未被回收,还需要用到这两块内存。但是20M和10M的业务执行的很快,这两块的内存空间很快就被操作系统回收了,因此这两部分内存空间就可被其他业务所使用,但不排除这两块区域就是内存碎片。这两块区域的总内存大小是30M,此时业务代码需要分配一个线性表来存储25M数据,打算使用顺序表来实现。但是,可以利用这两块内存碎片吗?答案是不可以的,因为顺序表存储数据是要求数据元素与数据元素的地址是绝对连续的。但是,这两块内存空间的最大大小也才是20M,是无法分配一个顺序表的。这也是内存碎片带来的危害,它使得可用的内存空间分散,无法分配连续的内存空间。那么,利用这两块空间就无法使用线性表来进行存储数据了吗?答案就是使用链表来去实现。
在链表中,每个数据元素看作是一个结点,而每个结点是单独分配的,结点与结点之间的地址可以是不连续的,它不像数组,必须要求数据元素之间的地址必须是连续的。如下图,每个结点都有自己独立的地址,且每个结点之间的地址不连续,但是又出现一个问题,线性表的逻辑是线性的,这也不线性啊,而且,这个结点是如何下一个结点在哪里呢?

这就是链表中为什么数据元素不是一个单纯的数据,而是一个结点了, 是因为这个结点中不仅仅有要存储的数据,还有一个数据成员是专门保存下一个结点的地址的。使用结构体来表示就是
| |
data成员是用来保存用户数据的,而next成员是用来保存下一个结点的地址的,这样的话,每个结点之间就好似被线连在了一起,除第一个结点和最后一个结点之外,每个结点都有一个前驱和后继结点。因此,它也是一个线性表。而data区域也常被称为数据域,next区域也被称为指针域或者是地址域。
所以,在这种总内存大小足够,但是连续的内存大小不足的时候,使用链表来存储和管理数据是比较好的,正是借助了这种数据元素地址之间的不连续性。
单向链表
单向链表的特点:
每个结点除了数据域存储数据之外,还需要地址域(指针域)存储下一个结点的地址。因此通过一个结点只能找到下一个结点,无法找到上一个结点
尾结点的地址域存储的是一个nullptr
大概的图示如下:

一个结点的next区域存储的是下一个结点的地址,只有最后一个结点的next存储的是nullptr,表示无后继结点了,这也是判断一个单向链表结束的标志。
在常见的设计中,会有一个dummy结点,也被称为头结点,它不是链表中存储有效数据的结点,而是用于方便管理链表的。假设现在有一个指针,称为node_,若没有这个头结点的话,node_存储的就是链表中第一个结点的地址,若链表无结点,这个node_的值就是nullptr。但是若有这个头结点,node_存储的就是这个头结点的地址,而第一个有效结点的地址就存储在头结点的地址域中,若链表无结点,node_依然存储的是这个头结点的地址,只不过这个头结点的地址域存储的是nullptr。这就保证了无论链表是否为空,node_的值不为空,永远存储的是一个头结点的地址,指向头结点,在操作链表的时候或者执行一些算法的时候,还是非常方便的,而且链表的元素个数还可以存储在头结点的数据域中。
如下图所示:有头结点的情况下

单向链表的数据结构
为了方便起见,假设保存的数据的类型是一个int类型,主要还是看链表的一些常见操作。
| |
在SinglyLinkedList类中,有一个嵌套类型,这个嵌套类型Node就是链表中结点的类型。Node类型提供了一个整数类型参数的构造函数用于构建新的结点
在SinglyLinkedList类中,有一个数据成员,这个数据的值保存的就是头结点的地址,且这个头结点的数据域为0,地址域为nullptr。
构造函数中,使用初始化列表初始化head_的值,head_指向头结点的同时,它未插入新结点之前,它同时也是尾结点,因此,它的next指针域的值也为nullptr,要复合尾结点的特征
析构函数中,后面实现,主要是为了遍历整个单向链表,释放每个结点。
单向链表的尾插法O(n)
尾插法通俗理解就是:每次需要插入的新结点都放在整个链表的尾部,这个新结点称为新的尾部结点。
在进行尾插法之前,需要讨论两种情况:
链表中无结点
链表中有至少一个结点,甚至多个结点

在上述图中,head指向的是一个链表中的头结点。第一种情况就是只有一个头结点,无其他有效结点。第二种情况就是除了头结点之外,还有其他额外结点。
尾插法的核心就是找到整个链表的尾部结点,随后将尾部结点的指针域赋值为新结点的地址,这个时候新结点就成为了尾部结点。
对于第一种情况,尾部结点就是这个head。第二种情况下,尾部结点就是这个地址为0x200的结点。
那么,是如何找到一个链表中的尾结点呢?利用头结点和尾部结点的特征(地址域为nullptr),定义一个指针tail,先使得tail指向头结点,随后利用while循环,循环条件就是tail→next_ ≠ nullptr。若 == nullptr的话,那么此时tail指向的就是尾部结点,若≠nullptr的话,更改tail的值,使得tail的值等于下一个结点的地址,也就是tail = tail→next;
第一种情况:tail = head;此时tail→next == nullptr,那么这个头结点就相当于是一个尾部结点,就可以执行插入
第二种情况:tail= head;此时tail→next == 0x100,不等于nullptr,那么tail = tail→next,此时tail指向0x100,继续判断tail→next是否等于nullptr,此时tail→next == 0x200,还是不等于,那么继续执行tail = tail→next,此时tail指向0x200,继续判断tail→next是否等于nullptr,是等于nullptr的,那么将推出循环,此时tail指向的0x200就是尾部结点,就可以后续执行插入操作了。
具体的代码就是:
| |
在分析一下吧:
对于第一种情况:tail→next直接是等于nullptr的,那么这个循环体就不会执行,此时tail指向的头结点就是尾部结点,因此继续执行下面的代码,构建新的结点,同时将尾部结点的next_设置为new_node。
对于第二种情况,会进入循环体不断更新tail的值,直到tail→next_ == nullptr。
尾插法在当前链表的数据结构设计中的时间复杂度为O(n)。有时候,为了优化尾插法的时间复杂度,可在链表类中增加一个指向尾部结点的指针,利用空间换取时间吧。这时候尾插法的时间复杂度直接为O(1),因为尾部结点可在常数级别下找到。
单向链表的头插法O(1)
头插法的通俗理解:每次插入的新结点都在头结点和当前链表中第一个有效结点之间,那么插入完成之后,这个新结点就成为了链表中第一个有效结点,而原来的第一个有效结点成为了第二个有效结点。
在进行写代码之前,还是需要讨论两种情况:
当前链表中无有效结点,只有头结点
当前链表中除头结点以外,还有至少一个有效结点

与尾插法时讨论的情况相同,但是后续的算法思路却是不一样的,尾插法的核心在于找到尾部结点,时间复杂度最坏是O(N),最好是O(1)。
但是头插法时不需要找到任何特殊结点,只需要拿到指向头结点的指针head即可,在这链表数据结构中是天然存在的。
对于第一种情况:链表中除头结点之外无有效结点,而head指向的就是头结点,因此,创建新的结点,直接将head→next的值赋值为新结点的地址即可,但是要注意,此时新结点是链表中的第一个有效结点,也是最后一个结点,那么最后一个结点的特征就是next指针域为nullptr,所以还需要对其进行赋值为nullptr。
对于第二种情况,复杂了一点点。链表之间的结点是链接在一起的,你若将新的结点插入到头结点和当前链表的第一个有效结点之间的话,你需要先将新结点的next指针域赋值为当前链表的第一个有效结点的地址,这个地址值就存储在头结点的next指针域,因此需要先执行new_node→next = head→next;此时新结点与当前链表第一个有效结点链接起来了,那么此时就需要断开头结点与当前链表第一个有效结点的链接,并建立与新结点的链接,那么此时就需要将head指向的头结点的next指针域赋值为新结点的地址,再次执行head→next = new_node;若1和2的操作颠倒的话,那么就会丢失链表中原来第一个有效结点的地址值。
看以下图示:

看一下具体的接口实现:
| |
但可以再对第一种情况进行分析,看以下图示:

在头结点中,next指针域的值本来就是nullptr,而新结点的next指针域的值在当前的情况下,也一定是nullptr,因此,第二种情况下的第一个操作符合当前第一种情况。而头结点的next指针域的值就是new_node的地址,也符合第二种情况的操作,所以可将这两种情况合成一个。
| |
头插法的时间复杂度为O(1)。因为不需要为了找特殊的结点而遍历链表,只需要进行简单的赋值操作即可
测试头插法和尾插法
实现的«运算符重载函数
| |
值得注意的是:在这个函数的实现中,p一开始的指向是第一个有效结点,后续的判断中,依赖p去与nullptr去做判断,是判断当前这个有效结点是否有效,若无效,则退出循环。而在尾插法的实现中,循环的条件是带上next,这个是不同的思路,尾插法中是为了寻找尾结点,而尾结点的特征就是当前结点的next指针域为nullptr。而输出链表结点数据的实现里,要求的每一个有效结点有效,就是不为nullptr
测尾插法
测试
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17void test_insert_tail() { // 创建一个链表 SinglyLinkedList sll{}; srand(time(nullptr)); // 头插法插入10个随机数据 for (int i = 0; i < 10; ++i) { int val = rand() % 100 + 1; sll.insertTail(val); std::cout << val << " "; } std::cout << "\n"; // 输出链表的数据 std::cout << sll << std::endl; }查看输出结果
1 283 89 5 17 81 25 40 8 88 70 83 89 5 17 81 25 40 8 88 70
测试头插法
测试实现代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17void test_insert_head() { // 创建一个链表 SinglyLinkedList sll{}; srand(time(nullptr)); // 头插法插入10个随机数据 for (int i = 0; i < 10; ++i) { int val = rand() % 100 + 1; sll.insertHead(val); std::cout << val << " "; } std::cout << "\n"; // 输出链表的数据 std::cout << sll << std::endl; }查看输出结果
1 294 74 52 76 56 8 41 7 25 42 42 25 7 41 8 56 76 52 74 94
单向链表的按值删除O(n)
在一个链表中删除指定数据的结点。也就是找到链表中数据域等于特定值的结点,随后将其删除,删除结点是简单,直接释放其所在的内存空间即可,这个结点的前一个结点和后一个结点怎么办呢?不能删除一个结点之后,就将其前后的结点断开啊,我们要做的就是将要删除的结点删除之后,还需要将被删除结点的前一个结点与后一个结点进行链接起来。看如下图示:

上图中,要删除值为40的结点,需要将地址为0x200的结点前一个结点的next指针域的值更新为地址为0x200的结点下一个结点(也就是地址为0x300)的地址。随后,再将其delete。首先,要做这两件事需要先做另外两件事:
由于这是单向链表,当前结点只能找到下一个结点,不可以找到前一个结点,因此需要有个指针,保存结点的上一个结点的地址
要delete这个结点,那么就需要一个指针保存这个结点的地址
我们可以定义两个指针,一个指针p用于遍历整个单向链表找到要删除的结点,另外一个指针q找到要删除的结点的前一个结点。看以下图示:

p的主要目标是找到要删除的结点,因此它的初始值一定是第一个有效结点的地址
q的目标是找到要删除的结点的前一个结点,它的初始值是头结点的地址。
循环终止的条件是p→next ≠ nullptr,这是因为若当前指向的有效结点为nullptr的时候终止循环。在循环体内判断当前p指向的有效结点的数据域的值是否为40:
若是40,则执行q→next = p→next;delete p;
若不是40,则q和p指针都需要移动:q = p; p = p→next;也就是q向前一步,也就是p的值,p也需要更新,指向下一个结点,也就是p = p→next。

实现代码:
| |
测试按值删除(删除单个值)
| |
查看输出结果:
| |
那么,留一个疑问,若要你删除单向链表中所有val的结点,该如何修改代码呢
- 一开始的思路是将上述代码中的break去掉,使得其重新执行整个代码的逻辑,但是,这里会出现一个问题,这里的p被delete之后,p当前的值对于当前程序来说是一个已经被释放的堆内存的地址值,是一个野指针,在下一次判断循环条件p ≠ nullptr,不为null,当进行p→data访问数据域的时候,会产生段错误,use-after-free了。

看上面这个图,p指向的结点被delete之后,p的值是无效的,若想循环体继续执行下去的话,需要重置p的值,由于q指向地址为0x100的结点,那么p此时应该被重置为0x300,但是p已经被释放了,访问不了p→next了。但是不要忘记了,q→next可是保存了0x300这个结点的,因此重置p,将p的值赋值为q→next即可。
| |
测试按值删除(删除多个相同的值)
| |
查看输出结果:
| |
单向链表的析构函数
析构函数的作用就是将一个链表中的所有结点全部释放,有两种思路:
一种是头结点不动,按照顺序删除第一个有效结点,因为每次删除第一个有效结点之后,会有新的结点成为新的有效结点,直到head→next == nullptr,最后删除头结点
一种是从头结点开始,依次删除每个结点,那么在删除一个结点之前,需要保存这个被删除结点的下一个结点,否则会丢失有效结点的信息,导致释放不全。可通过指向头结点的head来实现,不断的更改head的值,依次删除每个结点。每个结点都在成为头结点之后被删除
采用第二种方法,看以下图示:

初始时,定义一个新的指针p指向头结点,执行删除之前,需要保存head指向的结点的下一个结点,也就是p→next,可使用head进行保存,使其成为新的头结点,随后使用谁来删除这个旧的头结点呢,p还在保存旧头结点的地址值,可使用delete p删除。那么还需要重置p,以便继续删除新的头结点,p重新指向head指向的结点,p = head;
按照这个思路来看,直到head == nullptr的时候全部删除成功,那么循环的条件就是head ≠ nullptr。
| |
测试一下当前程序是否有内存泄漏,查看析构函数是否全部释放完毕。利用clang++的编译选项-fsanitize=address,undefined来判断是否有内存问题,比如堆栈溢出、head overflow、use-after-free等等。
在执行这个可执行程序之前,在macos平台上加上ASAN_OPTIONS=detect_leaks=1 ./a.out
| |
查看执行结果:
| |
单向链表的搜索
在当前链表中所有结点中搜索其数据域是否等于要查找的指定值,若找到则返回true,没找到则返回false。
遍历整个链表即可,时间复杂度为O(n)
| |
总结
其实在开头就描述过了,还是要简单的总结一下。
特点:每一个结点都是在堆内存上独立new出来的,结点内存不连续。内存利用率是非常高的,不需要大块的连续内存空间
插入
单独的去说插入这部分操作的时间复杂度为O(1)。
对于头插法的插入,无需进行任何的搜索,完成整个头插法的时间复杂度为O(1)。
对于尾插法,当前的链表数据结构中,只有一个头结点指针,无尾结点指针,因此,在插入之前,需要先遍历搜索到尾结点,在执行插入。遍历搜索的复杂度为O(1),插入的操作是O(1)。加在一起完成尾插法的时间复杂度为O(n)。
对于插入到其他位置,也是要分情况去讨论,若有尾结点的指针,则这个位置是头或者尾,那么整个时间复杂就是O(1);若是其他位置,则需要先进行遍历搜索,在插入,加在一起就是O(n)。
删除
单独的去说删除这部分的操作的时间复杂度为O(1)。
删除第一个有效结点:只需要进行指针之间的赋值操作,无需进行搜索,时间复杂度为O(1)。
删除其他位置上的结点:需要进行遍历搜索,再执行删除操作,加在一起的时间复杂度为O(n)。
则链表是不需要专门进行扩容操作的
缺点:内存占用量大,每一个结点需要额外分配空间存放其他结点的地址。每个结点所在的内存是不连续的,因此是无法进行随机访问的。且链表的搜索效率不高,只能从头结点开始逐结点遍历。
但其实在计算机世界,没有更好的方案,只有更适合的方案。一个链表的内存远比数组占用量大,但是它的内存利用率高。扩展一下分布式系统的CAP定理,一致性、分布性、分区容错性这三个基本条件,最多只能同时满足两个条件,不可能同时满足三个条件,因此没有最好的,只有最适合的,不同的场景有不同的设计方法。
链表虽然没有数组的内存占用量小,但是它也没有扩容带来的性能消耗。
因此,在通用的场景下:
对于有下标访问/随机访问多、搜索多可使用数组,虽然数组搜索是O(n),但是代码简单,而且若数组有序的话,使用二分搜索的话,时间复杂度为O(logn)。
对于增加、删除多可使用链表
不过具体的应用还需要根据具体的场景去分析,即使是链表的删除和增加,还需要根据位置去决定,还需要考虑插入删除之前是否有链表搜索。
单向循环链表
单向循环链表的特点:
每个结点中依然是两块区域,数据域存储数据,地址域存储下一个结点的地址。通过一个结点的地址域找到下一个结点,但无法回退到上一个结点。
尾结点的地址域不存储nullptr了,而是存储第一个有效结点的地址。(值得注意的是,若是链表中有头结点,那么尾结点指向的下一个结点就是头结点;若是无头结点,那么尾结点指向的下一个结点是第一个有效结点)
在上图中,无论是否有头结点,尾结点的next指针域存放的就是head的值。
单向循环链表的数据结构
每个结点的数据域中存储的数据类型还是假设为int,方便起见,当然它可以是任何类型,包括自定义类型。在单向循环链表的数据结构中,将添加另外一个数据成员,尾指针,这个指针指向链表中最后一个结点,而头指针指向链表中的头结点。
| |
数据成员多添加了一个尾指针指向尾结点。这使得尾插法和头插法的时间复杂度都是O(1)。
单向循环链表中的构造函数
在初始化列表中,head_指向new出来的头结点。而此时在整个链表中,只有一个头结点,也可看成是尾结点。那么tail也需要指向头结点,并且尾结点的特征就是next指向头结点,那么tail→next = head。
| |
初始化之后,链表的结构如下:
单向循环链表中的析构函数
循环释放链表中每个有效结点,包括头结点。
当前采用的方法是循环删除第一个有效结点,循环条件在单向循环链表中,就是p ≠ head_;最后head_还是指向头结点,直接delete head_删除即可
| |
单向循环链表的头插O(1)
在当前单向循环链表的数据结构中,分两种情况讨论插入,因为还需要考虑tail尾指针的情况。
若当前链表无有效结点,那么新插入的结点它就是尾结点,因为后续插入的结点位于第一个位置,tail必须要指向这个第一个插入的结点。而如何判断,这个插入的结点是第一个插入的头结点呢?先看一下执行插入的代码。new_node→next = head→next;head→next = new_node;一般new_node的next是指向下一个有效结点,而对于当前数据结构,在第一个的结点插入的时候,new_node→next = head→next,而head→next在初始化的时候,就是指向head的,因为构造函数中初始化的时候tail = head;tail→next = head;所以可利用这个条件来去判断。
若当前链表有有效结点,那么新插入的结点绝对不是第一个插入的了,因此直接执行插入即可,此时tail已经指向尾结点了。执行的插入代码是new_node→next = head→next;head→next = new_node;是不是和上述的代码一模一样?这就是构造函数中将头结点看作是尾结点的好处,不需要进行分支判断了。
| |
测试头插法
利用C++中生成随机数的头文件
第一个有效结点,每次插入都会变化
尾结点,在第一次插入之后,之后的每次插入都不会变化,因为是头插入,第一个插入的结点绝对是尾结点,不会变
| |
查看输出结果:
| |
单向循环链表的尾插法O(1)
由于单向循环数据结构中增加了tail_数据成员,且在构造函数中将头结点也看作是一个尾结点(tail_ = head_),那么无论此链表有没有有效结点,都可直接使用tail_数据成员执行插入,插入之后,更新tail_的指向,和尾结点中next的指向
| |
在增加了一个tail_数据成员之后,虽然内存空间增加了一些,但是省去了遍历寻找尾结点的时间,空间换取时间。
测试尾插法
利用C++中生成随机数的头文件
第一个有效结点的值在第一次插入之后不会发生变化,因为后续插入的结点都是在末尾插入
在每一次插入新结点,尾指针指向的结点的值会不断变化,
| |
查看输出结果:
| |
单向循环链表的按值删除单个结点O(n)
这个接口的主要作用是删除链表中第一个值为val的结点,后面若还有值为val的结点,则不删除。
在删除的时候,需要注意删除的这个结点是否是尾结点,因为若是尾结点,则需要更新tail的值。
| |
需要遍历整个链表中的有效结点。若找到值为val的结点, 则进行删除,但是删除的时候需要找到上一个结点。
p初始指向第一个有效结点,用于遍历整个链表。q初始指向头结点,也就是第一个有效结点的上一个结点,若p指向的结点中的数据域不等于val,则二者都要前进一步。
若p指向的结点的数据域的值为val,则进行删除,
但是依然需要判断当前删除的结点是否是尾结点,因为需要更新tail的值
测试按值删除单个结点
在下面的测试代码中,随机生成20个[1,10]之间的数字,目的是为了生成重复的数字
删除值为6的结点,并查看删除之后,其后的结点的为6是否被删除,这个接口下不该被删除
在尾部插入一个新结点,不在[1,10]范围之内,随后执行删除,目的是查看删除的结点是尾结点之后,tail的值是否更新
| |
查看输出结果:
| |
单向循环链表的按值删除全部结点
这个接口删除值为val的全部结点,同样在删除的时候需要注意删除的结点是否是尾结点,若是则需要更新tail
| |
依次遍历每个有效结点,查看结点的值是否等于val,若等于,则执行删除。q和p的指针的含义与remove接口是一样的
若删除的是尾结点,则需要更新tail值且不需要继续删除了,也就无需更新p的值,则退出循环。
测试按值删除全部结点
在下面的测试代码中,随机生成20个[1,10]之间的数字,目的是为了生成重复的数字
删除值为6的结点,并查看删除之后,其后的结点的为6是否被删除,这个接口下应该全部被删除
在尾部插入一个新结点,不在[1,10]范围之内,随后执行删除,目的是查看删除的结点是尾结点之后,tail的值是否更新
| |
查看输出结果:
| |