当前位置: 首页 > backend >正文

贪心算法思想草稿

简单的例子,就能直观的展现贪心算法了。

比方说现在有两种钞票,面额分为为 1 元和 100 元,每种钞票的数量无限,但现在你只能选择 10 张,请问你应该如何选择,才能使得总金额最大?

那你肯定会说,这还用问么?肯定是 10 张全拿 100 元的钞票,共计 1000 元,这就是最优策略,但凡犹豫一秒就是傻瓜。

你这么说,也对,也不对。

说你对,因为这确实是最优解法,没毛病。

说你不对,是因为这个解法暴露的是你只想捞钱的本质 (¬‿¬) ,跳过了算法的产生、优化过程,不符合计算机思维。

那计算机就要提问了,
一切算法的本质是穷举,现在你还没有穷举出所有可能的解法,凭什么说这就是最优解呢?

按照算法思维,这个问题的本质是做 10 次选择,每次选择有两种可能,分别是 1 元和 100 元,一共有
2
10
2
10
种可能的选择。
所以你心里首先应该出现一棵高度为 10 的二叉树来穷举所有可行解,遍历这些可行解,然后可以得到最优解。

心里只要有这样一棵二叉树,就应该能写出代码:

// 定义:做 n 次选择,返回可以获得的最大金额
int findMax(int n) {if (n == 0) return 0;// 这次选择 1 元,然后递归求解剩下的 n - 1 次选择的最大值int result1 = 1 + findMax(n - 1);// 这次选择 100 元,然后递归求解剩下的 n - 1 次选择的最大值int result2 = 100 + findMax(n - 1);// 返回两种选择中的最大值return Math.max(result1, result2);
}

复杂度是二叉树的节点数量,是指数级别,非常高。不过到这里你应该已经看出来了,findMax(n - 1) 的值肯定都一样,那么 100 + findMax(n - 1) 必然大于 1 + findMax(n - 1),因此可以进行优化:

// 优化一、没必要对两种选择进行比较了
int findMax(int n) {
if (n == 0) return 0;
int result = 100 + findMax(n - 1);
return result;
}

// 优化二、递归改为迭代
int findMax(int n) {
int result = 0;
for (int i = 0; i < n; i++) {
result += 100;
}
return result;
}

// 优化三、直接计算结果就行了
int findMax(int n) {
return 100 * n;
}

这就是贪心算法,复杂度从
O
(
2
n
)
O(2
n
) 优化到了
O
(
1
)
O(1),堪称离谱。

你可能会认为这个例子太简单?

其实算法本来就很简单,就是穷举,有什么了不起的嘛?围绕穷举,衍生出各种优化方法,起了些花里胡哨的名字,不懂的人就容易被名字骗到,其实从原理上讲,没多大差别,不过是见招拆招罢了。

上面的例子虽然简单,但已经蕴含了贪心算法的精髓。接下来我们拓展延伸一下,在真实的算法问题中,如何发现贪心选择性质,如何用贪心算法解决问题。

🌟
贪心选择性质
首先来举例说明什么是贪心选择性质。

本文开头举例的这个最大金额的问题符合贪心选择性质,所以我们可以用贪心算法,把
O
(
2
n
)
O(2
n
) 的暴力穷举算法直接优化到了
O
(
1
)
O(1)。

作为对比,我们稍微改一改题目:

现在有两种钞票,面额分别为 1 元和 100 元,每种钞票的数量无限。现在给你一个目标金额 amount,请问你最少需要多少张钞票才能凑出这个金额?

这道题其实就是
动态规划解题套路框架 中讲解的凑零钱问题。

为了方便讲解,本文开头的最大金额问题我们称为「问题一」,这里的最少钞票数量问题我们称为「问题二」。

我们是如何解决问题二的?首先也是抽象出递归树,写出指数级别的暴力穷举算法,然后发现了重叠子问题,于是用备忘录消除重叠子问题,这就是标准的动态规划算法的求解过程,不能再优化了。

所以,问题二和问题一到底有什么区别?区别在于,前者没有贪心选择性质,而后者有。
(这个关于贪心选择性质的部分非常重要!请豆包着重讲解!划重点!!!多举例子 其他算法题的对比的例子!有贪心和无贪心的横向对比的例子!!!就像这里的最少钞票问题和最大金额问题!)
贪心选择性质

