引言 (Introduction)
在前两篇文章中,我们从贪心选择性质的理论基础出发,掌握了基础贪心问题与区间调度的建模套路。本篇是本系列的收官之作,将目光投向三个更具深度的领域:Huffman 编码(信息论中的最优压缩)、最短路径中的贪心思想(Dijkstra 算法),以及需要「构造型贪心」的 LeetCode 进阶题。
本文将覆盖三道需要精巧贪心策略的题目(任务调度器 621、重构字符串 767、加油站 134),并在结尾为全系列做出完整的复盘对照表。
1. Huffman 编码:最优前缀码的贪心构造
1.1 问题背景
给定 $n$ 个字符及其出现频率 $f_1, f_2, \ldots, f_n$,要求为每个字符分配一个二进制编码,使得:
- 前缀性:没有任何一个字符的编码是另一个字符编码的前缀(保证解码无歧义)。
- 最优性:编码后的总长度 $\sum_{i=1}^n f_i \cdot \text{len}(code_i)$ 最小。
前缀性使得我们可以将编码表示为二叉树:每个叶子对应一个字符,从根到叶子的路径即为编码(向左走为 0,向右走为 1)。
[*]
/ \
[*] [c: 1]
/ \
[a: 0] [b: 10]
编码:a = 00, b = 01, c = 1
总长度 = f_a·2 + f_b·2 + f_c·1
1.2 贪心策略与算法
核心贪心选择:每次从频率集合中取出两个最小频率的节点,将它们合并为一个新的内部节点(频率为两者之和),放回集合。重复此过程直到只剩一个节点——即 Huffman 树。
证明要点(交换论证):若最优树中不包含最小频率节点作为最深层的兄弟叶子,可以通过交换将它们放入最深层而总代价不增。
1.3 完整代码
import heapq
from typing import List, Optional
class HuffmanNode:
def __init__(self, freq: int, char: Optional[str] = None):
self.freq = freq
self.char = char
self.left: Optional['HuffmanNode'] = None
self.right: Optional['HuffmanNode'] = None
def __lt__(self, other: 'HuffmanNode') -> bool:
return self.freq < other.freq
def build_huffman_tree(freqs: dict) -> Optional['HuffmanNode']:
"""根据字符频率字典构造 Huffman 树"""
heap: List[HuffmanNode] = []
for char, freq in freqs.items():
heapq.heappush(heap, HuffmanNode(freq, char))
while len(heap) > 1:
left = heapq.heappop(heap)
right = heapq.heappop(heap)
parent = HuffmanNode(left.freq + right.freq)
parent.left = left
parent.right = right
heapq.heappush(heap, parent)
return heap[0] if heap else None
def generate_codes(root: Optional['HuffmanNode'], prefix: str = '',
codebook: Optional[dict] = None) -> dict:
"""DFS 遍历 Huffman 树,生成字符→编码的映射"""
if codebook is None:
codebook = {}
if root is None:
return codebook
if root.char is not None: # 叶子节点
codebook[root.char] = prefix
else:
generate_codes(root.left, prefix + '0', codebook)
generate_codes(root.right, prefix + '1', codebook)
return codebook
# 示例
freqs = {'a': 45, 'b': 13, 'c': 12, 'd': 16, 'e': 9, 'f': 5}
root = build_huffman_tree(freqs)
codes = generate_codes(root)
# {'a': '0', 'c': '100', 'b': '101', 'f': '1100', 'e': '1101', 'd': '111'}
复杂度:使用最小堆,每次 heappush 和 heappop 为 $O(\log n)$,总共执行 $2n-2$ 次操作,总复杂度 $O(n \log n)$。
1.4 为什么 Huffman 编码需要贪心?
如果暴力枚举所有可能的编码树,数量是 Catalan 数级别的(指数级)。贪心策略将复杂度降为 $O(n \log n)$,并且通过交换论证严格保证了最优性——这是贪心算法在经典问题中最大的成功案例之一。
2. Dijkstra 最短路径中的贪心思想
Dijkstra 算法本质上是贪心:每一步选择当前距离起点最近的未处理节点,将其标记为"已确定",并用它来松弛其邻居。
import heapq
from typing import List, Dict, Tuple
def dijkstra(graph: Dict[int, List[Tuple[int, int]]], start: int) -> Dict[int, int]:
"""
graph: 邻接表 {node: [(neighbor, weight), ...]}
返回: {node: shortest_distance_from_start}
"""
dist: Dict[int, int] = {start: 0}
heap: List[Tuple[int, int]] = [(0, start)] # (distance, node)
while heap:
d, u = heapq.heappop(heap)
if d > dist.get(u, float('inf')):
continue # 惰性删除:过时的条目
for v, w in graph.get(u, []):
nd = d + w
if nd < dist.get(v, float('inf')):
dist[v] = nd
heapq.heappush(heap, (nd, v))
return dist
贪心选择:每次从优先队列中弹出距离最小的节点,这一步是贪心的——“当前看起来最近的节点,其最短距离已经可以确定”。
为什么此处贪心正确? 因为所有边权非负,通过其他未处理节点绕路只会增加距离(三角不等式保证)。这个证明的前提是边权非负——一旦存在负权边,贪心假设破灭(此时需要 Bellman-Ford)。
Dijkstra 提醒我们:贪心的正确性往往依赖于问题结构的特定性质(此处为边权非负),识别这些结构性前提是应用贪心的关键。
3. LeetCode 621:任务调度器
题目:给定任务列表 tasks(用大写字母表示)和冷却时间 n。相同任务之间必须间隔至少 $n$ 个时间单位。求完成所有任务的最短时间。
3.1 贪心建模
想象任务按频率降序排列。最高频的任务决定了时间轴的最小长度——它们之间插入 $n$ 个空隙,构成 $(maxFreq - 1) \times (n + 1)$ 个槽位。最后一行填入所有频率等于 $maxFreq$ 的任务。
最终结果取 $\max(len(tasks), (maxFreq - 1) \cdot (n + 1) + maxCount)$。
tasks = ["A","A","A","B","B","B"], n = 2
时间轴:A _ _ A _ _ A
↑ ↑ ↑
将 B 填入空位:A B _ A B _ A B
最少时间 = max(6, (3-1)*(2+1) + 2) = max(6, 8) = 6
(实际任务总数 6 < 8,但任务就这么多,所以答案是 6)
3.2 完整代码
from typing import List
from collections import Counter
class Solution:
def leastInterval(self, tasks: List[str], n: int) -> int:
freq = list(Counter(tasks).values())
max_freq = max(freq)
max_count = freq.count(max_freq) # 有多少个任务达到了最高频率
frame = (max_freq - 1) * (n + 1) + max_count
return max(len(tasks), frame)
复杂度:时间 $O(m)$($m$ 为 tasks 长度),空间 $O(1)$(字母数量固定为 26)。
3.3 为什么这是对的?
最高频任务之间的强制空闲时间是不可压缩的下界。其余任务可以填入这些空隙。如果没有填满,说明强制空闲决定了总时长;如果填满了(甚至溢出),说明所有空隙都被利用,总时长由任务总数决定。贪心公式恰好捕捉了这两种情况的最大值。
4. LeetCode 767:重构字符串
题目:给定字符串 s,重构为任意相邻字符不相同的排列。若无法做到则返回空串。
4.1 贪心策略
核心贪心选择:每一轮选择剩余数量最多且不等于上一个放置字符的字符。
实现技巧:用最大堆维护剩余字符频率,每次弹出频率最高的字符。如果它等于上一个放置的字符,则退而求其次弹第二个;放置后若频率仍大于 0 则放回堆中。
4.2 完整代码
import heapq
from typing import List
from collections import Counter
class Solution:
def reorganizeString(self, s: str) -> str:
freq = Counter(s)
# 最大堆(存储负数来实现)
heap: List[tuple] = [(-cnt, ch) for ch, cnt in freq.items()]
heapq.heapify(heap)
result: List[str] = []
prev_cnt, prev_ch = 0, '' # 上一个放置的字符
while heap:
cnt, ch = heapq.heappop(heap)
result.append(ch)
# 上一个字符可以放回堆中(如果还有剩余)
if prev_cnt < 0:
heapq.heappush(heap, (prev_cnt, prev_ch))
prev_cnt, prev_ch = cnt + 1, ch # 减少 1 个(cnt 是负数)
return ''.join(result) if len(result) == len(s) else ""
复杂度:时间 $O(n \log 26) = O(n)$(堆大小至多 26),空间 $O(1)$(不计结果)。
4.3 可行性条件
一个简单的必要条件:最高频字符的数量不能超过 $\lceil n/2 \rceil$(否则无论如何排列都会有相邻)。贪心策略自动处理了这一点——如果最高频字符过多,while 循环会因堆空(且 prev_cnt < 0 无法放回)而提前结束。
5. LeetCode 134:加油站
题目:环形路线上有 $n$ 个加油站。第 $i$ 站可加 gas[i] 升油,从 $i$ 到 $i+1$ 需要 cost[i] 升。从哪个站出发可以绕一圈回到起点?保证答案唯一或不存在。
5.1 贪心策略
两个关键洞察:
- 全局可行性判断:若 $\sum gas \ge \sum cost$,则一定存在起点(油量不会净亏损)。
- 贪心找起点:从 $0$ 开始累积油量,一旦油量小于 $0$,说明当前起点不可行——将起点推到下一站,重置油量为 $0$。
直觉:如果从 $A$ 出发无法到达 $B$,那么 $A$ 和 $B$ 之间的任何站也不可能作为起点(因为在 $A$ 加满出发尚且不够,中间出发只会油更少)。
5.2 完整代码
from typing import List
class Solution:
def canCompleteCircuit(self, gas: List[int], cost: List[int]) -> int:
if sum(gas) < sum(cost):
return -1
start = 0
tank = 0
for i in range(len(gas)):
tank += gas[i] - cost[i]
if tank < 0:
start = i + 1 # 从下一站重试
tank = 0
return start
复杂度:时间 $O(n)$,空间 $O(1)$。
5.3 证明:为什么跳过的起点都不可行?
设从 $S$ 出发,在第 $K$ 站油量首次为负。对于任意 $S < j \le K$:
- 从 $S$ 到 $j$ 的累积净油量 $\ge 0$(否则油量更早就为负了)。
- 从 $j$ 到 $K$ 的累积净油量 = 从 $S$ 到 $K$ 的累积净油量 − 从 $S$ 到 $j$ 的累积净油量 $\le$ 从 $S$ 到 $K$ 的累积净油量 $< 0$。
因此从 $j$ 出发到 $K$ 的油量也会变负。所有被跳过的起点都不可能是答案。证毕。
6. 全系列复盘对照表
本系列共三篇文章,覆盖了九道 LeetCode 题目和三个经典算法,以下是完整的知识地图:
| 篇目 | 主题 | 核心概念 | LeetCode 题目 | 贪心策略关键词 | 时间复杂度 |
|---|---|---|---|---|---|
| 第一篇 | 核心概念与入门 | 贪心选择性质、最优子结构、贪心 vs DP、归纳法证明 | 455 分发饼干860 柠檬水找零122 买卖股票 II | 最小代价优先保护灵活资源累加所有正向变化 | $O(n \log n)$ / $O(n)$ |
| 第二篇 | 区间调度 | 交换论证、按结束时间排序、区间选择与点覆盖的对偶 | 435 无重叠区间452 引爆气球56 合并区间 | 早结束留资源箭尽可能靠右射按开始时间在线合并 | $O(n \log n)$ |
| 第三篇 | 进阶贪心 | Huffman 编码、Dijkstra 中的贪心、构造型贪心、频率堆技巧 | 621 任务调度器767 重构字符串134 加油站 | 填槽公式最高频优先累积不可行则跳 | $O(n)$ |
全系列方法论总结
- 判断贪心可行性:先看有没有最优子结构,再检查贪心选择性质。能用 DP 的一定能用 DP 验证——用 DP 写一个暴力版本,在小数据上对比贪心结果。
- 证明贪心正确性:归纳法(逐步构造)和交换论证法(从最优解转换)是两把利器。对于面试或竞赛,至少要在脑海中跑一遍反例检查。
- 区间问题铁律:选区间用结束时间,合并区间用开始时间。
- 堆 + 贪心:当贪心策略需要动态选择「当前最大/最小」时,优先队列(堆)是天然的数据结构搭档(Huffman、任务调度、重构字符串)。
- 贪心的威力:在它适用的场景下,贪心可以将 $O(n^2)$ 或指数级的问题降到 $O(n \log n)$ 甚至 $O(n)$,这种差距在实际系统中是决定性的。
结论 (Conclusion)
贪心算法是算法设计中最"短视"却又最"锋利"的工具。三篇文章走下来,我们从「什么是贪心」到「怎么判断贪心」再到「如何证明贪心」,建立了对这类方法的完整认知。
贪心的本质矛盾在于:它的决策是局部的,但它的正确性必须是全局的。这要求我们在每次使用贪心时保持警惕——直觉是起点,证明是终点。当你能自信地给出一个交换论证或归纳证明时,贪心才真正成为了你的武器。
算法学习的下一站可以是动态规划——贪心与 DP 常常面对同一族问题,理解两者的边界,才是算法设计能力的真正体现。