反转单链表 是面试与教材里极高频的一题:给定头结点,把
1 → 2 → 3 → nullptr变成3 → 2 → 1 → nullptr。本文在统一约定下,讲清三种典型思路------用
vector(或栈)先存再连 、三指针原地迭代 、头插法(推荐 dummy head)重建 ------各自的时间空间、适用场景与实现细节。文末附带递归写法(本质是「隐式栈」)与常见坑。
0. 约定:结点与「谁拥有 next」
LeetCode 风格常用裸指针:
cpp
struct ListNode {
int val;
ListNode* next;
explicit ListNode(int v = 0, ListNode* n = nullptr) : val(v), next(n) {}
};
工程里更常见 std::unique_ptr<ListNode> 管整条链(见仓库 LinkedList.hpp)。反转的本质操作都是「改 next 指向」 ;下面为突出算法,代码以裸指针为主,最后一节说明 unique_ptr 时要注意的移动语义。
目标形态(整篇共用同一例子)
text
反转前: head ──▶ [1] ──▶ [2] ──▶ [3] ──▶ nullptr
反转后: head ──▶ [3] ──▶ [2] ──▶ [1] ──▶ nullptr
▲
└── 新的头结点(返回值)
1. 方法一:用 vector(或栈)辅助
1.1 思路
- 从表头沿
next走一遍,把每个结点的指针 (或值)依次push_back进vector。 - 再从尾到头(或正序遍历 vector)重新串
next:让v[i]->next = v[i-1],最后v[0]->next = nullptr,新头为v.back()。
也可以只存 val 到 vector<int>,第二遍用同一批结点按逆序重赋值------若结点结构只有 val 且允许改值 ,与存指针等价;一般面试里存 ListNode* 更直接。
1.1.1 图解:先「排成一排」,再按逆序接线
第一步:沿链走一遍,指针依次进 vector
text
链表: [1] ──▶ [2] ──▶ [3] ──▶ nullptr
vector: +-----+-----+-----+
| *1 | *2 | *3 | 下标: 0 1 2
+-----+-----+-----+
第二步:v[i]->next = v[i-1],最后 v[0]->next = nullptr,返回 v.back()
text
v[1]->next = v[0] 即 [2] ──▶ [1]
v[2]->next = v[1] 即 [3] ──▶ [2]
v[0]->next = nullptr 即 [1] ──▶ nullptr
结果: [3] ──▶ [2] ──▶ [1] ──▶ nullptr
▲
└── 新头 = v.back()
cpp
#include <vector>
ListNode* reverseWithVector(ListNode* head) {
if (!head) return nullptr;
std::vector<ListNode*> nodes;
for (ListNode* p = head; p; p = p->next)
nodes.push_back(p);
for (size_t i = 1; i < nodes.size(); ++i)
nodes[i]->next = nodes[i - 1];
nodes[0]->next = nullptr;
return nodes.back();
}
1.3 复杂度与评价
| 项目 | 结果 |
|---|---|
| 时间 | O(n),遍历两遍(收集 + 重连) |
| 额外空间 | O(n),存放 n 个指针 |
| 优点 | 思路直白、不易写错 next 顺序;可顺便做「第 k 个翻转」等需要随机访问下标的变形 |
| 缺点 | 额外数组;超长链可能受 vector 扩容影响(常数因子) |
若题目要求 O(1) 额外空间,此法不满足,需换下面两种。
用栈 :把 vector 换成 stack<ListNode*>,先一路 push,再一路 pop 重连,逻辑与 vector 逆序等价,空间仍是 O(n),只是写法不同。
text
push 顺序: 栈底 ... [1][2][3] 栈顶
pop 接线: 先拿到 3,再 2,再 1 ------ 天然逆序
2. 方法二:三指针(原地迭代,面试标配)
2.1 思路
维护三个指针(或两个指针 + 一个临时):
prev:已反转部分的头(初始nullptr)cur:当前要处理的结点nxt(可选):保存cur->next,避免改链后丢失后继
每一步:先把 cur->next 指回 prev,再把三个指针整体右移一格。本质是沿着链表走一遍,每次把一条边「反向」。
2.1.1 图解:三指针走一遍(每轮改一条边)
初始
text
prev cur nxt
│ │ │
▼ ▼ ▼
nullptr [1] ─────────────────▶ [2] ──▶ [3] ──▶ nullptr
第 1 轮 :nxt = cur->next,cur->next = prev,然后 prev←cur,cur←nxt
text
nullptr ◀── [1] [2] ──▶ [3] ──▶ nullptr
▲ ▲
prev cur(下一轮从这里继续)
第 2 轮之后同理 ,直到 cur 为空:
text
nullptr ◀── [1] ◀── [2] ◀── [3]
▲
prev = 新头
上面「一行里画三个指针」的紧凑写法:
text
nullptr ◀── 1 2 ──▶ 3 ──▶ nullptr
prev cur nxt
循环不变式:进入每轮迭代前,prev 指向的链表段已全部反转 ;cur 指向第一个尚未反转的结点。
2.2 代码
cpp
ListNode* reverseThreePointers(ListNode* head) {
ListNode* prev = nullptr;
ListNode* cur = head;
while (cur) {
ListNode* nxt = cur->next; // 先留住后继,否则下面一行会丢
cur->next = prev;
prev = cur;
cur = nxt;
}
return prev; // 结束时 cur 为 nullptr,prev 是新头
}
2.3 复杂度与评价
| 项目 | 结果 |
|---|---|
| 时间 | O(n),单趟 |
| 额外空间 | O(1),只有几个指针 |
| 优点 | 空间最优;代码短,面试最常写 |
| 缺点 | 指针顺序写反容易断链;必须先保存 nxt 再改 cur->next |
「三指针」有时也指 prev, cur, nxt 显式三个变量;也有教材写 p, q, r 交替前移,本质相同。
3. 方法三:头插法(dummy head 推荐)
3.1 思路
仍是从旧链逐个摘下 结点,但每次把摘下的结点插到「反转结果链的表头前 」。用 dummy 哨兵结点 挂在结果链前面,统一用 dummy.next 表示当前反转链的头:
ListNode dummy(0);(val无意义,仅占位;next默认为nullptr)- 循环:从旧链取
node = head,head = head->next - 头插到 dummy 后面 :
node->next = dummy.next; dummy.next = node;
这样每一轮都是同一套两句指针操作,不必单独维护 new_head 变量 ;空表时直接返回 dummy.next(为 nullptr),也无需分支。
3.1.1 图解:dummy 后永远是「当前反转链」的头
初始 :旧链 head,dummy.next 为空
text
dummy head
│ │
▼ ▼
[ D ] ──▶ nullptr [1] ──▶ [2] ──▶ [3] ──▶ nullptr
摘下 1 并头插到 dummy 后 (node->next = dummy.next; dummy.next = node;)
text
dummy
│
▼
[ D ] ──▶ [1] ──▶ nullptr head ──▶ [2] ──▶ [3] ──▶ nullptr
▲
└── dummy.next
再摘下 2
text
[ D ] ──▶ [2] ──▶ [1] ──▶ nullptr head ──▶ [3] ──▶ nullptr
再摘下 3,返回 dummy.next
text
[ D ] ──▶ [3] ──▶ [2] ──▶ [1] ──▶ nullptr
返回值:dummy.next ──▶ [3] ──▶ ...(哨兵 D 不进入业务链)
若题目禁止在堆上 new 额外结点,可用栈上的局部变量
ListNode dummy(0);,只多占一个结点大小的栈空间,不增加堆分配。
3.2 代码(dummy head)
cpp
ListNode* reverseHeadInsertDummy(ListNode* head) {
ListNode dummy(0); // 栈上哨兵;next 默认为 nullptr
while (head) {
ListNode* node = head;
head = head->next; // 旧链先走一步
node->next = dummy.next; // 接到「当前反转链」表头之前
dummy.next = node; // dummy 后始终是当前反转链的头
}
return dummy.next;
}
3.3 与「裸 new_head 指针」版等价
不用 dummy 时,上面两步对应 node->next = new_head; new_head = node;。dummy.next 扮演的角色就是 new_head ,只是插入位置写成「插在 dummy 与第一个反转结点之间」,和「链头前插入」在链表语义上相同。
3.4 与三指针的关系
两者都是原地、单趟、O(1) 空间(dummy 只占常数栈帧)。差别在心智模型:
- 三指针 :在原链上 改边方向,
prev拖着已反转段走。 - 头插法 + dummy :结果链挂在
dummy后面,每轮往dummy后插一个结点;写「反转中间一段」等题时,dummy 模式更容易推广。
写对了复杂度完全一样,面试里任选一种背熟即可;头插法更推荐带 dummy,代码形态统一、少一个手写变量名。
4. 递归:隐式栈,空间 O(n)
cpp
ListNode* reverseRecursive(ListNode* head) {
if (!head || !head->next) return head;
ListNode* new_head = reverseRecursive(head->next);
head->next->next = head;
head->next = nullptr;
return new_head;
}
递归到尾结点后,自底向上把每条边反向。调用栈深度为 O(n) ,故严格说额外空间是 O(n),不满足常数空间要求,但代码短、适合理解「反转 = 后序处理子问题」。
4.1 图解:每一层只「翻一条边」
以 1 → 2 → 3 为例:递归先处理 2 → 3,子问题返回后,右侧已是 3 → 2 → nullptr ,而 1 → 2 这条旧边还在 (1 的 next 尚未改)。此时内存里相当于「2 同时被 1 和 3 指着」,这是中间态,只在当前栈帧里存在一瞬:
text
子问题刚返回、尚未改当前层时:
[3] ──▶ [2] ──▶ nullptr
▲
│
[1] ────┘ ([1] 的 next 仍指向 [2])
当前层执行 head->next->next = head 与 head->next = nullptr 后,得到线性表:
text
[3] ──▶ [2] ──▶ [1] ──▶ nullptr
每一层各处理一条「回头边」,栈帧全部弹完即整条链反转完毕。
5. 对照小结
| 方法 | 时间 | 额外空间 | 特点 |
|---|---|---|---|
vector / 栈 |
O(n) |
O(n) |
最直观;可扩展需要下标的题 |
| 三指针 | O(n) |
O(1) |
面试默认答案 |
| 头插法(dummy) | O(n) |
O(1) |
与三指针等价类;dummy 统一插入形态,易推广到「反转区间」 |
| 递归 | O(n) |
O(n) 栈 |
理解用;深度大时可能栈溢出 |
6. 常见坑
- 改
cur->next前没保存cur->next:三指针里若先写cur->next = prev再取后继,后继已丢,只能得到半截链或死循环。 - 返回错指针 :迭代结束时新头是
prev(三指针)或dummy.next(头插法) ,不是原来的head。 - 空表 / 单结点 :上述写法一般已覆盖;递归注意
!head->next的边界。 unique_ptr版本 :不能简单「交换裸指针」而不移动所有权;通常要改写为「按unique_ptr移动子树」或仍用裸指针Node*做遍历游标、由类对象持有head的unique_ptr(实现细节见LinkedList.hpp)。
7. 练习与扩展
- LeetCode 206:反转链表(基础)
- LeetCode 92:反转链表 II(反转中间一段:
vector下标或三指针分段) - LeetCode 24:两两交换(头插 / 三指针变形)
8. 一句话总结
vector/ 栈 :先收集再逆序连接,O(n) 空间,最好想。- 三指针 :原地改向,O(1) 空间,最常考。
- 头插法 :推荐
dummy.next作反转链头 ,摘旧结点插到 dummy 后,O(1) 空间,与三指针二选一熟练即可。
记住三指针里的一句口诀:先存后继,再改指向,最后整体右移。
头插法带 dummy 时:先摘旧头、再
node->next = dummy.next、dummy.next = node,返回dummy.next。