Go 算法进阶手撕代码:动态规划、链表、树与并发

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

@

学习目标

读完本文你应该能:

  • 用「状态 + 选择」的框架独立写出动态规划四连题(完全背包 / 0-1 背包 / 子集和 / 子数组和为 K)。
  • 手写二分查找的三种形态(标准、左边界、右边界),并说清 left <= rightleft < right 的取舍。
  • 用最小堆或分治法合并 K 个有序链表;对链表做排序、去重、找倒数第 K 个、转平衡 BST。
  • 用层序数组(含 nil)重建二叉树,并写出右视图、之字形遍历。
  • 手写 Trie、并查/哈希表、LinkedHashMap,以及并发交替打印奇偶数。
  • 用 Fisher-Yates 与拒绝抽样生成无重复随机数组。

类比先行:下面每一节都会先用「生活直觉」把问题讲明白,再上代码。代码里的注释重点解释 为什么这样写,而不只是「做了什么」。


一、动态规划四题连讲

1. 完全背包:最少硬币数 + 输出具体组合

直觉:把「凑出金额 amount」想象成往存钱罐里投硬币。每投一枚面值为 c 的硬币,剩余要凑的钱就少 c,而硬币数 +1。我们要在所有投法中找「硬币数最少」的那条路。因为同一种硬币可以无限用(完全背包),所以内循环从小到大扫。

状态定义dp[i] = 凑出金额 i 所需的最少硬币数。 状态转移dp[i] = min(dp[i], dp[i-c] + 1),对每个硬币 c 都试一次。

graph LR
  A["dp[0]=0 起点"] --> B["选一枚硬币 c"]
  B --> C["dp[i] = min(dp[i], dp[i-c]+1)"]
  C --> D["i 从小到大扫(可重复选)"]
// coinChange 返回凑出 amount 的最少硬币数,无法凑出返回 -1
func coinChange(coins []int, amount int) int {
	dp := make([]int, amount+1)
	for i := 1; i <= amount; i++ {
		dp[i] = amount + 1 // 初始化为一个不可能的大值(上界是全部用 1 元)
	}
	dp[0] = 0 // 凑 0 元需要 0 枚硬币,这是递推的「地基」

	// 外循环枚举硬币,内循环从小到大:同一硬币可被多次选用(完全背包)
	for _, c := range coins {
		for i := c; i <= amount; i++ {
			if dp[i-c]+1 < dp[i] {
				dp[i] = dp[i-c] + 1
			}
		}
	}
	if dp[amount] > amount {
		return -1 // 没有被更新过,说明凑不出
	}
	return dp[amount]
}

输出具体组合:再开一个 choice[i] 数组记录「凑成 i 用的最后一枚硬币」,最后从 amount 往回跳即可回溯路径。

// coinChangeWithCoins 返回最少硬币数以及具体组合(顺序无所谓)
func coinChangeWithCoins(coins []int, amount int) ([]int, int) {
	dp := make([]int, amount+1)
	choice := make([]int, amount+1) // choice[i] 记录凑成 i 的最后一枚硬币
	for i := 1; i <= amount; i++ {
		dp[i] = amount + 1
	}
	for _, c := range coins {
		for i := c; i <= amount; i++ {
			if dp[i-c]+1 < dp[i] {
				dp[i] = dp[i-c] + 1
				choice[i] = c
			}
		}
	}
	if dp[amount] > amount {
		return nil, -1
	}
	count := dp[amount]
	res := []int{}
	for amount > 0 {
		c := choice[amount]
		res = append(res, c)
		amount -= c // 不断「减去最后一枚硬币」回溯出整条路径
	}
	return res, count
}

复杂度:时间 O(coins * amount),空间 O(amount)

2. 0-1 背包

直觉:每个物品「要么拿、要么不拿」,且只能拿一次。如果还像完全背包那样从小到大扫,同一物品会被重复拿。解决办法是内循环从大到小扫——这样用到 dp[w-c] 时,它还是「上一轮(没考虑当前物品)」的旧值,从而保证不重复选。

状态定义dp[w] = 容量 w 下能装的最大价值。 状态转移dp[w] = max(dp[w], dp[w-weight]+value)

