3232 个字词
16 分钟
贪心算法
首次发布: 2025-08-23
... 次访问

贪心算法(Greedy Algorithm)是一种在每一步选择中都采取在当前状态下最好或最优(即最有利)的选择,从而希望导致结果是全局最好或最优的算法。贪心算法在某些问题上能够得到最优解,但并不适用于所有问题。

零钱兑换: 一个简单的贪心例子#

LeetCode: 322. 零钱兑换 给定 nn 种硬币,第 ii 种硬币的面值为 coins[i1]coins[i - 1] ,目标金额为 amountamount ,每种硬币可以重复选取,问能够凑出目标金额的最少硬币数量。如果无法凑出目标金额,则返回 −1 。

解:

给定目标金额,贪心地选择不大于它且最接近它的硬币面值,直到目标金额为 0 或无法继续选择。

class Solution {
public:
    int coinChange(vector<int>& coins, int amount) {
        sort(coins.begin(), coins.end(), greater<int>());
        int cnt = 0;
        for (int coin : coins) {
            while (amount >= coin) {
                amount -= coin;
                cnt++;
            }
        }
        return amount == 0 ? cnt : -1;        
    }
};

我们给一些例子

  • coins = [1, 2, 5], amount = 11

    • 选择 5,amount = 6,cnt = 1
    • 选择 5,amount = 1,cnt = 2
    • 选择 1,amount = 0,cnt = 3
    • 返回 3,结果正确 ✅
  • coins = [1, 3, 4], amount = 6

    • 选择 4,amount = 2,cnt = 1
    • 选择 1,amount = 1,cnt = 2
    • 选择 1,amount = 0,cnt = 3
    • 返回 3,结果错误 ❌ (正确答案是 2,选择两个 3)

可以看出,贪心算法并不总是能得到最优解。在零钱兑换这个例子中,只有满足是规范币制的情况下,贪心算法才能得到最优解。

规范币制:

  • 充分条件1: 可整除链。每个大币值都是前一个币值的整数倍。例如 [1,2,4,8,16][1, 2, 4, 8, 16]
  • 充分条件2: 等比数列。币值按固定比例增长, [1,b,b2,][1, b, b^2, \ldots]
  • 充分条件3: 超递增序列。每个币值都大于前面所有币值之和, [1,2,4,8,16][1, 2, 4, 8, 16]

组合优化问题中的贪心算法#

组合优化问题通常涉及在有限的离散选项中选择一个子集,以最大化或最小化某个目标函数。贪心算法在解决某些组合优化问题时表现出色,尤其是当问题满足贪心选择性质最优子结构性质时。

  • 贪心选择性质:局部最优选择可以导致全局最优解。

设实例 II 的可行解集合为 F(I)2E(I)\mathcal{F}(I)\subseteq 2^{E(I)},目标为最小化 fI(S)f_I(S)。设 C(I)C(I) 为可选原子决策集合,Feasible(I,a)\text{Feasible}(I,a) 判定在当前前缀下选择 aC(I)a\in C(I) 的可行性,R(I,a)R(I,a) 为将 aa 固定后的约化子问题,\oplus 为解的合成算子。若存在评分函数 sI:C(I)Rs_I:C(I)\to\mathbb{R},使得存在某个最优解 SF(I)S^*\in\mathcal{F}(I) 与某个 aC(I)a^*\in C(I) 满足

aarg minaC(I),Feasible(I,a)sI(a),a^* \in \argmin_{a\in C(I),\,\text{Feasible}(I,a)} s_I(a),

且存在 SF(R(I,a))S'\in \mathcal{F}(R(I,a^*)) 使 S=aSS^*=a^*\oplus S',则称 II 在评分 sIs_I 下具有贪心选择性质。换言之,至少有一个最优解的首个决策等于贪心选择。

  • 最优子结构性质:问题的最优解包含其子问题的最优解。
OPT(I)=minSF(I)fI(S)\text{OPT}(I)=\min_{S\in\mathcal{F}(I)} f_I(S)

若存在约化算子 RR、合成算子 \oplus 与合并函数 Combine\text{Combine},使对一切实例 II

