07.算法——贪心算法

算法专题:贪心算法 (Greedy Algorithm)

相关数据结构实现用go语言实现

相关代码做题合集:https://github.com/longpi1/algorithm-pattern

1. 引言

在计算机科学中,贪心算法(Greedy Algorithm)是一种在每一步选择中都采取在当前状态下最好或最优(即最有利)的选择,从而希望导致结果是全局最好或最优的算法策略。它不从整体最优上加以考虑,而是只关注局部最优解。

简单来说,贪心算法就像一个“鼠目寸光”的决策者:它只看眼前的利益,做出当下看起来最划算的选择,并希望通过一系列这样的局部最优选择,最终能达到一个全局最优解。

2. 核心思想与基本原理

贪心算法的核心思想可以用两个字概括:“局部最优推导出全局最优”

它在每一步做出选择时,都会遵循以下原则:

  1. **做出当下看起来最优的选择 (Greedy Choice)**:不考虑未来的后果,只选择当前状态下能带来最大收益或最小成本的选项。
  2. **不回溯 (No Backtracking)**:一旦做出选择,就永远不会改变。这是贪心算法与动态规划(通常会考虑所有子问题,并可能回溯)的最大区别。

3. 贪心算法的特性

一个问题能够用贪心算法解决,通常需要满足以下两个关键性质:

3.1 贪心选择性质 (Greedy Choice Property)

  • 定义:一个全局最优解可以通过一系列局部最优(贪心)选择来达到。这意味着每一步的局部最优选择,最终都会成为全局最优解的一部分。
  • 重要性:这是贪心算法能够成功的核心前提。如果一个问题的最优解不能通过贪心选择来构建,那么贪心算法就不适用。

3.2 最优子结构性质 (Optimal Substructure)

  • 定义:一个问题的最优解包含其子问题的最优解。
  • 重要性:这个性质与动态规划所要求的性质相同。它表示问题的规模可以通过最优选择不断缩小,并且缩小后的子问题仍然具有最优解。

注意:证明一个贪心算法的正确性是至关重要的,通常采用反证法归纳法。因为“局部最优不一定能推导出全局最优”是贪心算法最常见的陷阱。

4. 贪心算法的适用场景

贪心算法通常用于解决优化问题,即在给定约束条件下找到最大或最小的某个值。它广泛应用于以下领域:

  • 调度问题:如活动选择问题、任务调度。
  • 图算法:如最小生成树(Prim算法、Kruskal算法)、最短路径(Dijkstra算法)。
  • 背包问题:分数背包问题(0/1背包问题通常不能用贪心)。
  • 货币找零问题(在标准币值下)。

5. 贪心算法的局限性

贪心算法的最大局限性在于其“短视”。它只关注当前局部最优,不考虑未来的影响,这可能导致最终结果并非全局最优。

经典反例:

  • 非标准币值下的找零问题:假设我们有币值 [1, 3, 4],要找零 6
    • 贪心策略:总是选择面值最大的硬币。
    • 过程:选择 4,剩余 2。再选择 1,剩余 1。再选择 1,剩余 0。总共使用了 3 枚硬币 (4, 1, 1)
    • 最优解:选择 3,剩余 3。再选择 3,剩余 0。总共使用了 2 枚硬币 (3, 3)
    • 结论:贪心算法在这里失败了。
  • 0/1 背包问题:物品不能被分割。
    • 贪心策略:按价值密度(价值/重量)排序,优先选择价值密度高的。
    • 反例:背包容量10。物品A(价值60, 重量10, 密度6),物品B(价值100, 重量20, 密度5),物品C(价值120, 重量30, 密度4)。
      • 贪心会选择A (价值60),然后背包满了。总价值60。
      • 最优解:B和C都不能单独装。但如果物品可以被分割,则贪心有效(见下文分数背包)。对于0/1背包,这是动态规划的经典问题。

6. 贪心算法与其它算法范式的区别

  • **与动态规划 (Dynamic Programming)**:
    • 相似点:都利用最优子结构性质。
    • 不同点:
      • 选择方式:贪心每一步只做一个最优选择,不回头。动态规划会尝试所有可能的子问题,并存储中间结果,从子问题的最优解构建原问题的最优解。
      • 适用范围:贪心适用范围更窄,要求具有贪心选择性质。动态规划适用范围更广,只要有重叠子问题和最优子结构即可。
      • 效率:贪心算法通常比动态规划更简单、更高效(时间复杂度更低),因为它无需考虑所有可能,但前提是它能给出正确解。
  • **与分治法 (Divide and Conquer)**:
    • 相似点:都将问题分解为子问题。
    • 不同点:
      • 子问题关系:分治法的子问题是独立的,互不影响。贪心算法的每一步选择会影响到后续子问题的输入。
      • 合并:分治法需要将子问题的解合并,而贪心法通过一系列选择直接构建最终解。

