文章目录
-
- 在数学严密逻辑下的贪婪法则
- [一、 前言:贪心算法的核心准则](#一、 前言:贪心算法的核心准则)
- [二、 柠檬水找零 (Easy)](#二、 柠檬水找零 (Easy))
-
- [2.1 题目描述](#2.1 题目描述)
- [2.2 贪心策略与数学证明](#2.2 贪心策略与数学证明)
- [2.3 C++ 代码实战](#2.3 C++ 代码实战)
- [三、 将数组和减半的最少操作次数 (Medium)](#三、 将数组和减半的最少操作次数 (Medium))
-
- [3.1 题目描述](#3.1 题目描述)
- [3.2 贪心策略与数学证明](#3.2 贪心策略与数学证明)
- [3.3 C++ 代码实战](#3.3 C++ 代码实战)
- [四、 最大数 (Medium)](#四、 最大数 (Medium))
-
- [4.1 题目描述](#4.1 题目描述)
- [4.2 贪心策略与数学证明](#4.2 贪心策略与数学证明)
- [4.3 C++ 代码实战](#4.3 C++ 代码实战)
- [五、 摆动序列 (Medium)](#五、 摆动序列 (Medium))
-
- [5.1 题目描述](#5.1 题目描述)
- [5.2 贪心策略与数学证明](#5.2 贪心策略与数学证明)
- [5.3 C++ 代码实战](#5.3 C++ 代码实战)
- [六、 最长递增子序列 (Medium)](#六、 最长递增子序列 (Medium))
-
- [6.1 题目描述](#6.1 题目描述)
- [6.2 贪心策略与数学证明](#6.2 贪心策略与数学证明)
- [6.3 C++ 代码实战](#6.3 C++ 代码实战)
- [七、 总结](#七、 总结)
在数学严密逻辑下的贪婪法则
一、 前言:贪心算法的核心准则
💬 开篇:贪心算法(Greedy Algorithm)在每一步都做出当前状态下的局部最优解,期望通过局部最优推导出全局最优。
🚀 核心痛点与破解之道 :
贪心算法最难的部分不在于代码的编写,而在于正确性的证明 。如果仅凭直觉"蒙"一个策略,极易在特殊测试用例中翻车。
因此,解决贪心问题必须秉持一个原则:先有严格的数学证明(反证法、交换论证法、数学归纳法等),再有代码实现。
本篇精选了 5 道经典的贪心题,我们将通过严格的数学推导 为主,辅以生活常识来具象化,带你彻底看透贪心算法的底层逻辑。
二、 柠檬水找零 (Easy)
2.1 题目描述
题目链接 :860. 柠檬水找零
描述 :
每杯柠檬水 5 美元。顾客只付 5、10、20 美元。你一开始没有零钱。
请问你能否给所有顾客正确找零?
2.2 贪心策略与数学证明
贪心策略:
- 遇到 5 元:直接收下。
- 遇到 10 元:找零 5 元。
- 遇到 20 元:优先找零 10 + 5 元;若不行,再找零 5 + 5 + 5 元。
严格数学证明(交换论证法) :
我们需要证明,在面临 20 元找零时,优先消耗 10 元(方案 A:10+5)必定不劣于消耗 5 元(方案 B:5+5+5) 。
假设存在一个全局最优解,在某个时刻客户支付 20 元时,我们手中有 10 元和 5 元,却采取了方案 B(消耗 3 张 5 元)。
后续的交易中,我们手中比方案 A 多了一张 10 元,少了 两张 5 元。
定义货币的"支付能力集合":
- 5 元钞票可以用于找零 10 元客户,也可以用于找零 20 元客户。
- 10 元钞票只能 用于找零 20 元客户。
可见,5 元钞票的作用域是 10 元钞票作用域的严格超集 。
如果我们在当前步骤用1张10元等价替换掉2张 5 元(即采用方案 A),在后续的任何状态中,方案 A 能够应对的客户请求集合完全包含了方案 B 能够应对的集合。因此,替换后不会使原先可行的解变得不可行。
结论:保留 5 元(方案 A)是绝对的占优策略。
生活常识辅助 :
5 块钱是"万金油",10 块钱是"大额废钞"。好钢用在刀刃上,遇到大额找零优先把废钞丢出去。
2.3 C++ 代码实战
cpp
class Solution {
public:
bool lemonadeChange(vector<int>& bills) {
int five = 0, ten = 0;
for (int x : bills) {
if (x == 5) {
five++;
}
else if (x == 10) {
if (five == 0) return false;
five--;
ten++;
}
else {
// 贪心:优先选用局部最优解 (10 + 5)
if (ten > 0 && five > 0) {
ten--;
five--;
}
// 退而求其次 (5 + 5 + 5)
else if (five >= 3) {
five -= 3;
}
else {
return false;
}
}
}
return true;
}
};
三、 将数组和减半的最少操作次数 (Medium)
3.1 题目描述
题目链接 :2208. 将数组和减半的最少操作次数
描述 :
每次可以从数组中选择任意一个数将其减半(可重复减半)。
求让数组和至少减少一半的最少操作次数。
3.2 贪心策略与数学证明
贪心策略 :
每次操作都选取当前数组中的最大值进行减半。
严格数学证明(反证法) :
设在第 i i i 次操作时,数组的最大值为 M M M,其他某个值为 x x x(且 M > x M > x M>x)。
假设最优解序列在第 i i i 步没有选择 M M M,而是选择了 x x x。则第 i i i 步数组总和减少了 x 2 \frac{x}{2} 2x。
如果在第 i i i 步依照贪心策略选择 M M M,则数组总和将减少 M 2 \frac{M}{2} 2M。
因为 M > x M > x M>x,所以 M 2 > x 2 \frac{M}{2} > \frac{x}{2} 2M>2x。
在有限次数 k k k 内,每次都选取当前最大值构成的减少量之和,必定大于等于任何其他选取策略。
即对于任何非贪心策略达到减半目标所需的次数 K o p t K_{opt} Kopt,贪心策略所需次数 K g r e e d y K_{greedy} Kgreedy 必然满足 K g r e e d y ≤ K o p t K_{greedy} \le K_{opt} Kgreedy≤Kopt。因此贪心策略即为全局最优。
生活常识辅助 :
为了最快减轻包裹的总重量,永远优先把其中最重的那一块石头切掉一半。
3.3 C++ 代码实战
cpp
#include <queue>
#include <vector>
class Solution {
public:
int halveArray(vector<int>& nums) {
// 大根堆维护当前所有元素的最大值
priority_queue<double> heap;
double sum = 0.0;
for (int x : nums) {
heap.push(x);
sum += x;
}
double target = sum / 2.0;
double reduced = 0.0;
int count = 0;
while (reduced < target) {
// 每次贪心地取出最大值
double max_val = heap.top();
heap.pop();
double half = max_val / 2.0;
reduced += half;
count++;
// 减半后放回,维护集合状态
heap.push(half);
}
return count;
}
};
四、 最大数 (Medium)
4.1 题目描述
题目链接 :179. 最大数
描述 :
给定一组非负整数,重新排列它们的顺序使之组成一个最大的整数。
例如:
[3, 30, 34, 5, 9]排列后最大为"9534330"。
4.2 贪心策略与数学证明
贪心策略 :
将数字转化为字符串,定义排序规则符 ≻ \succ ≻:
若 A 拼接 B > B 拼接 A A \text{拼接} B > B \text{拼接} A A拼接B>B拼接A,则 A ≻ B A \succ B A≻B( A A A 应该排在 B B B 前面)。
严格数学证明(传递性证明) :
要使自定义排序算法成立,该比较规则必须满足严格全序关系 (即具有传递性)。
设 A , B , C A, B, C A,B,C 为转换为字符串的数字,其对应十进制长度为 L a , L b , L c L_a, L_b, L_c La,Lb,Lc。
A ≻ B ⟹ A ⋅ 10 L b + B > B ⋅ 10 L a + A A \succ B \implies A \cdot 10^{L_b} + B > B \cdot 10^{L_a} + A A≻B⟹A⋅10Lb+B>B⋅10La+A。
整理得: A ⋅ ( 10 L b − 1 ) > B ⋅ ( 10 L a − 1 ) A \cdot (10^{L_b} - 1) > B \cdot (10^{L_a} - 1) A⋅(10Lb−1)>B⋅(10La−1),即 A 10 L a − 1 > B 10 L b − 1 \frac{A}{10^{L_a} - 1} > \frac{B}{10^{L_b} - 1} 10La−1A>10Lb−1B。
已知 A ≻ B A \succ B A≻B 且 B ≻ C B \succ C B≻C,则有:
A 10 L a − 1 > B 10 L b − 1 \frac{A}{10^{L_a} - 1} > \frac{B}{10^{L_b} - 1} 10La−1A>10Lb−1B 且 B 10 L b − 1 > C 10 L c − 1 \frac{B}{10^{L_b} - 1} > \frac{C}{10^{L_c} - 1} 10Lb−1B>10Lc−1C。
由数学传递性显然得出: A 10 L a − 1 > C 10 L c − 1 \frac{A}{10^{L_a} - 1} > \frac{C}{10^{L_c} - 1} 10La−1A>10Lc−1C。
将其逆推展开: A ⋅ ( 10 L c − 1 ) > C ⋅ ( 10 L a − 1 ) ⟹ A ⋅ 10 L c + C > C ⋅ 10 L a + A ⟹ A ≻ C A \cdot (10^{L_c} - 1) > C \cdot (10^{L_a} - 1) \implies A \cdot 10^{L_c} + C > C \cdot 10^{L_a} + A \implies A \succ C A⋅(10Lc−1)>C⋅(10La−1)⟹A⋅10Lc+C>C⋅10La+A⟹A≻C。
既然传递性成立,该贪心排序必然能够生成唯一的全局严格降序序列,使得拼接结果最大。
4.3 C++ 代码实战
cpp
#include <string>
#include <vector>
#include <algorithm>
class Solution {
public:
string largestNumber(vector<int>& nums) {
vector<string> strs;
for (int x : nums) {
strs.push_back(to_string(x));
}
// 自定义比较规则,根据推导,满足全序关系的传递性
sort(strs.begin(), strs.end(), [](const string& s1, const string& s2) {
return s1 + s2 > s2 + s1;
});
string ret = "";
for (const string& s : strs) {
ret += s;
}
// 边界处理:如果最大的数字是 0,说明全都是 0
if (ret[0] == '0') return "0";
return ret;
}
};
五、 摆动序列 (Medium)
5.1 题目描述
题目链接 :376. 摆动序列
描述 :
相邻数字差值严格在正数和负数之间交替。求最长摆动序列的长度。
例如
[1, 7, 4, 9, 2, 5]是摆动序列。
5.2 贪心策略与数学证明
贪心策略 :
在连续的单调区间内,仅保留极值点(波峰或波谷),跳过所有单调区间内的过渡节点。
严格数学证明(替换论证) :
假设最优摆动子序列 S o p t S_{opt} Sopt 在一段单调递增区间 [ A , B ] [A, B] [A,B] 内,没有选择局部最高点 B B B,而是选择了一个处于半山腰的过渡点 M M M( A ≤ M < B A \le M < B A≤M<B)。
由于处于摆动序列中,序列在 M M M 点之后必然需要转向下降,这意味着下一个选取的点 X X X 必须满足 X < M X < M X<M。
如果我们按照贪心策略,将 M M M 替换为局部最高点 B B B。
因为 B > M B > M B>M,所以满足 X < M X < M X<M 的所有候选点 X X X,必然也满足 X < B X < B X<B 。
即:点 M M M 后续的合法候选集 C M C_M CM 是点 B B B 后续合法候选集 C B C_B CB 的真子集( C M ⊂ C B C_M \subset C_B CM⊂CB)。
用 B B B 替换 M M M 不仅不会破坏摆动性质,反而放宽了后续选择的限制,绝不会缩短总序列长度。
波谷同理。因此,仅选取极值点必然能得到最长长度。
5.3 C++ 代码实战
cpp
class Solution {
public:
int wiggleMaxLength(vector<int>& nums) {
int n = nums.size();
if (n < 2) return n;
int ret = 0;
int left = 0; // 记录前一个区间的趋势差值
for (int i = 0; i < n - 1; i++) {
int right = nums[i + 1] - nums[i];
if (right == 0) continue; // 平地直接跳过
// 极值点判定:当前趋势与上一趋势相反
if (left * right <= 0) {
ret++;
left = right; // 更新趋势
}
}
return ret + 1; // 加上最后一个节点
}
};
六、 最长递增子序列 (Medium)
6.1 题目描述
题目链接 :300. 最长递增子序列
描述 :
找出数组中最长严格递增子序列的长度。要求时间复杂度 O ( N log N ) O(N \log N) O(NlogN)。
6.2 贪心策略与数学证明
贪心策略 :
维护一个数组 d,其中 d[i] 表示长度为 i 的递增子序列中,末尾元素的最小值 。
当遇到新元素 x x x 时:如果 x x x 大于 d 的末尾元素,追加;否则,在 d 中找到第一个大于等于 x x x 的元素并替换它。
严格数学证明:
- 单调性证明 :证明数组
d必然是严格单调递增的。
反证法:假设存在 i i i 使得 d [ i ] ≥ d [ i + 1 ] d[i] \ge d[i+1] d[i]≥d[i+1]。根据定义, d [ i + 1 ] d[i+1] d[i+1] 是长度为 i + 1 i+1 i+1 的递增子序列的最小末尾元素。那么从这个长度为 i + 1 i+1 i+1 的子序列中去掉最后一个元素,必然得到一个长度为 i i i 的严格递增子序列,且其末尾元素 y y y 必定严格小于 d [ i + 1 ] d[i+1] d[i+1]。
由于 y < d [ i + 1 ] y < d[i+1] y<d[i+1] 且 d [ i + 1 ] ≤ d [ i ] d[i+1] \le d[i] d[i+1]≤d[i],得出 y < d [ i ] y < d[i] y<d[i]。这与 d [ i ] d[i] d[i] 是长度为 i i i 序列的"最小"末尾元素相矛盾。故 d d d 数组必然严格递增,可使用二分查找。 - 贪心最优性证明 :
当 d [ k − 1 ] < x ≤ d [ k ] d[k-1] < x \le d[k] d[k−1]<x≤d[k] 时,用 x x x 替换 d [ k ] d[k] d[k]。
替换前,后续能接在 d [ k ] d[k] d[k] 后的元素集合为 S o l d = y ∣ y > d [ k ] S_{old} = {y \mid y > d[k]} Sold=y∣y>d[k]。
替换后,后续能接在 x x x 后的元素集合为 S n e w = y ∣ y > x S_{new} = {y \mid y > x} Snew=y∣y>x。
因为 x ≤ d [ k ] x \le d[k] x≤d[k],显然 S o l d ⊆ S n e w S_{old} \subseteq S_{new} Sold⊆Snew。替换操作使得后续序列生长的门槛降低,合法扩展集合变大,绝不会降低序列的最大长度。贪心策略得证。
生活常识辅助 :
我们要把一队人按身高排成最长。如果长度一样,排在队尾的那个人越矮越好,因为他越矮,后面能接上来的人就越多。
6.3 C++ 代码实战
cpp
class Solution {
public:
int lengthOfLIS(vector<int>& nums) {
// ret 数组即为理论推导中的 d 数组
vector<int> ret;
ret.push_back(nums[0]);
for (int i = 1; i < nums.size(); i++) {
// 如果大于最大元素,直接扩充序列长度
if (nums[i] > ret.back()) {
ret.push_back(nums[i]);
}
// 否则,利用二分查找寻找贪心替换点
else {
int left = 0, right = ret.size() - 1;
while (left < right) {
int mid = left + (right - left) / 2;
if (ret[mid] < nums[i]) {
left = mid + 1;
} else {
right = mid;
}
}
// 贪心替换:降低门槛以扩展后续可接纳集合
ret[left] = nums[i];
}
}
return ret.size();
}
};
七、 总结
💬 复盘 :经过上述严密的数学证明,我们可以确信:贪心并非凭空猜测,而是基于集合论与关系代数的绝对最优。
下一篇,我们将进入 贪心专题(二),继续以严谨的逻辑推导股票买卖等经典问题的最优解模型。敬请期待!