// knapsack01 返回容量 capacity 内能装的最大价值
func knapsack01(weights, values []int, capacity int) int {
	n := len(weights)
	dp := make([]int, capacity+1)
	for i := 0; i < n; i++ {
		// 倒序遍历是 0-1 背包的灵魂:保证每件物品只被选一次
		for w := capacity; w >= weights[i]; w-- {
			if dp[w-weights[i]]+values[i] > dp[w] {
				dp[w] = dp[w-weights[i]] + values[i]
			}
		}
	}
	return dp[capacity]
}

复杂度:时间 O(n * capacity),空间 O(capacity)

3. 子集和(能否选出若干数凑出 target)

直觉:这其实是 0-1 背包的布尔版——dp[s] 表示能否凑出和 s。常见变体是「数组能否分成和相等的两部分」,等价于「能否凑出总和的一半」。

// canPartition 判断数组能否被分成两个和相等的子集
func canPartition(nums []int) bool {
	sum := 0
	for _, v := range nums {
		sum += v
	}
	if sum%2 != 0 {
		return false // 奇数总和无法平分
	}
	target := sum / 2
	dp := make([]bool, target+1)
	dp[0] = true // 凑出 0 永远可行(什么都不选)
	for _, v := range nums {
		// 同样倒序,避免同一数字被重复使用
		for s := target; s >= v; s-- {
			dp[s] = dp[s] || dp[s-v]
		}
	}
	return dp[target]
}

复杂度:时间 O(n * target),空间 O(target)

4. 子数组和为 K(前缀和 + 哈希表)

直觉:与其枚举所有子数组(O(n²)),不如维护「前缀和」prefix。若 [0..j] 的前缀和是 p,而之前某处 [0..i] 的前缀和是 p-K,那么 (i, j] 这段的和正好是 K。用哈希表记录「每个前缀和出现过几次」,边走边查 prefix-K 即可。

// subarraySum 返回和为 k 的连续子数组个数
func subarraySum(nums []int, k int) int {
	prefix := 0
	count := 0
	m := map[int]int{0: 1} // 前缀和 0 出现 1 次,处理从下标 0 开始的子数组
	for _, v := range nums {
		prefix += v
		// 如果存在前缀和 = prefix-k,则中间这段和为 k
		if c, ok := m[prefix-k]; ok {
			count += c
		}
		m[prefix]++ // 把当前前缀和计入哈希表
	}
	return count
}

复杂度:时间 O(n),空间 O(n)


二、二分查找

直觉:二分就像你翻一本很厚的词典找词——每次都翻到中间,判断目标在左半还是右半,然后丢掉另一半。关键在于「边界怎么收」和「循环条件用 <= 还是 <」。

graph TD
  S["left, right"] --> M["mid = left + (right-left)/2"]
  M -->|"nums[mid] == target"| F["标准:直接返回"]
  M -->|"nums[mid] < target"| R1["left = mid+1 往右"]
  M -->|"nums[mid] > target"| R2["right = mid-1 往左"]

标准写法(查找任意等于 target 的位置)

// binarySearch 找到返回下标,找不到返回 -1
func binarySearch(nums []int, target int) int {
	left, right := 0, len(nums)-1
	for left <= right { // 闭区间 [left, right],当 left>right 时区间为空
		mid := left + (right-left)/2 // 这样写防止 (left+right) 溢出
		if nums[mid] == target {
			return mid
		} else if nums[mid] < target {
			left = mid + 1
		} else {
			right = mid - 1
		}
	}
	return -1
}

查找左边界(第一个等于 target 的位置)

// leftBound 返回 target 第一次出现的位置,不存在返回 -1
func leftBound(nums []int, target int) int {
	left, right := 0, len(nums)-1
	for left <= right {
		mid := left + (right-left)/2
		if nums[mid] == target {
			right = mid - 1 // 找到后继续向左压缩,贪心地找更左的
		} else if nums[mid] < target {
			left = mid + 1
		} else {
			right = mid - 1
		}
	}
	if left < len(nums) && nums[left] == target {
		return left
	}
	return -1
}

查找右边界(最后一个等于 target 的位置)

