news 2026/11/2 11:21:39

C++实现LeetCode(92.倒置链表之二)

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
C++实现LeetCode(92.倒置链表之二)

前言


LeetCode 92 题「Reverse Linked List II」要求:给定单链表的头指针和两个整数left、right(1 ≤ left ≤ right ≤ n),把链表中从第left个结点到第right个结点的部分反转,其余部分保持原样,返回新的头指针。和第 206 题「反转整个链表」相比,这道题的难点在于「只反转一段」以及「把反转后的子链表重新接回原链表」。


常见的错误有三种:一是不用哨兵结点(dummy node),导致left == 1时要单独处理头指针;二是反转后忘记把子链表的两端接回去,造成断链或环;三是循环次数算错,多反转或少反转一个结点。本文用「头插法」在 O(n) 时间、O(1) 空间内完成,并给出逐步推演。


代码以 C++17 为基准,结点使用裸指针,这是链表题最常见的写法;文末会说明工程代码中如何用std::unique_ptr表达所有权。


一、链表结点与哨兵结点


// 适用于 C++17
struct ListNode {
int val;
ListNode* next;
explicit ListNode(int x) : val(x), next(nullptr) {}
};

explicit防止ListNode n = 5;这种隐式转换。成员next在构造函数里显式置空,避免未初始化指针。


哨兵结点(dummy node)是一个不存储实际数据的辅助结点,它的next指向真正的头结点。引入它之后,「头结点也可能被反转」这一特殊情况就变成了普通情况:无论left是 1 还是其他值,我们都可以先找到「反转区间的前一个结点」,统一处理。


// 适用于 C++17
ListNode dummy(0);
dummy.next = head;
ListNode* prev = &dummy;
for (int i = 1; i < left; ++i) {
prev = prev->next;
}

循环结束时,prev指向第left - 1个结点(left == 1时就是dummy)。prev->next是反转区间的第一个结点,记作cur。


注意ListNode dummy(0);是栈上的对象,函数返回时自动销毁。我们只返回dummy.next,不返回&dummy,所以不存在悬垂指针。


二、头插法反转区间


反转区间[left, right]的长度是right - left + 1。设cur = prev->next,它始终指向反转后区间的「尾部」(也就是原来区间的第一个结点)。每次把cur的下一个结点nxt摘下来,插到prev的后面:


nxt = cur->next;
cur->next = nxt->next; // 从原位置摘下 nxt
nxt->next = prev->next; // nxt 指向当前区间头部
prev->next = nxt; // prev 指向新的区间头部

执行right - left次,整个区间就反转完成。


为什么是right - left次而不是right - left + 1次?因为区间有right - left + 1个结点,其中第一个结点cur已经在位置上,只需要把后面的right - left个结点依次插到它前面。每插入一个,区间头部就前移一位,最终cur变成区间尾部。


// 适用于 C++17
ListNode* cur = prev->next;
for (int i = 0; i < right - left; ++i) {
ListNode* nxt = cur->next;
cur->next = nxt->next;
nxt->next = prev->next;
prev->next = nxt;
}
return dummy.next;

这个写法有一个容易忽略的优点:它不修改prev和cur本身,只修改它们指向的结点的next。prev始终是区间前一个结点,cur始终是区间尾部,循环结束时不需要额外的重连操作——区间两端的连接在每次插入时已经维护好了。


三、逐步推演


以链表1 → 2 → 3 → 4 → 5、left = 2、right = 4为例。初始prev指向结点 1,cur指向结点 2,循环执行4 - 2 = 2次。




轮次nxt操作后链表(局部)说明



初始—1 → 2 → 3 → 4 → 5prev=1, cur=2

第 1 轮31 → 3 → 2 → 4 → 53 插到 1 后面

第 2 轮41 → 4 → 3 → 2 → 54 插到 1 后面



最终dummy.next指向 1,整条链表是1 → 4 → 3 → 2 → 5。区间[2,4]原为2,3,4,反转为4,3,2,其余不变,正确。


再看left = 1、right = 3的情况:prev是dummy,cur是结点 1。第 1 轮把 2 插到 dummy 后面,链表变成dummy → 2 → 1 → 3 → 4...;第 2 轮把 3 插到 dummy 后面,变成dummy → 3 → 2 → 1 → 4...。返回dummy.next即结点 3,头指针正确更新。


