Go 算法与手撕代码:链表、数组、排序与大数据处理

2022-02-11T10:00:00+08:00 | 13分钟阅读 | 更新于 2022-02-11T10:00:00+08:00

@
手撕代码核心链表操作二分/双指针排序/分治反转链表三数之和快排/堆排TopK外部排序布隆过滤器

学习目标

读完本文,你应该能够:

  • 徒手写出反转链表的迭代与递归两种写法,并讲清指针翻转的每一步;
  • 用「排序 + 双指针」套路解决三数之和之类的去重问题;
  • 区分 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 亿个数里找出重复」之类的问题,内存装不下时,核心武器是把大问题拆小、用位代替字节、用概率换空间

四类常用武器

  1. 分治 / 哈希分片:用 hash(x) % M 把数据分到 M 个小文件中,使每个小文件能放进内存单独处理。典型场景:超大文件找 TopK、找重复 URL。
  2. Bitmap(位图):一个 bit 表示一个数是否存在。40 亿个 int 用 HashSet 约 16GB,用 bitmap 仅约 500MB。适合「判断存在性 / 去重」且值域可接受的情况。
  3. Bloom Filter(布隆过滤器):用 k 个哈希 + 一个 bit 数组,空间极小,代价是「可能误判存在,但绝不会误判不存在」。适合缓存穿透防护、爬虫去重。
  4. 外存 + 外部排序 / 堆:见第四章,处理远超内存的排序与 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 次
}

核心收获:信息论告诉我们「每次能区分几份」,先算上限再设计称量方案,比盲目试错靠谱得多。


十一、自测题与动手练习

  1. 反转链表:用迭代法反转一个带环链表会怎样?如何检测链表是否有环(快慢指针)?
  2. 三数之和:把题目改为「三数之和最接近 target」,双指针该怎么调整?
  3. TopK:当 k 非常接近 n 时,堆解法与全部排序相比还有优势吗?为什么?
  4. 大文件排序:如果每一块排序后还要去重,外部排序流程要加哪一步?
  5. 二叉树:用迭代(非递归)实现后序遍历,有哪两种常见写法(双栈法 / 染色法)?
  6. 循环有序数组:若数组允许重复元素,nums[low] <= nums[mid] 的判断还安全吗?如何修正?
  7. 稳定性:给你一个结构体数组按分数排序,要求同分保持原顺序,你选哪种排序?为什么?
  8. 硬币问题:9 枚硬币已知 1 枚更轻,最少几次称量?写出分组方案。

十二、本章小结

本文把手撕代码高频题串成一条线:

  • 反转链表是「指针操作 + 防断链」的入门,递归与迭代都要会;
  • 三数之和是「排序 + 双指针 + 去重」的范本,可推广到两数之和、四数之和;
  • TopK 区分「堆(O(n log k),流式友好)」与「partition(平均 O(n),原地)」;
  • 大文件排序必须走外部排序:分块排序 + 多路归并;
  • 亿级数据靠分治、bitmap、布隆过滤器、哈希分片把大问题拆小;
  • 二叉树遍历递归最直观,迭代要靠栈/队列显式模拟;
  • 循环有序数组是二分变形,先判半段是否有序再决定收缩方向;
  • 快排 / 堆排一个是分区思想、一个是堆结构,二者都不稳定,而归并稳定;
  • 硬币称量用信息论(三进制)先算上限,再设计分治方案。

把这些套路练熟,面试中遇到变形题也能快速归类、套用对应解法。

复习提示:
  • 反转链表是面试必考——“防断链"是核心口诀:保存 next → 改指针 → 前移一步,三步循环。
  • 三数之和是排序 + 双指针的范本,去重要注意跳过相邻重复元素。
  • TopK:数据量大用堆(O(n log k)),数据可以全部放入内存用 partition(平均 O(n))。
  • 外部排序是大数据面试常客,分块排序 + K 路归并,面试官常追问"内存不够怎么办”。
  • 下一章的进阶手撕会讲动态规划 + 二分 + Trie,是这些基础题的升级版。
About Me

没什么想介绍的,一个很大众的码农…

喜欢代码,车,马,真的是 🐎

讨厌别人让我给自己的代码写注释 最厌烦别人的程序没有写注释

目标

学AI,加油!加油!