1. 为什么这两道题被放在训练营第八天:反转思维的两种“形态”
先说个有意思的现象:很多人在刷LeetCode的时候,习惯把题目按“难度”归类,344反转字符串是简单题,542反转链表II摇身一变就成了中等题。但如果你真正理解了这两道题背后的共性,会发现难度差纯粹来自“线性表”这个抽象概念的两种落地形态——数组天然支持随机访问,而链表只能老老实实挨个遍历。同样一个“反转”动作,落在数组上是O(1)空间的优雅交换,落在链表上却要处理指针断裂和重连,稍不留神就出现环或者丢节点。
代码随想录的训练营把这两题放在第八天,我个人的理解是:它们是一对绝佳的“对照组”。字符串反转帮你建立“双指针从两端向中间逼近”的直觉,链表反转II则是把这个直觉放到一个需要更精细指针操作的环境里去检验。训练营的打卡节奏是“每天两题、一易一难”,但真正串起这两道题的,并不是“今天任务完成”的打卡心态,而是你要在写第二题的时候,能主动回忆起第一题的那个核心动作:交换两端、逐步收缩。
这篇文章我不打算复述一遍官方题解,而是想从训练营学员的实际视角,把这两道题拆开揉碎。包括我在写542反转链表II时踩过的坑、看评论区发现的大量共性问题、以及把这两题横向对比后总结出的思维模型。如果你正在跟代码随想录的营期,或者自己刷题刷到了链表反转这一块,这篇应该能帮你省下不少debug时间。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. LeetCode 344反转字符串:看似简单却自带三个“思维定势陷阱”
2.1 题目限制条件里的信号:为什么它要求“原地修改”
题目描述很短:编写一个函数,其作用是将输入的字符串反转过来。输入字符串以字符数组s的形式给出。不要给另外的数组分配额外空间,你必须原地修改输入数组、使用O(1)的额外空间解决这一问题。
“不要给另外的数组分配额外空间”这句话就是整道题的核心信号。很多人第一反应是reverse(s.begin(), s.end())一行搞定,这在工程里没任何毛病,但训练营的题目筛选逻辑从来不是“让你调用库函数”,而是让你理解库函数底层到底做了什么。如果我直接在打卡记录里写一行STL,这道题对我而言就完全浪费了。
再说一个容易被忽略的细节:题目里说的是“字符数组”,不是string类型的字符串。为什么?因为真正的字符串在C++/Java里是不可变对象,你没办法在原地直接改一个string的某个字符(除非你用它的可变版本或者转成数组),而vector<char>或者char[]才允许你下标访问并且赋值。所以这道题本质考的是数组操作,它把“反转字符串”包装成了“反转字符数组”。
2.2 双指针的“交换-收缩”循环是怎样自然推导出来的
你不需要背下题解。我们可以从最朴素的做法开始推导:如果要反转字符数组,最直观的方式是建一个新数组,从后往前把元素填进去,最后再复制回来。但题目要求O(1)额外空间,这条路被堵死了。那能不能直接在原数组上做?第一个元素和最后一个元素换位,第二个元素和倒数第二个元素换位……一直换到中间为止。交换两个元素需要借助一个临时变量,这就是唯一的额外空间,满足O(1)要求。
于是双指针的方案自然就出来了:
cpp复制class Solution {
public:
void reverseString(vector<char>& s) {
int left = 0;
int right = s.size() - 1;
while (left < right) {
char temp = s[left];
s[left] = s[right];
s[right] = temp;
left++;
right--;
}
}
};
这个写法里最关键的一行是while (left < right)。为什么不是<=?以长度为4的数组[a, b, c, d]为例,left和right的变化过程是:0和3交换,1和2交换,然后left变成2、right变成1,循环条件2 < 1为假,退出。如果使用<=,当left等于right时,中间那个元素自己跟自己交换一遍,纯属多余操作,虽然不影响正确性,但会多一次无意义的边界判断。对于长度为奇数的数组,比如[a, b, c],left=1、right=1时中间元素也不需要交换。所以严格小于就是最干净、最不会出错的边界。
2.3 C++还藏着哪些“看起来能一行但面试官不买账”的写法
除了reverse(),C++里还有几种写法也能实现反转。有些是标准库函数,有些是自己控制下标,我整理了一个对比表格,方便训练营同学一眼看清它们的本质差异:
| 实现方式 | 核心代码 | 额外空间 | 是否推荐 |
|---|---|---|---|
| 标准库reverse | reverse(s.begin(), s.end()); |
O(1) | 工程推荐,刷题不推荐 |
| 双指针交换 | 上文的while循环 | O(1) | 训练营核心考点 |
| 双向队列法 | deque<char> dq; 逐个头插 |
O(n) | 不满足题目要求 |
| 递归反转 | 用递归交换首尾 | O(n)栈空间 | 不满足O(1)要求,但可拓展思路 |
特别想点名递归写法:它看起来非常有“算法感”,很多人在打卡笔记里会秀一个递归版本。但题目明确要求O(1)额外空间,递归的调用栈深度是O(n),严格来说并不满足条件。面试场景下,如果你主动写递归版本,面试官可能会追问“解释一下为什么递归的空间复杂度不满足要求”,这个问题的坑很深,因为很多人会误以为“没有显式分配数组就是O(1)”。函数调用栈也是额外空间,这个意识一定要建立起来。
3. LeetCode 542反转链表II:区间反转的边界精细操作是第一道门槛
3.1 训练营版本和力扣原题之间的“编号疑云”
先分享一个你可能已经发现的谜团:训练营标题写的是“LeetCode 542反转链表II”,但力扣官网搜索“反转链表 II”默认跳出的是第92题,而542是一道完全不相干的“01矩阵”题。这其实是代码随想录往期训练营的一个经典笔误,后来大家已经默认“542”指的就是92题——Reverse Linked List II。我在这里给新学员提个醒:别在力扣上搜“542”,你要找的是LeetCode 92 反转链表II。题目的要求是反转链表从第left个节点到第right个节点这一段,其余节点不变。
这种“编号对不上”的情况在跟着训练营刷题时偶尔会出现,因为训练营的课表可能是从旧版本复制过来的,而LeetCode题目编号偶尔会随着题库扩充发生变化。遇到这种情况,一定以题目名字为准,搜名字最靠谱。
3.2 先理解“区间反转”和“整体反转”的差别
如果你已经刷过206题反转整个链表,再看92题第一反应可能是:都是反转,把整条链表反转的代码改一改不就行了?这个思路方向是对的,但有一个本质区别:反转整条链表时,反转完之后头节点会变(原来的尾节点变成新头),而区间反转时,链表的头和尾大概率不变,只是中间一段被翻转了。因此,你需要额外处理四个关键节点:区间前一个节点(prev)、区间第一个节点(leftNode)、区间最后一个节点(rightNode)、区间后一个节点(succ)。只有把这条链表“切”出来,反转,再“接”回去,才不会影响其他部分。
这里我用一个具体的例子来说明。假设链表是1->2->3->4->5,要求反转left=2到right=4之间的节点,期望结果是1->4->3->2->5。在这个例子里:
- prev = 节点1(索引为1的节点)
- leftNode = 节点2
- rightNode = 节点4
- succ = 节点5
反转之后要做的操作是:prev->next = rightNode(节点1指向节点4),leftNode->next = succ(节点2指向节点5)。这步连接非常重要,漏掉任何一个,链表就会断成两截或者出现环。
3.3 三步走:定位区间、断开子链、反转拼接
先上代码,再一点一点解释每个细节为什么这么写。这是经典的三步思路:先遍历到区间起点,把子链表“切”出来,然后用反转整个链表的方法反转子链表,最后接回去。
cpp复制class Solution {
public:
ListNode* reverseBetween(ListNode* head, int left, int right) {
// 因为头节点可能发生变化(比如left=1),创建虚拟头节点
ListNode* dummy = new ListNode(0);
dummy->next = head;
// 第一步:让prev移动到left的前一个节点
ListNode* prev = dummy;
for (int i = 0; i < left - 1; i++) {
prev = prev->next;
}
// 此时prev指向left前一个节点
ListNode* leftNode = prev->next;
// 第二步:找到rightNode,并记录succ
ListNode* rightNode = leftNode;
for (int i = 0; i < right - left; i++) {
rightNode = rightNode->next;
}
ListNode* succ = rightNode->next;
// 第三步:切断子链表与后方的连接
rightNode->next = nullptr;
// 第四步:反转以leftNode为头、rightNode为尾的子链表
ListNode* subHead = reverseList(leftNode);
// 第五步:拼接回原链表
prev->next = subHead;
leftNode->next = succ;
return dummy->next;
}
private:
ListNode* reverseList(ListNode* head) {
ListNode* prev = nullptr;
ListNode* cur = head;
while (cur != nullptr) {
ListNode* next = cur->next;
cur->next = prev;
prev = cur;
cur = next;
}
return prev;
}
};
这版代码对应的反转子链表函数,就是用迭代法反转整条链表的经典实现。如果对迭代反转还不熟练,我建议你在草稿纸上画出三个节点、三个指针(prev/cur/next)的流动过程,这是链表题的基本功。
3.4 虚拟头节点的作用:不是为了炫技,而是为了统一逻辑
很多人在left=1的时候会卡住。如果不用虚拟头节点,当left=1时,prev应该是谁?它不存在,因为头节点前面没有节点。你当然可以单独写一个if (left == 1)分支,但这会让代码变得啰嗦,也容易在边界case里埋雷。虚拟头节点(dummy node)的引入,就是为了让“第一个节点的前一个节点”这句话永远成立。
这个思想值得单独记一笔:在很多链表题里,凡是涉及“可能要操作头节点”的场景,都可以先创建一个dummy节点指向head,最后返回dummy->next。这样做的好处是,你不需要为头节点单独写判断逻辑,所有节点的处理方式完全统一。链表操作中大量bug的出现,都是因为头节点和中间节点的“待遇不一致”。
4. 从“比赛模式”切到“面试模式”:为什么第92题的实际操作误区比想象中多
4.1 最常见的三个误区:断链、死循环、引用悬空
我翻了LeetCode 92题评论区,加上训练营打卡群里大家的讨论,发现有三个错误出现频率极高。如果你自己写完代码报错,优先检查是不是踩了这三个坑。
误区一:反转后忘记把leftNode指向succ。 结果就是反转部分的前半段指向了空指针。体现在测试结果上,就是链表尾段整段丢失,比如1->2->3->4->5,反转2到4之后变成1->4->3->2,节点5不见了。问题出在第二步找rightNode的记录:把succ存下来之后,反转子链表时leftNode的next会被改成nullptr,如果不重新接上succ,节点5自然就丢了。
误区二:找rightNode的循环次数搞错。 有的是for (int i = 0; i < right - left + 1; i++),多走了一位,把succ当成了rightNode;有的是for (int i = 0; i < right - left - 1; i++),少走了一位。我的经验是:先用具体数字验证,比如left=2, right=4,区间长度为3个节点,从leftNode(节点2)出发,只需要走2步到达节点4,所以是right - left步。
误区三:过度依赖递归实现,导致子链表反转后返回的“新头”出错。 递归反转链表如果用不好,很容易在返回时把子链表的头尾弄颠倒。训练营阶段我不建议在区间反转里强行用递归,先把迭代版本的reverseList吃透,后续再优化也不迟。
4.2 一个容易忽略的细节:区间长度为1时,代码必须“原地不动”
有些人在写测试用例时会遇到一个有趣的情况:left=3, right=3,即区间只有一个节点,反转等于没反转。这种情况下代码应该原封不动返回原链表。
我检查了一下上面的代码:prev走left-1步到达节点2,leftNode指向节点3,rightNode循环步数是0,所以rightNode仍然等于leftNode,succ = rightNode->next,也就是节点4。然后rightNode->next = nullptr把节点3指向空,反转子链表(只有一个节点,反转后还是它自己),最后prev->next = 节点3、节点3->next = 节点4,链表原封不动。没问题。
但如果你用的是另一种实现方式,比如“区间反转版双指针法”,就有可能在left == right时多做一次交换,导致值变了或者指针乱了。所以写完代码后,一定要自己测试left == right的边界用例,这是最常见的隐藏bug来源。
4.3 另一种解题流派:头插法在区间内原地反转
训练营和LeetCode评论区还有一个高票解法,不需要先找到rightNode,而是每遍历到一个节点就把它“头插”到prev后面。这个思路比“切出-反转-再接回”更紧凑,代码短一些,但对指针理解的抽象要求更高。我也把它列出来,作为对比。
cpp复制class Solution {
public:
ListNode* reverseBetween(ListNode* head, int left, int right) {
ListNode* dummy = new ListNode(0);
dummy->next = head;
ListNode* prev = dummy;
for (int i = 0; i < left - 1; i++) {
prev = prev->next;
}
ListNode* cur = prev->next;
for (int i = 0; i < right - left; i++) {
ListNode* next = cur->next;
cur->next = next->next;
next->next = prev->next;
prev->next = next;
}
return dummy->next;
}
};
这个头插法的思路是:每轮把cur的下一个节点(即next)摘下来,插到prev的后面。比如1->2->3->4->5反转2到4:第一轮把节点3插到节点1后面变成1->3->2->4->5;第二轮把节点4插到节点1后面变成1->4->3->2->5。整个过程不需要单独反转子链表,也不需要记录succ。它更适合面试时临场默写,因为代码更短、变量更少。 但代价是你必须理解“每轮头插”的指针变化,不然很容易把prev->next和cur->next写混。
我个人的建议是:两种解法都手写一遍,不要只看不练。你至少要能达到这样一个水平——别人提起“区间反转”,你脑子里能同时浮现出这两种方案的实现步骤和它们各自的边界处理差异。
5. 两题横向对比:同一套“反转”思维在不同数据结构上的迁移方法
5.1 数组反转和链表反转的真正差异:随机访问 vs 顺序访问
回到开头那个问题:为什么344是简单题,92是中等题?核心差异就一条——数组可以通过下标O(1)访问任意位置,链表必须从头遍历。
所以344反转字符串的双指针,每次交换时left和right都能准确找到对方;而92题你没办法直接找到“第left个节点”,必须用一个循环先走过去。这就是为什么链表题里大量出现“先找到某个位置”的遍历逻辑。你如果能把这种差异想通,后面做链表相关的其他题目,比如删除链表的倒数第N个节点、两两交换链表中的节点,思路会顺很多。
5.2 两种反转代码的“同构性”拆解
其实两张代码可以做一个非常直观的映射:
344反转字符串:
- 左指针指向第一个元素
- 右指针指向最后一个元素
- 交换左右指针所指元素
- 左指针右移,右指针左移
206/92链表反转:
- 一个指针指向当前节点
- 一个指针指向当前节点的前一个节点
- 把当前节点的next指向前一个节点
- 两个指针都向前移动
你会发现两者的骨架高度相似:都是维护两个指针,都是不断“交换/重定向”指向关系,都是通过循环把区间从头扫到尾。 区别只在于数组用下标移动,链表用next指针移动;数组是交换元素值,链表是改变节点指向。
5.3 抽象层的价值:把“反转”提炼成一种可复用的脑内操作
如果只停留在“我会写这两题”,那训练营的收获就少了点。更高一层是:以后看到任意线性结构的“反转”需求,先问自己三个问题:
- 这个结构支持随机访问吗?支持就用下标双指针,不支持就用顺序遍历。
- 反转的范围是整体还是部分?部分区间的话,需要记录哪些“断点”?
- 反转后结构的“头部”会不会变?会变的话,是不是需要虚拟头节点?
举个例子,LeetCode 151题“反转字符串中的单词”,本质上也是一道“反转”题,但它在一个字符串里做了两次反转:先翻转整个字符串,再逐个翻转单词。第一次反转可以用344的双指针;第二次也还是双指针,只是指针的移动范围变成了单词边界。这种“把大问题拆成多次反转”的思路,其实是算法题里经常出现的套路。
5.4 训练营第八天真正的打卡重点:建立“反转通用流程”
说到打卡,我想聊聊训练营里一个很实际的体验。代码随想录每天会布置两道题,但从来没有人在任务描述里告诉你“这两道题背后的共同点是什么”。第八天的共同点就是“反转”。如果打卡时你只把两段代码贴上提交通过就完了,那你第二天大概率就忘了;如果能在打卡笔记里写出类似“我总结了数组反转和链表反转的异同”,这个知识才会沉淀下来。
我在自己的打卡笔记里画过一幅手写图,左侧写344的实现步骤,右侧写92的实现步骤,中间用箭头标注“交换/重定向”和“移动指针”的对应关系。很朴素,但对记忆帮助极大。你也可以尝试用类似的方式,把代码逐行翻译成“人话”,比一遍遍抄代码有效得多。
6. 实操后的三个备查技巧与自查清单
6.1 写完代码后,先跑这三个用例
不管用哪种解法实现92题,我都会跑下面三个用例,覆盖绝大多数边界情况:
| 用例 | left | right | 期望输出 | 考察点 |
|---|---|---|---|---|
| 反转整个链表 | 1 | 5 | 5->4->3->2->1 | 头节点变化 |
| 反转中间区间 | 2 | 4 | 1->4->3->2->5 | 常规区间反转 |
| 区间只有一个节点 | 3 | 3 | 1->2->3->4->5 | 边界值,容易出现多余操作 |
| 链表只有两个节点且全反转 | 1 | 2 | 2->1 | 最简单但最容易在虚拟头处理上出错 |
第三个用例是最容易被忽略的,很多人在面试时一紧张,最后一个极简用例都没测就交卷了,结果被面试官用3 3的边界值问住,非常尴尬。
6.2 调试链表的四步断点法
链表题的debug难度比普通数组题高很多,因为你没法直接在IDE里看到“整个链表”的值。我分享一个自己的调试习惯:
第一步,在反转前打印整个链表的值;
第二步,打印prev、leftNode、rightNode、succ这四个关键节点在反转前的值;
第三步,反转后立刻打印这四个节点的值;
第四步,打印整个链表,看是否与预期一致。
这四步如果都能对上,代码基本不会出问题。如果哪一步对不上,错误就锁死在对应步骤里了。这个方法我在刷链表题时反复使用,成功率非常高。
6.3 一个小技巧:在LeetCode的Playground里写测试用例
LeetCode编辑器的Playground比直接“提交”更适合训练营自测。你可以在Playground里构造任意链表再调用函数,比如:
cpp复制#include <iostream>
using namespace std;
struct ListNode {
int val;
ListNode *next;
ListNode() : val(0), next(nullptr) {}
ListNode(int x) : val(x), next(nullptr) {}
ListNode(int x, ListNode *next) : val(x), next(next) {}
};
void printList(ListNode* head) {
while (head) {
cout << head->val << " ";
head = head->next;
}
cout << endl;
}
int main() {
ListNode* n5 = new ListNode(5);
ListNode* n4 = new ListNode(4, n5);
ListNode* n3 = new ListNode(3, n4);
ListNode* n2 = new ListNode(2, n3);
ListNode* n1 = new ListNode(1, n2);
Solution s;
ListNode* result = s.reverseBetween(n1, 2, 4);
printList(result);
return 0;
}
这种本地调试的价值在于,你可以在不提交的情况下观察链表每一步的中间状态,而LeetCode的在线编辑器如果报错,只会给你最终输出,中间状态要靠自己脑补。
7. 写在最后:第八天的两道题,到底在练什么
训练营走到第八天,你已经接触了数组、字符串、链表的基本操作。这两道反转题放在一起,表面上是练“双指针”和“反转”,实际上是练一个更底层的意识——同样的逻辑,在不同数据结构上的表达方式完全不同,你要能迅速切换。
我见过不少同学在刷完344之后,信心满满地去做92,结果卡了半天。这不是因为笨,而是因为脑子里的“反转”还停留在“交换元素值”的层面,没有切换到“改变指针指向”的层面。这两者之间的那道坎,只有靠手写链表反转的循环过程才能跨过去。
如果你今天打卡还没过,别急着抄题解。我建议你回到草稿纸,把1->2->3->4->5反转2到4这个例子的每一步都画出来,标出每次循环结束后cur、prev、next分别指向谁。图画出来了,代码自然就写出来了。这一步省不得,因为链表题考的不是记忆力,而是你能不能在大脑里模拟指针的流动。