7. 贪心算法的通用步骤

  1. 明确目标:确定要最大化或最小化的目标。
  2. 定义贪心选择:确定在每一步中要做出什么“局部最优”的选择。
  3. 证明正确性:这是最关键的,证明局部最优选择能够导出全局最优解。这通常是最难的部分。
  4. 实现:根据定义的贪心策略编写代码。通常涉及排序和迭代。

8. Go 语言实现示例

示例1:活动选择问题 (Activity Selection Problem)

问题描述:假设有一个教室,可以进行多项活动。每项活动都有开始时间 s 和结束时间 f。你的目标是选择尽可能多的活动,使得这些活动都可以在这个教室进行,即它们的时间互不重叠。

贪心策略:总是选择结束时间最早的活动。

证明(直观):选择结束时间最早的活动,可以尽快地释放教室,为后续的活动留下最大的时间空间,从而使得能安排的活动数量最多。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
package main

import (
"fmt"
"sort"
)

// Activity 定义活动结构体
type Activity struct {
Start int
End int
}

// ByEndTime 实现了 sort.Interface 接口,用于按结束时间排序
type ByEndTime []Activity

func (a ByEndTime) Len() int { return len(a) }
func (a ByEndTime) Swap(i, j int) { a[i], a[j] = a[j], a[i] }
func (a ByEndTime) Less(i, j int) bool { return a[i].End < a[j].End }

// activitySelection 使用贪心算法解决活动选择问题
func activitySelection(activities []Activity) []Activity {
if len(activities) == 0 {
return nil
}

// 1. 贪心选择:按活动结束时间进行排序
sort.Sort(ByEndTime(activities))

// 结果集,第一个选定的活动总是结束时间最早的
selectedActivities := []Activity{activities[0]}
lastFinishTime := activities[0].End

// 2. 迭代选择:从第二个活动开始遍历
for i := 1; i < len(activities); i++ {
// 如果当前活动的开始时间晚于或等于上一个选定活动的结束时间
// 那么它们不冲突,可以选择当前活动
if activities[i].Start >= lastFinishTime {
selectedActivities = append(selectedActivities, activities[i])
lastFinishTime = activities[i].End // 更新最新活动的结束时间
}
}

return selectedActivities
}

func main() {
activities := []Activity{
{Start: 1, End: 4},
{Start: 3, End: 5},
{Start: 0, End: 6},
{Start: 5, End: 7},
{Start: 3, End: 8},
{Start: 5, End: 9},
{Start: 6, End: 10},
{Start: 8, End: 11},
{Start: 8, End: 12},
{Start: 2, End: 13},
{Start: 12, End: 14},
}

fmt.Println("原始活动列表:")
for _, a := range activities {
fmt.Printf(" [%d, %d]\n", a.Start, a.End)
}

selected := activitySelection(activities)
fmt.Println("\n选择的活动 (贪心算法):")
for _, a := range selected {
fmt.Printf(" [%d, %d]\n", a.Start, a.End)
}
// 期望输出 (可能因排序实现而异,但数量应为4): [1,4], [5,7], [8,11], [12,14]
}

示例2:货币找零问题 (Coin Change Problem) - 贪心失效的例子

问题描述:给定一些不同面额的硬币 denominations 和一个总金额 amount,找出凑成该金额所需的最少硬币数量。

贪心策略:总是选择当前剩余金额下能用的最大面额硬币。

证明(标准币值):对于像 [1, 5, 10, 25] 这样的标准美元或人民币币值,这个贪心策略是有效的。这是因为大面额硬币的价值与小面额硬币之间存在特殊的倍数关系,使得局部最优推导全局最优。

反例演示:然而,如果币值不标准,贪心策略就会失效。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
package main

import (
"fmt"
"sort"
)

// coinChangeGreedy 尝试用贪心算法解决货币找零问题
// 注意:此函数在某些非标准币值下可能无法给出最优解。
func coinChangeGreedy(denominations []int, amount int) int {
if amount < 0 {
return -1 // 无效金额
}
if amount == 0 {
return 0 // 金额为0,不需要硬币
}

// 1. 贪心选择:将硬币面额从大到小排序
sort.Sort(sort.Reverse(sort.IntSlice(denominations)))

count := 0 // 硬币数量
remainingAmount := amount

// 2. 迭代选择:尽可能多地使用当前最大面额的硬币
for _, coin := range denominations {
for remainingAmount >= coin {
remainingAmount -= coin
count++
fmt.Printf(" 选择硬币 %d, 剩余金额 %d, 硬币数量 %d\n", coin, remainingAmount, count)
}
if remainingAmount == 0 {
break // 已经找零完毕
}
}

if remainingAmount == 0 {
return count
} else {
return -1 // 无法凑出指定金额
}
}

