XMU2026程序设计实践A题解集
前言
回看小学期三周,学了这么经典板子。刚刚转专业来到信息学院,C++没有怎么接触过,对我来说学习起来还是有很大的难度,,当然还有一些拖延的缘故。大部分可以AC了,也有一部分不能自己打一遍AC,不过应该也算变聪明了O(∩_∩)O。加油加油。
部分代码摘自Acwing Y总和老师的ppt。
题解集
暴力枚举
1. Link04 排序考试
思路:
使用循环处理多组输入输出的样例;调用sort函数排序;使用vector可变长数组存储每一组考试的成绩。
点击查看代码
vector <int> numbers;// 使用动态数组vector
// 核心排序与格式化输出
sort(numbers.begin(), numbers.end()); // 使用标准库 sort 进行升序排序
for (int i = 0; i < N; ++i) {
if (i > 0) cout << " ";
cout << numbers[i];
}
总结:
通过该题,在该课程了解vector的使用方法,以及sort函数排序。
2. Link05 假币问题
思路:
采用暴力枚举法。遍历12枚银币(\(\text{A} \sim \text{L}\)),对每一枚银币分别假设它偏轻、假设它偏重,代入三次称重的结果检验假设是否与天平状态吻合。若某一假设与称重逻辑冲突,则剔除该可能性,最终留存唯一合法假设。
点击查看代码
#include <iostream>
#include <string>
using namespace std;
string Left[3], Right[3], result[3];
// 判断给定的银币是否为假币,isLight=true表示假设假币较轻,false表示假设较重
bool isFeitCoin(char iCoin, bool isLight) {
string c;
c.push_back(iCoin); // 将 char 转为 string 方便查找
for (int i = 0; i < 3; ++i) {
string l = Left[i], r = Right[i];
// 如果假设假币是“重”的(!isLight),就交换左右盘,让其表现和“轻”币一致
if (!isLight) swap(l, r);
// 统一处理:如果在“左盘”(可能是真的左盘,也可能是交换后的右盘)发现了假币
if (l.find(c) != string::npos) {
if (result[i] != "up") return false;
} else {
if (result[i] == "up") return false;
}
}
return true;
}
int main() {
int t;
cin >> t;
while (t--) {
// 读取三组称重数据
for (int i = 0; i < 3; ++i) {
cin >> Left[i] >> Right[i] >> result[i];
}
// 遍历 12 枚银币 A - L
for (char iCoin = 'A'; iCoin <= 'L'; ++iCoin) {
if (isFeitCoin(iCoin, true)) { // 假设它是轻的
cout << iCoin << " is the counterfeit coin and it is light." << endl;
break;
} else if (isFeitCoin(iCoin, false)) { // 假设它是重的
cout << iCoin << " is the counterfeit coin and it is heavy." << endl;
break;
}
}
}
return 0;
}
总结:
思路很简单,暴力枚举即可。遇到问题有的时候不用想的很复杂,考虑一下暴力枚举。暴力枚举时,需要考虑全面,假银币可能是轻的也可能是重的。
3. Link07 三数之和
思路:
由「两数之和」思路延伸,可以采用双指针进行优化:固定第一个数 \(nums[i]\),在后续区间利用左右指针相向移动,寻找满足 \(nums[i] + nums[j] + nums[k] = target\) 的全部三元组。
点击查看代码
vector<vector<int>> threeSum(vector<int>& nums, int target){
vector<vector<int>> res;
sort(nums.begin(), nums.end());
nums.erase(unique(nums.begin(), nums.end()), nums.end());//双指针排序去重
for (int i = 0; i < nums.size(); i++) {
for (int j = i + 1, k = nums.size() - 1; j < k; j++) {
while (j < k - 1 && nums[i] + nums[j] + nums[k - 1] >= target) k--;
if (nums[i] + nums[j] + nums[k] == target)
res.push_back({nums[i], nums[j], nums[k]});
}//固定第一个数,在后续区间使用双指针
}
return res;
}
总结:
学习了利用排序 + 双指针(\(j\) 和 \(k\))将复杂度从 \(O(n^3)\) 优化至 \(O(n^2)\);
⚠️踩坑提示:双指针算法依赖数组单调性,使用前必须先执行 sort 排序;同时根据本题去重要求,搭配 unique + erase 完成数组去重,避免输出重复方案。
递归算法
4. Link10 汉诺塔II
思路:
将问题拆解为三个步骤:
- 先将上面的 (n-1) 个圆盘,从“起始柱”移动到“辅助柱”。
- 再将最底下的第 (n) 个大盘子,直接从“起始柱”移动到“目标柱”。
- 最后将那 (n-1) 个圆盘,从“辅助柱”移动回“目标柱”。
当只剩 (1) 个圆盘时,直接打印移动过程并返回,不再继续递归。
点击查看代码
// 递归函数:将 n 个盘子从 start 柱子移动到 end 柱子
void hannuota(int n, int start, int end){
if (n == 1){
// 只剩 1 个盘子时,直接打印移动并结束递归
printf("1:%c->%c\n", name[start], name[end]);
return ;
}
// 核心技巧:使用 3 - start - end 算出辅助柱的下标
int endd = 3 - start - end;
// 步骤 1:将上面的 n-1 个盘子从 start 移动到辅助柱 endd
hannuota(n - 1, start, endd);
// 步骤 2:将最底下的第 n 个大盘子从 start 移动到目标柱 end
printf("%d:%c->%c\n", n, name[start], name[end]);
// 步骤 3:将 n-1 个盘子从辅助柱 endd 移动到目标柱 end
hannuota(n - 1, endd, end);
}
总结:
深入理解了递归的本质,不要纠结内部具体细节。要记得写边界条件,避免死循环。
5. Link19 递归实现指数型枚举
思路:
要求输出全部子集,核心思路:每个数字只有选或者不选两种决策。若选:将u放入当前子集,标记为true;若不选:将u移除,标记为false。然后递归执行 dfs(u + 1)。当 u > n 时代表所有数字决策完毕,输出当前子集。
点击查看代码
int n;
bool st[N];//记录数字是否被选中
void dfs(int u)
{
//递归边界:所有n个数字已经决策完毕
if (u>n)
{
for (int i = 1; i<=n; i++)
if (st[i])
cout<< i <<" ";
cout<<endl;
return ;
}
//选择第u个数字
st[u] = true;
dfs(u+1);
//不选第u个数字 (恢复现场)
st[u] = false;
dfs(u+1);
}
总结:
代码执行顺序为先令 \(\mathit{st}[u] = \mathbf{true}\),递归返回后再执行 \(\mathit{st}[u] = \mathbf{false}\),因此枚举子集会按照优先选取靠前数字的DFS顺序生成。\(\mathit{st}[u] = \mathbf{false}\) 起到回溯、恢复现场的作用。
6. Link20 递归实现组合型枚举
思路:
dfs(u, start),其中 \(u\) 表示第 \(u\) 个位置,\(\mathit{start}\) 表示当前这一步能选取的最小起始数字。在第 \(u\) 个位置上,我们可以枚举从 \(\mathit{start}\) 到 \(n\) 的所有数字 \(i\)。如果选定 \(i\),放入 \(\mathit{path}[u]\),下一步递归调用时起始数字必须是 \(i+1\)。\(u>m\) 时,表示填满了 \(m\) 个位置,输出即可。
点击查看代码
void dfs(int u, int start)
{
//边界条件:位置已经填满
if(u > m)
{
for (int i = 1; i <= m; i++)
cout<<path[i]<<" ";
cout<<endl;
return ;
}
//尝试将i放入当前位置u
for (int i = start; i <= n; i++)
{
path[u] = i;
dfs(u+1, i+1);
path[u] = 0; //回溯,恢复现场
}
}
总结:
进阶掌握了剪枝与递归模板,引入start参数,解决了去重的问题。踩坑:dfs套路,写边界条件,再写一般情况,然后一定要记得回溯和恢复现场!!!
7. Link21 递归实现排列型枚举
思路:
dfs(u),\(u\) 表示当前正在排列中的第 \(u\) 个位置填入数字。在这一层,将未使用过的数字填入,接着进行下一层。递归回来时,需要恢复现场。
点击查看代码
void dfs(int u){
//递归出口:位置已经填满,输出结果
if (u > n){
for (int i = 1; i <= n; i++)
cout<<path[i]<<" ";
puts("");
return ;
}
//枚举当前可以填哪个数字
for (int i = 1; i <= n; i++){
if (!st[i]){//如果数字i没有被使用过
path[u] = i;
st[i] = true;
dfs(u + 1);
st[i] = false; //回溯:取消标记,会浮现场
}
}
}
总结:
搜索回溯标准模板:做选择 -> 递归进入下一层 -> 撤销选择(恢复现场)。全排列不限制升序,数字可以随意组合,没有start参数限制下界,每一层循环都必须从1遍历到n。
8. Link13 输出N皇后的全部摆法
思路:
遍历行,每一行放置一个皇后。每一个位置上共有三类冲突:列冲突、主对角线冲突、副对角线冲突。如果均不冲突,标记为true,进入下一层;递归返回时,恢复现场,将冲突标记置为false。
点击查看代码
void dfs(int u){
// 递归边界:n 行全部放置完成
if (u > n){
for (int i = 1; i <= n; i++){
cout << ans[i];
}
puts(""); // 输出换行
return ;
}
// 枚举当前行 u 可以放在哪一列 i
for (int i = 1; i <= n; i++){
// 剪枝判断:如果列、主对角线、副对角线都没有冲突
if (!pos[u + i - 1] && !col[i] && !neg[u - i + n]){
pos[u + i - 1] = col[i] = neg[u - i + n] = true; // 标记占用
ans[u] = i; // 记录方案
dfs(u + 1); // 递归搜下一行
pos[u + i - 1] = col[i] = neg[u - i + n] = false; // 回溯:恢复现场(核心)
}
}
}
总结:
副对角线(/)用和确定,主对角线(\)用差确定,搭配 +n 或 -1 的平移操作可以完美解决负数下标越界问题。经典的回溯dfs模板。
9. Link16 放苹果
思路
对于边界情况:苹果数小于盘子(等价于x个苹果放在x个盘子)、没有苹果(1种)、没有盘子(0种);普遍情况:把x个苹果放y个盘子里可以拆分为两种状态:空一个盘子(x个苹果放在y-1个盘子里),放满(x-y个苹果放在y个盘子里)。进行递归。
点击查看代码
int f(int x, int y){
//处理边界情况
if (x < y)
return f(x, x);
if (x == 0)
return 1;
if (y <= 0)
return 0;
return (f(x, y - 1) + f(x - y, y)); //拆分、递归
}
总结:
本题是很经典的递归。将大问题拆分成小问题,小问题进而拆成小小问题,进行递归,得出答案。注意:边界情况不要有缺漏!特别是苹果数比盘子小这个情况。
10. Link18 2的幂次方表式
思路:
从高到低(例如从 (2^{14}) 开始)遍历 (n) 的二进制位。如果第 i 位是 1,说明该数字包含一个 (2^i)。如果当前的幂次 i > 1,代表外层是一个 2(i) 的格式,此时指数 i 本身也必须被递归地转化为符合规定的幂次方表达。
点击查看代码
// 递归函数:将整数 n 转换为 2 的幂次方表达式
string dfs(int n){
string res; // 存储当前层的表达式
// 14 位刚刚好覆盖最高位。
for (int i = 14; i >= 0; i--){
// 检查 n 的二进制第 i 位是否为 1
if (n >> i & 1){
if (res.size()) res += '+'; // 只有当已有元素时,才添加 + 号
// 特殊处理指数 i 为 0 和 1 的情况
if (!i) res += "2(0)";
else if (i == 1) res += "2";
// 指数大于 1 的情况,需要递归处理指数 i 本身
else res += "2(" + dfs(i) + ")";
}
}
return res;
}
总结:
掌握递归的分层传递思想,外层求出数字n包含哪些 2 的幂,若幂次本身大于 1,又将该幂次作为参数继续递归,直到触底到 0 或 1 这两个基础情况。n >> i & 1快速提取一个整数二进制下的某一位值。
11. Link23 二进制密码锁
思路:
从左往右扫描,若当前位置 \(i\) 与目标不同,则必须按下按钮 \(i+1\)(翻转 \(i,\ i+1,\ i+2\)),因为只有这样才能在不破坏左边已匹配位的前提下修正 \(i\)。由于第一个按钮没有左边按钮可依,需枚举它按或不按两种情况,取最小步数。
点击查看代码
for (int i = 0; i < n; ++i) {
if (nextButton) { // 当前按钮需按下
times++;
if (i > 0) lock.flip(i-1);
lock.flip(i);
if (i+1 < n) lock.flip(i+1);
}
// 当前位置是否匹配,决定下一个按钮是否按下
nextButton = (lock[i] != targetLock[i]);
}
//外层循环枚举 nextButton 初始为 0 或 1,处理完所有位后检查 lock==targetLock 并更新最小次数
总结:
枚举首位按钮状态,依次修正每一位;学习了bitset的使用,用bitset存储密码状态,其flip(pos)能高效翻转单一位置,==运算符可直接比较整体状态,相比数组、字符串实现更简洁安全。注意边界防止下标越界;若首位两种枚举方案均无法达成目标状态,则输出impossible。
12. Link26 算24
思路:
4个数算24点,必定有两个数要先算,问题转化为3个数算24点,接着同理:2两个数算24点、1个数算24点。
点击查看代码
bool count24(double a[], int n)
{
if (n == 1)
{
if(isZero(a[0] - 24))
return true;
else
return false;
}
for (int i = 0; i < n - 1; i++)
for (int j = i + 1; j < n; j++)
{
double temp[n - 1] = {0};
int iTemp = 0;
for (int k = 0; k < n; k++)
if ((k != i) && (k != j))
temp[iTemp++] = a[k];
//枚举运算
temp[iTemp] = a[i] + a[j];
if (count24(temp,n-1)) return true;
temp[iTemp] = a[i] * a[j];
if (count24(temp,n-1)) return true;
temp[iTemp] = a[i] - a[j];
if (count24(temp,n-1)) return true;
temp[iTemp] = a[j] - a[i];
if (count24(temp,n-1)) return true;
if(!isZero(a[j]))
{
temp[iTemp] = a[i]/a[j];
if (count24(temp,n-1)) return true;
}
if(!isZero(a[i]))
{
temp[iTemp] = a[j]/a[i];
if (count24(temp,n-1)) return true;
}
}
return false; // 关键!递归全部尝试完无解返回false
}
总结:
降维递归思想,将4个数问题转为3个数2个数1个数问题。浮点数比较不可以x==y,必须引入一个极小的容差值 EPS(如 1e-6)使用 fabs(x - y) <= EPS 来判断。要注意除法分母不为0,不能直接写 temp[iTemp] = a[i]/a[j]; 而不检查 a[j] 是否为 0。
13. Link25 拨钟问题
思路:
时钟旋转特点:每次旋转90度,每4次旋转状态回归初始状态。一共有9种不同的move,每种move周期也为4。使用深度优先搜索(DFS),按顺序决定 9 种操作的执行次数 moveTimes[i]。当 9 种操作都确定了次数后,得到一条完整的搜索路径。
点击查看代码
void dfs(int n)
{
if (n >= 9) // 递归边界:9 种操作的次数都决定了
{
memcpy(clocks, oriClocks, sizeof(clocks)); // 将初始状态复制给临时数组
int times = 0;
// 根据 moveTimes 模拟操作
for (int i = 0; i < 9; ++i)
{
if (moveTimes[i])
{
for (int k = 0; k < moves[i].size(); ++k)
{
// 将影响的时钟状态顺时针转动 moveTimes[i] 格,%4 确保状态循环
clocks[moves[i][k] - 'A'] = (clocks[moves[i][k] - 'A'] + moveTimes[i]) % 4;
}
times += moveTimes[i];
}
}
// 检查是否所有时钟都指向 12 点(状态为 0)
bool flag = true;
for (int i = 0; i < 9; ++i)
{
if (clocks[i] != 0)
{
flag = false;
break;
}
}
// 如果解合法且总步骤更少,则更新最优解
if (flag && minTimes > times)
{
minTimes = min(minTimes, times);
memcpy(result, moveTimes, sizeof(result)); // 记录下这个最优的操作组合
}
return;
}
// 枚举当前第 n 种操作可以执行 0~3 次
for (int i = 0; i < 4; ++i)
{
moveTimes[n] = i;
dfs(n + 1); // 递归决定下一种操作
}
return;
}
总结:
找准“模 4 取余”的关键性质;在终点处利用 memcpy 从原状态 oriClocks 克隆一份副本进行模拟。这样做避免了复杂的“回溯(撤销操作)”逻辑,使得整个状态验证过程非常直观且互不干扰。
分治算法
14. Link27 大数排序
思路:
快排思路:确定分区点x;把<=x的移动到左边,>=x的移动到右边;递归排序左边分区,递归排序右边分区。
点击查看代码
void quick_sort(int q[], int l, int r)
{
// 递归边界:如果区间为空或只有一个元素,则无需排序
if (l >= r) return;
int i = l - 1, j = r + 1;
int x = q[l + r >> 1]; // 选取基准值
while (i < j)
{
// 左边指针向右移动,直到找到大于等于基准值的元素
do i++; while (q[i] < x);
// 右边指针向左移动,直到找到小于等于基准值的元素
do j--; while (q[j] > x);
// 如果指针没有相遇或交叉,交换这两个逆序元素
if (i < j) swap(q[i], q[j]);
}
// 递归处理划分后的左右子区间。注意,因为选择的是中间基准,此时 j 是左区间的右边界
quick_sort(q, l, j);
quick_sort(q, j + 1, r);
}
总结:
掌握“分治”思想,快速排序将大问题划分为两个相对独立的子问题,从而将时间复杂度降低为 \(O(n\log n)\)。避坑:在循环最后如果直接写 swap(q[i], q[j]);,当 i 和 j 已经交叉(i > j)时,会导致已经排好序的元素被错误地换回去,造成无限循环或错误结果。
15. Link28 快速选择第k个数
思路:
快选思路:确定分区点x;把<=x的移动到左边,>=x的移动到右边;判断第 k 小的数在左区间还是右区间,递归第k小的数所在的区间。
点击查看代码
int quick_sort(int k, int l, int r){
// 递归边界:区间只剩一个数,必定是我们要找的第 k 小的数
if (l >= r) return q[l];
// 选取区间中间的值作为基准 x
int x = q[l + r >> 1];
int i = l - 1, j = r + 1;
// 双指针进行划分操作
while (i < j){
do i++; while(q[i] < x);
do j--; while(q[j] > x);
if (i < j) swap(q[i], q[j]);
}
// 计算划分后左半部分区间 [l, j] 的元素个数
int s1 = j - l + 1;
// 核心逻辑:判断第 k 小的数在左区间还是右区间
if (k <= s1)
return quick_sort(k, l, j); // 在左区间,k 值不变
else
return quick_sort(k - s1, j + 1, r); // 在右区间,k 减去左区间的元素个数
}
16. Link30 归并排序
思路:
确定分界点mid = (l + r) / 2 ;递归左半部分和右半部分,分别进行归并排序,直到区间内只剩下一个元素;利用双指针 i 和 j 分别指向左右两个子区间的最小值,比较两者大小。
点击查看代码
void merge_sort(int q[], int l, int r){
// 递归边界:区间为空或只有一个元素,无需排序
if (l >= r) return ;
// 拆分:计算中点位置
int mid = l + r >> 1;
//递归:分别对左右两个子区间进行排序
merge_sort(q, l, mid);
merge_sort(q, mid + 1, r);
// (核心):利用双指针进行归并
int k = 0, i = l, j = mid + 1;
while (i <= mid && j <= r) // 当左右两个区间都没走完时
if (q[i] <= q[j]) tmp[k ++] = q[i ++]; // 将较小的放入 tmp
else tmp[k ++] = q[j ++];
// 处理剩余的左半部分元素
while (i <= mid) tmp[k ++] = q[i ++];
// 处理剩余的右半部分元素
while (j <= r) tmp[k ++] = q[j ++];
for (i = l, j = 0; i <= r; i++, j++) q[i] = tmp[j];
}
总结:
空间换时间,深刻理解分治思想和时空复杂度。
17. Link31 求排序的逆序对
思路:
利用归并排序的分治特性,在合并两个有序数组时,若右半数组元素小于左半数组元素,则左半部分剩余的所有元素都与它构成逆序对(mid - i + 1),累加该值即为总逆序对数。
点击查看代码
while (i <= mid && j <= r) {
if (arr[i] <= arr[j]) tmp[k++] = arr[i++];
else {
tmp[k++] = arr[j++];
ans += mid - i + 1; // 核心:如果右半较小,左半边剩余所有都比它大,构成逆序对
}
}
总结:
学会了将分治合并过程与条件统计相结合,利用归并排序的天然有序性,在 \(O(N\log N)\) 时间内解决了暴力 \(O(N^2)\) 无法处理的大数据逆序对统计问题,核心技巧是“排序与计数互不冲突”。
二分算法
18. Link32 查找指定数
思路:
利用数组有序的特性,通过二分查找算法不断将搜索区间减半,从而在 \(O(\log n)\) 的复杂度下快速定位目标值 target 的索引。
点击查看代码
while (l < r) {
int mid = l + r >> 1;
if (p[mid] >= target)
r = mid; // 目标在左半区或恰好是当前中间值
else
l = mid + 1; // 目标在右半区
}
if (p[l] == target) return l; // 循环结束后校验是否真的找到了 target
else return -1;
总结:
掌握了整数二分查找的模板(区间不断向左/右收缩),并踩坑提醒:二分循环结束后 l(或 r)位置的元素不一定等于 target(如果原数组中没有该元素),必须加上 if (p[l] == target) 特判,否则会对不存在的元素返回错误的索引。
19. Link33 攻击范围
思路:
利用有序数组特性,通过两次不同的二分查找分别确定目标值 k 的起始位置(第一个 >=k)和结束位置(最后一个 <=k),若左边界未找到 k 则说明数组中没有该元素。
点击查看代码
while (l < r) {
int mid = l + r >> 1; // 向左收缩,寻找左边界
if (nums[mid] >= k) r = mid; else l = mid + 1;
}
if (nums[l] != k) { cout << "-1 -1" << endl; continue; }
cout << l << " ";
l = 0, r = n - 1;
while (l < r) {
int mid = l + r + 1 >> 1; // 向右收缩,寻找右边界(+1防止死循环)
if (nums[mid] <= k) l = mid; else r = mid - 1;
}
cout << l << endl;
总结:
掌握了整数二分中“向左找”和“向右找”的两种不同模板,核心踩坑提醒:在找右边界(执行 l = mid)时,计算 mid 必须写成 l + r + 1 >> 1 进行上取整,否则会导致程序陷入死循环。
20. Link34 求方程的根
思路:
浮点数二分查找,利用连续函数零点特性,不断缩小区间 \([l,r]\),直到区间长度小于精度eps,求出方程 \(f(x)=0\) 的实数根。
点击查看代码
// 实数二分循环,不断逼近零点
while (r - l > eps)
{
double mid = (l + r) / 2;
if (f(mid) > 0) r = mid;
else l = mid;
}
总结:
实数二分不需要处理上下取整问题,依靠精度eps终止循环;注意根据函数单调性正确移动左右边界。
深度优先搜索dfs
20. Link38 林克的命运之阵
思路:
从(i,j)出发,走 n 步的方案数 等于 从(i+1,j)出发,走 n-1 步、从(i,j+1)出发,走 n-1 步 以及 从(i,j-1)出发,走 n-1 步 这三项之和。
点击查看代码
for (int k = 0; k < 3; k++) {
int x = i + dx[k], y = j + dy[k];
if (fates[x][y] == 0) res += dfs(x, y, n - 1); // 若目标点未访问则继续向下递归
}
fates[i][j] = 0; return res; // 回溯恢复标记并返回当前分支计数
总结:
掌握了DFS + 回溯的经典模板,在代码实现中,利用方向数组dx、dy枚举这三个移动分支,并通过 DFS + 全局访问标记 fates 来完成子问题的递归求解与路径计数。核心踩坑提醒:在递归返回后,必须立即执行 fates[i][j] = 0; 撤销当前点的占用标记(恢复现场)。
21. Link40 骑士林克的怜悯(1)
思路:
按照棋盘字典序依次枚举每个格子作为起点,DFS搜寻骑士周游路径,一旦找到能跳满全部格子的路线,即为字典序最小解,随后输出路径。
点击查看代码
for (int i = 0; i < 8; i++) {
int a = x + dx[i], b = y + dy[i];
if (a < 0 || a >= q || b < 0 || b >= p) continue; // 边界判断
if (st[a][b]) continue; // 跳过已访问格子
if (dfs(a, b, cnt + 1)) return true; // 递归找到完整解立即层层返回
}
st[x][y] = false; path.pop_back(); return false; // 回溯:撤销标记并移除路径记录
总结:
利用枚举起点的先后顺序天然保证字典序最小;寻找到合法方案后及时终止搜索,避免多余回溯。
22. Link42 击杀黄金蛋糕人马
思路:
考察区间DP分治思想与记忆化搜索,枚举水平或垂直切割位置,把剩余切割次数分配给两块子矩形,递归求出所有子块最大面积的最小值,借助记忆化数组剪枝,显著提升搜索效率。
点击查看代码
_For(i, 1, wide - 1) _For(k, 0, cutNumber - 1) // 枚举竖直切割位置及刀数分配
minMArea = min(minMArea, max(dfs(i, height, k), dfs(wide - i, height, cutNumber - 1 - k)));
_For(j, 1, height - 1) _For(k, 0, cutNumber - 1) // 枚举水平切割位置及刀数分配
minMArea = min(minMArea, max(dfs(wide, j, k), dfs(wide, height - j, cutNumber - 1 - k)));
return minMax[wide][height][cutNumber] = minMArea; // 记忆化记录并返回结果
总结:
掌握了记忆化搜索在二维矩形分割问题上的应用,核心踩坑提醒:递归传入的 cutNumber 代表“切几刀”而不是切几块(主函数传参为 M-1);若当前矩形的 wide * height < cutNumber + 1,应直接剪枝返回无穷大 Inf,防止出现无法切分导致的无限递归或大量无效搜索。
23. Link41 英杰们的蛋糕塔
思路:
从下往上搭建蛋糕,对每一层的半径和高度进行逆序枚举(优先尝试较大的体积),并结合可行性剪枝(当前体积+最小剩余体积不超过 n)、最优性剪枝(当前面积+最小剩余面积不超过当前最优解 ans)以及体积与侧面积的数学不等式来大幅缩减搜索空间。
点击查看代码
if (v + minv[u] > n) return; // 剪枝:即使取最小体积也超过 n
if (s + mins[u] >= ans) return; // 剪枝:当前面积已超过已知最小面积
if (s + 2 * (n - v) / R[u + 1] >= ans) return; // 剪枝:利用体积与侧面积的数学关系剪枝
for (int r = min(R[u + 1] - 1, (int)sqrt(n - v)); r >= u; r--) // 枚举半径,注意半径下限
for (int h = min(H[u + 1] - 1, (n - v) / r / r); h >= u; h--) // 枚举高度,注意高度下限
dfs(u - 1, v + r * r * h, s + 2 * r * h + (u == m ? r * r : 0));
总结:
学会了复杂 DFS 中的多重剪枝策略,核心踩坑提醒:枚举半径和高度时,其下界必须是当前的层数 u(因为从上往下第 1 层最小半径/高度为 1,第 2 层最小为 2,以此类推);同时优先搜索大半径/大高度的分支,能更快接近最优解并触发剪枝,从而避免超时(TLE)。
24. 二进制中1的个数(lowbit)
思路:
利用 lowbit(n) = n & -n 能提取出整数二进制中最低位的 1 的特性,每次循环减去这个最低位的 1 并计数,直到整数变为 0,统计出的次数即为二进制中 1 的个数。
点击查看代码
int lowbit(int n) {
return n & -n; // 返回 n 的二进制中最低位的 1 及其后面的 0
}
int NumberOf1(int n) {
int res = 0;
for (int i = n; i; i -= lowbit(i)) // 每次消去最低位的 1
res++;
return res;
}
总结:
掌握了 lowbit(x) = x & -x 这个精妙位运算技巧,学到了效率优势:相比常规逐位右移判断,该算法的时间复杂度仅与二进制中 1 的个数成正比(\(O(\text{popcount}(n))\)),而非总位数,在稀疏数据下效率极高,同时也完美避开了针对负数移位时可能导致的死循环陷阱。
25. 二进制中1的最低位位置
思路:
通过 lowbit(n) = n & -n 提取出二进制最低位的 1,再利用预先构建好的 log2 查找表,以 \(O(1)\) 的复杂度直接获取该 1 的所在位置。
点击查看代码
void buildlog2table(int n) {
for (int k = 0; k <= n; k++) log2[1 << k] = k; // 预处理 2^k 对应的 log 值
}
int lowbit(int n) {
return n & -n; // 提取最低位的 1
}
int pos0f1(int n) {
return log2[lowbit(n)]; // 通过查表 O(1) 找到最低位 1 的索引
}
总结:
掌握了以空间换时间的预处理技巧,核心踩坑提醒:数组 log2 的大小必须严格开为 1 << (MaxN + 1)。原代码中 log2[1 << MaxN] 会导致访问 log2[65536] 时发生数组越界(若 MaxN 为 16,数组最大有效索引仅为 65535),千万注意下标要预留出边界位置。
26. 真假记忆碎片
思路:
通过将预先硬编码的数独存入 memory 作为比照模板,利用 st 布尔数组逐行逐列检验用户输入的矩阵是否与已知数字冲突,并检测每行/每列是否存在重复数字。
点击查看代码
if (b[i][j] != memory[i][j] && memory[i][j] != 0) return false; // 校验输入必须与硬编码已知数字完全匹配
int t = b[i][j];
if (st[t]) return false; // 利用 st 标记数组检查当前行/列是否存在相同数字
else st[t] = true;
总结:
掌握了二维字符数组转整型矩阵及标记数组判重的技巧;模拟题,注意边界和细节。
27. 寻找林克的回忆(1)
思路:
利用行、列、小九宫格的三维状态数组(st_row、st_col、st_block)记录数字占用情况,在尝试填数时严格剪枝,一旦找到可行解立即层层返回并输出。
点击查看代码
for (int i = 1; i <= 9; i++) { // 枚举 1~9 尝试填入
if (!st_row[x][i] && !st_col[i][y] && !st_block[x / 3][y / 3][i]) { // 剪枝:行列及九宫格未占用
st_row[x][i] = st_col[i][y] = st_block[x / 3][y / 3][i] = true; // 标记占用
g[x][y] = i;
if (dfs(x, y + 1)) return true; // 递归下一格,若成功直接层层返回
g[x][y] = 0; // 回溯:恢复现场
st_row[x][i] = st_col[i][y] = st_block[x / 3][y / 3][i] = false; // 回溯:取消占用标记
}
}
总结:
掌握了位图标记数组在数独剪枝中的同步更新,核心踩坑提醒:读入初始棋盘时,必须同步更新 st_row、st_col 和 st_block 的初始占用状态,否则 DFS 在搜索时无法识别预置数字,导致重复填入冲突数字从而输出错误结果。
28. 寻找林克的回忆(2)
思路:
利用按位运算状态压缩与DFS回溯,将行、列、九宫格数字是否可用的状态(1-9)压缩为 9 位二进制整数(111111111 表示全未使用),通过优先搜索可选数字最少(ones 值最小)的空缺位进行强剪枝,结合 lowbit 快速枚举候选数字。
点击查看代码
for (int i = get(x, y); i; i -= lowbit(i)) { // 遍历当前格子所有可选的候选数字
int t = LOG[lowbit(i)]; // lowbit 提取最低位1,查表快速得到数字索引
row[x] -= 1 << t; col[y] -= 1 << t; block[x / 3][y / 3] -= 1 << t; // 标记占用
str[x * 9 + y] = '1' + t;
if (dfs(cnt - 1)) return true; // 递归下一层,搜到解直接向上返回
row[x] += 1 << t; col[y] += 1 << t; block[x / 3][y / 3] += 1 << t; // 回溯:恢复状态
str[x * 9 + y] = '.'; // 回溯:恢复字符串空格
}
总结:
掌握了本题的四大优化技巧:① 位压缩替代布尔数组;② 从可选数字最少的格子开始深搜(核心剪枝);③ 使用 lowbit 快速计算出要修改的位;④ 预计算 ones 表快速统计状态中 1 的个数(空缺数)。核心踩坑提醒:位运算回溯时,必须用 +=(或 |)将之前减去的位复原,一旦遗漏或方向写反会导致状态污染,输出错误的数独解。
29. 寻找林克的回忆(3)
思路:
采用位运算状态压缩高效维护行、列、九宫格的占用情况,结合优先搜索可选数字最少(ones 值最小)的空缺位进行强剪枝,在深度优先搜索中利用 lowbit 快速枚举候选数字,并实时计算靶形数独的加权得分。
点击查看代码
for (int i = get(x, y); i; i -= lowbit(i)) {
int t = LOG[lowbit(i)] + 1;
draw(x, y, t); // 填数并更新位掩码状态
dfs(cnt - 1, score + get_score(x, y, t)); // 递归搜索并累加得分
draw(x, y, -t); // 回溯:利用 -t 反填恢复位状态与棋盘
}
总结:
掌握了位运算状态压缩与“优先搜索候选数字最少空缺位”的核心剪枝策略,配合 lowbit 和预计算表快速枚举与回溯,实现了靶形数独的高效求解。
30. 寻找林克的回忆(4)
思路:
本质是标准数独搜索,用三组 16 位掩码分别记录每行 / 每列 / 每宫已用字母,候选集直接由 cand(r,c) = ~(rowMask | colMask | boxMask) & 0xFFFF 位运算得到;搜索时不按顺序填格,而是每层选候选数最少的空格(MRV)先填,无候选立即回溯,若全部空格候选都 ≥ 2 再扫一遍行列宫找「某字母在该单元只剩一个位置」的隐性唯一解强制填入,从而把 16×16 的搜索树压到极小。
点击查看代码
inline int cand(int r,int c){ return FULL & ~(rowMask[r]|colMask[c]|boxMask[boxId(r,c)]); }
// 枚举当前格所有候选字母:lowbit 取位,填入→递归→撤销
for (int t = bmask; t; t &= t - 1) {
int bit = t & -t;
g[br][bc] = 'A' + __builtin_ctz(bit);
rowMask[br] |= bit; colMask[bc] |= bit; boxMask[b] |= bit;
if (solve(remain - 1)) return true;
rowMask[br] ^= bit; colMask[bc] ^= bit; boxMask[b] ^= bit; g[br][bc] = '-';
}
总结:
MRV 加隐性唯一解这两个剪枝是关键,缺了后者 16×16 会明显变慢;另外踩过的坑是多测输入,要按「攒够 16 行处理一组」来读,顺手清掉行尾 \r 防止 Windows 换行把第 16 列读错。
31. 寻找林克的回忆 (DLX)
思路:
把数独转成精确覆盖问题——每个候选填法 \((r,c,d)\) 是一行,「该格已填 / 该行有 \(d\) / 该列有 \(d\) / 该宫有 \(d\)」四类共 \(4\times 256=1024\) 个约束是列,用 Dancing Links 跳舞链求一组行恰好覆盖每列一次。
点击查看代码
// 候选 (r,c,d) 生成一行,恰好覆盖“格子/行/列/宫”四个约束列;已知格只放它自己那一个候选
for (int d = lo; d <= hi; d++)
addRow((r * 16 + c) * 16 + d, // 行编号自带 (r,c,d),回溯完可直接反解
r * 16 + c + 1, // 约束一:格 (r,c) 被填
NN + r * 16 + d + 1, // 约束二:第 r 行出现 d
2 * NN + c * 16 + d + 1, // 约束三:第 c 列出现 d
3 * NN + boxId(r, c) * 16 + d + 1); // 约束四:所在 4×4 宫出现 d
总结:
数独类问题不要硬写搜索加剪枝,统一建模成精确覆盖后交给 DLX,模板一份到处套;坑在于四类约束的列编号必须错开、且根结点占用 0 号所以列号要从 1 开始。
广度优先搜索(bfs)
32. 净化迷雾森林
思路:
考察网格连通块的 BFS 广度优先搜索,从起点 @ 出发,利用队列逐层向外扩展,在遍历过程中将走过的 . 标记为 # 以防止重复访问,最终统计出所有相连的黑瓷砖总数。
点击查看代码
while (q.size()) {
auto t = q.front(); q.pop(); res++; // 弹出队头,计数+1
for (int i = 0; i < 4; i++) {
int x = t.x + dx[i], y = t.y + dy[i]; // 枚举上下左右四个方向
if (x < 0 || x >= n || y < 0 || y >= m || g[x][y] != '.') continue;
g[x][y] = '#'; q.push({x, y}); // 标记为已访问并加入队列
}
}
总结:
掌握了 BFS 求连通块个数的标准模板。
33. 突袭路线
思路:
拓扑排序本质是 BFS 框架,入度为 0 的点入队,依次弹出并删边,队列顺序即为拓扑序。
点击查看代码
queue<int> q;
for (int i = 1; i <= n; i++) if (!d[i]) q.push(i);
while (q.size()) {
int u = q.front(); q.pop();
for (int i = h[u]; ~i; i = ne[i]) {
int v = e[i];
if (--d[v] == 0) q.push(v); // 删边后入度为0则入队
}
}
总结:
拓扑排序用数组模拟队列更高效,注意判断是否有环(计数不等于 n)。
34. 加农的入侵
思路:
考察BFS广度优先搜索的层序遍历,从起点开始,每一轮向周围8个相邻方向蔓延;利用队列size()划分每一层节点,区分每一天扩散范围,逐层迭代直到无法继续扩散,最终输出总共经历的天数。
点击查看代码
while (q.size()) {
int len = q.size(); // 记录当前层(本轮/今天)需要处理的节点数
while (len--) {
auto t = q.front(); q.pop();
for (int i = 0; i < 8; i++) {
int nx = t.x + dx[i], ny = t.y + dy[i]; // 注意:必须用临时坐标 t.x,不能原坐标累加
if (nx < 0 || nx >= y || ny < 0 || ny >= x) continue; // 边界判定
if (map[nx][ny] == '.') {
map[nx][ny] = 'M';
q.push({nx, ny});
}
}
}
day++; // 当前层全部出队并处理完,天数/步数 + 1
}
总结:
掌握了 BFS 按层统计步数(len = q.size())的标准模板,核心踩坑提醒:枚举 8 个方向时,千万不能直接修改变量 x += dx[i],必须使用 nx = t.x + dx[i] 这种临时变量,否则会导致坐标产生累加偏移从而越界;同时务必严格补充边界检查,缺少判界会导致严重的数组越界及运行错误。
35. 骑士林克的怜悯(2)
思路:
利用 BFS 广度优先搜索在二维网格中寻找马(K)从起点到终点(H)的最短路径,每次按 8 个“日”字方向(dx、dy)扩展,结合距离数组 dist 判重,保证首次到达终点时的路径最短。
点击查看代码
for (int i = 0; i < 8; i++) {
int x = t.x + dx[i], y = t.y + dy[i];
if (x < 0 || x >= n || y < 0 || y >= m || g[x][y] == '*' || dist[x][y] != -1) continue;
dist[x][y] = dist[t.x][t.y] + 1; // 记录步数且标记为已访问,避免重复入队
if (make_pair(x, y) == end) return dist[x][y];
q.push({x, y});
}
总结:
掌握了 BFS 求网格最短路的通用模板。
36. 公主的攻击范围
思路:
多源 BFS 广度优先搜索,将所有初始值为 1 的点作为超级源点同时压入队列,利用 BFS 逐层向外扩展的特性,保证所有 0 第一次被访问时的距离即为到最近 1 的最短路径。
点击查看代码
for (int i = 0; i < n; i++) for (int j = 0; j < m; j++)
if (area[i][j] == '1') { dist[i][j] = 0; q.push({i, j}); } // 多源 BFS:将所有起点同时入队
while (q.size()) {
auto t = q.front(); q.pop();
for (int i = 0; i < 4; i++) {
int x = t.first + dx[i], y = t.second + dy[i];
if (x < 0 || x >= n || y < 0 || y >= m || dist[x][y] != -1) continue;
dist[x][y] = dist[t.first][t.second] + 1; // 记录步数并利用 -1 充当访问标记
q.push({x, y});
}
}
总结:
掌握了多源 BFS 的标准模板,核心踩坑提醒:多源 BFS 的核心在于初始化时必须将所有的源点同时入队并设置距离为 0,同时使用 memset(dist, -1, sizeof(dist)) 配合 dist[x][y] != -1 来进行判重,可以省去额外的 visited 数组,防止队列重复入队导致死循环。
37. Dijkstra求最短路(1)
思路:
循环 \(n\) 次,每次从未确定最短路的点中选出距离最小的点,用它去松弛所有邻接点,适用于稠密图,为朴素版 Dijkstra 算法。
点击查看代码
for (int i = 0; i < n; i++) {
int t = -1;
for (int j = 1; j <= n; j++)
if (!st[j] && (t == -1 || dist[t] > dist[j])) t = j; // 找最小
st[t] = true;
for (int j = 1; j <= n; j++) dist[j] = min(dist[j], dist[t] + g[t][j]); // 松弛
}
总结:
掌握稠密图朴素Dijkstra邻接矩阵实现;核心踩坑提醒:无穷值选用0x3f3f3f3f,避免相加溢出;重边务必取最小值;算法无法处理负权边;松弛操作注意判断g[u][v]是否为无穷,防止无效更新。
38. Dijkstra求最短路(2)
思路:
用小根堆维护当前未确定最短路的点,每次取出堆顶,松弛其出边,适用于稀疏图。
点击查看代码
priority_queue<PII, vector<PII>, greater<PII>> heap;
heap.push({0, 1});
while (heap.size()) {
auto [d, u] = heap.top(); heap.pop();
if (st[u]) continue; st[u] = true;
for (int i = h[u]; ~i; i = ne[i]) {
int v = e[i];
if (dist[v] > d + w[i]) dist[v] = d + w[i], heap.push({dist[v], v}); // 松弛入堆
}
}
总结:
稀疏图采用邻接表存储 + 堆优化 Dijkstra 求解单源最短路,利用 greater<PII> 构建小根堆,;注意判重,避免无效重复扩展。
39. 滚石柱
思路:
考察带三维姿态(0直立、1横向、2纵向)的 BFS 状态空间搜索,通过预计算当前姿态下往4个方向移动的坐标和姿态转移表,结合三维 dist[x][y][lie] 判重,搜索石块直立(lie==0)落在终点 O 上的最少步数。
点击查看代码
for (int i = 0; i < 4; i++) {
State nxt;
nxt.x = t.x + next_x[t.lie][i]; // 根据当前姿态和移动方向查表,求新坐标x
nxt.y = t.y + next_y[t.lie][i]; // 根据当前姿态和移动方向查表,求新坐标y
nxt.lie = next_lie[t.lie][i]; // 根据当前姿态和移动方向查表,求新姿态
if (!check(nxt) || dist[nxt.x][nxt.y][nxt.lie] != -1) continue;
dist[nxt.x][nxt.y][nxt.lie] = dist[t.x][t.y][t.lie] + 1;
q.push(nxt);
}
总结:
掌握了三维状态 BFS 与姿态转移表预计算的技巧,核心踩坑提醒:起点 X 的初始姿态判读极其容易出错,必须通过搜索相邻格子正确辨别单格、横躺与竖躺;同时直立状态绝对不能停留在 E(易碎地面),一定要在 check 函数中加上该特判,否则会得出非法路径的错误答案。
40. 最省赛程
思路:将状态定义为 (城市, 剩余油量),每次有两种转移:加一升油或沿边开到下个城市,用优先队列求最小花费。
点击查看代码
// 在状态 (city, fuel) 下:
if (fuel < cap && dist[city][fuel+1] > dist[city][fuel] + price[city]) {
dist[city][fuel+1] = dist[city][fuel] + price[city]; // 买一升油
heap.push({dist[city][fuel+1], city, fuel+1});
}
for (边) if (fuel >= w && dist[v][fuel-w] > dist[city][fuel]) {
dist[v][fuel-w] = dist[city][fuel]; // 直接开车
heap.push({dist[v][fuel-w], v, fuel-w});
}
总结:
状态空间不只是点,要包含油量,类似分层图最短路,注意终点要求油量为 0。
动态规划
41. Link64 林克的01背包
思路:
状态定义:\(f[i][j]\) 表示背包容量为 \(j\) 时,从前 \(i\) 件物品中选取若干物品能够获得的最大价值。
状态转移方程:
分为两种决策:
- 不选第 \(i\) 件物品:最优解等价于在前 \(i-1\) 件物品、容量 \(j\) 的方案,即 \(f[i-1][j]\);
- 选取第 \(i\) 件物品:需要预留物品体积,即 \(f[i-1][j-v[i]] + w[i]\)。
取两种情况的最大值:
空间优化:
观察转移方程,第 \(i\) 层状态仅依赖第 \(i-1\) 层。可以去掉第一维,使用一维数组 \(f[j]\)。
为防止物品被重复选取,容量 \(j\) 需要从大到小逆序遍历:
点击查看代码
for (int i = 1; i <= n; i++)
for (int j = V; j > 0; j--){
f[j] = f[j];
if (j - v[i] >= 0)
f[j] = max(f[j], f[j - v[i]] + w[i]);
}
总结:
01背包是背包DP基础模型,每件物品仅有选、不选两种选择。
踩坑提示:一维优化必须逆序枚举背包容量;若正序遍历会退化为完全背包(物品可多次选取)。
42. Link76 石子合并(区间DP)
思路:
令 \(f[i][j]\) 表示将第 \(i\) 堆石子到第 \(j\) 堆石子合并成一堆所需的最小代价。
状态转移:对于区间 \([i,j]\),最后一次合并必然是将左区间 \([i,k]\) 和右区间 \([k+1,j]\) 合并。枚举分割点 \(k\)(\(i \le k < j\)),转移方程:
代价计算技巧(前缀和):合并区间 \([i,j]\) 的代价等于区间石子总重量。借助前缀和数组 \(s\),区间和可以 \(O(1)\) 求得:\(s[j] - s[i-1]\)。
点击查看代码
// 区间 DP 模板:外层枚举区间长度
for (int len = 2; len <= n; len++) {
// 内层枚举起点 i
for (int i = 1; i + len - 1 <= n; i++) {
int j = i + len - 1; // 计算终点 j
f[i][j] = 1e8; // 求最小值需要初始化为一个很大的数
// 枚举分割点 k
for (int k = i; k < j; k++) {
// 状态转移:左区间代价 + 右区间代价 + 当前合并两堆的代价
f[i][j] = min(f[i][j], f[i][k] + f[k + 1][j] + s[j] - s[i - 1]);
}
}
}
总结:
掌握了区间 DP 的固定模板:区间 DP 的代码结构非常有规律,外层枚举区间长度 \(\mathit{len}\)(从2开始),中层枚举区间起点 \(i\),内层循环枚举分割点 \(k\)。
⚠️踩坑记录:
在状态转移 \(f[i][j] = \min\big(f[i][j],\ f[i][k] + f[k + 1][j] + s[j] - s[i - 1]\big)\) 中,\(s[j] - s[i - 1]\) 代表本次合并两堆区间产生的总代价,不是增量代价,准确理解该点是正确推导转移方程的关键。计算大区间 \([i,j]\) 依赖更小的子区间 \([i,k]\)、\([k+1,j]\),不能直接正向枚举起点终点。正确实现:外层枚举区间长度 \(\mathit{len}\),内层枚举区间起点 \(i\)。
43. Link75 最长公共子序列
思路:
运用闫氏DP分析法,定义 \(f[i][j]\) 为字符串 \(a\) 的前 \(i\) 个字符与字符串 \(b\) 的前 \(j\) 个字符所能构成的最长公共子序列的长度。
状态转移方程:
当两个序列当前字符相同时(即 \(a[i] = b[j]\)):
当两个序列当前字符不同时,当前字符无法形成匹配,选取两种方案的最大值:不选 \(a\) 的第 \(i\) 位 或者 不选 \(b\) 的第 \(j\) 位。
点击查看代码
for (int i = 1; i <= n; i++)
for (int j = 1; j <= m; j++) {
f[i][j] = max(f[i - 1][j], f[i][j - 1]); // 选出前一步的最大值,即最优解
if (a[i] == b[j])
f[i][j] = max(f[i][j], f[i - 1][j - 1] + 1); // 若当前字符相同,长度+1
}
总结:
掌握了二维数组 DP 的状态定义与转移逻辑;同时学到了一个非常实用的输入技巧:在读入时使用 cin >> a + 1 >> b + 1;,让字符串从下标 1 开始存储,这可以完美避免了循环时处理数组下标 0 所带来的边界判断,极大地简化了代码。
44. Link65 数字三角形(线性DP)
思路:
本题采用自底向上的递推思路。
状态定义:令 f[i][j] 表示从第 i 行第 j 列的数字出发,走到三角形最底层所能获取的最大路径和。
状态转移方程:位于 (i, j) 位置的数字,可以选择向下走到 (i+1, j),或者向右下走到 (i+1, j+1)。因此,其最大路径和等于当前数字加上两个可能子路径中的最大值。方程即:
f[i][j] += max(f[i + 1][j], f[i + 1][j + 1])
点击查看代码
// 从倒数第二行开始,逐层往上递推
for (int i = N - 1; i >= 1; i--)
for (int j = 1; j <= i; j++)
// 核心状态转移:当前值 + 下方和右下方子问题的最大值
f[i][j] += max(f[i + 1][j], f[i + 1][j + 1]);
总结:
掌握递推方向的选择,本题自底向上的递推写法可以规避自顶向下数组越界的判断,更加简洁。
45. Link72 最长上升子序列(2)(线性DP)
思路:
采用贪心+二分优化。
数组 \(q\) 的含义:\(q[k]\) 表示长度为 \(k+1\) 的上升子序列,末尾元素能取到的最小值。
- 遍历每一个元素 \(a[i]\);
- 在 \(q[0 \sim len]\) 二分查找:找到最大下标 \(r\),满足 \(q[r] < a[i]\);
- 此时可以拼接形成长度 \(r+2\) 的上升子序列:
- 更新 \(q[r+1] = a[i]\);
- 同时更新最大长度 \(\mathit{len}\)。
点击查看代码
int len = 0;
for (int i = 0; i < n; i++)
{
int l = 0, r = len;
while (l < r)
{
int mid = l + r + 1 >> 1;
if (q[mid] < a[i]) l = mid;
else r = mid - 1;
}
len = max(len, r + 1);
q[r + 1] = a[i];
}
总结:
采用贪心+二分优化,不保存完整子序列,只维护各长度子序列最小尾部值,时间复杂度 \(O(n\log n)\)。这个算法不能直接还原出完整子序列。踩坑提示:区分严格上升和非严格上升(<=)。
46. Link73 拦截导弹(线性DP)
思路:
第一问(最长非递增子序列长度):定义状态 f[i] 表示以第 i 枚导弹结尾的最长非递增子序列的长度。状态转移方程为 f[i] = max(f[i], f[j] + 1)(前提条件:h[j] >= h[i],即前一枚导弹高度不低于当前导弹)。最后找出 f 数组中的最大值即为答案。
第二问(最少拦截系统数):Dliworth定理(一个序列的最少非递增子序列覆盖数等于该序列的最长上升子序列的长度),遍历每一枚导弹,寻找第一套能够拦截它的系统(即 q[k] >= h[i],系统当前的最后高度大于等于来袭导弹高度)。如果找到了,就更新该系统的最后拦截高度;如果所有现有系统都无法拦截,则新增一套系统。
点击查看代码
for (int i = 0; i < n; i++)
{
// ---------- 第一问:求最长非递增子序列 ----------
f[i] = 1;
for (int j = 0; j < i; j++)
if (h[i] <= h[j])
f[i] = max(f[i], f[j] + 1);
res = max(res, f[i]);
// ---------- 第二问:贪心求最少拦截系统 ----------
int k = 0;
// 寻找第一套能够拦截当前导弹的系统(条件:该系统最后拦截高度 >= 当前高度)
while (k < cnt && q[k] < h[i]) k++;
if (k == cnt) // 没有现有系统能拦截,新增一套
q[cnt ++ ] = h[i];
else // 找到了,更新该系统的高度为当前导弹高度
q[k] = h[i];
}
总结:
学习并掌握了在 C++ 中利用
47. Link66 多重背包问题(2)(背包DP)
思路:
采用二进制优化的方法:将数量为 s 的物品按 (1, 2, 4, ..., 2^k) 进行分组拆包,将其转化为若干个独立的 01 背包物品,最后对拆分后的物品数组做一次一维的 01 背包求解即可。
点击查看代码
// 核心步骤:二进制拆分优化
for (int k = 1; k <= s; k *= 2) {
s -= k;
goods.push_back({v * k, w * k}); // 将 k 件物品打包成一件新的“捆绑物品”
}
if (s > 0) goods.push_back({v * s, w * s}); // 处理最后剩余的部分
}
// 转换为 01 背包,使用一维数组逆序循环求解
for (auto good: goods) {
for (int j = m; j >= good.v; j--) {
f[j] = max(f[j], f[j - good.v] + good.w);
}
}
总结:
如果直接使用三重循环,复杂度会高达 (O(V \cdot \sum s_i)) 从而导致超时。采用二进制思想,利用拆分后可以表达范围内的任何数,减少时间复杂度。
48. Link68 混合背包(背包DP)
思路:
分类讨论
完全背包(s == 0):利用正序遍历一维数组,允许同一物品被反复拿取。
多重背包(s > 0):采用二进制优化,将 (s) 个相同的物品拆分成 (log_2 s) 个 0-1 背包物品,再将它们依次进行 01 背包处理。
01 背包(s == -1):本质上就是数量为 1 的多重背包,直接令 s=1,然后套用多重背包的二进制拆分即可,或者直接做一次 01 背包处理。
点击查看代码
for (int i = 0; i < n; i++)
{
int v, w, s;
cin >> v >> w >> s;
// 1. 完全背包处理 (s == 0)
if(!s)
{
// 内层循环正序遍历,允许无限次选择同类物品
for (int j = v; j <= m; j++)
f[j] = max(f[j], f[j-v] + w);
}
else
{
// 2. 0-1 背包处理 (s == -1) -> 转化为 s = 1
if (s == -1) s = 1;
// 3. 多重背包处理 (s > 0) 之二进制优化
for(int k = 1; k <= s; k *= 2)
{
// 拆包后的物品按 0-1 背包处理,内层必须逆序
for (int j = m; j >= k * v; j--)
f[j] = max(f[j], f[j - k * v] + k * w);
s -= k; // 减去已打包的数量
}
// 处理多重背包拆包后剩余的物品
if (s)
{
for (int j = m; j >= s * v; j--)
f[j] = max(f[j], f[j - s * v] + s * w);
}
}
}
总结:
掌握了混合背包的合并政策。完全背包和多重/01背包在内层循环的方向上要严格区分,完全背包要求“覆盖最大价值”,只能正序循环;多重背包和01背包防止同组物品重复选择,必须逆序循环。
49. Link79 加分二叉树(区间DP)
思路:
任意一段区间 ([l, r]) 对应一棵子树,而区间的划分点 (k) 就是这棵子树的根节点。因此,我们可以将二叉树的问题完美地转化为区间动态规划问题。
状态定义:令 f[l][r] 表示中序遍历区间为 [l, r] 的子树能获得的最高加分;同时用 root[l][r] 记录下达到该最高加分时所选取的根节点编号。
状态转移:枚举区间内的每一个点 (k) 作为当前的根节点。左子树区间为 [l, k-1],右子树区间为 [k+1, r]。
score = (左子树最高分) * (右子树最高分) + 节点 k 的分数。
点击查看代码
// 区间 DP 模板:外层枚举区间长度,内层枚举起点
for (int len = 1; len <= n; len ++)
for (int l = 1; l + len - 1 <= n; l++)
{
int r = l + len - 1; // 计算区间终点
// 枚举当前区间内的根节点 k
for (int k = l; k <= r; k++)
{
// 边界处理:空子树的加分为 1,而非 0
int left = k == l ? 1 : f[l][k - 1];
int right = k == r ? 1 : f[k + 1][r];
int score = left * right + w[k];
if (f[l][r] < score) // 如果有更大的加分,则更新记录
{
f[l][r] = score;
root[l][r] = k; // 同时记录最优分对应的根节点
}
}
}
总结:
掌握区间DP与二叉树遍历的结合。踩坑提醒:空子树的分数不是0而是1,如果直接写left = f[l][k-1],当k == 1时,空子树得分为0,会出错。unsigned防止溢出。
50. Link80 没有上司的舞会(树形DP)
思路:
状态定义:定义 f[u][0] 为不选节点 (u) 时,以 (u) 为根的子树能获得的最大快乐值;定义 f[u][1] 为选择节点 (u) 时,以 (u) 为根的子树能获得的最大快乐值。
状态转移方程:
如果选择节点 (u)(即 f[u][1]),由于不能和直属上司同时参加,它的所有直接子节点 j 都不能选。方程:f[u][1] += f[j][0]。
如果不选节点 (u)(即 f[u][0]),它的所有直接子节点 j 有两种选择:选或不选都可以,取两者中的较大值。方程:f[u][0] += max(f[j][0], f[j][1])。
数据结构选型:树的特点是有一对多的关系。由于边是单向的(上司指向下属),我们只需在每次读取输入 a, b 时,执行 add(b, a) 添加一条从上级 b 到下级 a 的边,并标记 has_fa[a] = true 记录该节点有父节点。
寻找根节点:整棵树没有给出明确的根节点。我们可以遍历所有节点,利用 has_fa 数组找到没有父节点的那个节点,它就是真正的校长(根节点)。从根节点开始 DFS 递归求解,答案就是 max(f[root][0], f[root][1])。
点击查看代码
// 添加一条从 a 指向 b 的单向边
void add(int a, int b)
{
e[idx] = b, ne[idx] = h[a], h[a] = idx++;
}
void dfs(int u)
{
f[u][1] = happy[u]; // 如果选择节点 u,先加上它自己的快乐值
// 遍历节点 u 的所有子节点
for (int i = h[u]; ~i; i = ne[i]) // ~i 等价于 i != -1
{
int j = e[i]; // j 为 u 的子节点
dfs(j); // 递归深入计算子树
f[u][1] += f[j][0]; // 选u,则不选子节点j
f[u][0] += max(f[j][0], f[j][1]); // 不选u,子节点j可选可不选
}
}
int main()
{
cin >> n;
for (int i = 1; i <= n; i++) cin >> happy[i];
memset(h, -1, sizeof h); // 初始化邻接表头节点为 -1
for (int i = 0; i < n - 1; i++)
{
int a, b;
cin >> a >> b;
add(b, a); // b 是 a 的上司,建一条 b -> a 的单向边
has_fa[a] = true; // 标记节点 a 有上司
}
// 寻找根节点(没有上司的节点)
int root = 1;
while (has_fa[root]) root++;
dfs(root); // 从根节点开始递归搜索
cout << max(f[root][0], f[root][1]) << endl; // 根节点选或不选的最大值
return 0;
}
总结:
树形DP最大的特点就是“递归自底向上”,利用dfs优先深入到叶子节点,算完节点再回溯更新父节点的状态。
附录



浙公网安备 33010602011771号