OPT(I)=minaC(I),Feasible(I,a)Combine(I,a,OPT(R(I,a)))\text{OPT}(I)=\min_{a\in C(I),\,\text{Feasible}(I,a)}\, \text{Combine}\big(I,a,\,\text{OPT}(R(I,a))\big)

且若 S=aTS^*=a\oplus TII 的某个最优解分解,则 TT 必为子问题 R(I,a)R(I,a) 的最优解,即

Targ minSF(R(I,a))fR(I,a)(S)T\in \argmin_{S\in \mathcal{F}(R(I,a))} f_{R(I,a)}(S)

则称该问题具有最优子结构性质。

上述的两个性质定义了经典贪心算法的适用范围。需要注意的是,在经典贪心算法中,局部最优选择往往是一经确定就不再修改的(这与前述例子中的贪心策略不同,不过确实有 “带反悔的贪心算法” 的变种)。

通常而言,证明一个问题可以用贪心算法解决,往往需要先证明该问题满足贪心选择性质和最优子结构性质。这并不是一件容易的事,因此对于工程领域而言,往往记住有哪些经典的贪心算法适用问题就足够了。

最大切分乘积#

LeetCode: 343. 整数拆分 给定一个正整数 nn ,将其切分为 kk 个正整数的和 (k2k \ge 2),求切分后所有整数的乘积的最大值是多少。

解:

设将数 nn 切分为 kk 个正整数 n1,n2,,nkn_1, n_2, \ldots, n_k 的和,记为

n=i=1knin = \sum_{i=1}^{k} n_i

目标是最大化

P=i=1kniP = \prod_{i=1}^{k} n_i

根据均值不等式

nk=1ki=1knii=1knik=Pk\frac{n}{k} = \frac{1}{k}\sum_{i=1}^{k} n_i \geq \sqrt[k]{\prod_{i=1}^{k} n_i} = \sqrt[k]{P}

等号成立当且仅当 n1=n2==nkn_1 = n_2 = \ldots = n_k 时成立。因此,为了最大化乘积 PP,我们应尽量使各个切分的整数相等。

假设将 nnxx 为因子等分成 kk 份,即 n=kxn = kx,则乘积为

f(x)=xk=xnx=enxlnxf(x) = x^k = x^{\frac{n}{x}} = e^{\frac{n}{x} \ln x}

为了最大化 f(x)f(x),直接对 g(x)=1xlnxg(x) = \frac{1}{x} \ln x 求导

g(x)=1lnxx2g'(x) = \frac{1 - \ln x}{x^2}

显然,当 x=ex = e 时,g(x)=0g'(x) = 0,函数 g(x)g(x)x=ex = e 处取得最大值。

考虑到切分因子必须为整数,我们选择最接近 ee 的整数,即 2233。通过比较可以发现,使用 33 作为切分因子通常能获得更大的乘积。

f(3)=3n3>f(2)=2n2\begin{align*} f(3) = 3^{\frac{n}{3}} > f(2) = 2^{\frac{n}{2}} \end{align*}

因此,贪心策略为

  • 尽可能多地使用 33 进行切分。
  • 当剩余部分为 22 时 (n mod 3=2n \text{ mod } 3 = 2),直接使用 22
  • 当剩余部分为 11 时 (n mod 3=1n \text{ mod } 3 = 1),将一个 33 与这个 11 合并然后拆分为 2+22 + 2,因为 3×1<2×23 \times 1 < 2 \times 2
class Solution {
public:
    int integerBreak(int n) {
        if (n <= 3) return n - 1;
        int prod = 1;
        while (n > 4) {
            // 当 n % 3 == 1 时,循环最终会停在 n == 4
            // 此时不再拆分 3,而是直接乘以 4
            // 符合下面的代码
            prod *= 3;
            n -= 3;
        } 
        prod *= n;
        return prod;
    }
};

// 或者直接使用数学公式
class Solution {
public:
    int integerBreak(int n) {
        if (n <= 3) return n - 1;
        int a = n / 3, b = n % 3;
        if (b == 0) return pow(3, a);
        if (b == 1) return pow(3, a - 1) * 4;
        return pow(3, a) * 2;
    }
};