// rightBound 返回 target 最后一次出现的位置,不存在返回 -1
func rightBound(nums []int, target int) int {
	left, right := 0, len(nums)-1
	for left <= right {
		mid := left + (right-left)/2
		if nums[mid] == target {
			left = mid + 1 // 找到后继续向右压缩
		} else if nums[mid] < target {
			left = mid + 1
		} else {
			right = mid - 1
		}
	}
	if right >= 0 && nums[right] == target {
		return right
	}
	return -1
}

应用小结:用 <= + 收缩时 ±1 是最不容易出 bug 的模板;求左右边界只是「命中后往哪边再挤一步」的区别。x 的平方根搜索旋转排序数组在排序数组中找第一个 >= target 的位置 都是同一套模板。


三、N 个有序链表合并

直觉:K 个指针各自指着一条链表的当前最小候选,谁最小就取谁。如果每次都扫一遍 K 个指针(O(K)),总复杂度 O(KN) 偏慢。更聪明的做法是把 K 个「当前候选」放进最小堆,每次 O(logK) 取出最小值,再把它的下一个节点补进堆。

graph TD
  L1["链表1: 1→4→5"] --> H["最小堆(顶部最小)"]
  L2["链表2: 1→3→4"] --> H
  L3["链表3: 2→6"] --> H
  H --> O["结果: 1→1→2→3→4→4→5→6"]
package main

import "container/heap"

type ListNode struct {
	Val  int
	Next *ListNode
}

// 以下是 container/heap 接口实现,让 []*ListNode 变成最小堆
type MinHeap []*ListNode

func (h MinHeap) Len() int            { return len(h) }
func (h MinHeap) Less(i, j int) bool  { return h[i].Val < h[j].Val } // 按 Val 升序
func (h MinHeap) Swap(i, j int)       { h[i], h[j] = h[j], h[i] }
func (h *MinHeap) Push(x interface{}) { *h = append(*h, x.(*ListNode)) }
func (h *MinHeap) Pop() interface{} {
	old := *h
	n := len(old)
	x := old[n-1]
	*h = old[:n-1]
	return x
}

// mergeKLists 用最小堆合并 K 个有序链表
func mergeKLists(lists []*ListNode) *ListNode {
	h := &MinHeap{}
	heap.Init(h)
	// 先把每条链表的头节点入堆
	for _, l := range lists {
		if l != nil {
			heap.Push(h, l)
		}
	}
	dummy := &ListNode{} // 哨兵节点,省去处理头指针为空的判断
	cur := dummy
	for h.Len() > 0 {
		node := heap.Pop(h).(*ListNode) // 弹出当前最小
		cur.Next = node
		cur = cur.Next
		if node.Next != nil {
			heap.Push(h, node.Next) // 把该链的下一个候选补进堆
		}
	}
	return dummy.Next
}

分治写法(把 K 个链表两两合并,复用合并两个有序链表):

// mergeKListsDivide 分治合并:不断折半,直到只剩一个链表
func mergeKListsDivide(lists []*ListNode) *ListNode {
	if len(lists) == 0 {
		return nil
	}
	return divide(lists, 0, len(lists)-1)
}

func divide(lists []*ListNode, l, r int) *ListNode {
	if l == r {
		return lists[l] // 只有一个链表,直接返回
	}
	mid := l + (r-l)/2
	left := divide(lists, l, mid)
	right := divide(lists, mid+1, r)
	return mergeTwo(left, right) // 合并两个有序链表
}

func mergeTwo(a, b *ListNode) *ListNode {
	dummy := &ListNode{}
	cur := dummy
	for a != nil && b != nil {
		if a.Val < b.Val {
			cur.Next = a
			a = a.Next
		} else {
			cur.Next = b
			b = b.Next
		}
		cur = cur.Next
	}
	if a != nil {
		cur.Next = a
	} else {
		cur.Next = b
	}
	return dummy.Next
}

复杂度:最小堆 O(N logK),分治 O(N logK)(N 为总节点数)。


四、链表排序与去重

1. 链表排序(归并排序)

直觉:数组好排序是因为能随机下标访问、方便取中点;链表只能顺序走,所以用「快慢指针」找中点把链断开,递归地排左右两半,再合并——这就是链表的归并排序,稳定且 O(n log n)

