【贪心算法】专题(三):排序、博弈与区间的贪婪法则

文章目录

    • 在杂乱无章中建立绝对优势
    • [一、 前言:贪心与排序的羁绊](#一、 前言:贪心与排序的羁绊)
    • [二、 按身高排序 (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. 优势洗牌

描述

给定两个长度相等的数组 nums1nums2。重新排列 nums1,使得 nums1[i] > nums2[i] 的数量最大化。

3.2 贪心策略与博弈论证明

贪心策略(田忌赛马)

  1. 把己方马(nums1)和敌方马(nums2)分别按战斗力从小到大排序。

  2. 拿己方最弱的马 去挑战敌方最弱的马

    • 如果打得过(己方最弱 > 敌方最弱):直接上!赢下一局。
    • 如果打不过 (己方最弱 <= 敌方最弱):既然我最弱的马连你最弱的都打不过,那它肯定是炮灰。炮灰的价值在于消耗敌方最强的马!所以把它丢去和敌方目前最强的马对阵。

严格博弈论证明

假设己方最弱的马为 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 贪心策略与数学证明

贪心策略

回文串具有绝对的对称性。

  1. 对于出现次数为偶数次的字母,全部放进去,左右对称。
  2. 对于出现次数为奇数次的字母(假设出现了 k k k 次),我们拿走 k − 1 k-1 k−1 个(变成了偶数),全部放进去对称。剩下的 1 个只能忍痛丢弃。
  3. 点睛之笔 :回文串的正中心,可以容纳唯一一个没有配对的单身狗字母!只要我们丢弃过任何奇数次字母,我们就可以最后把总长度加 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 贪心策略与数学证明

贪心策略

  1. 把孩子胃口和饼干尺寸都从小到大排序。

  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 道题中,排序成为了无可争议的主角。

  1. 原顺序不可破时:采用索引排序(按身高排序、田忌赛马),这是一种极高阶的数据处理技巧。
  2. 田忌赛马博弈论:能赢就赢,不能赢就拿最弱的去极限一换一。
  3. 双指针贪心匹配:增减序列的两端逼近,分发饼干的从小到大匹配,都是证明了"绝不浪费资源"就能达到最优。

在下一篇 贪心专题(四) 中,我们将直面回溯与贪心的交汇点------跳跃游戏系列。准备好看看贪心是如何通过覆盖区间来秒杀动态规划的吧!🚀

相关推荐
Sakinol#2 小时前
Leetcode Hot 100 —— 二叉树 part02
算法·leetcode
IT19952 小时前
C++工作笔记-动态库中的单例类存储方式
开发语言·c++·笔记
N1_WEB2 小时前
HDU:杭电 2017 复试真题汇总
算法
努力学算法的蒟蒻2 小时前
day111(3.13)——leetcode面试经典150
算法·leetcode·面试
参.商.2 小时前
【Day37】94.二叉树的中序遍历 递归+迭代遍历
leetcode·golang
爱学习的小囧2 小时前
VCF 9.0 操作对象与指标报告自动化教程
运维·服务器·算法·自动化·vmware·虚拟化
嫂子开门我是_我哥2 小时前
心电域泛化研究从0入门系列 | 第四篇:域泛化核心理论与主流方法——破解心电AI跨域失效难题
人工智能·算法·机器学习
Olivia_su2 小时前
数据分析及可视化Tableau自学入门
算法·数据分析·tableau
Sakinol#2 小时前
Leetcode Hot 100 —— 矩阵
leetcode·矩阵