Golang - 贪心算法 — 局部最优推导全局最优

贪心算法 --- 局部最优推导全局最优


一、贪心算法的核心思想

贪心算法的名字听起来像是"贪得无厌",但它其实是一种相当务实的策略:在每一步都选择当前看起来最好的选项,然后寄希望于这一系列局部最优选择最终拼出一个全局最优解

打个比方,你站在迷宫入口,面前有三条岔路,你看不到尽头,只能看到每条路开头的一小段。贪心策略就是选择"开头看起来最宽敞的那条",然后一直走下去不回头。这种策略不保证一定能走到终点,但在很多问题上确实管用。

贪心能成立的前提

不是所有问题都能用贪心。贪心算法要产生正确结果,问题本身必须满足两个条件:

  1. 贪心选择性质:局部最优选择不会妨碍后续构造全局最优解。换句话说,做出当前选择之后,剩下的子问题仍然可以独立求解,不需要回头修改之前的选择。
  2. 最优子结构:问题的最优解包含子问题的最优解。这个条件动态规划也要求,但贪心比 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 背包贪心失效,需要动态规划
  • 判断贪心是否适用:手推小样例,看是否存在反例

相关推荐
xcLeigh13 小时前
Go入门:变量声明的五种方式详解
java·开发语言·golang
techdashen21 小时前
Go设计取舍之三: 0.3ns每次的错误Benchmark
开发语言·后端·golang
techdashen1 天前
Go设计取舍之四: map不变时能否并发修改不同value
开发语言·后端·golang
名字还没想好☜1 天前
Go 表驱动测试实战:用 t.Run 子测试组织可维护的单元测试
golang·单元测试·log4j·go·testing
golang学习记1 天前
Claude Code写哪种变成语言最快?
golang·claude code
ShuiShenHuoLe1 天前
Go html/template 使用入门
开发语言·golang·html
Achou.Wang1 天前
深入理解go语言-第5章 并发编程——Go的灵魂
大数据·算法·golang
名字还没想好☜2 天前
Go 的 time.After 在 select 循环里内存泄漏:定时器堆积原理与 timer.Reset 正确姿势
java·数据库·golang·go·goroutine
ttwuai2 天前
GoFrame 后台日志清空失败:无 WHERE 删除为什么被拦住
前端·golang