目录
  1. 1. 一、贪心算法的核心思想与理论框架
  2. 2. 二、贪心策略的严格证明方法
  3. 3. 三、活动选择问题
  4. 4. 四、霍夫曼编码
  5. 5. 五、Dijkstra最短路径算法
  6. 6. 六、Prim与Kruskal的最小生成树
  7. 7. 七、分数背包问题
  8. 8. 八、区间调度问题的泛化与拟阵视角
  9. 9. 九、贪心与DP的对比——硬币找零问题
  10. 10. 十、面试常见追问
  11. 11. 十一、拟阵理论与贪心算法的深层联系
  12. 12. 十二、更多贪心经典问题
  13. 13. 十三、贪心算法的局限性、边界与常见陷阱
  14. 14. 十四、更多面试常见追问
  15. 15. 十五、总结:贪心算法的思维框架
【数据结构与算法体系】之贪心算法

一、贪心算法的核心思想与理论框架

贪心算法(Greedy Algorithm)在每一步选择中都采取当前状态下看起来最优的选择,期望通过一系列局部最优决策最终达到全局最优解。贪心算法的精妙之处在于——对于满足特定性质的问题,这种”短视”的策略恰好能得到全局最优解;但对于另一些问题,贪心策略可能导致任意差的解。

贪心算法适用的问题必须满足两个关键性质。贪心选择性质(Greedy-Choice Property):全局最优解可以通过一系列局部最优选择来构造。形式化地说,存在一个全局最优解,它包含贪心算法在第一步做出的选择。换句话说,第一个贪心选择不会”关闭”通往全局最优的大门。最优子结构(Optimal Substructure):做出贪心选择后,剩下的子问题与原问题具有相同的形式,且子问题的最优解可以与贪心选择组合成原问题的最优解。

贪心算法与动态规划的重要区别:DP系统地探索所有可能的决策组合(通过状态转移方程枚举所有可能的选择),保证找到全局最优;贪心算法在每个步骤仅做一次选择且永不回溯,因此只有在问题具有贪心选择性质时才保证正确。两者都需要最优子结构,但DP还要求重叠子问题。

贪心算法在计算理论上更强大的意义是——它是拟阵(Matroid)理论的算法体现。拟阵是一类具有”贪心算法可以得到最优解”性质的组合结构。如果一个问题的可行解集合构成一个拟阵,那么对任意权重函数,贪心算法(按权重降序选择不破坏独立性的元素)都能得到最大权独立集。最小生成树问题可以建模为图拟阵(Graphic Matroid)上的贪心算法,Kruskal算法正是该理论的实例化。理解拟阵理论可以让我们在更高层次上判断贪心算法的适用性,但面试和工程中我们主要使用交换论证和反例测试。

二、贪心策略的严格证明方法

验证贪心策略的正确性是贪心算法最核心的难点——很多直觉上”显然正确”的贪心策略实际上是错误的。以下是两种经典的证明方法。

交换论证(Exchange Argument):这是最通用且最有力的贪心证明技术。步骤如下:

  1. 假设存在一个最优解OPT,它可能与贪心解不同。
  2. 在不使OPT变差的前提下,将OPT逐步”改造”为贪心解。
  3. 从第一步开始:如果OPT的第一步选择与贪心算法的选择不同,将OPT的第一步替换为贪心算法的选择,并证明替换后解的质量不会下降。
  4. 递归地对后续步骤应用相同论证。
  5. 最终我们得到:贪心解G的质量 ≥ 任何最优解OPT的质量,因此G也是最优的。

交换论证的美妙在于它不需要知道OPT具体是什么,只需要证明”存在一个包含贪心选择的最优解”。

贪心领先(Greedy Stays Ahead):通过维护贪心解和最优解之间的某种度量关系,证明贪心算法在每一步之后都不”落后于”最优解。典型应用在区间调度中——证明贪心算法在第k步选择的活动的结束时间不晚于任何最优解在第k步选择的活动的结束时间。

数学归纳法:证明对于规模为n的问题,贪心选择能导出最优解。归纳基础:n=1时,最优解显然包含唯一的贪心选择。归纳步骤:假设规模为n-1时贪心正确,证明规模为n时也正确。通常与最优子结构结合使用。

贪心算法的反例构造:当怀疑贪心策略不正确时,构造反例是最快的方法。一个有用的技巧是:寻找”贪心选择看似最优,但阻碍了后续更好地组合”的场景。硬币找零问题的经典反例:面值[1, 3, 4],目标金额6。贪心”每次选最大面值”选4+1+1=3枚,但最优是3+3=2枚。

三、活动选择问题

活动选择问题(Activity Selection)是贪心算法的经典入门问题:给定N个活动,每个活动有开始时间s[i]和结束时间f[i]。同一时间只能进行一个活动,求最多能安排多少个互不重叠的活动。

贪心策略:按结束时间最早优先选择。每次选择当前未选的活动中结束时间最早且不与已选活动冲突的活动。

为什么按结束时间最早而不是开始时间最早、最短时长优先?假设按最短时长优先——考虑活动A(1, 5)和B(4, 6)和C(5, 10),最短的B会阻止选A和C(只能选1个),而最优解是选A和C(共2个)。假设按开始时间最早优先——考虑一个覆盖全天且开始最早的长活动,它会阻止选择所有其他活动。而按结束时间最早优先,结束早的活动”释放资源”也早,留给后续活动的空间最大。

