学习目标
读完本文,你应该能够:
- 徒手写出反转链表的迭代与递归两种写法,并讲清指针翻转的每一步;
- 用「排序 + 双指针」套路解决三数之和之类的去重问题;
- 区分 TopK 的「堆解法」与「快排 partition 解法」,并分析各自复杂度;
- 理解大文件排序为何必须走外部排序(分块 + 多路归并);
- 面对亿级数据能说出分治、bitmap、布隆过滤器、哈希分片等工具;
- 完整实现二叉树的前中后序与层序遍历(递归 + 迭代);
- 掌握循环有序数组的二分查找变形;
- 说清快排与堆排原理,并背出主流排序算法的稳定性对照表;
- 用信息论思路理解「100 枚硬币 3 次称量」为何足够。
一、反转链表
直觉类比
把链表想成一列手拉手排队的人,每个人只认得自己右手边的人。反转链表,就是让每个人改牵左手边的人——但如果你直接改牵,就会从此看不到后面的人(断链)。所以诀窍是:在改牵之前,先让一个小本子记下「下一个人是谁」。
指针翻转过程(Mermaid)
graph LR
subgraph 初始
A1[1] --> B1[2]
B1 --> C1[3]
C1 --> D1[nil]
end
subgraph 第1步: prev=nil, cur=1
A2[1] -.->|Next=prev| N2[nil]
A2 --> B2[2]
B2 --> C2[3]
C2 --> D2[nil]
end
subgraph 第2步: prev=1, cur=2
A3[1] --> N3[nil]
B3[2] -.->|Next=prev| A3
B3 --> C3[3]
C3 --> D3[nil]
end
subgraph 第3步: prev=2, cur=3
A4[1] --> N4[nil]
B4[2] --> A4
C4[3] -.->|Next=prev| B4
end
subgraph 完成: 返回prev=3
C5[3] --> B5[2]
B5 --> A5[1]
A5 --> N5[nil]
end可运行 Go 代码(分步注释)
package main
import "fmt"
// ListNode 单链表节点
type ListNode struct {
Val int
Next *ListNode
}
// reverseIter 迭代反转:用 prev/cur/next 三个指针滚动
func reverseIter(head *ListNode) *ListNode {
var prev *ListNode // prev 始终指向上一个已反转的节点
cur := head // cur 是当前正在处理的节点
for cur != nil {
next := cur.Next // ① 先存下后继,否则改完指针就找不到它了(防断链)
cur.Next = prev // ② 把当前节点指向上一个节点,完成"翻转"
prev = cur // ③ prev 前移一步
cur = next // ④ cur 前移一步
}
return prev // 循环结束时 cur 为 nil,prev 恰好是新头
}
// reverseRecur 递归反转:把"子链表的反转"当作已完成的前提
func reverseRecur(head *ListNode) *ListNode {
if head == nil || head.Next == nil {
return head // 空链表或只有一个节点,直接返回(递归出口)
}
newHead := reverseRecur(head.Next) // 假设 head.Next 之后的部分已反转好
head.Next.Next = head // 让后一个节点回指当前节点
head.Next = nil // 当前节点变成尾部,断掉原来的指向
return newHead // 新的头始终是原链表的最后一个节点
}
// 构建 [1,2,3] 并打印
func main() {
head := &ListNode{1, &ListNode{2, &ListNode{3, nil}}}
r := reverseIter(head)
for r != nil {
fmt.Print(r.Val, " ")
r = r.Next
}
// 输出: 3 2 1
}
二、三数之和
直觉类比
从一群身高不同的人里挑出「三人组,身高和为 0」。最笨的办法是三重循环。但如果我们先让大家按身高排好队,就可以用「固定一人,左右两人夹逼」的办法,避免大量重复尝试——这正是双指针的核心思想。
思路流程(Mermaid)
flowchart TD
A[对数组排序] --> B[枚举第一个数 i]
B --> C{i 与前一个相同?}
C -->|是| B
C -->|否| D[左指针 left=i+1, 右指针 right=n-1]
D --> E{left < right?}
E -->|否| B
E -->|是| F[计算 sum = nums+i+left+right]
F --> G{sum == 0?}
G -->|等于0| H[记录结果, 左右两侧跳过重复值]
G -->|小于0| I[left++ 增大和]
G -->|大于0| J[right-- 减小和]
H --> D
I --> D
J --> D可运行 Go 代码(分步注释)
package main
import (
"fmt"
"sort"
)
// threeSum 返回所有不重复的三元组,使三数之和为 0
func threeSum(nums []int) [][]int {
sort.Ints(nums) // ① 排序是双指针的前提
res := [][]int{}
n := len(nums)
for i := 0; i < n-2; i++ {
// ② 跳过重复的 i,避免产生重复三元组
if i > 0 && nums[i] == nums[i-1] {
continue
}
// ③ 剪枝:当前数与后面最小两数之和已 >0,后面只会更大
if nums[i]+nums[i+1]+nums[i+2] > 0 {
break
}
// ④ 剪枝:当前数与后面最大两数之和仍 <0,换更大的 i
if nums[i]+nums[n-1]+nums[n-2] < 0 {
continue
}
left, right := i+1, n-1
for left < right {
s := nums[i] + nums[left] + nums[right]
if s == 0 {
res = append(res, []int{nums[i], nums[left], nums[right]})
// ⑤ 去重:跳过相同的 left 和 right
for left < right && nums[left] == nums[left+1] {
left++
}
for left < right && nums[right] == nums[right-1] {
right--
}
left++
right--
} else if s < 0 {
left++ // 和太小,左指针右移增大和
} else {
right-- // 和太大,右指针左移减小和
}
}
}
return res
}
func main() {
fmt.Println(threeSum([]int{-1, 0, 1, 2, -1, -4}))
// 输出: [[-1 -1 2] [-1 0 1]]
}
三、TopK / 第 K 大
直觉类比
TopK 就像「从一大堆考生里挑出分数最高的 K 名」。两种思路:其一,用一个只能装 K 人的小本子(最小堆),每来一个更高的就替换掉里面最低分的;其二,借鉴快排的「分区」——只要知道某次分区后 pivot 排在第几,就能只在一半里继续找,平均只需看少量元素。
解法一:最小堆(适合流式数据)
package main
import (
"container/heap"
"fmt"
)
// IntHeap 用 container/heap 实现最小堆
type IntHeap []int
func (h IntHeap) Len() int { return len(h) }
func (h IntHeap) Less(i, j int) bool { return h[i] < h[j] } // 最小堆
func (h IntHeap) Swap(i, j int) { h[i], h[j] = h[j], h[i] }
func (h *IntHeap) Push(x interface{}) { *h = append(*h, x.(int)) }
func (h *IntHeap) Pop() interface{} {
old := *h
n := len(old)
x := old[n-1]
*h = old[:n-1]
return x
}
// topK 返回最大的 k 个元素(不保证内部顺序)
func topK(nums []int, k int) []int {
h := &IntHeap{}
heap.Init(h)
for _, v := range nums {
heap.Push(h, v)
if h.Len() > k {
heap.Pop(h) // 堆始终保持 k 个元素,堆顶是其中最小的
}
}
res := make([]int, 0, k)
for h.Len() > 0 {
res = append(res, heap.Pop(h).(int))
}
return res
}
func main() {
fmt.Println(topK([]int{3, 1, 5, 2, 4}, 3)) // 最大3个: [5 4 3](顺序不定)
}
解法二:快排 partition(平均 O(n))
package main
import "fmt"
// partition 按"大于 pivot 的放左边"分区,返回 pivot 最终下标
func partition(nums []int, left, right int) int {
pivot := nums[right]
i := left
for j := left; j < right; j++ {
if nums[j] > pivot { // 找第 K 大 → 大的在前
nums[i], nums[j] = nums[j], nums[i]
i++
}
}
nums[i], nums[right] = nums[right], nums[i]
return i
}
// kthLargest 返回第 k 大(k 从 1 开始);会修改 nums
func kthLargest(nums []int, k int) int {
target := k - 1 // 第 k 大对应降序数组下标 k-1
left, right := 0, len(nums)-1
for left <= right {
p := partition(nums, left, right)
if p == target {
return nums[p]
} else if p > target {
right = p - 1
} else {
left = p + 1
}
}
return -1
}
func main() {
fmt.Println(kthLargest([]int{3, 1, 5, 2, 4}, 2)) // 第2大: 4
}
复杂度对照
- 堆解法:建堆 + 遍历,时间 O(n log k),空间 O(k),适合数据流式到来、k 远小于 n。
- partition 解法:平均 O(n),最坏 O(n²)(每次分区极不均衡),空间 O(1)(原地),注意会修改原数组。
四、大文件排序
直觉类比
内存只有 1GB,却要排序 10GB 的日志文件——这就像你的书桌放不下整本字典,只能先分册排序,再逐页合并。这就是外部排序:先把大文件切成能塞进内存的小块各自排序,再用「多路归并」像拉拉链一样合并成有序大文件。
外部排序流程(Mermaid)
flowchart LR
F[10GB 大文件] --> S1[分块读取]
S1 --> C1[块1 读入内存排序 写回 chunk1]
S1 --> C2[块2 排序 写回 chunk2]
S1 --> C3[块N 排序 写回 chunkN]
C1 --> M[多路归并]
C2 --> M
C3 --> M
M --> O[有序大文件]Go 思路代码(分步注释)
package main
import (
"bufio"
"fmt"
"os"
"sort"
"strconv"
"strings"
)
// sortChunk 把单个分块文件读入内存、排序、写回
func sortChunk(path string, lines []int) error {
sort.Ints(lines) // 内存中排序(数据量需小于可用内存)
f, err := os.Create(path)
if err != nil {
return err
}
defer f.Close()
w := bufio.NewWriter(f)
for _, v := range lines {
fmt.Fprintln(w, v)
}
return w.Flush()
}
// mergeChunks 多路归并:每次从各块当前最小值中选最小,写入结果
func mergeChunks(outPath string, chunkPaths []string) error {
readers := make([]*bufio.Scanner, len(chunkPaths))
cur := make([]int, len(chunkPaths)) // 各路当前值
valid := make([]bool, len(chunkPaths)) // 各路是否还有值
for i, p := range chunkPaths {
f, _ := os.Open(p)
readers[i] = bufio.NewScanner(f)
advance(readers[i], &cur[i], &valid[i])
defer f.Close()
}
out, _ := os.Create(outPath)
defer out.Close()
w := bufio.NewWriter(out)
defer w.Flush()
for {
// 选当前有效路中的最小值
best, idx := 0, -1
for i := range cur {
if valid[i] && (idx == -1 || cur[i] < best) {
best, idx = cur[i], i
}
}
if idx == -1 {
break // 全部耗尽
}
fmt.Fprintln(w, best)
advance(readers[idx], &cur[idx], &valid[idx])
}
return nil
}
func advance(s *bufio.Scanner, v *int, ok *bool) {
if s.Scan() {
n, _ := strconv.Atoi(strings.TrimSpace(s.Text()))
*v, *ok = n, true
} else {
*ok = false
}
}
func main() {
// 实际场景中按固定行数/字节数切分;此处仅示意调用
_ = sortChunk
_ = mergeChunks
}
五、亿级数据处理思路
直觉类比
面对「10 亿个数里找出重复」之类的问题,内存装不下时,核心武器是把大问题拆小、用位代替字节、用概率换空间。
四类常用武器
- 分治 / 哈希分片:用
hash(x) % M把数据分到 M 个小文件中,使每个小文件能放进内存单独处理。典型场景:超大文件找 TopK、找重复 URL。 - Bitmap(位图):一个 bit 表示一个数是否存在。40 亿个 int 用 HashSet 约 16GB,用 bitmap 仅约 500MB。适合「判断存在性 / 去重」且值域可接受的情况。
- Bloom Filter(布隆过滤器):用 k 个哈希 + 一个 bit 数组,空间极小,代价是「可能误判存在,但绝不会误判不存在」。适合缓存穿透防护、爬虫去重。
- 外存 + 外部排序 / 堆:见第四章,处理远超内存的排序与 TopK。
Bitmap 示例(Go)
package main
import "fmt"
// Bitmap 用位运算压缩存储"是否存在"
type Bitmap struct {
bits []uint64
}
func NewBitmap(n int) *Bitmap {
return &Bitmap{bits: make([]uint64, (n>>6)+1)}
}
func (b *Bitmap) Set(x int) {
b.bits[x>>6] |= 1 << (x & 63) // 把第 x 位置 1
}
func (b *Bitmap) Has(x int) bool {
return b.bits[x>>6]&(1<<(x&63)) != 0
}
func main() {
bm := NewBitmap(1000000)
bm.Set(42)
fmt.Println(bm.Has(42), bm.Has(43)) // true false
}
六、二叉树遍历
直觉类比
遍历二叉树就像走迷宫:前/中/后序决定你「在路口的什么时候做标记」(访问节点),层序则像一层一层地扫描楼层。递归写法最自然;迭代写法需要显式用栈(或队列)模拟系统调用栈。
递归与迭代结构(Mermaid)
flowchart TD
R[根节点] --> L[左子树]
R --> Ri[右子树]
subgraph 前序: 根->左->右
A1[访问根] --> A2[遍历左] --> A3[遍历右]
end
subgraph 中序: 左->根->右
B1[遍历左] --> B2[访问根] --> B3[遍历右]
end
subgraph 后序: 左->右->根
C1[遍历左] --> C2[遍历右] --> C3[访问根]
end可运行 Go 代码(分步注释)
package main
import "fmt"
type TreeNode struct {
Val int
Left *TreeNode
Right *TreeNode
}
// 递归:前序
func preorder(root *TreeNode) []int {
res := []int{}
var dfs func(*TreeNode)
dfs = func(n *TreeNode) {
if n == nil {
return
}
res = append(res, n.Val) // 先访问根
dfs(n.Left)
dfs(n.Right)
}
dfs(root)
return res
}
// 迭代:中序(用栈模拟)
func inorderIter(root *TreeNode) []int {
res := []int{}
stack := []*TreeNode{}
cur := root
for cur != nil || len(stack) > 0 {
for cur != nil {
stack = append(stack, cur) // 一路压左
cur = cur.Left
}
cur = stack[len(stack)-1] // 弹出最左
stack = stack[:len(stack)-1]
res = append(res, cur.Val) // 访问
cur = cur.Right // 转向右子树
}
return res
}
// 层序:队列实现(BFS)
func levelOrder(root *TreeNode) []int {
res := []int{}
if root == nil {
return res
}
queue := []*TreeNode{root}
for len(queue) > 0 {
n := queue[0]
queue = queue[1:]
res = append(res, n.Val)
if n.Left != nil {
queue = append(queue, n.Left)
}
if n.Right != nil {
queue = append(queue, n.Right)
}
}
return res
}
func main() {
// 1
// / \
// 2 3
root := &TreeNode{1, &TreeNode{2, nil, nil}, &TreeNode{3, nil, nil}}
fmt.Println(preorder(root)) // [1 2 3]
fmt.Println(inorderIter(root)) // [2 1 3]
fmt.Println(levelOrder(root)) // [1 2 3]
}
七、循环有序数组查找指定值
直觉类比
普通有序数组像一条直尺,二分一眼找到中点。循环有序数组(如 [4,5,6,1,2,3])像把直尺从中剪断再接成环后拍平,仍然有序但「断点」未知。二分的诀窍是:先判断哪一半是真正连续有序的,再判断 target 是否落在该半区间内。
二分变形流程(Mermaid)
flowchart TD
A[low=0, high=n-1] --> B{low <= high?}
B -->|否| Z[返回 -1]
B -->|是| C[mid = (low+high)/2]
C --> D{nums[mid] == target?}
D -->|是| Y[返回 mid]
D -->|否| E{左半 [low,mid] 有序?}
E -->|是| F{target 在左半区间?}
F -->|是| G[high=mid-1]
F -->|否| H[low=mid+1]
E -->|否| I{target 在右半区间?}
I -->|是| J[low=mid+1]
I -->|否| K[high=mid-1]
G --> B
H --> B
J --> B
K --> B可运行 Go 代码(分步注释)
package main
import "fmt"
// searchRotate 在循环有序数组中查找 target,返回下标,找不到返回 -1
func searchRotate(nums []int, target int) int {
low, high := 0, len(nums)-1
for low <= high {
mid := low + (high-low)/2 // 防溢出写法
if nums[mid] == target {
return mid
}
// 判断左半段 [low, mid] 是否完全有序
if nums[low] <= nums[mid] {
// 左半有序:看 target 是否落在左半区间
if target >= nums[low] && target < nums[mid] {
high = mid - 1
} else {
low = mid + 1
}
} else {
// 右半段 [mid, high] 有序:看 target 是否落在右半区间
if target > nums[mid] && target <= nums[high] {
low = mid + 1
} else {
high = mid - 1
}
}
}
return -1
}
func main() {
fmt.Println(searchRotate([]int{4, 5, 6, 1, 2, 3}, 2)) // 4
fmt.Println(searchRotate([]int{4, 5, 6, 1, 2, 3}, 0)) // -1
}
八、排序算法:快排与堆排
直觉类比
- 快速排序:像「选一个裁判,比裁判小的站左边、大的站右边」,再对两边各自重复。理想情况下每次都能对半分。
- 堆排序:先建一座「最大堆」金字塔(父总比子大),然后反复把塔尖(最大值)搬到末尾,再下沉调整,像不断把最高的楼层拆到最边上。
快排分区过程(Mermaid)
flowchart LR
subgraph 选pivot=末尾
P[5]
end
subgraph 分区后
L[小于5: 3 1 2] --> M[5] --> R[大于5: 8 6]
end
L --> LL[递归排序左]
R --> RR[递归排序右]快排实现(Go)
package main
import "fmt"
// quickSort 递归快排(原地)
func quickSort(nums []int, left, right int) {
if left >= right {
return
}
p := partitionQS(nums, left, right)
quickSort(nums, left, p-1)
quickSort(nums, p+1, right)
}
func partitionQS(nums []int, left, right int) int {
pivot := nums[right]
i := left
for j := left; j < right; j++ {
if nums[j] < pivot { // 小的放左边
nums[i], nums[j] = nums[j], nums[i]
i++
}
}
nums[i], nums[right] = nums[right], nums[i]
return i
}
func main() {
a := []int{5, 3, 8, 1, 2, 6}
quickSort(a, 0, len(a)-1)
fmt.Println(a) // [1 2 3 5 6 8]
}
堆调整过程(Mermaid)
flowchart TD
A[建最大堆] --> B[交换堆顶与末尾]
B --> C[堆大小减1]
C --> D[下沉调整恢复堆性质]
D --> E{堆大小>1?}
E -->|是| B
E -->|否| F[排序完成]堆排实现(Go)
package main
import "fmt"
// heapSort 堆排序(升序,用最大堆)
func heapSort(nums []int) {
n := len(nums)
// ① 自底向上建堆:从最后一个非叶子节点开始下沉
for i := n/2 - 1; i >= 0; i-- {
siftDown(nums, i, n)
}
// ② 反复把堆顶(最大)换到末尾,再调整
for end := n - 1; end > 0; end-- {
nums[0], nums[end] = nums[end], nums[0]
siftDown(nums, 0, end) // 堆大小缩小为 end
}
}
// siftDown 下沉:将下标 i 的元素调整到合适位置以维持最大堆
func siftDown(nums []int, i, n int) {
for {
l, r := i*2+1, i*2+2
largest := i
if l < n && nums[l] > nums[largest] {
largest = l
}
if r < n && nums[r] > nums[largest] {
largest = r
}
if largest == i {
break // 已满足堆性质
}
nums[i], nums[largest] = nums[largest], nums[i]
i = largest
}
}
func main() {
b := []int{5, 3, 8, 1, 2, 6}
heapSort(b)
fmt.Println(b) // [1 2 3 5 6 8]
}
九、哪些排序是稳定的
稳定性定义
稳定性:若待排序序列中存在两个「关键字相等」的元素,排序后它们的相对先后顺序保持不变,则称该排序是稳定的。例如 [3a, 1, 3b](两个 3 来自不同记录),稳定排序后必然是 3a 仍在 3b 前面。
主流排序稳定性对照表
| 排序算法 | 平均时间 | 最坏时间 | 空间 | 稳定性 | 说明 |
|---|---|---|---|---|---|
| 冒泡排序 | O(n²) | O(n²) | O(1) | 稳定 | 相邻比较,相等不交换 |
| 插入排序 | O(n²) | O(n²) | O(1) | 稳定 | 相等时插在已排序列之后 |
| 归并排序 | O(n log n) | O(n log n) | O(n) | 稳定 | 合并时优先取左半元素 |
| 计数排序 | O(n+k) | O(n+k) | O(k) | 稳定 | 桶累计,逆序回填 |
| 基数排序 | O(d·n) | O(d·n) | O(n+k) | 稳定 | 依赖低位稳定排序 |
| 选择排序 | O(n²) | O(n²) | O(1) | 不稳定 | 跨位置交换会打乱相等元素 |
| 快速排序 | O(n log n) | O(n²) | O(log n) | 不稳定 | 分区交换破坏顺序 |
| 堆排序 | O(n log n) | O(n log n) | O(1) | 不稳定 | 堆顶与末尾交换破坏顺序 |
| 希尔排序 | O(n^1.3) | O(n²) | O(1) | 不稳定 | 跳跃分组插入 |
记忆口诀:稳的有「冒插归计基」;不稳的有「选快堆希」。面试常问:快排、堆排、希尔都不稳定,归并稳定。
十、100 枚硬币天平找异常
直觉类比
天平每次称量有三种结果:左重、右重、平衡。这意味着一次称量最多把可能性切成 3 份——这是典型的三进制信息论。100 枚硬币里恰有 1 枚异常(不知轻重),总状态数是 100 × 2 = 200(100 个位置 × 轻/重两种可能)。
为什么 3 次不够、需要更多?
3 次称量最多区分 3³ = 27 种结果,远小于 200,所以仅凭「轻重未知」的 200 种状态,3 次其实不够。经典结论:要定位「1 枚异常且不知轻重」,最多可处理 (3^n - 3) / 2 枚;n=5 时可处理 (243-3)/2 = 120 枚,故 100 枚至少需要 5 次。
若已知异常"更轻":3 次为何够?
此时状态数仅 100(只需定位位置)。每次三等分:
第1次: 100 → 33 | 33 | 34 称 33 vs 33
若平衡 → 异常在剩余的 34 里;否则在较轻那 33 里
第2次: ~34 → 11 | 11 | 12
第3次: ~12 → 4 | 4 | 4 称 4 vs 4
第4次: ~4 → 1 | 1 | 2 ← 实际上 4 还需 2 次
注意:即使已知轻重,3 次也只能覆盖 3³ = 27 < 100,因此 100 枚已知轻重也需 5 次(每轮三分逼近)。真正「3 次够」的场景是 最多 27 枚已知轻重或 13 枚轻重未知((27-3)/2=12)。
分治思路(Go 模拟三分)
package main
import "fmt"
// weighTimes 已知轻重时,最少称量次数 = ceil(log3(n))
func weighTimes(n int) int {
t, base := 0, 1
for base < n {
base *= 3
t++
}
return t
}
func main() {
fmt.Println(weighTimes(27)) // 3:27 枚已知轻重,3 次足够
fmt.Println(weighTimes(100)) // 5:100 枚需 5 次
}
核心收获:信息论告诉我们「每次能区分几份」,先算上限再设计称量方案,比盲目试错靠谱得多。
十一、自测题与动手练习
- 反转链表:用迭代法反转一个带环链表会怎样?如何检测链表是否有环(快慢指针)?
- 三数之和:把题目改为「三数之和最接近 target」,双指针该怎么调整?
- TopK:当
k非常接近n时,堆解法与全部排序相比还有优势吗?为什么? - 大文件排序:如果每一块排序后还要去重,外部排序流程要加哪一步?
- 二叉树:用迭代(非递归)实现后序遍历,有哪两种常见写法(双栈法 / 染色法)?
- 循环有序数组:若数组允许重复元素,
nums[low] <= nums[mid]的判断还安全吗?如何修正? - 稳定性:给你一个结构体数组按分数排序,要求同分保持原顺序,你选哪种排序?为什么?
- 硬币问题:9 枚硬币已知 1 枚更轻,最少几次称量?写出分组方案。
十二、本章小结
本文把手撕代码高频题串成一条线:
- 反转链表是「指针操作 + 防断链」的入门,递归与迭代都要会;
- 三数之和是「排序 + 双指针 + 去重」的范本,可推广到两数之和、四数之和;
- TopK 区分「堆(O(n log k),流式友好)」与「partition(平均 O(n),原地)」;
- 大文件排序必须走外部排序:分块排序 + 多路归并;
- 亿级数据靠分治、bitmap、布隆过滤器、哈希分片把大问题拆小;
- 二叉树遍历递归最直观,迭代要靠栈/队列显式模拟;
- 循环有序数组是二分变形,先判半段是否有序再决定收缩方向;
- 快排 / 堆排一个是分区思想、一个是堆结构,二者都不稳定,而归并稳定;
- 硬币称量用信息论(三进制)先算上限,再设计分治方案。
把这些套路练熟,面试中遇到变形题也能快速归类、套用对应解法。
- 反转链表是面试必考——“防断链"是核心口诀:保存 next → 改指针 → 前移一步,三步循环。
- 三数之和是排序 + 双指针的范本,去重要注意跳过相邻重复元素。
- TopK:数据量大用堆(O(n log k)),数据可以全部放入内存用 partition(平均 O(n))。
- 外部排序是大数据面试常客,分块排序 + K 路归并,面试官常追问"内存不够怎么办”。
- 下一章的进阶手撕会讲动态规划 + 二分 + Trie,是这些基础题的升级版。