graph TD
  A["4→2→1→3"] --> B["快慢指针找中点,断开"]
  B --> C["4→2 | 1→3"]
  C --> D["各自排序后合并"]
  D --> E["1→2→3→4"]
// sortList 自顶向下归并排序链表
func sortList(head *ListNode) *ListNode {
	if head == nil || head.Next == nil {
		return head // 0 或 1 个节点天然有序
	}
	slow, fast := head, head
	var prev *ListNode
	for fast != nil && fast.Next != nil {
		prev = slow
		slow = slow.Next
		fast = fast.Next.Next
	}
	prev.Next = nil // 在中点前断开,分成两条独立链表

	left := sortList(head)
	right := sortList(slow)
	return mergeTwo(left, right) // mergeTwo 见上一节
}

2. 有序链表去重

// deleteDuplicates 删除有序链表中重复的元素(保留一个)
func deleteDuplicates(head *ListNode) *ListNode {
	cur := head
	for cur != nil && cur.Next != nil {
		if cur.Val == cur.Next.Val {
			cur.Next = cur.Next.Next // 跳过重复节点
		} else {
			cur = cur.Next
		}
	}
	return head
}

3. 有序数组去重(原地,双指针)

直觉:慢指针 i 始终指向「已整理区间的最后一个不重复元素」,快指针 j 负责向前探路,遇到不同的就挪到 i 后面。

// removeDuplicates 原地删除排序数组中的重复项,返回新长度
func removeDuplicates(nums []int) int {
	if len(nums) == 0 {
		return 0
	}
	i := 0
	for j := 1; j < len(nums); j++ {
		if nums[j] != nums[i] {
			i++
			nums[i] = nums[j] // 把不同值搬到前面
		}
	}
	return i + 1 // 有效长度为 i+1
}

复杂度:排序 O(n log n);去重均为 O(n),数组去重额外空间 O(1)


五、倒数第 K 个节点 & 升序链表转平衡 BST

1. 倒数第 K 个节点

直觉:经典「快慢指针」——让快指针先走 K 步,然后快慢一起走,快指针到尾时,慢指针恰好停在倒数第 K 个。

// getKthFromEnd 返回倒数第 k 个节点(1-based)
func getKthFromEnd(head *ListNode, k int) *ListNode {
	fast, slow := head, head
	for i := 0; i < k; i++ {
		if fast == nil {
			return nil // k 超过链表长度
		}
		fast = fast.Next
	}
	for fast != nil {
		fast = fast.Next
		slow = slow.Next
	}
	return slow
}

2. 升序链表转平衡二叉搜索树

直觉:平衡 BST 要求左右子树节点数尽量相等,所以每次都要取「正中间」元素做根。链表没有随机访问,于是用快慢指针找中点当根,左半部分递归建左子树,右半部分递归建右子树。

type TreeNode struct {
	Val   int
	Left  *TreeNode
	Right *TreeNode
}

// sortedListToBST 把升序链表转为高度平衡的 BST
func sortedListToBST(head *ListNode) *TreeNode {
	if head == nil {
		return nil
	}
	slow, fast := head, head
	var prev *ListNode
	for fast != nil && fast.Next != nil {
		prev = slow
		slow = slow.Next
		fast = fast.Next.Next
	}
	root := &TreeNode{Val: slow.Val} // 中点做根
	if prev != nil {
		prev.Next = nil          // 断开左半部分
		root.Left = sortedListToBST(head)
	}
	root.Right = sortedListToBST(slow.Next) // 右半部分
	return root
}

复杂度:找倒数第 K 个 O(n);建 BST O(n log n)(每次找中点 O(n),递归深度 log n)。


六、二叉树:右视图与之字形遍历

直觉:这俩都是「层序遍历(BFS)」的变体。右视图 = 每一层最后一个被访问到的节点;之字形 = 偶数层从左往右、奇数层从右往左(或反过来),本质是在收集每层时决定是否反转顺序。

graph TD
  R["1"] --> L2["2"]
  R --> R2["3"]
  L2 --> L3["4"]
  L2 --> R3["5"]
  R2 --> R4["6"]

右视图

