第一章:贪心算法核心思想与C++实现概述
贪心算法是一种在每一步选择中都采取当前状态下最优决策的算法设计策略,期望通过局部最优解达到全局最优解。该方法适用于具有“贪心选择性质”和“最优子结构”的问题。虽然贪心算法不能保证所有问题都能得到全局最优解,但在诸如活动选择、最小生成树(Prim、Kruskal)、最短路径(Dijkstra)等问题中表现优异。
贪心算法的基本步骤
- 将问题分解为一系列子问题
- 确定合适的贪心策略
- 每一步应用该策略做出局部最优选择
- 将选择结果合并为最终解
C++中的典型实现模式
以活动选择问题为例,目标是选出最多互不重叠的活动:
#include <iostream>
#include <vector>
#include <algorithm>
struct Activity {
int start, finish;
};
// 按结束时间升序排序
bool compare(Activity a, Activity b) {
return a.finish < b.finish;
}
std::vector<Activity> greedyActivitySelection(std::vector<Activity>& activities) {
std::sort(activities.begin(), activities.end(), compare);
std::vector<Activity> selected;
selected.push_back(activities[0]); // 选择第一个活动
int lastSelected = 0;
for (int i = 1; i < activities.size(); ++i) {
if (activities[i].start >= activities[lastSelected].finish) {
selected.push_back(activities[i]);
lastSelected = i;
}
}
return selected;
}
上述代码首先按活动结束时间排序,然后依次选择与已选活动不冲突的最早结束活动。这种策略确保了资源的最大利用率。
贪心算法适用性对比
| 问题类型 | 是否适用贪心 | 说明 |
|---|
| 活动选择 | 是 | 按结束时间贪心选择可得最优解 |
| 分数背包问题 | 是 | 按单位价值排序装入 |
| 0-1背包问题 | 否 | 需动态规划求解 |
第二章:经典贪心问题的C++实战解析
2.1 活动选择问题——区间调度最优解
活动选择问题是典型的贪心算法应用场景,目标是从一组具有开始和结束时间的活动中,选出最大兼容子集。
问题建模
每个活动
i由区间[s
i, f
i]表示,若两活动区间不重叠,则兼容。目标是求最大兼容活动子集。
贪心策略
采用“最早结束时间优先”策略:按结束时间升序排序,依次选择与已选活动兼容的下一个活动。
// 活动结构体
type Activity struct {
start, finish, index int
}
// 贪心选择函数
func selectActivities(acts []Activity) []int {
sort.Slice(acts, func(i, j int) bool {
return acts[i].finish < acts[j].finish
})
selected := []int{acts[0].index}
lastFinish := acts[0].finish
for i := 1; i < len(acts); i++ {
if acts[i].start >= lastFinish {
selected = append(selected, acts[i].index)
lastFinish = acts[i].finish
}
}
return selected
}
代码逻辑清晰:先排序确保结束时间递增,随后遍历并贪心选取不冲突活动。参数说明:输入为活动切片,输出为被选活动原始索引列表,便于追踪原始顺序。
2.2 分数背包问题——价值密度驱动决策
在分数背包问题中,物品可以被分割,因此我们优先选择单位重量价值最高的物品,以最大化总收益。
价值密度排序策略
通过计算每件物品的“价值密度”(价值/重量),按降序排列,贪心地装入背包直至容量耗尽。
| 物品 | 价值 | 重量 | 价值密度 |
|---|
| A | 60 | 10 | 6.0 |
| B | 100 | 20 | 5.0 |
| C | 120 | 30 | 4.0 |
算法实现
# 按价值密度降序排序并贪心选择
items = sorted([(v, w, v/w) for v, w in zip(values, weights)], key=lambda x: x[2], reverse=True)
total_value = 0
for value, weight, density in items:
if capacity >= weight:
total_value += value
capacity -= weight
else:
total_value += density * capacity # 取部分重量
break
代码首先计算每个物品的价值密度,并按该指标排序。当背包剩余容量不足以容纳整件物品时,取其部分重量,乘以密度得到对应价值。
2.3 最优装载问题——容量约束下的贪心策略
在资源分配场景中,最优装载问题要求在有限容量下尽可能多地装入物品,目标是最大化装载数量而非价值。该问题适用于集装箱运输、内存页调度等实际应用。
贪心策略的构建
核心思想是优先选择重量最轻的物品,从而为后续物品留出更多空间。此策略在物品无价值差异时可保证全局最优。
- 输入:物品重量数组
w[],载重上限 W - 输出:最多可装载的物品数
- 算法步骤:排序 → 遍历累加 → 超限终止
int optimalLoading(vector<int>& w, int W) {
sort(w.begin(), w.end()); // 升序排列
int count = 0, sum = 0;
for (int weight : w) {
if (sum + weight > W) break;
sum += weight;
count++;
}
return count;
}
上述代码首先对重量排序,确保每次选择当前最轻物品。时间复杂度为
O(n log n),主要开销来自排序。当所有物品均可装入时,循环遍历全部元素。
正确性分析
若存在更优解包含较重物品而排除较轻者,可通过替换操作构造不劣于原解的新方案,证明贪心选择的局部最优可导向全局最优。
2.4 任务调度最小化等待时间——排序与贪心结合
在多任务环境中,如何安排任务执行顺序以最小化总等待时间是一个经典优化问题。关键在于识别最优调度策略:**最短处理时间优先(Shortest Processing Time First, SPT)**。
贪心策略的正确性
若任务间无依赖且共享同一处理器,按执行时间升序排列可使后续任务的累积等待时间最小。该策略基于贪心思想:尽早完成耗时短的任务,释放资源。
算法实现示例
// Task 表示一个任务
type Task struct {
id int
time int // 执行所需时间
}
// 按 time 升序排序,实现 SPT 调度
sort.Slice(tasks, func(i, j int) bool {
return tasks[i].time < tasks[j].time
})
上述代码对任务切片进行排序,确保执行时间短的任务优先处理。排序后依次执行,可证明其总等待时间最小。
时间复杂度分析
- 排序开销:O(n log n),为主导项
- 调度过程:O(n),线性扫描
整体效率由排序决定,适用于大多数实时调度场景。
2.5 区间覆盖问题——从局部最优到全局覆盖
在调度与资源分配场景中,区间覆盖问题要求用最少的区间覆盖整个时间线。贪心策略在此类问题中表现优异:每次选择能延伸最远的合法区间。
贪心选择性质
关键在于每一步选择右端点最小但能衔接当前左端点的区间,确保局部最优推动全局最优。
算法实现
func minIntervalCover(intervals [][]int, target int) int {
sort.Slice(intervals, func(i, j int) bool {
return intervals[i][0] < intervals[j][0] // 按左端点排序
})
count, covered := 0, 0
for i := 0; i < len(intervals) && covered < target; {
if intervals[i][0] > covered {
return -1 // 断层,无法覆盖
}
maxRight := covered
for i < len(intervals) && intervals[i][0] <= covered {
if intervals[i][1] > maxRight {
maxRight = intervals[i][1] // 选择能延伸最远的
}
i++
}
covered = maxRight
count++
}
if covered < target { return -1 }
return count
}
上述代码通过排序预处理,逐轮选取可衔接且右端点最大的区间,确保覆盖连续性。参数 target 表示需覆盖的目标右端点,返回最小区间数。
第三章:图论中的贪心应用案例
3.1 Prim算法构建最小生成树——边权贪心选取
Prim算法通过贪心策略逐步扩展最小生成树(MST),从任意顶点出发,每次选择连接已访问与未访问顶点的最短边。
算法核心步骤
- 初始化一个空的MST集合和一个优先队列存储边
- 任选起始节点,标记为已访问
- 将所有邻接边加入优先队列
- 取出权重最小的边,若目标节点未访问,则加入MST
- 重复直至所有节点被覆盖
代码实现(Python)
import heapq
def prim(graph, start):
mst = []
visited = set([start])
edges = [(weight, start, to) for to, weight in graph[start]]
heapq.heapify(edges)
while edges:
weight, frm, to = heapq.heappop(edges)
if to not in visited:
visited.add(to)
mst.append((frm, to, weight))
for neighbor, w in graph[to]:
if neighbor not in visited:
heapq.heappush(edges, (w, to, neighbor))
return mst
上述代码使用最小堆维护候选边,确保每次取出当前最短边。graph以邻接表形式存储,每条边按权重排序,时间复杂度为O(E log E)。
3.2 Dijkstra单源最短路径——距离优先扩展
Dijkstra算法通过贪心策略,从源点出发逐步扩展最近的未访问节点,确保每一步都更新到当前最短路径。
算法核心流程
使用优先队列维护各节点的当前最短距离,每次取出距离最小的节点进行松弛操作。
- 初始化源点距离为0,其余为无穷大
- 将源点加入优先队列
- 循环直到队列为空:取出最小距离节点u,遍历其邻接边(u,v)
- 若通过u到v的路径更短,则更新dist[v]
代码实现(Go)
type Edge struct {
to, weight int
}
type Node struct {
vertex, dist int
}
func Dijkstra(graph [][]Edge, start int) []int {
n := len(graph)
dist := make([]int, n)
for i := range dist {
dist[i] = math.MaxInt32
}
dist[start] = 0
pq := &MinHeap{}
heap.Push(pq, Node{start, 0})
for pq.Len() > 0 {
u := heap.Pop(pq).(Node)
if u.dist > dist[u.vertex] {
continue
}
for _, e := range graph[u.vertex] {
if newDist := dist[u.vertex] + e.weight; newDist < dist[e.to] {
dist[e.to] = newDist
heap.Push(pq, Node{e.to, newDist})
}
}
}
return dist
}
上述代码中,优先队列确保每次扩展距离源点最近的节点,实现“距离优先”策略。dist数组记录从源点到各节点的最短距离,松弛操作持续优化路径估计值。
3.3 贪心着色法解决图着色问题——启发式节点染色
图着色问题是经典的NP难问题,贪心着色法提供了一种高效的启发式求解策略。其核心思想是按特定顺序为节点分配可选的最小颜色编号,确保相邻节点颜色不同。
算法流程
- 选择一个节点排序策略(如按度降序)
- 依次处理每个节点
- 为其分配未被邻接点使用的最小正整数颜色
伪代码实现
def greedy_coloring(graph):
colors = {}
for node in sorted(graph.nodes, key=lambda x: -len(graph.adj[x])):
used_colors = {colors[neigh] for neigh in graph.adj[node] if neigh in colors}
color = 1
while color in used_colors:
color += 1
colors[node] = color
return colors
该实现优先处理高连接度节点,
used_colors集合记录邻居已用颜色,循环查找最小可用颜色值。
性能对比
| 策略 | 时间复杂度 | 最优性 |
|---|
| 随机顺序 | O(V + E) | 差 |
| 度降序 | O(V log V + E) | 较好 |
第四章:字符串与数组中的贪心技巧
4.1 跳跃游戏——可达范围内的最远拓展
在跳跃游戏中,给定一个非负整数数组,每个位置代表从该位置最多可跳跃的步数。目标是判断是否能到达最后一个位置。
贪心策略的核心思想
维护当前可达的最远边界,遍历过程中不断更新该边界。只要索引未超出边界,就持续扩展最远可达位置。
func canJump(nums []int) bool {
farthest := 0
for i := range nums {
if i > farthest {
return false // 当前位置不可达
}
farthest = max(farthest, i+nums[i]) // 更新最远可达位置
}
return true
}
上述代码中,
farthest 记录当前可到达的最远下标。遍历时,若
i 超出
farthest,说明无法继续前进;否则利用
nums[i] 更新最远边界。
算法复杂度分析
- 时间复杂度:O(n),仅需一次遍历
- 空间复杂度:O(1),仅使用常量额外空间
4.2 加油站问题——环形路径中的起点判定
在环形路径中判断能否绕行一周的加油站问题,核心在于总油量与总消耗的平衡关系。若总油量小于总耗油量,则无解;否则必存在一个可行起点。
贪心策略分析
从任意点出发,维护当前剩余油量。若在某站无法继续前行,则说明起点应设在其后方站点,通过一次遍历即可确定最优起点。
代码实现
func canCompleteCircuit(gas []int, cost []int) int {
total, current, start := 0, 0, 0
for i := 0; i < len(gas); i++ {
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 时,说明起点需后移至
i+1。
4.3 拆分最大乘积——整数分解的贪心规律
在整数拆分问题中,目标是将一个正整数分解为多个正整数之和,使得这些整数的乘积最大。数学分析表明,尽可能多地拆分为 2 和 3 能够最大化乘积,尤其优先使用 3。
贪心策略的核心规律
当 n ≥ 4 时,拆分为 3 的收益高于 2 或 1。最优策略如下:
- 若 n % 3 == 0,则全拆为 3
- 若 n % 3 == 1,则拆出两个 2,其余为 3(避免 3+1)
- 若 n % 3 == 2,则拆一个 2,其余为 3
代码实现与逻辑分析
func integerBreak(n int) int {
if n <= 3 {
return n - 1
}
quotient := n / 3
remainder := n % 3
switch remainder {
case 0:
return pow(3, quotient)
case 1:
return pow(3, quotient-1) * 4 // 3+1 替换为 2+2
default:
return pow(3, quotient) * 2
}
}
该算法时间复杂度 O(1),利用数学规律避免暴力枚举。关键在于识别 3 的最优性,并通过余数调整边界情况。
4.4 移除K个数字构最小数——单调栈与贪心删减
在给定一个以字符串形式表示的非负整数和整数 `k`,目标是移除 `k` 个数字,使得剩下的数字构成的新数最小。该问题可通过**贪心策略结合单调栈**高效解决。
贪心思想分析
从左到右遍历每一位数字,若当前位比栈顶元素小,则删除栈顶(更“大”的前一位),从而让更小的数字提前。此操作确保高位尽可能小,符合最小数构造逻辑。
算法实现
使用单调递增栈维护当前最优序列:
def removeKdigits(num, k):
stack = []
for digit in num:
while k > 0 and stack and stack[-1] > digit:
stack.pop()
k -= 1
stack.append(digit)
# 处理剩余需删除的位(如全递增)
stack = stack[:-k] if k > 0 else stack
result = ''.join(stack).lstrip('0')
return result if result else '0'
上述代码中,`stack` 模拟单调栈行为,每次弹出破坏单调性的较大值;最后去除前导零并处理空结果。时间复杂度为 O(n),每个元素最多入栈出栈一次。
第五章:贪心策略的边界与工程实践思考
何时不应使用贪心算法
贪心策略在局部最优选择能导向全局最优解时极为高效,但其局限性同样显著。例如在0-1背包问题中,按价值密度排序贪心选取物品无法保证最优解,因无法回溯调整已做决策。
工程中的近似解权衡
在资源调度系统中,我们曾尝试用贪心算法分配服务器负载。尽管无法达到理论最优,但在98%的场景下误差小于5%,响应延迟降低40%。以下是核心调度逻辑:
// 按请求耗时升序分配任务
sort.Slice(tasks, func(i, j int) bool {
return tasks[i].Duration < tasks[j].Duration
})
for _, task := range tasks {
selectedServer := findLeastLoaded(servers)
assign(task, selectedServer)
}
贪心与动态规划的对比决策
| 场景 | 推荐策略 | 理由 |
|---|
| 活动选择问题 | 贪心 | 满足贪心选择性质,O(n log n) |
| 最短路径(含负权) | 动态规划 | 贪心无法处理负权边 |
实际系统中的混合策略
在CDN内容分发网络中,结合贪心与反馈机制:初始部署采用贪心选择最近节点,运行时收集延迟数据并周期性重优化。该方案使缓存命中率提升至92%,同时保持低计算开销。
- 贪心适用于实时性要求高、数据规模大的场景
- 需设计监控指标验证贪心解的质量
- 建议在关键系统中保留回滚或再优化通道