Files
cpp-courseware/2.Course/1.深入浅出程序设计竞赛(基础篇)/3.简单数据结构/1.线性表/链表(一) 讲义.md
T

14 KiB
Raw Blame History

C++ 链表(一):讲义

一、 引言

1.1 问题引入

我们来回顾一下最熟悉的数据结构——数组。如果想记录一排学生的队伍,用数组 string names[50]; 来存,非常方便。数组的特点是什么?—— 内存中,所有元素都是“肩并着肩”挨在一起的,位置连续且固定。

这带来了好处:可以通过下标 names[i] 瞬间访问到任何一个学生,这叫随机访问。

但也带来了麻烦:

  • 插入:如果一个新同学想插到队伍中间,那么他之后的所有同学都必须依次往后挪一个位置,工程浩大。
  • 删除:如果一个同学离队,他之后的所有同学又得依次往前填补空位。

对于一个需要频繁增删成员的动态队伍来说,数组这种结构,显然不够灵活。

1.2 价值阐述

有没有一种数据结构,能像一个灵活的队伍,轻松地让成员加入或离开,而不需要大规模地挪动呢?这就是今天要学习的——链表。

链表是计算机科学中最基础也最重要的数据结构之一。它在内存中的存储方式,解决了数组在插入和删除操作上的低效问题。掌握链表,将能够:

  • 理解一种全新的、非连续的数据组织方式。
  • 高效地处理动态变化的数据集合。
  • 为学习更高级的数据结构(如哈希表、树、图等)打下理论基础。

二、 核心思想

2.1 思路铺垫

想象一下,排好队的学生解散后,在大厅里随意站着,他们在大厅中的位置是不连续的。我们还能还原出原来的队伍顺序吗?

如果只知道他们的名字,显然不能。但如果在解散前,我们让每个同学都记住一件事——我后面站的是谁,情况就完全不同了。

只要我们还知道谁是第一个人(起点),我们就能:

  1. 找到第一个人。
  2. 问他:“你后面是谁?” -> 从而找到第二个人。
  3. 再问第二个人:“你后面是谁?” -> 从而找到第三个人。 ... 就这样,像一条线索,一个接一个,把整个队伍重新“串”起来。

这种数据在内存中可以分散存储,仅依靠一个“指向下一个元素”的信息来维持逻辑顺序的数据结构,就是链表。

2.2 链表的组成

一个链表由若干个**节点(Node)**串联而成。每个节点,就像队伍里的一个同学,包含两个核心信息:

  1. 数据域 (Data):节点本身存储的数据。例如,同学的编号、姓名等。
  2. 指针域 (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 分步演示
  1. 【起点】:创建一个“当前位置”变量 current,让它从头节点出发。current = head; (即 current = 1)。
  2. 【第1步】:
    • 判断:current 不是终点 ( 1 != 0 )。
    • 访问:输出当前位置的编号 1。
    • 前进:查询 Next[1],得到 4。更新 current 的值为 4。
  3. 【第2步】:查询 Next[4],得到 3。更新 current 的值为 3。(其他略)
  4. 【第3步】:查询 Next[3],得到 2。更新 current 的值为 2。(其他略)
  5. 【第4步】:
    • 判断:current 不是终点 ( 2 != 0 )。
    • 访问:输出当前位置的编号 2。
    • 前进:查询 Next[2],得到 0。更新 current 的值为 0。
  6. 【终点】:
    • 判断: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 的新逻辑顺序。

image-20260705163944820

这个过程需要修改节点的后继关系。正确的步骤分为两步,且顺序很重要:

  1. 第一步:设置新节点X的后继

    • 动作:将新节点 X 的后继指针,指向节点 A 原本的后继 B。

    • 原因:节点 A 的后继信息 Next[A] 是我们找到节点 B 的唯一信息。如果先修改 Next[A],B 的位置信息就会丢失,导致链表后半部分失联,所以要先设置新节点X的后继。

    • 代码实现:

      Next[X] = Next[A];
      
  2. 第二步:修改前驱节点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 的新顺序。

image-20260705162706312

这个过程只需要一步关键操作:

  1. 修改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 记录前驱与后继的双向连接

定义:双向链表的每个节点,不再只有一个后继指针,而是拥有两个指针:

  1. next 指针:指向下一个节点。
  2. prev 指针:指向前一个节点。
image-20260705162740579

通过这种方式,每个节点都同时掌握了其前驱和后继的信息,数据在逻辑上形成了双向的链接。

7.2 双向链表的优势
  • 高效查找前驱:对于任意一个节点 B,找到其前驱 A 不再需要遍历,只需一步 A = B->prev 即可,时间复杂度为 O(1)。
  • 实现了双向遍历:我们既可以从头节点 head 开始,通过 next 指针正向遍历链表;因为是双向的,我们自然还会定义并储存一个尾节点 tail ,那么就可以通过尾节点 tail 的 prev 指针反向遍历。
  • 高效的删除:由于可以瞬间找到前驱和后继,双向链表的删除操作也变得极其高效。

双向链表删除节点B的逻辑:

  1. 让B的前驱A,跳过B,直接指向B的后继C:A->next = C 即 B->prev->next = B->next。
  2. 让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 的新结构。

image-20260705163927459

步骤一:将新节点 X 链接到其前驱和后继

首先设置新节点 X 自己的 prev 和 next 指针,让它正确地指向其前驱和后继。

  1. 设置 X 的 next 指针:
    • 动作:将 X 的 next 指针指向 A 原本的后继节点 B。
    • 代码实现:X->next = A->next;
  2. 设置 X 的 prev 指针:
    • 动作:将 X 的 prev 指针指向它的前驱节点 A。
    • 代码实现:X->prev = A;

完成这两步后,新节点 X 已经正确地连接到了链表中,但原有的 A 和 B 之间的链接还没有更新到 x。

步骤二:更新原有节点,使其指向新节点 X

接下来修改 A 和 B 的指针,让它们转而指向新节点 X。

  1. 更新后继节点 B 的 prev 指针:

    • 动作:将 B 的 prev 指针从指向 A 改为指向 X。
    • 代码实现:A->next->prev = X; (即 B->prev = X)
    • 注意:这一步必须在第4步之前执行,因为第4步会修改 A->next 的指向。
  2. 更新前驱节点 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)的前驱查找能力,使得插入和删除操作在各种情况下都极为高效,是实际应用中更常用、更强大的链表形态。