【数据结构入门】顺序表和链表,一篇讲透(超详细图解 + 完整代码)
适合人群:刚学数据结构、被"顺序表/链表"绕晕的初学者。全文用大白话 + 生活类比讲解,代码可直接编译运行。
目录
- 一、先搞懂:什么是线性表
- [二、线性表的两种实现:顺序存储 vs 链式存储](#二、线性表的两种实现:顺序存储 vs 链式存储)
- [三、先补一个小知识:C++ 的"引用"](#三、先补一个小知识:C++ 的"引用")
- [四、顺序表(Sequential List)](#四、顺序表(Sequential List))
- [五、链表(Linked List)](#五、链表(Linked List))
- [六、顺序表 vs 链表:到底该用哪个?](#六、顺序表 vs 链表:到底该用哪个?)
- [七、面试/408 高频考点速查表](#七、面试/408 高频考点速查表)
- 八、写在最后
一、先搞懂:什么是线性表
线性表(linear list)就是:n 个"同类型"数据元素排成的一条"队"。
形式化一点写:
L = (a1, a2, a3, ..., an) n >= 0
拆开看这几个点,考试特别爱考:
| 概念 | 含义 |
|---|---|
n |
表的长度。n = 0 时叫空表 |
a1 |
表头元素,它没有前驱 |
an |
表尾元素,它没有后继 |
| 其他元素 | 都有唯一 的前驱和唯一的后继 |
i |
元素 ai 的位序 |
⚠️ 初学最容易踩的坑:位序 ≠ 数组下标
- 位序 :从 1 开始数(第 1 个、第 2 个......)------ 教材、408 真题都用位序
- 数组下标 :从 0 开始数 ------ C/C++ 代码里用
所以"第 i 个位置"在代码里通常是
arr[i-1]。做题时一定要看清题目问的是位序还是下标,这是丢分重灾区。
线性表在"数据结构三要素"里的位置
数据结构三要素 = 逻辑结构 + 物理结构(存储结构)+ 数据运算。
- 线性表的定义 → 讲的是逻辑结构
- 增删改查这些运算的定义 → 针对逻辑结构
- 运算的具体实现 → 必须落到某种存储结构上
于是就有了一句话总结:
用顺序存储实现线性表 = 顺序表;用链式存储实现线性表 = 链表。
二、线性表的两种实现:顺序存储 vs 链式存储
顺序存储
把逻辑上相邻的元素,存放在一段连续的物理存储单元里。逻辑关系由物理位置关系直接体现。
大白话:像电影院的连号座位,第 3 个和第 4 个人就是挨着坐的,找人也方便(座位号一算就到)。
链式存储
逻辑上相邻的元素,存放在任意的一组物理存储单元 中,元素之间的逻辑关系用指针表示。
大白话:像寻宝游戏,每张纸条上写着"下一个线索在哪儿",纸条本身可以散落在任何地方。
用一张图理解
假设线性表 L = ("张三", "李四", "王五", "赵六"):
顺序存储(挤在一起):
┌────────┬────────┬────────┬────────┐
│ 张三 │ 李四 │ 王五 │ 赵六 │ 地址连续
└────────┴────────┴────────┴────────┘
1000 1004 1008 1012
链式存储(散落各处,靠指针牵着):
┌────────┬─────┐ ┌────────┬─────┐ ┌────────┬─────┐ ┌────────┬──────┐
│ 张三 │ ●──┼────▶│ 李四 │ ●──┼────▶│ 王五 │ ●──┼────▶│ 赵六 │ NULL │
└────────┴─────┘ └────────┴─────┘ └────────┴─────┘ └────────┴──────┘
地址 2000 地址 512 地址 9000 地址 300
一句话记住区别:
顺序表:逻辑上相邻,物理上也相邻。
链表:逻辑上相邻,物理上不一定相邻。
三、先补一个小知识:C++ 的"引用"
严蔚敏《数据结构(C 语言版)》里掺了一些 C++ 的引用语法(&),学校教材也爱这么写。不懂它,看书上的函数原型就会懵。
引用是什么?
引用不是新定义一个变量,而是给已存在的变量取了个"别名"。 编译器不会为引用单独开内存,它和被引用的变量共用同一块内存。
就像《水浒传》里:李逵,宋江叫他"铁牛",江湖上人称"黑旋风"------三个名字,同一个人。
cpp
int a = 10;
int& ra = a; // ra 是 a 的别名
ra = 20; // 改 ra 就是改 a
printf("%d %d", a, ra); // 输出 20 20
引用的三条铁律
- 定义时必须初始化
- 一个变量可以有多个引用
- 引用一旦引用了某个实体,就不能再改去引用别人
cpp
int& r; // ❌ 编译报错:引用必须初始化
int a = 1, b = 2;
int& r = a;
r = b; // ⚠️ 这不是让 r 改指 b,而是把 b 的值赋给 a!
传值 / 传指针 / 传引用 对比
| 传参方式 | 形参是什么 | 能否改变实参 | 大对象有额外拷贝开销吗 |
|---|---|---|---|
| 传值 | 实参的拷贝 | ❌ 不能 | 有 |
| 传指针 | 实参的地址 | ✅ 能 | 无 |
| 传引用 | 实参的别名 | ✅ 能 | 无 |
cpp
void Swap1(int x, int y) { int t = x; x = y; y = t; } // ❌ 改不了实参
void Swap2(int* x, int* y) { int t = *x; *x = *y; *y = t; } // ✅ 可以
void Swap3(int& x, int& y) { int t = x; x = y; y = t; } // ✅ 可以,写法更清爽
为什么要讲这个? 因为后面链表初始化时要"修改调用方的头指针"。教材里常见两种写法:
- 用
void ListInit(SLNode** pphead)------ 二级指针(纯 C 做法,本教程用这个) - 用
void ListInit(SLNode*& phead)------ 指针的引用(C++ 做法,更省事)
小贴士:想用引用语法,把
.c文件改名成.cpp就行。C++ 兼容 C,但反过来不行。
四、顺序表(Sequential List)
4.1 静态顺序表 vs 动态顺序表
静态顺序表:用固定大小的数组存数据。
c
#define N 100 // 写死容量,方便改类型/容量
typedef int SLDataType; // 方便以后把 int 换成别的类型
typedef struct SeqList {
SLDataType a[N]; // 固定大小的静态数组
int size; // 已经存了多少个有效数据
} SL;
- ✅ 优点:实现简单
- ❌ 缺点:容量写死了。开小了不够用,开大了浪费
动态顺序表 :用一个堆上动态申请的数组,不够了就扩容。
c
typedef struct SeqList {
SLDataType* a; // 指向动态申请的数组
int size; // 有效数据个数
int capacity; // 当前容量(能装多少个)
} SL;
- ✅ 优点:适用场景广,不确定存多少数据也不怕
- ❌ 缺点:实现稍复杂
实际开发一律用动态顺序表。 静态的会了,动态的就是"手拿把掐"。
4.2 完整接口实现(可直接编译运行)
SeqList.h:
c
#pragma once
#include <stdio.h>
#include <stdlib.h>
#include <assert.h>
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 SLPushFront(SL* ps, SLDataType x); // 头插
void SLPopBack(SL* ps); // 尾删
void SLPopFront(SL* ps); // 头删
void SLInsert(SL* ps, int pos, SLDataType x); // 在 pos 位置插入
void SLErase(SL* ps, int pos); // 删除 pos 位置
int SLFind(SL* ps, SLDataType x); // 按值查找,返回下标
SLDataType SLAt(SL* ps, int pos); // 按位序查找
SeqList.c:
c
#include "SeqList.h"
/* ---------- 初始化 ---------- */
void SLInit(SL* ps)
{
assert(ps);
ps->a = (SLDataType*)malloc(sizeof(SLDataType) * 4); // 默认给 4 个,别开太大
if (ps->a == NULL) {
perror("malloc fail"); // 申请失败
exit(-1);
}
ps->size = 0;
ps->capacity = 4;
}
/* ---------- 销毁 ---------- */
void SLDestroy(SL* ps)
{
assert(ps);
if (ps->a != NULL) { // 防止重复销毁
free(ps->a); // 堆上的空间必须还回去,否则内存泄漏
ps->a = NULL;
ps->size = 0;
ps->capacity = 0;
}
}
/* ---------- 扩容 ---------- */
void SLCheckCapacity(SL* ps)
{
assert(ps);
if (ps->size == ps->capacity) { // 满了才扩
int newCapacity = ps->capacity * 2; // 一般是 2 倍扩容
SLDataType* tmp = (SLDataType*)realloc(ps->a, sizeof(SLDataType) * newCapacity);
if (tmp == NULL) {
perror("realloc fail");
exit(-1);
}
ps->a = tmp; // ⚠️ 必须用临时指针接,直接覆盖会丢原地址
ps->capacity = newCapacity;
}
}
/* ---------- 插入(核心) ---------- */
void SLInsert(SL* ps, int pos, SLDataType x)
{
assert(ps);
assert(pos >= 0 && pos <= ps->size); // pos 是下标,合法范围 [0, size]
SLCheckCapacity(ps); // 满了先扩容
// 把 [pos, size-1] 的元素整体往后挪一格
// 从后往前挪!从前往后会覆盖掉还没搬走的数据
for (int i = ps->size; i > pos; i--) {
ps->a[i] = ps->a[i - 1];
}
ps->a[pos] = x;
ps->size++;
}
/* ---------- 删除(核心) ---------- */
void SLErase(SL* ps, int pos)
{
assert(ps);
assert(pos >= 0 && pos < ps->size);
// 把 [pos+1, size-1] 的元素整体往前挪一格
// 从前往后挪!从后往前会覆盖
for (int i = pos; i < ps->size - 1; i++) {
ps->a[i] = ps->a[i + 1];
}
ps->size--;
}
/* ---------- 查找 ---------- */
int SLFind(SL* ps, SLDataType x) // 按值查找:O(n)
{
assert(ps);
for (int i = 0; i < ps->size; i++) {
if (ps->a[i] == x) return i; // 返回下标,找不到返回 -1
}
return -1;
}
SLDataType SLAt(SL* ps, int pos) // 按位序查找:O(1),直接算地址
{
assert(ps);
assert(pos >= 0 && pos < ps->size);
return ps->a[pos];
}
void SLPrint(SL* ps)
{
assert(ps);
for (int i = 0; i < ps->size; i++) printf("%d ", ps->a[i]);
printf("\n");
}
/* ---------- 复用上面的接口实现头尾操作 ---------- */
void SLPushBack(SL* ps, SLDataType x) { SLInsert(ps, ps->size, x); }
void SLPushFront(SL* ps, SLDataType x) { SLInsert(ps, 0, x); }
void SLPopBack(SL* ps) { SLErase(ps, ps->size - 1); }
void SLPopFront(SL* ps) { SLErase(ps, 0); }
测试一下:
c
int main()
{
SL s;
SLInit(&s);
SLPushBack(&s, 1); // 1
SLPushBack(&s, 2); // 1 2
SLPushFront(&s, 0); // 0 1 2
SLInsert(&s, 1, 9); // 0 9 1 2
SLPrint(&s); // 输出:0 9 1 2
SLErase(&s, 0); // 9 1 2
SLPrint(&s); // 输出:9 1 2
printf("find 1 -> %d\n", SLFind(&s, 1)); // 输出 1
SLDestroy(&s);
return 0;
}
4.3 关键细节:扩容到底发生了什么?
realloc(ptr, size) 是扩容的核心,它有两种结局:
① 原地扩容:数组后面刚好还有空地
原地址 ──▶ [■■■■][空 空 空 空]
返回的地址和 ptr 一样
② 异地扩容:后面被别的数据占了
原地址 ──▶ [■■■■][别人的数据]
新地址 ──▶ [ ■■■■■■■■ ] 把老数据拷过来,再 free 掉老空间
返回新地址(和 ptr 不同)
两个必须记住的点:
realloc失败会返回NULL。千万别写ps->a = realloc(ps->a, ...),一旦失败,老地址就丢了,内存泄漏。必须用临时指针接(见上面的tmp)。- 扩容是有代价的(可能要搬 n 个元素),所以采用2 倍扩容 这种"前瞻式"策略,把多次小扩容合并成少数几次大扩容,均摊下来每次插入的代价仍是 O(1)。
free 的真相:free 并不是把空间"销毁"了 ,而是把这段空间的使用权还给操作系统 ,操作系统之后可能把它分给别人。所以 free 之后一定要把指针置 NULL,防止"野指针"。
4.4 复杂度分析(考试必背)
| 操作 | 最好 | 最坏 | 平均 |
|---|---|---|---|
| 按位序查找(随机访问) | O(1) | O(1) | O(1) |
| 按值查找 | O(1)(在表头) | O(n)(在表尾/不存在) | O(n) |
| 插入 | O(1)(尾插,无需扩容) | O(n)(头插,要挪 n 个 + 可能异地扩容搬 n 个) | O(n) |
| 删除 | O(1)(尾删) | O(n)(头删,要挪 n-1 个) | O(n) |
平均情况怎么来的? 假设在任意位置插入/删除的概率相等(都是 1/(n+1) 或 1/n),那么移动次数的期望是一个等差数列的平均值:
- 插入平均移动
n/2次 → O(n) - 删除平均移动
(n-1)/2次 → O(n) - 按值查找平均比较
(n+1)/2次 → O(n)
一句话总结顺序表:访问快(O(1)),插入删除慢(O(n)),空间要连续。
4.5 真题演练(2023 年 408 选择题)
题目 :下列对顺序存储的有序表(长度为 n)实现给定操作的算法中,平均时间复杂度为 O(1) 的是____
A. 查找包含指定值元素的算法
B. 插入包含指定值元素的算法
C. 删除第 i(1≤i≤n)个元素的算法
D. 获取第 i(1≤i≤n)个元素的算法
答案:D
解析:
- A/B/C 都需要按值查找或挪动元素。挪动和比较次数基本是 1~n 的等差数列,平均在 n/2 左右 → 都是 O(n)
- D 是位序查找(随机访问) 。顺序表物理上连续,通过首地址 +
i * sizeof(元素)常数次运算就能算出任意位置的地址 → O(1) ✅
4.6 两道经典 LeetCode 题(顺序表思想)
① 27. 移除元素
给你一个数组
nums和一个值val,原地移除所有等于val的元素,返回新长度。
思路 1(暴力) :从前往后遍历,遇到等于 val 的就把后面元素整体往前挪。最坏情况(大部分元素都等于 val)时间复杂度 O(n²)。❌
思路 2(空间换时间) :新建一个 tmp 数组,把不等于 val 的元素依次放进去,再拷回原数组。时间 O(n),但空间 O(n)。
思路 3(双指针,最优) :两个指针 src 和 dst 都从 0 开始。src 负责遍历,只要 src 指向的元素不等于 val,就把它覆盖到 dst 位置,然后 dst++。时间 O(n),空间 O(1)。✅
c
int removeElement(int* nums, int numsSize, int val)
{
int src = 0, dst = 0;
while (src < numsSize) {
if (nums[src] != val) {
nums[dst] = nums[src]; // 把"要保留的"往前放
dst++;
}
src++;
}
return dst; // dst 就是新的有效长度
}
② 26. 删除有序数组中的重复项
原地删除有序数组中的重复元素,使每个元素只出现一次,返回新长度。
同样是双指针。因为数组有序,所以重复元素一定挨着:
c
int removeDuplicates(int* nums, int numsSize)
{
if (numsSize == 0) return 0;
int dst = 1; // 第一个元素一定保留
for (int src = 1; src < numsSize; src++) {
if (nums[src] != nums[dst - 1]) { // 和"已保留的最后一个"比较
nums[dst] = nums[src];
dst++;
}
}
return dst;
}
五、链表(Linked List)
5.1 为什么需要链表?
先看顺序表的痛点:
- 插入/删除平均要挪 O(n) 个元素
- 空间必须连续,满了要扩容(扩容有代价,还可能浪费空间)
于是就有了链式结构。
链表的本质:需要存一个数据,就去申请一块空间,然后用指针把它们串起来。 存数据和指针的这块空间叫结点(Node)。
生活类比:链表就像高铁车厢。节假日人多,可以在任意位置加挂车厢;人少就摘掉,不用把整列车重造。
5.2 单链表
5.2.1 结点定义
c
typedef int SLTDataType;
typedef struct SListNode {
SLTDataType data; // 数据域:存值
struct SListNode* next; // 指针域:存下一个结点的地址
} SLTNode;
⚠️ 语法重点 :结构体里面为什么写
struct SListNode* next而不是SLTNode* next?因为在结构体定义内部 ,
typedef的别名SLTNode还没生效(typedef 要在整个声明结束后才可用)。所以必须写完整的struct SListNode*。
5.2.2 头指针、头结点、尾结点
-
头指针:指向第一个结点的指针
-
尾结点 :它的
next指向NULL -
头结点(哨兵位) :一个不存有效数据的额外结点,纯粹为了让代码好写
带头结点的单链表:
head ──▶ ┌────┬───┐ ┌────┬───┐ ┌────┬──────┐
│ 哨 │ ●─┼──▶│ a1 │ ●─┼──▶│ a2 │ NULL │
└────┴───┘ └────┴───┘ └────┴──────┘
头结点(不存数据) 第1个有效结点 尾结点不带头结点的单链表:
head ──▶ ┌────┬───┐ ┌────┬──────┐
│ a1 │ ●─┼──▶│ a2 │ NULL │
└────┴───┘ └────┴──────┘
带头结点的好处(这是本节的灵魂):
插入和删除第一个结点时,不用单独判断 。因为第一个有效结点前面永远有个头结点,
prev永远不为空。
⚠️ 一个常见误区 :有些书把链表长度存在头结点的数据域里。这不太合理 ------如果链表存 char,长度一长就溢出了。头结点就老老实实当哨兵。
听说经验 :OJ(力扣)和校招给的链表基本都不带头结点 ;学校考试和 408 题目基本都带头结点 。题目一般会说明,如果没说,答题时最好主动注明你的实现带不带头结点,方便阅卷老师理解。
5.2.3 完整接口实现
SList.h:
c
#pragma once
#include <stdio.h>
#include <stdlib.h>
#include <assert.h>
typedef int SLTDataType;
typedef struct SListNode {
SLTDataType data;
struct SListNode* next;
} SLTNode;
SLTNode* BuyNode(SLTDataType x); // 申请一个新结点
SLTNode* SListInit(void); // 初始化(返回哨兵位)
void SListDestroy(SLTNode* phead); // 销毁
void SListPrint(SLTNode* phead); // 打印
int SListSize(SLTNode* phead); // 有效元素个数
SLTNode* SListFind(SLTNode* phead, SLTDataType x); // 按值查找
SLTNode* SListAt(SLTNode* phead, int pos); // 按下标查找
void SListInsert(SLTNode* phead, int pos, SLTDataType x);// 下标 pos 处插入
void SListErase(SLTNode* phead, int pos, SLTDataType* px);// 删除下标 pos 并带出值
void SListPushFront(SLTNode* phead, SLTDataType x); // 头插
void SListPushBack(SLTNode* phead, SLTDataType x); // 尾插
void SListPopFront(SLTNode* phead); // 头删
void SListPopBack(SLTNode* phead); // 尾删
SList.c:
c
#include "SList.h"
/* ---------- 申请新结点 ---------- */
SLTNode* BuyNode(SLTDataType x)
{
SLTNode* newNode = (SLTNode*)malloc(sizeof(SLTNode));
if (newNode == NULL) {
perror("malloc fail");
exit(-1);
}
newNode->data = x;
newNode->next = NULL;
return newNode;
}
/* ---------- 初始化:返回哨兵位头结点 ---------- */
SLTNode* SListInit(void)
{
// ⚠️ 不能定义成局部变量 SLTNode head; 出了函数就销毁了
// 所以必须从堆上申请
return BuyNode(-1); // 头结点的 data 不用管
}
/* ---------- 打印 / 求长度:核心是遍历 ---------- */
void SListPrint(SLTNode* phead)
{
assert(phead);
SLTNode* cur = phead->next; // ⚠️ 跳过头结点,它不存有效数据
while (cur != NULL) {
printf("%d -> ", cur->data);
cur = cur->next; // 从当前结点的 next 拿到下一个结点
}
printf("NULL\n");
}
int SListSize(SLTNode* phead)
{
assert(phead);
int n = 0;
SLTNode* cur = phead->next;
while (cur) { n++; cur = cur->next; }
return n; // ⚠️ 头结点不算在内
}
/* ---------- 查找 ---------- */
SLTNode* SListFind(SLTNode* phead, SLTDataType x)
{
assert(phead);
SLTNode* cur = phead->next;
while (cur) {
if (cur->data == x) return cur; // 找到第一个就返回
cur = cur->next;
}
return NULL;
}
SLTNode* SListAt(SLTNode* phead, int pos)
{
assert(phead);
SLTNode* cur = phead->next;
int i = 0;
while (cur && i < pos) { cur = cur->next; i++; } // 计数 + 非空 一起判断
return cur; // 可能为 NULL,说明第 pos 个结点不存在
}
/* ---------- 插入(本节最重点!) ---------- */
void SListInsert(SLTNode* phead, int pos, SLTDataType x)
{
assert(phead);
// 找到第 pos 个结点的【前一个】结点,才能改链接关系
SLTNode* prev = SListAt(phead, pos - 1);
if (prev == NULL) { printf("插入位置非法\n"); return; }
SLTNode* newNode = BuyNode(x);
// ⚠️⚠️ 这两句顺序绝对不能交换!
newNode->next = prev->next; // ① 新结点先接管后面的链表
prev->next = newNode; // ② 前一个结点再指向新结点
}
为什么这两句不能交换? 画个图就懂了:
交换前:prev ──▶ [ B ] ──▶ [ C ] ──▶ ...
✅ 正确顺序:
① newNode->next = prev->next; newNode ──▶ [B]
prev ──▶ [B],newNode ──▶ [B]
② prev->next = newNode;
prev ──▶ [newNode] ──▶ [B] ──▶ [C] ✔ 成功
❌ 错误顺序:
① prev->next = newNode;
prev ──▶ [newNode],而 [B] 没人指向它了!链表断了
② newNode->next = prev->next; 此时 prev->next 已经是 newNode 自己
newNode ──▶ newNode ← 自己指自己,完蛋
头插为什么不用特殊处理?
c
// 头插:在 pos = 0 处插入
// SListAt(phead, -1) 返回什么?循环 i < -1 一次都不执行,返回 phead->next...
等等,这里要小心。上面的实现里 SListAt(phead, -1) 会返回第一个有效结点,这不是我们要的。更稳妥的写法 是让 SListAt 支持 pos = -1 返回头结点:
c
SLTNode* SListAt(SLTNode* phead, int pos)
{
assert(phead);
SLTNode* cur = phead; // 从【头结点】开始!
int i = -1;
while (cur && i < pos) { cur = cur->next; i++; }
return cur;
}
这样 SListAt(phead, -1) 返回头结点,SListAt(phead, 0) 返回第一个有效结点。于是头插时 prev 就是头结点,代码和中间插入完全一样,不需要单独判断 ------ 这就是哨兵位的价值!
5.2.4 删除(顺序同样要命)
c
void SListErase(SLTNode* phead, int pos, SLTDataType* px)
{
assert(phead);
SLTNode* prev = SListAt(phead, pos - 1);
// 第 pos 个结点不存在 → 非法删除
if (prev == NULL || prev->next == NULL) {
printf("删除位置非法\n");
return;
}
SLTNode* del = prev->next; // ⚠️ 先保存要删的结点
*px = del->data; // 用输出参数把删掉的值带出去
prev->next = del->next;
free(del);
del = NULL;
}
同样有顺序陷阱 :必须先保存被删结点 del,再让 prev->next 跨过它。否则一旦改了 prev->next,就再也找不到 del 了,free 不掉 → 内存泄漏。
c
void SListDestroy(SLTNode* phead)
{
SLTNode* cur = phead;
while (cur) {
SLTNode* next = cur->next; // ⚠️ 先存下一个,否则 free 完就找不到了
free(cur);
cur = next;
}
}
规律总结 :链表里凡是"先断开再找"的操作,都容易翻车。操作指针前,先把要用的地址存进临时变量。
5.2.5 四道单链表高频题
① 876. 链表的中间结点 ------ 快慢指针
- 思路 1:遍历一遍求长度 n,再遍历一遍找第 n/2 个。O(n),需要两趟
- 思路 2(更优) :
slow一次走 1 步,fast一次走 2 步。fast走到尾时,slow刚好在中间。O(n),一趟搞定 ✅
c
struct ListNode* middleNode(struct ListNode* head)
{
struct ListNode *slow = head, *fast = head;
// 偶数个结点:fast 走到 NULL 结束
// 奇数个结点:fast 走到尾结点结束
while (fast && fast->next) {
slow = slow->next;
fast = fast->next->next;
}
return slow;
}
② 面试题 02.02. 返回倒数第 k 个结点 ------ 快慢指针
fast 先走 k 步,然后 slow 和 fast 同时走。当 fast 走到空时,slow 就在倒数第 k 个。(slow 和 fast 之间始终隔着 k 个结点)
c
int kthToLast(struct ListNode* head, int k)
{
struct ListNode *fast = head, *slow = head;
while (k--) fast = fast->next; // fast 先走 k 步
while (fast) { // 同时走
slow = slow->next;
fast = fast->next;
}
return slow->val;
}
③ 203. 移除链表元素 ------ 哨兵位的经典应用
准备 prev 和 cur 两个指针遍历。当 cur->val == val 时,让 prev->next = cur->next,然后 free(cur)。
麻烦在哪? 如果第一个结点 就是要删的,prev 是空,得单独处理并更新头指针。加一个哨兵位就完美解决 :prev 永远不为空。
c
struct ListNode* removeElements(struct ListNode* head, int val)
{
struct ListNode* dummy = (struct ListNode*)malloc(sizeof(struct ListNode));
dummy->next = head; // 哨兵位,保证 prev 永不为空
struct ListNode* prev = dummy, *cur = head;
while (cur) {
if (cur->val == val) {
prev->next = cur->next; // 跨过 cur
free(cur);
cur = prev->next;
} else {
prev = cur;
cur = cur->next;
}
}
head = dummy->next;
free(dummy);
return head;
}
④ 206. 反转链表 ------ 头插法
思路极简:cur 遍历原链表,每取下一个结点头插到新链表,走完就反转了。
c
struct ListNode* reverseList(struct ListNode* head)
{
struct ListNode* newHead = NULL;
struct ListNode* cur = head;
while (cur) {
struct ListNode* next = cur->next; // ① 先存下一个
cur->next = newHead; // ② 头插到新链表
newHead = cur; // ③ 更新新链表头
cur = next; // ④ 继续下一个
}
return newHead;
}
⑤ 21. 合并两个有序链表 ------ 归并 + 哨兵位
和归并两个有序数组思路一样:依次比较,取小的尾插到新链表。不同点:链表可以把结点"摘下来"直接挂过去,数组只能拷贝数据。
用哨兵位后,插入第一个结点时不用单独判断(因为 tail 始终不为空)。
c
struct ListNode* mergeTwoLists(struct ListNode* l1, struct ListNode* l2)
{
if (l1 == NULL) return l2;
if (l2 == NULL) return l1;
struct ListNode* dummy = (struct ListNode*)malloc(sizeof(struct ListNode));
struct ListNode* tail = dummy;
while (l1 && l2) {
if (l1->val <= l2->val) { tail->next = l1; l1 = l1->next; }
else { tail->next = l2; l2 = l2->next; }
tail = tail->next;
}
tail->next = l1 ? l1 : l2; // 没走完的直接挂上
struct ListNode* head = dummy->next;
free(dummy);
return head;
}
⑥ 160. 相交链表
- 分别遍历两个链表找到尾结点,同时求出两个长度
- 尾结点相同 → 相交;不同 → 不相交(因为相交后必然共享同一段尾巴)
- 算出长度差,让长的先走差距步 ,然后两个一起走,第一个相等的结点就是交点
⑦ 141/142. 环形链表 ------ 快慢指针判环
fast 一次 2 步,slow 一次 1 步。不带环,fast 先到尾;带环,两人都进环后开始"追赶",fast 一定会追上 slow。
找环入口的两种方法:
- 方法 1(转成交叉链表) :找到相遇点后,把相遇点的
next置空作为"尾",问题就变成"求两个链表的交点" - 方法 2(更简单) :一个指针从表头 出发,另一个从相遇点 出发,都每次走 1 步,它们一定在环入口相遇
拓展思考(校招可能深挖):
slow走 1 步、fast走 2 步,一定能相遇吗? → 能。因为进入环后,两者的距离每次缩小 1,一定能缩到 0slow走 1 步、fast走 3 步呢? → 距离每次缩小 2,如果环长是偶数、初始距离是奇数,就可能永远跳过,不一定相遇
⑧ 138. 随机链表的复制(较难)
每个结点多一个 random 指针,指向链表中任意结点或 NULL。
- 思路 1 :先把链表复制一份,
random先空着。然后对每个结点,遍历原链表找出它的random是第几个,再去拷贝链表对应位置。每个random都是 O(n) → 整体 O(n²) - 思路 2(O(n),很巧妙) :
- 把拷贝结点插到原结点后面 (
A → A' → B → B' → ...) - 这样原结点的
random->next就是拷贝结点的random - 把拷贝结点取下来尾插成新链表,最后恢复原链表
- 把拷贝结点插到原结点后面 (
5.3 链表的分类
按三个维度组合,一共 8 种链表:
| 维度 | 选项 |
|---|---|
| 方向 | 单向 / 双向 |
| 头结点 | 带头结点 / 不带头结点 |
| 循环 | 循环 / 非循环 |
只需要重点掌握 3 种(考试和实践出现的基本就是这三种):
- 带头结点单链表
- 不带头结点单链表
- 带头双向循环链表
其他组合都能触类旁通。
5.3.1 双向链表
单链表的痛点:找前驱很麻烦 ,只能从头遍历,O(n)。所以只有当前结点的指针 pos(没有头指针)时,想在 pos 之前插入、或删除 pos 本身,都做不到。
双向链表每个结点多一个前驱指针:
c
typedef struct DListNode {
LTDataType data;
struct DListNode* prev; // 指向前驱
struct DListNode* next; // 指向后继
} DLTNode;
适用场景:倒着遍历、删除当前结点、在当前结点前插入。
5.3.2 循环链表
单链表适合头插头删 (O(1)),但尾插尾删要 O(n)(得先找尾)。而实践中头尾操作比"第 i 个位置"的操作更常见。
循环链表 就是让尾结点的 next 指回头结点:
双向循环链表:
┌─────────────────────────────────┐
▼ │
┌────────┐ ┌────┐ ┌────┐ ┌────┐
│ 头结点 │◀──▶│ a1 │◀──▶│ a2 │◀──▶│ a3 │
└────────┘ └────┘ └────┘ └────┘
▲ │
└─────────────────────────────────┘
- 单循环链表 :尾结点
next指向头结点 - 双向循环链表 :尾结点
next指向头结点,头结点prev指向尾结点
为什么双向循环链表最实用? 因为可以通过头结点的 prev 直接找到尾结点 ,于是头插、尾插、头删、尾删全都是 O(1) !C++ STL 里的 list 就是这么实现的。
5.3.3 单链表 vs 循环单链表(两处不同)
① 初始化不同 :循环单链表要让头结点的 next 指向自己。
c
// 单链表
phead->next = NULL;
// 循环单链表
phead->next = phead;
② 遍历结束条件不同 :循环链表不能用"指针为空"作为结束条件(永远不为空),而是"走一圈回到头结点"。
c
// 单链表
while (cur != NULL) { ... cur = cur->next; }
// 循环单链表
while (cur != phead) { ... cur = cur->next; }
5.3.4 带头双向循环链表的核心操作
在 pos 之后插入 newNode (要改 4 个指针):
c
void DCListInsert(DLTNode* pos, LTDataType x)
{
assert(pos);
DLTNode* newNode = BuyNode(x);
// pos 可以是任意结点(包括头结点),不用考虑前后为空
newNode->next = pos->next;
pos->next->prev = newNode; // 原后继的 prev 指向新结点
newNode->prev = pos;
pos->next = newNode;
}
顺序建议 :先处理
newNode和自己的前后,把pos->next = newNode放到最后 ,就不会因为pos->next被提前改动而找不到原后继。
删除 pos 结点(改 2 个指针,无先后顺序):
c
void DCListErase(DLTNode* pos)
{
assert(pos && pos->next != pos); // 不能删头结点
pos->prev->next = pos->next;
pos->next->prev = pos->prev;
free(pos);
}
这两句没有先后关系 ,因为从头到尾
pos自己的prev和next都没被修改。
对比一下 :如果是非循环 的双向链表,pos 是尾结点时还要处理 pos->next == NULL,而且 pos 不能是头结点。循环结构把这些边界情况全消掉了 ------ 这就是它好用的原因。
5.3.5 真题演练
① 2023 年 408 选择题 02(双向链表插入)
非空双向链表 L,结点结构
| prev | data | next |。在指针p所指结点(非尾结点 )之后插入指针s所指新结点。执行完s->next = p->next; p->next->prev = s;后,还需要执行的是( )
答案:C (s->prev = p; p->next = s;)
解析 :非尾结点 → 不用考虑 p 的后继为空。这题的技巧就是画图,而且题目故意先把容易让人晕的那两句给了出来,不画图真的会晕菜。
② 2016 年 408 选择题 02(双向循环链表删除)
带头结点的双向循环链表 L,删除指针
p所指结点,正确的语句序列是( )
答案:D (p->prev->next = p->next; p->next->prev = p->prev;)
解析 :双向循环 → 不用考虑 p 的后继为空。同样靠画图。这两句没有先后关系。
③ 2021 年 408 选择题 01(单循环链表删除第一个元素)
头指针
h指向带头结点的非空单循环链表,p是尾指针,q是临时指针。删除链表第一个元素,正确的是( )
答案:D
解析 :先让 q 指向第一个结点,再让 h 指向第二个结点 。h 一旦先动,就找不到第一个结点了。
这题的精髓在"特殊情况" :p 是尾指针,看起来像个路人。但当链表只有一个结点 时,被删的第一个结点同时也是尾结点 !这时 p == q,删完后尾结点就是头结点本身。这道题完美体现了 408 为什么要求你考虑边界情况。
六、顺序表 vs 链表:到底该用哪个?
顺序表和链表逻辑结构都是线性结构 ,最大的区别在存储结构(物理结构)。
优缺点对照表
| 顺序表 | 链表 | |
|---|---|---|
| 随机访问(按下标) | ✅ O(1) | ❌ O(n) |
| 插入/删除(除头尾) | ❌ O(n),要挪数据 | ✅ O(1)(双向循环链表确定位置后) |
| 头插/头删 | ❌ O(n) | ✅ O(1) |
| 尾插/尾删 | ✅ O(1)(均摊) | 单链表 O(n);双向循环链表 O(1) |
| 空间管理 | 需扩容,可能有浪费 | ✅ 按需申请释放,不需扩容 |
| CPU 缓存命中率 | ✅ 高 | ❌ 低 |
| 内存碎片 | ✅ 不产生 | ❌ 容易产生 |
详细说明
顺序表的优点:
- 支持下标随机访问(O(1))。实践中非常有用:排序、排序后二分查找、优先级队列,都适合在顺序表上做
- CPU 缓存命中率高,且不产生内存碎片
顺序表的缺点:
- 插入删除要挪数据(除了尾插/尾删),O(n)。只适合大量尾插尾删的场景
- 空间不够要扩容(有代价),且可能有空间浪费
链表的优点:
- 按需申请释放空间,不需要扩容
- 双向循环链表在确定位置的情况下,任意位置插入删除都是 O(1),不挪数据
链表的缺点:
- 不支持下标随机访问
- CPU 缓存命中率低,容易产生内存碎片
核心结论 :顺序表和链表相辅相成 ------ 顺序表的优点正好弥补链表的缺点,反之亦然。实践中要根据使用场景来选择,没有绝对的好坏。
延伸:为什么顺序表"缓存友好"?(CPU 缓存)
现代 CPU 有多级缓存:L1(分指令缓存 + 数据缓存)、L2、L3。
┌─────────┐
│ CPU │
└────┬────┘
┌────▼────┐ L1:每个核独享,最小最快
┌────▼────┐ L2:每个核独享
┌────▼────┐ L3:所有核心共享
┌────▼────┐
│ 内存 │ 最大最慢
└─────────┘
- 越靠近 CPU 越小、越快;越远越大、越慢
- CPU 不会一个字节一个字节地加载 ,而是一块一块地加载 ,这个块叫 Cache Line ,主流是 64 Bytes
CPU 的读取流程 :先在 Cache 里找 → 找到就直接用(命中);没找到就去内存取,同时把这一整块调入 Cache,方便后续访问。
为什么这么设计?------ 局部性原理
- 时间局部性 :一个数据被访问了,它很可能在不久的将来又被访问
- 空间局部性 :一个数据被访问了,它相邻的数据也很可能马上被访问
于是差距就出来了:
c
// 顺序表:连续内存
for (int i = 0; i < n; i++) sum += a[i];
// 访问 a[0] 时,a[1]~a[15](64字节/4字节)一并被拉进 Cache
// 后面 15 次访问全部命中!✅
// 链表:结点散落各处
for (SLTNode* cur = head; cur; cur = cur->next) sum += cur->data;
// 每个结点的地址都是"跳"过去的,Cache Line 里装的是别人
// 基本每次都 Cache Miss,要访问内存 ❌
我们平时说"数组遍历比链表快",不只是因为复杂度 。即使都是 O(n),顺序表也常常快好几倍,因为缓存。
延伸:内存碎片
内存碎片 :内存中总空闲容量足够,但这些空闲空间被分割成许多不连续的小块,无法分配给需要连续内存的程序使用。
就像停车场有很多零散空位,但停不下一辆需要连续多个车位的大巴车。
- 顺序表 :插入删除过程中始终保持一块连续 空间,不产生内存碎片
- 链表 :不断申请和释放小块空间,会导致严重的内存碎片。比如链表结构里空闲部分明明还有 6 块空间,却申请不出超过 3 块的连续空间
七、面试/408 高频考点速查表
| 考点 | 结论 |
|---|---|
| 位序 vs 下标 | 位序从 1 开始,下标从 0 开始 |
| 顺序表按位序查找 | O(1),靠首地址 + 偏移量直接算 |
| 顺序表插入/删除 | 平均 O(n),要挪元素 |
| 顺序表扩容 | 一般 2 倍扩容,用 realloc,必须用临时指针接返回值 |
| 循环单链表初始化 | 头结点 next 指向自己 |
| 循环单链表遍历结束条件 | cur == phead,不是 cur == NULL |
| 单链表插入/删除的前提 | 先找到前一个结点 |
| 单链表插入的顺序 | newNode->next = prev->next; 在前,prev->next = newNode; 在后 |
| 单链表删除的顺序 | 先保存被删结点,再改链接关系 |
| 头结点(哨兵位)的作用 | 插入/删除第一个结点时不用单独判断 |
| 双向循环链表插入 | 改 4 个指针,pos->next = newNode 建议放最后 |
| 双向循环链表删除 | 改 2 个指针,无先后顺序 |
| OJ 链表 vs 408 链表 | OJ/校招基本不带头结点;408/学校考试基本带头结点 |
| 顺序表 vs 链表 | 顺序表访问快、缓存友好、无碎片;链表按需申请、插入删除快、但缓存不友好、有碎片 |
八、写在最后
把这篇的核心思想浓缩成三句话:
- 顺序表 = 数组 + 一个 size。它的强大在于 O(1) 随机访问,它的软肋在于插入删除要挪数据。
- 链表 = 结点 + 指针。它的强大在于按需申请、改指针就能插删,它的软肋在于找不到下标、缓存不友好。
- 头结点(哨兵位)是链表代码优雅的关键。加上它,头插头删的边界判断全部消失。
给初学者的学习建议:
- 链表的题一定要画图。408 里双向链表那几道题,答案都写着"本题的技巧就是画图"------不画图谁都会晕
- 指针操作先存地址再断开,这条规律能帮你躲掉 90% 的链表 bug
- 调试链表时,先手动构造一个只有 3~4 个结点的链表,比在 OJ 上瞎猜快得多
如果这篇对你有帮助,欢迎点赞 + 收藏,后续会继续更新栈、队列、二叉树等内容~
本文基于个人学习笔记整理,如有错误欢迎评论区指正。