交换论证证明:设贪心算法选择的活动序列为G = {g1, g2, …, gk},按结束时间排序。设存在最优解OPT = {o1, o2, …, om},也按结束时间排序。我们证明k ≥ m。

对于第一个活动:贪心选择的g1是所有活动中结束时间最早的。在OPT中,o1的结束时间f(o1) ≥ f(g1)(因为g1是最早结束的)。因此我们可以将OPT中的o1替换为g1——g1不与{o2, …, om}冲突(因为f(g1) ≤ f(o1) ≤ s(o2),其中最后一个不等号是由于o2和o1不重叠),替换后的解仍为可行且活动数不变。

以此类推,对于任意位置j,可以证明f(gj) ≤ f(oj),贪心选择的活动”结束得不晚于”最优解相应地选择的活动。因此贪心解至少安排了与最优解同样多的活动。

int activitySelection(int[] s, int[] f) {
int n = s.length;
Integer[] indices = new Integer[n];
for (int i = 0; i < n; i++) indices[i] = i;
Arrays.sort(indices, Comparator.comparingInt(i -> f[i]));

int count = 1;
int lastFinish = f[indices[0]];
for (int i = 1; i < n; i++) {
if (s[indices[i]] >= lastFinish) {
count++;
lastFinish = f[indices[i]];
}
}
return count;
}

时间复杂度O(N log N)(主要由排序决定),空间复杂度O(N)。如果活动已按结束时间排序,贪心选择可以在O(N)时间内完成。活动选择问题还有另一种变体——带权活动选择问题:每个活动有价值,求总价值最大。这个问题不再满足贪心选择性质,需要用DP解决(状态:dp[i] 表示考虑前i个活动的最大价值,转移:dp[i] = max(dp[i-1], dp[p(i)] + w[i]),其中p(i)是在活动i开始之前结束的最后一个活动)。

四、霍夫曼编码

霍夫曼编码(Huffman Coding)是一种最优前缀编码,用于数据压缩。给定每个字符出现的频率,构建一棵二叉树,使频率高的字符用较短的编码,频率低的字符用较长的编码,从而使得编码总长度最小化。

前缀码的性质:没有任何一个字符的编码是另一个字符编码的前缀。这保证了解码的唯一确定性——从左到右扫描编码串时,一旦匹配到某个字符的编码,就可以确定该字符,不存在歧义。前缀码等价于构建一棵二叉树,字符都在叶子节点上,从根到叶子的路径(左=0,右=1)就是该字符的编码。

证明霍夫曼编码的最优性

  1. 在最优前缀码树中,频率最小的两个字符一定位于最深的一层且是兄弟节点。证明(交换论证):假设存在一个最优树,但频率最小的字符x不在最深层。将x与最深层的一个字符y交换。交换后总代价的变化 = -(freq(x) × depth(y) + freq(y) × depth(x)) + (freq(x) × depth(x) + freq(y) × depth(y)) = (freq(x)-freq(y)) × (depth(x)-depth(y)) ≤ 0(因为freq(x)≤freq(y)且depth(x)≤depth(y))。所以交换后代价不增,可以将x移到最深层。同理,频率次小的字符z也可以移到最深层与x成为兄弟。
  2. 合并两个频率最小的字符(作为兄弟)等价于创建一个频率为二者之和的新”元字符”。这个合并操作满足最优子结构——如果原始问题的最优树存在,则合并后的子问题的最优树是原始最优树减去x和z作为叶子的部分。
  3. 由以上两点,贪心策略”每次合并频率最小的两个节点”是正确的。
class HuffmanNode implements Comparable<HuffmanNode> {
char ch;
int freq;
HuffmanNode left, right;

HuffmanNode(char ch, int freq) {
this.ch = ch; this.freq = freq;
}

HuffmanNode(int freq, HuffmanNode left, HuffmanNode right) {
this.freq = freq; this.left = left; this.right = right;
}

public int compareTo(HuffmanNode other) {
return this.freq - other.freq;
}

boolean isLeaf() { return left == null && right == null; }
}

HuffmanNode buildHuffmanTree(Map<Character, Integer> freqMap) {
PriorityQueue<HuffmanNode> pq = new PriorityQueue<>();
for (var entry : freqMap.entrySet()) {
pq.offer(new HuffmanNode(entry.getKey(), entry.getValue()));
}

while (pq.size() > 1) {
HuffmanNode left = pq.poll();
HuffmanNode right = pq.poll();
pq.offer(new HuffmanNode(left.freq + right.freq, left, right));
}
return pq.poll(); // 根节点
}

void generateCodes(HuffmanNode node, String code, Map<Character, String> codeMap) {
if (node == null) return;
if (node.isLeaf()) {
codeMap.put(node.ch, code);
} else {
generateCodes(node.left, code + "0", codeMap);
generateCodes(node.right, code + "1", codeMap);
}
}

