线性DP&记忆化搜索
动态规划基础
一、几道栗题
几种简单相加转移状态的情况
T1
给出一个 \(2*n\) 的网格,你现在需要用 \(n\) 个 \(2*1\) 的多米诺骨牌占满整个棋盘。
◦多米诺骨牌可以横着或者竖着放
◦求有多少方案数占满整个棋盘
\(N <= 10^6\)
solution
设 \(f[n]\) 为 \(n\) 列的答案,发现对其有影响的是横着多米诺横着放还是竖着放
当横着放的时候 ,对前一个有影响,竖着放的时候只需一个,所以可以回到原来只有一列和两列的时候进行转移,发现每一列的方案数都只和前两列有关,分类讨论即可
转移式:
递推,也算是一个简单的 \(dp\),只不过绝大多数 \(dp\) 的转移方程不是根 据这题一样简简单单的一个加号就解决的
T2
◦给出一个 \(n*m\) 的网格,每次只能向右或者向下走,求从 \((1,1)\) 走到 \((n,m)\) 的方案数,其中有些位置是不能走的。
\(n,m <= 1000\)
solution
类似过河卒,根据上题的思想;
假设没有障碍物,设 \(dp[i][j]\) 表示从 \((1,1)\),走到 \((i,j)\) 的方案数
根据上一步可以从哪里来
转移式
有障碍:如果 \((i,j)\) 是一个障碍,则定义 \(dp[i][j] = 0\)
T3
给出一个高度为 \(n\) 的金字塔,你现在要从金字塔的顶端走到金字塔的底端。 ◦ 金字塔的第i层,第j房间(记为\((i,j)\) )有价值为 \(a[i][j]\) 的宝藏,每一步能从 \((i,j)\) 能 走到,\((i+1,j)\) , \((i+1,j+1)\)。求从金字塔顶部走到底部能获得的最大的价值是多少?
solution
和前面两个题类似,前面都是求方案数,所以运算法则应为加法,而这里求最优值,所以应该取 \(max\)
设 \(dp[i][j]\) 表示从塔顶走到 \((i,j)\) 能得到的最大的价值是多少。
转移式为
二. 动态规划基本思想
将大问题转化为若干个子问题,通过求解子问题,从而得到大问题的解
小问题可以通过边界情况直接可以计算出来
基本思想
是将待求解的问题分解为若干个子问题(阶段),按顺序求解 子阶段,小的子问题的解,为更大子问题的求解提供了有用的信息
特点:
可以节省时间,避免了很多冗余的计算
三,动态规划的状态
1.动态规划过程中,需要有状态表示和最优化值(方案值)
2.状态表示是对当前子问题的解的局面集合的一种(充分的)描述
3.最优化值(方案值)则是对应的状态集合下的最优化信息(方案值), 我们最终能通过其直接或间接得到答案
满足的三条性质:——充分描述,尽量简洁
1:具有最优化子结构:即问题的最优解能有效地从问题的子问题的最优 解构造而来 ◦
2:具有简洁性:尽可能的简化状态的表示,获得更优的时间复杂度。
3:同时能够全面的描述一个局面。一个局面有一个答案,而这个局面是 需要一些参数来描述的
四,状态的转移
由于具有最优化子结构(在最优化问题种),所以求当前状态的最优值可以通过其他的(较小的问题)状态的最优值加以变化而求出。所以, 当一个状态的所有子结构都已经被计算之后,我们就可以通过一个过程 计算出他的最优值。这个过程就是状态的转移
五,时间复杂度
状态数 × 状态转移复杂度
\(dp\) 的两种优化时间的方法
◦ 1:减少状态的维数
◦ 2:加速状态转移,例如数据结构优化或者分析性质(单调队列优化)
经典栗题
T1 最长上升子序列
• 给出一个长度为 \(n\) 的数列 \(a[1..n]\), 求这个数列的最长上升子序列。
• 就是给你一个序列,请你在其中求出一段不断严格上升的子序列,子序列是不 一定要求连续的。
\(N <= 1000\)
solution
不难发现,当我们枚举最长上升长度的时候,如果再添加元素,观察能是否能当做最长上升子序列只取决于最长上升子序列的最后一个元素
设状态:\(dp[i]\) 表示以 \(i\) 结尾的最长上升子序列
枚举这个子序列倒数第二个数是什么即可
转移方程为
时间复杂度:\(O(n^2)\)
\(nlogn\) 做法
\(n^2\) 的做法 \(10^5\) 就 \(T\) 了,所以可以优化来降低复杂度
状态:\(dp[i]\) 该序列中,上升子序列长度为 \(i\) 的上升子序列的最小末尾数值
• 如果枚举的值当前上升子序列末尾大,那么结尾元素就暂时改成 \(a[i]\) ,可以当做最长上升子序列
• 如果比末尾元素小,就进行二分向前查找,更新前面和比他刚好大的 \(dp[l]\) 的值
核心代码:
for (int i = 2;i <= n; i++){
int l = 0, r = len, mid;
if(a[i] > f[len]) f[++len] = a[i];
else{
while (l < r){
int mid = (l + r) >> 1;
if(f[mid] > a[i]) r = mid;
else l = mid + 1;
}
f[l] = min(f[l], a[i]);
}
}
printf("%d", len);
注意初始化!!!
合唱队形
\(N\) 位同学站成一排,音乐老师要请其中的\(( N−K )\)位同学出列,使得剩下的 \(K\) 位同学排成合唱队形
合唱队形是指这样的一种队形:设 \(K\) 位同学从左到右依次编号为 \(1,2,…\) 他们的身高分别为 \(T_1,T_2,…,T_K\)
则他们的身高满足 $T1~T_i+1> ...>Tk $
你的任务是,已知所有N位同学的身高,计算最少需要几位同学出列,可 以使得剩下的同学排成合唱队形。
\(n<=100000\)
solution
不难发现,其左边和右边分别是个上升序列
我们只需正着跑一个最长上升子序列,反着跑一个最长上升子序列,然后枚举最高点 \(f[i] + g[i] - 1\)
然后答案就为
/*
work by:Ariel_
*/
#include <iostream>
#include <cstdio>
using namespace std;
const int M = 110;
int read(){
int x = 0,f = 1;char c = getchar();
while(c < '0'||c > '9'){if(c == '-')f = -1;c = getchar();}
while(c >= '0'&&c <= '9'){x = x*10 + c - '0';c = getchar();}
return f*x;
}
int n, a[M], f1[M], f2[M], b[M];
int main(){
std::ios::sync_with_stdio(false);
n = read();
for (int i = 1;i <= n; i++){
a[i] = read();f1[i] = 1;f2[i] = 1;
}
for (int i = 2;i <= n; i++){
for (int j = 1;j < i; j++){
if(a[j] < a[i] && f1[j] + 1 >= f1[i]){
f1[i] = f1[j] + 1;
}
}
}
for (int i = n - 1;i >= 1; i--){
for (int j = i + 1;j <= n; j++){
if(a[j] < a[i] && f2[j] + 1 >= f2[i]){
f2[i] = f2[j] + 1;
}
}
}
int ans = 0x3f3f3f3f;
for (int k = 1;k <= n; k++){
ans = min(ans, n - (f2[k] + f1[k] - 1));
}
printf("%d", ans);
}
乘积最大
◦设有一个长度为 \(N\) 的数字串,要求选手使用 \(K\) 个乘号将它分成 \(K+1\) 个部分, 找出一种分法,使得这 \(K+1\) 个部分的乘积能够为最大。
312,当 \(N=3,K=1\) 时会有以下两种分法:
◦1. \(3*12=36\)
◦ 2. \(31*2=62\)
\(◦N,K(6≤N≤80,1≤K≤50)\)
状态:
用 \(f[i][a]\) 表示前 \(i\) 位数包含 \(a\) 个乘号所能达到的最大乘积
枚举上一个乘号所在的位置,
将 \(j\) 从 \(a\) 到 \(i - 1\) 进行一次枚举,表示前 \(j\) 位中含有 \(a-1\) 个乘号,且最后一 个乘号的位置在 \(j\) 处。那么当最后一个乘号在 \(j\) 处时最大值为前 \(j\) 位中含 有 \(a - 1\) 个乘号的最大值乘上 \(j\) 处之后到 \(i\) 的数字
状态转移(\(cut(b + 1,i)\) 表示 \(b + 1\) 到 \(i\) 位数字)
简单 \(dp\)
先想状态,看否能够转移,不能转移就还一种转移方法或者再加一维,看是否能描述的更加详细
T2 最长公共子序列
给定两个字符串 \(S\) 和 \(T\),长度分别为 \(n\) 和 \(m\),求解这两个字符串的最长公共子序列
◦比如字符串\(S\):\(BDCABA\);字符串\(T\):\(ABCBDAB\)
◦则这两个字符串的最长公共子序列长度为\(4\),最长公共子序列是:\(BCBA\)。
\(n,m<=1000\)
状态
设 \(dp[i][j]\) 表示,\(S\) 串的第 \(i\) 个前缀和 \(T\) 串的第 \(j\) 个前缀的最长公共子序列
转移
如果,两个串最后一个位置相同,这两个位置一定在公共子序列中,
那么我们只需要求出 \(S\) 的 \(i-1\) 前缀和 \(T\) 的 \(j-1\) 前缀的最长上升子序列
如果最后一个不相同,那么有可能第一个串的最后一个和第二个串的倒数第二个匹配了
或者第二个串的最后一个和第一个串的倒数第二个匹配了,两者取最大值
分情况讨论
◦如果 \(S[i]==T[j]\),\(dp[i][j]=dp[i-1][j-1]+1\);
◦如果\(S[i]!=T[j]\),\(dp[i][j]=max(dp[i-1][j],dp[i][j-1])\)
最后答案 $ dp[n][m] $
最长公共上升子序列 LCIS(不大懂)
◦给两个序列 \(A\) 长度为 \(n\) 和 \(B\) 长度为 \(m\),求最长的公共子序列,还要保证这个序列是上升的
记忆化搜索
一、几道栗题
T1
给出一个 \(n*m\) 的网格,每次只能向右或者向下走,求从 \((1,1)\) 走到 \((n,m)\) 的方案数,其中有些位置是不能走的
\(n,m<=1000\)
和第一个题一个样,现在从搜索的角度想这个问题
solution
直接 \(dfs\) 但发现多了许多的计算,所以我们把每次计算的都结果都存下来,最后只统计答案即可(记忆化搜索)
滑雪
给定一个区域,由一个二维数组给出。数组的 \((i,j)\) 代表点 \((i,j)\) 的高度。我们要找一个最长的滑雪路径,注意滑雪只能从高往低处滑。下面是一个例子。
solution
状态:
\(dp[x][y]\) 存储在当前位置下山的最大长度
转移:
发现它与它能到达的其他地方有关,所以直接用其他比他底的点转移
要保证对应的高度小于 \(H[x][y]\) 才能取 \(max\)
有些 \(dp\) 转移顺序不能确定
一般递推式动态规划还要注意枚举状态的顺序,要保证算当前状态时子 状态都已经算完了。
但设f[u]为以节点u为终点的食物链数量是记忆化搜索不需要,因为记忆化搜索就是个搜索,只不过把重复的 部分记下来了而已。我们不用像递推一样过于关注顺序,像搜索一样直接要求什么
拓扑图DP
拓扑图 \(dp\) 通常是在拓扑图上求关于所有路径的某种信息之和。
这里 的“和”的运算法则可以是加法或是取 \(max\) 和 \(min\) 或者其他定义的运算
转移:按拓扑序沿着有向边转移
T1
给定 \(n\) 个点 \(m\) 条边的有向无环食物网,求其中有多少条食物链
\(n<=10^5,m<=2*10^5\)
状态
设 \(f[u]\) 为以节点 \(u\) 为终点的食物链数量
转移
/*
work by:Ariel_
*/
#include <iostream>
#include <cstdio>
#include <queue>
using namespace std;
const int M = 5e6 + 10;
int read(){
int x = 0,f = 1;char c = getchar();
while(c < '0'||c > '9'){if(c == '-')f = -1;c = getchar();}
while(c >= '0'&&c <= '9'){x = x*10 + c - '0';c = getchar();}
return f*x;
}
int n, m;
struct edge{
int v,nxt;
}e[M << 1];
int head[M],cnt;
void add(int u, int v){
e[++cnt] = (edge){v, head[u]};head[u] = cnt;
}
int rd[M],cd[M],ans,dis[M];
queue <int> q;
void dfs(){
while(!q.empty()){
int u = q.front();
q.pop();
for (int i = head[u]; i;i = e[i].nxt){
int v = e[i].v;
dis[v] += dis[u];
rd[v]--;
if(!rd[v]){
if(!cd[v]){
ans += dis[v];
}
else q.push(v);
}
}
}
}
int main(){
std::ios::sync_with_stdio(false);
n = read(),m = read();
for (int i = 1, u, v;i <= m; i++){
u = read(),v = read(),add(u, v);
rd[v]++,cd[u]++;
}
for (int i = 1;i <= n; i++){
if(!rd[i])dis[i] = 1,q.push(i);
}
dfs();
printf("%d", ans);
}
拓扑图dp:
我们对于一般非有关期望和概率的 \(dp\),如果题目中每一个转移关系是双边的,那么如果我们把\(dp\) 的每一个状态记为一个点, \(dp\) 状态之间关 系构成的图就是一个拓扑图
◦拓扑图 \(dp\) 实际上就是已经给了我们这个拓扑关系了,也就不需要我们自己找了,其实是更简单。
经典栗题
◦ 给一个 \(n\) 个点 \(m\) 条边的无向图,每一条边 \((u,v)\) 有两个参数 \((len,cnt)\) 表示边的 长度以及边上不同的礼物数量,我们在每一个走过的边 \((u,v,len,cnt)\) 只能选 \(1\) 个礼物,选择的方案数是\(cnt\)
我们现在想从 \(S\) 走到 \(T\),我们想要求出在只走最短路径的情况下有多少种选择的礼物的方案数。
◦一条路径选择礼物的方案数就是每条边的 \(cnt\) 的乘积。答案对一个大质数取模。
◦ \(n<=100000,m<=300000\)
solution
\((u,v,len,cnt)\) 其实就是 \((u,v)\) 点对有 \(cnt\) 条长度 \(len\) 为边,求 \(S\) 到 \(T\) 的最短路径方案数。
求以最短路径为前提的一些问题,果断先建最短路图
然后就是求 \(DAG\) 上从 \(S\) 到 \(T\),路径的方案数。
状态:
设 \(f[u]\) 为从 \(u\) 到 \(T\) 路径的方案数
转移
答案: \(f[S]\)
/*
work by:Ariel_
*/
#include <iostream>
#include <cstdio>
#include <queue>
using namespace std;
const int M = 5e6 + 10;
int read(){
int x = 0,f = 1;char c = getchar();
while(c < '0'||c > '9'){if(c == '-')f = -1;c = getchar();}
while(c >= '0'&&c <= '9'){x = x*10 + c - '0';c = getchar();}
return f*x;
}
int n, m;
struct edge{
int v,nxt;
}e[M << 1];
int head[M],cnt;
void add(int u, int v){
e[++cnt] = (edge){v, head[u]};head[u] = cnt;
}
int rd[M],cd[M],ans,dis[M];
queue <int> q;
void dfs(){
while(!q.empty()){
int u = q.front();
q.pop();
for (int i = head[u]; i;i = e[i].nxt){
int v = e[i].v;
dis[v] += dis[u];
rd[v]--;
if(!rd[v]){
if(!cd[v]){
ans += dis[v];
}
else q.push(v);
}
}
}
}
int main(){
std::ios::sync_with_stdio(false);
n = read(),m = read();
for (int i = 1, u, v;i <= m; i++){
u = read(),v = read(),add(u, v);
rd[v]++,cd[u]++;
}
for (int i = 1;i <= n; i++){
if(!rd[i])dis[i] = 1,q.push(i);
}
dfs();
printf("%d", ans);
}