分数背包#

给定 nn 个物品,和一个容量为 capacitycapacity 的背包。其中第 ii 个物品的重量为 weight[𝑖1]weight[𝑖 − 1],价值为 value[𝑖1]value[𝑖 − 1]。每个物品只能选择一次,但可以选择物品的一部分,价值根据选择的重量比例计算,求在限定背包容量下背包中物品的最大价值。

注意这里是分数背包,即每个物品不用全部装入背包,可以选择部分装入。例如,面前有 10kg 的黄金,5kg 的白银,背包容量为 7kg,可以选择装入 5kg 黄金和 2kg 白银(分别是原有黄金和白银的 1/2 和 2/5)。

解:

考虑使用最大化单位重量下的物品价值的贪心策略。由于是分数背包,因此这样的贪心选择必然是最优的。

double fracKnapsack(vector<int>& weights, vector<int>& values, int capacity) {
    int n = weights.size();
    vector<pair<double, int>> vp; // (value/weight, index)
    for (int i = 0; i < n; i++) {
        vp.push_back({(double)values[i] / weights[i], i});
    }
    sort(vp.rbegin(), vp.rend()); // 按照单位重量价值从大到小排序
    double totalValue = 0.0;
    for (const auto& [ratio, idx] : vp) {
        if (capacity <= 0) break;
        int takeWeight = min(capacity, weights[idx]);
        totalValue += takeWeight * ratio;
        capacity -= takeWeight;
    }
    return totalValue;
}

最大容量问题#

LeetCode: 11. 盛最多水的容器 输入一个长度为 nn 的数组 heightheight,其中的每个元素代表一个垂直隔板的高度。数组中的任意两个隔板,以及它们之间的空间可以组成一个容器。容器的容量等于所围成的面积大小。求如何在数组中选择两个隔板,使得组成的容器的容量最大。返回最大容量。

解:

这题自然可以用穷举求解,但是采用贪心法可以缩小搜索空间。

使用左右双指针,左指针 leftleft 指向数组开头,右指针 rightright 指向数组结尾。计算当前容器的容量,并更新最大容量。然后每次向内移动较短的指针,直至两个指针相遇。在这个过程中记录最大容量。

这里每次移动的是较短的指针,是因为如果移动较长的指针,容量必定是不会增加的(因为容器的有效高度由短板决定,移动长板不会增加短板的高度,只会减少宽度)。

class Solution {
public:
    int maxArea(vector<int>& height) {
        int left = 0, right = height.size() - 1;
        int max_vol = 0;
        while (left < right) {
            int vol = min(height[left], height[right]) * (right - left);
            max_vol = max(max_vol, vol);
            if (height[left] > height[right]) right--;
            else left++;
        }
        return max_vol;
    }
};

活动选择问题 (多区间调度问题)#

给定若干半开区间 [si,fi)[s_i, f_i),选出最多个互不重叠的区间。

解:

  • 将区间按结束时间 ff 升序排序。
  • 依次扫描,若当前区间的开始时间 slast_ends \geq last\_end,则选择该区间,并更新 last_end=flast\_end = f
  • 该策略同时用于 435/646;时间复杂度 O(nlogn)O(n \log n)(排序)。

正确性要点

  • 贪心选择性质:最早结束的区间留给后续最多的可用时间。
  • 最优子结构:选择最早结束的区间后,剩余问题在右侧子区间上同样最优。
// 435. 无重叠区间:返回最少删除数
class Solution435 {
public:
    int eraseOverlapIntervals(vector<vector<int>>& intervals) {
        if (intervals.empty()) return 0;
        sort(intervals.begin(), intervals.end(),
             [](const auto& a, const auto& b){ return a[1] < b[1]; });
        int cnt = 0, lastEnd = INT_MIN;
        for (auto& it : intervals) {
            if (it[0] >= lastEnd) { // 选
                cnt++;
                lastEnd = it[1];
            }
        }
        return (int)intervals.size() - cnt; // 删除 = 总数 - 可选最大数
    }
};