霍夫曼编码的局限性:它是静态的——需要预先知道所有字符的频率。对于流式数据或频率动态变化的场景,需要自适应霍夫曼编码(Adaptive Huffman Coding),FGK算法和Vitter算法可以在处理数据的同时动态更新霍夫曼树。此外,霍夫曼编码生成的是整数位的编码,每个字符的编码长度是整数;算术编码(Arithmetic Coding)将整个消息编码为一个[0, 1)区间内的实数,可以实现小数位的编码长度,压缩率更接近信息论极限(熵),但计算更复杂。

霍夫曼编码在现实世界中被广泛使用:JPEG图像压缩(对量化后的DCT系数使用霍夫曼编码)、MP3音频压缩、PKZIP/GZIP等压缩工具、DEFLATE算法(结合LZ77和霍夫曼编码,是ZIP、PNG、gzip的基础)。

五、Dijkstra最短路径算法

Dijkstra算法求解非负权有向图中从单个源点到所有其他顶点的最短路径。Dijkstra算法本质上是一种贪心算法——每次都选择当前距离最小的未确定顶点,将其标记为”已确定”(其距离不再更新),并对其所有出边进行松弛(Relaxation)操作。

int[] dijkstra(Graph graph, int source) {
int V = graph.V;
int[] dist = new int[V];
boolean[] visited = new boolean[V];
Arrays.fill(dist, Integer.MAX_VALUE);
dist[source] = 0;

PriorityQueue<int[]> pq = new PriorityQueue<>((a, b) -> a[1] - b[1]);
pq.offer(new int[]{source, 0});

while (!pq.isEmpty()) {
int[] cur = pq.poll();
int v = cur[0];
if (visited[v]) continue; // 懒惰删除:优先队列中的旧(更差)距离直接跳过
visited[v] = true;

for (Edge e : graph.adj[v]) {
int u = e.to;
if (!visited[u] && dist[v] + e.weight < dist[u]) {
dist[u] = dist[v] + e.weight;
pq.offer(new int[]{u, dist[u]});
}
}
}
return dist;
}

Dijkstra算法使用二叉堆实现优先队列时,时间复杂度为O((V + E) log V)。如果使用斐波那契堆,理论上可以做到O(V log V + E)(因为Decrease-Key在斐波那契堆上是O(1)摊还)。注意上述实现中我们使用了”懒惰删除”(将新距离直接插入优先队列,旧的距离记录在出队时被visited检查跳过),这避免了需要支持Decrease-Key操作的数据结构限制,在边数不太大时是更实用的策略。

Dijkstra的贪心正确性证明:设S是已确定最短路径的顶点集合。假设算法首次错误地将顶点v标记为已确定,即dist[v]不是源点到v的最短距离。设从源点到v的真正最短路径上第一个不属于S的顶点是u。由于路径上的所有边权非负,且源点到u的路径是真正最短路径的前缀,我们有dist[u] = δ(source, u)(因为u之前的顶点都在S中,其最短距离已被正确计算并用于松弛u)。同时,由于v是当前S外dist最小的顶点,dist[v] ≤ dist[u] = δ(source, u)。又由于非负权性质,δ(source, u) ≤ δ(source, v)。因此dist[v] ≤ δ(source, v)。而dist[v]是某条从源点到v的路径的长度,至少为δ(source, v)。所以dist[v] = δ(source, v),矛盾。这里非负权条件是关键——对于有负权边的图,经过更多顶点的路径可能反而更短,违背了不等式δ(source, u) ≤ δ(source, v)。

启发式扩展:A*算法在Dijkstra的基础上引入启发式函数h(v)(估计从v到目标顶点的距离),将优先队列的键值从dist[v]改为dist[v] + h(v)。当h(v)满足”可采纳性”(Admissible,不高估实际距离)和”一致性”(Consistent)时,A*保证找到最短路径且比Dijkstra探索更少的顶点。在路径规划(地图导航)、游戏AI中广泛应用。

六、Prim与Kruskal的最小生成树

最小生成树(Minimum Spanning Tree,MST)是连接无向连通图中所有顶点的权重和最小的边的子集(形成一棵树)。MST问题在拟阵理论中是最典型的贪心示例。

Prim算法从任意顶点开始,不断选择权重最小的边连接已选顶点集合与未选顶点集合,直到所有顶点都被连接。它像是Dijkstra的”兄弟算法”——代码结构几乎相同,唯一的区别在于优先队列的键值意义。

int primMST(Graph graph) {
int V = graph.V;
boolean[] inMST = new boolean[V];
int[] minEdge = new int[V];
Arrays.fill(minEdge, Integer.MAX_VALUE);
minEdge[0] = 0;

int totalWeight = 0;
PriorityQueue<int[]> pq = new PriorityQueue<>((a, b) -> a[1] - b[1]);
pq.offer(new int[]{0, 0});

while (!pq.isEmpty()) {
int[] cur = pq.poll();
int v = cur[0];
if (inMST[v]) continue;
inMST[v] = true;
totalWeight += cur[1];

for (Edge e : graph.adj[v]) {
int u = e.to;
if (!inMST[u] && e.weight < minEdge[u]) {
minEdge[u] = e.weight;
pq.offer(new int[]{u, e.weight});
}
}
}
return totalWeight;
}

