一. 动态规划(Dynamic Programming)
难点:状态转移方程的构建和初始化条件的设计
典型问题:01背包问题
分析:
状态定义 dp[i][j]
表示前i
个物品放入容量为j
的背包的最大价值。状态转移需要判断是否选择当前物品。
#include <iostream>
#include <algorithm>
using namespace std;
int main() {
int n, capacity;
cin >> n >> capacity;
int weight[n], value[n];
int dp[n+1][capacity+1] = {0}; // 初始化为0
for (int i = 0; i < n; i++)
cin >> weight[i] >> value[i];
for (int i = 1; i <= n; i++) {
for (int j = 0; j <= capacity; j++) {
if (j >= weight[i-1]) // 能装下当前物品
dp[i][j] = max(dp[i-1][j], dp[i-1][j - weight[i-1]] + value[i-1]);
else
dp[i][j] = dp[i-1][j]; // 不装当前物品
}
}
cout << dp[n][capacity];
return 0;
}
关键点:
-
状态转移方程:
max(不选当前物品,选当前物品)
-
时间复杂度:O(n * capacity),需注意数据规模是否允许。
1. 矩阵取数游戏(P1005)
-
题目类型:区间DP + 高精度
-
难点:需要处理大数运算(
__int128
或高精度类),状态转移涉及从两端取数的最优解。 -
状态定义:
dp[i][j]
表示区间[i, j]
取数的最大得分。 -
转移方程:
dp[i][j] = max(dp[i+1][j] * 2 + a[i], dp[i][j-1] * 2 + a[j]);
-
代码参考:洛谷P1005题解。
2. 石子合并(P1880)
-
题目类型:区间DP + 环形处理
-
难点:环形问题需展开为链式(复制数组),并枚举分割点
k
。 -
状态定义:
dp[i][j]
表示合并区间[i, j]
的最小/最大代价。 -
转移方程:
dp[i][j] = min(dp[i][k] + dp[k+1][j] + sum[i][j]);
-
代码参考:洛谷区间DP题单。
3. 过河卒(P1002)
-
题目类型:坐标DP + 路径计数
-
难点:处理马的控制点(不可达位置),初始化边界条件。
-
状态定义:
dp[i][j]
表示到达(i, j)
的路径数。 -
转移方程:
dp[i][j] = dp[i-1][j] + dp[i][j-1]; // 非马控制点
4. 滑雪(P1434)
-
题目类型:记忆化搜索(DFS + DP)
-
难点:需递归遍历四个方向,记忆化存储已计算的结果。
-
状态定义:
dp[x][y]
表示从(x, y)
出发的最长滑坡长度。 -
转移方程:
dp[x][y] = max(dfs(nx, ny) + 1); // (nx, ny) 为合法邻接点
-
代码参考:动态规划与记忆化搜索题单。
5. 关路灯(P1220)
-
题目类型:区间DP + 状态附加维度
-
难点:需记录当前区间和位置(左/右端点),分类讨论移动方向。
-
状态定义:
dp[i][j][0/1]
表示关闭[i, j]
区间的灯后位于左/右端点的最小功耗。 -
转移方程:
dp[i][j][0] = min(dp[i+1][j][0] + cost1, dp[i+1][j][1] + cost2);
-
代码参考:洛谷区间DP题单。
6. 合唱队形(P3205)
-
题目类型:区间DP + 双端插入
-
难点:需记录最后插入的元素是左端还是右端。
-
状态定义:
dp[i][j][0/1]
表示区间[i, j]
以左/右端结尾的排列方案数。 -
转移方程:
if (a[i] < a[i+1]) dp[i][j][0] += dp[i+1][j][0];
-
代码参考:洛谷区间DP题单。
例题说明 :
-
入门题:过河卒(P1002)、数字三角形(P1216)。
-
进阶题:石子合并(P1880)、关路灯(P1220)。
-
高难度题:矩阵取数(P1005)、滑雪(P1434)。
二. 图论 - 最短路径(Dijkstra算法)
难点:优先队列优化和松弛操作的理解
代码示例:
#include <vector>
#include <queue>
using namespace std;
const int INF = 0x3f3f3f3f;
vector<pair<int, int>> graph[1001]; // 邻接表:graph[u] = {v, weight}
int dist[1001]; // 最短距离数组
void dijkstra(int start) {
priority_queue<pair<int, int>, vector<pair<int, int>>, greater<>> pq;
fill(dist, dist + 1001, INF);
dist[start] = 0;
pq.push({0, start});
while (!pq.empty()) {
int u = pq.top().second;
int d = pq.top().first;
pq.pop();
if (d > dist[u]) continue; // 已找到更优路径,跳过
for (auto &edge : graph[u]) {
int v = edge.first, w = edge.second;
if (dist[v] > dist[u] + w) { // 松弛操作
dist[v] = dist[u] + w;
pq.push({dist[v], v});
}
}
}
}
关键点:
-
使用优先队列优化,每次取出距离最小的点。
-
松弛操作:若通过当前点
u
到达v
更近,则更新dist[v]
。 -
注意:Dijkstra不能处理负权边!
1. P4779 【模板】单源最短路径(标准版)
-
题目描述:给定有向图,求从源点出发到所有点的最短路径。
-
算法:Dijkstra堆优化(优先队列实现)。
-
关键点:
-
使用邻接表存图。
-
优先队列优化,时间复杂度为O((V+E)logV)。
-
注意不能在更新时判断
vis
,而应在出队时判断。
-
-
代码参考:见洛谷P4779题解。
2. P3371 【模板】单源最短路径(弱化版)
-
题目描述:与P4779类似,但数据范围较小,适合练习基础Dijkstra。
-
算法:Dijkstra(未优化或优先队列优化)。
-
关键点:
-
弱化版允许使用未优化的Dijkstra(O(V²))。
-
输出要求中,不可达点输出2³¹-158。
-
-
代码参考:见洛谷P3371题解。
3. P1339 Heat Wave G
-
题目描述:无向图,求从起点到终点的最短路。
-
算法:Dijkstra或SPFA(题目未禁用SPFA时)。
-
关键点:
-
使用邻接矩阵存图(适合稠密图)。
-
注意无向图的边需双向处理。
-
-
代码参考:见洛谷P1339题解。
4. P1629 邮递员送信
-
题目描述:求从源点到所有点的最短路及所有点返回源点的最短路之和。
-
算法:Dijkstra + 反向建图。
-
关键点:
-
正向图跑一次Dijkstra,反向图再跑一次。
-
反向图可将“返回路径”转化为单源最短路问题。
-
-
代码参考:见洛谷题单图论2-2。
5. P2296 寻找道路
-
题目描述:在满足路径点出边均与终点连通的条件下,求最短路径。
-
算法:拓扑排序 + Dijkstra/BFS。
-
关键点:
-
先用反向图拓扑排序筛选合法点。
-
再在合法点上跑最短路6。
-
-
代码参考:见洛谷P2296题解。
6. P1144 最短路计数
-
题目描述:求从起点到各点的最短路径条数(边权为1)。
-
算法:BFS或Dijkstra(带DP统计)。
-
关键点:
-
若
dis[y] == dis[x]+1
,则累加路径数。 -
需模100003输出4。
-
-
代码参考:见洛谷题单图论2-2。
题目编号 | 名称 | 算法 | 难度 |
---|---|---|---|
P4779 | 单源最短路径(标准版) | Dijkstra堆优化 | 普及+/提高 |
P3371 | 单源最短路径(弱化版) | Dijkstra基础 | 普及- |
P1339 | Heat Wave G | Dijkstra/SPFA | 普及 |
P1629 | 邮递员送信 | Dijkstra + 反向图 | 普及+/提高 |
P2296 | 寻找道路 | 拓扑排序 + 最短路 | 提高+/省选- |
P1144 | 最短路计数 | BFS/Dijkstra + DP | 普及+/提高 |
例题说明: 建议按顺序练习,从模板题(P3371、P4779)开始,逐步挑战综合应用题(如P2296)。更多题目可参考洛谷题单图论2-2。
三. 深度优先搜索(DFS)与剪枝
难点:剪枝条件的合理设计
典型问题:全排列问题
代码示例:
#include <iostream>
using namespace std;
int n, path[10];
bool visited[10];
void dfs(int step) {
if (step == n) { // 终止条件
for (int i = 0; i < n; i++)
cout << path[i] << " ";
cout << endl;
return;
}
for (int i = 1; i <= n; i++) {
if (!visited[i]) { // 剪枝:已用过的数字不再选
visited[i] = true;
path[step] = i;
dfs(step + 1);
visited[i] = false; // 回溯
}
}
}
int main() {
cin >> n;
dfs(0);
return 0;
}
关键点:
-
使用
visited
数组避免重复选择,实现剪枝。 -
回溯时恢复状态(
visited[i] = false
)。
1、全排列类问题
例题1. P1255 数楼梯(改)
- 问题描述:计算走n阶楼梯的方案数,每次可走1或2阶。
- DFS实现:递推公式
f(n) = f(n-1) + f(n-2)
,边界条件f(0)=f(1)=1
。 - 剪枝技巧:直接递归的时间复杂度为O(2ⁿ),但本题数据范围较小(n ≤ 10),无需剪枝。优化方法可采用记忆化搜索或递推法,将复杂度降至O(n)。
例题2. 火星人排列问题(类似洛谷题目)
- 问题描述:给定一个排列,求其后的第r个字典序排列。
- DFS实现:通过回溯生成全排列,记录当前排列顺序。
- 剪枝技巧:找到目标排列后,标记并立即剪枝后续所有分支,避免无效搜索
2、组合优化问题
例题3. P2036 [COCI2008-2009#2] PERKET
- 问题描述:选择若干调料,最小化总酸度与总苦度的绝对差。
- DFS实现:枚举每种调料的“选”与“不选”,记录当前酸度积和苦度和。
- 剪枝技巧:
- 可行性剪枝:必须至少选一种调料,否则结果无效。
- 最优性剪枝:若当前酸度积与苦度差的绝对值已超过已找到的最小值,则提前终止该分支。
例题4. P1433 吃奶酪
- 问题描述:在平面上找到吃掉所有奶酪的最短路径。
- DFS实现:枚举所有可能的路径顺序,计算总距离。
- 剪枝技巧:
- 最优性剪枝:实时记录当前最短路径长度,若某次搜索的累积距离已超过该值,立即回溯。
- 状态缓存:通过标记已访问的奶酪避免重复计算。
3、迷宫与路径问题
例题5. P1605 迷宫
- 问题描述:从起点到终点的可行路径数,存在障碍物。
- DFS实现:向四个方向递归搜索,标记已访问的位置。
- 剪枝技巧:
- 边界检查:超出迷宫范围或遇到障碍物时终止分支。
- 状态回溯:递归返回后恢复当前位置的未访问状态,避免路径重复。
例题6. P1443 马的遍历
- 问题描述:计算马从起点到棋盘各点的最少步数。
- DFS/BFS实现:通常用BFS更高效,但DFS可通过剪枝优化。
- 剪枝技巧:若当前步数已超过已知到达某点的最短步数,终止该分支。
4、经典剪枝策略总结
- 可行性剪枝:提前判断当前路径是否可能满足条件(如越界、不合法状态)。
- 示例:迷宫问题中遇到障碍物立即终止搜索。
- 最优性剪枝:在求最优解的问题中,若当前路径的代价已超过已知最优解,提前回溯。
- 示例:吃奶酪问题中实时更新最短路径并剪枝。
- 状态去重:通过哈希或标记数组避免重复访问同一状态。
- 示例:全排列问题中通过
vis
数组标记已选元素。
- 示例:全排列问题中通过
- 搜索顺序优化:优先搜索更可能接近答案的分支,减少无效计算。
- 示例:PERKET问题中按字典序优先选择调料组合。
四. 并查集(Union-Find)
难点:路径压缩和按秩合并的优化
代码示例:
int parent[1001];
int rank[1001];
void init() {
for (int i = 0; i < 1001; i++) {
parent[i] = i;
rank[i] = 1;
}
}
int find(int x) { // 路径压缩
if (parent[x] != x)
parent[x] = find(parent[x]);
return parent[x];
}
void union_set(int x, int y) { // 按秩合并
int rootx = find(x), rooty = find(y);
if (rootx == rooty) return;
if (rank[rootx] > rank[rooty])
parent[rooty] = rootx;
else {
parent[rootx] = rooty;
if (rank[rootx] == rank[rooty])
rank[rooty]++;
}
}
关键点:
-
find
函数通过递归实现路径压缩,降低树高。 -
union_set
根据秩的大小合并,避免树退化。
1、基础并查集模板题
例题1. P1551 亲戚
- 问题描述:判断多对人物是否属于同一家族(即是否连通)。
- 实现思路:
- 初始化并查集,每个节点的父节点指向自己。
- 合并所有输入的亲戚关系。
- 对每个查询使用
find
操作判断两个节点是否属于同一集合。
- 关键优化:
- 路径压缩:在
find
函数中通过递归或循环将节点直接连接到根节点,降低树的高度。 - 时间复杂度:单次操作接近 O(1)。
- 路径压缩:在
- 代码要点:
int find(int x) { if (father[x] == x) return x; return father[x] = find(father[x]); // 路径压缩 }
例题2. P3367 【模板】并查集
- 问题描述:实现并查集的合并与查询操作。
- 实现思路:
- 操作类型为
1
时合并两个集合,为2
时查询两个元素是否同属一个集合。
- 操作类型为
- 代码特点:
- 与 P1551 类似,但输入格式包含动态操作指令,适合作为并查集的标准模板题。
2、并查集扩展应用
例题3. P1892 [BOI2003] 团伙
- 问题描述:处理朋友和敌人两种关系,朋友的朋友是朋友,敌人的敌人也是朋友。
- 实现思路:
- 扩展域并查集:将每个元素 i 拆分为两个域:朋友域 i 和敌人域 i+n。
- 合并逻辑:
- 若 a 和 b 是朋友,合并 a 和 b。
- 若 a 和 b 是敌人,合并 a 与 b+n、b 与 a+n。
- 关键分析:
- 通过反集(敌人域)实现敌人的敌人合并为朋友,例如 a 的敌人域与 b 的朋友域合并。
- 代码片段:
if (op == 'E') { merge(a, b + n); // a的敌人域与b的朋友域合并 merge(b, a + n); }
例题4. P1536 村村通
- 问题描述:计算使所有村庄连通最少需要新增的道路数。
- 实现思路:
- 合并所有已有道路的村庄。
- 统计最终独立集合的数量 k,答案即 k−1。
- 剪枝技巧:
- 路径压缩:优化查询效率。
- 连通块统计:遍历所有节点,统计根节点数量。
3、带权并查集
例题5. P1196 [NOI2002] 银河英雄传说
- 问题描述:计算战舰队列中两艘战舰之间的间隔,支持合并队列与查询距离。
- 实现思路:
- 带权并查集:维护每个节点到根节点的距离 d[x]。
- 合并时更新距离权值,路径压缩时同步更新权值。
- 关键代码:
int find(int x) { if (fa[x] == x) return x; int root = find(fa[x]); d[x] += d[fa[x]]; // 更新距离权值 return fa[x] = root; }
4、总结与优化策略
- 基础操作:路径压缩和按秩合并是并查集的核心优化,能将时间复杂度降至接近 O(1)。
- 扩展应用:
- 扩展域:处理多关系问题(如敌友关系)。
- 带权并查集:维护节点间的附加信息(如距离、排名)。
- 适用场景:
- 连通性判断(亲戚、网络连接)。
- 动态合并与查询(图论、集合关系维护)。
五. 贪心算法(Greedy)
难点:正确性证明和贪心策略的选择
典型问题:区间调度(选择最多不重叠区间)
代码示例:
#include <vector>
#include <algorithm>
using namespace std;
struct Interval {
int start, end;
};
bool compare(Interval &a, Interval &b) {
return a.end < b.end; // 按结束时间排序
}
int maxIntervals(vector<Interval> intervals) {
sort(intervals.begin(), intervals.end(), compare);
int count = 0, last_end = -1;
for (auto &itv : intervals) {
if (itv.start >= last_end) {
count++;
last_end = itv.end;
}
}
return count;
}
关键点:
-
贪心策略:优先选择结束时间早的区间。
-
正确性证明:局部最优(选最早结束)导致全局最优。
1、基础贪心问题
例题1. P1090 [NOIP 2004 提高组] 合并果子
- 问题描述:将多堆果子合并成一堆,每次合并消耗体力等于两堆重量之和,求最小总消耗。
- 贪心策略:每次选择当前最小的两堆合并,重复直到只剩一堆。使用优先队列(小根堆)优化选择过程。
- 实现代码:
priority_queue<int, vector<int>, greater<int>> pq; // 插入所有果子重量到优先队列 while (pq.size() > 1) { int a = pq.top(); pq.pop(); int b = pq.top(); pq.pop(); sum += a + b; pq.push(a + b); }
- 关键点:优先队列保证每次取最小两堆,时间复杂度 O(n logn)。
例题2. P4995 跳跳!
- 问题描述:从地面跳到不同高度的平台,每次跳跃消耗体力为高度差的平方,求最大总消耗。
- 贪心策略:排序后交替选择最大和最小的平台,形成“最大差值跳跃链”。
- 代码片段:
sort(s.begin(), s.end()); while (!s.empty()) { last = s.back(); s.pop_back(); sum += (last - first) * (last - first); if (!s.empty()) { first = s.front(); s.pop_front(); sum += (last - first) * (last - first); } }
- 分析:通过排序和双端操作最大化每次跳跃的差值,时间复杂度 O(nlogn)。
2、经典贪心模型
例题3. P1223 排队接水
- 问题描述:安排 n 个人接水顺序,使平均等待时间最小。
- 贪心策略:按接水时间升序排序,短任务优先处理。
- 代码核心:
sort(t, t + n); // 按接水时间排序 double sum = 0; for (int i = 0; i < n; i++) { sum += t[i] * (n - i - 1); // 后续所有人的等待时间累加 }
- 时间复杂度:O(nlogn),正确性证明可通过反证法。
例题4. P1080 国王游戏
- 问题描述:安排大臣左右手的金币数排列,使得获得最多奖赏的大臣的奖赏尽可能少。
- 贪心策略:按左右手乘积升序排列,乘积小的排前面。
- 代码思路:
bool cmp(Node a, Node b) { return a.left * a.right < b.left * b.right; } // 排序后遍历计算最大值
- 难点:需结合高精度计算处理大数相乘,贪心正确性需数学归纳法证明。
3、进阶贪心应用
例题5. P1094 纪念品分组
- 问题描述:将纪念品分组,每组最多两件且总价值不超过 w,求最少组数。
- 贪心策略:排序后使用双指针,最大与最小配对。
- 实现代码:
sort(p, p + n); int i = 0, j = n - 1, ans = 0; while (i <= j) { if (p[i] + p[j] <= w) i++, j--; else j--; ans++; }
- 优化点:通过双指针将时间复杂度降至 O(n)。
例题6. P1230 智力大冲浪
- 问题描述:在截止时间内完成游戏任务,避免扣款最大化剩余奖金。
- 贪心策略:按扣款降序排序,优先处理扣款高的任务,尽量在截止时间前安排。
- 实现方法:使用标记数组记录时间段是否被占用,按扣款顺序尝试填充时间槽。
4、总结与刷题建议
- 贪心核心:通过局部最优选择构造全局最优解,常见策略包括排序、优先队列、双指针等。
- 洛谷推荐题单:
- 基础:P1421(模拟)、P1048(背包贪心)
- 进阶:P2671(区间贪心)、P1880(动态规划与贪心结合)
- 注意点:贪心算法需严格证明正确性,例如反证法或数学归纳法。
全部总结
-
动态规划:从简单模型(如背包)入手,理解状态定义。
-
图论:熟练Dijkstra和Floyd算法的应用场景。
-
搜索:合理剪枝,避免暴力超时。
-
并查集:必须掌握路径压缩优化。
-
贪心:多练习经典问题,如区间问题、哈夫曼编码等。
还有争取AK csp-J