func main() {
// 示例1:标准币值,贪心有效
denominations1 := []int{1, 5, 10, 25}
amount1 := 63
fmt.Printf("--- 标准币值找零 (贪心有效) ---\n")
fmt.Printf("面额: %v, 金额: %d\n", denominations1, amount1)
result1 := coinChangeGreedy(denominations1, amount1)
fmt.Printf("所需最少硬币数量 (贪心): %d\n", result1) // 期望: 63 = 2*25 + 1*10 + 3*1 = 2+1+3 = 6

fmt.Println("\n--- 非标准币值找零 (贪心失效) ---")
// 示例2:非标准币值,贪心失效
// 最优解是 3 + 3 = 6 (2枚硬币)
// 贪心解是 4 + 1 + 1 = 6 (3枚硬币)
denominations2 := []int{1, 3, 4}
amount2 := 6
fmt.Printf("面额: %v, 金额: %d\n", denominations2, amount2)
result2 := coinChangeGreedy(denominations2, amount2)
fmt.Printf("所需最少硬币数量 (贪心): %d\n", result2) // 期望: 3 (但最优是2)
}

示例3:分数背包问题 (Fractional Knapsack Problem)

问题描述:你有一个背包,最大载重为 capacity。有 N 件物品,每件物品有其价值 value 和重量 weight。你可以选择拿走物品的一部分。目标是最大化你带走物品的总价值。

贪心策略:总是优先选择单位重量价值最高的物品(即价值/重量 比值最大的物品)。

证明(直观):如果你能分割物品,那么单位重量价值高的物品意味着你在有限的重量限制下,能获得更多的价值。优先填满这些“高密度”价值的物品,将最大化总价值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
package main

import (
"fmt"
"sort"
)

// Item 定义物品结构体
type Item struct {
Value float64
Weight float64
Ratio float64 // 单位重量价值比
}

// ByRatio 实现了 sort.Interface 接口,用于按单位重量价值比降序排序
type ByRatio []Item

func (a ByRatio) Len() int { return len(a) }
func (a ByRatio) Swap(i, j int) { a[i], a[j] = a[j], a[i] }
func (a ByRatio) Less(i, j int) bool {
// 降序排序:Ratio 越大,Less 返回 true
return a[i].Ratio > a[j].Ratio
}

// fractionalKnapsack 使用贪心算法解决分数背包问题
func fractionalKnapsack(items []Item, capacity float64) float64 {
if capacity <= 0 || len(items) == 0 {
return 0
}

// 1. 计算每个物品的单位重量价值比
for i := range items {
if items[i].Weight > 0 {
items[i].Ratio = items[i].Value / items[i].Weight
} else {
items[i].Ratio = 0 // 或者处理为无穷大,取决于问题定义
}
}

// 2. 贪心选择:按单位重量价值比降序排序物品
sort.Sort(ByRatio(items))

totalValue := 0.0
currentWeight := 0.0

// 3. 迭代选择:遍历排序后的物品
for _, item := range items {
if currentWeight+item.Weight <= capacity {
// 如果背包还能完整装下当前物品,则完整装入
totalValue += item.Value
currentWeight += item.Weight
fmt.Printf(" 装入整个物品: 价值 %.2f, 重量 %.2f. 当前总价值: %.2f, 剩余容量: %.2f\n",
item.Value, item.Weight, totalValue, capacity-currentWeight)
} else {
// 如果背包不能完整装下,则装入一部分
remainingCapacity := capacity - currentWeight
fraction := remainingCapacity / item.Weight
totalValue += item.Value * fraction
currentWeight += remainingCapacity
fmt.Printf(" 装入部分物品: 价值 %.2f, 重量 %.2f (占比 %.2f). 当前总价值: %.2f, 剩余容量: %.2f\n",
item.Value*fraction, remainingCapacity, fraction, capacity-currentWeight)
break // 背包已满
}
}

return totalValue
}

func main() {
items := []Item{
{Value: 60, Weight: 10},
{Value: 100, Weight: 20},
{Value: 120, Weight: 30},
}
capacity := 50.0

fmt.Println("--- 分数背包问题 ---")
fmt.Printf("背包容量: %.2f\n", capacity)
fmt.Println("物品列表:")
for _, item := range items {
fmt.Printf(" 价值: %.2f, 重量: %.2f\n", item.Value, item.Weight)
}

maxValue := fractionalKnapsack(items, capacity)
fmt.Printf("\n背包中可获得的最大价值: %.2f\n", maxValue)
// 期望输出 (根据价值密度排序: (60/10=6), (100/20=5), (120/30=4))
// 会先拿价值密度最高的 (60,10),剩余容量40
// 再拿价值密度次高 (100,20),剩余容量20
// 拿 (120,30) 的 20/30 部分 -> 120 * (20/30) = 80
// 总价值 = 60 + 100 + 80 = 240
}

9. 结论

贪心算法是一种简单直观、高效的算法设计策略,但它的适用性相对有限。成功的贪心算法通常需要严格的数学证明来保证其局部最优选择能够导出全局最优解。在面对一个新问题时,首先尝试设计贪心策略,然后尝试证明或寻找反例,如果贪心不奏效,再考虑动态规划等更通用的方法。


07.算法——贪心算法
https://blog.longpi1.com/2025/06/20/07-算法贪心算法/