Dijkstra中优先队列的键值表示”从源点到该顶点的当前最短距离”——是一个全局度量;Prim中优先队列的键值表示”该顶点到当前已选集合的最小边权”——是一个局部度量。这个微妙的差别导致了两者证明方法的截然不同。

Kruskal算法是另一种MST算法:将所有边按权重升序排序,依次考虑每条边,如果加入该边不会形成环,则将其加入MST。判断”是否形成环”使用并查集(Union-Find / Disjoint Set Union,DSU)数据结构。

class UnionFind {
int[] parent;
int[] rank;

public UnionFind(int n) {
parent = new int[n];
rank = new int[n];
for (int i = 0; i < n; i++) parent[i] = i;
}

public int find(int x) {
if (parent[x] != x) {
parent[x] = find(parent[x]); // 路径压缩
}
return parent[x];
}

public boolean union(int x, int y) {
int px = find(x), py = find(y);
if (px == py) return false;
if (rank[px] < rank[py]) {
parent[px] = py;
} else if (rank[px] > rank[py]) {
parent[py] = px;
} else {
parent[py] = px;
rank[px]++;
}
return true;
}
}

int kruskalMST(int V, List<Edge> edges) {
Collections.sort(edges, Comparator.comparingInt(e -> e.weight));
UnionFind uf = new UnionFind(V);
int totalWeight = 0;
int count = 0;

for (Edge e : edges) {
if (uf.union(e.from, e.to)) {
totalWeight += e.weight;
count++;
if (count == V - 1) break;
}
}
return totalWeight;
}

Kruskal算法的时间复杂度为O(E log E)(排序占主导)。使用按秩合并和路径压缩的并查集,Find和Union的均摊时间复杂度为O(α(V)),其中α是阿克曼函数的反函数——对于任何实际可观测的宇宙尺度的输入,α(V) ≤ 4。因此并查集部分几乎可以视为O(1)每个操作。

Kruskal的贪心正确性证明(使用割性质):设当前考虑的边e = (u, v)权重为w,且u和v当前属于不同的连通分量(即加入e不会形成环)。考虑任意一个将u和v分开的割(Cut)——例如割的一边是u当前的连通分量。在这个割的所有交叉边中,e的权重最小(因为Kruskal按权重升序考虑边,所以所有跨越这两个连通分量的更小权重的边早就被加入MST了)。由MST的割性质(Cut Property)——对于任意割,跨越该割的最小权重边一定属于某棵MST——可知e一定在某棵MST中。因此贪心地选择e是正确的。

Prim vs Kruskal的选择策略:Prim适合稠密图(E接近V²),朴素的O(V²)实现在稠密图上因常数因子小非常快(直接扫描minEdge数组找最小,无需堆)。Kruskal适合稀疏图(E接近V),因为排序O(E log E) ≈ O(V log V)开销可接受。内存方面,Kruskal需要存储所有边,在极大图上可能超出内存;Prim只需邻接表。另外,如果边已经按权值排好序(如输入来自已排序的文件),或图以边列表给出,Kruskal更直观。

次小生成树(Second Best MST):在MST的基础上,枚举每条不在MST中的边,将其加入MST会形成环,从环中删除权重最大的边(不是刚加入的那条),得到一棵新的生成树。所有这样的树中权重最小的就是次小生成树。时间复杂度O(E log V)(需要预处理MST上的LCA查询)。

七、分数背包问题

分数背包问题(Fractional Knapsack)与0/1背包问题的区别在于物品可以取任意比例。这个问题具有贪心选择性质——按单位重量价值(v[i]/w[i])降序依次选取物品,如果当前物品不能完全放入,则放入恰好填满背包的部分。

double fractionalKnapsack(double W, double[] w, double[] v) {
int n = w.length;
Integer[] idx = new Integer[n];
for (int i = 0; i < n; i++) idx[i] = i;
Arrays.sort(idx, (a, b) -> Double.compare(v[b] / w[b], v[a] / w[a]));

double totalValue = 0;
for (int i : idx) {
if (W <= 0) break;
double take = Math.min(w[i], W);
totalValue += take * (v[i] / w[i]);
W -= take;
}
return totalValue;
}

为什么分数背包中贪心策略正确,而0/1背包中不正确?因为分数背包允许分割物品,使得”单位价值最大化”可以在连续尺度上进行,不会留出”未被充分利用的残差”。而在0/1背包中,选了单位价值略高的大件可能留下无法利用的剩余空间(必须是小件的整数倍),反而导致总价值不如选稍小但刚好利用完容量的组合。这也揭示了贪心适用性的一个直觉:当决策空间是凸的(连续的、可分割的),贪心往往可行;当决策空间有离散的”间隙”,贪心可能犯错。

八、区间调度问题的泛化与拟阵视角

活动选择问题是”单资源区间调度”的最简单形式。进一步泛化:

多资源区间调度(Interval Partitioning):给定N个区间,使用最少的资源(如教室、机器),使得同一资源上安排的区间互不重叠。贪心策略:按开始时间排序,对每个区间,如果存在一个空闲资源,分配给它;否则新增一个资源。这个贪心解给出的是最小资源数量——证明使用”深度”概念:任意时刻重叠区间的最大数量d是一个下界(任何可行调度至少需要d个资源),而贪心算法恰好使用d个资源,因此是最优的。