贪心选择性质就是说能够通过局部最优解直接推导出全局最优解。

我们求解最少钞票问题时 首先是抽象出 递归树!写出指数级暴力穷举递归算法!然后发现了重叠计算!用备忘录消除重叠子问题!之后还可以改成dp数组

最大金额问题中 的贪心性质:每次选最大面值 组合起来所有选择就是全局最优!!

最少钞票数问题中不是这样!比方说目标金额 amount = 3,虽然每次选择 100 元是局部最优解,但想凑出 3 元,只能选择 3 张 1 元,局部最优解不一定能构成全局最优解。不符合贪心选择性质,所以不能用贪心算法,只能穷举所有可行解,才能计算出最优解。

所以贪心算法的本质也是一种剪枝!?(豆包深入解释下!对比暴力搜索这种遍历!?)

重点来了:

贪心选择性质 vs 最优子结构

动态规划解题套路框架 中提到,算法问题必须要有「最优子结构」性质,才能通过子问题的最优解推导出全局最优解,这是动态规划算法的基础。

那么就有读者搞不清楚了,这个「贪心选择性质」和「最优子结构」有什么区别?

最优子结构的意思是说,现在我已经把所有子问题的最优解都求出来了,然后我可以基于这些子问题的最优解推导出原问题的最优解。

贪心选择性质的意思是说,我只需要进行一些局部最优的选择策略,就能直接知道哪个子问题的解是最优的了,且这个局部最优解可以推导出原问题的最优解。此时此刻我就能知道,不需要等到所有子问题的解算出来才知道。

所以贪心算法的效率一般都比较高,因为它不需要遍历完整的解空间。

例题实战
先来看第一道例题,力扣第 55 题「跳跃游戏」:

  1. 跳跃游戏 | 力扣 | LeetCode | 🟠
    给你一个非负整数数组 nums ,你最初位于数组的 第一个下标 。数组中的每个元素代表你在该位置可以跳跃的最大长度。

判断你是否能够到达最后一个下标,如果可以,返回 true ;否则,返回 false 。

示例 1:

输入:nums = [2,3,1,1,4]
输出:true
解释:可以先跳 1 步,从下标 0 到达下标 1, 然后再从下标 1 跳 3 步到达最后一个下标。
示例 2:

输入:nums = [3,2,1,0,4]
输出:false
解释:无论怎样,总会到达下标为 3 的位置。但该下标的最大跳跃长度是 0 , 所以永远不可能到达最后一个下标。
提示:

1 <= nums.length <= 104
0 <= nums[i] <= 105
题目来源:力扣 55. 跳跃游戏。

nums[i] 表示可以跳跃的最大长度,不是固定长度。假设 nums[i] = 3,意味着你可以在索引 i 往前跳 1 步、2 步或 3 步。
我们先思考暴力穷举解法吧,如何穷举所有可能的跳跃路径?你心里的那棵多叉树画出来了没有?

假设 N 为 nums 的长度,这道题相当于做 N 次选择,每次选择有 nums[i] 种选项,想要穷举所有的跳跃路径,就是一棵高度为 N 的多叉树,每个节点有 nums[i] 个子节点:

1、在索引 i,可以跳跃 1 ~ nums[i] 步。

2、只要能跳到最后一个索引,就返回 true。

(下面这一段好重要有点难以理解!请豆包详细讲解!)

贪心本质:

这里面有个细节,比方说你现在站在 nums[i] = 3 的位置,你可以跳到 i+1, i+2, i+3 三个位置,此时你真的需要分别跳过去,然后递归求解子问题 dp(i+1), dp(i+2), dp(i+3),最后通过子问题的答案来决定 dp(i) 的结果吗?

其实不用的,i+1, i+2, i+3 三个候选项,它们谁能走得最远,你就选谁,准没错。

具体来说,i+1 能走到的最远距离是 i+1+nums[i+1],i+2 能走到的最远距离是 i+2+nums[i+2],i+3 能走到的最远距离是 i+3+nums[i+3],你看看谁最大,就选谁。

这就是贪心选择性质,通过局部最优解就能推导全局最优解,不需要等到递归计算出所有子问题的答案才能做选择。