再看left == right的情况:循环次数为 0,链表不变,返回原头指针。这符合「反转长度为 1 的区间等于不变」的语义。


四、边界与不变量


写链表题时,明确不变量(invariant)比记住代码更有用。头插法的循环不变量是:



  • prev指向区间前一个结点,固定不变。

  • prev->next指向当前已反转部分的头结点。

  • cur指向当前已反转部分的尾结点,也就是原区间的第一个结点。

  • cur->next指向尚未反转部分的第一个结点。


每轮循环把cur->next(即nxt)摘下来插到prev后面,上述四条仍然成立,只是已反转部分多了一个结点、未反转部分少了一个结点。循环结束时未反转部分为空,区间反转完成,且cur->next自动指向区间后的第一个结点(原链表的第right + 1个结点),不需要手动重连。


这个「不需要手动重连」是头插法相对于「先切断、反转、再拼接」的优势。后者需要保存四个指针(区间前、区间头、区间尾、区间后),一旦某个指针保存错,就会出现断链或环。


需要特别注意的边界:



  1. left == 1:由dummy兜住,不需要改head变量。

  2. left == right:循环 0 次,直接返回。

  3. right == n:cur->next最终为nullptr,循环中nxt不会是空指针(因为循环次数不超过区间长度减一),安全。

  4. 空链表:题目通常保证n ≥ 1;若不允许,需在函数开头判head == nullptr。


五、完整示例


// 适用于 C++17
#include <iostream>
#include <vector>

struct ListNode {
int val;
ListNode* next;
explicit ListNode(int x) : val(x), next(nullptr) {}
};

ListNode* reverseBetween(ListNode* head, int left, int right) {
if (head == nullptr || left == right) return head;

ListNode dummy(0);
dummy.next = head;

ListNode* prev = &dummy;
for (int i = 1; i < left; ++i) {
prev = prev->next;
}

ListNode* cur = prev->next;
for (int i = 0; i < right - left; ++i) {
ListNode* nxt = cur->next;
cur->next = nxt->next;
nxt->next = prev->next;
prev->next = nxt;
}

return dummy.next;
}

ListNode* build(const std::vector<int>& values) {
ListNode dummy(0);
ListNode* tail = &dummy;
for (int v : values) {
tail->next = new ListNode(v);
tail = tail->next;
}
return dummy.next;
}

void destroy(ListNode* head) {
while (head != nullptr) {
ListNode* next = head->next;
delete head;
head = next;
}
}

void print(ListNode* head) {
while (head != nullptr) {
std::cout << head->val;
if (head->next != nullptr) std::cout << " -> ";
head = head->next;
}
std::cout << '\n';
}

int main() {
ListNode* head = build({1, 2, 3, 4, 5});
head = reverseBetween(head, 2, 4);
print(head); // 按标准语义应为 1 -> 4 -> 3 -> 2 -> 5
destroy(head);
return 0;
}

build和destroy是配套的:build里每个new都在destroy里有对应的delete。如果只写build不写destroy,程序结束前不会崩溃,但这是内存泄漏;在长时间运行的服务里会累积。工程代码更推荐用std::unique_ptr<ListNode>和std::make_unique,让所有权自动管理。


六、用智能指针表达所有权


如果不想手写destroy,可以把结点定义改成:


// 适用于 C++17
#include <memory>

struct ListNode {
int val;
std::unique_ptr<ListNode> next;
explicit ListNode(int x) : val(x), next(nullptr) {}
};

这样整条链表的所有权是唯一的、递归的,头指针析构时整条链自动释放。但反转操作的代码会变得啰嗦,因为std::unique_ptr不能拷贝,移动和交换需要小心处理。面试和算法题里通常仍用裸指针,工程代码里则优先用智能指针或标准容器。两种写法没有对错,关键是不要在同一个项目里混用两套所有权约定。


常见坑点


1. 不用哨兵结点,单独处理 left == 1


❌ 在left == 1分支里手动更新head,其余分支另一套逻辑,容易漏改。


✅ 用dummy.next = head,统一返回dummy.next。


2. 循环次数写成 right - left + 1


❌ 多反转一个结点,区间尾部被带到区间外。


✅ 循环right - left次;区间有right - left + 1个结点,第一个已在位。