// 646. 最长数对链:返回可选最大数
class Solution646 {
public:
    int findLongestChain(vector<vector<int>>& pairs) {
        sort(pairs.begin(), pairs.end(),
             [](const auto& a, const auto& b){ return a[1] < b[1]; });
        int cnt = 0, lastEnd = INT_MIN;
        for (auto& p : pairs) {
            if (p[0] >= lastEnd) { // 选
                cnt++;
                lastEnd = p[1];
            }
        }
        return cnt;
    }
};

其他经典的贪心算法#

  • Huffman 编码:用于数据压缩,通过构建最优前缀码树来最小化编码长度。
  • Prim 和 Kruskal 算法:用于寻找加权无向图的最小生成树。
  • Dijkstra 算法:用于寻找加权有向图中单源最短路径。

数值优化中的贪心算法#

数值优化问题通常涉及在连续空间中寻找函数的最优值。贪心算法在某些数值优化问题中也能发挥作用,尤其是在高维空间中。

逐步坐标下降优化#

假设我们有一个标量值函数 f(x),xRnf(x), x \in \mathbb{R}^nnn 是一个非常大的数。我们想要找到 f(x)f(x) 的最小值。假设这里我们不能使用梯度下降法,因为计算梯度涉及到非常复杂的微分计算,难以快速得到梯度信息(或者压根就是不可微的一个函数)。

我们考虑一种简单的贪心策略:逐步坐标下降(Coordinate Descent)。这种方法的核心思想是每次只优化一个变量,保持其他变量不变。

逐步坐标下降算法

Input:一个标量值函数 f(x)f(x) 和初始点 x(0)Rnx^{(0)} \in \mathbb{R}^n
Output:最小值点 xx^*
Procedure:
1:初始化 x=x(0)x = x^{(0)}, k=1k = 1
2:while 收敛条件不满足 do
3:  for i=1i = 1 to nn do
4:     找到 xix_i 的最优点 xi(k)x_i^{(k)},求解 xi(k)=arg minxif(x1(k),x2(k),,xi1(k),xi,xi+1(k1),,xn(k1))x_i^{(k)} = \argmin_{x_i} f(x_1^{(k)}, x_2^{(k)}, \ldots, x_{i-1}^{(k)}, x_i, x_{i+1}^{(k-1)}, \ldots, x_n^{(k-1)})
5:   end for
6:   k=k+1k = k + 1
7:构造最终解 x=(x1(k),x2(k),,xn(k))x^* = (x_1^{(k)}, x_2^{(k)}, \ldots, x_n^{(k)})
8:返回最优解 xx^*

很显然,这种贪心的方法并不能保证全局最优解,但在某些情况下,它可以快速找到一个较好的局部最优解。

可以证明,当满足 f(x)f(x) 是凸函数时,逐步坐标下降法能够收敛到全局最优解。

证明:

xx^*f(x)f(x) 的全局最优解,x(k)x^{(k)} 是第 kk 次迭代的解。根据逐步坐标下降法的定义,我们有: f(x(k+1))f(x(k))f(x^{(k+1)}) \leq f(x^{(k)}) 因为每次迭代都选择了当前坐标的最优值。

由于 f(x)f(x) 是凸函数,且 f(x)f(x) 在每次迭代中单调递减且有下界(因为 f(x)f(x) 有最小值),根据单调有界定理,序列 {f(x(k))}\{f(x^{(k)})\} 收敛。

考虑到凸函数有且只有一个最小值,因此,逐步坐标下降法在 f(x)f(x) 是凸函数的条件下,能够收敛到全局最优解。

问题: 如果函数 f(x)f(x) 不是凸函数呢?

在这种情况下,逐步坐标下降法可能会陷入局部最优解,无法保证找到全局最优解。这是因为非凸函数具有不止一个的极小值点。由上述迭代情况的分析,坐标下降法可以收敛到不同的局部最优解,具体取决于初始点 x(0)x^{(0)} 的选择。这说明了贪心策略在非凸优化问题或其他优化问题中的局限性。

参考资料#

留言板