贪心算法实战:leetcode-master的最优解策略
本文深入探讨了贪心算法在LeetCode经典问题中的应用,包括理论基础、适用场景和实战策略。文章系统分析了贪心算法的基本特征和适用条件,通过多个典型案例(区间调度、股票交易、分发糖果、监控二叉树、单调递增数字等)详细讲解了贪心策略的设计思路和实现方法。每种问题都提供了完整的算法分析、代码实现和复杂度评估,帮助读者掌握贪心算法的核心思想和应用技巧。
贪心算法理论基础与适用场景
贪心算法是一种在每一步选择中都采取当前状态下最优(即最有利)的选择,从而希望导致结果是全局最优的算法策略。与动态规划不同,贪心算法不会回溯,一旦做出选择就不会改变,这使得它在某些问题上具有更高的效率。
贪心算法的基本特征
贪心算法适用于具有"贪心选择性质"和"最优子结构"的问题:
最优子结构:一个问题的最优解包含其子问题的最优解。这意味着我们可以通过子问题的最优解来构造原问题的最优解。
贪心选择性质:通过局部最优选择可以构建全局最优解,且这些选择不会依赖于未来的选择。
贪心算法的适用场景
贪心算法特别适合以下几类问题:
1. 区间调度问题
当需要在一系列活动中选择互不冲突的活动,使得选择的活动数量最多时,贪心算法通常能给出最优解。
def interval_scheduling(intervals):
# 按结束时间排序
intervals.sort(key=lambda x: x[1])
count = 0
end = -float('inf')
for interval in intervals:
if interval[0] >= end:
count += 1
end = interval[1]
return count
2. 霍夫曼编码
在数据压缩中,霍夫曼编码使用贪心策略为不同字符分配不同长度的编码,使得总体编码长度最小。
3. 最小生成树
Prim和Kruskal算法都使用贪心策略来构建最小生成树,每次选择权重最小的边。
4. 最短路径问题
Dijkstra算法使用贪心策略,每次选择当前距离起点最近的节点。
5. 分数背包问题
与0-1背包问题不同,分数背包问题允许物品分割,贪心算法(按价值密度排序)能给出最优解。
贪心算法的局限性
虽然贪心算法简单高效,但它并不适用于所有优化问题。以下情况不适合使用贪心算法:
| 问题类型 | 贪心算法表现 | 推荐方法 |
|---|---|---|
| 0-1背包问题 | 可能不是最优解 | 动态规划 |
| 旅行商问题 | 通常不是最优解 | 动态规划、分支定界 |
| 图着色问题 | 可能使用更多颜色 | 回溯法、启发式算法 |
贪心算法的证明技巧
要证明贪心算法的正确性,通常需要使用以下方法:
- 贪心选择性质证明:证明第一步的贪心选择包含在某个最优解中
- 最优子结构证明:证明剩余子问题的最优解与已做选择组合后仍是原问题的最优解
- 数学归纳法:通过归纳证明贪心选择的正确性
- 交换论证:证明任何其他解都可以通过交换操作转换为贪心解而不降低解的质量
贪心算法的实际应用案例
在leetcode-master项目中,贪心算法被广泛应用于各种经典问题:
以分发饼干问题为例,贪心策略的核心思想是:
def findContentChildren(g, s):
g.sort() # 孩子胃口排序
s.sort() # 饼干大小排序
child = 0
cookie = 0
while child < len(g) and cookie < len(s):
if g[child] <= s[cookie]:
child += 1
cookie += 1
return child
这个算法的贪心选择是:总是尝试用当前最小的饼干满足当前最小胃口的孩子,如果不能满足则尝试更大的饼干。
贪心选择策略的比较
不同的贪心策略可能导致不同的结果,选择合适的贪心策略至关重要:
| 策略类型 | 适用场景 | 例子 |
|---|---|---|
| 最短处理时间优先 | 任务调度 | SJF调度算法 |
| 最早截止时间优先 | 实时系统 | EDF调度算法 |
| 最大价值密度优先 | 分数背包 | 按价值/重量排序 |
| 最小冲突优先 | 图着色 | 选择冲突最少的颜色 |
贪心算法的复杂度分析
贪心算法的时间复杂度通常由排序操作决定,大多数贪心算法的时间复杂度为O(n log n),空间复杂度为O(1)或O(n)。
在实际应用中,我们需要根据具体问题特点选择合适的贪心策略,并通过数学证明或实验验证其正确性。虽然贪心算法不能解决所有优化问题,但在适用的情况下,它能提供简单高效的解决方案。
区间问题:无重叠区间与用箭引爆气球
在贪心算法的应用中,区间问题是一类非常经典且实用的场景。这类问题通常涉及到多个区间的重叠判断、合并、选择等操作,能够很好地体现贪心算法"局部最优导致全局最优"的思想。本文将深入探讨两个典型的区间问题:无重叠区间和用箭引爆气球,通过详细的算法分析和代码实现,帮助读者掌握解决这类问题的核心技巧。
问题背景与核心思想
区间问题的核心在于处理多个区间之间的关系,特别是重叠关系。无论是寻找最大不重叠区间集合,还是用最少的点覆盖所有区间,都需要我们对区间进行合理的排序和遍历。
贪心策略的核心:通过合适的排序方式(按左边界或右边界),使得我们在遍历过程中能够做出最优的局部选择,从而得到全局最优解。
无重叠区间问题分析
问题描述:给定一个区间的集合,找到需要移除区间的最小数量,使剩余区间互不重叠。
算法思路
对于无重叠区间问题,我们有两种主要的贪心策略:
- 按右边界排序:优先选择结束早的区间,为后续区间留下更多空间
- 按左边界排序:处理重叠时更新最小右边界
代码实现
// 按右边界排序的解法
class Solution {
public:
static bool cmp(const vector<int>& a, const vector<int>& b) {
return a[1] < b[1];
}
int eraseOverlapIntervals(vector<vector<int>>& intervals) {
if (intervals.empty()) return 0;
sort(intervals.begin(), intervals.end(), cmp);
int count = 1; // 不重叠区间数量
int end = intervals[0][1];
for (int i = 1; i < intervals.size(); i++) {
if (intervals[i][0] >= end) {
end = intervals[i][1];
count++;
}
}
return intervals.size() - count;
}
};
复杂度分析
| 操作 | 时间复杂度 | 空间复杂度 |
|---|---|---|
| 排序 | O(n log n) | O(log n) |
| 遍历 | O(n) | O(1) |
| 总计 | O(n log n) | O(log n) |
用箭引爆气球问题分析
问题描述:在二维空间中有许多球形的气球,用最少数量的箭引爆所有气球。
算法思路
这个问题可以转化为:找到最少的点,使得每个区间至少包含一个点。这与无重叠区间问题密切相关。
代码实现
class Solution:
def findMinArrowShots(self, points: List[List[int]]) -> int:
if not points:
return 0
# 按左边界排序
points.sort(key=lambda x: x[0])
result = 1 # 至少需要一支箭
for i in range(1, len(points)):
if points[i][0] > points[i-1][1]: # 不重叠
result += 1
else: # 重叠
# 更新重叠区间的最小右边界
points[i][1] = min(points[i-1][1], points[i][1])
return result
两种问题的对比
| 特性 | 无重叠区间 | 用箭引爆气球 |
|---|---|---|
| 目标 | 移除最少区间使剩余不重叠 | 用最少的箭引爆所有气球 |
| 排序方式 | 右边界优先 | 左边界或右边界均可 |
| 核心操作 | 统计不重叠区间数 | 统计需要箭的数量 |
| 重叠判断 | 开始 ≥ 前一个结束 | 开始 > 前一个结束 |
| 关系 | 气球问题可转化为区间问题 | 区间问题的特殊应用 |
实战技巧与注意事项
-
排序选择:按右边界排序通常更直观,但按左边界排序在某些情况下代码更简洁
-
边界处理:特别注意区间边界相等的情况,不同问题有不同的处理方式
-
更新策略:在重叠情况下,需要正确更新边界值以确保后续判断的正确性
-
特殊情况:空输入、单区间输入等边界情况需要单独处理
扩展应用
区间问题的解法不仅适用于这两个特定问题,还可以扩展到其他类似场景:
- 会议室安排:最多可以安排多少场会议
- 课程安排:选择不冲突的课程最大化学习收益
- 任务调度:在时间限制内完成最多任务
通过掌握区间问题的贪心解法,我们能够高效解决一大类实际的调度和选择问题,这种思维方式在算法竞赛和实际工程中都有重要应用。
股票问题与分发糖果的贪心策略
在贪心算法的实际应用中,股票交易问题和分发糖果问题展现了两种截然不同但同样精妙的贪心策略。这两个问题虽然表面看似毫不相关,却都体现了贪心算法的核心思想:通过局部最优选择达到全局最优解。
股票买卖的最佳时机 II
股票买卖问题是一个经典的贪心算法应用场景。在LeetCode 122题"买卖股票的最佳时机II"中,我们需要设计一个算法来计算在给定股票价格序列下能够获得的最大利润,允许进行多次交易。
问题分析
给定一个数组 prices,其中 prices[i] 表示第i天股票的价格。规则如下:
- 可以进行多次买卖操作
- 不能同时参与多笔交易(必须在再次购买前出售之前的股票)
- 目标是最大化总利润
贪心策略的核心洞察
传统的思路可能会让人陷入寻找买入和卖出时机的复杂分析中,但贪心算法提供了一个极其简洁而高效的解决方案:
将整体利润分解为每天的利润变化
具体来说,第i天到第j天的总利润可以分解为:
prices[j] - prices[i] = (prices[i+1] - prices[i]) + (prices[i+2] - prices[i+1]) + ... + (prices[j] - prices[j-1])
算法实现
基于这个洞察,贪心策略变得非常简单:收集所有的正利润
def maxProfit(prices):
result = 0
for i in range(1, len(prices)):
# 只累加正的价格差
result += max(prices[i] - prices[i-1], 0)
return result
算法复杂度分析
| 指标 | 数值 | 说明 |
|---|---|---|
| 时间复杂度 | O(n) | 只需遍历一次数组 |
| 空间复杂度 | O(1) | 只使用常数级别的额外空间 |
贪心正确性证明
通过以下流程图可以清晰地理解为什么这个贪心策略是有效的:
这个策略的局部最优是:只要当天的价格比前一天高,就获得这部分利润。全局最优是:所有正利润的总和就是最大可能利润。
分发糖果问题
LeetCode 135题"分发糖果"展示了另一种类型的贪心策略,需要处理两个维度的约束条件。
问题描述
老师需要给N个孩子分发糖果,要求:
- 每个孩子至少分配到1个糖果
- 相邻的孩子中,评分高的孩子必须获得更多的糖果
求老师至少需要准备多少颗糖果。
双维度贪心策略
这个问题的难点在于两个相邻约束条件会相互影响。贪心策略的关键是:分两次处理,先处理一个维度,再处理另一个维度
第一次遍历:从左到右处理右边大于左边的情况
# 初始化每个孩子至少1个糖果
candies = [1] * len(ratings)
# 从左到右:如果右边评分比左边高,右边糖果数 = 左边糖果数 + 1
for i in range(1, len(ratings)):
if ratings[i] > ratings[i-1]:
candies[i] = candies[i-1] + 1
第二次遍历:从右到左处理左边大于右边的情况
# 从右到左:如果左边评分比右边高,左边糖果数取最大值
for i in range(len(ratings)-2, -1, -1):
if ratings[i] > ratings[i+1]:
candies[i] = max(candies[i], candies[i+1] + 1)
算法复杂度分析
| 指标 | 数值 | 说明 |
|---|---|---|
| 时间复杂度 | O(n) | 两次线性遍历 |
| 空间复杂度 | O(n) | 需要存储每个孩子的糖果数 |
为什么需要两次遍历?
下面的序列图说明了两次遍历的必要性:
两种策略的对比分析
虽然都是贪心算法,但股票问题和分发糖果问题展现了不同的策略模式:
| 特征 | 股票问题 | 分发糖果问题 |
|---|---|---|
| 贪心策略 | 收集所有正利润 | 分两次处理相邻关系 |
| 处理维度 | 单维度时间序列 | 双维度相邻约束 |
| 遍历次数 | 1次 | 2次 |
| 空间复杂度 | O(1) | O(n) |
| 关键洞察 | 利润分解 | 分而治之 |
实际应用场景
股票策略的应用
- 量化交易中的短期波动捕捉
- 投资组合的再平衡策略
- 市场趋势分析中的收益最大化
分发糖果策略的应用
- 资源分配中的公平性问题
- 绩效评估中的等级划分
- 多约束条件下的优化问题
代码实现示例
股票问题的完整实现
def max_profit(prices):
"""
计算股票交易的最大利润
:param prices: 股票价格列表
:return: 最大利润
"""
if not prices or len(prices) < 2:
return 0
profit = 0
for i in range(1, len(prices)):
# 只要今天比昨天价格高,就获得这部分利润
if prices[i] > prices[i-1]:
profit += prices[i] - prices[i-1]
return profit
# 测试用例
test_cases = [
[7, 1, 5, 3, 6, 4], # 期望输出: 7
[1, 2, 3, 4, 5], # 期望输出: 4
[7, 6, 4, 3, 1] # 期望输出: 0
]
for i, prices in enumerate(test_cases):
result = max_profit(prices)
print(f"测试用例 {i+1}: {prices} -> 最大利润: {result}")
分发糖果的完整实现
def candy(ratings):
"""
分发糖果算法
:param ratings: 孩子评分列表
:return: 最少需要的糖果数
"""
n = len(ratings)
if n == 0:
return 0
if n == 1:
return 1
# 初始化每个孩子至少1颗糖
candies = [1] * n
# 从左到右遍历:处理右边评分高于左边的情况
for i in range(1, n):
if ratings[i] > ratings[i-1]:
candies[i] = candies[i-1] + 1
# 从右到左遍历:处理左边评分高于右边的情况
for i in range(n-2, -1, -1):
if ratings[i] > ratings[i+1]:
candies[i] = max(candies[i], candies[i+1] + 1)
return sum(candies)
# 测试用例
test_cases = [
[1, 0, 2], # 期望输出: 5
[1, 2, 2], # 期望输出: 4
[1, 3, 2, 2, 1] # 复杂用例
]
for i, ratings in enumerate(test_cases):
result = candy(ratings)
print(f"测试用例 {i+1}: {ratings} -> 最少糖果数: {result}")
性能优化技巧
股票问题的优化
股票问题本身已经是最优解,时间复杂度O(n),空间复杂度O(1),无法再优化。
分发糖果的优化
虽然时间复杂度已经是O(n),但可以稍微优化空间使用:
def candy_optimized(ratings):
n = len(ratings)
candies = [1] * n
# 第一次遍历:只处理递增序列
for i in range(1, n):
if ratings[i] > ratings[i-1]:
candies[i] = candies[i-1] + 1
# 第二次遍历同时求和,减少一次遍历
total = candies[-1]
for i in range(n-2, -1, -1):
if ratings[i] > ratings[i+1]:
candies[i] = max(candies[i], candies[i+1] + 1)
total += candies[i]
return total
常见错误与陷阱
股票问题的常见错误
- 错误理解多次交易:认为需要记录每次买卖的具体时间点
- 过度复杂化:试图使用动态规划等复杂方法
- 忽略边界条件:没有处理空数组或单元素数组
分发糖果的常见错误
- 同时处理两个方向:试图在一次遍历中同时处理左右关系
- 初始化错误:忘记每个孩子至少1颗糖
- 遍历顺序错误:第二次遍历必须从右到左
扩展思考
股票问题的变种
如果加入交易手续费的限制,贪心策略仍然适用,但需要调整策略:
def max_profit_with_fee(prices, fee):
profit = 0
min_price = prices[0]
for i in range(1, len(prices)):
if prices[i] < min_price:
min_price = prices[i]
elif prices[i] > min_price + fee:
profit += prices[i] - min_price - fee
min_price = prices[i] - fee # 关键:避免重复扣除手续费
return profit
分发糖果的变种
如果评分相同的孩子要求糖果数也相同,问题会变得更加复杂,需要用到图论中的拓扑排序概念。
这两个问题虽然简单,但蕴含的贪心思想却非常深刻。股票问题教会我们如何将复杂问题分解为简单子问题,而分发糖果问题展示了如何处理多约束条件下的优化问题。掌握这两种贪心策略的模式,对于解决实际中的优化问题具有重要的指导意义。
监控二叉树与单调递增数字的贪心解法
贪心算法在解决特定类型问题时展现出强大的威力,特别是在需要做出局部最优选择以达到全局最优解的场景中。本文将深入探讨两个经典的贪心算法应用:监控二叉树(LeetCode 968)和单调递增数字(LeetCode 738),揭示其背后的贪心策略和实现细节。
监控二叉树的贪心策略
监控二叉树问题要求我们在二叉树节点上安装摄像头,每个摄像头可以监控其父节点、自身和直接子节点,目标是使用最少数量的摄像头监控所有节点。
贪心思路分析
通过分析问题,我们发现一个关键洞察:摄像头不应该放在叶子节点上。这是因为:
- 摄像头放在叶子节点只能监控三层中的两层(自身和父节点),浪费了监控能力
- 摄像头放在叶子节点的父节点可以监控三层(父节点、自身、子节点),利用率更高
- 从下往上放置摄像头可以最大化每个摄像头的监控范围
状态定义与转移
我们定义三种节点状态:
- 0: 该节点无覆盖
- 1: 本节点有摄像头
- 2: 本节点有覆盖
状态转移规则如下:
算法实现
class Solution {
private:
int result;
int traversal(TreeNode* cur) {
// 空节点默认有覆盖
if (cur == nullptr) return 2;
int left = traversal(cur->left); // 左
int right = traversal(cur->right); // 右
// 情况1: 左右节点都有覆盖
if (left == 2 && right == 2) return 0;
// 情况2: 左右节点至少有一个无覆盖
if (left == 0 || right == 0) {
result++;
return 1;
}
// 情况3: 左右节点至少有一个有摄像头
if (left == 1 || right == 1) return 2;
return -1; // 不会执行到这里
}
public:
int minCameraCover(TreeNode* root) {
result = 0;
// 情况4: 根节点无覆盖
if (traversal(root) == 0) {
result++;
}
return result;
}
};
复杂度分析
| 指标 | 数值 | 说明 |
|---|---|---|
| 时间复杂度 | O(n) | 需要遍历所有节点一次 |
| 空间复杂度 | O(h) | 递归栈空间,h为树的高度 |
单调递增数字的贪心策略
单调递增数字问题要求找到小于等于给定数字N的最大整数,且该整数的各位数字单调递增(每个数字不小于前一个数字)。
贪心思路分析
关键洞察:当出现数字递减时,应该将前一位数字减1,后面的所有数字设置为9。例如:
- 输入:332 → 输出:299
- 输入:1234 → 输出:1234(已经是单调递增)
- 输入:10 → 输出:9
算法步骤
- 将数字转换为字符串便于处理
- 从右向左遍历,找到第一个递减的位置
- 将该位置的前一位数字减1
- 将该位置之后的所有数字设置为9
- 转换回数字返回
算法实现
class Solution {
public:
int monotoneIncreasingDigits(int N) {
string strNum = to_string(N);
int flag = strNum.size(); // 标记从哪里开始设置为9
for (int i = strNum.size() - 1; i > 0; i--) {
if (strNum[i - 1] > strNum[i]) {
flag = i;
strNum[i - 1]--;
}
}
for (int i = flag; i < strNum.size(); i++) {
strNum[i] = '9';
}
return stoi(strNum);
}
};
复杂度分析
| 指标 | 数值 | 说明 |
|---|---|---|
| 时间复杂度 | O(n) | n为数字的位数 |
| 空间复杂度 | O(n) | 需要字符串存储数字 |
贪心算法对比分析
| 特性 | 监控二叉树 | 单调递增数字 |
|---|---|---|
| 问题类型 | 树结构优化 | 数字序列优化 |
| 贪心策略 | 从下往上放置摄像头 | 从右向左处理数字 |
| 状态定义 | 0/1/2三种状态 | 数字大小比较 |
| 遍历顺序 | 后序遍历 | 反向遍历 |
| 关键操作 | 状态转移判断 | 数字减1和置9 |
| 复杂度 | O(n)时间, O(h)空间 | O(n)时间, O(n)空间 |
实战技巧与注意事项
-
监控二叉树技巧:
- 空节点默认设置为有覆盖状态(返回2)
- 使用后序遍历确保从下往上处理
- 注意处理根节点的特殊情况
-
单调递增数字技巧:
- 必须从右向左遍历才能利用之前的结果
- 使用flag标记需要修改的位置,避免重复操作
- 字符串操作比数学运算更方便
-
贪心算法验证:
- 手动模拟测试用例验证正确性
- 思考是否存在反例
- 确保局部最优能推导出全局最优
这两个问题展示了贪心算法在不同领域的应用:一个在树结构上通过状态转移实现优化,另一个在数字序列上通过贪心修改达到目标。虽然问题领域不同,但都体现了贪心算法的核心思想——通过局部最优选择达到全局最优解。
通过深入理解这两个问题的贪心解法,我们可以更好地掌握贪心算法的应用技巧,在面对类似问题时能够快速识别贪心策略并实现高效解法。
总结
贪心算法作为一种高效的算法设计范式,在解决具有最优子结构和贪心选择性质的问题时表现出色。本文通过多个LeetCode经典问题的深入分析,展示了贪心算法在不同场景下的应用:从区间问题的排序处理,到股票问题的利润分解;从分发糖果的双向遍历,到二叉树监控的状态转移;再到数字处理的贪心修改。这些案例不仅体现了贪心算法'局部最优导致全局最优'的核心思想,也揭示了问题特性和算法选择之间的内在联系。掌握这些贪心策略的模式和实现技巧,对于提高算法设计能力和解决实际问题都具有重要意义。在实际应用中,需要根据问题特点选择合适的贪心策略,并通过数学证明或实验验证确保其正确性。
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考



