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
|
|||
|
|
}
|
|||
|
|
```
|