带截止时间的任务调度(Task Scheduling with Deadlines):N个任务,每个任务有截止时间d[i]和利润p[i]。每个任务需要一个单位时间完成。最大化完成任务的利润和。贪心策略:按利润降序考虑任务,尽量安排在截止时间之前最晚的空闲时间槽中执行(为其他任务留出更多灵活空间)。这等价于并查集维护”前一个空闲时间槽”的优化。

int scheduleTasks(int[] deadlines, int[] profits) {
int n = deadlines.length;
Integer[] idx = new Integer[n];
for (int i = 0; i < n; i++) idx[i] = i;
Arrays.sort(idx, (a, b) -> profits[b] - profits[a]);

int maxDeadline = Arrays.stream(deadlines).max().getAsInt();
UnionFind slots = new UnionFind(maxDeadline + 1); // 每个时间槽一个节点

int totalProfit = 0;
for (int i : idx) {
int slot = slots.find(Math.min(maxDeadline, deadlines[i]));
if (slot > 0) {
totalProfit += profits[i];
slots.union(slot, slot - 1); // 标记该槽已使用
}
}
return totalProfit;
}

这些区间调度问题在拟阵框架下统一:可行任务集合构成一个拟阵(调度拟阵),因此贪心算法(按利润降序选择,如果仍能安排则加入)总是得到最大权独立集。

九、贪心与DP的对比——硬币找零问题

硬币找零问题是区分贪心与DP的经典案例。给定面值coins和金额amount:

分数版(假想的连续硬币):贪心正确——每次选最大面值的硬币,取整数部分。

一般版(离散硬币):贪心不一定正确。如果硬币面值构成”规范体系”(Canonical Coin System),贪心策略保证最优。例如,人民币面值[1, 5, 10, 20, 50, 100],美元面值[1, 5, 10, 25],欧元面值[1, 2, 5, 10, 20, 50, 100, 200]都是规范体系。但[1, 3, 4]不是——对于金额6,贪心选4+1+1=3枚,最优是3+3=2枚。

判断一个硬币体系是否为规范体系需要检查所有金额是否贪心最优,有数学条件和算法方法可判定(通常检查有限个金额即可——不超过最大面值与次大面值的乘积)。

DP解(一般情况):dp[i] = min(dp[i - coin] + 1),对所有coin ≤ i。时间复杂度O(amount × N)。

十、面试常见追问

问题一:Dijkstra算法为什么不能处理负权边?

Dijkstra算法基于”已确定顶点的最短距离不再更新”的不变性。证明该不变性时使用了”非负权”的关键假设。当存在负权边时,这个不变性被破坏——经过更多顶点的路径可能由于负权边的抵消作用而变得更短。具体例子:A→B = 3,A→C = 2,C→B = -2。从A出发,Dijkstra首先确定C(dist=2),然后从C松弛B,dist[B] = 0。但此时dist[B](0)小于刚刚”确定”的dist[C](2),违反了不变性——实际上dist[C]的最小值可能通过A→B→…→C继续缩小,但我们已将其锁定。对于含负权边的图,应使用Bellman-Ford算法(O(VE))或SPFA(Bellman-Ford的队列优化,平均O(E)但最坏O(VE))。两者都能正确处理负权边并检测负权环(在N-1轮松弛后如果还能继续松弛,说明存在负权环)。

问题二:Prim算法和Kruskal算法在实际中如何选择?

Prim适合稠密图(E接近V²),其朴素的O(V²)实现(不使用堆,直接扫描minEdge数组找最小)在稠密图上因常数因子小而非常快——每次只需V次比较找出最小值。Kruskal适合稀疏图(E接近V),排序O(E log E) ≈ O(V log V)开销可接受。内存方面,Kruskal需要存储所有边并额外维护并查集结构(O(V)空间);Prim使用邻接表和图遍历结构即可。如果图非常巨大(上百万元素),稠密版的Prim在内存和速度上都优于Kruskal(O(V²) vs O(V² log V) ≈ O(V²))。在代码实现上,无论是Prim还是Kruskal,标准实现都不超过50行,都是简洁的算法。

问题三:如何判断一个问题是否可以用贪心解决?

遵循以下步骤:(1)检查问题是否具有最优子结构——全局最优解能否由局部决策逐步构造。(2)尝试设计贪心策略并测试其对已知案例的正确性。(3)构造反例——这是最快的验证方法。尝试制造”贪心选择看似最优,但阻碍了后续更优组合”的场景。如果找不到反例,进入下一步。(4)尝试证明贪心选择性质(交换论证或贪心领先)。(5)如果无法证明但直觉感觉正确,检查问题是否属于已知的贪心适用类型——是否涉及拟阵结构(如最小生成树、调度问题)、是否可以用排序+贪心处理(如区间调度、任务调度)、是否是最短路径的特例(Dijkstra属于广义贪心)。

一个实用的经验法则:如果问题可以通过排序+一次扫描解决(如活动选择、区间合并、跳跃游戏),贪心往往是对的;如果问题需要多轮决策且每轮选择影响后续选项时(如0/1背包、正则匹配),贪心很可能是错的。

问题四:霍夫曼编码与算术编码有何区别?

