# 删除链表的倒数第N个结点 (Remove Nth Node From End of List) ## 题目描述 给你一个链表,删除链表的倒数第 `n` 个结点,并且返回链表的头结点。 ### 示例 **示例 1:** ``` 输入:head = [1,2,3,4,5], n = 2 输出:[1,2,3,5] ``` **示例 2:** ``` 输入:head = [1], n = 1 输出:[] ``` **示例 3:** ``` 输入:head = [1,2], n = 1 输出:[1] ``` ### 约束条件 - 链表中结点的数目为 `sz` - `1 <= sz <= 30` - `0 <= Node.val <= 100` - `1 <= n <= sz` ### 进阶 你能尝试使用一趟扫描实现吗? ## 思路推导 ### 暴力解法分析 **思路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+1` 步,然后 `fast` 和 `slow` 一起移动,直到 `fast` 到达链表末尾。此时 `slow` 指向要删除结点的前一个结点。 ### 详细算法流程(双指针法) **步骤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:计算链表长度** ```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` 个结点,栈顶就是要删除结点的前一个结点。 **详细算法流程**: **步骤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),不是最优解 - ❌ 不推荐使用,双指针法更优 ## 解法 ### Go 实现(双指针法) ```go package main import "fmt" // ListNode 链表结点定义 type ListNode struct { Val int Next *ListNode } func removeNthFromEnd(head *ListNode, n int) *ListNode { // 创建哑结点,处理删除头结点的特殊情况 dummy := &ListNode{0, head} fast, slow := dummy, dummy // fast 先移动 n + 1 步 for i := 0; i <= n; i++ { fast = fast.Next } // fast 和 slow 一起移动,直到 fast 为 nil for fast != nil { fast = fast.Next slow = slow.Next } // 删除 slow 的下一个结点 slow.Next = slow.Next.Next return dummy.Next } // 辅助函数:创建链表 func createList(nums []int) *ListNode { dummy := &ListNode{} current := dummy for _, num := range nums { current.Next = &ListNode{num, nil} current = current.Next } return dummy.Next } // 辅助函数:打印链表 func printList(head *ListNode) { current := head for current != nil { fmt.Printf("%d", current.Val) if current.Next != nil { fmt.Printf(" -> ") } current = current.Next } fmt.Println() } // 测试用例 func main() { // 测试用例1 head1 := createList([]int{1, 2, 3, 4, 5}) fmt.Print("输入: ") printList(head1) fmt.Printf("n = 2\n") result1 := removeNthFromEnd(head1, 2) fmt.Print("输出: ") printList(result1) // 测试用例2: 删除头结点 head2 := createList([]int{1}) fmt.Print("\n输入: ") printList(head2) fmt.Printf("n = 1\n") result2 := removeNthFromEnd(head2, 1) fmt.Print("输出: ") printList(result2) // 测试用例3: 删除最后一个结点 head3 := createList([]int{1, 2}) fmt.Print("\n输入: ") printList(head3) fmt.Printf("n = 1\n") result3 := removeNthFromEnd(head3, 1) fmt.Print("输出: ") printList(result3) // 测试用例4: 长链表 head4 := createList([]int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}) fmt.Print("\n输入: ") printList(head4) fmt.Printf("n = 5\n") result4 := removeNthFromEnd(head4, 5) fmt.Print("输出: ") printList(result4) } ``` ### Go 实现(计算长度法) ```go func removeNthFromEndByLength(head *ListNode, n int) *ListNode { if head == nil { return nil } // 计算链表长度 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 } ``` ### Go 实现(栈法) ```go func removeNthFromEndByStack(head *ListNode, n int) *ListNode { if head == nil { return nil } // 创建哑结点 dummy := &ListNode{0, head} // 将所有结点压入栈 var stack []*ListNode current := dummy for current != nil { stack = append(stack, current) current = current.Next } // 弹出 n 个结点 for i := 0; i < n; i++ { stack = stack[:len(stack)-1] } // 栈顶就是要删除结点的前一个结点 prev := stack[len(stack)-1] prev.Next = prev.Next.Next return dummy.Next } ``` ## 复杂度分析 ### 双指针法 - **时间复杂度:** O(L) - 其中 L 是链表长度 - 只需遍历链表一次 - **空间复杂度:** O(1) - 只使用了常数级别的额外空间 - 只需要几个指针变量 ### 计算长度法 - **时间复杂度:** O(L) - 第一次遍历计算长度:O(L) - 第二次遍历删除结点:O(L) - 总时间复杂度:O(2L) = O(L) - **空间复杂度:** O(1) - 只使用了常数级别的额外空间 ### 栈法 - **时间复杂度:** O(L) - 需要遍历链表一次 - **空间复杂度:** O(L) - 需要额外的栈空间存储所有结点 ## 进阶问题 ### Q1: 如果链表是循环链表,应该如何处理? **方法**:检测循环,计算长度,然后调整删除位置 ```go func removeNthFromEndCircular(head *ListNode, n int) *ListNode { if head == nil { return nil } // 计算链表长度并检测循环 length := 1 slow, fast := head, head.Next for fast != nil && fast.Next != nil && slow != fast { slow = slow.Next fast = fast.Next.Next length++ } // 如果有循环 if slow == fast { // 计算循环长度 cycleLength := 1 slow = slow.Next for slow != fast { slow = slow.Next cycleLength++ } // 总长度 totalLength := length + cycleLength - 1 pos := totalLength - n // 处理位置调整 if pos < 0 { pos += totalLength } // 执行删除 return removeNthFromEndByPosition(head, pos) } // 没有循环,使用原有方法 return removeNthFromEnd(head, n) } ``` ### Q2: 如果要求删除前 n 个结点,应该如何修改? **方法**:直接删除前 n 个结点 ```go func removeFirstNNodes(head *ListNode, n int) *ListNode { for i := 0; i < n && head != nil; i++ { head = head.Next } return head } ``` ### Q3: 如果链表很长,如何优化内存使用? **方法**: 1. 使用固定大小的滑动窗口 2. 避免存储整个链表 3. 使用递归(但会增加栈空间) ```go func removeNthFromEndOptimized(head *ListNode, n int) *ListNode { // 使用固定大小的窗口 dummy := &ListNode{0, head} slow, fast := dummy, dummy // fast 先移动 n + 1 步 for i := 0; i <= n; i++ { if fast == nil { return head // n > 链表长度 } fast = fast.Next } // 移动窗口 for fast != nil { slow = slow.Next fast = fast.Next } // 删除结点 slow.Next = slow.Next.Next return dummy.Next } ``` ## P7 加分项 ### 1. 深度理解:为什么需要哑结点? **关键点:** - 处理删除头结点的特殊情况 - 统一处理逻辑,减少边界条件判断 - 简化代码,提高可读性 **哑结点的作用:** ```go // 没有哑结点的情况 func removeNthFromEndWithoutDummy(head *ListNode, n int) *ListNode { // 需要特殊处理删除头结点的情况 length := 0 current := head for current != nil { length++ current = current.Next } if length == n { return head.Next // 删除头结点 } // ... 其他逻辑 return head } ``` ### 2. 实战扩展:链表操作的通用技巧 **技巧总结:** - 使用哑结点简化边界处理 - 双指针技巧:快慢指针、前后指针 - 递归处理链表问题 - 栈辅助解决链表问题 **通用模板:** ```go func solveLinkedListProblem(head *ListNode) *ListNode { dummy := &ListNode{0, head} // ... 使用双指针或其他技巧 return dummy.Next } ``` ### 3. 变形题目 1. [19. 删除链表的倒数第N个节点](https://leetcode.cn/problems/remove-nth-node-from-end-of-list/) - 原题 2. [82. 删除排序链表中的重复元素 II](https://leetcode.cn/problems/remove-duplicates-from-sorted-list-ii/) - 删除所有重复元素 3. [83. 删除排序链表中的重复元素](https://leetcode.cn/problems/remove-duplicates-from-sorted-list/) - 删除重复元素保留一个 4. [203. 移除链表元素](https://leetcode.cn/problems/remove-linked-list-elements/) - 删除指定值的节点 ### 4. 优化技巧 **空间优化:** - 原地操作,不使用额外空间 - 递归改为迭代 **时间优化:** - 一次遍历完成 - 提前终止条件 **代码优化:** - 合并重复逻辑 - 减少不必要的变量 ### 5. 实际应用场景 **应用场景:** - 缓存淘汰策略(LRU) - 音乐播放列表管理 - 浏览器历史记录 - 撤销/重做功能 **面试问题:** - 如何处理并发访问的链表? - 如何实现线程安全的链表操作? ### 6. 面试技巧 **常见面试问题:** 1. 时间/空间复杂度分析 2. 边界条件处理 3. 优化思路 4. 相关题目变体 **回答技巧:** - 先给出暴力解法 - 逐步优化 - 说明权衡取舍 ### 7. 相关题目推荐 **相关题目:** 1. [206. 反转链表](https://leetcode.cn/problems/reverse-linked-list/) 2. [21. 合并两个有序链表](https://leetcode.cn/problems/merge-two-sorted-lists/) 3. [141. 环形链表](https://leetcode.cn/problems/linked-list-cycle/) 4. [142. 环形链表 II](https://leetcode.cn/problems/linked-list-cycle-ii/) ## 总结 这道题的核心是: 1. **双指针法**:一次遍历,快慢指针配合 2. **边界处理**:使用哑结点简化删除头结点的处理 3. **多种解法**:双指针、计算长度、栈法各有优劣 **易错点**: - 忘记处理删除头结点的情况 - 快指针移动步数错误(应该是 n+1) - 空链表的特殊情况处理 - 循环链表的特殊情况 **最优解法**:双指针法,时间 O(L),空间 O(1)