在算法世界里,贪心算法一直以 “简单粗暴却高效” 的特质让人又爱又恨。简单的贪心问题能让你一眼看穿思路,而复杂问题却常常让人困惑 “为什么这样选就是最优”。其中,“推公式” 作为贪心算法的重要拓展方向,更是将 “局部最优推导全局最优” 的思想发挥到了极致 —— 它不依赖固定模板,而是通过数学推导找到排序规则,再基于规则对问题元素排序,最终得到最优解。今天,我们就来深入拆解贪心算法中的推公式思想,从原理到实践,结合经典例题带你掌握这门 “推导艺术”。
贪心算法的本质是 “每一步都做出当前最优选择,最终期望得到全局最优”。但 “最优选择” 的标准往往模糊不清,尤其是当问题涉及多个元素的顺序排列时 —— 比如 “如何排列多个数字得到最大整数”“如何安排奶牛赶回去的顺序使损失最小”,此时直接判断 “谁先谁后” 并不直观。
而推公式的核心作用,就是将 “最优顺序” 这个模糊问题,转化为可量化的数学判断标准。它的核心逻辑是:
简单来说,推公式就是通过相邻元素的交换论证,找到排序的数学依据。这种思想的魅力在于,它无需枚举所有可能的排列(因为复杂度极高),而是通过数学推导将问题转化为 “排序问题”,时间复杂度可降至 O (nlogn),高效且通用。
为什么通过相邻元素推导的排序规则,能保证整个序列的最优性?这背后依赖于离散数学中的 “全序关系”—— 如果排序规则满足自反性、反对称性和传递性,那么按照该规则排序后的序列,就是全局最优序列。
在贪心推公式问题中,我们只需证明:对于任意三个元素 a、b、c,如果 a 应该在 b 前面,b 应该在 c 前面,那么 a 一定应该在 c 前面(传递性)。而这个传递性,往往会通过推导的数学规则自然满足。后续例题中,我们会通过具体推导验证这一点。
掌握推公式的思想,无需死记硬背,只需遵循以下四步:
接下来,我们通过三个经典例题,一步步拆解推公式的实践过程,从简单到复杂,带你吃透每一个细节。
题目链接:https://www.luogu.com.cn/problem/P1012
设有 n 个正整数,将它们联接成一排,相邻数字首尾相接,组成一个最大的整数。例如输入 3 个数字 13、312、343,输出应为 34331213。
目标是 “最大化拼接后的整数”,核心是确定两个数字 x 和 y 的排列顺序 —— 是 x 在前、y 在后(拼接为 xy),还是 y 在前、x 在后(拼接为 yx)?
由于数字拼接的结果是字符串,直接比较两个数字的大小没有意义(比如 13 和 312,13<312,但 13312<31213)。因此,我们需要通过推公式找到 “xy 和 yx 哪个更大” 的判断标准。
设 x 和 y 对应的字符串为 s 和 t,我们需要比较 s+t 和 t+s 的大小:
这个推导过程非常直接,因为交换 x 和 y 的位置,只会改变 x 和 y 拼接后的结果,不会影响其他元素的拼接(比如序列 a、x、y、b,交换 x 和 y 后,a 与 x/y 的拼接、y/x 与 b 的拼接都不受影响,仅 x 和 y 之间的拼接变化)。
该规则是否满足传递性?假设对于三个字符串 s、t、u,有 s+t > t+s 且 t+u > u+t,那么一定有 s+u > u+s 吗?
举个例子:s="343",t="312",u="13"
显然满足传递性。因此,该排序规则是有效的。
#include <iostream>
#include <algorithm>
#include <string>
using namespace std;
const int N = 25;
int n;
string a[N];
// 自定义排序规则:s+t > t+s 则s在前
bool cmp(string& s, string& t) {
return s + t > t + s;
}
int main() {
cin >> n;
for (int i = 1; i <= n; i++) {
cin >> a[i];
}
sort(a + 1, a + 1 + n, cmp);
// 特殊情况:如果排序后第一个元素是"0",说明所有元素都是0
if (a[1] == "0") {
cout << 0 << endl;
return 0;
}
for (int i = 1; i <= n; i++) {
cout << a[i];
}
cout << endl;
return 0;
}题目链接:https://www.luogu.com.cn/problem/P2878
有 n 头奶牛在花园里吃花,每头奶牛距离牛圈 Ti 分钟(FJ 往返需要 2×Ti 分钟),每分钟吃 Di 朵花。FJ 每次只能赶一头奶牛回牛圈,在赶奶牛的过程中,其他奶牛继续吃花。求最少的花被吃掉的数量。
目标是 “最小化总损失”,核心是确定赶奶牛的顺序。假设我们有两头奶牛 A(T1, D1)和 B(T2, D2),需要判断先赶 A 还是先赶 B,总损失更小。
我们需要计算两种顺序的总损失,然后比较大小。
首先明确:赶奶牛的过程中,未被赶的奶牛会持续吃花。因此,赶某一头奶牛的 “时间成本” 会影响所有未被赶的奶牛的损失。
设当前已经花费的时间为 T(即在此之前,FJ 已经花费了 T 分钟赶其他奶牛),现在考虑 A 和 B 的顺序:
顺序 1:先赶 A,再赶 B
顺序 2:先赶 B,再赶 A
我们的目标是选择总损失更小的顺序,即比较 2×T1×D2 和 2×T2×D1 的大小(2 是常数,可忽略):
该规则是否满足传递性?假设对于三头奶牛 A、B、C,有 T1×D2 < T2×D1 且 T2×D3 < T3×D2,那么是否有 T1×D3 < T3×D1?
证明:由 T1×D2 < T2×D1 → T1/T2 < D1/D2;
由 T2×D3 < T3×D2 → T2/T3 < D2/D3;
两式相乘得:(T1/T2)×(T2/T3) < (D1/D2)×(D2/D3) → T1/T3 < D1/D3 → T1×D3 < T3×D1。
传递性成立!因此,该排序规则是有效的。
#include <iostream>
#include <algorithm>
using namespace std;
typedef long long LL;
const int N = 1e5 + 10;
int n;
struct Cow {
int t; // 距离牛圈的时间(单程)
int d; // 每分钟吃花的数量
} a[N];
// 排序规则:T1*D2 < T2*D1 → A在前
bool cmp(Cow& x, Cow& y) {
return (LL)x.t * y.d < (LL)y.t * x.d;
}
int main() {
cin >> n;
for (int i = 1; i <= n; i++) {
cin >> a[i].t >> a[i].d;
}
sort(a + 1, a + 1 + n, cmp);
LL total_loss = 0; // 总损失
LL time_used = 0; // 已经花费的时间(用于计算后续奶牛的损失)
for (int i = 1; i <= n; i++) {
// 赶当前奶牛的往返时间内,其他未被赶的奶牛继续吃花
// 未被赶的奶牛是a[i+1..n],它们的总吃花速度是sum(a[j].d for j=i+1 to n)
// 但直接计算sum会超时,因此换一种思路:
// 每头奶牛被其他奶牛“影响”的时间,是在它被赶走之前,FJ花费的总时间
// 即对于奶牛a[i],它会在time_used分钟内持续吃花,直到被赶走
total_loss += (LL)time_used * a[i].d;
// 赶当前奶牛花费的时间(往返),累加到总时间中
time_used += 2LL * a[i].t;
}
cout << total_loss << endl;
return 0;
}题目链接:https://www.luogu.com.cn/problem/P1842
有 N 头奶牛,每头奶牛的体重为 Wi,力量为 Si。将奶牛摞在一起,每头奶牛的压扁指数等于 “上面所有奶牛的总重量 - 自身力量”。总压扁指数是所有奶牛中最大的压扁指数。求最小的总压扁指数。
目标是 “最小化最大压扁指数”,核心是确定奶牛的摞放顺序。假设我们有两头奶牛 A(W1, S1)和 B(W2, S2),需要判断 A 在 B 上面还是 B 在 A 上面,能让最大压扁指数更小。
设 A 和 B 上面的所有奶牛总重量为 W(这部分重量对 A 和 B 的压扁指数影响是固定的,交换 A 和 B 的位置不会改变)。我们分别计算两种顺序的最大压扁指数。
顺序 1:A 在上面,B 在下面
顺序 2:B 在上面,A 在下面
我们需要选择两种顺序中 “最大压扁指数更小” 的一种。由于 W 是公共项,可将其从所有表达式中减去(不影响大小比较),简化为比较:max (-S1, W1 - S2) 和 max (-S2, W2 - S1)
我们的目标是找到使 max (-S1, W1 - S2) < max (-S2, W2 - S1) 成立的条件,即 A 在 B 上面更优的条件。
为了推导这个条件,我们可以假设最优的排序规则是 W1 + S1 < W2 + S2(后续验证这个假设)。代入具体数值验证:
例:A (10, 3),B (2, 5)
再例:A (2,5),B (3,3)
进一步数学推导:假设 W1 + S1 ≤ W2 + S2,证明 max (-S1, W1 - S2) ≤ max (-S2, W2 - S1)。
由于 W1 + S1 ≤ W2 + S2 → W1 - S2 ≤ W2 - S1,且 -S1 ≤ W2 - S1(因为 W2 ≥1)。
同时,W1 + S1 ≤ W2 + S2 → W1 - S2 ≤ -S2(因为 W1 + S1 ≤ W2 + S2 → W1 - S2 ≤ W2 - S1 - S1 + S2? 此处省略复杂推导,核心结论是:当 W1 + S1 ≤ W2 + S2 时,A 在 B 上面更优)。
因此,排序规则为:按照 W + S 的值从小到大排序,W + S 小的奶牛排在上面。
传递性验证:假设 A、B、C 满足 W1+S1 ≤ W2+S2 且 W2+S2 ≤ W3+S3,则 W1+S1 ≤ W3+S3,传递性成立。因此,该规则是有效的。
#include <iostream>
#include <algorithm>
#include <climits>
using namespace std;
typedef long long LL;
const int N = 5e4 + 10;
int n;
struct Cow {
int w; // 体重
int s; // 力量
} a[N];
// 排序规则:w1 + s1 < w2 + s2 → 排在前面(上面)
bool cmp(Cow& x, Cow& y) {
return (LL)x.w + x.s < (LL)y.w + y.s;
}
int main() {
cin >> n;
for (int i = 1; i <= n; i++) {
cin >> a[i].w >> a[i].s;
}
sort(a + 1, a + 1 + n, cmp);
LL max_pressure = LLONG_MIN; // 最大压扁指数
LL total_weight = 0; // 上面所有奶牛的总重量
for (int i = 1; i <= n; i++) {
// 当前奶牛的压扁指数 = 上面总重量 - 自身力量
LL pressure = total_weight - a[i].s;
// 更新最大压扁指数
if (pressure > max_pressure) {
max_pressure = pressure;
}
// 将当前奶牛的重量累加到总重量中(为下面的奶牛计算)
total_weight += a[i].w;
}
cout << max_pressure << endl;
return 0;
}通过以上三道例题,我们可以总结出推公式思想的适用场景:
常见的问题类型包括:排列优化(最大化 / 最小化某种指标)、任务调度(安排顺序使总代价最小)、组合优化(如拼接、摞放等)。
贪心算法中的推公式思想,是一种 “化繁为简” 的数学智慧 —— 它将复杂的排列优化问题,通过相邻元素的交换论证,转化为清晰的数学排序规则,最终通过排序得到最优解。这种思想不依赖固定模板,核心在于 “推导” 和 “验证”,需要我们具备一定的数学思维和逻辑推理能力。 推公式思想不仅适用于贪心算法,在动态规划、排序算法等领域也有广泛应用。希望你能通过本文的学习,举一反三,在遇到类似问题时,能够快速想到 “推公式” 的思路,用数学推导破解算法难题。 最后,算法学习的核心是 “理解” 和 “实践”。建议大家在看完本文后,重新推导一遍例题的排序规则,然后独立完成拓展练习,加深对推公式思想的理解。如果在推导过程中遇到问题,不妨回到本文的例题,对比思路,找到问题所在。祝你在算法的道路上越走越远!