霍夫曼编码为每个符号分配一个整数位的编码(如0.3概率的符号可能仍需1位编码,造成比特浪费)。当符号概率不是2的负幂次时,霍夫曼编码的期望编码长度可能在熵的1位以内。算术编码(Arithmetic Coding)将整个消息映射到[0.0, 1.0)区间内的一个实数,符号的概率决定了区间划分的比例,越可能的符号区间越大(需要的编码位越少)。算术编码可以实现小数位的编码,压实率更接近熵极限(通常比霍夫曼编码高5-10%)。但算术编码计算复杂度更高(涉及高精度算术),且受专利限制(早期专利已过期)。现代压缩算法(如bzip2、LZMA、Zstandard)一般使用算术编码或其高速变体(ANS,Asymmetric Numeral Systems)替代霍夫曼编码。

问题五:贪心算法得到近似解的场景有哪些?

当贪心不保证最优时,有时可以作为高效近似算法使用:(1)集合覆盖问题(Set Cover):每次选择覆盖最多未覆盖元素的集合,近似比为 H(n) = 1 + 1/2 + … + 1/n ≈ ln n(可证明这是最佳可能的多项式时间近似比,除非P=NP)。(2)0/1背包的贪心近似:按单位重量价值选择,截止到不能放入为止,然后比较”贪心解”与”只选最贵的一个物品”哪个价值更大,取最大值。这个简单策略给出了2近似比(至少是最优值的一半)。(3)顶点覆盖(Vertex Cover):每次选择度数最大的顶点加入覆盖集,但这不是近似最优的——简单的最优近似是”随便取一条边的两个端点都加入覆盖集”,给出2近似比。(4)旅行商问题(TSP):最近邻居贪心(每次去最近的未访问城市)没有常数近似比,但Christofides算法(先求MST,再做完美匹配)给出3/2近似比,是Metric TSP的最佳已知近似算法。

十一、拟阵理论与贪心算法的深层联系

拟阵(Matroid)是贪心算法正确性的代数理论基础。一个拟阵M = (S, I)由一个有限集S和S的子集族I(称为独立集)构成,且满足:(1)遗传性:独立集的任意子集仍是独立集;(2)交换性:如果A和B是独立集且|A| < |B|,则存在x ∈ B\A 使得 A ∪ {x} 也是独立集。

在拟阵上,贪心算法总能找到最大权独立集:将S中元素按权重降序排列,依次考虑每个元素,如果将其加入当前独立集后仍保持独立性(属于I),则加入;否则跳过。

许多经典贪心问题都是拟阵的特例:

  • 图拟阵(Graphic Matroid):S是图的所有边,I是所有不构成环的边集(即森林)。最大权独立集 = 最大权森林 = 最小生成树(当图为连通时)。Kruskal算法正是图拟阵上的贪心算法。
  • 划分拟阵(Partition Matroid):S被划分为若干不相交的块,每个块有容量上限。独立集是从每块中选取不超过容量上限的元素集合。区间调度(每个时间点是一个”块”,容量为1)是划分拟阵的特例。
  • 横截拟阵(Transversal Matroid):二分图匹配中的拟阵结构,可用于任务分配问题。

理解拟阵可以让程序员在更高抽象层次判断贪心算法是否适用——如果一个问题可以建模为拟阵,贪心就是可证明正确的。这个视角将众多看似不相关的贪心算法统一在一个优雅的代数框架下。

十二、更多贪心经典问题

加油站问题(Gas Station):环形加油站,每个站可加gas[i]的油,去下一站需要cost[i]的油。是否存在一个出发站使得可以绕一圈?贪心策略:如果从站A出发无法到达站B(A到B之间任何站出发都不可达B+1),则下一次尝试从B+1站出发。关键证明:如果总gas ≥ 总cost(有解的必要条件),算法保证找到解。时间复杂度O(N)。

int canCompleteCircuit(int[] gas, int[] cost) {
int total = 0, tank = 0, start = 0;
for (int i = 0; i < gas.length; i++) {
total += gas[i] - cost[i];
tank += gas[i] - cost[i];
if (tank < 0) {
start = i + 1; // 从下一个站重新尝试
tank = 0;
}
}
return total >= 0 ? start : -1;
}

跳跃游戏(Jump Game):每个位置上的数字表示可以跳跃的最远距离。最少跳几次到终点?贪心策略:维护”当前跳能到达的最远位置”和”下一跳能到达的最远位置”。在当前位置的跳跃范围内,不断更新下一跳的最远位置;当到达”当前跳的最远位置”时,必须跳一步(步数+1),更新当前边界为下一跳的最远位置。这个策略的贪心选择性质可以严格证明:在必须跳的时候,选择能最大化后续可达范围的跳是最优的。时间复杂度O(N),空间O(1)。

拼接最小数字:给定一组非负整数,将它们拼接成最小的字符串(如[3, 30, 34, 5, 9] → “3033459”)。贪心策略:按”a拼接b < b拼接a”的规则排序。即自定义比较器(a, b) -> (a+b).compareTo(b+a)。这个排序规则是传递的(证明需要数学分析),因此排序后的拼接必然是全局最优。