// rightSideView 返回从右侧能看到的节点值(每层最右一个)
func rightSideView(root *TreeNode) []int {
	var res []int
	if root == nil {
		return res
	}
	queue := []*TreeNode{root}
	for len(queue) > 0 {
		size := len(queue)
		for i := 0; i < size; i++ {
			node := queue[0]
			queue = queue[1:]
			if i == size-1 {
				res = append(res, node.Val) // 本层最后一个入结果
			}
			if node.Left != nil {
				queue = append(queue, node.Left)
			}
			if node.Right != nil {
				queue = append(queue, node.Right)
			}
		}
	}
	return res
}

之字形层序遍历

// zigzagLevelOrder 之字形(Z 形)层序遍历
func zigzagLevelOrder(root *TreeNode) [][]int {
	var res [][]int
	if root == nil {
		return res
	}
	queue := []*TreeNode{root}
	level := 0
	for len(queue) > 0 {
		size := len(queue)
		row := make([]int, size)
		for i := 0; i < size; i++ {
			node := queue[0]
			queue = queue[1:]
			idx := i
			if level%2 == 1 {
				idx = size - 1 - i // 奇数层反向填入
			}
			row[idx] = node.Val
			if node.Left != nil {
				queue = append(queue, node.Left)
			}
			if node.Right != nil {
				queue = append(queue, node.Right)
			}
		}
		res = append(res, row)
		level++
	}
	return res
}

复杂度:两者均为 O(n),每个节点进出队一次。


七、根据数组(含 nil 节点)重建二叉树与层次遍历

直觉:题目常给一个「层序 + 用 nil 表示空」的数组,例如 [1,2,nil,3] 表示根 1、左 2、右空、2 的左孩子 3。这种表示法天然就是层序,所以用一个队列按层把节点挂上去即可重建;反过来做层序遍历,也能把树还原成同样的数组。

graph TD
  A["数组 [1,2,nil,3]"] --> B["层序建树"]
  B --> C["1 的左=2, 右=nil; 2 的左=3"]
  C --> D["层序遍历还原回 [1,2,nil,3]"]
// buildFromLevelOrder 由层序数组(nil 表示空节点)重建二叉树
func buildFromLevelOrder(vals []*int) *TreeNode {
	if len(vals) == 0 || vals[0] == nil {
		return nil
	}
	root := &TreeNode{Val: *vals[0]}
	queue := []*TreeNode{root}
	i := 1
	for len(queue) > 0 && i < len(vals) {
		node := queue[0]
		queue = queue[1:]
		// 挂左孩子
		if i < len(vals) && vals[i] != nil {
			node.Left = &TreeNode{Val: *vals[i]}
			queue = append(queue, node.Left)
		}
		i++
		// 挂右孩子
		if i < len(vals) && vals[i] != nil {
			node.Right = &TreeNode{Val: *vals[i]}
			queue = append(queue, node.Right)
		}
		i++
	}
	return root
}

// levelOrder 把二叉树按层序输出为含 nil 的数组(便于对比还原)
func levelOrder(root *TreeNode) []*int {
	var res []*int
	if root == nil {
		return res
	}
	queue := []*TreeNode{root}
	for len(queue) > 0 {
		node := queue[0]
		queue = queue[1:]
		if node == nil {
			res = append(res, nil)
			continue
		}
		v := node.Val
		res = append(res, &v)
		queue = append(queue, node.Left, node.Right) // 即使为空也入队,保留结构
	}
	// 去掉末尾无意义的 nil(最底层右侧的空指针)
	for len(res) > 0 && res[len(res)-1] == nil {
		res = res[:len(res)-1]
	}
	return res
}

复杂度:建树与遍历都是 O(n)


八、前缀树 Trie

直觉:Trie 像一本按字母分层排布的通讯录——根节点是空,第一层是所有单词的首字母,往下依次挂后续字母。要查「是否存在以某前缀开头的单词」,只需顺着字母一层层往下走;走不通就说明没有。它把「前缀匹配」从 O(M*N) 降到 O(键长)

graph TD
  root["● 根"] --> c["c"]
  c --> a["a"]
  a --> t["t 结束"]
  root --> d["d"]
  d --> o["o"]
  o --> g["g 结束"]
