深入探讨了动态规划(Dynamic Programming, DP) 。DP 的核心在于"重叠子问题"和"状态转移",它本质上是一种全局最优的搜索策略,通过遍历所有可能的状态并记录中间结果,确保最终解是全局最优的。
贪心算法(Greedy Algorithm) 则是一种更为直接、激进的策略。它在每一步决策时,不考虑整体最优,而是做出在当前看来局部最优 的选择。贪心算法的假设是:由一系列局部最优的选择,最终可以堆叠出全局最优解。
贪心算法没有固定的套路或模板(不像 DP 有明确的状态转移方程),其核心难点在于证明:为什么当前步骤的局部最优解一定能导致全局最优?
贪心算法(Greedy)与动态规划(DP)的主要区别在于:
- 决策过程 :
- DP:在做当前决策时,会考虑之前的状态和未来的可能性(通过状态转移方程关联)。
- Greedy:在做当前决策时,只关注当前状态,做出一个不可回撤的选择,然后处理剩下的子问题。
- 复杂度 :
- 由于贪心算法通常只需遍历一次数据(或配合排序),其时间复杂度通常为 O ( N ) O(N) O(N) 或 O ( N log N ) O(N \log N) O(NlogN),优于 DP 的 O ( N 2 ) O(N^2) O(N2)。
因此,如果一个问题能用贪心解决,那么贪心通常是该问题的最优解法。
一、 线性序列贪心:利润最大化
这类问题通常涉及对数组的线性遍历。核心在于将一个跨越长周期的宏观问题,分解为若干个微观的、局部的决策问题。
1.1 实战例题:买卖股票的最佳时机 II
题目链接 :LeetCode 122. Best Time to Buy and Sell Stock II
题目描述:给定一个数组 prices ,其中 prices[i] 表示股票第 i 天的价格。在每一天,你可以决定买入和/或卖出股票。你也可以先买入,然后在同一天卖出。返回 你能获得的 最大利润 。(不限制交易次数)。
逻辑解析 :
本题的难点在于:如何在多变的股价波动中找到买卖的最佳时机点?
如果试图寻找"最低点买入"和"最高点卖出",情况会非常复杂,因为可能存在多个波段。
1. 利润的数学分解(核心推导)
假设我们在第 0 天买入,坚持持有到第 3 天卖出。
-
总利润 : p r i c e s [ 3 ] − p r i c e s [ 0 ] prices[3] - prices[0] prices[3]−prices[0]。
-
数学变换 :
将每一天的价格差拆解开来:
p r i c e s [ 3 ] − p r i c e s [ 0 ] = ( p r i c e s [ 3 ] − p r i c e s [ 2 ] ) + ( p r i c e s [ 2 ] − p r i c e s [ 1 ] ) + ( p r i c e s [ 1 ] − p r i c e s [ 0 ] ) prices[3] - prices[0] = (prices[3] - prices[2]) + (prices[2] - prices[1]) + (prices[1] - prices[0]) prices[3]−prices[0]=(prices[3]−prices[2])+(prices[2]−prices[1])+(prices[1]−prices[0])
-
物理含义 :
这表明,在第 0 天买入并在第 3 天卖出 的操作,在利润结果上严格等价于 第 0 天买第 1 天卖,第 1 天买第 2 天卖,第 2 天买第 3 天卖 这一系列连续操作的总和。
2. 贪心策略的构建既然任何长期的交易都可以分解为相邻两天 的交易组合,那么问题就简化为:
对于每一天(第 i i i 天),是否应该利用它和前一天(第 i − 1 i-1 i−1 天)的差价进行获利? -
决策依据:
- 如果 p r i c e s [ i ] > p r i c e s [ i − 1 ] prices[i] > prices[i-1] prices[i]>prices[i−1]:说明这两天股票在涨。这一段区间的差价(利润)是正数。为了让总利润最大,必须赚取这一段利润。
- 如果 p r i c e s [ i ] ≤ p r i c e s [ i − 1 ] prices[i] \le prices[i-1] prices[i]≤prices[i−1]:说明这两天股票在跌或持平。这一段区间的差价是负数或零。为了不亏钱,选择放弃这一段(即不持有股票,或者理解为利润为 0)。
-
最终公式 :最大利润 = ∑ i = 1 n − 1 m a x ( p r i c e s [ i ] − p r i c e s [ i − 1 ] , 0 ) ∑i=1n−1max(prices[i]−prices[i−1],0) ∑i=1n−1max(prices[i]−prices[i−1],0)(n 为价格数组长度)
-
局部最优 → \rightarrow → 全局最优:
- 不需要预测未来是涨是跌。
- 只要今天比昨天贵,就把这部分的差价加到总利润里。
- 最终的总利润就是所有正向差价之和。由于只累加正数,结果必然是理论上的最大值。
示例推演 :
prices = [1, 5, 3, 6]
- 第 1 天价格 5,第 0 天价格 1。 5 − 1 = 4 > 0 5-1=4 > 0 5−1=4>0。累加 4。
- 第 2 天价格 3,第 1 天价格 5。 3 − 5 = − 2 < 0 3-5=-2 < 0 3−5=−2<0。放弃。
- 第 3 天价格 6,第 2 天价格 3。 6 − 3 = 3 > 0 6-3=3 > 0 6−3=3>0。累加 3。
- 最大利润 = 4 + 3 = 7 4 + 3 = 7 4+3=7。
等值式为 :
( p r i c e s [ 1 ] − p r i c e s [ 0 ] ) ⏟ 0 天买 1 天卖 + ( p r i c e s [ 3 ] − p r i c e s [ 2 ] ) ⏟ 2 天买 3 天卖 = 7 \underbrace{(prices[1] - prices[0])}{0天买1天卖} + \underbrace{(prices[3] - prices[2])}{2天买3天卖} = 7 0天买1天卖 (prices[1]−prices[0])+2天买3天卖 (prices[3]−prices[2])=7
Java 代码:
java
public int maxProfit(int[] prices) {
int maxProfit = 0;
// 从第 1 天(索引 1)开始遍历,每次回顾前一天(索引 i-1)
for (int i = 1; i < prices.length; i++) {
// 计算相邻两天的利润差
int diff = prices[i] - prices[i - 1];
// 贪心决策:
// 只有当利润为正(diff > 0)时,才将其计入总利润。
// 这相当于我们捕捉了股价走势图中每一个"上升"的片段。
if (diff > 0) {
maxProfit += diff;
}
}
return maxProfit;
}
复杂度:
- 时间复杂度 : O ( N ) O(N) O(N)。只需遍历一次数组。
- 空间复杂度 : O ( 1 ) O(1) O(1)。
二、 区间覆盖贪心:跳跃游戏系列
"跳跃游戏"系列问题的核心,并非在于"跳跃"这一动作本身,而在于维护一个动态伸缩的右边界(Cover Range)。
我们将数组的索引视为一条数轴。数组中的每个元素 nums[i] 实际上定义了一个以 i 为左端点,i + nums[i] 为右端点的潜在覆盖区间。
贪心算法的任务就是将这些分散的小区间,通过线性扫描,融合为一个从起点出发的、连续的、尽可能远的大区间。
2.1 实战例题:跳跃游戏
题目链接 :LeetCode 55. Jump Game
题目描述:给定一个非负整数数组 nums ,你最初位于数组的 第一个下标 。数组中的每个元素代表你在该位置可以跳跃的最大长度。判断你是否能够到达最后一个下标。
逻辑深度解析:
- 误区 :纠结于"到底跳几步"。在当前位置
i,可以跳 1 步,也可以跳nums[i]步,选择太多,容易陷入回溯思维。 - 贪心视角 :不关心具体跳几步,只关心覆盖范围 。
- 在位置
i,能够覆盖的最远距离是i + nums[i]。 - 在遍历过程中,不断维护一个变量
cover,表示当前能够到达的最远索引。
- 在位置
- 局部最优:每次都更新最大的覆盖范围。
- 全局最优 :如果最终
cover大于等于终点下标,则说明可达。
关键细节 :
遍历必须限制在 cover 范围内。即 for (int i = 0; i <= cover; i++)。因为如果 i 超过了 cover,说明这个位置根本无法到达,更别提从这个位置起跳了。
Java 代码:
java
public boolean canJump(int[] nums) {
// cover 表示当前能跳到的最远位置,初始在 0
int cover = 0;
// 注意终止条件:只能在 cover 范围内移动
// 同时也无需遍历到最后一个元素,只需判断 cover 是否能覆盖 nums.length - 1
for (int i = 0; i <= cover; i++) {
// 更新当前能覆盖的最远距离
// i + nums[i] 是从当前位置能触及的最远边界
cover = Math.max(cover, i + nums[i]);
// 剪枝:如果已经能覆盖到末尾,直接返回 true
if (cover >= nums.length - 1) {
return true;
}
}
// 如果遍历结束还无法覆盖到末尾
return false;
}
2.2 实战例题:跳跃游戏 II
题目链接 :LeetCode 45. Jump Game II
题目描述:给定一个长度为 n 的 0 索引整数数组 nums。初始位置为 nums[0]。生成的测试用例可以保证你总是可以到达数组的最后一个位置。目标是使用最少的跳跃次数到达数组的最后一个位置。
逻辑解析 :
这是上一题的进阶版。上一题只问"能不能",这一题问"最少几次",上一题我们只维护了一个"最远边界",而在这道题中,我们需要维护每一跳的边界 。
这个问题本质上是一个隐式的 BFS(广度优先搜索) ,但不需要队列,只需要通过贪心维护两个边界变量即可实现 O ( N ) O(N) O(N) 的层序遍历。
- 决策困境 :在位置 0,可以跳到 1, 2, ...
nums[0]。应该跳到哪里? - 贪心策略 :不要只看这一步跳多远,要看这一步跳出去后,下一步能覆盖多远 。我们希望每一步都尽可能地扩大覆盖范围。
为了实现 O ( N ) O(N) O(N) 遍历,需要维护两个变量:
curCover(当前覆盖边界) :表示当前步数下,最远能走到的位置。在达到这个边界之前,不需要增加步数。nextCover(下一步覆盖边界) :表示如果在当前覆盖范围内选择一个起跳点,下一步最远能走到哪里。
算法流程:
- 从头开始遍历数组。
- 在遍历过程中,不断计算
i + nums[i],并更新nextCover(记录潜在的最大射程)。 - 当遍历指针
i到达了curCover(即当前这一步走到了尽头):- 必须进行下一次跳跃:步数
steps++。 - 更新边界:
curCover = nextCover(将当前步数的终点延伸到刚才记录的最大射程)。 - 如果
curCover已经覆盖了终点,循环结束。
- 必须进行下一次跳跃:步数
Java 代码:
java
public int jump(int[] nums) {
if (nums.length == 1) return 0;
int curCover = 0; // 当前跳跃步数能到达的边界
int nextCover = 0; // 下一步能到达的最远边界
int steps = 0; // 记录跳跃次数
// 注意:遍历到 nums.length - 2 即可
// 因为当我们到达 nums.length - 1 时,已经不需要再跳了
for (int i = 0; i < nums.length - 1; i++) {
// 贪心策略:实时统计下一步能覆盖的最远距离
nextCover = Math.max(nextCover, i + nums[i]);
// 边界判断:如果走到了当前步数的边界
if (i == curCover) {
// 必须进行一次跳跃
steps++;
// 更新当前边界为刚才统计出的最大范围
curCover = nextCover;
// 剪枝:如果已经覆盖到终点,提前结束
if (curCover >= nums.length - 1) {
break;
}
}
}
return steps;
}
复杂度:
- 时间: O ( N ) O(N) O(N)。不需要像 DP 那样 O ( N 2 ) O(N^2) O(N2) 计算所有状态。
- 空间: O ( 1 ) O(1) O(1)。
三、 字符串/序列切分:划分字母区间
这类问题涉及对序列进行切分(Partitioning),要求切分后的片段满足特定条件(如互不重叠、包含特定元素等)。
3.1 实战例题:划分字母区间
题目链接 :LeetCode 763. Partition Labels
题目描述:给你一个字符串 s 。我们要把这个字符串划分为尽可能多的片段,同一字母最多出现在一个片段中。返回一个表示每个字符串片段的长度的列表。
逻辑解析:
- 题目核心约束 :如果字符 'a' 出现在片段 A 中,那么字符串中所有的 'a' 都必须出现在片段 A 中。
- 推论 :一个片段的结束位置 ,至少要是该片段内所有字符在字符串中出现的最远位置。
贪心策略:
- 预处理 :先遍历一遍字符串,记录每个字符最后一次出现的下标。例如
lastIndex['a'] = 8。 - 遍历与扩展 :
- 维护两个指针:
left(当前片段起始)和right(当前片段结束)。 - 遍历字符串
i从 0 到s.length。 - 对于遇到的每个字符
c,更新当前片段的结束边界right。right = Math.max(right, lastIndex[c])。- 含义 :既然当前片段包含了
c,那么这个片段至少要延伸到c最后出现的位置。
- 维护两个指针:
- 切割时机 :
- 当遍历指针
i到达了right时,说明下标0到i之间出现的所有字符,它们最后出现的位置都没有超过i。 - 此时可以安全地切一刀。记录长度
right - left + 1,并更新left = i + 1开始下一个片段。
- 当遍历指针
Java 代码:
java
public List<Integer> partitionLabels(String s) {
// 预处理:记录每个字符最后出现的位置
int[] lastOccur = new int[26];
char[] chars = s.toCharArray();
for (int i = 0; i < chars.length; i++) {
lastOccur[chars[i] - 'a'] = i;
}
List<Integer> result = new ArrayList<>();
int left = 0;
int right = 0;
// 遍历字符串
for (int i = 0; i < chars.length; i++) {
// 贪心策略:找到当前范围内所有字符的最远边界
right = Math.max(right, lastOccur[chars[i] - 'a']);
// 切割时机:当扫描位置 i 追上了当前的最远边界 right
if (i == right) {
// 记录当前片段长度
result.add(right - left + 1);
// 更新下一片段的起始位置
left = i + 1;
}
}
return result;
}
复杂度:
- 时间: O ( N ) O(N) O(N)。两次遍历(一次预处理,一次切割)。
- 空间: O ( 1 ) O(1) O(1)。
lastOccur数组固定大小为 26。
四、 排序 + 贪心:资源分配与重构
很多贪心题目本身没有显而易见的贪心策略,但一旦将数据按照特定维度排序,贪心策略就会浮现。这类题目通常涉及两个维度的权衡(如:孩子胃口 vs 饼干尺寸,身高 vs 前面人数)。
解决此类问题的黄金法则:在两个维度权衡中,一定要先确定一个维度,再处理另一个维度。 如果两个维度一起考虑,必然顾此失彼。
4.1 实战例题:根据身高重建队列
题目链接 :LeetCode 406. Queue Reconstruction by Height
题目描述:假设有打乱顺序的一群人站成一个队列。每个人由一个整数对 (h, k) 表示,其中 h 是这个人的身高,k 是排在这个人前面且身高大于或等于 h 的人数。编写一个算法来重建这个队列。
逻辑解析 :
本题有两个维度:身高 h 和人数 k。我们需要分析它们之间的依赖关系。
-
依赖性分析:
- 如果我们在一个"高个子"前面插入一个"矮个子":高个子的
k值不会受到影响 。因为k只统计"大于等于"自己身高的人,矮个子对他来说是"透明"的。 - 如果我们在一个"矮个子"前面插入一个"高个子":矮个子的
k值会被破坏。因为高个子会被矮个子统计在内。
- 如果我们在一个"高个子"前面插入一个"矮个子":高个子的
-
贪心策略确定 :
为了保证后续的操作不破坏前面的成果,必须先处理高个子,再处理矮个子。
- 排序规则 :
- 优先按身高
h从高到低 排序。(确保我们在处理第i个人时,已经排好队的0到i-1个人全部都比第i个人高或相等)。 - 如果身高
h相同,按k从小到大 排序。(例如[7,0]和[7,1],显然[7,0]应该在前面)。
- 优先按身高
- 排序规则 :
-
插入逻辑推导 :
假设排序后的数组为:
[7,0], [7,1], [6,1], [5,0], [5,2], [4,4]。需要构建一个结果队列
queue。- 步骤 1 :拿出
[7,0]。queue为空,直接放入。queue: [[7,0]]。 - 步骤 2 :拿出
[7,1]。插入到索引 1。queue: [[7,0], [7,1]]。 - 步骤 3 :拿出
[6,1]。- 此时
queue里的人是[7,0], [7,1]。 - 关键点 :由于是降序处理的,所以
queue里的所有人(7)都比当前这个人(6)高。 - 当前这个人要求
k=1(前面有 1 个比他高的)。 - 既然
queue里现有的全比他高,那么我们只要把他插入到 索引 1 的位置,他前面就自然会有 1 个人(即索引 0 的那个人),完全满足k=1的定义。 queue变为:[[7,0], [6,1], [7,1]]。
- 此时
- 步骤 4 :拿出
[5,0]。- 此时
queue里的人都 ≥ 5 \ge 5 ≥5。 - 要求
k=0。直接插入到 索引 0。 queue变为:[[5,0], [7,0], [6,1], [7,1]]。- 验证 :这次插入有没有破坏后面的人?
- 对于
[7,0]:前面插入了5。但5 < 7,不影响[7,0]的计数。 - 对于
[6,1]:前面插入了5。但5 < 6,不影响[6,1]的计数。
- 对于
- 此时
结论 :对于排序后的每个人
[h, k],只需要将其插入到queue的 第 k 个位置(即 index = k) 即可。 - 步骤 1 :拿出
Java 代码:
java
public int[][] reconstructQueue(int[][] people) {
// 排序
// 优先按身高 h 降序 (从大到小)
// 如果身高 h 相同,按 k 升序 (从小到大)
Arrays.sort(people, (a, b) -> {
if (a[0] == b[0]) {
return a[1] - b[1];
}
return b[0] - a[0];
});
// 插入
// 使用 LinkedList,因为涉及大量的"中间插入"操作,链表性能优于数组
// 这里的泛型是 int[],即每个节点存一个人的信息
LinkedList<int[]> queue = new LinkedList<>();
for (int[] p : people) {
// p[1] 就是 k 值
// 逻辑:因为 queue 中已有的所有人身高都 >= p[0]
// 所以把 p 插在第 k 个位置,p 的前面就正好有 k 个比他高的人
queue.add(p[1], p);
}
// 3. 转换回数组返回
return queue.toArray(new int[people.length][]);
}
复杂度分析:
- 时间复杂度 : O ( N 2 ) O(N^2) O(N2)。排序耗时 O ( N log N ) O(N \log N) O(NlogN)。但在循环中,
LinkedList.add(index, val)的平均复杂度是 O ( N ) O(N) O(N)(虽然链表插入是 O ( 1 ) O(1) O(1),但需要先寻找位置),循环执行 N 次,总共 O ( N 2 ) O(N^2) O(N2)。 - 空间复杂度 : O ( N ) O(N) O(N)。用于存储结果队列。
五、 总结
贪心算法(Greedy)是算法面试中一种高风险高回报的策略。它不像动态规划那样有明确的模板(状态转移方程),而是依赖于对问题本质的深刻洞察。
5.1 贪心 vs 动态规划
在面对一道求"最值"的题目时,如何快速判断是该用 Greedy 还是 DP?
| 特征 | 贪心算法 (Greedy) | 动态规划 (DP) |
|---|---|---|
| 决策依据 | 只看当下:仅依赖当前状态,做出局部最优选择。 | 瞻前顾后:依赖子问题的解(重叠子结构),通过状态转移方程关联过去与未来。 |
| 可回撤性 | 不可回撤(一条路走到黑)。选了就定了。 | 可回撤(隐式)。DP 本质是遍历所有可能路径并取最优,相当于回撤重选。 |
| 核心难点 | 证明局部最优能导致全局最优(反证法)。 | 构建状态转移方程与初始化边界。 |
| 复杂度 | 通常 O ( N ) O(N) O(N) 或 O ( N log N ) O(N \log N) O(NlogN)(较快)。 | 通常 O ( N 2 ) O(N^2) O(N2) 或更高(较慢)。 |
| 适用场景 | 区间覆盖、跳跃游戏、股票买卖 II(无后效性)。 | 0/1 背包、最长公共子序列(有后效性,选了 A 可能导致 B 装不下)。 |
5.2 贪心题目的解题步骤
- 从简单用例推导 :
不要空想证明。先拿纸笔画几个简单的 Test Case,看看是否存在某种"每次选最大/最小/最长"就能得到答案的规律。- 例如:跳跃游戏,每次跳得越远越好?买卖股票,只要涨就赚?
- 验证"无后效性" :
试着反问:"如果这一步选了局部最优,会不会导致后面无路可走,或者错失了更好的机会?"- 会(有后效性) → \rightarrow → 放弃贪心,转向 DP。(如 0/1 背包:选了体积大价值高的,可能导致后面两个体积小但总价值更高的装不下)。
- 不会(无后效性) → \rightarrow → 贪心。(如区间覆盖:选了结束时间最早的区间,留给后面的空间就越大,绝对不会亏)。
- 确定排序规则 :
如果是处理集合类问题(如区间、队列),90% 的贪心都需要先排序 。尝试按照"开始时间"、"结束时间"、"大小"等维度排序,观察是否有规律。在涉及两个维度的权衡时(如身高 vs 前面人数),一定要先确定一个维度(排序),再贪心地处理另一个维度。切忌同时处理两个维度。
5.3 复杂度分析
- 时间复杂度 :
- 线性扫描类 (如股票 II、跳跃游戏 I/II): O ( N ) O(N) O(N)。只需一次遍历。
- 排序贪心类 (如划分字母区间、重构队列):取决于排序,通常为 O ( N log N ) O(N \log N) O(NlogN)。
- 如果题目数据规模 N ≈ 10 5 N \approx 10^5 N≈105,通常暗示 O ( N ) O(N) O(N) 或 O ( N log N ) O(N \log N) O(NlogN) 的解法,贪心是首选对象。
- 空间复杂度 :
- 大多数贪心算法只需常数个变量(如
cover,maxDiff),空间为 O ( 1 ) O(1) O(1)。 - 如果需要存储排序后的结果或辅助数组(如
lastIndex映射),则为 O ( N ) O(N) O(N) 或 O ( C ) O(C) O(C)(字符集大小)。
- 大多数贪心算法只需常数个变量(如
贪心算法没有万能模板,它更像是一种思维直觉的训练。通过大量练习上述提到的覆盖、切分、排序类题目,可以有效培养这种"直觉"。