期末考察范围:暴力,贪心,分治,递归,dp,搜索,其他....
暴力:编程 枚举
贪心:编程
分治/递归:编程,简答
dp:编程 0/1背包简化
搜索:简答,编程
其他:问答
注意:以下所有仅供思路和理解上的参考。
DP:
先说说01背包:
01背包模板题
01背包是动态规划中最基础也是最重要的模型。本文记录从朴素的二维 DP 到空间优化后的一维 DP 的推导过程。
问题描述
给定 $N$ 个物品,第 $i$ 个物品的体积为 $v[i]$,价值为 $w[i]$。
有一个容量为 $M$ 的背包。每件物品只能选择一次,求在不超过背包容量的前提下,能装入的最大总价值。
1. 朴素解法 (二维 DP)
状态定义
定义 $f[i][j]$ 表示:只看前 $i$ 个物品,在背包容量为 $j$ 的情况下,能获得的最大价值。
状态转移方程
对于第 $i$ 个物品,我们只有两个选择:
- 不选:价值等于前 $i-1$ 个物品在容量 $j$ 下的价值。即 $f[i-1][j]$。
- 选:前提是 $j \ge v[i]$。价值等于前 $i-1$ 个物品在容量 $j-v[i]$ 下的价值加上当前物品价值 $w[i]$。
转移方程如下:
$$f[i][j] = \max(f[i-1][j], \ f[i-1][j-v[i]] + w[i])$$
核心代码
// 朴素二维版本
cin >> n >> m;
rep(i, 1, n) cin >> v[i] >> w[i];
rep(i, 1, n) // 枚举物品
{
rep(j, 0, m) // 枚举容积
{
f[i][j] = f[i - 1][j]; // 不选
if (j >= v[i]) // 选(前提是装得下)
f[i][j] = max(f[i][j], f[i - 1][j - v[i]] + w[i]);
}
}
cout << f[n][m] << endl;空间优化:从二维到一维
1. 滚动数组的思路
观察二维状态转移方程:
$$ f[i][j] = \max(f[i-1][j], \ f[i-1][j-v[i]] + w[i]) $$
我们发现,计算第 $i$ 层物品的状态时,只依赖于第 $i-1$ 层(上一层)的状态。更早的 $i-2$ 层数据其实已经没用了。
这就好比盖楼,盖第 10 层的时候,只需要基于第 9 层,跟第 1 层没关系。
所以,我们可以把二维数组 $f[i][j]$ 压缩成一维数组 $f[j]$。
新方程:
$$ f[j] = \max(f[j], \ f[j-v[i]] + w[i]) $$
2. 为什么必须倒序遍历?(核心)
将状态压缩为一维后,枚举背包容量 $j$ 的顺序变得至关重要。
-
如果正序遍历 (
rep):
当我们计算 $f[j]$ 时,需要用到 $f[j-v[i]]$。
因为 $j-v[i] < j$,在从左往右遍历时,f[j-v[i]] 已经被当前这层(第 $i$ 个物品)更新过了。
这就意味着:我们在通过“已经放入第 $i$ 个物品”的状态来更新 $f[j]$,导致一件物品被放入多次。这是完全背包的做法,不符合 01 背包“每种只有一件”的限制。 -
如果倒序遍历 (
dep):
当我们计算 $f[j]$ 时,由于是从右往左更新,f[j-v[i]] 还没有被当前层访问到。
此时的 $f[j-v[i]]$ 存储的依然是上一层(第 $i-1$ 个物品)的状态。
这完美符合了 $f[i][j] = \max(f[i-1][j], \dots)$ 的定义。
3. 优化后代码实现
利用宏定义 dep (for loop downto) 来实现倒序:
// 空间优化核心代码
// v[i]: 体积, w[i]: 价值, s: 背包总容量
vll f(s + 1, 0);
rep(i, 1, n) // 第一层循环:枚举物品
{
// 第二层循环:必须倒序!从背包容量 s 遍历到 v[i]
dep(j, s, v[i])
{
f[j] = max(f[j], f[j - v[i]] + w[i]);
}
}
cout << f[s] << endl;此外01背包有时候还有这样的问题:
1.不要求背包装满能够获得的最大价值。
2.背包恰好装满时能够获得的最大价值
进阶:背包恰好装满 vs 不必装满
在 01 背包的变种题目中,我们经常会遇到两种不同的问法:
- 不要求背包装满,求能获得的最大价值(标准 01 背包)。
- 要求背包恰好装满,求能获得的最大价值。若无法装满,则输出 0 或无解。
例题参考:牛客网 - 01背包的一些特定的情况
初始化决定状态
这两种问法的区别,仅仅在于 DP 数组的初始化。
1. 不要求装满
初始化: f 数组全部初始化为 0。
原理:
如果背包不要求装满,那么“容量为 $j$ 的背包什么都不装”也是一种合法状态,其价值为 0。
所以在转移过程中,任何容量起步都是合法的。
2. 要求恰好装满
初始化: f[0] = 0,其余 f[1...m] 全部初始化为 -INF(负无穷)。
原理:
-
合法起点:只有容量为 0 的背包,在什么都不装的情况下是“恰好装满”的(被 0 体积的东西填满),价值为 0。所以
f[0] = 0。 -
非法起点:对于 $j > 0$,如果不装任何东西,是不可能“恰好装满”容量 $j$ 的。所以这些状态在初始时是非法的,用
-INF标记。 -
状态转移:在 DP 过程中,
max操作会保证我们就只能从合法状态(即从f[0]推导出来的路径)进行转移。如果一个状态无法由f[0]经过若干物品推导而来,它的值会依然是一个很小的负数。
代码实现
以下代码同时展示了这两种情况的处理。
dp1: 对应不要求装满。dp2: 对应要求恰好装满。
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
typedef vector<ll> vll;
#define IOS ios::sync_with_stdio(0); cin.tie(0); cout.tie(0);
#define endl '\n'
#define rep(i, x, n) for (ll i = x; i <= n; i++)
#define dep(i, x, n) for (ll i = x; i >= n; i--)
#define INF 0x3f3f3f3f3f3f3f3f
const int N = 1e3 + 10;
ll n, m;
ll v[N], w[N];
void solve()
{
cin >> n >> m;
rep(i, 1, n) cin >> v[i] >> w[i];
// dp1: 不要求装满,全部初始化为 0
vll dp1(m + 1, 0);
// dp2: 要求恰好装满,初始化为 -INF,只有 dp2[0] 为 0
vll dp2(m + 1, -INF);
dp2[0] = 0;
rep(i, 1, n)
{
dep(j, m, v[i])
{
// 方案 1 转移
dp1[j] = max(dp1[j], dp1[j - v[i]] + w[i]);
// 方案 2 转移 (逻辑完全一样)
dp2[j] = max(dp2[j], dp2[j - v[i]] + w[i]);
}
}
// 输出方案 1
cout << dp1[m] << endl;
// 输出方案 2
// 注意:如果 dp2[m] 依然是负数(说明由 -INF 转移而来),则无解
if (dp2[m] < 0)
{
cout << 0 << endl;
}
else
{
cout << dp2[m] << endl;
}
}
int main()
{
IOS;
int _ = 1;
while (_--)
{
solve();
}
return 0;
}由于复习时间和考的范围有限其他的背包问题就先不写了,想清楚上面的问题至少对01背包问题有了一个深刻的认识。
然后我们再说说递归
经典的搜索任务:
1.子集枚举
2.排列枚举
3.组合枚举
描述:前两个在解空间中可用解空间树描述,前一个称为子集树,共有2^n个叶节点,后一个称为排列树,叶节点共有n!个。
我在luogu上找了一些典型的例题,除此之外有时间还可以针对的写写对应的题单。
我们以 Luogu P1706 全排列问题为例,深入理解DFS和回溯的思想。
题目描述
P1706 全排列问题
按照字典序输出自然数 $1$ 到 $n$ 所有不重复的排列。
1. 算法核心:搜索树与回溯
排列树
我们可以把全排列的过程想象成“填空位”。
假设有 $n$ 个空盒子,我们需要把 $1 \dots n$ 这 $n$ 个数字填进去。
- 第一层递归:站在第 1 个盒子前,我们有 $1 \dots n$ 种选择。
- 第二层递归:站在第 2 个盒子前,我们不能选刚才已经用过的数字,只能选剩下的。
- 终止条件:当我们走到第 $n+1$ 个盒子时,说明前 $n$ 个都已经填满,此时输出结果。
关键概念:状态数组 st[]
在递归过程中,我们需要知道哪些数字由于被前面的盒子占用了而不能选。
因此引入一个 bool 数组 st[i]:
-
st[i] == true: 数字 $i$ 已经被使用了。 -
st[i] == false: 数字 $i$ 目前可用。
核心步骤:回溯
这是新手最容易晕的地方。当我们填完一种排列(比如 1 2 3),需要退回到上一步(第 2 个盒子),把 3 拿出来,尝试填入其他数字。
拿出来”这个动作,就是回溯(恢复现场)。
如果不把 st[3] 重新设为 false,程序会以为 3 还在盒子里,导致后续的排列无法再次使用 3。
2. 代码实现细节
我们在 dfs(x) 函数中维护三个逻辑:
-
边界判断:如果
x > n,说明已经填完了,打印并返回。 - 枚举选择:从小到大尝试 $1 \dots n$ 每个数字。
-
分支与回溯:
- 如果数字
i没用过 $\rightarrow$ 标记st[i]=true$\rightarrow$ 递归dfs(x+1)。 - 递归回来后 $\rightarrow$ 恢复现场
st[i]=false。
- 如果数字
3. 完整 AC 代码
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
#define IOS ios::sync_with_stdio(0); cin.tie(0); cout.tie(0);
#define endl '\n'
int n;
int arr[15]; // 存储当前的排列结果
bool st[15]; // 状态数组,记录数字是否被使用
// x 表示当前正在枚举第几个位置
void dfs(int x) {
// 1. 截止条件:如果位置 x 超过了 n,说明 1~n 都填好了
if (x > n) {
for (int i = 1; i <= n; i++) {
// 题目要求保留 5 个场宽
printf("%5d", arr[i]);
}
printf("\n");
return; // 非常重要!必须返回,否则会越界继续执行
}
// 2. 枚举当前位置 x 可以填入的数字 (1 到 n)
for (int i = 1; i <= n; i++) {
// 如果数字 i 没有被使用过
if (!st[i]) {
// 2.1 修改状态(占位)
arr[x] = i; // 把 i 填入第 x 个坑
st[i] = true; // 标记 i 已经被用过了
// 2.2 递归到下一层
dfs(x + 1);
// 2.3 回溯(恢复现场)
// 这一步至关重要:从下一层回来后,要把 i 拿出来,
// 标记为未使用,这样循环到下一次或者回退时才能再次使用 i
arr[x] = 0; // (可选) 实际上会被覆盖,不写也没事
st[i] = false; // 核心:解除标记
}
}
}
void solve() {
cin >> n;
dfs(1); // 从第 1 个位置开始搜索
}
int main() {
// 由于使用了 printf,混合 cin/cout 时最好不要关闭同步流,或者只用 C 风格 IO
// 这里为了题目要求的格式化输出,核心部分使用了 printf
int _ = 1;
while (_--) {
solve();
}
return 0;
}递归与搜索:从全排列到组合输出
在上一篇文章中,我们通过 [P1706 全排列] 学习了利用 st[] 标记数组来实现全排列的搜索。
今天我们来看看它的兄弟问题:[P1157 组合的输出]。
题目描述
从 $1 \sim n$ 这 $n$ 个自然数中,任取 $r$ 个数,输出所有可能的组合。
要求:每个组合中的元素按从小到大的顺序排列,所有组合按字典序输出。
例如 $n=5, r=3$,输出:
1 2 3, 1 2 4 ... 3 4 5
1. 组合 vs 排列:核心区别
在写代码之前,必须搞清楚“组合”和“排列”在搜索树上的区别:
- 全排列 :顺序重要。
1 2 3和3 2 1是两个不同的方案。-
策略:每次都从 $1$ 扫到 $n$,只要没用过(
!st[i])就能选。
-
策略:每次都从 $1$ 扫到 $n$,只要没用过(
- 组合 :顺序不重要。
1 2 3和3 2 1是同一个组合。- 策略:为了不重复计算,我们人为规定**“必须按从小到大的顺序选”。
- 比如:选了
2之后,下一个数只能选3, 4, 5...,绝对不能回头选1。
关键参数:start
为了实现“不回头”,我们在 DFS 函数中增加一个参数 start,表示当前这个空位,只能从 start 开始枚举。
2. 代码实现细节
函数定义 dfs(x, start)
-
x: 当前正在填第几个坑(一共要填 $r$ 个)。 -
start: 当前这个坑,数字枚举的起始值。
递归逻辑
-
截止条件:
x > r,说明 $r$ 个坑填满了,输出并返回。 -
枚举选择:循环
i从start到n。 -
递归下一步:
dfs(x + 1, i + 1)。-
重点:下一个坑必须比当前选的
i还要大,所以是i + 1,而不是start + 1。
-
重点:下一个坑必须比当前选的
3. 完整 AC 代码
#include <bits/stdc++.h>
using namespace std;
#define IOS ios::sync_with_stdio(0); cin.tie(0); cout.tie(0);
#define endl '\n'
int n, r; // n选r
int arr[25]; // 存储当前的组合
// x: 当前枚举到了第几个位置 (1..r)
// start: 当前位置只能填 >= start 的数字
void dfs(int x, int start)
{
// 1. 截止条件:选够了 r 个数
if (x > r)
{
for (int i = 1; i <= r; i++)
{
printf("%3d", arr[i]); // 题目要求3个场宽
}
cout << endl;
return;
}
// 2. 枚举当前位置能填的数
// 只能从 start 开始填,保证单调递增,从而保证是组合而不是排列
for (int i = start; i <= n; i++)
{
arr[x] = i; // 填入数字
// 3. 递归下一层
// 下一个位置必须从 i+1 开始填
// 错误示范:dfs(x + 1, start + 1) -> 会导致重复或非递增
dfs(x + 1, i + 1);
arr[x] = 0; // 回溯(其实不写也行,会被覆盖)
}
}
void solve()
{
cin >> n >> r;
// 从第 1 个位置开始填,数字最小从 1 开始
dfs(1, 1);
}
int main()
{
// IOS;
// 注意:混用 printf 和 cout 时建议不要关同步,或者统一用一种 IO
int _ = 1;
while (_--)
{
solve();
}
return 0;
}递归与搜索:指数型枚举(子集选择)
在之前的文章中,我们攻克了 [P1706 全排列]和 [P1157 组合输出]。
今天我们来解决搜索算法中的第三块拼图:[B3622 枚举子集]。
题目描述
今有 $n$ 位同学,可以从中选出任意名同学参加合唱。请输出所有可能的选择方案。
每一名同学的状态用字符表示:
Y:参加N:不参加
要求:以字典序输出答案。
例如 $n=3$,输出:
NNN, NNY, NYN ... YYY
1. 指数型枚举 vs 排列组合
在写代码之前,我们要搞清楚这道题和之前两道题在决策树上的本质区别:
- 全排列/组合:是“填坑”模型。我们要考虑的是“当前这个坑填哪个数字”。
-
指数型枚举 (子集):是“开关”模型。
- 我们不需要去选数字,而是要遍历这 $n$ 位同学。
- 策略:对于每一位同学,我们只有 2 种选择——选 (Y) 或者 不选 (N)。
- 这种结构对应的是一棵二叉树,总方案数是 $2^n$。
关键逻辑:字典序
题目要求字典序输出。在 ASCII 码中,'N' (78) 小于 'Y' (89)。
为了满足字典序,我们在递归决策时,必须严格遵守顺序:
先尝试 N (不选) $\to$ 再尝试 Y (选)。
2. 代码实现细节
状态记录 string ans
与全排列使用 arr[] 数组不同,这里我们用一个字符串 string ans 来模拟栈结构,记录当前路径上的选择。
递归逻辑 dfs(x)
-
x:表示当前正在做第x位同学的决定。 -
截止条件:
x > n,说明 $n$ 位同学的决定都做完了,输出ans并返回。 -
枚举选择:
-
ans += 'N'(入栈) $\to$dfs(x+1)$\to$ans.pop_back()(回溯) -
ans += 'Y'(入栈) $\to$dfs(x+1)$\to$ans.pop_back()(回溯)
-
为了代码简洁,我们可以用一个字符数组 op[2] = {'N', 'Y'} 来循环枚举。
3. 完整 AC 代码
#include <bits/stdc++.h>
using namespace std;
// CJX__//
typedef long long ll;
#define endl '\n'
// 题目参数
ll n;
// 字典序 N < Y,所以 N 放前面(对应索引0),Y 放后面(对应索引1)
char op[2]={'N','Y'};
string ans; // 记录当前的决策路径 (充当栈)
// x 表示当前正在做第 x 个人的决定
void dfs(int x){
// 1. 截止条件:已经做完 n 个人的决定了
if(x > n){
cout << ans << endl;
return ;
}
// 2. 枚举当前人的两种状态:0(N) 和 1(Y)
for(int i = 0; i <= 1; i++){
ans += op[i]; // 1. 做出选择 (入栈)
dfs(x + 1); // 2. 递归下一层 (处理下一个人)
ans.pop_back(); // 3. 回溯 (出栈,恢复现场)
}
}
void solve()
{
cin >> n;
dfs(1); // 从第 1 个人开始
}
int main()
{
ios::sync_with_stdio(0);
cin.tie(0);
cout.tie(0);
int t = 1;
while (t--)
{
solve();
}
return 0;
}DFS剪枝的那些要具体问题具体分析,以上的题目都是最基础最经典的三种例题,DFS的路道阻且长,还希望多加练习。
给出暴力枚举的递归题单:
分治:先看PTT,先得复习了一下物理了。
《算法基础篇》第 1 章 基础算法(下).pdf
分治在这个PPT的下面了,倒着翻容易找到
未知的期末考前卷?(保持怀疑):
1. (单选题, 5.0 分)
贪心算法与动态规划算法的主要区别是( )。
- A. 最优子结构
- B. 贪心选择性质 ✅
- C. 构造最优解
- D. 定义最 优解
2. (单选题, 5.0 分)
Strassen矩阵乘法是利用( )实现的算法。
- A. 分治策略 ✅
- B. 动态规划法
- C. 贪心法
- D. 回溯法
3. (单选题, 5.0 分)
( )是贪心算法与动态规划算法的共同点。
- A. 重叠子问题
- B. 构造最优解
- C. 贪心选择性质
- D. 最优子结构性质 ✅
4. (单选题, 5.0 分)
解决活动安排问题,最好用( )算法
- A. 分治
- B. 贪心 ✅
- C. 动态规划
- D. 穷举
5. (单选题, 5.0 分)
下列算法中通常以自底向下的方式求解最优解的是( )。
- A. 分治法
- B. 动态规划法 ✅
- C. 贪心法
二、 多选题 (共 5 题, 25.0 分)
6. (多选题, 5.0 分)
实现最大子段和序列的算法是( )。
- A. 回溯法
- B. 分治策略 ✅
- C. 动态规划法 ✅
- D. 贪心法
7. (多选题, 5.0 分)
下列关于贪心算法的描述中,哪些是正确的?
- A. 贪心算法在每一步选择中都采取当前状态下最优的选择,希望通过局部最优达到全局最优解。
- B. 贪心算法总能保证得到问题的全局最优解。
- C. 贪心算法适用于所有优化问题。
- D. 贪心算法通常用于求解具有贪心选择性质的问题。
- E. 贪心算法的时间复杂度总是低于动态规划算法。
8. (多选题, 5.0 分)
动态规划算法的核心要素包括哪些?
- A. 最优子结构
- B. 贪心选择性质
- C. 重叠子问题
- [] D. 递归关系
- E. 分治法的分解策略
9. (多选题, 5.0 分)
下列关于分治法的描述中,哪些是正确的?
- A. 分治法通过将原问题分解成若干个子问题来求解。
- B. 分治法的子问题通常是相互独立的。
- C. 分治法适用于所有递归问题。
- D. 分治法的典型应用包括快速排序和归并排序。
- E. 分治法的运行时间通常比动态规划更快。
10. (多选题, 5.0 分)
下列算法中,哪些属于贪心算法?
- A. 霍夫曼编码算法
- B. Dijkstra最短路径算法
- C. 斐波那契数列的递归算法
- D. 背包问题的0-1版本
- E. Kruskal最小生成树算法
三、 填空题 (共 5 题, 25.0 分)
11. (填空题, 5.0 分)
使用动态规划算法 MATCHAIN 找出 $n$ 个矩阵链乘所需的最小的元素乘法次数时间为 $O(n^3)$ ,空间为 $O(n^2)$ 。
12. (填空题, 5.0 分)
求长度分别为 $n$ 和 $m$ 的两个串的最长公共子序列的算法 LCS 求出这两个串的最长公共子序列长度所需处理的时间为 $O(nm)$ ,空间为 $O(nm)$ 。
13. (填空题, 5.0 分)
考虑使用动态规划算法求解一个多段图中从源点 $s$ 到汇点 $t$ 的一条最短路。假定多段图中的结点有 $n$ 个,边有 $m$ 条,共有 $k$ 段,且使用邻接表表示图。该算法的时间复杂度为 $O(n + m)$ 。
14. (填空题, 5.0 分)
考虑确定下列矩阵链乘的最优乘法次序问题:$M_1(10 \times 3), M_2(3 \times 12), M_3(12 \times 15), M_4(15 \times 8), M_5(8 \times 2)$。计算 $M_1M_2M_3M_4M_5$ 需要的阵元素乘法最少的乘法次数为 732 ,一个表示最优计算次序的加括号表示为 M1((M2(M3(M4M5)))) 。
15. (填空题, 5.0 分)
两个序列 $A=$"xyxnzxysxy" 和 $B=$"zxzyysxxyxxz" 的最长公共子序列的长度为 6 。
四、 判断题 (共 2 题, 10.0 分)
16. (判断题, 5.0 分)
在动态规划中,最优子结构性质表明原问题的最优解中所包含的子问题的解也必然是相应子问题的最优解。
- A. 对 ✅
- B. 错
17. (判断题, 5.0 分)
贪心算法总是能够得到全局最优解,因此它适合于所有优化问题。
- A. 对
- B. 错 ✅
五、 简答题 (共 3 题, 15.0 分)
18. (简答题, 5.0 分)
考虑使用动态规划方法求解下列问题:01背包数据如下表,求:能够放入背包的具有最大价值的物品集合。
| 物品 $i$ | 重量 $w_i$ | 价值 $v_i$ | 承重量 $W$ |
|---|---|---|---|
| 1 | $w_1=2$ | $v_1=12$ | $W=5$ |
| 2 | $w_2=1$ | $v_2=10$ | |
| 3 | $w_3=3$ | $v_3=20$ | |
| 4 | $w_4=2$ | $v_4=15$ |
如设:$V(i, j)$ —— 前 $i$ 个物品中能够装入承重量 $j$ 的背包中的最大总价值。请将如下递推式填写完整:
$V(0, j) = 0$ (0个物品),$V(i, 0) = 0$ (承重量0)
$V(i, j) = V(i-1, j)$ 当 $i$ 个物品不能装入, $j < w_i$ (超重)
$V(i, j) = \max { __________ , __________ }$ $j \ge w_i$ (不超重)
自底向上:按行或列填写下表。
| $V$ | $j=0$ | 1 | 2 | 3 | 4 | 5 |
|---|---|---|---|---|---|---|
| $i=0$ | 0 | 0 | 0 | 0 | 0 | 0 |
| 1 | 0 | |||||
| 2 | 0 | |||||
| 3 | 0 | |||||
| 4 | 0 |
19. (简答题, 5.0 分)
考虑在序列 $A[1..n]$ 中找最大最小元素的问题。一个分治算法描述如下:如果 $n \le 2$ 就直接求解。否则,将序列等分成两个子序列 $A[1..n/2]$ 和 $A[n/2+1..n]$,分别找出这两个子序列的最大最小元素 $x_1, y_1$ 和 $x_2, y_2$;然后据此求出 $A[1..n]$ 的最大元素 $x=\max{x_1, x_2}$ 及最小元素 $y=\min{y_1, y_2}$。请给出该算法计算时间 $T(n)$ 满足的递归方程,并解方程来确定算法的时间复杂度。假定 $n=2^k$($k$ 为正数)。
20. (简答题, 5.0 分)
简述动态规划的基本思想、与贪心法的区别及其不足之处。
18题
AC代码:
#include <bits/stdc++.h>
using namespace std;
// CJX__//
typedef long long ll; // 不开long long 见祖宗
typedef unsigned long long ull;
typedef __int128 i128;
typedef pair<int, int> PII;
typedef pair<ll, ll> PLL;
typedef vector<int> vi;
typedef vector<ll> vll;
typedef vector<double> vd;
typedef vector<PII> vPII;
#define IOS \
ios::sync_with_stdio(0); \
cin.tie(0); \
cout.tie(0);
#define debug(...) cout << "[debug] " #__VA_ARGS__ " = " << (__VA_ARGS__) << endl;
#define out(x) cout << ((x) ? "YES" : "NO") << endl
#define mod(x, P) (((x) % (P) + (P)) % (P))
#define endl '\n'
#define gcd __gcd
#define lc p << 1
#define rc p << 1 | 1
#define INF 0x3f3f3f3f3f3f3f3f
#define inf 0x3f3f3f3f
#define fi first
#define se second
#define all(x) x.begin(), x.end()
#define lowbit(x) ((x) & (-x))
#define rep(i, x, n) for (ll i = x; i <= n; i++)
#define dep(i, x, n) for (ll i = x; i >= n; i--)
#define mem(a, x) memset(a, x, sizeof a)
const double eps = 1e-5;
const int N = 1e5 + 10, M = 2 * N, K = 26;
const ll MOD = 1e9 + 7, Md3 = 998244353, Md7 = 1e9 + 7, Md9 = 1e9 + 9;
const ll base1 = 131, base2 = 13331;
const int dx[8] = {-1, 0, 1, 0, -1, -1, 1, 1}, dy[8] = {0, 1, 0, -1, -1, 1, -1, 1};
const int ddx[8] = {1, 1, 2, 2, -1, -1, -2, -2}, ddy[8] = {2, -2, 1, -1, 2, -2, 1, -1};
template <typename T>
bool cmin(T &a, const T &b) { return b < a ? a = b, 1 : 0; }
template <typename T>
bool cmax(T &a, const T &b) { return b > a ? a = b, 1 : 0; }
template <typename T>
void sort_range(vector<T> &v, int l, int r) { sort(v.begin() + l, v.begin() + r + 1); }
template <typename T>
struct BIT1
{
int n;
vector<T> tr;
BIT1(int n) : n(n), tr(n + 1) {}
void add(int x, T v)
{
for (; x <= n; x += x & -x)
tr[x] += v;
}
T sum(int x)
{
T r = 0;
for (; x; x -= x & -x)
r += tr[x];
return r;
}
T range(int l, int r) { return sum(r) - sum(l - 1); }
};
template <typename T>
struct BIT2
{
int n, m;
vector<vector<T>> t1, t2, t3, t4;
BIT2(int n_ = 0, int m_ = 0) { init(n_, m_); }
void init(int n_, int m_)
{
n = n_;
m = m_;
t1.assign(n + 1, vector<T>(m + 1, T{}));
t2.assign(n + 1, vector<T>(m + 1, T{}));
t3.assign(n + 1, vector<T>(m + 1, T{}));
t4.assign(n + 1, vector<T>(m + 1, T{}));
}
void _add(int x, int y, const T &v)
{
for (int i = x; i <= n; i += i & -i)
for (int j = y; j <= m; j += j & -j)
{
t1[i][j] += v;
t2[i][j] += v * x;
t3[i][j] += v * y;
t4[i][j] += v * x * y;
}
}
void rangeAdd(int x1, int y1, int x2, int y2, const T &v)
{
_add(x1, y1, v);
_add(x1, y2 + 1, -v);
_add(x2 + 1, y1, -v);
_add(x2 + 1, y2 + 1, v);
}
T prefixSum(int x, int y)
{
T r{};
for (int i = x; i > 0; i -= i & -i)
for (int j = y; j > 0; j -= j & -j)
r += t1[i][j] * (x + 1) * (y + 1) - t2[i][j] * (y + 1) - t3[i][j] * (x + 1) + t4[i][j];
return r;
}
T rangeSum(int x1, int y1, int x2, int y2)
{
if (x1 > x2 || y1 > y2)
return T{};
return prefixSum(x2, y2) - prefixSum(x1 - 1, y2) - prefixSum(x2, y1 - 1) + prefixSum(x1 - 1, y1 - 1);
}
};
struct Random
{
mt19937_64 rng;
Random() : rng(chrono::steady_clock::now().time_since_epoch().count()) {}
ull rand_ull(ull max_val = -1) { return rng() % (max_val + 1); }
ll rand_ll(ll l, ll r) { return uniform_int_distribution<ll>(l, r)(rng); }
int rand_int(int l, int r) { return uniform_int_distribution<int>(l, r)(rng); }
double rand_db(double l, double r) { return uniform_real_distribution<double>(l, r)(rng); }
bool rand_bool(double p = 0.5) { return bernoulli_distribution(p)(rng); }
template <typename T>
void shuffle(vector<T> &v) { std::shuffle(v.begin(), v.end(), rng); }
};
ll qmi(ll a, ll b, ll p)
{
ll res = 1 % p;
a %= p;
while (b)
{
if (b & 1)
res = res * a % p;
a = a * a % p;
b >>= 1;
}
return res;
}
/*
*/
ll n, m;
ll w[N], v[N];
ll f[1000][1000];
void solve()
{
cin >> n >> m;
rep(i, 1, n) cin >> w[i] >> v[i];
rep(i, 1, n)
{
rep(j, 0, m)
{
f[i][j] = f[i - 1][j];
if (j >= w[i])
{
f[i][j] = max(f[i][j], f[i - 1][j - w[i]] + v[i]);
}
}
}
rep(i, 0, n)
{
rep(j, 0, m)
{
cout << f[i][j] << " ";
}
cout << endl;
}
}
int main()
{
IOS;
int _ = 1;
// cin>>_;//如果是多组数据
while (_--)
{
solve();
}
return 0;
}按照题目要求输入样例完成表格 结果如图
分治法求最大最小值 - 题解
1. 算法思路
利用分治思想将数组划分为两半,分别求出左右两部分的最大值和最小值,最后进行合并。
- 分解:将数组 $A[l..r]$ 分为 $A[l..mid]$ 和 $A[mid+1..r]$。
- 解决:递归求解两个子数组的最小值和最大值。
-
合并:
- 全局最小值 = $\min(\text{左半部分最小}, \text{右半部分最小})$
- 全局最大值 = $\max(\text{左半部分最大}, \text{右半部分最大})$
2. 复杂度分析
设比较次数为 $T(n)$。
递推方程:
$$ T(n) = \begin{cases} 1 & n = 2 \\ 2T(\frac{n}{2}) + 2 & n > 2 \end{cases} $$
解方程:
当 $n=2^k$ 时,通过迭代法可得:
$$ T(n) = \lceil \frac{3n}{2} \rceil - 2 $$
时间复杂度: $O(n)$
3. 代码实现
#include <bits/stdc++.h>
using namespace std;
//CJX__//
typedef long long ll;
typedef pair<ll, ll> PLL;
typedef vector<ll> vll;
#define IOS ios::sync_with_stdio(0); cin.tie(0); cout.tie(0);
#define endl '\n'
#define rep(i, x, n) for (ll i = x; i <= n; i++)
#define fi first
#define se second
/*
分治法求解最大最小值
*/
vll a;
PLL dfs(int l, int r) {
if (l == r) return {a[l], a[l]};
if (l + 1 == r) return {min(a[l], a[r]), max(a[l], a[r])};
int mid = (l + r) >> 1;
PLL L = dfs(l, mid);
PLL R = dfs(mid + 1, r);
return {min(L.fi, R.fi), max(L.se, R.se)};
}
void solve()
{
int n;
cin >> n;
a.assign(n + 1, 0);
rep(i, 1, n) cin >> a[i];
PLL res = dfs(1, n);
cout << res.se << " " << res.fi << endl; // 输出 最大值 最小值
}
int main()
{
IOS;
int _ = 1;
while (_--)
{
solve();
}
return 0;
}20. (简答题, 5.0 分)
简述动态规划的基本思想、与贪心法的区别及其不足之处。
1. 动态规划的基本思想
动态规划的核心思想是将复杂问题分解为若干个重叠的子问题。
- 分解:将原问题分解为子问题。
- 求解:通过递归或迭代的方式求解子问题。
- 记录:将子问题的解存储在表格中(记忆化),避免重复计算。
- 合并:利用子问题的最优解构造出原问题的最优解。
核心要素:最优子结构、重叠子问题。
2. 与贪心法的区别
| 特性 | 动态规划 | 贪心算法 |
|---|---|---|
| 决策方式 | 整体最优:考察所有可能的子问题组合,自底向上或自顶向下求解。 | 局部最优:每一步只做当前看起来最好的选择,不回溯。 |
| 子问题关系 | 依赖于子问题的解,子问题之间通常是重叠的。 | 依赖于当前的选择,通常不依赖于子问题的解。 |
| 适用范围 | 适用于满足最优子结构和重叠子问题性质的问题(如背包、LCS)。 | 适用于满足最优子结构和贪心选择性质的问题(如活动安排、MST)。 |
| 效率 | 较慢,通常需要遍历所有状态。 | 较快,通常一次遍历即可。 |
3. 动态规划的不足之处
- 空间复杂度高:需要通过二维数组或高维数组存储所有子问题的解(状态表),占用大量内存。
- 时间复杂度相对较高:虽然比穷举法快,但相比贪心算法,DP 需要计算表格中的每一个状态,计算量仍然较大。
- 状态定义困难:对于某些复杂问题,很难找到合适的状态定义和状态转移方程。
分治的补充
P1115 最大子段和 (分治策略与DP对比)
题目链接:P1115 最大子段和 - 洛谷
标签:分治动态规划线段树基础
题目描述
给出一个长度为 $n$ 的序列 $a$,选出其中连续且非空的一段使得这段和最大。
输入格式
- 第一行是一个整数,表示序列的长度 $n$。
- 第二行有 $n$ 个整数,第 $i$ 个整数表示序列的第 $i$ 个数字 $a_i$。
输出格式
- 输出一行一个整数表示答案。
数据范围
- $1 \leq n \leq 2 \times 10^5$
- $-10^4 \leq a_i \leq 10^4$
解题思路
这道题是经典的 最大子数组和 ** 问题。虽然在竞赛中通常使用 $O(n)$ 的动态规划解决,但作为分治算法**的练习题,它非常有价值。
掌握本题的分治解法,是后续学习线段树维护区间最大子段和的前置基础。
方法一:动态规划
这是最直接且效率最高的解法。
核心逻辑
我们定义 f[i] 为 以第 $i$ 个元素结尾的最大子段和。
对于每个位置 $i$,我们面临两个选择:
- 接上之前的子段:如果
f[i-1]是正数,那么加上a[i]肯定比a[i]单独成段要大。 - 自己另起炉灶:如果
f[i-1]是负数,加上它只会拖累a[i],不如直接从a[i]开始。
$$f[i] = \max(f[i-1] + a[i], \ a[i])$$
最终答案是所有 f[i] 中的最大值。
代码实现 (DP)
void solve(){
ll n;
cin >> n;
vll a(n + 1, 0), f(n + 1, 0);
ll ans = -INF;
rep(i, 1, n) cin >> a[i];
rep(i, 1, n)
{
f[i] = max(f[i - 1] + a[i], a[i]);
cmax(ans, f[i]);
}
cout << ans << endl;
}方法二:分治法
分治法的核心是将一个大区间的问题拆解为若干个小区间的问题。对于区间 [L, R],其最大子段和可能出现在以下三种情况:
- 完全在左半边
[L, mid]:即不跨越中点,递归求解。 - 完全在右半边
[mid + 1, R]:即不跨越中点,递归求解。 - 跨越中点:子段包含
mid和mid + 1。- 这种情况无法递归求解,需要线性扫描。
- 我们需要找出:以
mid结尾的 最大后缀和 (记为s1) + 以mid + 1开头的 最大前缀和 (记为s2)。 - 跨越中点的最大和即为
s1 + s2。
最终结果为:max(左边最大, 右边最大, 跨越中点最大)
复杂度分析
- 时间复杂度:
T(n) = 2T(n/2) + O(n)。根据主定理,复杂度为O(n log n)。 - 空间复杂度:
O(log n)(递归栈深度)。
代码实现 (分治)
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const ll INF = 0x3f3f3f3f3f3f3f3f;
void solve()
{
ll n;
cin >> n;
vector<ll> a(n + 1, 0);
for (ll i = 1; i <= n; i++) cin >> a[i];
// 分治核心函数
function<ll(ll, ll)> fz = [&](ll l, ll r) -> ll
{
// 1. 递归基:区间长度为1,直接返回
if (l == r) return a[l];
ll mid = l + r >> 1;
// 2. 递归求左右两边的最大子段和
ll lmax = fz(l, mid);
ll rmax = fz(mid + 1, r);
// 3. 求跨越中点的最大子段和
// 左半部分:从 mid 向左扫描,找最大后缀和
ll s1 = -INF, sum = 0;
for (ll i = mid; i >= l; i--) {
sum += a[i];
s1 = max(s1, sum);
}
// 右半部分:从 mid+1 向右扫描,找最大前缀和
ll s2 = -INF; sum = 0;
for (ll i = mid + 1; i <= r; i++) {
sum += a[i];
s2 = max(s2, sum);
}
// 4. 返回三者最大值
return max({lmax, rmax, s1 + s2});
};
cout << fz(1, n) << endl;
}
int main()
{
ios::sync_with_stdio(0);
cin.tie(0); cout.tie(0);
solve();
return 0;
}