数据结构与算法(六) 贪心算法

简介: 数据结构与算法(六) 贪心算法

前言


这篇文章来讲贪心算法(Greedy Algorithm),这是一种特殊的动态规划算法


正文


1、本质


我们在之前的文章中讲过,动态规划可以解决一类具有最优子结构和重叠子问题特征的问题


贪心算法本质上是一种特殊的动态规划算法,因此在看这篇文章前,建议先看之前动态规划的文章



回到正题,先来简单回顾下动态规划,动态规划的核心就是状态转移方程


无论是自上而下利用递归的解题思路,还是自下而上利用递推的解题思路,都可以用状态转移方程串联起来


状态转移方程的关键就是怎么根据较小规模的问题推导出较大规模的问题


在每一步推导的过程中,其实就是在做一件事情,即在当前状态下,尝试所有选择,到达新的状态


然后不断重复这个过程,从已知状态开始直至待求状态结束



跳跃游戏 II | leetcode45

给定一个非负整数数组,初始位于数组的第一个下标,数组中的元素代表所在位置可以跳跃的最大长度

假设一定可以到达数组最后一个位置,问最少需要跳多少次才能到达


为了更好地理解这一过程,这里以跳跃游戏为例,假设给定数组 [2, 3, 1, 1, 4],画出递归树如下:

vv.png

注意到这个问题是满足最优子结构和重叠子问题的,因此必定可以用动态规划来解

这里以自上而下的解题思路为例给出代码

class Solution {
public:
    vector<int> memo;
    // 动态规划
    int dp(vector<int>& nums, int pos) {
        int n = nums.size();
        // 边界条件
        if (pos >= n - 1) {
            return 0;
        }
        // 查备忘录
        if (memo[pos] != n) {
            return memo[pos];
        }
        // 尝试所有选择,优化点
        // 写备忘录
        for (int i = 1; i <= nums[pos]; i++) {
            memo[pos] = min( memo[pos], dp(nums, pos + i) + 1 );
        }
        // 返回
        return memo[pos];
    }
    int jump(vector<int>& nums) {
        int n = nums.size();
        memo = vector<int>(n, n); // 初始化备忘录
        return dp(nums, 0);
    }
};


动态规划在每一步的推导过程中,都要尝试所有选择,这是因为在某些问题中无法判断哪个选择是最优选择


但是贪心算法无需尝试所有选择,而是可以通过推导得出当前最优解,并且局部最优解可以到达全局最优解


假设现在站在索引 0 的位置上,其可跳长度为 2,那么可能的选择有:跳到索引 1 的位置上、跳到索引 2 的位置上。动态规划的思路是,我不知道这两种做法哪种好,所以我就两种都试一下。而贪心算法建立在更强的约束上,在面临多个选择时,能判断出当前最优解是什么。回到上述例子,索引 1 位置的可跳长度是 3,如果当前选择跳到索引 1,那么下一步最远可以跳到索引 4;同理,索引 2 位置的可跳长度是 1,如果当前选择跳到索引 2,那么下一步最远可以跳到索引 3。明显当前跳到索引 1 是优于跳到索引 2 的,因为下一步可跳位置的选择更多,因此贪心算法就会直接选择跳到索引 1,而不会再去尝试跳到索引 2


class Solution {
public:
    // 无需递归
    int jump(vector<int>& nums) {
        int n = nums.size();
        int pos = 0; // 当前位置
        int ans = 0; // 当前步数
        // 进主流程
        while (pos < n - 1) {
            int nxtPos = 0; // 下一位置
            int maxPos = 0; // 最远位置
            // 直接判断最优位置
            // 无需尝试所有选择
            for (int i = 0; i <= nums[pos]; i++) {
                if (pos + i >= n - 1) {
                    nxtPos = pos + i;
                    break;
                }
                if (maxPos < pos + i + nums[pos + i]) {
                    maxPos = pos + i + nums[pos + i];
                    nxtPos = pos + i;
                }
            }
            pos = nxtPos;
            ans = ans + 1;
        }
        // 返回
        return ans;
    }
};


2、核心


贪心算法是一种特殊的动态规划算法,它的约束更强,适用场景更少,但一般来说具有更高的效率


这是因为动态规划需要尝试所有选择,而贪心算法则可以通过推导直接选择当前最优解



贪心算法所解决的问题除了要满足最优子结构外,更重要的是要满足贪心选择性质

所谓的贪心选择性质就是说每次选择仅依赖于之前的选择,而不依赖于后续的选择


理解这个性质很关键,这是贪心算法的核心


在每一步推导过程中,贪心算法在当前状态下做局部最优解以到达下一状态,直至到达待求的问题


对于一个具体的问题,使用贪心算法前必须要证明每一步推导所做的局部最优解能到达全局最优解



凑零钱问题 | leetcode322

给定一个整数数组 coins 表示不同面额的硬币,以及一个整数 amount 表示总金额

假设每种硬币的数量是无限的,问凑成总金额所需的最少的硬币个数,若无法凑成,则返回 -1


注意,如果盲目使用贪心算法,可能取不到全局最优解,这里以凑零钱问题为例说明这种情况


对于上述问题,使用贪心算法的一个直观思路是:每次选择面额最大的硬币,直至凑满总金额


这对于普通的面额分布确实是可行的,例如 coins = [1, 5, 10], amount = 13


