想象你第一次坐绿皮火车。站台上看过去,车厢一节接一节,从车头到车尾排成一条直线。有意思的是,淡季时车次会摘掉几节车厢,旺季时又会加挂几节——无论怎么增减,车厢之间始终保持着前后顺序,任何一节车厢都可以独立地接上或卸下,丝毫不影响其他车厢。再想想超市的储物柜:一排柜子整整齐齐地靠墙排列,你存东西时按号找格,柜子之间紧挨着,物理位置完全连续。
这两个场景,恰好对应了 C 语言里两种最重要的线性存储结构:链表和顺序表。火车车厢是"松散的连接"——靠挂钩把一节节独立车厢串起来;储物柜是"紧挨着的排布"——一块连续的区域从头排到尾。你会看到,它们各有各的长处,也各有各的软肋。
线性表:n 个相同特性元素的有限序列
先退一步,看看这两个结构共同的"父亲"——线性表。
线性表(linear list)是 n 个具有相同特性的数据元素的有限序列。 这句话每个词都值得拆开来看:
- 相同特性:一个线性表里所有元素必须是同一类型。你不可能在同一个线性表里既存整数又存字符串,就像一节车厢里不能一半装煤炭一半装乘客(客运车厢和货运车厢是两种"特性")。
- 有限序列:元素个数 n 是有限的,且元素之间有严格的先后顺序——"第一个、第二个、第三个"这个次序是结构本身的一部分。
- n 可以为 0:空表也是合法的线性表,就像一趟没挂车厢的空车头。
线性表在实际中应用极广,常见的就有:顺序表、链表、栈、队列、字符串……它们都是线性表,只是各自的约束和操作方式不同。
那"线性"到底是什么意思?它指的是逻辑上是一条直线——每个元素最多有一个直接前驱、一个直接后继,整个序列可以画成一条线:
a1 -> a2 -> a3 -> ... -> an
注意,这句话说的是"逻辑上",物理存储上并不一定连续。线性表在内存里通常有两种存储方式:
- 数组(顺序存储):一块连续的内存,元素紧挨着放——对应储物柜。
- 链式存储:元素散落在内存各处,靠指针串联——对应火车车厢。
这两种方式就是本文的主角。先看第一种。
顺序表:概念与结构
顺序表是用一段物理地址连续的存储单元依次存储数据元素的线性结构,一般情况下采用数组存储。
顺序表的底层就是数组。它和"裸数组"的区别,是第一个要弄清楚的问题:
- 数组是 C 语言的内置类型,大小在编译期就定死了,你只能用下标访问它,越界访问编译器都不一定拦得住。
- 顺序表是在数组之上做了一层封装,实现了常用的增、删、改、查接口,并且自己维护"现在有几个有效数据"、"容量还剩多少"这些信息,还能自动扩容。
一句话:数组是顺序表的底层,顺序表是对数组的封装。
| 对比维度 | 数组 | 顺序表 |
|---|---|---|
| 本质 | 语言内置类型 | 自定义结构体 + 接口 |
| 大小 | 编译期定死,不可变 | 动态增长(自动扩容) |
| 操作 | 只有下标访问 | 增删改查全套接口 |
| 有效性 | 不记录元素个数 | size 记录有效元素个数 |
顺序表的分类:静态与动态
顺序表按存储方式分两类:静态顺序表和动态顺序表。
静态顺序表用定长数组存储元素:
#define MAX_SIZE 100
// 静态顺序表:定长数组 + 有效个数
typedef struct SeqList
{
int a[MAX_SIZE]; // 定长数组,容量写死
int size; // 有效数据个数
} SeqList;它的缺陷非常直观:空间给少了不够用,给多了造成浪费。定 100 个,用户偏要存 200 个,程序直接崩;定 10000 个,用户只存 3 个,白占 40KB 内存。无论是"不够用"还是"浪费",都让人难受。
动态顺序表则按需申请空间:满了就扩容,用多少申请多少。这也是实际开发中真正用的东西。接下来完整实现它。
动态顺序表的实现
先定义结构体。它需要三个成员:
a:指向动态申请的数组空间的指针(回顾一下:malloc 返回的是指针,我们把它存到结构体里,通过ps->a[i]就能像数组一样访问);size:当前有效数据个数;capacity:当前空间容量。
// SeqList.h
#pragma once
#include <stdio.h>
#include <stdlib.h>
#define INIT_CAPACITY 4
typedef int SLDataType; // 数据类型的"统一出口",想存别的类型改这一处
// 动态顺序表 -- 按需申请
typedef struct SeqList
{
SLDataType* a; // 指向动态申请的数组空间
int size; // 有效数据个数
int capacity; // 空间容量
} SL;
// 初始化和销毁
void SLInit(SL* ps);
void SLDestroy(SL* ps);
void SLPrint(SL* ps);
// 扩容
void SLCheckCapacity(SL* ps);
// 头部插入删除 / 尾部插入删除
void SLPushBack(SL* ps, SLDataType x);
void SLPopBack(SL* ps);
void SLPushFront(SL* ps, SLDataType x);
void SLPopFront(SL* ps);
// 指定位置之前插入 / 删除数据
void SLInsert(SL* ps, int pos, SLDataType x);
void SLErase(SL* ps, int pos);
// 查找
int SLFind(SL* ps, SLDataType x);你可能注意到所有接口都传 SL* 指针。为什么不能直接传 SL 值?因为 C 语言函数传参是值传递,传 SL 进去,函数里改的是副本,外面的结构体纹丝不动。传指针才能通过 ps->xxx 真正修改结构体的成员——这和之前学指针时"要修改实参就传地址"是同一个道理。
这里的 `SLDataType` 用 `typedef` 起了别名。以后想让顺序表存 `double`,只需要改这一行,其余代码全部不用动——这就是别名的威力。
初始化、销毁、打印这三件套:
// SeqList.c
#include "SeqList.h"
// 初始化:一个元素都没有,空间也还没申请
void SLInit(SL* ps)
{
ps->a = NULL;
ps->size = 0;
ps->capacity = 0;
}
// 销毁:释放动态内存,防止内存泄漏
void SLDestroy(SL* ps)
{
free(ps->a); // 释放动态申请的数组
ps->a = NULL; // 置空,防止野指针
ps->size = 0;
ps->capacity = 0;
}
// 打印:遍历输出所有有效数据
void SLPrint(SL* ps)
{
for (int i = 0; i < ps->size; i++)
{
printf("%d ", ps->a[i]);
}
printf("\n");
}扩容是动态顺序表的核心。什么时候需要扩容?size == capacity 的时候——空间满了。扩容策略采用 2 倍增长:新容量 = 旧容量 × 2。这里最容易踩的坑是 realloc 失败:它返回 NULL 时,如果直接写 ps->a = realloc(...),会把原来的指针弄丢,内存泄漏且程序崩溃。所以必须先用临时变量接收返回值,判断成功后再赋值:
// 检查容量:满了就扩容,采用 2 倍策略
void SLCheckCapacity(SL* ps)
{
if (ps->size == ps->capacity) // 满了才扩容
{
// 新容量:旧容量为 0(刚初始化)时先给 INIT_CAPACITY,否则翻倍
int newCapacity = (ps->capacity == 0) ? INIT_CAPACITY : ps->capacity * 2;
SLDataType* tmp = (SLDataType*)realloc(ps->a, newCapacity * sizeof(SLDataType));
if (tmp == NULL) // realloc 失败返回 NULL,不能直接覆盖 ps->a
{
perror("realloc fail");
exit(1);
}
ps->a = tmp; // 扩容成功,更新指针
ps->capacity = newCapacity; // 更新容量
}
}realloc 扩容时有两种情况:原地扩容(后面有足够空间)和异地扩容(重新找一块更大的空间、把数据搬过去、释放旧空间)。无论哪种,对使用者来说接口不变——你拿到的还是那块"更大的数组"。
尾插尾删是四个接口里最简单的,因为数据都在"尾部",不需要搬动任何元素:
// 尾插:新数据放在最后面
void SLPushBack(SL* ps, SLDataType x)
{
SLCheckCapacity(ps); // 先确保有空间
ps->a[ps->size] = x; // 新数据放在最后一个有效位置之后
ps->size++;
}
// 尾删:直接把有效个数减一即可
void SLPopBack(SL* ps)
{
if (ps->size == 0) // 边界:空表不能删
return;
ps->size--; // 逻辑上"看不见"最后一个元素了
}尾删为什么不用真的把数据清掉?因为 `size` 才是"有效数据"的边界,`size--` 之后,最后一个元素虽然在内存里还在,但任何接口都不会再碰它,下次尾插会直接覆盖它。用边界管理数据,是顺序表最重要的设计思想。
头插头删就没这么便宜了——头部是个"插队"的位置,所有元素都得往后挪一位(或者往前挪一位):
// 头插:所有数据后移一位,再把新数据放到最前面
void SLPushFront(SL* ps, SLDataType x)
{
SLCheckCapacity(ps);
int end = ps->size - 1; // 从最后一个元素开始往后搬
while (end >= 0)
{
ps->a[end + 1] = ps->a[end];// 每个元素搬到它的后一个位置
end--;
}
ps->a[0] = x; // 腾出位置 0,放入新数据
ps->size++;
}
// 头删:所有数据前移一位,覆盖掉第一个元素
void SLPopFront(SL* ps)
{
if (ps->size == 0)
return;
int begin = 0;
while (begin < ps->size - 1)
{
ps->a[begin] = ps->a[begin + 1]; // 每个元素往前挪一位
begin++;
}
ps->size--;
}指定位置的插入删除,是头插头删的推广:pos 是下标,在 pos 之前插入,就是把 pos 及其后面的元素整体后移;删除 pos,就是把 pos 后面的元素整体前移:
// 在 pos 位置之前插入数据(pos 是下标)
void SLInsert(SL* ps, int pos, SLDataType x)
{
if (pos < 0 || pos > ps->size) // 合法范围:[0, size],pos == size 等价于尾插
return;
SLCheckCapacity(ps);
int end = ps->size - 1;
while (end >= pos) // 从后往前搬,避免覆盖
{
ps->a[end + 1] = ps->a[end];
end--;
}
ps->a[pos] = x; // 空出来的位置放新数据
ps->size++;
}
// 删除 pos 位置的数据
void SLErase(SL* ps, int pos)
{
if (pos < 0 || pos >= ps->size) // 越界直接返回
return;
int begin = pos;
while (begin < ps->size - 1) // 从前往后搬,覆盖掉 pos 位置
{
ps->a[begin] = ps->a[begin + 1];
begin++;
}
ps->size--;
}
// 查找:找到返回下标,找不到返回 -1
int SLFind(SL* ps, SLDataType x)
{
for (int i = 0; i < ps->size; i++)
{
if (ps->a[i] == x)
return i;
}
return -1;
}写完了接口,一定要勤测试:每写完一个函数就立刻测试,不要憋到最后一次性测试。等代码堆到几百行再排错,问题定位起来非常痛苦——你不知道是刚写的插入有问题,还是三天前的初始化埋了雷。增量式开发、增量式验证,是写数据结构代码的黄金法则。
编写代码过程中要勤测试,避免写出大量代码后再测试而导致出现问题,问题定位无从下手。
下面是一份完整的测试程序,每个接口测一行,打印结果对照注释:
// Test.c
#include "SeqList.h"
int main()
{
SL s;
SLInit(&s);
SLPushBack(&s, 1);
SLPushBack(&s, 2);
SLPushBack(&s, 3);
SLPushBack(&s, 4);
SLPrint(&s); // 1 2 3 4
SLPushBack(&s, 5); // 容量 4 满了,触发扩容
SLPrint(&s); // 1 2 3 4 5
SLPopBack(&s);
SLPrint(&s); // 1 2 3 4
SLPushFront(&s, 0);
SLPrint(&s); // 0 1 2 3 4
SLPopFront(&s);
SLPrint(&s); // 1 2 3 4
SLInsert(&s, 2, 99); // 在下标 2 之前插入 99
SLPrint(&s); // 1 2 99 3 4
SLErase(&s, 2); // 删除下标 2 的元素
SLPrint(&s); // 1 2 3 4
int pos = SLFind(&s, 3);
printf("3 的位置是 %d\n", pos); // 2
SLDestroy(&s); // 用完释放,养成好习惯
return 0;
}补充:2 倍扩容为什么均摊下来是 O(1)
细心的同学可能会问:扩容时明明要把旧数据全部拷贝一遍,是 O(N) 的操作,怎么能说尾插是 O(1) 呢?答案是均摊——把偶尔一次的大开销,摊到前面的多次廉价操作上。
假设容量从 4 开始、每次翻倍,连续尾插 N 个元素。扩容发生的时刻是容量刚满时:容量 4 满了扩到 8(拷贝 4 个),8 满了扩到 16(拷贝 8 个),16 满了扩到 32(拷贝 16 个)……把历次扩容的拷贝量加起来:
总拷贝量 = 4 + 8 + 16 + ... + 2^k (k ≈ log₂N)
≈ 2^(k+1) - 4
≈ 2N - 4 ← 等比数列求和,最大的那一项已经接近 2NN 次尾插的总代价是 O(N),均摊到每一次尾插就是 O(1)——即使某一次扩容瞬间是 O(N),但"把线拉长"看,平均每次都是常数。反过来,如果每次扩容只加 1 个容量,那么扩容要触发 N 次,总拷贝量是 1 + 2 + 3 + ... + N = N(N-1)/2 = O(N²),均摊下来每次尾插都是 O(N),那动态顺序表就彻底失去意义了。这就是"成倍扩容"这个看似随意的策略背后的数学:总代价是等比数列求和,而不是等差数列求和。
这个"均摊分析"思想在后面无处不在:栈的扩容、哈希表的再哈希、双栈实现队列……凡是"偶尔贵一次、整体不贵"的操作,都值得用均摊的视角去看。另外,C++ 标准库 `vector` 用的是 1.5 倍而不是 2 倍扩容——2 倍扩容均摊最优(O(1)),但"上次扩容后可能有一半空间闲着";1.5 倍在"均摊代价"和"空间利用率"之间取了折中。理解了这个权衡,你就看懂了动态数组设计里最难的一角。
顺序表算法题
顺序表(数组)上有一批经典算法题,它们考察的核心往往就是双指针——一个指针负责遍历,另一个指针负责记录"新数组"该写到哪里。看三道最典型的。
第一道:移除元素(LeetCode 27)。题目要求原地移除所有值等于 val 的元素,并返回新长度。思路:src 负责扫描原数组,dst 指向新数组的下一个写入位置。遇到不等于 val 的元素,就把它搬到 dst 处;等于 val 的直接跳过——它就被"移除"了:
// LeetCode 27. 移除元素:原地移除所有值等于 val 的元素
// 思路:双指针,src 遍历,dst 记录保留元素该放的位置
int removeElement(int* nums, int numsSize, int val)
{
int src = 0; // 快指针:遍历原数组
int dst = 0; // 慢指针:保留元素的位置
while (src < numsSize)
{
if (nums[src] != val) // 找到要保留的元素
{
nums[dst] = nums[src]; // 往前搬
dst++;
}
src++;
}
return dst; // dst 就是新数组的长度
}第二道:删除有序数组中的重复项(LeetCode 26)。数组已有序,重复元素必相邻。dst 指向"已去重区域的最后一个元素",src 向后扫描,每当发现 nums[src] 和 nums[dst] 不同,说明找到了新元素,把它放到 dst + 1 处:
// LeetCode 26. 删除有序数组中的重复项
// 思路:有序 => 重复元素相邻,双指针原地去重
int removeDuplicates(int* nums, int numsSize)
{
if (numsSize == 0) // 空数组直接返回
return 0;
int dst = 0; // 已去重区域的最后一个下标
for (int src = 1; src < numsSize; src++)
{
if (nums[src] != nums[dst])// 遇到新元素
{
dst++;
nums[dst] = nums[src]; // 放到去重区域末尾
}
}
return dst + 1; // 长度 = 最后一个保留元素下标 + 1
}第三道:合并两个有序数组(LeetCode 88)。把两个有序数组合并成一个有序数组。最容易想到的是"从头开始比",但 nums1 的有效数据占着前面,从前往后写会覆盖还没比较的元素。解法是从后往前归并——两个数组的末尾都比,大的放 nums1 的最末尾,因为末尾是空的,不会覆盖任何还没处理的数据:
// LeetCode 88. 合并两个有序数组
// 思路:从后往前归并,避免覆盖 nums1 中尚未处理的有效数据
void merge(int* nums1, int nums1Size, int m,
int* nums2, int nums2Size, int n)
{
int end1 = m - 1; // nums1 有效数据的最后一个位置
int end2 = n - 1; // nums2 的最后一个位置
int end = m + n - 1; // 合并后数组的最后一个位置
while (end1 >= 0 && end2 >= 0) // 两边都还有数据时
{
if (nums1[end1] > nums2[end2]) // 谁大谁放后面
nums1[end--] = nums1[end1--];
else
nums1[end--] = nums2[end2--];
}
// 如果 nums2 还有剩余,全部搬到前面
// (nums1 还有剩余时不用管,它本来就在正确的位置)
while (end2 >= 0)
{
nums1[end--] = nums2[end2--];
}
}刷 OJ 题代码出错怎么办?把 OJ 代码复制到 VS 里,创建测试方法调用目标函数,用 VS 调试工具单步跟踪、监视变量,一步步看数据怎么变的,比盯着屏幕空想要高效得多。调试技能是学数据结构的基本功。
顺序表的问题与思考
顺序表实现完了,现在回头审视它的软肋。有三个问题比较突出:
- 中间/头部的插入删除,时间复杂度为 O(N)。头插要搬 N 个元素,中间插平均也要搬 N/2 个。插入删一次就要大动干戈,数据量大时非常吃力。
- 增容消耗大。扩容时要申请新空间、拷贝全部数据、释放旧空间,是一次不小的开销。而且这种消耗发生在"插入"这种高频操作里。
- 2 倍扩容必然浪费空间。例如当前容量为 100,满了以后增容到 200,我们继续插入了 5 个数据,后面不再插入了——那么就浪费了 95 个数据空间。容量翻倍是"预支",但实际使用往往填不满。
思考:如何解决以上问题呢?要是能像火车那样——要用车厢时挂一节,不用时摘一节,每节车厢都是独立的存在,不要求大家住在一排连续的房子里——那就既没有搬移,也没有浪费了。这正是链表的设计哲学,也是下一节的主角。
单链表:概念与结构
链表是一种物理存储结构上非连续、非顺序的存储结构,数据元素的逻辑顺序是通过链表中的指针链接次序实现的。
回想开头的火车:每节车厢独立存在,靠挂钩连接成"车列"。链表就是这套机制的内存版——每个元素是一个结点(node),结点之间用指针"挂钩"。
结点由两部分组成:
- 数据域:当前结点保存的数据;
- 指针域:保存下一个结点的地址。
在 C 语言里,结点就是一个结构体。这里要注意一个结构体的特殊用法——自引用:结构体成员里有一个指向"同类型结构体"的指针。写起来像这样(假设保存整型数据):
// 单链表结点:数据 + 指向下一个结点的指针
struct SListNode
{
int data; // 结点数据
struct SListNode* next; // 保存下一个结点的地址
};为什么 next 必须是 struct SListNode* 指针,而不能直接 struct SListNode next;?因为那样会无限嵌套——next 里又有 next,大小永远算不出来。指针的大小是固定的(32 位机器 4 字节,64 位机器 8 字节),所以用指针就可以。
当链表只有一个结点时,它的 next 保存的是 NULL——表示"后面没有车厢了"。整个链表的逻辑结构可以画成:
+------+------+ +------+------+ +------+------+
plist ->| data | next |----->| data | next |----->| data | next |
+------+------+ +------+------+ +------+------+ NULL
1 2 3
plist 是一个指针变量,保存第一个结点的地址,我们说 plist "指向"第一个结点。如果想让 plist 指向第二个结点,只需要把 plist 的内容改成第二个结点的地址(就像课件里改 plist 保存的内容为第二个结点的地址 0x0012FFA0)。要遍历链表,就沿着 next 一路走:cur = cur->next,走到 NULL 结束。
链表的性质
链表的三个性质要刻进脑子里:
- 链式结构在逻辑上是连续的,在物理结构上不一定连续。逻辑上,
1 -> 2 -> 3有严格的先后;物理上,这三个结点的地址可能隔着十万八千里。 - 结点一般是从堆上申请的——也就是用
malloc申请,用free释放。每次插入数据,才去申请一个结点;删除数据,就释放这个结点。这和顺序表"一次申请一大块"完全不同。 - 从堆上申请来的空间,是按照一定策略分配出来的,每次申请的空间可能连续,可能不连续。堆内存管理策略(空闲链表、伙伴系统等)决定了相邻两次
malloc的结果不一定挨着,所以链表结点物理上"随缘分布"很正常。
正是"每个结点独立申请、靠指针连接"这个特性,给了链表无与伦比的灵活性——插入删除只需改指针,不需要搬移任何数据。
单链表的实现
先给出完整的结构体和接口声明:
// SList.h
#pragma once
#include <stdio.h>
#include <stdlib.h>
typedef int SLTDataType;
typedef struct SListNode
{
SLTDataType data; // 结点数据
struct SListNode* next; // 保存下一个结点的地址
} SLTNode;
void SLTPrint(SLTNode* phead);
// 头部插入删除 / 尾部插入删除(注意:全部传二级指针!)
void SLTPushBack(SLTNode** pphead, SLTDataType x);
void SLTPushFront(SLTNode** pphead, SLTDataType x);
void SLTPopBack(SLTNode** pphead);
void SLTPopFront(SLTNode** pphead);
// 查找
SLTNode* SLTFind(SLTNode* phead, SLTDataType x);
// 在指定位置之前插入数据
void SLTInsert(SLTNode** pphead, SLTNode* pos, SLTDataType x);
// 删除 pos 结点
void SLTErase(SLTNode** pphead, SLTNode* pos);
// 在指定位置之后插入数据
void SLTInsertAfter(SLTNode* pos, SLTDataType x);
// 删除 pos 之后的结点
void SLTEraseAfter(SLTNode* pos);
// 销毁链表
void SListDestroy(SLTNode** pphead);打印和申请结点:
// SList.c
#include "SList.h"
// 打印:从头结点开始,沿着 next 一路走到底
void SLTPrint(SLTNode* phead)
{
SLTNode* cur = phead;
while (cur != NULL)
{
printf("%d -> ", cur->data);
cur = cur->next; // 走到下一个结点
}
printf("NULL\n");
}
// 申请一个新结点:每次插入之前都要先申请
SLTNode* BuySLTNode(SLTDataType x)
{
SLTNode* newnode = (SLTNode*)malloc(sizeof(SLTNode));
if (newnode == NULL)
{
perror("malloc fail");
exit(1);
}
newnode->data = x;
newnode->next = NULL;
return newnode;
}插入删除可能会改变头指针 `plist` 本身的值(比如链表为空时尾插,`plist` 从 `NULL` 变成新结点的地址;头插时 `plist` 变成新结点的地址)。函数形参是实参的拷贝——传一级指针 `SLTNode* phead`,函数里改的是形参副本,外面的 `plist` 纹丝不动。只有传**指针的地址**(二级指针 `SLTNode** pphead`),才能通过 `*pphead` 修改外面那个指针变量。凡是"可能要改头指针"的接口,一律用二级指针。
尾插和头插:
// 尾插:新结点挂在链表末尾
void SLTPushBack(SLTNode** pphead, SLTDataType x)
{
SLTNode* newnode = BuySLTNode(x);
// 情况一:链表为空,新结点直接成为头结点(必须改头指针!)
if (*pphead == NULL)
{
*pphead = newnode;
return;
}
// 情况二:链表非空,找到最后一个结点,让它的 next 指向新结点
SLTNode* tail = *pphead;
while (tail->next != NULL) // tail 走到最后一个结点
{
tail = tail->next;
}
tail->next = newnode; // 挂上去
}
// 头插:新结点永远插在最前面,头指针指向新结点
void SLTPushFront(SLTNode** pphead, SLTDataType x)
{
SLTNode* newnode = BuySLTNode(x);
newnode->next = *pphead; // 新结点指向原来的头结点
*pphead = newnode; // 头指针指向新结点
}头插的过程用图来看非常清晰(新结点 n 插到原头 a 之前):
插入前: plist -> a -> b -> NULL
插入后: plist -> n -> a -> b -> NULL
步骤: ① n->next = a(让 n 指向原头)
② plist = n(让头指针指向 n)
尾插时空链表的处理是典型的边界情况——空链表没有"最后一个结点",tail = *pphead 就是 NULL,tail->next 会解引用空指针直接崩溃。所以必须先单独判断 *pphead == NULL。这正是初学者最容易翻车的地方。
头删尾删,注意空链表和单结点的边界:
// 尾删:删除最后一个结点
void SLTPopBack(SLTNode** pphead)
{
// 边界一:空链表,什么都不做
if (*pphead == NULL)
return;
// 边界二:只有一个结点,删除后头指针要置空
if ((*pphead)->next == NULL)
{
free(*pphead);
*pphead = NULL;
return;
}
// 一般情况:找到倒数第二个结点
SLTNode* prev = NULL; // 记录 tail 的前一个
SLTNode* tail = *pphead;
while (tail->next != NULL)
{
prev = tail;
tail = tail->next;
}
free(tail); // 释放最后一个结点
prev->next = NULL; // 倒数第二个的 next 置空,链表闭合
}
// 头删:删除第一个结点
void SLTPopFront(SLTNode** pphead)
{
if (*pphead == NULL) // 空链表
return;
SLTNode* next = (*pphead)->next; // 先保存第二个结点
free(*pphead); // 释放头结点
*pphead = next; // 头指针指向第二个结点
}单链表尾删为什么必须找"倒数第二个"?因为链表没有"回头看"的能力——要断开最后一个结点,必须修改它前一个结点的 `next`,而单向链表只能往前找。这就是单链表尾部操作天生比头部操作麻烦的原因,也是后面双向链表存在的意义之一。
查找、指定位置插入删除、销毁:
// 查找:返回第一个匹配的结点地址,找不到返回 NULL
SLTNode* SLTFind(SLTNode* phead, SLTDataType x)
{
SLTNode* cur = phead;
while (cur != NULL)
{
if (cur->data == x)
return cur;
cur = cur->next;
}
return NULL;
}
// 在 pos 之前插入(pos 由 SLTFind 查找得到)
void SLTInsert(SLTNode** pphead, SLTNode* pos, SLTDataType x)
{
if (pos == *pphead) // pos 是头结点:等价于头插
{
SLTPushFront(pphead, x);
return;
}
SLTNode* prev = *pphead;
while (prev->next != pos) // 找到 pos 的前一个结点
{
prev = prev->next;
}
SLTNode* newnode = BuySLTNode(x);
prev->next = newnode; // 先让前一个结点指向新结点
newnode->next = pos; // 再让新结点指向 pos
}
// 删除 pos 结点
void SLTErase(SLTNode** pphead, SLTNode* pos)
{
if (pos == *pphead) // 删头结点:等价于头删
{
SLTPopFront(pphead);
return;
}
SLTNode* prev = *pphead;
while (prev->next != pos) // 找到 pos 的前一个结点
{
prev = prev->next;
}
prev->next = pos->next; // 前一个结点越过 pos 指向它的后继
free(pos); // 释放 pos
}
// 在 pos 之后插入:不需要改头指针,一级指针就够
void SLTInsertAfter(SLTNode* pos, SLTDataType x)
{
SLTNode* newnode = BuySLTNode(x);
newnode->next = pos->next; // 先连后面的结点(顺序不能反!)
pos->next = newnode; // 再让 pos 指向新结点
}
// 删除 pos 之后的结点
void SLTEraseAfter(SLTNode* pos)
{
if (pos == NULL || pos->next == NULL) // 没有后继可删
return;
SLTNode* del = pos->next; // 要删的结点
pos->next = del->next; // pos 越过 del,直接指向 del 的后继
free(del); // 释放 del
}
// 销毁链表:逐个释放所有结点,防内存泄漏
void SListDestroy(SLTNode** pphead)
{
SLTNode* cur = *pphead;
while (cur != NULL)
{
SLTNode* next = cur->next; // 先保存下一个,再释放当前
free(cur);
cur = next;
}
*pphead = NULL; // 头指针置空
}SLTInsertAfter 里两步的顺序为什么不能反?如果先 pos->next = newnode,那么原 pos->next(后面的结点)的地址就丢了,新结点找不到后继,链表断成两截。所以永远是先连后面的,再改前面的。这个顺序问题在链表操作里会反复出现,务必养成"先接好再拆"的习惯。
至于 SLTInsert / SLTErase 为什么传二级指针——因为当 pos 恰好是头结点时,它们内部会调用会改头指针的头插/头删;而 SLTInsertAfter / SLTEraseAfter 只操作 pos 后面,永远碰不到头指针,一级指针足矣。
链表的分类
链表的结构非常多样,从三个维度看:
| 维度 | 两种选择 |
|---|---|
| 带头 / 不带头 | 有没有不存数据的哨兵位头结点 |
| 单向 / 双向 | 结点只有一个 next,还是 next + prev 都有 |
| 循环 / 非循环 | 尾结点指向 NULL,还是绕回头部 |
三个维度两两组合,一共 2 × 2 × 2 = 8 种链表结构。不过别被这个数字吓到,实际最常用的只有两种:
- 无头单向非循环链表:结构最简单,一般不会单独用来存数据,更多是作为其他数据结构的子结构——比如哈希桶、图的邻接表。另外,这种结构在笔试面试中出现很多,上面的实现就是它。
- 带头双向循环链表:结构最复杂,一般用在单独存储数据。实际工程中使用的链表数据结构,大多都是带头双向循环链表。它结构复杂,但代码实现以后你会发现,复杂结构反而带来了巨大的优势——实现反而更简单了。后面我们代码实现了就知道了。
先别急着往下跳,我们把单链表上的经典算法题过一遍,再把快慢指针的数学证明推清楚,最后回到带头双向循环链表——你会发现所有东西都串起来了。
单链表算法题
单链表算法题是笔试面试的重灾区,核心考察指针操作、边界处理和数学直觉。逐个看思路与关键代码。
移除链表元素(LeetCode 203):删除所有值为 val 的结点。需要一个 prev 记录当前结点的前驱(因为单链表删结点必须改前驱的 next),同时特判头结点:
// LeetCode 203. 移除链表元素
// 思路:prev 记录前驱,删非头结点时修改 prev->next
struct ListNode* removeElements(struct ListNode* head, int val)
{
// 先处理头部连续等于 val 的结点
while (head != NULL && head->val == val)
{
struct ListNode* del = head;
head = head->next;
free(del);
}
// 再处理中间和尾部
struct ListNode* prev = head;
struct ListNode* cur = head == NULL ? NULL : head->next;
while (cur != NULL)
{
if (cur->val == val)
{
prev->next = cur->next; // 前驱越过 cur
free(cur);
cur = prev->next; // cur 后移
}
else
{
prev = cur;
cur = cur->next;
}
}
return head;
}删除排序链表中的重复元素(LeetCode 83):链表有序,所以重复的元素必然相邻。用一个指针 cur 从前往后扫,只要 cur->val == cur->next->val,就删掉 cur->next;否则 cur 后移。因为重复值连续,删完当前这个,下一个可能还是重复值,所以删除后不要急着移动 cur,要再检查一次:
// LeetCode 83. 删除排序链表中的重复元素
// 思路:有序 => 重复相邻;cur 不动反复删,直到当前值和下一个不同
struct ListNode* deleteDuplicates(struct ListNode* head)
{
struct ListNode* cur = head;
while (cur != NULL && cur->next != NULL)
{
if (cur->val == cur->next->val)
{
struct ListNode* del = cur->next; // 记下要删的结点
cur->next = del->next; // cur 越过 del
free(del); // 释放 del
}
else
{
cur = cur->next; // 值不同,cur 才往前走
}
}
return head;
}这里的易错点恰恰是"删除后 cur 不移动":如果删完就 cur = cur->next,遇到 1 -> 1 -> 1 这种三个连续重复,只会删掉中间那个,留下 1 -> 1。对比"移除链表元素(LeetCode 203)"会发现两题结构相似但细节不同:203 要删除所有值等于 val 的结点(可能分散,需要 prev 记录前驱);83 只删相邻重复(有序保证),不需要 prev。把这两题对照着做,你对"指针该在什么时候动"会有一个质的飞跃。
反转链表(LeetCode 206)——三指针法。核心思路:遍历链表,把每个结点的 next 指针"掉头",指向它的前驱。需要三个指针:n1(前驱,初始为 NULL)、n2(当前结点)、n3(后继,防断链):
初始:NULL 1 -> 2 -> 3 -> 4 -> NULL
n1 n2 n3
第一步:n2->next = n1,反转第一个结点
NULL <- 1 2 -> 3 -> 4 -> NULL
n1 n2 n3
整体后移,继续反转……
最终:4 -> 3 -> 2 -> 1 -> NULL,n1 就是新头
// LeetCode 206. 反转链表(三指针法)
struct ListNode* reverseList(struct ListNode* head)
{
struct ListNode* n1 = NULL; // 新链表头,初始为空
struct ListNode* n2 = head; // 当前要处理的结点
struct ListNode* n3 = NULL; // 保存 n2 的下一个,防止断链
while (n2 != NULL)
{
n3 = n2->next; // ① 先保存后继
n2->next = n1; // ② 掉头:当前结点指向前驱
n1 = n2; // ③ 三个指针整体后移
n2 = n3;
}
return n1; // 原链表最后一个结点,现在是新链表头
}链表的中间结点(LeetCode 876):快慢指针——slow 一次走一步,fast 一次走两步。fast 到终点时,slow 恰好走到中间:
// LeetCode 876. 链表的中间结点
// 思路:快慢指针,fast 到终点时 slow 刚好在中间
struct ListNode* middleNode(struct ListNode* head)
{
struct ListNode* slow = head;
struct ListNode* fast = head;
while (fast != NULL && fast->next != NULL)
{
slow = slow->next; // 慢指针一步
fast = fast->next->next; // 快指针两步
}
return slow;
}链表中倒数第 k 个结点(剑指 Offer 22 / 牛客):还是快慢指针,但这次两个指针不同步出发——fast 先走 k 步,然后 slow 和 fast 一起走。fast 到 NULL 时,slow 恰好停在倒数第 k 个结点上。为什么?因为 fast 比 slow 领先 k 步,当 fast 走完整个链表时,slow 距离终点正好还有 k 步——它就是倒数第 k 个:
// 链表中倒数第 k 个结点
// 思路:fast 先走 k 步,再和 slow 同步走,fast 到 NULL 时 slow 就是倒数第 k 个
struct ListNode* getKthFromEnd(struct ListNode* head, int k)
{
struct ListNode* fast = head;
struct ListNode* slow = head;
while (k--) // ① fast 先走 k 步
{
if (fast == NULL) // 链表长度不足 k,倒数第 k 个不存在
return NULL;
fast = fast->next;
}
while (fast != NULL) // ② 两个指针同步走,fast 到 NULL 停止
{
fast = fast->next;
slow = slow->next;
}
return slow; // ③ slow 就是倒数第 k 个结点
}注意这个题目考的是一个巧妙的转化:"倒数第 k 个" = "正数第 n-k+1 个",但单链表没法从后往前数,所以用快指针先跑 k 步来"探测总长度",相当于把"从后数 k 步"翻译成了"从前往后对齐"。这个思想和"找中间结点"(fast 一步、slow 一步)形成了一对经典组合——面试官常常把这两题连着考。
合并两个有序链表(LeetCode 21):经典的归并思想。两个链表各用一个指针,谁小取谁,取完指针后移;最后把没走完的那条直接接上:
// LeetCode 21. 合并两个有序链表
// 思路:双指针归并 + 哨兵位头结点简化头指针处理
struct ListNode* mergeTwoLists(struct ListNode* list1, struct ListNode* list2)
{
struct ListNode* phead = (struct ListNode*)malloc(sizeof(struct ListNode)); // 哨兵位
struct ListNode* tail = phead; // tail 指向新链表尾部
while (list1 != NULL && list2 != NULL)
{
if (list1->val < list2->val) // 谁小接谁
{
tail->next = list1;
list1 = list1->next;
}
else
{
tail->next = list2;
list2 = list2->next;
}
tail = tail->next;
}
// 剩余部分直接接上(最多只剩一条链)
tail->next = (list1 != NULL) ? list1 : list2;
struct ListNode* head = phead->next; // 真正的头是哨兵位的后继
free(phead); // 释放哨兵位
return head;
}这里已经用到了"哨兵位"的思想——一个不存数据的虚拟头结点,让头插、空表处理全部统一,不用再特判头指针。这个技巧在双向链表里会被用到极致。
链表分割(牛客:链表分割):把链表按值 x 分成两段:小于 x 的在前,大于等于 x 的在后,两段内部相对顺序不变。思路:两个哨兵位,lessHead 挂小于 x 的结点,greaterHead 挂其余结点,最后把两段接起来:
// 链表分割:小于 x 的在前,大于等于 x 的在后
struct ListNode* partition(struct ListNode* pHead, int x)
{
// 两个哨兵位,分别挂两段结点
struct ListNode* lessHead = (struct ListNode*)malloc(sizeof(struct ListNode));
struct ListNode* greaterHead = (struct ListNode*)malloc(sizeof(struct ListNode));
struct ListNode* lessTail = lessHead;
struct ListNode* greaterTail = greaterHead;
struct ListNode* cur = pHead;
while (cur != NULL)
{
if (cur->val < x) // 小于 x 挂到 less 段
{
lessTail->next = cur;
lessTail = cur;
}
else // 其余挂到 greater 段
{
greaterTail->next = cur;
greaterTail = cur;
}
cur = cur->next;
}
greaterTail->next = NULL; // 尾结点要收口,否则可能带环!
lessTail->next = greaterHead->next; // 两段拼接
struct ListNode* head = lessHead->next;
free(lessHead);
free(greaterHead);
return head;
}链表分割的经典坑:`greaterTail->next` 必须置 `NULL`。因为最后一个进入 greater 段的结点,它的 `next` 可能还指向原链表后面的某个结点——不斩断的话,新链表尾部会带着一截"尾巴",甚至形成环,测试直接超时。
链表的回文结构(牛客:链表的回文结构):判断链表前半段和后半段是否对称。先找到中间结点,再反转后半段,最后两头往中间比:
// 回文结构:找中间 + 反转后半 + 双指针比对
bool chkPalindrome(struct ListNode* A)
{
// ① 快慢指针找中间结点
struct ListNode* slow = A;
struct ListNode* fast = A;
while (fast != NULL && fast->next != NULL)
{
slow = slow->next;
fast = fast->next->next;
}
// ② 反转 slow 之后的后半段
struct ListNode* n1 = NULL;
struct ListNode* n2 = slow;
struct ListNode* n3 = NULL;
while (n2 != NULL)
{
n3 = n2->next;
n2->next = n1;
n1 = n2;
n2 = n3;
}
// ③ 头指针和反转后的头(n1)同步走,比对值
struct ListNode* head = A;
while (n1 != NULL)
{
if (head->val != n1->val)
return false;
head = head->next;
n1 = n1->next;
}
return true;
}相交链表(LeetCode 160):两个链表从某个结点开始共用同一段。如果两个链表相交,它们的尾结点必然相同。思路:先分别数出两个链表的长度,算出长度差,让长链表先走差值步对齐,然后两个指针同步走,第一次相等的地方就是交点;走到 NULL 都不相等就是不相交:
// LeetCode 160. 相交链表
// 思路:先对齐(长链表先走长度差),再同步走找交点
struct ListNode* getIntersectionNode(struct ListNode* headA, struct ListNode* headB)
{
struct ListNode* curA = headA;
struct ListNode* curB = headB;
int lenA = 0, lenB = 0;
// ① 数长度(顺便可以判断尾结点是否相同,不同则必不相交)
while (curA != NULL) { lenA++; curA = curA->next; }
while (curB != NULL) { lenB++; curB = curB->next; }
// ② 长链表先走差值步
int gap = abs(lenA - lenB);
struct ListNode* longList = lenA > lenB ? headA : headB;
struct ListNode* shortList = lenA > lenB ? headB : headA;
while (gap--)
{
longList = longList->next;
}
// ③ 同步走,找第一个相同的结点
while (longList != shortList)
{
longList = longList->next;
shortList = shortList->next;
}
return longList; // 可能同时为 NULL(不相交),也符合语义
}环形链表 I(LeetCode 141) 和 环形链表 II(LeetCode 142) 是重头戏,涉及快慢指针的严格数学证明,我们单独开一节认真推导。
随机链表的复制(LeetCode 138):结点除了 next 还有一个 random 指针,指向任意结点或 NULL。复制时难点在于:random 指向的地址必须映射到新链表的对应结点。经典解法分三步:① 在每个原结点后面复制一个新结点(如 A -> A' -> B -> B');② 新结点的 random 指向"原结点 random 的后继";③ 拆开链表,把新结点串成新链表。核心是"random->next"这个巧妙的地址映射。
快慢指针的证明
回到环形链表 I。快慢指针的经典表述是:慢指针一次走一步,快指针一次走两步,两个指针从链表起始位置开始运行。如果链表带环,它们一定会在环中相遇;如果链表不带环,快指针率先走到链表的末尾(遇到 NULL,返回 false)。
// LeetCode 141. 环形链表 I:判断链表是否有环
bool hasCycle(struct ListNode* head)
{
struct ListNode* slow = head;
struct ListNode* fast = head;
while (fast != NULL && fast->next != NULL) // 无环时 fast 先到头
{
slow = slow->next; // 慢指针一次一步
fast = fast->next->next; // 快指针一次两步
if (slow == fast) // 相遇说明有环
return true;
}
return false; // 快指针走到 NULL,无环
}直觉上"会相遇"很容易接受,但课件提出了两个值得较真的问题,需要数学证明。
思考 1:为什么快指针每次走两步、慢指针走一步,一定可以相遇?有没有可能遇不上?
推理如下。设链表的环入口之前的部分长度为 L,环的长度为 C。
-
fast先进环。等slow走完入环前的距离 L、刚准备进环时,fast已经在环里绕了若干圈,此刻fast和slow之间的距离为 N(slow在前,fast在后,沿着环的顺时针方向量,N 最大不超过 C-1,因为slow刚进环时fast至少比它多走了一步,也可能已经套圈)。 -
接下来的追逐中,
slow走一步,fast走两步——每追击一次(即每过一个时间单位),两者之间的距离缩小 1 步:
距离变化:N, N-1, N-2, N-3, ... , 2, 1, 0
距离为 0 的那一刻,就是 fast 追上 slow 的时刻,两指针相遇。
因为距离 N 是一个有限的正整数,每次追近 1,最多追 N 次必然归零。所以:在带环链表中,慢指针走一步、快指针走两步,最终一定会相遇。
还有一种等价说法:慢指针进环后,快慢指针之间的距离最多就是环长 C,每次缩小 1,所以在慢指针走完一圈之前,快指针必然追上慢指针——因为 C 步之内距离必然归零。这个"慢指针一圈内必被追上"的结论,在后面环形链表 II 的证明里还要用到。
思考 2:快指针一次走 3 步、4 步……n 步行吗?
按同样的分析方法,慢指针一次走一步,快指针一次走三步。slow 进环时两者距离为 N,每追击一次距离缩小 2 步:
- 情况一:N 是偶数,距离按
N, N-2, N-4, ..., 2, 0变化,第一轮就追上了。 - 情况二:N 是奇数,距离按
N, N-2, N-4, ..., 3, 1, -1变化——距离从 1 减 2 变成 -1。距离为 -1 意味着fast超过了slow一个身位,即fast跑到了slow前面 C-1 步的位置(在环上,-1 等价于 C-1,因为环是首尾相接的),进入新的一轮追击:- 如果 C-1 是偶数,下一轮距离每轮减 2,能追上;
- 如果 C-1 是奇数,下一轮又会错过,距离再次变成 -1,如此反复——永远追不上。
看起来"永远追不上"的条件是:N 是奇数,且 C 是偶数。但慢着——这个前提条件真的存在吗?我们需要证明它在任何实际链表里都不可能发生。
设环的周长为 C,头结点到环入口(slow 入环的位置)的长度为 L。slow 走一步,fast 走三步,当 slow 入环开始追逐时,假设 fast 此时已经在环里绕了 x 圈(x 为非负整数,课件里用"绕环 x 周"表示)。
相遇时两者走过的路程分别为:
fast走的路程:L + xC + C - Nslow走的路程:L
由于慢指针走一步、快指针走三步,时间相同,路程满足:3 × 慢指针路程 = 快指针路程,即:
3L = L + xC + C - N
移项化简:
2L = (x + 1)C - N
现在做奇偶分析。左边 2L 是 2 的倍数,一定为偶数。右边 (x+1)C - N 要等于一个偶数,只有两种可能:
- 情况 1:偶数 = 偶数 - 偶数;
- 情况 2:偶数 = 奇数 - 奇数。
由前面的分析(思考 2 的情况一),如果 N 是偶数,第一圈内快慢指针就相遇了,不存在问题。
关键看 N 是奇数的情况(思考 2 的情况二)。此时 N 是奇数,要让 (x+1)C - N 等于偶数 2L,根据"偶数 = 奇数 - 奇数",(x+1)C 必须也是奇数,而 (x+1)C 是奇数意味着 C 必须是奇数。
于是我们得到结论:当 N 是奇数时,C 必然也是奇数。这和"永远追不上"需要的条件"N 是奇数,C 是偶数"直接矛盾——该情况根本不可能存在。
因此,"快指针一次走 3 步"在带环链表中最终也一定可以相遇。快指针一次走 4 步、5 步……n 步,证明方式完全一样,都能相遇。
虽然已经证明了快指针不论走多少步都能在带环链表中相遇,但编写代码时,走 3 步需要内层循环处理"fast->next 为 NULL 提前结束"这类额外步骤,逻辑更绕。所以涉及快慢指针的算法题中,通常习惯使用慢指针走一步、快指针走两步的方式——编码最简单,证明也最直观。
环形链表 II:入口在哪
环形链表 II 不仅要求判断有环,还要求找出环的入口结点。课件给出了一个漂亮且反直觉的结论:
让一个指针从链表起始位置开始遍历链表,同时让一个指针从判环时相遇点的位置开始绕环运行,两个指针都每次走一步,最终肯定会在入口点的位置相遇。
下面严格证明这个结论。
说明:设 H 为链表的起始点,E 为环入口点,M 为判环时的相遇点。设环的长度为 R,H 到 E 的距离为 L,E 到 M 的距离为 X,则 M 到 E 的距离为 R - X。图示如下:
H ----------- E
|\
| \ 环(周长 R)
| \
| M (相遇点,E 到 M 距离 X,M 到 E 距离 R-X)
| /
| /
|/
判环时(快指针两步、慢指针一步),相遇时两者的路程:
fast走的路程:L + X + nR(先走 L 到入口,再走到 M,且因为快,已经绕了 n 圈)slow走的路程:L + X
两个事实需要先说明:
- n 至少为 1。快指针先进环,它绕了若干圈之后到达 M 的位置,最后又在 M 与慢指针相遇。也就是说在慢指针进环之前,快指针已经至少完整绕了一圈,n ≥ 1。
- 慢指针进环之后,快指针肯定会在慢指针走一圈之内追上慢指针。因为慢指针刚进环时,快慢指针之间的距离最多就是环的长度 R,而两者每次移动距离差缩小 1 步,因此在慢指针移动一圈之前,快指针必然追上。而快指针速度是慢指针的两倍,所以有:
2 × (L + X) = L + X + nR
左边是"时间相同、速度 2 倍,路程也 2 倍"的直接结论。移项化简:
L + X = nR
L = nR - X
再变形:
L = (n - 1)R + (R - X)
(n 为 1, 2, 3, 4……,具体值取决于环的大小——环越小,快指针绕的圈数越多,n 越大。)
这个式子意味着什么?L 是头结点到入口的距离;R - X 是相遇点 M 绕环回到入口 E 的距离。当 n = 1 时,L = R - X——从头结点出发走 L 步恰好到达入口,从相遇点出发走 R-X 步也恰好到达入口。当 n > 1 时,不过是先绕了 n-1 圈再走 R-X,最终落脚点依然是入口。
所以:一个指针从链表起始位置运行,一个指针从相遇点位置绕环,每次各走一步,两个指针最终会在入口点相遇。证明完毕。
// LeetCode 142. 环形链表 II:返回环的入口结点
// 思路:先找相遇点,再一个从头走、一个从相遇点走,相遇处即入口
struct ListNode* detectCycle(struct ListNode* head)
{
struct ListNode* slow = head;
struct ListNode* fast = head;
// ① 判环,顺便记录相遇点
while (fast != NULL && fast->next != NULL)
{
slow = slow->next;
fast = fast->next->next;
if (slow == fast)
break; // 相遇,跳出循环
}
// 无环:fast 走到了 NULL
if (fast == NULL || fast->next == NULL)
return NULL;
// ② 一个指针从头开始,一个指针从相遇点开始,同步走一步
struct ListNode* meet = fast; // 相遇点
struct ListNode* cur = head;
while (cur != meet)
{
cur = cur->next; // 从头走
meet = meet->next; // 从相遇点绕环走
}
return cur; // 相遇处就是环的入口
}双向链表:带头双向循环链表
现在回到前面预告过的"结构最复杂、实现反而最简单"的带头双向循环链表。
先澄清一个概念。课件里说:这里的"带头"跟前面单链表阶段说的"头结点"是两个概念(前面为了便于理解,直接把单链表的第一个结点称为头结点,其实不严谨)。带头链表里的头结点,实际是一个"哨兵位"(sentinel):哨兵位结点不存储任何有效元素,只是站在那里"放哨",让链表的操作逻辑统一。
它的结构有三个要点:
- 带头:有一个哨兵位头结点,永远存在,链表"空"或"非空"它都在;
- 双向:每个结点有
next和prev两个指针,既能往后走也能往回走; - 循环:哨兵位的
next指向第一个有效结点,最后一个有效结点的next指回哨兵位;哨兵位的prev指向最后一个有效结点。
空链表长这样(哨兵位自己首尾相连):
phead
|
v
+---+---+
| | | prev 和 next 都指向自己
+---+---+
结点结构体:
// List.h
#pragma once
#include <stdio.h>
#include <stdlib.h>
#include <stdbool.h>
#include <assert.h>
typedef int LTDataType;
typedef struct ListNode
{
struct ListNode* next; // 保存下一个结点的地址
struct ListNode* prev; // 保存前一个结点的地址
LTDataType data;
} LTNode;
LTNode* LTInit(void); // 初始化,返回哨兵位
void LTDestroy(LTNode* phead);
void LTPrint(LTNode* phead);
bool LTEmpty(LTNode* phead);
void LTPushBack(LTNode* phead, LTDataType x);
void LTPopBack(LTNode* phead);
void LTPushFront(LTNode* phead, LTDataType x);
void LTPopFront(LTNode* phead);
void LTInsert(LTNode* pos, LTDataType x); // 在 pos 之后插入
void LTErase(LTNode* pos); // 删除 pos 结点
LTNode* LTFind(LTNode* phead, LTDataType x);注意!这里的接口全部只传**一级指针** `LTNode* phead`,不再需要二级指针。为什么?因为哨兵位头结点**永远存在**,任何插入删除都不会改变"头指针"本身(它始终指向那个哨兵位)。头指针不变,自然就不需要传它的地址。单链表里那种"链表可能为空、头指针可能改变"的烦恼,被哨兵位彻底消灭了。
初始化、销毁、打印、判空:
// List.c
#include "List.h"
// 申请一个新结点
LTNode* BuyLTNode(LTDataType x)
{
LTNode* newnode = (LTNode*)malloc(sizeof(LTNode));
if (newnode == NULL)
{
perror("malloc fail");
exit(1);
}
newnode->data = x;
newnode->next = NULL;
newnode->prev = NULL;
return newnode;
}
// 初始化:创建哨兵位头结点(不存有效数据),首尾都指向自己
LTNode* LTInit(void)
{
LTNode* phead = BuyLTNode(0); // 哨兵位的数据无意义,随便给
phead->next = phead;
phead->prev = phead;
return phead;
}
// 销毁:先释放所有有效结点,最后释放哨兵位
void LTDestroy(LTNode* phead)
{
LTNode* cur = phead->next;
while (cur != phead) // 绕回哨兵位说明遍历完毕
{
LTNode* next = cur->next; // 先保存后继
free(cur);
cur = next;
}
free(phead); // 最后释放哨兵位
}
// 打印:从第一个有效结点开始,绕回哨兵位结束
void LTPrint(LTNode* phead)
{
LTNode* cur = phead->next;
printf("phead<->");
while (cur != phead)
{
printf("%d<->", cur->data);
cur = cur->next;
}
printf("phead\n");
}
// 判断链表是否为空:只有哨兵位(自己指向自己)就是空
bool LTEmpty(LTNode* phead)
{
return phead->next == phead;
}插入和删除是这个结构的精华。统一的插入接口 LTInsert(pos, x) 表示"在 pos 之后插入",四个插入操作(头插、尾插)都复用它:
// 在 pos 之后插入新结点(统一的插入接口)
void LTInsert(LTNode* pos, LTDataType x)
{
LTNode* newnode = BuyLTNode(x);
LTNode* posNext = pos->next; // 先保存 pos 的后继
pos->next = newnode; // ① pos 指向新结点
newnode->prev = pos; // ② 新结点指回 pos
newnode->next = posNext; // ③ 新结点指向原后继
posNext->prev = newnode; // ④ 原后继指回新结点
}
// 尾插 = 在最后一个结点之后插入 = 在哨兵位之前插入
void LTPushBack(LTNode* phead, LTDataType x)
{
LTInsert(phead->prev, x); // phead->prev 就是最后一个结点
}
// 头插 = 在哨兵位之后插入
void LTPushFront(LTNode* phead, LTDataType x)
{
LTInsert(phead, x); // 插在哨兵位后面就是头插
}以尾插为例,图上是这样的(新结点 n 插到最后一个结点 a 和哨兵位 phead 之间):
插入前:phead <-> ... <-> a <-> phead
插入后:phead <-> ... <-> a <-> n <-> phead
拆解: a->next = n; n->prev = a; n->next = phead; phead->prev = n;
注意:空链表也能直接尾插!因为空链表里 phead->prev 就是 phead 自己,"在哨兵位之前插入"依然成立。空表、单结点、多结点的情况被完全统一——这正是哨兵位 + 循环带来的最大红利。
删除接口同理,LTErase(pos) 删除 pos 结点,头删尾删都是它的特例:
// 删除 pos 结点(统一的删除接口)
void LTErase(LTNode* pos)
{
assert(pos != NULL); // pos 不能为空
LTNode* prev = pos->prev; // 前驱
LTNode* next = pos->next; // 后继
prev->next = next; // 前驱直接指向后继
next->prev = prev; // 后继指回前驱
free(pos); // 释放 pos
}
// 尾删 = 删除最后一个结点 = 删除哨兵位的前一个
void LTPopBack(LTNode* phead)
{
assert(!LTEmpty(phead)); // 空链表不能删
LTErase(phead->prev);
}
// 头删 = 删除哨兵位的后一个
void LTPopFront(LTNode* phead)
{
assert(!LTEmpty(phead));
LTErase(phead->next);
}
// 查找
LTNode* LTFind(LTNode* phead, LTDataType x)
{
LTNode* cur = phead->next;
while (cur != phead) // 绕回哨兵位结束
{
if (cur->data == x)
return cur;
cur = cur->next;
}
return NULL;
}对比一下:单链表的 SLTInsert 要特判"pos 是头结点"、"链表为空",SLTPushBack 要单独处理空链表,尾删要找倒数第二个结点……而带头双向循环链表里,所有插入删除都收敛到 LTInsert 和 LTErase 两个函数,每个函数固定四步指针操作,零特判。这就是课件说的"结构复杂,但实现反而简单了"——结构的复杂性换来了代码的简单性。C 标准库的 list、Linux 内核的链表,本质都是这个结构。
顺序表与链表的对比分析
最后,把两种结构放到同一张表里全面对比。以单链表作为链表的代表:
| 不同点 | 顺序表 | 链表(单链表) |
|---|---|---|
| 存储空间上 | 物理上一定连续 | 逻辑上连续,但物理上不一定连续 |
| 随机访问 | 支持,O(1),直接下标 | 不支持,O(N),必须从头遍历 |
| 任意位置插入或删除元素 | 可能需要搬移元素,效率低,O(N) | 只需修改指针指向,O(1) |
| 插入 | 动态顺序表,空间不够时需要扩容,有空间浪费 | 没有容量的概念,按需申请释放,不存在空间浪费 |
| 应用场景 | 元素高效存储 + 频繁访问 | 任意位置高效插入和删除 |
逐条展开说说:
- 存储空间:顺序表要求一段连续内存,大表可能申请失败(内存碎片导致没有那么大块的连续空间);链表结点零散分布,只要还有零散内存就能挂上去。
- 随机访问:顺序表
a[i]一步到位,O(1),这是它最大的王牌;链表想访问第 k 个结点得从头走 k 步,O(N)。 - 任意位置插入删除:顺序表要搬移 O(N) 个元素;链表只改常数个指针,O(1)。但注意——链表这个 O(1) 的前提是"你已经站在 pos 结点上了"。要找 pos 本身,还是要 O(N) 遍历。
- 插入时:顺序表可能扩容,扩容要申请新空间、拷贝数据、释放旧空间,且 2 倍预支有浪费;链表没有容量概念,用多少申请多少。
- 缓存友好性(表格外补充的一点):顺序表连续存储,遍历时 CPU 缓存命中率高;链表结点分散,遍历时频繁缓存未命中,实际速度往往比理论值差很多。大数据量下"顺序表遍历 1000 万次"可能比链表快一个数量级。
那到底怎么选?没有银弹,只有场景:
- 数据量相对固定、以访问/修改为主、偶尔尾部插入 → 顺序表(成绩单、排行榜、下标访问频繁的场景)。
- 数据量动态变化大、频繁在任意位置插入删除、对随机访问不敏感 → 链表(任务队列、LRU 缓存、图的邻接表等)。
回到开头的两个场景:储物柜(顺序表)胜在"按号直取"——你报 37 号,一步到位;火车(链表)胜在"随时加挂摘卸"——淡季摘两节、旺季挂三节,车厢本身不动。真实程序里,两者往往还组合使用(比如哈希桶是"数组 + 链表"),但那是后话了。
到这里,从储物柜和火车出发,我们走完了线性表的整条主线:理解了顺序表和链表的概念与分类,完整实现了两套数据结构的所有接口,推演了插入删除的每一步指针变化,用数学证明了快慢指针的相遇与环入口的结论,最后做了全维度对比。写数据结构,重点从来不是背代码,而是想清楚每一步为什么这么做——理解了"为什么传二级指针""为什么先连后面的""为什么哨兵位让实现变简单",代码自然就写得出来,也经得起面试官的追问。下次遇到"顺序表还是链表"的选择题,你应该能自信地回答了。
思考题与练习
动手之前先自查:顺序表的增删查改、单链表的头尾操作、双向链表的统一插入删除、快慢指针的相遇证明——这些代码你能不看书默写出来吗?写不出来的地方,就是还没真正掌握的地方。下面是几道层层递进的练习,用来检验和巩固。
练习 1:补全顺序表接口。 给动态顺序表补两个接口:void SLModify(SL* ps, int pos, SLDataType x)(修改指定下标元素)和 int SLContains(SL* ps, SLDataType x)(判断元素是否存在)。想一想:修改和查找各自的时间复杂度是多少?
提示
修改需要先校验 `pos` 的范围(`0 <= pos < size`),然后直接 `ps->a[pos] = x`,O(1);查找要遍历,O(N)。这正好呼应了对比表里"顺序表随机访问 O(1)、链表 O(N)"的差异。练习 2:验证扩容的均摊代价。 写一个程序:初始化一个空顺序表,连续尾插 100 万个元素,在 SLCheckCapacity 里加一行计数,统计 realloc 触发的次数。结果会是多少次?和"每次扩容 +1"的方案对比,谁触发的次数更多?
提示
2 倍扩容从容量 4 开始:4 → 8 → 16 → ... → 2^k,触发次数约为 log₂(10⁶) ≈ 20 次,每次扩容拷贝前面所有元素,总拷贝量约 2×10⁶ 次,均摊到每次尾插是常数。而每次 +1 扩容要触发约 10⁶ 次 realloc、总拷贝量约 5×10¹¹ 次——这就是"为什么必须成倍扩容"的实证。练习 3:单链表的"倒数"操作。 上面补充了"链表中倒数第 k 个结点"的快慢指针解法。现在再写一个变体:给定链表和 k,删除链表的倒数第 k 个结点(LeetCode 19),返回新链表头。
提示
先找倒数第 k 个结点(fast 先走 k 步),再记录它的前驱,最后让前驱的 `next` 跳过它并 `free`。注意 k 等于链表长度时删的是头结点,此时前驱是 NULL,需要特殊处理——或者直接用哨兵位头结点,让逻辑统一。练习 4:递归版合并有序链表。 前面用循环实现了 LeetCode 21。请用递归再写一遍:mergeTwoLists 每次比较两个头结点,谁小谁当"合并结果的第一个结点",其余部分递归合并。
提示
递归出口是两个链表之一为空,直接返回另一个。核心逻辑只有几行:`if (list1 == NULL) return list2; if (list2 == NULL) return list1;` 然后比较头结点值,让较小者的 `next` 指向递归合并结果并返回较小者。递归版本更短,但深度受链表长度限制(最坏 O(N) 栈帧),数据量极大时不如循环版稳妥。练习 5(挑战):约瑟夫环。 n 个人围成一圈,从第 1 个人开始报数,报到 m 的人出圈,下一个人从 1 重新开始报,直到只剩一个人。用循环链表模拟这个过程,输出出圈顺序和最后幸存者的编号。(n = 5, m = 3 时,出圈顺序是 3, 1, 5, 2,幸存者是 4,可以拿这个验证。)
提示
核心是"报数 = 沿 next 走 m-1 步,然后删除当前结点"。注意:删除时正好删到"头"的情况,要让头指针后移;只剩一个结点时它的 `next` 指向自己,直接输出并 free。约瑟夫环还有一个纯数学的 O(N) 递推解法(f(1)=0, f(i)=(f(i-1)+m)%i),感兴趣可以查一下,那是"用数学消灭数据结构"的经典案例。学完本章,你应该能说清:顺序表为什么扩容要成倍增长(均摊 O(1));单链表接口为什么传二级指针、哪些接口不需要;为什么 InsertAfter 必须先连后面的再改前面的;带头双向循环链表为什么"结构最复杂实现最简单";快慢指针为什么能相遇(距离逐次减 1)、环入口为什么是"L = nR - X";顺序表适合什么场景、链表适合什么场景。这些都清楚,线性表就过关了,可以放心进入栈和队列。
还没有评论 — 第一条由你来留。