14 KiB
C++ 链表(一):讲义
一、 引言
1.1 问题引入
我们来回顾一下最熟悉的数据结构——数组。如果想记录一排学生的队伍,用数组 string names[50]; 来存,非常方便。数组的特点是什么?—— 内存中,所有元素都是“肩并着肩”挨在一起的,位置连续且固定。
这带来了好处:可以通过下标 names[i] 瞬间访问到任何一个学生,这叫随机访问。
但也带来了麻烦:
- 插入:如果一个新同学想插到队伍中间,那么他之后的所有同学都必须依次往后挪一个位置,工程浩大。
- 删除:如果一个同学离队,他之后的所有同学又得依次往前填补空位。
对于一个需要频繁增删成员的动态队伍来说,数组这种结构,显然不够灵活。
1.2 价值阐述
有没有一种数据结构,能像一个灵活的队伍,轻松地让成员加入或离开,而不需要大规模地挪动呢?这就是今天要学习的——链表。
链表是计算机科学中最基础也最重要的数据结构之一。它在内存中的存储方式,解决了数组在插入和删除操作上的低效问题。掌握链表,将能够:
- 理解一种全新的、非连续的数据组织方式。
- 高效地处理动态变化的数据集合。
- 为学习更高级的数据结构(如哈希表、树、图等)打下理论基础。
二、 核心思想
2.1 思路铺垫
想象一下,排好队的学生解散后,在大厅里随意站着,他们在大厅中的位置是不连续的。我们还能还原出原来的队伍顺序吗?
如果只知道他们的名字,显然不能。但如果在解散前,我们让每个同学都记住一件事——我后面站的是谁,情况就完全不同了。
只要我们还知道谁是第一个人(起点),我们就能:
- 找到第一个人。
- 问他:“你后面是谁?” -> 从而找到第二个人。
- 再问第二个人:“你后面是谁?” -> 从而找到第三个人。 ... 就这样,像一条线索,一个接一个,把整个队伍重新“串”起来。
这种数据在内存中可以分散存储,仅依靠一个“指向下一个元素”的信息来维持逻辑顺序的数据结构,就是链表。
2.2 链表的组成
一个链表由若干个**节点(Node)**串联而成。每个节点,就像队伍里的一个同学,包含两个核心信息:
- 数据域 (Data):节点本身存储的数据。例如,同学的编号、姓名等。
- 指针域 (Next):一个“指针”或“线索信息”,用于存储下一个节点的位置信息。
此外,我们还需要一个单独的变量 head,来记录整个链表的头节点(起点)。
2.3 步骤拆解:用 Next 数组模拟链表
在程序中,如何表示“某人后面是谁”这个信息呢?一种非常直观的方式是使用一个**Next数组**,这种方法也被称为静态链表。
- 规则:
Next[i] = j,其含义是:编号为i的节点,它的下一个节点是编号为j的节点。 - 结束标记:队伍的最后一个人后面没人了。我们需要一个特殊值来表示“链表的末尾”,通常使用
0或-1(因为节点编号通常从1开始)。
案例【例15-10】:
有4名同学,原始队列顺序是 1 -> 4 -> 3 -> 2。已知1号是头节点。
根据“我后面是谁”的规则,我们可以构建出 Next 数组:
- 1号后面是4号 =>
Next[1] = 4; - 4号后面是3号 =>
Next[4] = 3; - 3号后面是2号 =>
Next[3] = 2; - 2号后面没人了 =>
Next[2] = 0;(使用0作为结束标记)
这样,我们就用一个简单的数组,成功地在计算机中描述了这个手牵手的链式关系。
三、 实例:遍历链表
遍历是指从头节点开始,沿着“Next”指针,依次访问链表中的每一个节点,直到末尾。
3.1 例15-10 还原排队顺序
- 已知信息:
- 头节点
head = 1。 Next数组:Next[1]=4,Next[4]=3,Next[3]=2,Next[2]=0。
- 头节点
3.2 分步演示
- 【起点】:创建一个“当前位置”变量
current,让它从头节点出发。current = head;(即current = 1)。 - 【第1步】:
- 判断:
current不是终点 (1 != 0)。 - 访问:输出当前位置的编号
1。 - 前进:查询
Next[1],得到4。更新current的值为4。
- 判断:
- 【第2步】:查询
Next[4],得到3。更新current的值为3。(其他略) - 【第3步】:查询
Next[3],得到2。更新current的值为2。(其他略) - 【第4步】:
- 判断:
current不是终点 (2 != 0)。 - 访问:输出当前位置的编号
2。 - 前进:查询
Next[2],得到0。更新current的值为0。
- 判断:
- 【终点】:
- 判断:
current是终点 (0 == 0)。循环条件不满足,循环结束。
- 判断:
最终,我们按 1 4 3 2 的正确顺序输出了整个队伍。
四、 链表的遍历代码实现
4.1 while 循环实现
while 循环非常直观地体现了遍历的逻辑。
// 假设Next数组已构建好, head = 1
int current = head; // 1. 从头节点出发
while (current != 0) { // 2. 只要还没到终点
cout << current << " "; // 3. 访问当前节点
current = Next[current]; // 4. 移动到下一个节点
}
4.2 for 循环的实现
我们也可以用 for 循环来表达同样的过程,这种写法更紧凑,并将控制逻辑集中在一起。
// for (初始化; 循环条件; 循环后操作)
for (int current = head; current != 0; current = Next[current]) {
cout << current << " ";
}
- 初始化:
int current = head(从起点开始) - 循环条件:
current != 0(何时停止) - 循环后操作:
current = Next[current](如何去下一个节点)
这两种写法本质上完全一样,都实现了链表的遍历。
五、 核心操作一:插入
5.1 先连接新节点的后继,再修改前驱的后继
实例场景:
假设我们有一条 A -> B 的链路,现在新节点 X 想要插入在 A 和 B 之间,形成 A -> X -> B 的新逻辑顺序。
这个过程需要修改节点的后继关系。正确的步骤分为两步,且顺序很重要:
-
第一步:设置新节点X的后继
-
动作:将新节点
X的后继指针,指向节点A原本的后继B。 -
原因:节点
A的后继信息Next[A]是我们找到节点B的唯一信息。如果先修改Next[A],B的位置信息就会丢失,导致链表后半部分失联,所以要先设置新节点X的后继。 -
代码实现:
Next[X] = Next[A];
-
-
第二步:修改前驱节点A的后继
- 动作:现在
X已经连接到了链表的后半部分,可以放心地修改A的后继指针,使其指向新插入的节点X。 - 代码实现:
Next[A] = X;
- 动作:现在
总结:Next[X] = Next[A]; 和 Next[A] = X; 这两行代码,必须严格按照此顺序执行,以确保链表的完整性。
因为如果先把节点A的后继更新B。那么节点A原本的后继是B的这一关键信息就彻底丢失了。接下来需要设置新节点 X 的后继为 B,此时 B 节点就无法再找到了,因为如果节点 B 去查询节点 A 的后继,得到的结果变成了 B 自己,所以会导致无法执行 Next[X] = Next[A];这一步。
**六、 删除 **
6.1 核心思想:修改前驱节点的后继,直接指向被删节点的后继
实例:
假设我们有一条 A -> B -> C 的链路,现在需要从删除节点 B,形成 A -> C 的新顺序。
这个过程只需要一步关键操作:
-
修改A的后继
- 动作:将节点
A的后继指针,直接指向B的后继节点C。 - 分析:节点
A如何知道C的位置?通过B的后继信息Next[B]即可获得。 - 代码实现:
Next[A] = Next[B]; - 执行完这行代码后,节点
A绕过了B直接与C连接,节点B从逻辑上被成功删除,因为再也没有可能访问到节点B了。
- 动作:将节点
6.2 如何找到前驱节点?
上述删除操作看似简单,但它有一个前提:我们必须知道待删除节点 B 的前驱节点是 A。
在单向链表中,每个节点只存储了其后继节点的信息,并不知道其前驱是谁。这意味着,如果只给定删除节点B,我们无法直接执行 Next[A] = Next[B],因为我们不知道 A 在什么地方。
为了找到 A,我们只能从链表的头节点 head 开始,沿着后继指针逐个遍历,直到找到一个节点 p,满足 Next[p] == B。这个节点 p 就是我们需要的 A。这个查找过程,在最坏情况下需要遍历整个链表,时间复杂度为 O(N)。
这使得单向链表的删除操作,在其核心逻辑之外,增加了一个效率不高的查找步骤。
七、 双向链表
为了解决单向链表寻找前驱节点难的问题,我们对链表结构进行了一次升级。
7.1 记录前驱与后继的双向连接
定义:双向链表的每个节点,不再只有一个后继指针,而是拥有两个指针:
next指针:指向下一个节点。prev指针:指向前一个节点。
通过这种方式,每个节点都同时掌握了其前驱和后继的信息,数据在逻辑上形成了双向的链接。
7.2 双向链表的优势
- 高效查找前驱:对于任意一个节点
B,找到其前驱A不再需要遍历,只需一步A = B->prev即可,时间复杂度为 O(1)。 - 实现了双向遍历:我们既可以从头节点
head开始,通过next指针正向遍历链表;因为是双向的,我们自然还会定义并储存一个尾节点tail,那么就可以通过尾节点tail的prev指针反向遍历。 - 高效的删除:由于可以瞬间找到前驱和后继,双向链表的删除操作也变得极其高效。
双向链表删除节点B的逻辑:
- 让B的前驱A,跳过B,直接指向B的后继C:
A->next = C即B->prev->next = B->next。 - 让B的后继C,回头跳过B,直接指向B的前驱A:
C->prev = A即B->next->prev = B->prev。
完成这两步,节点 B 就从双向连接中删除了,整个过程的时间复杂度为 O(1)。
7.3 双向链表的插入逻辑
双向链表的插入操作同样高效,其核心在于要同时维护 next 和 prev 两个方向的链接。假设我们需要在节点 A 和其后继节点 B 之间,插入一个新节点 X,目标是形成 A <--> X <--> B 的新结构。
步骤一:将新节点 X 链接到其前驱和后继
首先设置新节点 X 自己的 prev 和 next 指针,让它正确地指向其前驱和后继。
- 设置
X的next指针:- 动作:将
X的next指针指向A原本的后继节点B。 - 代码实现:
X->next = A->next;
- 动作:将
- 设置
X的prev指针:- 动作:将
X的prev指针指向它的前驱节点A。 - 代码实现:
X->prev = A;
- 动作:将
完成这两步后,新节点 X 已经正确地连接到了链表中,但原有的 A 和 B 之间的链接还没有更新到 x。
步骤二:更新原有节点,使其指向新节点 X
接下来修改 A 和 B 的指针,让它们转而指向新节点 X。
-
更新后继节点
B的prev指针:- 动作:将
B的prev指针从指向A改为指向X。 - 代码实现:
A->next->prev = X;(即B->prev = X) - 注意:这一步必须在第4步之前执行,因为第4步会修改
A->next的指向。
- 动作:将
-
更新前驱节点
A的next指针:- 动作:将
A的next指针从指向B改为指向X。 - 代码实现:
A->next = X;
- 动作:将
整个过程因为不涉及数据移动,时间复杂度同样为 O(1)。
八、 总结
- 链表的核心是用一个指针(
Next信息)将物理上不连续的数据,在逻辑上串联起来。 - 遍历链表需要两个关键信息:头节点
head和Next关系网。 - 遍历的三要素是:起点(
current=head)、终点判断(current!=0) 和 前进方式(current=Next[current])。 - 链表的核心优势在于其插入和删除操作的高效性,这源于它通过修改指针(后继/前驱关系)而非移动数据来改变逻辑顺序。
- 插入操作的关键在于**“先连接新节点的后继,再修改前驱节点的后继”**,以确保链表的连续性。
- 单向链表的删除操作存在一个瓶颈:寻找待删节点的前驱节点是低效的 (O(N))。
- 双向链表通过增加一个
prev指针,以微小的空间开销,换来了O(1)的前驱查找能力,使得插入和删除操作在各种情况下都极为高效,是实际应用中更常用、更强大的链表形态。