【贪心算法】专题(一):从局部到全局,数学证明下的最优决策

文章目录

    • 在数学严密逻辑下的贪婪法则
    • [一、 前言:贪心算法的核心准则](#一、 前言:贪心算法的核心准则)
    • [二、 柠檬水找零 (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 的元素并替换它。

严格数学证明

  1. 单调性证明 :证明数组 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 数组必然严格递增,可使用二分查找。
  2. 贪心最优性证明
    当 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();
    }
};

七、 总结

💬 复盘 :经过上述严密的数学证明,我们可以确信:贪心并非凭空猜测,而是基于集合论与关系代数的绝对最优。

下一篇,我们将进入 贪心专题(二),继续以严谨的逻辑推导股票买卖等经典问题的最优解模型。敬请期待!

相关推荐
卡梅德生物科技1 小时前
卡梅德生物:ANGPT2(Angiopoietin-2)靶点机制解析与药物研发新趋势
人工智能·面试·学习方法·aav腺病毒·适配体
iAkuya1 小时前
(leetcode)力扣100 92.最小路径和(动态规划)
算法·leetcode·动态规划
shehuiyuelaiyuehao2 小时前
算法5,有效三角形个数
算法·leetcode·排序算法
丶小鱼丶2 小时前
数据结构和算法之【数组】
java·数据结构·算法
承渊政道2 小时前
C++学习之旅【⽤哈希表封装myunordered_map和myunordered_set以及位图和布隆过滤器介绍】
数据结构·c++·学习·哈希算法·散列表·hash-index·图搜索算法
0 0 02 小时前
CCF-CSP 37-4集体锻炼【C++】考点:数学(最大公因数gcd特性),常数优化
开发语言·c++·算法
程序员小明儿2 小时前
量子计算探秘:从零开始的量子编程与算法之旅 · 第三篇
算法·量子计算
开源盛世!!2 小时前
3.9-3.11学习笔记
数据结构·算法
天若有情6732 小时前
【C++实用工具】RandEmmet:致敬Emmet的极简随机数生成器(附完整源码+GitHub)
开发语言·c++·github