Files
interview/16-LeetCode Hot 100/删除链表的倒数第N个结点.md
yasinshaw f0833d63cf docs: 增强 LeetCode 题目解题思路详细程度
对两个 LeetCode Hot 100 题目进行了详细的思路改进:

## 改进内容

### 1. 两数相加.md
- 新增"思路推导"部分:从暴力解法分析到优化思考
- 详细化"解题思路":分步骤说明每个关键点
- 增加"关键细节说明":4个核心细节深入分析
- 增加"边界条件分析":4种边界情况完整演示
- 增加"Q&A 问题解释":5个常见问题详细解答
- 增加"执行过程演示":完整执行过程可视化
- 增加"常见错误":5个典型错误对比说明

### 2. 删除链表的倒数第N个结点.md
- 新增"思路推导"部分:暴力解法到双指针优化
- 详细化双指针法、计算长度法、栈法的完整流程
- 增加"关键细节说明":n+1步、哑节点等核心概念
- 增加"边界条件分析":删除头、尾、中间节点等场景
- 增加"Q&A 问题解释":5个核心问题深入解答
- 增加"执行过程演示":完整执行过程可视化
- 增加"常见错误":5个典型错误对比说明

## 改进效果
- 从简单算法流程升级为完整思考路径
- 从基础步骤说明升级为详细原理分析
- 从复杂度概览升级为逐步推导过程
- 增加了可视化执行过程和常见错误对比
- 更适合面试准备和深度理解

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-03-08 21:31:34 +08:00

26 KiB
Raw Blame History

删除链表的倒数第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两次遍历 - 计算长度法

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

步骤1fast 先移动 n+1 = 3 步
        fast
         ↓
dummy -> 1 -> 2 -> 3 -> 4 -> 5
  ↑
slow

步骤2fast 和 slow 同时移动,直到 fast 到达末尾
                   fast
                    ↓
dummy -> 1 -> 2 -> 3 -> 4 -> 5 -> nil
              ↑
            slow

步骤3此时 slow 指向要删除节点的前一个节点
        要删除的是 4slow 指向 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 会指向 nilhead 的前一个节点不存在)
- 无法执行删除操作

有哑节点的情况:
dummy -> head -> 1 -> 2 -> 3 -> 4 -> 5

如果要删除头节点:
- slow 指向 dummy
- 执行 dummy.Next = dummy.Next.Next
- 成功删除 head 节点

4. 时间复杂度的优化

  • 计算长度法O(2L) - 两次遍历
  • 双指针法O(L) - 一次遍历
  • 虽然都是 O(L),但双指针法的常数因子更小

解题思路

方法一:双指针法(推荐)

核心思想:使用两个指针 fastslowfast 先移动 n+1 步,然后 fastslow 一起移动,直到 fast 到达链表末尾。此时 slow 指向要删除结点的前一个结点。

详细算法流程(双指针法)

步骤1初始化哑节点和指针

dummy := &ListNode{0, head}  // 哑节点,简化头节点删除
fast := dummy                 // 快指针
slow := dummy                 // 慢指针

关键点

  • 为什么需要哑节点?
    • 统一处理删除头节点的情况
    • 避免 nil 指针的边界判断
  • 为什么 fast 和 slow 都指向 dummy
    • 保证 fast 和 slow 的距离准确
    • 从同一个起点开始,距离计算更清晰

步骤2fast 先移动 n+1 步

for i := 0; i <= n; i++ {
    fast = fast.Next
}

关键点

  • 为什么是 i <= nn+1 步)而不是 i < nn 步)?
    • n+1 步slow 最终指向要删除节点的前一个节点
    • n 步slow 最终指向要删除的节点本身
    • 删除操作需要前一个节点
  • 为什么要移动 n+1 步?
    • 让 fast 和 slow 之间拉开 n 个节点的距离
    • 当 fast 到达末尾nilslow 刚好在目标位置

示例

链表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

步骤3fast 和 slow 同时移动,直到 fast 为 nil

for fast != nil {
    fast = fast.Next
    slow = slow.Next
}

关键点

  • 为什么条件是 fast != nil
    • fast 最终会指向最后一个节点的 Next即 nil
    • 当 fast 为 nil 时slow 刚好在目标位置
  • 为什么 fast 和 slow 都移动一步?
    • 保持两者之间的距离不变
    • 维持 n 个节点的间距

示例

继续上面的例子:
fast 在节点 4slow 在 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删除节点

slow.Next = slow.Next.Next

关键点

  • 为什么可以这样做?
    • slow 指向要删除节点的前一个节点
    • slow.Next 是要删除的节点
    • slow.Next.Next 是要删除节点的下一个节点
    • 直接跳过要删除的节点

步骤5返回结果

return dummy.Next

关键点

  • 为什么返回 dummy.Next 而不是 dummy
    • dummy 是哑节点,不是链表的一部分
    • dummy.Next 才是真正的链表头
    • 即使删除了头节点,也能正确返回

关键细节说明

细节1为什么是 n+1 步?

// ❌ 错误:移动 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为什么需要哑节点

// ❌ 没有哑节点:删除头节点会出错
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=2fast 在节点 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=4fast 在节点 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=2fast 为 nil
- 没有进入同时移动循环
- slow 指向 dummy
- 执行 dummy.Next = dummy.Next.Next = nil

输出:[]

Q&A 问题解释

Q1为什么双指针法只需要一次遍历 A

  • 快指针先走 n+1 步,建立了 n 个节点的距离
  • 然后快慢指针同时移动,保持这个距离
  • 当快指针到达末尾时,慢指针刚好在目标位置
  • 整个过程只需要遍历链表一次

