文章目录
-
- 在杂乱无章中建立绝对优势
- [一、 前言:贪心与排序的羁绊](#一、 前言:贪心与排序的羁绊)
- [二、 按身高排序 (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++ 代码实战)
- [四、 最长回文串 (Easy)](#四、 最长回文串 (Easy))
-
- [4.1 题目描述](#4.1 题目描述)
- [4.2 贪心策略与数学证明](#4.2 贪心策略与数学证明)
- [4.3 C++ 代码实战](#4.3 C++ 代码实战)
- [五、 增减字符串匹配 (Easy)](#五、 增减字符串匹配 (Easy))
-
- [5.1 题目描述](#5.1 题目描述)
- [5.2 贪心策略与边界逼近证明](#5.2 贪心策略与边界逼近证明)
- [5.3 C++ 代码实战](#5.3 C++ 代码实战)
- [六、 分发饼干 (Easy)](#六、 分发饼干 (Easy))
-
- [6.1 题目描述](#6.1 题目描述)
- [6.2 贪心策略与数学证明](#6.2 贪心策略与数学证明)
- [6.3 C++ 代码实战](#6.3 C++ 代码实战)
- [七、 总结](#七、 总结)
在杂乱无章中建立绝对优势
一、 前言:贪心与排序的羁绊
💬 开篇:很多时候,数组初始的状态杂乱无章,你根本无从下手去"贪"。
🚀 核心破局点 :排序是贪心的前置装甲!
无论是给身高排队,还是田忌赛马分配战力,亦或是给胃口不同的孩子发饼干,一旦数据变得有序,局部的贪心选择就能顺理成章地滚动为全局最优。
💡 学习指南 :
本篇的 5 道题,每一道都深刻依赖于排序或索引映射。特别是《优势洗牌(田忌赛马)》,我们将用严格的博弈论视角,证明为什么"打不过就送死"是绝对正确的策略。
二、 按身高排序 (Easy)
2.1 题目描述
题目链接 :2418. 按身高排序
描述 :
给你一个字符串数组
names,和一个正整数数组heights。两者一一对应。请按身高 降序 返回对应的名字数组
names。
2.2 索引排序
注意:这道题和贪心没啥关系,但它涉及到索引排序,这是一个非常常见的方法,也是下一道题的前置知识
业务痛点 :
如果要排序 heights,数组里面的元素位置会被打乱,那你怎么知道打乱后的身高原本对应哪个名字?(不能把人名和身高拆散)。
解决方案(索引排序大法) :
不直接动原数组,我们创建一个"名片夹"(索引数组 index:[0, 1, 2, ... n-1])。
然后我们对"名片夹"进行排序,比较的规则是:谁对应的高度高,谁的索引就排在前面 。
排完序后,index[0] 存的就是最高的人的原下标。顺着 index 数组去拿 names 即可。
2.3 C++ 代码实战
cpp
#include <vector>
#include <string>
#include <numeric>
#include <algorithm>
class Solution {
public:
vector<string> sortPeople(vector<string>& names, vector<int>& heights) {
int n = names.size();
// 1. 创建一个索引数组
vector<int> index(n);
// 使用 iota 快速填充 0, 1, 2... n-1
iota(index.begin(), index.end(), 0);
// 2. 根据 heights 的大小对 index 数组进行降序排序
sort(index.begin(), index.end(), [&](int i, int j) {
// 注意:这里比较的是原数组 heights 中的值
return heights[i] > heights[j];
});
// 3. 根据排好序的索引提取最终的名字
vector<string> ret;
for (int i : index) {
ret.push_back(names[i]);
}
return ret;
}
};
三、 优势洗牌(田忌赛马)(Medium)
3.1 题目描述
题目链接 :870. 优势洗牌
描述 :
给定两个长度相等的数组
nums1和nums2。重新排列nums1,使得nums1[i] > nums2[i]的数量最大化。
3.2 贪心策略与博弈论证明
贪心策略(田忌赛马):
-
把己方马(
nums1)和敌方马(nums2)分别按战斗力从小到大排序。 -
拿己方最弱的马 去挑战敌方最弱的马:
- 如果打得过(己方最弱 > 敌方最弱):直接上!赢下一局。
- 如果打不过 (己方最弱 <= 敌方最弱):既然我最弱的马连你最弱的都打不过,那它肯定是炮灰。炮灰的价值在于消耗敌方最强的马!所以把它丢去和敌方目前最强的马对阵。
严格博弈论证明 :
假设己方最弱的马为 A m i n A_{min} Amin,敌方最弱的马为 B m i n B_{min} Bmin,敌方最强的马为 B m a x B_{max} Bmax。
- 情况一: A m i n > B m i n A_{min} > B_{min} Amin>Bmin
此时 A m i n A_{min} Amin 能够战胜 B m i n B_{min} Bmin。如果不让 A m i n A_{min} Amin 打 B m i n B_{min} Bmin,而是让更强的 A k A_k Ak 去打 B m i n B_{min} Bmin,虽然也能赢,但造成了"战力溢出浪费",削弱了己方对阵敌方更强马匹的能力。因此 A m i n A_{min} Amin 对阵 B m i n B_{min} Bmin 是代价最小的得分方式。 - 情况二: A m i n ≤ B m i n A_{min} \le B_{min} Amin≤Bmin
此时己方最弱的马 A m i n A_{min} Amin 无法战胜敌方任何 一匹马(因为它连敌方最弱的都打不过)。
既然 A m i n A_{min} Amin 必定送一分,那它送给谁最划算?
如果送给 B m i n B_{min} Bmin,敌方的王牌 B m a x B_{max} Bmax 依然保留,会吃掉己方一匹强马。
如果送给 B m a x B_{max} Bmax,敌方的王牌被消耗,己方的强马得以保全,去迎战敌方中等马,胜率大增。
因此,用必败的 A m i n A_{min} Amin 兑子 B m a x B_{max} Bmax 是绝对的最优决策。
(注:因为 nums2 原本的顺序不能乱,返回答案要和 nums2 原顺序对应,所以我们要对 nums2 进行索引排序,就像上一题一样!)
3.3 C++ 代码实战
cpp
class Solution {
public:
vector<int> advantageCount(vector<int>& nums1, vector<int>& nums2) {
int n = nums1.size();
// 1. 己方马匹按战斗力从小到大排序
sort(nums1.begin(), nums1.end());
// 2. 敌方马匹不能改变物理顺序,我们对它的索引进行排序
vector<int> index2(n);
iota(index2.begin(), index2.end(), 0);
sort(index2.begin(), index2.end(), [&](int i, int j) {
return nums2[i] < nums2[j];
});
vector<int> ret(n);
// 双指针指向敌方排序后的最弱马(left)和最强马(right)
int left = 0, right = n - 1;
// 遍历己方的马 (从最弱的开始)
for (int x : nums1) {
// 如果己方当前最弱马能打过敌方当前最弱马
if (x > nums2[index2[left]]) {
// 安排对决,记录答案到对应位置,敌方弱马指针后移
ret[index2[left++]] = x;
}
// 如果打不过
else {
// 当炮灰去消耗敌方当前的最强马,敌方强马指针前移
ret[index2[right--]] = x;
}
}
return ret;
}
};
四、 最长回文串 (Easy)
4.1 题目描述
题目链接 :409. 最长回文串
描述 :
给定一个包含大写和小写字母的字符串
s。用这些字母构造一个最长的回文串,返回长度(注意区分大小写)。
4.2 贪心策略与数学证明
贪心策略 :
回文串具有绝对的对称性。
- 对于出现次数为偶数次的字母,全部放进去,左右对称。
- 对于出现次数为奇数次的字母(假设出现了 k k k 次),我们拿走 k − 1 k-1 k−1 个(变成了偶数),全部放进去对称。剩下的 1 个只能忍痛丢弃。
- 点睛之笔 :回文串的正中心,可以容纳唯一一个没有配对的单身狗字母!只要我们丢弃过任何奇数次字母,我们就可以最后把总长度加 1。
证明(平凡) :
要使长度最大,必须最大限度利用成对的字符。对任一字符统计量 x x x, x / 2 × 2 x / 2 \times 2 x/2×2 即为其能提供的最大成对长度。中心孤立字符位至多 1 个。因此 S u m ( x / 2 × 2 ) + [ 是否存在奇数次频次 ? 1 : 0 ] Sum(x/2 \times 2) + [是否存在奇数次频次 ? 1 : 0] Sum(x/2×2)+[是否存在奇数次频次?1:0] 必然是理论最大上界。
4.3 C++ 代码实战
cpp
class Solution {
public:
int longestPalindrome(string s) {
// 1. 计数:用大小为 128 的数组模拟哈希表,统计字符频率
int hash[128] = {0};
for (char ch : s) {
hash[ch]++;
}
// 2. 统计能配对的长度
int ret = 0;
for (int count : hash) {
// 无论 count 是奇是偶,除以 2 乘 2 都能抹平奇数的那 1 个零头,取到最大的偶数部分
ret += count / 2 * 2;
}
// 3. 判断中心位:如果拼接后的长度小于原字符串总长度,
// 说明一定有字符因为是奇数次而被剩下了。我们挑一个放在正中心!
if (ret < s.size()) {
return ret + 1;
}
return ret;
}
};
五、 增减字符串匹配 (Easy)
5.1 题目描述
题目链接 :942. 增减字符串匹配
描述 :
范围
[0, n]内的所有整数重构排列perm,满足:若
s[i] == 'I',则perm[i] < perm[i+1](上升)若
s[i] == 'D',则perm[i] > perm[i+1](下降)返回任意一个合法排列。
5.2 贪心策略与边界逼近证明
这是一道非常优美的贪心题。
贪心策略 :
我们手里的数字牌是 [0, 1, 2 ... n]。
- 遇到
'I'(要求上升):为了让后面的人更容易 比我高,我必须放低姿态。贪心点:我直接出当前手里最小的牌! - 遇到
'D'(要求下降):为了让后面的人更容易 比我矮,我必须拔高标准。贪心点:我直接出当前手里最大的牌!
严格数学证明 :
设当前可用数字集合为 [ L , R ] [L, R] [L,R]。
- 若指令为
'I',选择最小值 L L L。此后下一次不论指令是啥,需要在剩余集合 [ L + 1 , R ] [L+1, R] [L+1,R] 中选一个数 X X X。显然 ∀ X ∈ [ L + 1 , R ] , L < X \forall X \in [L+1, R], L < X ∀X∈[L+1,R],L<X 恒成立,上升关系完美达成。 - 若指令为
'D',选择最大值 R R R。此后需要在剩余集合 [ L , R − 1 ] [L, R-1] [L,R−1] 中选一个数 Y Y Y。显然 ∀ Y ∈ [ L , R − 1 ] , R > Y \forall Y \in [L, R-1], R > Y ∀Y∈[L,R−1],R>Y 恒成立,下降关系完美达成。
这种两端向内逼近(双指针)的策略,能保证每一步构造都必定符合指令且不发生死锁,因此必定合法。
5.3 C++ 代码实战
cpp
class Solution {
public:
vector<int> diStringMatch(string s) {
int n = s.size();
int left = 0; // 当前可用的最小数字
int right = n; // 当前可用的最大数字
vector<int> ret;
for (char ch : s) {
if (ch == 'I') {
// 要求上升,填入最小值,为后续留出空间
ret.push_back(left++);
} else {
// 要求下降,填入最大值,为后续留出空间
ret.push_back(right--);
}
}
// 循环结束后,left 和 right 会相遇,剩下最后唯一的一张牌,塞进去
ret.push_back(left);
return ret;
}
};
六、 分发饼干 (Easy)
6.1 题目描述
题目链接 :455. 分发饼干
描述 :
g数组是孩子们的胃口,s数组是饼干的尺寸。一块饼干只能给一个孩子,且
s[j] >= g[i]时孩子才能满足。求最多满足几个孩子。
6.2 贪心策略与数学证明
贪心策略:
-
把孩子胃口和饼干尺寸都从小到大排序。
-
双指针遍历:优先用刚好能满足的小饼干去喂胃口最小的孩子。
- 如果当前饼干满足不了当前孩子,那这个小饼干就是个废物(连胃口最小的孩子都喂不饱,更别想喂别人了),直接扔掉看下一个饼干。
严格数学证明(反证法) :
设胃口最小的孩子为 g 1 g_1 g1,能够满足他的所有饼干中,尺寸最小的为 s 1 s_1 s1(即 s 1 ≥ g 1 s_1 \ge g_1 s1≥g1)。
假设存在一种全局最优分配方案,把 s 1 s_1 s1 喂给了胃口更大的孩子 g x g_x gx,而把更大的饼干 s x s_x sx 喂给了 g 1 g_1 g1。
因为 s x > s 1 ≥ g 1 s_x > s_1 \ge g_1 sx>s1≥g1 且 s 1 ≥ g x s_1 \ge g_x s1≥gx。
我们在该最优方案中,强行交换饼干 s 1 s_1 s1 和 s x s_x sx 的归属。
交换后, g 1 g_1 g1 拿到 s 1 s_1 s1(由于 s 1 ≥ g 1 s_1 \ge g_1 s1≥g1,满足), g x g_x gx 拿到 s x s_x sx(由于 s x > s 1 ≥ g x s_x > s_1 \ge g_x sx>s1≥gx,也满足)。
这就证明了:把最小且适用的饼干喂给胃口最小的孩子,绝不会破坏最优解的达成,且能最大限度保留大块饼干应对未来大胃口孩子。因此贪心策略成立。
6.3 C++ 代码实战
cpp
class Solution {
public:
int findContentChildren(vector<int>& g, vector<int>& s) {
// 1. 双双排序,是贪心匹配的前提
sort(g.begin(), g.end());
sort(s.begin(), s.end());
int ret = 0;
int m = g.size(), n = s.size();
// 2. 双指针匹配
// i 指向孩子,j 指向饼干
for (int i = 0, j = 0; i < m && j < n; i++, j++) {
// 如果当前的饼干 j 满足不了当前的孩子 i
// 说明饼干太小了,找下一块更大的饼干
while (j < n && s[j] < g[i]) {
j++;
}
// 只要饼干没找越界,说明找到了一块合适的,喂给他!
if (j < n) {
ret++;
}
}
return ret;
}
};
七、 总结
💬 复盘 :在本篇的 5 道题中,排序成为了无可争议的主角。
- 原顺序不可破时:采用索引排序(按身高排序、田忌赛马),这是一种极高阶的数据处理技巧。
- 田忌赛马博弈论:能赢就赢,不能赢就拿最弱的去极限一换一。
- 双指针贪心匹配:增减序列的两端逼近,分发饼干的从小到大匹配,都是证明了"绝不浪费资源"就能达到最优。
在下一篇 贪心专题(四) 中,我们将直面回溯与贪心的交汇点------跳跃游戏系列。准备好看看贪心是如何通过覆盖区间来秒杀动态规划的吧!🚀