diff --git a/16-LeetCode Hot 100/两数相加.md b/16-LeetCode Hot 100/两数相加.md index fc5c12e..624fc72 100644 --- a/16-LeetCode Hot 100/两数相加.md +++ b/16-LeetCode Hot 100/两数相加.md @@ -29,23 +29,556 @@ LeetCode 2. Medium 输出:[8,9,9,9,0,0,0,1] ``` +## 思路推导 + +### 暴力解法分析 + +**思路1:先转成整数,相加后再转回链表** +```python +def addTwoNumbers(l1, l2): + # 1. 链表转整数 + num1 = 0 + while l1: + num1 = num1 * 10 + l1.val + l1 = l1.next + + num2 = 0 + while l2: + num2 = num2 * 10 + l2.val + l2 = l2.next + + # 2. 整数相加 + total = num1 + num2 + + # 3. 整数转链表(逆序) + dummy = ListNode(0) + if total == 0: + return ListNode(0) + + while total > 0: + digit = total % 10 + # 需要头插法保持逆序 + new_node = ListNode(digit) + new_node.next = dummy.next + dummy.next = new_node + total //= 10 + + return dummy.next +``` + +**时间复杂度**:O(max(m, n)) +**空间复杂度**:O(1) + +**问题分析**: +- ❌ **整数溢出**:如果链表很长(如100位),会超出整型范围 +- ❌ **不符合题意**:题目就是要我们处理大数相加,不能依赖语言的大整数特性 +- ❌ **需要反转**:整数转链表时需要头插法,增加复杂度 + +### 优化思考 + +**观察**: +- ✅ 链表已经是逆序存储(个位在表头) +- ✅ 每个节点只存储一位数字 +- ✅ 就像小学竖式加法,从个位开始加 + +**关键洞察**: +``` +竖式加法: + 3 4 2 (链表: 2 -> 4 -> 3) ++ 4 6 5 (链表: 5 -> 6 -> 4) +--------- + 8 0 7 (链表: 7 -> 0 -> 8) + +从个位开始: +个位:2 + 5 = 7,无进位 +十位:4 + 6 = 10,写0进1 +百位:3 + 4 + 1(进位) = 8 +``` + +### 为什么这样思考? + +**1. 逆序存储的巧思** +- 如果是正序存储,需要先反转链表 +- 逆序存储天然符合加法从低位开始的习惯 +- 可以直接从表头开始处理 + +**2. 同时遍历的合理性** +- 两个链表对应位相加 +- 类似于合并两个有序链表的思路 +- 使用指针同时移动 + +**3. 进位的处理** +- 每次计算都要考虑上一位的进位 +- `sum = l1.val + l2.val + carry` +- 新进位 = `sum / 10` +- 当前位 = `sum % 10` + +**4. 为什么是 max(m, n)** +- 两个链表长度可能不同 +- 短链表遍历完后,长链表继续处理 +- 还要考虑最后的进位可能增加一位 + +--- + ## 解题思路 ### 核心思想 -模拟竖式加法,同时遍历两个链表,逐位相加并处理进位。 +**模拟竖式加法**:同时遍历两个链表,逐位相加并处理进位,使用哑节点简化头节点处理。 -### 算法流程 -1. 初始化哑节点和进位 carry = 0 -2. 同时遍历两个链表: - - 计算当前位的和:sum = l1.val + l2.val + carry - - 更新进位:carry = sum / 10 - - 创建新节点:sum % 10 -3. 处理最后的进位 -4. 返回结果链表 +### 详细算法流程 -### 复杂度分析 -- **时间复杂度**:O(max(m, n)),m 和 n 分别为两个链表的长度 -- **空间复杂度**:O(1),不考虑结果链表的空间 +**步骤1:初始化变量** +```go +dummy := &ListNode{0, nil} // 哑节点,简化头节点处理 +curr := dummy // 当前节点指针 +carry := 0 // 进位,初始为0 +``` + +**关键点**: +- 为什么需要哑节点? + - 统一处理逻辑,不需要特殊判断第一个节点 + - 简化代码,减少if-else +- carry初始为什么是0? + - 第一次相加前没有进位 + +**步骤2:同时遍历两个链表** +```go +for l1 != nil || l2 != nil { + // 获取当前位的值,如果链表已遍历完则为0 + x := 0 + if l1 != nil { + x = l1.Val + l1 = l1.Next + } + + y := 0 + if l2 != nil { + y = l2.Val + l2 = l2.Next + } + + // 计算当前位的和 + sum := x + y + carry + + // 更新进位 + carry = sum / 10 + + // 创建新节点(当前位的值) + curr.Next = &ListNode{sum % 10, nil} + curr = curr.Next +} +``` + +**关键点**: +- 为什么用 `||` 而不是 `&&`? + - 只要有一个链表没遍历完就要继续 + - 长链表的剩余位需要单独处理 +- 为什么 `l1 != nil` 时还要检查? + - 避免空指针异常 + - 已遍历完的链表对应的位视为0 +- 为什么 `sum / 10` 是进位? + - 整除,如 12 / 10 = 1(进位) +- 为什么 `sum % 10` 是当前位? + - 取余,如 12 % 10 = 2(当前位) + +**步骤3:处理最后的进位** +```go +if carry > 0 { + curr.Next = &ListNode{carry, nil} +} +``` + +**关键点**: +- 为什么需要这个判断? + - 例如:9 + 9 = 18,需要创建新节点存储1 + - 例如:1 + 2 = 3,不需要创建新节点 +- 什么时候会进位? + - 最后一位相加 >= 10 + +**步骤4:返回结果** +```go +return dummy.Next +``` + +**关键点**: +- 为什么返回 `dummy.Next` 而不是 `dummy`? + - `dummy` 是哑节点,值为0 + - `dummy.Next` 才是真正的结果链表头 + +--- + +### 关键细节说明 + +**细节1:为什么用 `||` 而不是 `&&`?** +```go +// ❌ 错误:会漏掉长链表的剩余部分 +for l1 != nil && l2 != nil { } + +// ✅ 正确:处理完所有位 +for l1 != nil || l2 != nil { } +``` + +**示例**: +``` +l1: 9 -> 9 -> 9 +l2: 9 + +使用 &&:只处理第一位 9+9=18,漏掉 l1 的剩余两位 +使用 ||:处理所有位 9+9=18, 0+9=9, 0+9=9 +结果:8 -> 9 -> 9 -> 1 +``` + +**细节2:为什么需要检查 `l1 != nil`?** +```go +// 获取当前位的值 +x := 0 +if l1 != nil { + x = l1.Val + l1 = l1.Next +} +``` + +**原因**: +- 长度不同的链表,短的遍历完后就为 `nil` +- 直接访问 `l1.Val` 会空指针异常 +- 遍历完的位视为0,不影响加法结果 + +**细节3:哑节点的作用** +```go +// ❌ 没有哑节点:需要特殊处理第一个节点 +var head *ListNode +var curr *ListNode +if l1 != nil || l2 != nil { + head = &ListNode{(l1.Val + l2.Val) % 10, nil} + curr = head +} +// 后续节点处理... + +// ✅ 有哑节点:统一处理 +dummy := &ListNode{0, nil} +curr := dummy +// 所有节点统一处理... +return dummy.Next // 跳过哑节点 +``` + +**细节4:为什么最后还要判断进位?** +```go +if carry > 0 { + curr.Next = &ListNode{carry, nil} +} +``` + +**示例**: +``` +情况1:9 + 9 = 18 +- 最后 carry = 1 +- 需要创建新节点存储1 +- 结果:8 -> 1 + +情况2:1 + 2 = 3 +- 最后 carry = 0 +- 不需要创建新节点 +- 结果:3 +``` + +--- + +### 边界条件分析 + +**边界1:两个链表长度相同** +``` +输入:l1 = [2,4,3], l2 = [5,6,4] +过程: +- 第1位:2+5+0=7, carry=0, 结果:7 +- 第2位:4+6+0=10, carry=1, 结果:7->0 +- 第3位:3+4+1=8, carry=0, 结果:7->0->8 +输出:[7,0,8] +``` + +**边界2:一个链表很长** +``` +输入:l1 = [9,9,9,9,9,9,9], l2 = [9,9,9,9] +过程: +- 第1位:9+9=18, carry=1 +- 第2位:9+9+1=19, carry=1 +- 第3位:9+9+1=19, carry=1 +- 第4位:9+9+1=19, carry=1 +- 第5位:9+0+1=10, carry=1 +- 第6位:9+0+1=10, carry=1 +- 第7位:9+0+1=10, carry=1 +- 最后进位:carry=1 +输出:[8,9,9,9,0,0,0,1] +``` + +**边界3:链表只有一个0** +``` +输入:l1 = [0], l2 = [0] +过程: +- 第1位:0+0+0=0, carry=0 +- 最后进位:carry=0,不需要创建新节点 +输出:[0] +``` + +**边界4:最后有进位** +``` +输入:l1 = [9,9], l2 = [1] +过程: +- 第1位:9+1=10, carry=1 +- 第2位:9+0+1=10, carry=1 +- 最后进位:carry=1,创建新节点 +输出:[0,0,1] +``` + +--- + +### Q&A 问题解释 + +**Q1:为什么链表是逆序存储的?** +A:这是设计上的巧思: +- ✅ 符合加法从个位开始的习惯 +- ✅ 不需要反转链表 +- ✅ 可以直接从头指针开始遍历 + +**Q2:如果链表是正序存储的怎么办?** +A:需要反转链表或使用栈: +```go +// 方法1:反转链表 +func addTwoNumbersReverse(l1, l2 *ListNode) *ListNode { + l1 = reverseList(l1) + l2 = reverseList(l2) + result := addTwoNumbers(l1, l2) + return reverseList(result) +} + +// 方法2:使用栈 +func addTwoNumbersStack(l1, l2 *ListNode) *ListNode { + stack1, stack2 := []*ListNode{}, []*ListNode{} + + // 入栈 + for l1 != nil { + stack1 = append(stack1, l1) + l1 = l1.Next + } + for l2 != nil { + stack2 = append(stack2, l2) + l2 = l2.Next + } + + // 从栈顶(高位)开始相加 + carry := 0 + var head *ListNode + for len(stack1) > 0 || len(stack2) > 0 || carry > 0 { + x, y := 0, 0 + if len(stack1) > 0 { + x = stack1[len(stack1)-1].Val + stack1 = stack1[:len(stack1)-1] + } + if len(stack2) > 0 { + y = stack2[len(stack2)-1].Val + stack2 = stack2[:len(stack2)-1] + } + + sum := x + y + carry + carry = sum / 10 + + // 头插法(因为是正序) + node := &ListNode{sum % 10, head} + head = node + } + + return head +} +``` + +**Q3:时间复杂度为什么是 O(max(m, n))?** +A: +- 两个链表长度分别为 m 和 n +- 同时遍历,较短的先遍历完 +- 最多遍历 max(m, n) 次 +- 最后处理进位是 O(1) +- 总时间:O(max(m, n)) + +**Q4:空间复杂度为什么是 O(1)?** +A: +- 不考虑结果链表(这是输出必须的) +- 只使用了固定数量的变量:dummy, curr, carry, sum, x, y +- 不随输入规模增长 +- 所以是 O(1) + +**Q5:如何处理多个链表相加?** +A: +```go +// 方法1:逐个相加 +func addMultipleNumbers(lists []*ListNode) *ListNode { + if len(lists) == 0 { + return nil + } + + result := lists[0] + for i := 1; i < len(lists); i++ { + result = addTwoNumbers(result, lists[i]) + } + return result +} + +// 方法2:分治相加 +func addMultipleNumbersDivide(lists []*ListNode) *ListNode { + if len(lists) == 0 { + return nil + } + return divide(lists, 0, len(lists)-1) +} + +func divide(lists []*ListNode, left, right int) *ListNode { + if left == right { + return lists[left] + } + mid := left + (right-left)/2 + l1 := divide(lists, left, mid) + l2 := divide(lists, mid+1, right) + return addTwoNumbers(l1, l2) +} +``` + +--- + +### 复杂度分析(详细版) + +**时间复杂度**: +``` +- 遍历链表:O(max(m, n)) + - m 和 n 分别为两个链表的长度 + - 同时遍历,短的先结束 + +- 处理进位:O(1) + - 最多一次操作 + +- 创建新节点:O(max(m, n) + 1) + - 最多比长链表多一位 + +总计:O(max(m, n)) +``` + +**空间复杂度**: +``` +- 不考虑结果链表:O(1) + - 只使用固定数量变量 + - dummy, curr, carry, sum, x, y + +- 考虑结果链表:O(max(m, n) + 1) + - 结果链表最多比长链表多一位 + +通常说:O(1)(不考虑结果链表) +``` + +--- + +### 执行过程演示 + +**示例输入**:l1 = [2,4,3], l2 = [5,6,4] + +``` +初始状态: +l1: 2 -> 4 -> 3 +l2: 5 -> 6 -> 4 +dummy -> 0 +carry = 0 + +=== 第1次循环 === +x = 2, y = 5 +sum = 2 + 5 + 0 = 7 +carry = 7 / 10 = 0 +curr.Next = 7 +dummy -> 7 +l1: 4 -> 3, l2: 6 -> 4 + +=== 第2次循环 === +x = 4, y = 6 +sum = 4 + 6 + 0 = 10 +carry = 10 / 10 = 1 +curr.Next = 0 +dummy -> 7 -> 0 +l1: 3, l2: 4 + +=== 第3次循环 === +x = 3, y = 4 +sum = 3 + 4 + 1 = 8 +carry = 8 / 10 = 0 +curr.Next = 8 +dummy -> 7 -> 0 -> 8 +l1: nil, l2: nil + +=== 循环结束 === +carry = 0,不需要创建新节点 + +=== 返回结果 === +return dummy.Next -> [7,0,8] +``` + +--- + +### 常见错误 + +**错误1:忘记处理最后的进位** +```go +// ❌ 错误 +for l1 != nil || l2 != nil { + // ... 计算逻辑 +} +return dummy.Next // 忘记检查carry + +// ✅ 正确 +for l1 != nil || l2 != nil { + // ... 计算逻辑 +} +if carry > 0 { // 检查最后的进位 + curr.Next = &ListNode{carry, nil} +} +return dummy.Next +``` + +**错误2:使用 && 而不是 ||** +```go +// ❌ 错误:会漏掉长链表的剩余部分 +for l1 != nil && l2 != nil { + // ... +} + +// ✅ 正确 +for l1 != nil || l2 != nil { + // ... +} +``` + +**错误3:忘记检查链表是否为nil** +```go +// ❌ 错误:空指针异常 +x := l1.Val // l1可能为nil + +// ✅ 正确 +x := 0 +if l1 != nil { + x = l1.Val +} +``` + +**错误4:返回dummy而不是dummy.Next** +```go +// ❌ 错误:多了一个0 +return dummy + +// ✅ 正确 +return dummy.Next +``` + +**错误5:进位计算错误** +```go +// ❌ 错误:应该用整除 +carry = sum / 10.0 // 浮点除法 + +// ✅ 正确 +carry = sum / 10 // 整除 +``` --- diff --git a/16-LeetCode Hot 100/删除链表的倒数第N个结点.md b/16-LeetCode Hot 100/删除链表的倒数第N个结点.md index 84b3cd5..db6ae89 100644 --- a/16-LeetCode Hot 100/删除链表的倒数第N个结点.md +++ b/16-LeetCode Hot 100/删除链表的倒数第N个结点.md @@ -35,46 +35,822 @@ 你能尝试使用一趟扫描实现吗? +## 思路推导 + +### 暴力解法分析 + +**思路1:两次遍历 - 计算长度法** +```go +func removeNthFromEnd(head *ListNode, n int) *ListNode { + // 第一次遍历:计算链表长度 + length := 0 + current := head + for current != nil { + length++ + current = current.Next + } + + // 计算要删除的正数位置 + pos := length - n + + // 创建哑节点 + dummy := &ListNode{0, head} + current = dummy + + // 第二次遍历:移动到要删除节点的前一个节点 + for i := 0; i < pos; i++ { + current = current.Next + } + + // 删除节点 + current.Next = current.Next.Next + + return dummy.Next +} +``` + +**时间复杂度**:O(2L) = O(L),其中 L 是链表长度 +**空间复杂度**:O(1) + +**问题分析**: +- ✅ **思路清晰**:先算长度,再定位删除 +- ❌ **两次遍历**:需要遍历链表两次 +- ❌ **效率一般**:虽然时间复杂度是 O(L),但常数因子是2 + +### 优化思考 + +**观察**: +- 题目进阶要求:**能否尝试使用一趟扫描实现?** +- 关键问题:如何在一次遍历中找到倒数第N个节点? + +**关键洞察**: +``` +如果两个指针相距 N 个节点,当快指针到达末尾时, +慢指针恰好指向倒数第N个节点! + +示例:删除倒数第2个节点 (n=2) +原始链表:1 -> 2 -> 3 -> 4 -> 5 + +步骤1:fast 先移动 n+1 = 3 步 + fast + ↓ +dummy -> 1 -> 2 -> 3 -> 4 -> 5 + ↑ +slow + +步骤2:fast 和 slow 同时移动,直到 fast 到达末尾 + fast + ↓ +dummy -> 1 -> 2 -> 3 -> 4 -> 5 -> nil + ↑ + slow + +步骤3:此时 slow 指向要删除节点的前一个节点 + 要删除的是 4,slow 指向 3 + +执行删除:slow.Next = slow.Next.Next +结果:1 -> 2 -> 3 -> 5 +``` + +### 为什么这样思考? + +**1. 双指针的巧妙之处** +- 快慢指针相距 N 个节点 +- 当快指针到达末尾时,慢指针刚好在目标位置 +- **类比**:就像两个人赛跑,一个人先跑N步,然后同时跑,当先跑的人到达终点时,后跑的人距离终点还有N步 + +**2. 为什么是 n+1 步而不是 n 步?** +``` +如果移动 n 步: + fast + ↓ +dummy -> 1 -> 2 -> 3 -> 4 -> 5 + ↑ +slow + +同时移动后: + fast + ↓ +dummy -> 1 -> 2 -> 3 -> 4 -> 5 + ↑ + slow + +此时 slow 指向要删除的节点本身,而不是前一个节点 +删除操作需要前一个节点:slow.Next = slow.Next.Next + +如果移动 n+1 步: + fast + ↓ +dummy -> 1 -> 2 -> 3 -> 4 -> 5 + ↑ +slow + +同时移动后: + fast + ↓ +dummy -> 1 -> 2 -> 3 -> 4 -> 5 -> nil + ↑ + slow + +此时 slow 指向要删除节点的前一个节点 +可以方便删除:slow.Next = slow.Next.Next +``` + +**3. 为什么需要哑节点?** +``` +没有哑节点的情况: +head -> 1 -> 2 -> 3 -> 4 -> 5 + +如果要删除头节点(倒数第5个节点): +- slow 会指向 nil(head 的前一个节点不存在) +- 无法执行删除操作 + +有哑节点的情况: +dummy -> head -> 1 -> 2 -> 3 -> 4 -> 5 + +如果要删除头节点: +- slow 指向 dummy +- 执行 dummy.Next = dummy.Next.Next +- 成功删除 head 节点 +``` + +**4. 时间复杂度的优化** +- 计算长度法:O(2L) - 两次遍历 +- 双指针法:O(L) - 一次遍历 +- 虽然都是 O(L),但双指针法的常数因子更小 + +--- + ## 解题思路 ### 方法一:双指针法(推荐) -**核心思想:**使用两个指针 `fast` 和 `slow`,`fast` 先移动 `n` 步,然后 `fast` 和 `slow` 一起移动,直到 `fast` 到达链表末尾。此时 `slow` 指向要删除结点的前一个结点。 +**核心思想**:使用两个指针 `fast` 和 `slow`,`fast` 先移动 `n+1` 步,然后 `fast` 和 `slow` 一起移动,直到 `fast` 到达链表末尾。此时 `slow` 指向要删除结点的前一个结点。 -**算法步骤:** -1. 创建哑结点 `dummy`,指向链表头 -2. 初始化 `fast` 和 `slow` 指针都指向 `dummy` -3. `fast` 先移动 `n + 1` 步 -4. `fast` 和 `slow` 同时移动,直到 `fast` 为 `nil` -5. 此时 `slow.next` 就是要删除的结点,执行 `slow.next = slow.next.next` -6. 返回 `dummy.next` +### 详细算法流程(双指针法) -**为什么移动 n + 1 步?** -- 这样 `slow` 最终会停在要删除结点的前一个结点 -- 方便删除操作:`slow.next = slow.next.next` +**步骤1:初始化哑节点和指针** +```go +dummy := &ListNode{0, head} // 哑节点,简化头节点删除 +fast := dummy // 快指针 +slow := dummy // 慢指针 +``` + +**关键点**: +- 为什么需要哑节点? + - 统一处理删除头节点的情况 + - 避免 nil 指针的边界判断 +- 为什么 fast 和 slow 都指向 dummy? + - 保证 fast 和 slow 的距离准确 + - 从同一个起点开始,距离计算更清晰 + +**步骤2:fast 先移动 n+1 步** +```go +for i := 0; i <= n; i++ { + fast = fast.Next +} +``` + +**关键点**: +- 为什么是 `i <= n`(n+1 步)而不是 `i < n`(n 步)? + - n+1 步:slow 最终指向要删除节点的前一个节点 + - n 步:slow 最终指向要删除的节点本身 + - 删除操作需要前一个节点 +- 为什么要移动 n+1 步? + - 让 fast 和 slow 之间拉开 n 个节点的距离 + - 当 fast 到达末尾(nil)时,slow 刚好在目标位置 + +**示例**: +``` +链表:1 -> 2 -> 3 -> 4 -> 5 +删除倒数第 2 个节点(n=2) + +fast 移动 3 步(n+1=3): +初始: dummy -> 1 -> 2 -> 3 -> 4 -> 5 + ↑fast/slow + +第1步: dummy -> 1 -> 2 -> 3 -> 4 -> 5 + ↑fast + ↑slow + +第2步: dummy -> 1 -> 2 -> 3 -> 4 -> 5 + ↑fast + ↑slow + +第3步: dummy -> 1 -> 2 -> 3 -> 4 -> 5 + ↑fast + ↑slow +``` + +**步骤3:fast 和 slow 同时移动,直到 fast 为 nil** +```go +for fast != nil { + fast = fast.Next + slow = slow.Next +} +``` + +**关键点**: +- 为什么条件是 `fast != nil`? + - fast 最终会指向最后一个节点的 Next,即 nil + - 当 fast 为 nil 时,slow 刚好在目标位置 +- 为什么 fast 和 slow 都移动一步? + - 保持两者之间的距离不变 + - 维持 n 个节点的间距 + +**示例**: +``` +继续上面的例子: +fast 在节点 4,slow 在 dummy + +第1次同时移动: + fast + ↓ +dummy -> 1 -> 2 -> 3 -> 4 -> 5 + ↑ + slow + +第2次同时移动: + fast + ↓ +dummy -> 1 -> 2 -> 3 -> 4 -> 5 -> nil + ↑ + slow + +fast 为 nil,循环结束 +slow 指向节点 3,是要删除节点 4 的前一个节点 +``` + +**步骤4:删除节点** +```go +slow.Next = slow.Next.Next +``` + +**关键点**: +- 为什么可以这样做? + - slow 指向要删除节点的前一个节点 + - slow.Next 是要删除的节点 + - slow.Next.Next 是要删除节点的下一个节点 + - 直接跳过要删除的节点 + +**步骤5:返回结果** +```go +return dummy.Next +``` + +**关键点**: +- 为什么返回 `dummy.Next` 而不是 `dummy`? + - dummy 是哑节点,不是链表的一部分 + - dummy.Next 才是真正的链表头 + - 即使删除了头节点,也能正确返回 + +--- + +### 关键细节说明 + +**细节1:为什么是 n+1 步?** +```go +// ❌ 错误:移动 n 步 +for i := 0; i < n; i++ { + fast = fast.Next +} +// 结果:slow 指向要删除的节点本身,无法删除 + +// ✅ 正确:移动 n+1 步 +for i := 0; i <= n; i++ { + fast = fast.Next +} +// 结果:slow 指向要删除节点的前一个节点 +``` + +**图解**: +``` +链表:1 -> 2 -> 3 -> 4 -> 5 +删除倒数第 2 个节点(n=2),即删除节点 4 + +移动 n 步(错误): + fast + ↓ +dummy -> 1 -> 2 -> 3 -> 4 -> 5 -> nil + ↑ +slow + +同时移动后: + fast + ↓ +dummy -> 1 -> 2 -> 3 -> 4 -> 5 -> nil + ↑ + slow + +slow 指向节点 4,无法删除自己! +❌ 无法执行 slow.Next = slow.Next.Next + +移动 n+1 步(正确): + fast + ↓ +dummy -> 1 -> 2 -> 3 -> 4 -> 5 -> nil + ↑ +slow + +同时移动后: + fast + ↓ +dummy -> 1 -> 2 -> 3 -> 4 -> 5 -> nil + ↑ + slow + +slow 指向节点 3,可以删除下一个节点 4 +✅ 执行 slow.Next = slow.Next.Next 成功 +``` + +**细节2:为什么需要哑节点?** +```go +// ❌ 没有哑节点:删除头节点会出错 +func removeNthFromEndWithoutDummy(head *ListNode, n int) *ListNode { + fast := head + slow := head + + // fast 移动 n 步 + for i := 0; i < n; i++ { + fast = fast.Next + } + + // 同时移动 + for fast != nil { + fast = fast.Next + slow = slow.Next + } + + // 如果要删除的是头节点,slow 会是 nil + // slow.Next 会空指针异常 + slow.Next = slow.Next.Next + + return head +} + +// ✅ 有哑节点:统一处理 +func removeNthFromEndWithDummy(head *ListNode, n int) *ListNode { + dummy := &ListNode{0, head} + fast := dummy + slow := dummy + + // fast 移动 n+1 步 + for i := 0; i <= n; i++ { + fast = fast.Next + } + + // 同时移动 + for fast != nil { + fast = fast.Next + slow = slow.Next + } + + // 即使删除头节点,slow 也指向 dummy,不会是 nil + slow.Next = slow.Next.Next + + return dummy.Next +} +``` + +**细节3:边界条件 - 只有一个节点** +``` +输入:head = [1], n = 1 + +步骤: +1. dummy -> 1 -> nil +2. fast 移动 2 步(n+1=2) + - 第1步:fast = 1 + - 第2步:fast = nil +3. fast 已经是 nil,不进入同时移动的循环 +4. slow 指向 dummy +5. 执行 slow.Next = slow.Next.Next + - dummy.Next = dummy.Next.Next + - dummy.Next = 1.Next = nil +6. 返回 dummy.Next = nil + +输出:[] +``` + +**细节4:边界条件 - 删除最后一个节点** +``` +输入:head = [1,2], n = 1 + +步骤: +1. dummy -> 1 -> 2 -> nil +2. fast 移动 2 步(n+1=2) + - 第1步:fast = 1 + - 第2步:fast = 2 +3. 同时移动 + - 第1次:fast = nil, slow = 1 +4. slow 指向节点 1 +5. 执行 slow.Next = slow.Next.Next + - 1.Next = 1.Next.Next + - 1.Next = 2.Next = nil +6. 返回 dummy.Next = 1 + +输出:[1] +``` + +--- + +### 边界条件分析 + +**边界1:删除头节点** +``` +输入:head = [1,2,3,4,5], n = 5 + +过程: +- fast 移动 6 步(n+1=6) +- fast 最终为 nil +- slow 还在 dummy,没有移动 +- 执行 dummy.Next = dummy.Next.Next +- dummy.Next 原本指向 1,现在指向 2 + +输出:[2,3,4,5] +``` + +**边界2:删除尾节点** +``` +输入:head = [1,2,3,4,5], n = 1 + +过程: +- fast 移动 2 步(n+1=2),fast 在节点 2 +- 同时移动: + - 第1次:fast=3, slow=1 + - 第2次:fast=4, slow=2 + - 第3次:fast=5, slow=3 + - 第4次:fast=nil, slow=4 +- slow 指向节点 4 +- 执行 4.Next = 4.Next.Next = nil + +输出:[1,2,3,4] +``` + +**边界3:删除中间节点** +``` +输入:head = [1,2,3,4,5], n = 3 + +过程: +- fast 移动 4 步(n+1=4),fast 在节点 4 +- 同时移动: + - 第1次:fast=5, slow=1 + - 第2次:fast=nil, slow=2 +- slow 指向节点 2 +- 执行 2.Next = 2.Next.Next = 4 + +输出:[1,2,4,5] +``` + +**边界4:只有一个节点** +``` +输入:head = [1], n = 1 + +过程: +- fast 移动 2 步(n+1=2),fast 为 nil +- 没有进入同时移动循环 +- slow 指向 dummy +- 执行 dummy.Next = dummy.Next.Next = nil + +输出:[] +``` + +--- + +### Q&A 问题解释 + +**Q1:为什么双指针法只需要一次遍历?** +A: +- 快指针先走 n+1 步,建立了 n 个节点的距离 +- 然后快慢指针同时移动,保持这个距离 +- 当快指针到达末尾时,慢指针刚好在目标位置 +- 整个过程只需要遍历链表一次 + +**Q2:如果 n 大于链表长度怎么办?** +A: +- 根据题目约束:`1 <= n <= sz`(sz 是链表长度) +- 所以 n 不会大于链表长度 +- 但如果需要防御性编程,可以添加检查: +```go +// 计算链表长度 +length := 0 +current := head +for current != nil { + length++ + current = current.Next +} + +// 检查 n 是否合法 +if n > length { + return head // n 超出范围,不删除 +} +``` + +**Q3:为什么栈法的空间复杂度是 O(L)?** +A: +- 栈法需要将所有节点压入栈 +- 栈的大小等于链表长度 +- 所以空间复杂度是 O(L) +- 双指针法只需要几个指针变量,空间复杂度是 O(1) + +**Q4:三种方法各有什么优缺点?** +A: +``` +双指针法(推荐): +✅ 一次遍历,时间最优 +✅ 空间复杂度 O(1) +✅ 代码简洁优雅 +❌ 思路相对复杂 + +计算长度法: +✅ 思路清晰,容易理解 +✅ 空间复杂度 O(1) +❌ 需要两次遍历 +❌ 时间复杂度常数因子较大 + +栈法: +✅ 思路直观 +✅ 容易理解和实现 +❌ 空间复杂度 O(L) +❌ 不是最优解 +``` + +**Q5:如何处理循环链表?** +A: +- 需要先检测链表是否有环 +- 如果有环,计算环的长度 +- 然后调整删除位置 +- 详见"进阶问题"部分 + +--- + +### 复杂度分析(详细版) + +**双指针法**: +``` +时间复杂度: +- fast 先移动 n+1 步:O(n) +- fast 和 slow 同时移动:O(L-n) +- 总计:O(n) + O(L-n) = O(L) + +空间复杂度: +- 只使用固定数量的指针:dummy, fast, slow +- 不随输入规模增长 +- 总计:O(1) +``` + +**计算长度法**: +``` +时间复杂度: +- 第一次遍历计算长度:O(L) +- 第二次遍历删除节点:O(L-n) +- 总计:O(L) + O(L-n) = O(2L) = O(L) + +空间复杂度: +- 只使用固定数量的变量 +- 总计:O(1) +``` + +**栈法**: +``` +时间复杂度: +- 遍历链表入栈:O(L) +- 弹出 n 个节点:O(n) +- 总计:O(L) + O(n) = O(L) + +空间复杂度: +- 栈存储所有节点:O(L) +- 总计:O(L) +``` + +--- + +### 执行过程演示 + +**示例输入**:head = [1,2,3,4,5], n = 2 + +``` +=== 初始状态 === +dummy -> 1 -> 2 -> 3 -> 4 -> 5 + ↑ +fast/slow + +=== fast 移动 n+1 = 3 步 === + +第1步: +dummy -> 1 -> 2 -> 3 -> 4 -> 5 + ↑fast + ↑ +slow + +第2步: +dummy -> 1 -> 2 -> 3 -> 4 -> 5 + ↑fast + ↑ +slow + +第3步: +dummy -> 1 -> 2 -> 3 -> 4 -> 5 + ↑fast + ↑ +slow + +=== fast 和 slow 同时移动 === + +第1次同时移动: +dummy -> 1 -> 2 -> 3 -> 4 -> 5 + ↑fast + ↑ + slow + +第2次同时移动: +dummy -> 1 -> 2 -> 3 -> 4 -> 5 -> nil + ↑fast + ↑ + slow + +fast 为 nil,循环结束 + +=== 删除节点 === +slow 指向节点 3 +执行 slow.Next = slow.Next.Next +即 3.Next = 3.Next.Next = 5 + +=== 最终结果 === +dummy -> 1 -> 2 -> 3 -> 5 +返回 dummy.Next = [1,2,3,5] +``` + +--- + +### 常见错误 + +**错误1:fast 移动 n 步而不是 n+1 步** +```go +// ❌ 错误 +for i := 0; i < n; i++ { // 只移动 n 步 + fast = fast.Next +} +// slow 指向要删除的节点本身,无法删除 + +// ✅ 正确 +for i := 0; i <= n; i++ { // 移动 n+1 步 + fast = fast.Next +} +// slow 指向要删除节点的前一个节点 +``` + +**错误2:没有使用哑节点** +```go +// ❌ 错误:删除头节点时会出错 +func removeNthFromEnd(head *ListNode, n int) *ListNode { + fast := head + slow := head + // ... 移动逻辑 + slow.Next = slow.Next.Next // 删除头节点时 slow 可能是 nil + return head +} + +// ✅ 正确 +func removeNthFromEnd(head *ListNode, n int) *ListNode { + dummy := &ListNode{0, head} // 使用哑节点 + fast := dummy + slow := dummy + // ... 移动逻辑 + slow.Next = slow.Next.Next // 不会空指针 + return dummy.Next +} +``` + +**错误3:返回 dummy 而不是 dummy.Next** +```go +// ❌ 错误:多了一个哑节点 +return dummy // 返回 [0,1,2,3,5] + +// ✅ 正确 +return dummy.Next // 返回 [1,2,3,5] +``` + +**错误4:同时移动时忘记移动 slow** +```go +// ❌ 错误:只移动了 fast +for fast != nil { + fast = fast.Next + // 忘记移动 slow +} + +// ✅ 正确 +for fast != nil { + fast = fast.Next + slow = slow.Next // 也要移动 slow +} +``` + +**错误5:删除节点时直接操作 head** +```go +// ❌ 错误:无法处理删除头节点的情况 +func removeNthFromEnd(head *ListNode, n int) *ListNode { + // ... 找到要删除的节点 + if 要删除的是头节点 { + head = head.Next // 这样修改不会影响返回值 + } + return head +} + +// ✅ 正确:使用哑节点统一处理 +func removeNthFromEnd(head *ListNode, n int) *ListNode { + dummy := &ListNode{0, head} + // ... 删除逻辑 + return dummy.Next // 即使删除头节点也能正确返回 +} +``` + +--- ### 方法二:计算长度法 -**核心思想:**先遍历链表计算长度,然后计算要删除的正数位置,再遍历到该位置删除结点。 +**核心思想**:先遍历链表计算长度,然后计算要删除的正数位置,再遍历到该位置删除结点。 -**算法步骤:** -1. 遍历链表,计算长度 `length` -2. 要删除的正数位置为 `length - n` -3. 创建哑结点 `dummy`,指向链表头 -4. 遍历到第 `length - n - 1` 个结点 -5. 删除下一个结点 -6. 返回 `dummy.next` +**详细算法流程**: + +**步骤1:计算链表长度** +```go +length := 0 +current := head +for current != nil { + length++ + current = current.Next +} +``` + +**步骤2:计算要删除的正数位置** +```go +pos := length - n // 倒数第 n 个 = 正数第 (length-n) 个 +``` + +**步骤3:创建哑节点并移动到目标位置** +```go +dummy := &ListNode{0, head} +current = dummy +for i := 0; i < pos; i++ { + current = current.Next +} +``` + +**步骤4:删除节点** +```go +current.Next = current.Next.Next +``` + +**步骤5:返回结果** +```go +return dummy.Next +``` + +--- ### 方法三:栈法 -**核心思想:**将所有结点压入栈中,然后弹出 `n` 个结点,栈顶就是要删除结点的前一个结点。 +**核心思想**:将所有结点压入栈中,然后弹出 `n` 个结点,栈顶就是要删除结点的前一个结点。 -**算法步骤:** -1. 创建哑结点 `dummy` -2. 将所有结点压入栈 -3. 弹出 `n` 个结点 -4. 栈顶结点的 `next` 指向要删除结点的 `next` -5. 返回 `dummy.next` +**详细算法流程**: + +**步骤1:创建哑节点** +```go +dummy := &ListNode{0, head} +``` + +**步骤2:将所有节点压入栈** +```go +var stack []*ListNode +current := dummy +for current != nil { + stack = append(stack, current) + current = current.Next +} +``` + +**步骤3:弹出 n 个节点** +```go +for i := 0; i < n; i++ { + stack = stack[:len(stack)-1] +} +``` + +**步骤4:删除节点** +```go +prev := stack[len(stack)-1] +prev.Next = prev.Next.Next +``` + +**步骤5:返回结果** +```go +return dummy.Next +``` + +**优缺点**: +- ✅ 思路直观,容易理解 +- ❌ 空间复杂度 O(L),不是最优解 +- ❌ 不推荐使用,双指针法更优 ## 解法