Q2如果 n 大于链表长度怎么办? A

  • 根据题目约束:1 <= n <= szsz 是链表长度)
  • 所以 n 不会大于链表长度
  • 但如果需要防御性编程,可以添加检查:
// 计算链表长度
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]

常见错误

错误1fast 移动 n 步而不是 n+1 步

// ❌ 错误
for i := 0; i < n; i++ {  // 只移动 n 步
    fast = fast.Next
}
// slow 指向要删除的节点本身,无法删除

// ✅ 正确
for i := 0; i <= n; i++ {  // 移动 n+1 步
    fast = fast.Next
}
// slow 指向要删除节点的前一个节点

错误2没有使用哑节点

// ❌ 错误:删除头节点时会出错
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

// ❌ 错误:多了一个哑节点
return dummy  // 返回 [0,1,2,3,5]

// ✅ 正确
return dummy.Next  // 返回 [1,2,3,5]

错误4同时移动时忘记移动 slow

// ❌ 错误:只移动了 fast
for fast != nil {
    fast = fast.Next
    // 忘记移动 slow
}

// ✅ 正确
for fast != nil {
    fast = fast.Next
    slow = slow.Next  // 也要移动 slow
}

错误5删除节点时直接操作 head

// ❌ 错误:无法处理删除头节点的情况
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 := 0
current := head
for current != nil {
    length++
    current = current.Next
}

步骤2计算要删除的正数位置

pos := length - n  // 倒数第 n 个 = 正数第 (length-n) 个

步骤3创建哑节点并移动到目标位置

dummy := &ListNode{0, head}
current = dummy
for i := 0; i < pos; i++ {
    current = current.Next
}

步骤4删除节点

current.Next = current.Next.Next

步骤5返回结果

return dummy.Next

方法三:栈法

核心思想:将所有结点压入栈中,然后弹出 n 个结点,栈顶就是要删除结点的前一个结点。

详细算法流程

步骤1创建哑节点

dummy := &ListNode{0, head}

步骤2将所有节点压入栈

var stack []*ListNode
current := dummy
for current != nil {
    stack = append(stack, current)
    current = current.Next
}

步骤3弹出 n 个节点

for i := 0; i < n; i++ {
    stack = stack[:len(stack)-1]
}

步骤4删除节点

prev := stack[len(stack)-1]
prev.Next = prev.Next.Next

步骤5返回结果

return dummy.Next

优缺点

  • 思路直观,容易理解
  • 空间复杂度 O(L),不是最优解
  • 不推荐使用,双指针法更优

解法

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 实现(计算长度法)

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 实现(栈法)

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: 如果链表是循环链表,应该如何处理?

方法:检测循环,计算长度,然后调整删除位置

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 个结点

func removeFirstNNodes(head *ListNode, n int) *ListNode {
    for i := 0; i < n && head != nil; i++ {
        head = head.Next
    }
    return head
}

Q3: 如果链表很长,如何优化内存使用?

方法

  1. 使用固定大小的滑动窗口
  2. 避免存储整个链表
  3. 使用递归(但会增加栈空间)
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. 深度理解:为什么需要哑结点?

关键点:

  • 处理删除头结点的特殊情况
  • 统一处理逻辑,减少边界条件判断
  • 简化代码,提高可读性

哑结点的作用:

// 没有哑结点的情况
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. 实战扩展:链表操作的通用技巧

技巧总结:

  • 使用哑结点简化边界处理
  • 双指针技巧:快慢指针、前后指针
  • 递归处理链表问题
  • 栈辅助解决链表问题

通用模板:

func solveLinkedListProblem(head *ListNode) *ListNode {
    dummy := &ListNode{0, head}
    // ... 使用双指针或其他技巧
    return dummy.Next
}

3. 变形题目

  1. 19. 删除链表的倒数第N个节点 - 原题
  2. 82. 删除排序链表中的重复元素 II - 删除所有重复元素
  3. 83. 删除排序链表中的重复元素 - 删除重复元素保留一个
  4. 203. 移除链表元素 - 删除指定值的节点

4. 优化技巧

空间优化:

  • 原地操作,不使用额外空间
  • 递归改为迭代

时间优化:

  • 一次遍历完成
  • 提前终止条件

代码优化:

  • 合并重复逻辑
  • 减少不必要的变量

5. 实际应用场景

应用场景:

  • 缓存淘汰策略LRU
  • 音乐播放列表管理
  • 浏览器历史记录
  • 撤销/重做功能

面试问题:

  • 如何处理并发访问的链表?
  • 如何实现线程安全的链表操作?

6. 面试技巧

常见面试问题:

  1. 时间/空间复杂度分析
  2. 边界条件处理
  3. 优化思路
  4. 相关题目变体

回答技巧:

  • 先给出暴力解法
  • 逐步优化
  • 说明权衡取舍

7. 相关题目推荐

相关题目:

  1. 206. 反转链表
  2. 21. 合并两个有序链表
  3. 141. 环形链表
  4. 142. 环形链表 II

总结

这道题的核心是:

  1. 双指针法:一次遍历,快慢指针配合
  2. 边界处理:使用哑结点简化删除头结点的处理
  3. 多种解法:双指针、计算长度、栈法各有优劣

易错点

  • 忘记处理删除头结点的情况
  • 快指针移动步数错误(应该是 n+1
  • 空链表的特殊情况处理
  • 循环链表的特殊情况

最优解法:双指针法,时间 O(L),空间 O(1)