280 lines
6.3 KiB
Markdown
280 lines
6.3 KiB
Markdown
---
|
||
tags: [笔试, 微派, 链表, 双指针, Go]
|
||
create time: 2026-05-16 14:40
|
||
---
|
||
|
||
# 02 - 删除链表倒数第 n 个节点
|
||
|
||
## 题面
|
||
|
||
**输入**:
|
||
1. 一个单向链表的头结点 `head`
|
||
2. 整数 `n`(`1 ≤ n ≤ 链表长度`)
|
||
|
||
**输出**: 删除倒数第 n 个节点后的链表头结点
|
||
|
||
**要求**:
|
||
- ACM 模式:从 stdin 读取,stdout 输出结果
|
||
- **一次遍历**完成(O(1) 额外空间)
|
||
- 被删除节点一定存在
|
||
|
||
**示例 1**:
|
||
```
|
||
输入: head = [1, 2, 3, 4, 5], n = 2
|
||
输出: [1, 2, 3, 5]
|
||
解释: 倒数第 2 个是 4,删除后得到 1->2->3->5
|
||
```
|
||
|
||
**示例 2**:
|
||
```
|
||
输入: head = [1], n = 1
|
||
输出: []
|
||
解释: 只有一个节点,删除它后链表为空
|
||
```
|
||
|
||
**示例 3**:
|
||
```
|
||
输入: head = [1, 2], n = 1
|
||
输出: [1]
|
||
解释: 倒数第 1 个(即尾节点 2),删除后剩下 1
|
||
```
|
||
|
||
> [!question] 💡 思考一下
|
||
> 如果题目说"只允许遍历一次",为什么不能先走一遍求长度再走一遍删除?
|
||
|
||
---
|
||
|
||
## 思路
|
||
|
||
### 方法一:快慢指针(双指针)⭐ 最优
|
||
|
||
#### 核心思想
|
||
|
||
```mermaid
|
||
flowchart LR
|
||
A["dummy -> 1 -> 2 -> 3 -> 4 -> 5<br/>n = 2"] --> B["fast 和 slow 都从 dummy 出发"]
|
||
B --> C["第一步: fast 先走 n+1 = 3 步"]
|
||
C --> D["此时 fast=dummy, slow=dummy<br/>fast 在空位, slow 在 dummy"]
|
||
D --> E["第二步: 一起走直到 fast == nil"]
|
||
E --> F["slow 停在 3 的位置"]
|
||
F --> G["slow.Next = slow.Next.Next<br/>跳过节点 4"]
|
||
G --> H["删除完成: 1->2->3->5"]
|
||
```
|
||
|
||
#### 关键步骤
|
||
|
||
1. **创建虚拟头结点 `dummy`**,指向 `head`
|
||
- 为什么要 dummy?因为**可能删除头结点本身**(如示例 2),用 dummy 可以避免特殊判断。
|
||
|
||
2. **快指针 `fast` 先走 n+1 步**
|
||
- 走到 n+1 而不是 n,是为了让慢指针最终停在**要删除节点的前驱**。
|
||
|
||
3. **快慢指针同步前进**,直到 `fast == nil`
|
||
|
||
4. **执行删除**: `slow.Next = slow.Next.Next`
|
||
|
||
#### 为什么是 n+1 步?
|
||
|
||
```
|
||
链表: dummy → 1 → 2 → 3 → 4 → 5
|
||
↑
|
||
删除倒数第 2 个(节点 4)
|
||
|
||
fast 先走 3 步 (n+1):
|
||
dummy → 1 → 2 → 3 → 4 → 5
|
||
^ ^ ^
|
||
dummy dummy(fast) ...继续...
|
||
|
||
不对,让我重新演示:
|
||
dummy → 1 → 2 → 3 → 4 → 5 → nil
|
||
^
|
||
fast 走1步 → 1
|
||
fast 走2步 → 2
|
||
fast 走3步 → 3 (n+1=3 步)
|
||
|
||
现在 fast/slow 同时走:
|
||
slow→1, fast→4 (step 1)
|
||
slow→2, fast→5 (step 2)
|
||
slow→3, fast→nil (step 3, 停止)
|
||
|
||
slow 停在 3,slow.Next = 4(要删除的节点)✓
|
||
```
|
||
|
||
> [!tip] 规律总结
|
||
> - 快指针先走 `n+1` 步
|
||
> - 当快指针到达 `nil` 时,慢指针恰好停在被删节点的**前驱**
|
||
> - 时间复杂度 O(n),空间复杂度 O(1)
|
||
|
||
---
|
||
|
||
### 方法二:两次遍历(备选)
|
||
|
||
虽然题目通常要求一次遍历,但了解这个方法有助于面试中展示全面性:
|
||
|
||
1. 第一次遍历计算链表长度 L
|
||
2. 第二次遍历走到正数第 `(L-n)` 个节点,执行删除
|
||
|
||
时间 O(n),空间 O(1),但需要**两次**遍历。
|
||
|
||
---
|
||
|
||
### 方法三:栈(备选)
|
||
|
||
1. 将所有节点入栈
|
||
2. 弹出 n 次,第 n 次弹出的就是要删除的节点
|
||
3. 栈顶元素即为前驱节点,执行删除
|
||
|
||
时间 O(n),空间 O(n)(不满足 O(1) 空间要求)。
|
||
|
||
---
|
||
|
||
## 代码提示
|
||
|
||
```go
|
||
// 1. 定义 ListNode 结构体
|
||
// 2. 创建 dummy := &ListNode{Next: head}
|
||
// 3. fast, slow := dummy, dummy
|
||
// 4. for i := 0; i <= n; i++ { fast = fast.Next } // 先走 n+1 步
|
||
// 5. for fast != nil { fast = fast.Next; slow = slow.Next }
|
||
// 6. slow.Next = slow.Next.Next // 删除
|
||
// 7. return dummy.Next
|
||
```
|
||
|
||
---
|
||
|
||
## 技巧
|
||
|
||
> [!note] ACM 模式的辅助函数
|
||
> 在 ACM 模式下,通常需要写 helper 函数来将数组转为链表,以及将链表输出为数组格式。这些虽然不是算法核心,但**必不可少**。
|
||
|
||
```go
|
||
// 常用模板:数组转链表
|
||
func arrToList(arr []int) *ListNode {
|
||
dummy := &ListNode{}
|
||
cur := dummy
|
||
for _, v := range arr {
|
||
cur.Next = &ListNode{Val: v}
|
||
cur = cur.Next
|
||
}
|
||
return dummy.Next
|
||
}
|
||
|
||
// 常用模板:链表转数组
|
||
func listToArray(head *ListNode) []int {
|
||
var result []int
|
||
for head != nil {
|
||
result = append(result, head.Val)
|
||
head = head.Next
|
||
}
|
||
return result
|
||
}
|
||
```
|
||
|
||
> [!warning] ⚠️ 边界检查
|
||
> 删除首节点的情况由 `dummy` 自动处理,不需要 `if head == nil` 判断。但要注意 `n` 超过链表长度的情况——题目保证合法时可跳过。
|
||
|
||
---
|
||
|
||
## 代码
|
||
|
||
```go
|
||
package main
|
||
|
||
import (
|
||
"bufio"
|
||
"fmt"
|
||
"os"
|
||
"strconv"
|
||
"strings"
|
||
)
|
||
|
||
// ListNode 单链表节点定义
|
||
type ListNode struct {
|
||
Val int
|
||
Next *ListNode
|
||
}
|
||
|
||
func main() {
|
||
scanner := bufio.NewScanner(os.Stdin)
|
||
|
||
// 第一行: 数组形式的链表 "1 2 3 4 5"
|
||
if !scanner.Scan() {
|
||
return
|
||
}
|
||
parts := strings.Fields(scanner.Text())
|
||
n := len(parts)
|
||
|
||
// 第二行: 目标 n
|
||
if !scanner.Scan() {
|
||
return
|
||
}
|
||
removeN, _ := strconv.Atoi(strings.TrimSpace(scanner.Text()))
|
||
|
||
// 数组转链表
|
||
head := arrToList(parts)
|
||
|
||
// 删除倒数第 n 个节点
|
||
newHead := removeNthFromEnd(head, removeN)
|
||
|
||
// 输出: 逗号分隔或空格分隔均可
|
||
output := listToArray(newHead)
|
||
if len(output) == 0 {
|
||
fmt.Println("[]")
|
||
} else {
|
||
fmt.Print("[")
|
||
for i, v := range output {
|
||
if i > 0 {
|
||
fmt.Print(" ")
|
||
}
|
||
fmt.Print(v)
|
||
}
|
||
fmt.Println("]")
|
||
}
|
||
}
|
||
|
||
// removeNthFromEnd 一次遍历删除倒数第 n 个节点
|
||
func removeNthFromEnd(head *ListNode, n int) *ListNode {
|
||
// 创建虚拟头结点,简化头节点删除的处理
|
||
dummy := &ListNode{Next: head}
|
||
fast, slow := dummy, dummy
|
||
|
||
// 快指针先走 n+1 步
|
||
for i := 0; i <= n; i++ {
|
||
fast = fast.Next
|
||
}
|
||
|
||
// 快慢指针同步前进,直到 fast 到达末尾
|
||
for fast != nil {
|
||
fast = fast.Next
|
||
slow = slow.Next
|
||
}
|
||
|
||
// slow 现在停在要删除节点的前驱
|
||
slow.Next = slow.Next.Next
|
||
|
||
return dummy.Next
|
||
}
|
||
|
||
// arrToList 将字符串数组转换为链表
|
||
func arrToList(parts []string) *ListNode {
|
||
dummy := &ListNode{}
|
||
cur := dummy
|
||
for _, p := range parts {
|
||
v, _ := strconv.Atoi(p)
|
||
cur.Next = &ListNode{Val: v}
|
||
cur = cur.Next
|
||
}
|
||
return dummy.Next
|
||
}
|
||
|
||
// listToArray 将链表转换为切片
|
||
func listToArray(head *ListNode) []int {
|
||
var result []int
|
||
for head != nil {
|
||
result = append(result, head.Val)
|
||
head = head.Next
|
||
}
|
||
return result
|
||
}
|
||
```
|