type TrieNode struct {
	children map[rune]*TrieNode
	isEnd    bool // 标记到此是否为一个完整单词
}

type Trie struct {
	root *TrieNode
}

func NewTrie() *Trie {
	return &Trie{root: &TrieNode{children: make(map[rune]*TrieNode)}}
}

// Insert 插入一个单词
func (t *Trie) Insert(word string) {
	node := t.root
	for _, ch := range word {
		if _, ok := node.children[ch]; !ok {
			node.children[ch] = &TrieNode{children: make(map[rune]*TrieNode)}
		}
		node = node.children[ch] // 沿路径下走,没有就新建
	}
	node.isEnd = true // 单词结尾打标记
}

// Search 判断完整单词是否存在
func (t *Trie) Search(word string) bool {
	node := t.searchPrefix(word)
	return node != nil && node.isEnd
}

// StartsWith 判断是否存在以 prefix 为前缀的单词
func (t *Trie) StartsWith(prefix string) bool {
	return t.searchPrefix(prefix) != nil
}

// searchPrefix 沿前缀走,返回终点节点(走不通返回 nil)
func (t *Trie) searchPrefix(prefix string) *TrieNode {
	node := t.root
	for _, ch := range prefix {
		if _, ok := node.children[ch]; !ok {
			return nil
		}
		node = node.children[ch]
	}
	return node
}

复杂度:插入、查找、前缀匹配均为 O(L)(L 为字符串长度),与单词总数无关——这是 Trie 的核心优势。


九、并发手撕:交替打印奇偶数

直觉:两个 goroutine 要「你一下我一下」地打印,关键是谁先说话、说完通知对方。用两个 channel 互相发信号:奇数协程打印完通知偶数协程,偶数协程打印完再通知奇数协程,形成乒乓。注意把 channel 设为带缓冲(容量 1),避免最后一步「没人接收」的死锁。

sequenceDiagram
  participant Main
  participant Odd
  participant Even
  Main->>Odd: 发初始信号
  Odd->>Even: 打印奇数后发信号
  Even->>Odd: 打印偶数后发信号
  Note over Odd,Even: 循环直到 n
package main

import (
	"fmt"
	"sync"
)

// printOddEven 交替打印 1..n 的奇偶数(先奇后偶)
func printOddEven(n int) {
	odd := make(chan struct{}, 1)  // 缓冲 1:避免最后一步发送无人接收而死锁
	even := make(chan struct{}, 1)
	var wg sync.WaitGroup
	wg.Add(2)

	go func() {
		defer wg.Done()
		for i := 1; i <= n; i += 2 {
			<-odd          // 等奇数信号
			fmt.Println(i) // 打印奇数
			even <- struct{}{}
		}
	}()

	go func() {
		defer wg.Done()
		for i := 2; i <= n; i += 2 {
			<-even         // 等偶数信号
			fmt.Println(i) // 打印偶数
			odd <- struct{}{}
		}
	}()

	odd <- struct{}{} // 主协程点火:先让奇数打印
	wg.Wait()
}

为什么用缓冲 channel:若用无缓冲,最后偶数协程打印完 n 后向 odd 发信号,而奇数协程此时循环已结束、不再接收,发送会永久阻塞 → 死锁。缓冲 1 让这次发送直接落入缓冲区、立即返回,协程安全退出。


十、实现 HashMap 与有序哈希(LinkedHashMap)

1. 基础 HashMap:set/get + 扩容

直觉:HashMap 就是「数组 + 链表」。对 key 算哈希拿到桶下标,冲突的元素用链表串起来。当元素太多(负载因子过高)时,桶太挤、链表变长,查找退化成 O(n),于是翻倍容量、把所有元素重新哈希(rehash)

type entry struct {
	key   string
	value int
	next  *entry // 拉链法解决冲突
}

type MyHashMap struct {
	buckets  []*entry
	size     int
	capacity int
}

func NewHashMap(capacity int) *MyHashMap {
	return &MyHashMap{buckets: make([]*entry, capacity), capacity: capacity}
}

// hash 简单字符串哈希,再对容量取模定位桶
func (m *MyHashMap) hash(key string) int {
	h := 0
	for _, c := range key {
		h = h*31 + int(c)
	}
	if h < 0 {
		h = -h
	}
	return h % m.capacity
}