分糖果(Candy):N个孩子排成一排,每个孩子至少1颗糖,rating高的孩子必须比相邻rating低的孩子得到更多糖果。求最少糖果总数。贪心策略:从左到右(满足”高于左邻则多得”约束),再从右到左(满足”高于右邻则多得”约束),每个位置取两趟的较大值。虽然看起来像DP(需要两次扫描),本质上每次只做局部最优决策,利用的是”左约束”和”右约束”可以独立求解然后合并的性质。

十三、贪心算法的局限性、边界与常见陷阱

局部最优吞没全局最优:贪心的致命缺陷在硬币找零反例中表现得淋漓尽致。更深层的解释是:当决策空间的”梯度”方向与全局最优方向不一致时,跟随每一步的局部梯度(贪心)可能将你引向局部极值点而非全局最优点。这在连续优化中对应”梯度下降陷入局部最优”,在离散贪心中对应”短视选择排除了更优组合”。

贪心可能产生的解质量无界差:在某些问题上贪心算法得到的解质量可能任意差(无常数近似比)。例如:

  • 带权集合覆盖的纯贪心近似比仅为O(log n)(这是可达到的最佳多项式时间近似比)
  • 0/1背包贪心(仅按单位价值选)在最坏情况下的相对误差可任意大(价值极高的物品重量略大于剩余容量时被跳过)
  • TSP最近邻居贪心没有常数近似比(可以构造使贪心路径长度为 Θ(log n)倍最优的实例)

如何系统化地避免贪心陷阱

  1. 先尝试小规模手动枚举所有可能解,验证贪心是否给出了最优解(n=3, 4, 5的小案例即可暴露许多贪心错误)
  2. 尝试形式化证明贪心选择性质——如果推导不下去,往往意味着存在反例
  3. 考虑问题是否属于拟阵——如果属于,贪心有理论保证
  4. 考虑贪心+DP的混合策略:在某些步骤使用贪心剪枝,其他步骤使用DP穷举

**贪心算法在面试中的”信号”**:

  • 问题涉及”最少/最多 + 排序后一次扫描” → 贪心很可能是对的
  • 问题说”你可以按照任意顺序处理” → 贪心可能在排序后可用
  • 问题涉及”选择子集使得某个量最大化,且选择之间互不冲突” → 可能是拟阵
  • 数据包含”开始时间+结束时间”、”利润+截止时间” → 区间调度类,贪心常正确
  • 图上的MST和最短路 → Prim/Kruskal/Dijkstra本质上都是贪心

贪心与在线算法(Online Algorithm)的关系:贪心算法天然适合在线场景——决策需要立即做出,无法预知未来输入。例如:滑雪板租赁问题(租 vs 买)使用平衡策略;页面置换算法(OPT是最优离线,LRU是在线贪心);秘书问题/最优停止问题(在前37%只是观察,之后选第一个比之前所有都更好的)。在线算法的竞争比(Competitive Ratio)分析可以量化贪心策略在不确定性下的性能保证。

十四、更多面试常见追问

问题六:如何设计一个贪心算法来求解”删除K位数字使剩余数字最小”?

给定一个字符串表示的数字,删除K位使得剩余数字组成的值最小(保持原顺序)。贪心策略:从左到右扫描,如果当前数字大于下一个数字(出现了递减),删除当前数字,K减1,然后回退一个位置(因为删除后新的相邻关系可能形成递减)。如果扫描完K仍大于0(说明数字呈非递减排列),从尾部删除剩余的K位。核心洞察:要得到最小的数字,应该让高位数字尽可能小。一个产生递减的高位数字是”害群之马”,应优先删除。这等价于”寻找最小的字典序子序列”。

String removeKdigits(String num, int k) {
StringBuilder sb = new StringBuilder();
for (char c : num.toCharArray()) {
while (k > 0 && sb.length() > 0 && sb.charAt(sb.length() - 1) > c) {
sb.deleteCharAt(sb.length() - 1);
k--;
}
sb.append(c);
}
// 删除尾部剩余的K位
while (k-- > 0 && sb.length() > 0) sb.deleteCharAt(sb.length() - 1);
// 去除前导零
while (sb.length() > 1 && sb.charAt(0) == '0') sb.deleteCharAt(0);
return sb.length() == 0 ? "0" : sb.toString();
}

时间复杂度O(N),空间复杂度O(N)。使用StringBuilder模拟栈,当出现”当前字符小于栈顶字符”时pop(贪心删除高位的大数字)。这个解法实际上等价于维护一个单调栈(Monotonic Stack),展示了贪心与单调栈的深层联系。

问题七:贪心算法在实时系统和硬实时约束下有什么特殊考虑?

在硬实时系统中(如航空电子、汽车ECU、工业机器人),贪心算法面临额外的约束:不仅需要解的质量保证,还需要最坏执行时间(WCET, Worst-Case Execution Time)的可预测性。例如,Dijkstra使用二叉堆的Extract-Min操作虽然摊还O(log N),但最坏可能O(N)(如果堆因操作序列变得不平衡)。在硬实时系统中,通常选择WCET可预测的数据结构,即使牺牲平均性能。例如:

  • EDF调度器(Earliest Deadline First)不使用斐波那契堆(虽然Decrease-Key O(1)摊还,但Extract-Min可能O(N)最坏),而是使用二叉堆或就绪任务数量固定的小型任务集
  • 速率单调(Rate Monotonic)调度是静态优先级分配(非贪心,预先计算),避免了运行时的贪心决策不确定
  • 某些实时系统使用堆预分配静态分析来保证每个堆操作在已知的周期数内完成