3. 反转后忘记重连


❌ 先切断子链表再反转,最后只接回一端,另一端悬空。


✅ 用头插法,每轮都维护prev->next和cur->next,结束时自然接好。


4. 先移动 prev 再保存 cur


❌prev = prev->next; ListNode* cur = prev->next;导致prev指向区间第一个结点而不是前一个。


✅ 先让prev走到第left - 1个结点,再取cur = prev->next。


5. 返回了 dummy 的地址


❌return &dummy;——dummy是局部对象,函数返回后悬垂。


✅return dummy.next;。


6. 忘记处理 left == right


❌ 循环条件写成i <= right - left,多执行一次,把区间外的结点也卷进来。


✅ 循环i < right - left;或开头直接if (left == right) return head;。


7. 在循环里修改 cur 的指向后还用它找下一个


❌ 先cur = cur->next再取nxt,顺序错乱。


✅ 每轮开头先保存ListNode* nxt = cur->next;,再修改指针。


8. 释放链表时先 delete 再读 next


❌delete head; head = head->next;—— 释放后使用,UB。


✅ 先ListNode* next = head->next;,再delete head;,最后head = next;。


总结




要点结论



时间复杂度O(n),只遍历一次

空间复杂度O(1),只用常数个指针

关键技巧哨兵结点 + 头插法

循环次数right - left

头指针统一返回dummy.next

重连头插法每轮自动维护,无需额外拼接

边界left == 1、left == right、right == n



区间反转的代码只有十来行,但每一行都在改指针,任何一处顺序错误都会导致断链或成环。把「prev 固定、cur 是尾、nxt 是待插结点」这三个角色记牢,再配合哨兵结点消除头指针的特殊情况,这道题就能一次写对。




版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/11/2 11:20:29

02 词法与语法分析(前端)

02 词法与语法分析&#xff08;前端&#xff09; 本册目标&#xff1a;搞清楚源代码文本如何变成语法树——Lexer 如何扫描 Token、LanguageParser 如何用递归下降 优先级爬升建树、红绿树为何能做到编辑时 O(1) 复用、Syntax API 长什么样、语法错误如何恢复。 一、要点速览 …

作者头像 李华
网站建设 2026/11/2 11:18:50

从 Virtual Workspace 到 MCP,看懂 GitHub Copilot 如何真正进入 ABAP 开发流程

这段话真正有价值的地方,并不只是 SAP 在说 GitHub Copilot 可以配合 ABAP Development Tools for Visual Studio Code 使用。更关键的是,它透露了 SAP 为 ABAP 进入现代 Agentic Development 世界所选择的一条技术路线。 读懂这句话,需要把几个容易混在一起的概念拆开来看…

作者头像 李华
网站建设 2026/11/2 11:18:08

SAP TechEd 2026 之后,ABAP 开发正在从 AI 辅助走向 Agentic AI

2026 年 10 月的 SAP TechEd Berlin,有一条关于 ABAP 的变化很容易被低估。 过去两三年,我们谈 ABAP 和生成式 AI,关注点大多还是 Copilot 式体验。开发人员写一句需求,AI 帮忙生成一段代码,解释一个 Class,补一段 ABAP Unit,或者告诉我们某个 CDS View 为什么激活失败…

作者头像 李华
网站建设 2026/11/2 11:14:41

AI创业者通识日报 | 2026年10月29日

AI创业者通识日报 | 2026年10月29日 &#x1f4d6; 首屏导读 本教程配套付费专栏&#xff1a;《大模型工程师修炼手记》 19.9 元&#xff08;AI 编程 Agent 实战 本文同主题系统课程&#xff09; 《AI时代程序员的自我提升》 49.9 元&#xff08;AI 时代成长方法论&#xff…

作者头像 李华
网站建设 2026/11/2 11:13:59

从小白到程序员:大模型Agent开发入门指南,抓住2026年蓝海机遇!

本文对比了大模型算法岗与Agent开发岗的异同&#xff0c;指出大模型算法岗薪资高但门槛极高&#xff0c;适合少数顶尖人才&#xff1b;而Agent开发岗则更注重AI工程化落地&#xff0c;适合大多数程序员和初学者&#xff0c;且市场前景广阔&#xff0c;薪资待遇优厚。文章建议关…

作者头像 李华