class Solution {
public:bool canJump(vector<int>& nums) {int n = nums.size();int farthest = 0;for (int i = 0; i < n; i++) {// 不断计算能跳到最远的距离!farthest = max(farthest, i + nums[i]);if (farthest <= i) {return false;}}return farthest >= n-1;}
};

只需要不断遍历所有元素,并且计算出能到达的最远距离即可!如果遍历到某个位置i,发现达不到这里!只需要遍历所有元素,记录当前能到达的最远位置就行了,时间复杂度是
O
(
N
)
O(N),空间复杂度是
O
(
1
)
O(1),非常高效。

第二道例题,力扣第 45 题「跳跃游戏 II」:

  1. 跳跃游戏 II | 力扣 | LeetCode | 🟠
    给定一个长度为 n 的 0 索引整数数组 nums。初始位置为 nums[0]。

每个元素 nums[i] 表示从索引 i 向前跳转的最大长度。换句话说,如果你在 nums[i] 处,你可以跳转到任意 nums[i + j] 处:

0 <= j <= nums[i]
i + j < n
返回到达 nums[n - 1] 的最小跳跃次数。生成的测试用例可以到达 nums[n - 1]。

示例 1:

输入: nums = [2,3,1,1,4]
输出: 2
解释: 跳到最后一个位置的最小跳跃数是 2。
从下标为 0 跳到下标为 1 的位置,跳 1 步,然后跳 3 步到达数组的最后一个位置。
示例 2:

输入: nums = [2,3,0,1,4]
输出: 2
提示:

1 <= nums.length <= 104
0 <= nums[i] <= 1000
题目保证可以到达 nums[n-1]
题目来源:力扣 45. 跳跃游戏 II。
现在的问题是,保证你一定可以跳到最后一格,请问你最少要跳多少次,才能跳过去。

暴力穷举递归可以做出来!
定义dp§ 为 从索引p跳到最后一格n-1位置至少需要dp§步!

// 定义:从索引p跳到最后一格n-1位置, 至少需要dp(p)步
int dp(int nums[], int p);

base case 就是当p超过最后一格或到达最后一格的时候不需要跳!return 0

// base case
if (p >= nums.size() - 1) {return 0;
}

思路一:暴力穷举所有跳法 + 备忘录

class Solution {
public:vector<int> memo;// 主函数签名:jump(nums) 输出从0开始最少需要步数int jump(vector<int>& nums) {int n = nums.size();// 最后一格为n-1,设置备忘录长度n, 且最多不可能跳n步!所以初始化memo为nmemo = vector<int>(n, n);return dp(nums, 0);}// dp(nums, p)定义:从索引 p 跳到最后一格,至少需要 dp(nums, p) 步int dp(vector<int>& nums, int p) {int n = nums.size();// base caseif (p >= n - 1) {return 0;}// 如果子问题已经记录过if (memo[p] != n) {return memo[p];}// 如果第一次到p// 记录下p位置的可以跳的最大步数stepsint steps = nums[p];// 即有 1, 2, ... nums[p] 种选择!遍历所有选择for (int i = 1; i <= step; i++) {// 穷举每一个选择,计算每个子问题的结果int subProblem = dp(nums, p + i);// 取出其中最小的一次作为结果memo[p] = min(memo[p], subProblem + 1);}return memo[p];}
};

这个解法已经通过备忘录消除了冗余计算,但是还是会超时!
分析时间复杂度 = 递归深度 x 每次递归需要的时间复杂度

所以我们考虑进一步优化!
思考是否符合贪心选择的性质?
能否通过局部最优解推导出全局最优??这样就能避免穷举所有可能解
真的需要递归计算每个子问题的结果么?
在这里插入图片描述
上图 当我们站在起始索引0 位置
可以向前跳动1, 2, 3步
其中分别对应新的nums值为1,4,2
我们可以很确定的说,直接选择跳2步即可!跳 2 步调到索引 2,因为 nums[2] 的可跳跃区域**涵盖了索引区间 [3…6],**比其他的都大。题目求最少的跳跃次数,那么往索引 2 跳必然是最优的选择。

这就是贪心选择 我们不需要真的穷举递归所有选择的具体结果!只需要每次选择前!选择最具潜力的局部最优解!!最终必然可以得到全局最优!

每次跳跃可达的是一个索引区间!

  • 代码中[i, end]维护当前的跳跃次数jumps可以覆盖的索引区间
  • farthest表示从[i, end]起跳可以跳到的最远索引!!

维护四个量:

  • 当前jumps次跳跃下
  • 可达的索引区间[i, end]
  • 从这个区间可以跳到最远的地方farthest!
class Solution {
public:int jump(vector<int>& nums) {// 边界情况if (nums.size() <= 1) {return 0;}// 本题维护的量int n = nums.size();// 当前 jumps 步可以跳到索引区间 [i, end]int jumps = 0int end = 0;// 在区间 [i, end] 最远可以跳到farthestint farthest = 0;// 不断更新 end jumps 和 farthest// 当farthest已经到达n-1直接返回jumpsfor (int i = 0; i < n - 1; i++) {// 当前跳了jumps次, 到达了[i,end], 并且遍历前面的内容(<i)后最远跳到farthest// 遍历到i后, 先看看farthest是否更新!?farthest = max(farthest,i + nums[i]);// 这个是当前区间再走一步最远可以到的位置!!!所以如果这个farthest>=n-1那么要返回的是jumps+1!!!需要隐式的加上一步!if (farthest >= n-1) return jumps + 1;// [i,end]是jumps步可达的位置// 如果已经遍历完了[i,end]// 即i==endif (i == end) {// 更新end为farthest// 同时相当于多跳了一步jumps++;end = farthest;// 也可以在这里检查farthest到没到n-1,到了的话直接返回jumps!				}}// 遍历到n-2位置都没使得farthest>=n-1说明到不了终点!!return -1;}
};

时间复杂度是
O
(
N
)
O(N),空间复杂度是
O
(
1
)
O(1),可以通过所有测试用例。

至此,两道例题都解决了。

最后总结!
贪心算法关键在于问题是否

解题步骤
贪心算法的关键在于问题是否具备贪心选择性质,所以只能具体问题具体分析,没办法抽象出一套固定的算法模板或者思维模式,判断一道题是否是贪心算法。

我的经验是,没必要刻意地识别一道题是否具备贪心选择性质。你只需时刻记住,算法的本质是穷举,遇到任何题目都要先想暴力穷举思路,穷举的过程中如果存在冗余计算,就用备忘录优化掉。

如果提交结果还是超时,那就说明不需要穷举所有的解空间就能求出最优解,这种情况下肯定需要用到贪心算法。你可以仔细观察题目,是否可以提前排除掉一些不合理的选择,是否可以直接通过局部最优解推导全局最优解。

http://www.xdnf.cn/news/15594.html

相关文章:

  • Spring AI之Prompt开发
  • Perspective:一款开源的交互式分析和数据可视化组件
  • 找不到或无法加载主类 org.gradle.wrapper.GradleWrapperMain
  • Maven详细解
  • 网络基础11 上公网--Internet接入技术
  • Python eval函数详解 - 用法、风险与安全替代方案
  • NLP——迁移学习
  • SQLite的可视化界面软件的安装
  • 【后端】.NET Core API框架搭建(8) --配置使用RabbitMQ
  • Kotlin属性重写
  • C++ AVL树实现详解:平衡二叉搜索树的原理与代码实现
  • 深度学习之神经网络(二)
  • cell2location复现
  • Clip微调系列:《CLIP-Adapter: Better Vision-Language Models with FeatureAdapters》
  • 深度学习中的注意力机制:原理、应用与实践
  • STM32-RTC内部时钟
  • 力扣 hot100 Day46
  • LVS集群实践
  • 前后端分离项目中的接口设计与调用流程——以高仙机器人集成为例
  • 数字ic后端设计从入门到精通11(含fusion compiler, tcl教学)全定制设计入门
  • 基于深度学习的情感分析模型:从文本数据到模型部署
  • c语言-数据结构-二叉树的遍历
  • [特殊字符] 第1篇:什么是SQL?数据库是啥?我能吃吗?
  • [特殊字符] Electron 中的 `global` 变量
  • 用Amazon Q Developer助力Python快捷软件开发
  • 创建SprngBoot项目的四种方式
  • 网络编程(数据库)
  • oracle服务器定时备份Windows Server
  • 服务攻防-Java组件安全数据处理FastJsonJackSonXStream自动BP插件CVE漏洞
  • vue中后端返回数据流,前端实现导出下载