问题八:如何证明并查集+路径压缩+按秩合并的时间复杂度是O(α(N))?

并查集虽然不直接是贪心算法,但其实现(路径压缩)与贪心有关系,且它是Kruskal算法实现的核心。摊还O(α(N))的证明是算法分析中的经典难题:

  • α(N)是阿克曼函数A(n,n)的反函数——对于任何实际的宇宙尺度输入(N < 2^65536),α(N) ≤ 4
  • 证明的核心是定义秩的分组(rank groups),并分别计算每个操作中”find的代价随着rank组变化而摊销”
  • 直观:路径压缩使得树的深度被极大地扁平化。每次Find操作的耗费被分摊到路径上所有节点上(这些节点被重新连接到根,后续Find更便宜)。按秩合并确保树的深度不会因Union操作而显著增长。
  • 更惊人的事实:如果仅使用路径压缩而不使用按秩合并(或反之),摊还复杂度变为O(log N)而非O(α(N))。两者结合才能达到O(α(N))。
  • 对于面试:不需要完整推导,但应知道”反阿克曼函数”这个结论,以及”对于任何实际输入α(N) ≤ 4”这个事实,这已经足够展示理解深度。

问题九:图论中的贪心着色算法何时最优?

图的顶点着色问题(Graph Coloring)——用最少的颜色给顶点着色,使相邻顶点颜色不同。这是一个经典的NP-hard问题。贪心着色算法:按某种顺序处理顶点,为每个顶点分配”可用的最小颜色号”。贪心着色的颜色数取决于顶点处理顺序——对于任意图,存在某个顺序使得贪心着色产生最优着色(即色数)。但找到这个顺序本身就是NP-hard的。

某些图类上贪心着色的性能保证:

  • 对于二部图(Bipartite Graph):按BFS顺序贪心着色总是只用2种颜色(最优)
  • 对于区间图(Interval Graph):按左端点顺序贪心着色产生最优着色(色数 = 最大团大小)
  • 对于一般图的近似:smallest_last排序(每次删除度数最小的顶点,逆序作为处理顺序)保证颜色数 ≤ 最大度 + 1

这个例子再次说明:即使是NP-hard问题,在特定输入子类上贪心可能最优——这也是在实际中贪心算法广泛应用的重要原因:实际问题通常带有结构性约束,使贪心恰好可行或交出高质量近似解。

十五、总结:贪心算法的思维框架

掌握贪心算法的关键不是记住每个问题的贪心策略,而是建立以下思维框架:

  1. 识别贪心适用信号:排序+扫描、区间调度、MST、最短路径(非负权)、拟阵结构
  2. 提出贪心候选策略:按什么排序?每一步选什么?选择准则是什么?
  3. 测试候选策略:在小实例上手动枚举,尝试构造反例
  4. 证明或推翻:如果直觉强烈认为正确,使用交换论证或贪心领先方法;如果找到反例,考虑DP或回溯
  5. 考虑实现:排序复杂度、是否需要优先队列、是否需要并查集

贪心算法是人类日常决策的自然模式(”此时此刻,什么是最好的?”),将它提升为可证明正确的算法工具,需要对问题结构的深入理解和严格的数学证明能力。这也是贪心算法在技术面试中备受青睐的原因——它考验的不仅是编程能力,更是算法直觉和证明思维。

问题十:硬币找零问题的贪心与DP对比——为什么有时贪心正确有时不正确?

硬币找零问题是区分贪心与DP的经典教学案例。当硬币面值构成规范体系(Canonical Coin System)时,贪心策略(每次选最大面值)保证最优解。规范体系的数学条件较深(通常需验证有限个关键金额的贪心最优性),但常见货币体系(人民币、美元、欧元)都满足规范体系性质。反例如[1, 3, 4]:金额6的贪心解为4+1+1=3枚,最优解为3+3=2枚。

更一般地分析:规范体系成立的充分条件是——硬币面值满足”每个面值不小于其前面所有面值之和的某个性质”。例如人民币的1、5、10、20、50、100序列中,5 > 1, 10 > 1+5=6, 20 > 1+5+10=16, 50 > 1+5+10+20=36, 100 > 1+5+10+20+50=86,满足”超递增序列”性质,贪心必然最优。

在面试中:(1)如果给定的是已知规范货币体系(如美元),可以直接用贪心;(2)如果给定的是任意的硬币面值,必须用DP。能够区分这两者,展示了”对算法假设条件的敏感性意识”——这比死记硬背DP解法更能体现算法素养。

贪心思想在搜索剪枝中的应用:在回溯/DP搜索中,贪心解常被用作剪枝边界(Branch and Bound)——先将物品按单位价值排序,贪心选择获得一个上界(对最大化问题),若当前分支即使贪心也达不到已知最优解,则剪枝。这种”贪心引导搜索”的策略在组合优化中广泛使用,用贪心的效率加速穷举的完备性。

打赏
  • 微信
  • 支付宝

评论