但对于特殊的面额分布就会出现问题,例如 coins = [1, 4, 5 ], amount = 13


如果使用贪心算法,那么选择的硬币依次是 5、5、1、1、1,但最优选择方式却是 5、4、4


3、框架


贪心算法的框架很简单,总结起来就是一句话:在推导过程中,每次都选择当前最优解

只不过在使用贪心算法时需要特别注意,局部最优解必须能到达全局最优解


4、例题


(1)无重叠区间 | leetcode435


给定一个区间集合,返回移除区间的最小数量,使得剩余的区间互不重叠


基本思路

题目要求移除最少数量的区间使得剩余的区间互不重叠

可转换为选择最多数量的区间使得选择的区间互不重叠

根据贪心算法的思路,每次选择右端点最小且不与先前区间重叠的区间即可

这是因为同样是增加一个区间,如果选择右端点较小的区间,那么以后选择的可能性更多

class Solution {
public:
    int eraseOverlapIntervals(vector<vector<int>>& intervals) {
        // 特判
        int n = intervals.size();
        if (n <= 0) {
            return 0;
        }
        // 预处理,为了提高效率
        // 按右端点从小到大排序
        sort(intervals.begin(), intervals.end(), [](const auto& u, const auto& v) {
            return u[1] < v[1];
        });
        // 每次选择右端点最小且不与先前区间重叠的区间
        int ans = 1;
        int end = intervals[0][1];
        for (int i = 1; i < n; i++) {
            // 因为数组已提前排序,所以按顺序遍历就能选择右端点最小的区间
            // 然后只需保证当前区间与先前区间无重叠,也即当前区间的左端点大于等于先前区间的右端点
            if (intervals[i][0] >= end) {
                ans = ans + 1;
                end = intervals[i][1];
            }
        }
        // 返回结果
        return n - ans;
    }
};


(2)单调递增的数字 | leetcode738


给定一个整数,返回小于等于这个数字的最大数字,使得返回的数字是单调递增的


基本思路


从折线图的角度来看,从左往后找第一个谷顶,将这个数字减去一,将后面数字改为九

如:13542 -> 13499


若第一个谷顶是平的,则找谷顶最左边的位置,将这个数字减去一,将后面数字改为九

如:15542 -> 14999


class Solution {
public:
    int monotoneIncreasingDigits(int n) {
        if (n < 10) {
            return n;
        }
        string s = std::to_string(n);
        int m = s.size();
        int i = 0;
        while (i < m - 1 && s[i] <= s[i + 1]) {
            i++;
        }
        if (i == m - 1) {
            return n;
        }
        while (i > 0 && s[i] == s[i - 1]) {
            i--;
        }
        s[i] = s[i] - 1;
        i++;
        while (i < m) {
            s[i] = '9';
            i++;
        }
        return stoi(s);
    }
};

目录
相关文章
|
1月前
|
存储 算法 索引
【算法与数据结构】队列的实现详解
【算法与数据结构】队列的实现详解
159 0
|
2天前
|
机器学习/深度学习 存储 算法
数据结构与算法 动态规划(启发式搜索、遗传算法、强化学习待完善)
数据结构与算法 动态规划(启发式搜索、遗传算法、强化学习待完善)
9 1
|
6天前
|
算法 调度 C语言
算法--贪心算法
贪心算法是每次选择当前最优解,期望达到全局最优,适用于有最优子结构的问题。它不回退,与动态规划不同。适用于分数背包问题,但0-1背包问题可能无法保证最优解。常见应用包括找零、最小生成树、单源最短路径和任务调度。贪心算法步骤包括建模、分问题、求局部最优解及整合。尽管简单,但需谨慎评估是否适用,例如0-1背包问题可能需用动态规划求解。
|
6天前
|
搜索推荐 C语言
【C语言/数据结构】排序(归并排序|计数排序|排序算法复杂度)
【C语言/数据结构】排序(归并排序|计数排序|排序算法复杂度)
11 0
|
6天前
|
存储 算法
Leetcode 30天高效刷数据结构和算法 Day1 两数之和 —— 无序数组
给定一个无序整数数组和目标值,找出数组中和为目标值的两个数的下标。要求不重复且可按任意顺序返回。示例:输入nums = [2,7,11,15], target = 9,输出[0,1]。暴力解法时间复杂度O(n²),优化解法利用哈希表实现,时间复杂度O(n)。
18 0
|
13天前
|
存储 机器学习/深度学习 算法
|
17天前
|
存储 算法 Python
程序设计的艺术:算法与数据结构的魅力
程序设计的艺术:算法与数据结构的魅力
8 0
|
17天前
|
存储 算法
数据结构开篇(普普通通浅浅聊数据结构)什么是数据结构 、什么是算法、重要性、如何学好数据结构呢
数据结构开篇(普普通通浅浅聊数据结构)什么是数据结构 、什么是算法、重要性、如何学好数据结构呢
|
24天前
|
算法 程序员
贪心算法(被黑心公司强迫写的降薪算法)
贪心算法(被黑心公司强迫写的降薪算法)
|
24天前
|
存储 人工智能 算法
有哪些数据结构与算法是程序员必须要掌握的?——“数据结构与算法”
有哪些数据结构与算法是程序员必须要掌握的?——“数据结构与算法”