// Set 插入或更新
func (m *MyHashMap) Set(key string, value int) {
	if m.size >= m.capacity*7/10 { // 负载因子 0.7 触发扩容
		m.resize()
	}
	idx := m.hash(key)
	for e := m.buckets[idx]; e != nil; e = e.next {
		if e.key == key {
			e.value = value // 已有 key 直接更新
			return
		}
	}
	// 头插法加入新节点
	m.buckets[idx] = &entry{key: key, value: value, next: m.buckets[idx]}
	m.size++
}

// Get 取值
func (m *MyHashMap) Get(key string) (int, bool) {
	idx := m.hash(key)
	for e := m.buckets[idx]; e != nil; e = e.next {
		if e.key == key {
			return e.value, true
		}
	}
	return 0, false
}

// resize 翻倍容量并重新哈希
func (m *MyHashMap) resize() {
	old := m.buckets
	m.capacity *= 2
	m.buckets = make([]*entry, m.capacity)
	m.size = 0
	for _, head := range old {
		for e := head; e != nil; e = e.next {
			m.Set(e.key, e.value) // 复用 Set 完成重新哈希
		}
	}
}

2. 有序哈希表(LinkedHashMap 思路)

直觉:普通 HashMap 遍历顺序不确定。若想「按插入顺序」遍历,就额外维护一条双向链表记录先后关系,再用 map 做 O(1) 查找,链表负责保序。

type lnode struct {
	key   string
	value int
	prev  *lnode
	next  *lnode
}

type LinkedHashMap struct {
	m    map[string]*lnode
	head *lnode // 哨兵头(最旧)
	tail *lnode // 哨兵尾(最新)
}

func NewLinkedHashMap() *LinkedHashMap {
	head := &lnode{}
	tail := &lnode{}
	head.next = tail
	tail.prev = head
	return &LinkedHashMap{m: make(map[string]*lnode), head: head, tail: tail}
}

func (l *LinkedHashMap) addToTail(n *lnode) {
	n.prev = l.tail.prev
	n.next = l.tail
	l.tail.prev.next = n
	l.tail.prev = n
}

func (l *LinkedHashMap) remove(n *lnode) {
	n.prev.next = n.next
	n.next.prev = n.prev
}

func (l *LinkedHashMap) Put(key string, value int) {
	if n, ok := l.m[key]; ok {
		n.value = value // 已存在则更新,保持原位
		return
	}
	n := &lnode{key: key, value: value}
	l.m[key] = n
	l.addToTail(n) // 新 key 挂到链表尾部(最新)
}

func (l *LinkedHashMap) Get(key string) (int, bool) {
	if n, ok := l.m[key]; ok {
		return n.value, true
	}
	return 0, false
}

// Keys 按插入顺序返回所有 key
func (l *LinkedHashMap) Keys() []string {
	var res []string
	for n := l.head.next; n != l.tail; n = n.next {
		res = append(res, n.key)
	}
	return res
}

复杂度:HashMap 平均 O(1)(最坏 O(n),靠扩容维持在常数附近);LinkedHashMap 同样 O(1) 且保序。


十一、生成无重复随机数组(Fisher-Yates + 拒绝抽样)

直觉:要「洗牌」,最公平的办法是 Fisher-Yates——从后往前,每次随机挑一个前面的元素与当前位置交换,保证每个排列等概率。如果你要的只是「从大池子里抽少量不重复样本」,则可用拒绝抽样:随机生成一个数,重复就丢掉重抽,直到凑够。

graph LR
  A["初始 1..n"] --> B["从后往前随机交换"]
  B --> C["得到均匀打乱的排列"]
  C --> D["或:拒绝抽样抽 k 个不重复"]
package main

import (
	"math/rand"
	"time"
)

// generateUniqueRandom 用 Fisher-Yates 生成 1..n 的无重复随机排列
func generateUniqueRandom(n int) []int {
	rng := rand.New(rand.NewSource(time.Now().UnixNano()))
	arr := make([]int, n)
	for i := 0; i < n; i++ {
		arr[i] = i + 1
	}
	// 从最后一个开始,与 [0, i] 中随机一个交换
	for i := n - 1; i > 0; i-- {
		j := rng.Intn(i + 1)
		arr[i], arr[j] = arr[j], arr[i]
	}
	return arr
}

