1. 算法竞赛备战笔记:从动态规划到博弈论
最近为了备战新生赛,我暂时放下了Codeforces的训练,开始集中精力刷HDOJ的经典题目。在这个过程中,我系统性地整理了各种基础算法的核心思想和解题模板,包括动态规划、最短路算法、二分图匹配以及组合博弈等专题。这些内容不仅是算法竞赛中的高频考点,也是计算机科学中许多实际问题的理论基础。
2. 动态规划专题精讲
2.1 最长递增子序列(LIS)问题
问题描述:给定一个整数序列,求其中最长的严格递增子序列的长度。
解法分析:
经典的LIS问题有两种主流解法:O(n²)的DP方法和O(nlogn)的贪心+二分法。在实际比赛中,后者效率更高,更适合处理大规模数据。
贪心+二分法的核心思想:
维护一个数组g,其中g[i]表示长度为i的递增子序列的最小末尾元素。遍历原数组时,对于每个元素x:
- 如果x大于g中所有元素,则扩展g
- 否则,用x替换g中第一个大于等于x的元素
这种策略保证了g数组始终保持有序,可以使用二分查找优化。
cpp复制vector<int> a(n), g;
for (int x : a) {
auto it = lower_bound(g.begin(), g.end(), x);
if (it == g.end()) g.push_back(x);
else *it = x;
}
cout << g.size(); // 输出LIS长度
注意事项:
- 如果需要非严格递增(允许相等),使用upper_bound代替lower_bound
- 此方法只能求出长度,要记录具体序列需要额外处理
- Dilworth定理:一个序列的最少非递增子序列划分数等于其最长递增子序列长度
2.2 搬寝室问题
问题描述:有n件行李,每次搬运必须选择两件,消耗体力为两件重量差的平方。要求进行k次搬运,使总消耗体力最小。
解题思路:
- 首先对行李重量排序,这样相邻行李的重量差最小
- 定义状态f[i][j]表示前i件行李中选择j对的最小消耗
- 状态转移考虑是否选择第i件行李:
- 不选:f[i][j] = f[i-1][j]
- 选:必须与第i-1件配对,f[i][j] = f[i-2][j-1] + (a[i]-a[i-1])²
关键代码:
cpp复制sort(a.begin(), a.end());
vector<vector<ll>> f(n+1, vector<ll>(k+1, INF));
for (int i = 0; i <= n; i++) f[i][0] = 0;
for (int j = 1; j <= k; j++) {
for (int i = 2*j; i <= n; i++) {
f[i][j] = min(f[i-1][j], f[i-2][j-1] + (a[i]-a[i-1])*(a[i]-a[i-1]));
}
}
cout << f[n][k];
优化技巧:
- 滚动数组优化空间复杂度
- 提前处理边界条件避免复杂判断
- 注意初始化f[i][0] = 0表示选0对消耗为0
2.3 免费馅饼问题
问题描述:馅饼会在不同时间落在0-10的位置上,初始站在5,每秒只能移动1个单位,求最多能接多少个馅饼。
解题思路:
- 将时间作为纵坐标,位置作为横坐标,转化为数字三角形问题
- 可以从终点倒推,简化状态转移
- 状态转移方程:
dp[t][x] += max(dp[t+1][x-1], dp[t+1][x], dp[t+1][x+1])
实现细节:
cpp复制for (int t = max_time; t > 0; t--) {
for (int x = 1; x < 10; x++) {
dp[t-1][x] += max({dp[t][x-1], dp[t][x], dp[t][x+1]});
}
// 处理边界
dp[t-1][0] += max(dp[t][0], dp[t][1]);
dp[t-1][10] += max(dp[t][10], dp[t][9]);
}
cout << dp[0][5];
常见错误:
- 没有正确处理边界条件导致数组越界
- 正向递推时需要考虑移动限制,不如反向简洁
- 忘记初始化起点位置
2.4 丑数问题
问题描述:只包含2,3,5,7质因数的数称为丑数,求第n个丑数。
多指针解法:
维护四个指针分别对应2,3,5,7,每次取各指针指向的丑数乘以对应质因数的最小值作为下一个丑数,并移动相应指针。
cpp复制vector<ll> ugly = {1};
int p2=0, p3=0, p5=0, p7=0;
for (int i = 1; i < n; i++) {
ll next = min({ugly[p2]*2, ugly[p3]*3, ugly[p5]*5, ugly[p7]*7});
ugly.push_back(next);
if (next == ugly[p2]*2) p2++;
if (next == ugly[p3]*3) p3++;
if (next == ugly[p5]*5) p5++;
if (next == ugly[p7]*7) p7++;
}
cout << ugly[n-1];
注意事项:
- 需要处理重复值的情况(如2×3和3×2)
- 可以预计算前5842个丑数打表提高查询效率
- 扩展:可以处理任意质因数集合的情况
3. 搜索算法实战
3.1 BFS在电梯问题中的应用
问题描述:奇怪的电梯每层有一个数字k,可以上k层或下k层,求从A到B的最少按键次数。
标准BFS解法:
cpp复制struct State { int floor, steps; };
queue<State> q;
q.push({start, 0});
vector<bool> visited(total_floors+1, false);
while (!q.empty()) {
auto cur = q.front(); q.pop();
if (cur.floor == target) return cur.steps;
int next = cur.floor + k[cur.floor];
if (next <= total_floors && !visited[next]) {
visited[next] = true;
q.push({next, cur.steps+1});
}
next = cur.floor - k[cur.floor];
if (next >= 1 && !visited[next]) {
visited[next] = true;
q.push({next, cur.steps+1});
}
}
return -1; // 不可达
优化技巧:
- 双向BFS可以显著提高搜索效率
- 使用位运算优化状态存储
- 预处理可达性分析
3.2 带权BFS与优先队列
问题描述:在迷宫中,有些格子需要额外时间,求最短到达时间。
解法:使用优先队列(最小堆)保证每次扩展当前最优解。
cpp复制struct Node {
int x, y, time;
bool operator>(const Node& other) const {
return time > other.time;
}
};
priority_queue<Node, vector<Node>, greater<Node>> pq;
pq.push({start_x, start_y, 0});
vector<vector<int>> dist(h, vector<int>(w, INF));
while (!pq.empty()) {
auto cur = pq.top(); pq.pop();
if (cur.x == target_x && cur.y == target_y) return cur.time;
for (auto [dx, dy] : directions) {
int nx = cur.x + dx, ny = cur.y + dy;
int new_time = cur.time + (grid[nx][ny] == 'x' ? 2 : 1);
if (new_time < dist[nx][ny]) {
dist[nx][ny] = new_time;
pq.push({nx, ny, new_time});
}
}
}
注意事项:
- 优先队列的比较函数定义
- 距离矩阵的初始化与更新
- 边界条件检查
4. 图论算法精要
4.1 Dijkstra算法模板
单源最短路径标准实现:
cpp复制vector<ll> dijkstra(const vector<vector<PLL>>& adj, int start) {
vector<ll> dist(n+1, INF);
priority_queue<PLL, vector<PLL>, greater<PLL>> pq;
dist[start] = 0;
pq.emplace(0, start);
while (!pq.empty()) {
auto [d, u] = pq.top(); pq.pop();
if (d > dist[u]) continue;
for (auto [v, w] : adj[u]) {
if (dist[v] > dist[u] + w) {
dist[v] = dist[u] + w;
pq.emplace(dist[v], v);
}
}
}
return dist;
}
多源点处理技巧:
创建虚拟源点0,连接到所有实际源点,边权为0:
cpp复制for (int source : sources) {
adj[0].emplace_back(source, 0);
adj[source].emplace_back(0, 0);
}
auto dist = dijkstra(adj, 0);
堆优化要点:
- 使用邻接表存储图
- 优先队列中存储(d, u)而非(u, d)
- 跳过已处理的节点(dist[u] < d)
4.2 二分图匹配
匈牙利算法核心:
cpp复制bool dfs(int u) {
for (int v : adj[u]) {
if (!vis[v]) {
vis[v] = true;
if (!match[v] || dfs(match[v])) {
match[v] = u;
return true;
}
}
}
return false;
}
int max_match() {
int res = 0;
fill(match, match + n + 1, 0);
for (int u = 1; u <= n; u++) {
fill(vis, vis + n + 1, false);
if (dfs(u)) res++;
}
return res;
}
二分图相关定理:
- König定理:最小点覆盖数 = 最大匹配数
- 最大独立集 = 顶点数 - 最大匹配数
- 最小路径覆盖 = 顶点数 - 最大匹配数
5. 组合博弈论基础
5.1 Nim游戏与SG函数
Nim游戏胜负判定:
所有堆石子数的异或和为0时先手必败,否则必胜。
SG函数定义:
SG(x) = mex{SG(y) | y是x的后继状态}
其中mex表示最小排除值(最小的非负整数不在集合中)
组合游戏定理:
多个独立游戏的SG值异或和为0时先手必败,否则必胜。
Nim游戏变种解法:
cpp复制int total = 0;
for (int x : piles) total ^= x;
if (total == 0) {
cout << "先手必败";
} else {
int ways = 0;
for (int x : piles) {
if ((total ^ x) < x) ways++;
}
cout << "可行方案数: " << ways;
}
SG函数计算示例:
cpp复制int sg[MAXN];
void compute_sg() {
sg[0] = 0; // 终局
for (int i = 1; i < MAXN; i++) {
unordered_set<int> s;
for (int move : possible_moves) {
if (i >= move) s.insert(sg[i - move]);
}
int m = 0;
while (s.count(m)) m++;
sg[i] = m;
}
}
6. 算法竞赛经验总结
在刷题过程中,我总结了以下几点重要经验:
- 模板化思维:对经典算法建立标准化模板,比赛时快速套用
- 边界处理:特别注意数组边界、空输入等特殊情况
- 复杂度分析:确保算法在最大数据规模下的可行性
- 调试技巧:
- 小数据手工验证
- 打印中间结果
- 对拍验证正确性
- 优化策略:
- 空间换时间
- 预处理和记忆化
- 数学优化
对于动态规划问题,关键是:
- 明确状态定义
- 确定转移方程
- 处理好初始条件和边界情况
- 考虑空间优化可能性
在图论算法中,需要注意:
- 图的存储方式选择(邻接表vs邻接矩阵)
- 防止重边和自环的影响
- 负权边的特殊处理
- 堆优化Dijkstra的正确实现
最后,算法竞赛不仅是编程能力的比拼,更是思维能力和心理素质的考验。保持稳定的训练节奏,定期复习经典算法,分析每场比赛的得失,才能在竞赛中不断进步。