贪心算法 --- 局部最优推导全局最优
一、贪心算法的核心思想
贪心算法的名字听起来像是"贪得无厌",但它其实是一种相当务实的策略:在每一步都选择当前看起来最好的选项,然后寄希望于这一系列局部最优选择最终拼出一个全局最优解。
打个比方,你站在迷宫入口,面前有三条岔路,你看不到尽头,只能看到每条路开头的一小段。贪心策略就是选择"开头看起来最宽敞的那条",然后一直走下去不回头。这种策略不保证一定能走到终点,但在很多问题上确实管用。
贪心能成立的前提
不是所有问题都能用贪心。贪心算法要产生正确结果,问题本身必须满足两个条件:
- 贪心选择性质:局部最优选择不会妨碍后续构造全局最优解。换句话说,做出当前选择之后,剩下的子问题仍然可以独立求解,不需要回头修改之前的选择。
- 最优子结构:问题的最优解包含子问题的最优解。这个条件动态规划也要求,但贪心比 DP 更严格------它还要求"局部最优"本身就是通往全局最优的正确分岔口。
贪心 vs 动态规划 vs 回溯
| 特征 | 贪心 | 动态规划 | 回溯 |
|---|---|---|---|
| 选择方式 | 只看当前最优 | 看所有子问题的最优 | 试遍所有可能 |
| 是否回头 | 不回头,一步到位 | 不回头,但记住了所有状态 | 回头,撤销无效选择 |
| 时间复杂度 | 通常 O(n) 或 O(n log n) | O(n²) 或更高 | 可能指数级 |
| 适用范围 | 狭窄,需要严格证明 | 广泛,有最优子结构就行 | 最广,什么都能试 |
| 举例 | 活动选择、零钱兑换(特殊面额) | 背包问题、最长子序列 | 八皇后、全排列 |
关键区别:贪心一旦选了就不改,DP 会把所有状态都算一遍再取最优,回溯则是试错然后撤回。贪心最轻最快,但门槛最高------你得证明"只看眼前最优"确实不会误入歧途。
二、经典问题一:活动选择(区间调度)
问题描述
会议室一天有 N 个活动要安排,每个活动有开始时间和结束时间。你要选出尽可能多的活动,且它们之间不能有时间冲突(一个活动结束之后才能开始下一个)。
贪心策略
直觉上,要让后续活动有更多时间可用,就应该尽早结束当前活动 。所以策略是:按结束时间升序排序,每次选结束最早且不与已选活动冲突的活动。
为什么这个策略是对的?
考虑排序后的活动序列,假设我们选了结束时间最早的活动 A(结束时间为 e₁),而存在另一种方案选了活动 B(结束时间为 e₂,且 e₂ > e₁)。由于 A 比 B 更早结束,所有在方案 B 之后能安排的活动,在方案 A 之后也一定能安排(因为时间窗口更大)。所以选 A 不会比选 B 差------这就是贪心选择性质的证明思路。
Go 实现
go
package main
import (
"fmt"
"sort"
)
// Activity 表示一个活动,有开始和结束时间
type Activity struct {
Name string
Start int
End int
}
// SelectActivities 贪心选择最多不重叠活动
func SelectActivities(activities []Activity) []Activity {
if len(activities) == 0 {
return nil
}
// 按结束时间升序排序
sort.Slice(activities, func(i, j int) bool {
return activities[i].End < activities[j].End
})
selected := []Activity{activities[0]}
lastEnd := activities[0].End
// 遍历剩余活动,选择不冲突的
for i := 1; i < len(activities); i++ {
if activities[i].Start >= lastEnd {
selected = append(selected, activities[i])
lastEnd = activities[i].End
}
}
return selected
}
func main() {
acts := []Activity{
{"晨会", 1, 4},
{"评审", 3, 5},
{"培训", 0, 6},
{"午餐会", 5, 7},
{"复盘", 3, 8},
{"周报", 5, 9},
{"分享", 6, 10},
{"答疑", 8, 11},
{"团建", 8, 12},
{"会议X", 2, 13},
{"总结", 12, 14},
}
result := SelectActivities(acts)
fmt.Println("最多可安排的活动数量:", len(result))
for _, a := range result {
fmt.Printf(" %s [%d, %d]\n", a.Name, a.Start, a.End)
}
}
运行输出(预期):
最多可安排的活动数量: 4
晨会 [1, 4]
午餐会 [5, 7]
答疑 [8, 11]
总结 [12, 14]
三、经典问题二:分数背包
问题描述
你有一个容量为 W 的背包和 N 个物品,每个物品有重量 wᵢ 和价值 vᵢ。分数背包允许你拿走物品的一部分(比如只拿半块金条),目标是让背包中物品总价值最大。
贪心策略
既然可以拆分,最聪明的做法就是优先装性价比最高的物品(单位重量价值 vᵢ/wᵢ 最大)。按性价比降序排序,依次往背包里装,装满为止。
为什么这个策略是对的?
如果背包里还有空位,而存在一个性价比更高的物品没有被装进去,那我们就应该用它来替换已经装进去的性价比较低的物品(或其一部分),这样总价值会增加。所以最优解一定是先把性价比最高的装满------这就是贪心选择性质。
Go 实现
go
package main
import (
"fmt"
"sort"
)
// Item 表示一个可选物品
type Item struct {
Name string
Weight float64
Value float64
}
// UnitValue 计算单位重量价值
func (it Item) UnitValue() float64 {
return it.Value / it.Weight
}
// FractionalKnapsack 分数背包贪心算法
func FractionalKnapsack(items []Item, capacity float64) (float64, []string) {
// 按单位价值降序排序
sort.Slice(items, func(i, j int) bool {
return items[i].UnitValue() > items[j].UnitValue()
})
totalValue := 0.0
remaining := capacity
taken := []string{}
for _, item := range items {
if remaining <= 0 {
break
}
if item.Weight <= remaining {
// 整个物品都能装进去
totalValue += item.Value
remaining -= item.Weight
taken = append(taken, fmt.Sprintf("%s: 全部 (价值 %.1f)", item.Name, item.Value))
} else {
// 只能装一部分
fraction := remaining / item.Weight
totalValue += item.Value * fraction
taken = append(taken, fmt.Sprintf("%s: %.1f%% (价值 %.1f)", item.Name, fraction*100, item.Value*fraction))
remaining = 0
}
}
return totalValue, taken
}
func main() {
items := []Item{
{"金条", 10, 60},
{"银块", 20, 100},
{"铜锭", 30, 120},
}
capacity := 50.0
totalValue, taken := FractionalKnapsack(items, capacity)
fmt.Printf("背包容量: %.1f\n", capacity)
fmt.Printf("最大总价值: %.1f\n", totalValue)
for _, t := range taken {
fmt.Printf(" %s\n", t)
}
}
运行输出(预期):
背包容量: 50
最大总价值: 240.0
金条: 全部 (价值 60.0)
银块: 全部 (价值 100.0)
铢锭: 66.7% (价值 80.0)
四、贪心失效的例子:0-1 背包
分数背包允许拆分物品,贪心有效。但 0-1 背包(物品不可拆分,要么全拿要么不拿)贪心就不一定最优了。
反例:背包容量 50,物品如下:
| 物品 | 重量 | 价值 | 性价比 |
|---|---|---|---|
| A | 10 | 60 | 6.0 |
| B | 20 | 100 | 5.0 |
| C | 30 | 120 | 4.0 |
贪心按性价比排序,先拿 A(10),再拿 B(20),还剩 20 不够拿 C,总价值 160。
但如果拿 B+C(20+30=50),总价值 220,远比贪心结果好!
原因 :0-1 背包中,拿了 A 之后剩余容量的最优子问题不是独立的------它取决于你拿了什么。贪心选择破坏了后续选择的空间,这就是"贪心选择性质"不满足的典型场景。
0-1 背包只能用动态规划求解,后面会学到。
五、如何判断一个问题适合贪心
| 信号 | 适合贪心 | 不适合贪心 |
|---|---|---|
| 局部选择是否有"无后效性" | 是:选完之后子问题独立 | 否:选完之后剩余空间被约束 |
| 是否有反例推翻贪心策略 | 找不到 | 能找到(如 0-1 背包) |
| 典型题型 | 区间调度、最小生成树、Dijkstra | 背包(0-1)、旅行商、复杂状态转移 |
实用判断法:手推 3-5 个小样例,看每一步贪心选择是否总能导向最优。如果某个样例里贪心选错了,那这个问题大概率需要 DP 或回溯。
六、本章小结
- 贪心算法每一步只做当前最优选择,不回头,速度极快
- 贪心生效需要两个条件:贪心选择性质 + 最优子结构
- 活动选择问题:按结束时间排序,每次选最早结束且不冲突的活动
- 分数背包:按性价比排序,优先装性价比最高的物品
- 0-1 背包贪心失效,需要动态规划
- 判断贪心是否适用:手推小样例,看是否存在反例