// rejectionSample 在 [0, max) 中随机抽 k 个不重复整数(拒绝抽样)
func rejectionSample(max, k int) []int {
	rng := rand.New(rand.NewSource(time.Now().UnixNano()))
	seen := make(map[int]bool)
	res := []int{}
	for len(res) < k {
		x := rng.Intn(max)
		if !seen[x] { // 没出现过才收下,重复则丢弃重试
			seen[x] = true
			res = append(res, x)
		}
		// 若 k 接近 max,拒绝率会升高,此时应改用 Fisher-Yates 再取前 k 个
	}
	return res
}

复杂度:Fisher-Yates O(n);拒绝抽样期望 O(k)(当 k << max 时高效,否则退化为接近 O(max))。


十二、自测题与动手练习

  1. 完全背包变形:把「最少硬币数」改成「组合数」——给定硬币和 amount,求凑出 amount方案总数(注意内外循环顺序会影响是组合数还是排列数,为什么?)。
  2. 二分边界:给定一个按非递减排序、但被旋转过的数组(如 [4,5,6,7,0,1,2]),用二分在 O(log n) 内找最小值,并说明与标准二分的边界差异。
  3. 链表:给定两个用链表表示的非负整数(如 2->4->35->6->4,低位在前),写出相加函数,返回 7->0->8。要求先讲清进位处理,再写代码。
  4. 二叉树:实现「二叉树的最大路径和」(路径可拐弯,不一定过根),并分析为什么需要在递归里返回「单侧最大贡献」而非整条路径和。
  5. Trie 进阶:在本文 Trie 基础上增加 Delete(word) 方法,要求删除后正确清理不再被任何单词使用的节点,并避免误删共享前缀。
  6. 并发:把「交替打印奇偶数」改成「三个协程按 1、2、3 循环打印到 n」,用 channel 传递「轮到谁」的信号,注意退出条件防死锁。
  7. 设计:用本文 HashMap 思路实现一个支持 LRU(最近最少使用)淘汰的缓存:Get/Put 均为 O(1),容量满时淘汰最久未使用项。

十三、本章小结

  • 动态规划本质是「状态 + 选择」:完全背包内循环从小到大(可重复选),0-1/子集和从大到小(不重复选);子数组和为 K 用前缀和 + 哈希把 O(n²) 打到 O(n)
  • 二分查找一套模板走天下,左右边界只是「命中后往哪边再挤一步」;务必用 left + (right-left)/2 防溢出。
  • 链表题大多靠双指针(快慢、前后)、递归与哨兵节点;归并排序和 K 路合并是高频组合。
  • 二叉树层序遍历(BFS)是右视图、之字形、层序建树的共同底座;重建优先认准「层序数组 + nil」或「前/中/后序」的组合。
  • Trie 把前缀匹配降为 O(键长)HashMap 靠数组 + 拉链 + 扩容维持在 O(1)LinkedHashMap 用双向链表保序。
  • 并发手撕核心是「用 channel 当信号量乒乓传递」,别忘了缓冲/退出条件防止死锁。
  • 随机无重复优先 Fisher-Yates;只在 k << 池子 时用拒绝抽样。

把这些套路各自手写三遍,面试现场就能做到「看到题先说直觉,再画状态/结构,最后边写边讲 why」。

复习提示:
  • 动态规划核心:找状态 + 找转移方程;背包问题记住"完全背包正序、0-1背包倒序"。
  • 二分查找模板:标准写法找任意位置,左边界/右边界只改 left+1right-1 的方向。
  • Trie 删节点:删除后要检查子节点是否为空,避免误删共享前缀——这是面试官常追问的细节。
  • 并发交替打印:channel 传信号是核心思路,退出条件防死锁是必考点。
  • 上一篇是基础手撕(链表、排序、TopK),这一篇是进阶(DP、Trie、HashMap),两篇结合使用效果最佳。
About Me

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

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

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

目标

学AI,加油!加油!