第一章:贪心算法核心思想与适用场景
贪心算法是一种在每一步选择中都采取当前状态下最优决策的算法设计策略,期望通过局部最优解达到全局最优解。该算法不回溯已经做出的选择,因此实现简单、效率高,常用于求解最优化问题。
核心思想
贪心算法的核心在于“局部最优导向全局最优”。它在每个阶段选择当前看起来最佳的选项,而不考虑未来可能产生的影响。这种策略能否成功,取决于问题是否具备贪心选择性质和最优子结构。
- 贪心选择性质:可以通过局部最优选择构造出全局最优解
- 最优子结构:一个问题的最优解包含其子问题的最优解
典型适用场景
贪心算法适用于一些特定的经典问题,例如:
- 活动选择问题
- 最小生成树(Prim 和 Kruskal 算法)
- 霍夫曼编码
- 分数背包问题
以下是一个简单的活动选择问题的 Go 语言实现示例:
// 按结束时间排序后贪心选择
package main
import (
"fmt"
"sort"
)
type Activity struct {
start, end int
}
func selectActivities(activities []Activity) []Activity {
// 按照结束时间升序排序
sort.Slice(activities, func(i, j int) bool {
return activities[i].end < activities[j].end
})
var selected []Activity
lastEnd := -1
for _, act := range activities {
if act.start >= lastEnd { // 当前活动开始时间不早于上一个结束时间
selected = append(selected, act)
lastEnd = act.end
}
}
return selected
}
| 问题类型 | 是否适用贪心 | 说明 |
|---|
| 0-1背包 | 否 | 必须动态规划,无法拆分物品 |
| 分数背包 | 是 | 可按单位价值贪心选取 |
| 最短路径(Dijkstra) | 是 | 在非负权图中具有贪心性质 |
graph LR
A[开始] --> B{按结束时间排序}
B --> C[选择第一个活动]
C --> D[遍历后续活动]
D --> E{兼容?}
E -->|是| F[加入结果]
E -->|否| D
F --> G[输出最大兼容活动集]
第二章:区间问题中的贪心策略
2.1 区间调度问题:如何选择最多的不重叠区间
在处理任务安排或资源分配时,常遇到区间调度问题:给定一组闭区间,目标是选出尽可能多的互不重叠的区间。
贪心策略的核心思想
最优解可通过贪心算法实现:按区间的结束时间升序排列,优先选择最早结束且与已选区间不重叠的区间。
算法实现
func maxNonOverlapIntervals(intervals [][]int) int {
sort.Slice(intervals, func(i, j int) bool {
return intervals[i][1] < intervals[j][1] // 按结束时间排序
})
count := 0
end := math.MinInt64
for _, interval := range intervals {
if interval[0] >= end { // 当前开始时间不早于上一个的结束时间
count++
end = interval[1]
}
}
return count
}
上述代码首先对区间按结束时间排序,随后遍历并贪心选择。参数说明:intervals 为输入的区间数组,每个元素为 [start, end];返回值为最多可选的不重叠区间数。该策略确保局部最优选择能导向全局最优解。
2.2 区间覆盖问题:最小化选择区间数覆盖目标范围
在区间覆盖问题中,目标是使用最少数量的给定区间覆盖一个连续的目标范围。该问题广泛应用于任务调度、资源分配等场景。
贪心策略的核心思想
采用贪心算法,每次选择能覆盖当前最左未覆盖点且右端点最大的区间,逐步推进覆盖范围。
算法实现示例
func minIntervalCover(intervals [][]int, targetLeft, targetRight int) int {
sort.Slice(intervals, func(i, j int) bool {
if intervals[i][0] == intervals[j][0] {
return intervals[i][1] > intervals[j][1] // 右端点降序
}
return intervals[i][0] < intervals[j][0] // 左端点升序
})
count := 0
currentRight := targetLeft
i := 0
n := len(intervals)
for currentRight < targetRight {
if i >= n || intervals[i][0] > currentRight {
return -1 // 无法覆盖
}
maxRight := currentRight
for i < n && intervals[i][0] <= currentRight {
if intervals[i][1] > maxRight {
maxRight = intervals[i][1]
}
i++
}
currentRight = maxRight
count++
}
return count
}
上述代码首先按左端点排序,优先保留右端点更大的区间。外层循环推进当前覆盖边界,内层选择可延伸最远的区间。时间复杂度为 O(n log n),主要开销在排序。贪心选择确保每一步局部最优,最终达到全局最优解。
2.3 合并区间:从无序到有序的贪心合并逻辑
在处理重叠区间问题时,贪心算法结合排序可高效实现区间合并。核心思想是先按起始位置对区间排序,再依次比较当前区间与前一个区间是否存在重叠。
算法步骤
- 将所有区间按左端点升序排列
- 初始化结果列表,加入第一个区间
- 遍历后续区间,若与前一区间重叠,则合并;否则直接添加
代码实现
func merge(intervals [][]int) [][]int {
sort.Slice(intervals, func(i, j int) bool {
return intervals[i][0] < intervals[j][0]
})
var result [][]int
result = append(result, intervals[0])
for i := 1; i < len(intervals); i++ {
last := &result[len(result)-1]
cur := intervals[i]
if cur[0] <= (*last)[1] {
(*last)[1] = max((*last)[1], cur[1]) // 扩展右边界
} else {
result = append(result, cur)
}
}
return result
}
上述代码通过排序消除无序性,利用贪心策略每次尽可能延长当前区间的右边界,确保合并最优。时间复杂度为 O(n log n),主要开销来自排序。
2.4 区间交集:双指针与贪心的结合应用
在处理区间重叠问题时,双指针与贪心策略的结合能高效求解区间交集。首先将两个区间列表按起始位置升序排列,随后使用双指针分别遍历两组区间。
算法核心逻辑
当两个区间存在交集时,交集的左端为两者左端的最大值,右端为两者右端的最小值。若左端小于等于右端,则构成有效交集。
func intervalIntersection(A [][]int, B [][]int) [][]int {
var result [][]int
i, j := 0, 0
for i < len(A) && j < len(B) {
lo := max(A[i][0], B[j][0])
hi := min(A[i][1], B[j][1])
if lo <= hi {
result = append(result, []int{lo, hi})
}
if A[i][1] < B[j][1] {
i++
} else {
j++
}
}
return result
}
上述代码中,
max 和
min 分别确定交集边界。若当前区间的右端较小,则移动对应指针,这是贪心选择:放弃无法再产生交集的区间。
时间复杂度分析
- 排序时间复杂度:O(m log m + n log n)
- 双指针扫描:O(m + n)
整体效率优于暴力匹配,适用于日程重合、资源调度等场景。
2.5 会议室安排问题:从一维到二维的贪心扩展
在经典的一维会议室安排问题中,目标是为若干会议分配最少数量的会议室,使得时间不冲突的会议可共用同一房间。该问题通常通过按开始时间排序并使用最小堆维护结束时间来高效求解。
从一维到二维的扩展
当引入会议室容量与参会人数两个维度时,问题升级为二维约束:每个会议不仅有时间区间,还指定所需人数;每间会议室有固定容量。此时需同时满足时间不重叠与容量匹配条件。
- 一维:仅考虑时间区间 [start, end)
- 二维:增加容量约束 capacity ≥ required_people
def min_meeting_rooms_2d(intervals, required, rooms):
# intervals: [(s, e)], required: [人数], rooms: [(cap, id)]
events = sorted(zip(intervals, required), key=lambda x: x[0][0])
occupied = [] # (end_time, capacity)
for (s, e), req in events:
# 尝试复用已释放且容量足够的会议室
occupied.sort()
assigned = False
for i, (end, cap) in enumerate(occupied):
if end <= s and cap >= req:
occupied[i] = (e, cap)
assigned = True
break
if not assigned:
heapq.heappush(occupied, (e, req)) # 占用新室或扩容
return len(occupied)
上述代码通过贪心策略优先复用资源,体现了从一维调度向多维资源匹配的自然延伸。
第三章:排序与重排类贪心题型
3.1 根据特定规则排序实现最优拼接(如最大数问题)
在处理数字拼接形成最大数的问题时,关键在于定义合适的排序规则。传统升序或降序无法直接适用,需比较两个数字字符串 a 和 b 拼接后的结果 ab 与 ba 的字典序大小。
核心排序逻辑
若将整数数组转换为字符串后按“ab > ba”规则降序排列,则拼接结果最大。
- 将每个整数转为字符串
- 自定义比较函数:判断 a+b 是否大于 b+a
- 按此规则排序并拼接
func largestNumber(nums []int) string {
strNums := make([]string, len(nums))
for i, num := range nums {
strNums[i] = strconv.Itoa(num)
}
sort.Slice(strNums, func(i, j int) bool {
return strNums[i]+strNums[j] > strNums[j]+strNums[i]
})
if strNums[0] == "0" {
return "0"
}
return strings.Join(strNums, "")
}
上述代码中,
sort.Slice 使用自定义比较函数决定字符串顺序。例如 "3" 和 "30" 比较时,"330" > "303",故 "3" 应排在前面。最终形成最大数值字符串。
3.2 任务重排以满足约束条件(如不含相邻重复字符)
在调度系统中,任务重排常用于避免资源冲突或满足特定约束。一个典型场景是确保相同类型的任务不连续执行,即不含相邻重复字符。
贪心策略与优先队列
采用贪心算法,每次选择当前可执行且剩余次数最多的非重复任务,可最大化调度空间。使用最大堆维护任务频次:
type Task struct {
char byte
count int
}
// 使用优先队列按count降序排列
上述结构通过动态调整任务优先级,确保高频任务被优先调度但不相邻。
调度可行性判断
设最多任务频次为 maxFreq,空槽数量为 (maxFreq - 1) * n(n为冷却间隔)。若其余任务总数不足以填满空槽,则无法完成重排。
- 输入任务序列:["A","A","A","B","B"]
- 重排结果:"A,B,A,B,A"
- 关键约束:相邻任务不相同
3.3 摆动序列中的极值点贪心选取
在摆动序列问题中,目标是找到最长的子序列,使其相邻元素之间呈现交替的上升和下降趋势。贪心策略的核心在于:**仅保留极值点**,即局部峰值或谷值,从而延长后续选择的空间。
贪心选择的正确性
每当序列出现上升趋势时,我们应尽可能延迟选择上升结束点(即峰值),因为更高的峰值能为后续下降提供更大的灵活性。同理,下降过程中应保留最低谷值。
算法实现
int wiggleMaxLength(vector<int>& nums) {
if (nums.size() < 2) return nums.size();
int up = 1, down = 1;
for (int i = 1; i < nums.size(); ++i) {
if (nums[i] > nums[i-1])
up = down + 1; // 上升趋势更新
else if (nums[i] < nums[i-1])
down = up + 1; // 下降趋势更新
}
return max(up, down);
}
该实现通过两个状态变量
up 和
down 分别记录以上升或下降结尾的最长摆动长度,避免显式寻找极值点,时间复杂度为 O(n)。
第四章:分配与匹配类贪心模型
4.1 分发饼干问题:最大化满足需求的贪心匹配
在分配饼干问题中,目标是使用有限的饼干资源,最大化满足孩子的数量。每个孩子有特定的饥饿值,每块饼干有固定的尺寸,只有当饼干尺寸大于或等于孩子的饥饿值时,该孩子才会被满足。
贪心策略的核心思想
采用贪心算法,优先将最小的可行饼干分配给当前需求最小的孩子,从而保留较大的饼干用于后续更高需求的孩子。
算法实现与代码解析
func findContentChildren(g []int, s []int) int {
sort.Ints(g)
sort.Ints(s)
i, j := 0, 0
for i < len(g) && j < len(s) {
if s[j] >= g[i] {
i++
}
j++
}
return i
}
上述代码中,
g 表示孩子饥饿值数组,
s 为饼干尺寸数组。排序后双指针遍历,
i 指向孩子,
j 指向饼干。每当找到可满足的孩子,
i 增加,最终返回满足的孩子总数。
4.2 学生分配座位:局部最优决策的应用实例
在教务系统中,学生座位分配常采用贪心策略实现局部最优。每次为当前学生选择可用座位中最靠前且满足约束(如性别间隔、视力保护)的位置。
算法逻辑与实现
def assign_seats(students, seats):
seats.sort() # 按位置优先级排序
assignment = {}
for student in students:
for seat in seats:
if is_compatible(student, seat): # 满足约束条件
assignment[student.name] = seat
seats.remove(seat)
break
return assignment
该函数按学生顺序逐个分配首个兼容座位,时间复杂度为 O(nm),其中 n 为学生数,m 为座位数。
决策过程分析
- 每步选择当前最优解,不回溯
- 适用于约束明确且局部决策影响有限的场景
- 虽不能保证全局最优,但效率高、实现简洁
4.3 跳跃游戏系列:可达性判断与步数优化
在跳跃游戏问题中,核心目标是判断从数组起始位置能否到达最后一个下标,并进一步优化跳跃步数。
可达性判断:贪心策略
通过维护当前能到达的最远边界,遍历过程中不断更新该边界。若最远边界覆盖末位,则可达。
func canJump(nums []int) bool {
farthest := 0
for i := 0; i < len(nums); i++ {
if i > farthest { // 当前位置不可达
return false
}
farthest = max(farthest, i+nums[i]) // 更新最远可达位置
}
return true
}
上述代码中,
farthest 记录当前可抵达的最远索引,
i+nums[i] 表示从位置
i 最多跳到的位置。
最小步数优化
在保证可达的前提下,使用贪心法按层扩展,每轮确定最小跳跃次数。
- 维护当前覆盖范围
end - 预计算下一跳可达最远位置
- 到达边界时步数加一
4.4 加油站问题:环形路径下的贪心可行性判定
在环形路径上,加油站问题要求判断从某一起始站点出发,能否环绕一圈。核心在于油量的动态平衡:每站加油量与到达下一站耗油量之差构成净收益。
贪心策略的可行性依据
若总加油量 ≥ 总耗油量,则至少存在一个可行起点。我们采用贪心法:从站点0开始模拟,记录当前油量余量。一旦油量不足前往下一站,重置起点为下一站,并重新累计。
算法实现
func canCompleteCircuit(gas []int, cost []int) int {
total, current, start := 0, 0, 0
for i := range gas {
diff := gas[i] - cost[i]
total += diff
current += diff
if current < 0 {
start = i + 1
current = 0
}
}
if total < 0 {
return -1
}
return start
}
代码中,
total 跟踪全局油量平衡,
current 表示从当前假设起点出发的油量余量。当
current < 0 时,说明无法抵达下一站,需更新起点。最终若
total ≥ 0,则
start 为唯一可行解。
第五章:高频题型总结与刷题建议
常见算法模式归类
在LeetCode等平台中,滑动窗口、双指针、DFS/BFS和动态规划是出现频率最高的几类题型。例如,涉及子数组或子串的问题常可用滑动窗口解决:
// Go语言实现最小覆盖子串(LeetCode 76)
func minWindow(s string, t string) string {
need := make(map[byte]int)
for i := range t {
need[t[i]]++
}
left, start, end := 0, 0, len(s)+1
matched := 0
for right := 0; right < len(s); right++ {
if cnt, exists := need[s[right]]; exists {
need[s[right]]--
if need[s[right]] == 0 {
matched++
}
}
// 缩小左边界
for matched == len(need) {
if right-left < end-start {
start, end = left, right
}
if cnt, exists := need[s[left]]; exists {
if cnt == 0 {
matched--
}
need[s[left]]++
}
left++
}
}
if end > len(s) {
return ""
}
return s[start : end+1]
}
刷题策略优化
- 按主题分阶段刷题:先集中攻克数组与字符串,再进入树结构与图遍历
- 每完成5道同类题目后,复盘解法模板,提炼通用代码框架
- 记录错题时标注思维盲点,如边界处理遗漏或状态转移方程设计错误
典型数据结构应用对照
| 场景 | 推荐结构 | 案例题号 |
|---|
| 快速查找配对元素 | 哈希表 | 1, 149 |
| 优先级调度 | 堆(优先队列) | 23, 218 |
| 括号匹配与表达式求值 | 栈 | 20, 224 |