算法 {动态规划DP)}
@LOC_3
动态规划DP
定义
给定一个DAG图:
DP[x]
表示所有以x
为终点的路径的集合; (DAG中的每条路径 都代表一个方案);
a->b
边说明 DP转换方程为DP[a]->DP[b]
;
从前往后遍历DAG序, 就可以计算出所有点的DP值;
性质
@DELI;
@MARK: @LOC_2
;
对于这个DP的DAG图, 你的答案 也就是DAG中的路径, 他的值记录在哪里呢? 也就是一个DAG上的路径 是记录在哪里呢?
比如一个路径a->b->c->d
, 这个路径的值 是记录在DP[d]
上的, 也就是 在终点; 即DP(x)
的含义是: 以x
为终点的 DAG图中的路径; 为什么呢? 因为不管是什么DP 他都是在从前到后的遍历DAG序, 因此你记录的DP值 一定是以当前节点为终点的路径;
后继更新:我知道当前的DP值; 前驱更新:不知道当前的DP值;
正向DP 使用前驱/后继都可以, 反向DP 因为我们通过会使用DFS方式(而不是递推方式) 而DFS式的DP 都是前驱更新方式 (即Dfs(st)
此时你是不知道DP[st]
的);
不管是前驱/后继更新方式, 对于DAG图中的任一条边a->b
他的DP转移方程 一定是DP[a] 更新 DP[b]
, 这点是不变的;
@DELI;
比如说 你定义了DP, 然后假如说 答案就是DP[N]
;
于是接下来, 你就要保证 你的DP转移, 一定可以保证DP[N]
的正确性;
比如说, DP转移是DP[<N] + 1 -> DP[N]
, 那么你就要保证 最终DP[N]
一定是正确的;
注意, 即使某一个DP转移 是错误的 也没问题, 只要保证 最终DP[N]
是正确的即可(这是你一切的目的); 因为DP转移 他是规定死了的, 他不会保证合法性(即你DP转移的方案 可能根本就是非法的);
.
比如说 连续相同的字符 必须要合并到一起, 也就是"aaaa" == "a"
, DP定义是 [0...i]
合并后的长度, 那么 假如DP[j] = "aba"
他要更新DP[N]
且假如[j+1,...N] == "a...a"
, 此时 你会更新3 + 1 -> DP[N]
即"aba" + "a..a" = "abaa"
, 你会发现, 这是个错误的呀, 因为相同的要合并 "aa"
应该是"a"
才对啊, 即应该用3
更新答案 而不是用4
呀; 但其实 这是没问题的, 因为虽然对于这个DP[j]
他会得到错误的DP转移, 是 对于DP[jj] = "ab"
那么此时 他就是2+1
来更新, 总之 他最终会保证DP[N]
是正确的, 存在错误的DP转移没问题; 参见: @LINK: (https://editor.csdn.net/md/?not_checkout=1&articleId=138811438)
;
@DELI;
在实际DP问题中, 你可能并不知道 拓扑序的具体形态 (因为DP状态可能非常多 即DAG非常大 你不可能也没有必要 去得到他的拓扑序), 而我们的求解 只要DP[x]
是正确的 然后你用它去更新其所有的后继节点 就可以了 (不管是什么算法 其实他们的本质 都是在做这个事情);
@DELI;
如果DP[x]
是正确的, 那么他的所有前驱节点 一定都是正确的! 不可能存在: 他有一个前驱节点的DP值 还没确定(也就是不正确), 这是不可能的, 因为[DP[x]
是正确的]<->[其所有前驱节点 都是正确的], 他俩是等价命题!
@DELI;
#DP优化#;
如果(i,?)
只会用到(i-1, ?)
:
#滚动数组优化#: 将(i,j)
的空间 优化为(2, j)
; (时间不变);
MARK: @LOC_1
;
如果(i,?)
只会用到(i-1, ?)
, 且每次的i
都要进行 (i, ?) = (i-1, ?)
的操作 (即memcpy
), 且(i,j)
的前驱 一定是(i-1, <j)
(即满足单调性), 那么 把空间优化为(j)
, 然后以j
倒序来更新 (时间空间都优化了);
.
令DP的状态范围为(N, M)
, 原来的时间是:
O
(
N
∗
(
M
+
M
)
)
O(N * (M + M))
O(N∗(M+M)) (第一个M是memcpy
(即当前元素不选择), 第二个M是for
遍历更新(即选择当前元素)); 现在的时间是O(N * M)
; (看似只是差个常数, 但如果N*M = 1e8
量级, 这种优化是非常重要的, 参见LINK: (https://editor.csdn.net/md/?not_checkout=1&articleId=131324719)-(@LOC_0)
;
@DELI;
DP的集合元素 一定是序列 (也就是, DAG上的路径 他自然是序列), 但是 不代表 DP的定义 一定是线性的序列的, 也就是 答案 从DP本质讲 是个序列, 但是不同的DP定义 可能跟序列没有关系 这个答案序列 变成集合 也可以 (也就是[a,b,c]
可以变成{a,b,c}
, 就可以进一步的做一些优化);
LINK: (https://editor.csdn.net/md/?articleId=130774546)-(@LOC_1)
;
@DELI;
DP集合里的元素, 一定是序列, 不可能是集合;
LINK: (https://editor.csdn.net/md/?not_checkout=1&articleId=131009658)-(@LOC_0)
;
@DELI;
DP递推的次序 是重要的;
通常, 根据cur
的前驱节点进行划分得到pre
和 根据cur
的后驱节点划分得到nex
, 这两种递推方式 是可以等效转换的;
但也有特殊情况, 即获取后驱节点 很简单 (或时间复杂度很低), 然而如果你想根据cur
去得到他的前驱节点 时间会大大增加;
.
LINK: (https://editor.csdn.net/md/?not_checkout=1&articleId=131009658)-(@LOC_1)
;
因此, 具体是采用前驱来递推, 还是采用后驱来递推, 要仔细分析, 两者并不是完全等价的;
@DELI;
当DP的递推 采用的后驱方式, 即通过cur
获得其后驱节点nex
, 此时cur
的值 一定是正确的;
那么 有2种情况: 要么DP(cur)
是有效的, 要么是无效的;
当DP(cur)
是无效的, 要把他给continue
掉, 这样可以省掉内循环, 此时他肯定更新不了后驱节点;
.
LINK: https://editor.csdn.net/md/?not_checkout=1&articleId=131009780
; 这道题, 你把无效节点给continue
掉, 时间会大大增加, 因为内循环没有了;
算法
用一个for
循环遍历所有的状态, 不要在外面做特判
比如你原来的代码是: for( int st = 0; st < N; st += 2){ DP[st] = ?;} for( int st = 1; st < N; st += 2){ DP[st] = ?;}
不要写这种代码, 具体原因参见: @LINK: https://editor.csdn.net/md/?not_checkout=1&articleId=140250011
;
改写成for( int st = 0; st < N; ++st){ if( st & 1){ DP[st] = ?;} else{ DP[st] = ?;}}
, 即你需要把所有的状态 都放到一块 放到同一个for
代码域里面;
例题
@LINK: https://editor.csdn.net/md/?not_checkout=1&articleId=134163076
;
使數組A的所有的長度為L的連續子數組 都為合法數組的, 最小代價;
@DELI;
@LINK: https://editor.csdn.net/md/?not_checkout=1&articleId=131282965
;
DP节点的遍历的顺序 (即如何确定DAG序列);
錯誤
DP[i]
的定义: 要表示[0...i]
即以i
为结尾的子数组, 还是表示[0...j]满足(j<=i)
的子数组集合;
哪个好? 当然是第一个, 因为他简单, 第二个是包含了第一个的; 你刚开始分析问题, 要从简到深, 先简单的来;
换句话说, 不要总想着 用一个Dfs( 0, ...)
就找到所有答案, 这复杂的多, 要分而治之 用Dfs(0), Dfs(1), Dfs(2),...
这样简单明了的多;
@DELI;
#記憶化DP, 最好就把DP值 設置為DFS的返回值, 不要把DFS函數的返回值設置為void
然後手動去調用DP[i][j]
#
例題: @LINK: https://editor.csdn.net/md/?not_checkout=1&articleId=134171492
;
筆記
DP求解: 首先DAG的所有起点的DP值 是独立的(即他不需要通过其他点的DP值来求得) 通过初始化操作就可以求得; 然后假设DP[x]
已经得到了, 然后更新他的所有后驱节点;
通过这种方式 已经可以得到所有点的DP值, 因为 比如c
的所有前驱是Pres
, 那么你对Pres
的所有点(假设其DP都是正确的) 让他们去更新其所有的后驱节点, 最终c
的DP值就得到了;
.
比如拓扑序[Begs, x, ...]
, 由于DP[Begs]
都正确(初始化) 因此x
的所有前驱 一定是属于{Begs}
, 因此 你只要让Begs
里的所有点 都去更新他们的后驱, 最终x
的DP值 也就得到了;
@DELI;
#错误#
不要以为 对SET的划分, 就可以理所当然的 变成对DP的划分;
比如, DP为 求一个序列的|
或值的最大值, 那么 即便你SET划分了 SET(i,j) = S1 U S2
, 那么 就可以直接变成 DP(i,j) = max( DP(S1), DP(S2));
吗?
.
你必须判断 |
或运算, 他符合拆分吗?
.
比如pre | cur
, 你让pre
越大 那么结果就越大吗? 错误的, 比如pre | (10)
, 显然110 > 101
, 然后 最优解是101
而不是110
;
换句话说, 当前的最优解即DP(i,j)
应该是101 | 10 = 111
, 即pre = 101
, 而你只会得到pre = 110
的情况;
所以, 必须以DP
的递推 作为最核心的评判标准, 因为对SET集合划分 这当然很简单, 一个集合 愿意怎么划分都可以;
@LINK: https://editor.csdn.net/md/?articleId=130692245
;
@DELI;
#题型#
从左上角到右下的二维网格的最值路径 (只能往下或右走) MARK: @LOC_0
;
背包问题 (每种物品只有一个, 属性为{体积v, 价值w}
, 求体积不超过V下 最大的价值) MARK: @LOC_1
;
背包问题 (每种物品无数个, 求体积不超过V下 最大的价值) MARK: @LOC_2
;
背包问题 (每组 有若干个物品 每个1个, 同一组里 最多选择1个物品) MARK: LOC_3
;
LIS MARK: @LOC_4
;
LCS MARK: @LOC_5
;
字符串最少编辑次数 MARK: @LOC_6
;
@DELI;
#定义#
#状态节点#
节点集合i
, 节点j
集合,…,
每个集合里选择一个节点, 合并起来 构成一个状态节点(i,j,z)
;
LINK: @LOC_0
: (i,j)
表示每个网格点的坐标;
LINK: @LOC_1
: (i,j)
表示i
号物品 j
为背包容量;
@DELI;
#状态节点的 方案#
可以是序列 也可以是集合; ??? (必须是序列把?)
由若干个操作组成, 也就是 它里面的元素 称之为操作;
状态节点a
的任一方案序列 都符合a: [op1, op2, ..., opN]
的形式
.
他的具体定义 是非常非常重要的, 但因题而异 因此没有一个统一的定义标准;
.
注意, 他不是一个普通的序列 [...]
, 而是a: [...]
,也就是 不同状态节点的方案序列 是一定不同的; 比如说, 两个不同的状态节点a,b
他俩都一个方案序列: [op1, op2]
虽然这序列是一样的, 但因为前面还有个编号, 因此是不同的;
.
.
理解这一点 非常重要, 比如说 字符串最少编辑次数问题, A="abcd", B="abcd"
, 那么(0,0) (1,1) (2,2) (3,3)
都存在同一个方案序列[]
(即无操作), 但是 他们是不同的 因为比如(3,3)
的[]
意味着是让A[0123]
与B[0123]
相同, 而(1,1)
的[]
意味着是让A[01]
与B[01]
相同, 含义是不同的, 比如你要更新(3,3)
去掉最后元素A[3], B[3]
后 他变成了(2,2)
, 虽然(2,2)
也是[]
但他可以去更新(3,3)
, 但是你不可以用(1,1)
的[]
去更新(3,3)
;
.
因此, 方案序列 不可以只是去看后面的那个序列 因为在不同状态下 含义是不同的;
方案序列的 集合
状态节点a
的 方案序列的 集合, 记作 SET(a)
, a: [...]
;
不同节点的集合, 一定不同?
方案序列的 权值
具体定义要根据DP值来设定, 一般有: 序列长度/ 长度1;
方案集合的 DP值
任意方案集合S的DP值为: 该集合里所有方案的 权值的 {最值/ 总和/ …};
首先最重要的是: DP(i,j)
, 我们的目的 就是为了求他; 他对应一个集合SET(i,j)
(DP值 一定是从SET
里获得的)
划分子集合
将任意状态节点的 方案集合S, 划分为若干划分子集合{S1,S2,…}, 那么S集合的DP值 是通过{S1,S2,…}的DP值来得到的;
比如, 状态节点a
的 方案序列的 集合a: [..., end]
, 通过其最后元素end
的性质 作划分;
.
比如当end == x
时, 此时a: [..., x]
集合 等价于 向b: [...]
集合里的每个方案序列的末尾添加x
; 简单来讲, 即 DP( b) + x的代价
可以更新 DP(a)
;
.
再当end == y
时, 此时a: [..., y]
集合 等价于 向c: [...]
集合里的每个方案序列的末尾添加y
; 即DP( c) + y的代价
也可以更新DP(a)
;
.
因此, DP(a)
的值 就取决于{ DP(b) + x的代价, DP(c) + y的代价}
若干个值;
换句话说: DP(cur)
(某一个状态节点的DP值) 可以通过其划分子集合的DP值来求得;
但是有一种特殊情况: 如果某个划分子集合S1
是空的, 那么 他的DP值 是未定义的; 但是, 你可能仍然认为DP(S1)
的值 等于DP(pre) + S1的代价
, 这是错误的;
.
比如LCS, (i,j)
的集合划分为: {S1: 方案序列的最后元素为(i,j)
, S2: 最后元素不是(i,j)
}; 当A[i] != A[j]
时, 此时S1划分一定为空, 如果用DP(i-1,j-1) + 1
去更新DP(i,j)
这肯定是错误的;
划分子集合的 等效代换
cur
状态节点的方案集合 划分为{S1, S2, …}, 对于任意一个划分子集合Si
,他可以等效与: 由若干个 前驱的状态节点的方案集合 做特定的方案序列的 合并操作;
.
比如石子合并的区间DP, (i,j): {S}; S = {S1, S2, ...}
(比如令Sk为: S中的所有方案序列 去掉最后的操作, 此时有2个石堆 [i,k] 和 [k+1, j]
的方案序列的集合); 对于Sk, 他等效于 (i,k): {...}
合并 (k+1, j): {...}
合并 op([i,j])
; 也就是, Sk
的集合大小 一定等于 |Set(i,k)| * |Set(k+1,j)|
; (比如(i,j)
的某个方案是 [ op(1,2), op(3,4), op(1,4)]
, 他属于S2
, (1,2): {...}
里有个方案[ op(1,2)]
, (3,4): {...}
有个方案[ op(3,4)]
, 他俩 连同op(1,4)
三者合并一起 (再排序) 得到[ op(1,2), op(3,4), op(1,4)]
;
cur
状态节点的方案集合 划分为{S1, S2, …}, 对于任意一个划分子集合Si
, 比如他是形如: cur: [..., x]
, 通常是将他的最后元素x
删除掉 此时的序列变成了[...]
, 你需要找一个状态节点pre
使得他的方案集合 等于 此时的[...]
(注意是等于, 也就是 他俩的序列是完全完全一样的);
DP值的递推
设SET(i,j) = S1 U S2
,S1 = SET(i-1, j)
, S2 = 合并( SET(i-1, j-1), A[i])
;
则 DP(i,j) = op( DP(S1), DP(S2)))
, DP(S1) = DP(SET(i-1,j))
, DP(S2) = op( DP(i-1,j-1), A[i])
;
对DP(i,j), DP(S1)
的递推 一般没有问题, 但重点是: DP(S2)
, 你必须要保证 你更新DP(S2)
时的那个op
操作, 是符合S2 = 合并( SET(i-1,j-1), A[i]))
;
.
换句话说, S2
集合的最优值(即DP值) 也是满足S2
集合的划分, 即 DP(S2)
一定可以通过 DP(SET(i-1,j-1))
来获得;
.
注意, S2
他是集合 他可以划分 (即统一将最后元素去掉 就变成了某个前驱节点的集合), 但是 不一定说明 他的DP
值 也可以进行划分, 这非常重要, 如果DP值不能划分 则即便SET划分成功了 整个DP是错误的 LINK: @LOC_0
;
算法模板
通用模板
分析模板
(i,j): 单纯解释`i`是什么意思, `j`是什么意思即可;
op{i,k}: 将A[i]*k (k={1,2,4,8,...})(k为1, 表示无操作)
op的比较: `op1.i < op2.i`;
Case(i,j): [op1,...,opN] 将`A[0...i]`这些数 施加j次操作
. opX.i = X
|Case(i,j)|: A[op1.i]*op1.k | ...
(i,j)的所有方案序列的集合: Set(i,j)
Set(i,j)的划分子集合: Set(i,j) = {Si};
S1: Case.back().k == 1
S2: Case.back().k != i
S2/x (x={1/.../j}) Case.back().k = (1 << x)
Set(i,j)的划分子集合的等效代换:
S1: 合并( Set(i-1, j), op(i,1));
DP(S1) = DP( Set(i-1,j)) | A[i];
S2/x: 合并( Set(i-1, j-x), op(i,1<<x));
DP(S2/x) = DP( Set(i-1,j-x)) | (A[i]<<x));
DP( Set(i,j)) = MAX( DP(S1), DP(S2/...));
代码模板
{ // 动态规划DP
auto __get_dp = [&]( int _i, int _j){
if( _i < 0 || _j < 0) return 0;
return DP[ _i][ _j];
};
#define __GET_DP_( _i, _j) __get_dp( _i, _j)
//#define __GET_DP_( _i, _j) DP[ _i][ _j]
//----
auto & curDP = DP[ i][ j];
//----
#undef __GET_DP_
} // 动态规划DP
...
将A变成B的 最少操作次数
//--
{ // 将A变成B的 最少操作次数;
//< 给定两个序列{A,B}, 对A可以进行操作: {删除某元素/ 修改某元素为任意值/ 在某元素的{前/后}插入任意值 (有`A.size()+1`个空隙 每个空隙都可以插入若干个任意字符)}, 求将A变成B的 最小操作次数;
// . 比如将`abc`变成`acbd` 的最小操作次数为2 (修改: 将`c`变成`d`, 插入: 在`a`后面插入`c`);
// 通过二维DP, `O(N*M)`的时间 (N,M为两个序列的长度);
@TODO __A = @TODO, __B = @TODO; // @TODO __A = @TODO, __B = @TODO;
//< 确保通过`__A[...],__B[...]` 来访问其内部元素;
int __A_size = @TODO, __B_size = @TODO; // int __A_size = @TODO, __B_size = @TODO;
constexpr int __Array_D1 = @TODO, __Array_D2 = @TODO; // constexpr int __Array_D1 = @TODO, __Array_D2 = @TODO;
int (* __DP)[ __Array_D1][ __Array_D2] = &@TODO; // int (* __DP)[ __Array_D1][ __Array_D2] = &@TODO;
ASSERT_( __A_size <= __Array_D1 && __B_size <= __Array_D2);
{ // (0,?)
bool exist = false;
for( int __i = 0; __i < __B_size; ++__i){
if( __A[ 0] == __B[ __i]) exist = true;
(*__DP)[ 0][ __i] = (exist ? __i : __i + 1);
}
}
{ // (?,0)
bool exist = false;
for( int __i = 0; __i < __A_size; ++__i){
if( __A[ __i] == __B[ 0]) exist = true;
(*__DP)[ __i][ 0] = (exist ? __i : __i + 1);
}
}
//> (>0, >0)
for( int __j, __i = 1; __i < __A_size; ++__i){
for( __j = 1; __j < __B_size; ++__j){
auto & __curDP = (*__DP)[ __i][ __j];
//> modify
__curDP = (*__DP)[ __i - 1][ __j - 1] + 1;
//> delete
__curDP = min( __curDP, (*__DP)[ __i - 1][ __j] + 1);
//> insert
__curDP = min( __curDP, (*__DP)[ __i][ __j - 1] + 1);
//> no operation
if( __A[ __i] == __B[ __j]) __curDP = min( __curDP, (*__DP)[ __i - 1][ __j - 1]);
}
}
} // 将A变成B的 最少操作次数;
//--
例题
这题很难, DP定义 不一定是将答案进行拆分 然后对子答案进行 这是很经典传统的DP做法, 但是 比如答案是无解的 那么你的DP就不进行了吗? 因此 DP的定义和过程 不一定是非得和答案相关的 即使答案无解 DP过程也是有意义的;
@LINK: https://editor.csdn.net/md/?not_checkout=1&articleId=133187368
@DELI;
LINK: https://editor.csdn.net/md/?not_checkout=1&articleId=130650605
最长的LCS&LIS (即最长公共单调子序列), 如果直接按照DP递推 会多一个O(N)
量级, 你需要对递推式 有足够的观察力 才可将他优化为O(1)
;
@DELI;
整数拆分: https://editor.csdn.net/md/?not_checkout=1&articleId=130620175
定义
状态节点state
;
节点集合i
, 节点j
集合,…,
每个集合里选择一个节点, 合并起来 构成一个状态节点(i,j,z)
;
状态序列;
.
状态列表 即所有的状态节点所构成的拓扑序 (至于拓扑序怎么定义, 要根据下面的DP递推式);
操作集合, 结果列表:
.
每个状态节点 都共用同一个操作集合, 比如操作集合为{op1, op2, …}, 当前状态节点 必须执行某一个操作, (比如如果当前节点执行op1操作, 那么就不可以再执行op2操作);
.
每个操作, 都会得到一个结果序列 (可以为空);
.
.
比如, 对于背包问题, 操作集合为: {选择(对应的结果序列为[cur]
), 不选(其结果序列为[]
)}; 比如字符串最短编辑问题 操作集合为:{无操作, 修改, 删除, 向后插入若干个, 删除后向后插入若干, 修改后向后插入若干};
.
某些操作, 当前节点可能是无法执行的(但一定存在某个操作 是可以执行的); 比如背包问题里 当前状态为(i,j) 如果i
物品的体积>j
那么选择这个操作 就无法执行;
方案序列;
.
每个DAG上的路径 有若干个方案序列; 每个状态节点有若干个DAG路径;
.
比如路径a->b->c->d
(对应DAG的路径, 每个元素 即状态节点), 他有若干个方案序列, 每一个方案序列一定形如[a的某个结果序列, b的某个结果序列, ...]
的形态;
方案序列的集合;
.
每个状态节点, 都有一个方案序列的集合;
方案的权值;
.
所有方案 都有一个权值 (且权值计算公式 对任意方案都通用);
Dp(i, st_j)
的定义式;
.
记作Dp( i, st_j)
, 表示Set( i, st_j)
中 最优方案的权值; 即Dp(i,j) = Dp( Set(i,j))
;
Dp(i, j)
的递推式;
.
虽然Dp(i,j)
的定义式为: Set(i,j)
里的最优方案的权值; 虽然是这样定义的, 但并不意味着 Dp(i,j)
就要通过遍历整个Set(i,j)
来计算;
.
比如说, Set(i,j)
可以分为2个部分: S1: Set(i-1, j)
和 S2: Set(i-1,j) + A[i]
(这里+
号的意思是: Set(i-1, j)
的所有方案序列 往末尾添加A[i]
元素);
.
.
你可能会认为, Dp(i,j)
的计算 自然是通过这2个方案集合{S1,S2}的Dp
值来计算的, 这样做当然没有错, 但可能会超时 , 一旦超时 就要懂得去变通 因为毕竟Dp(i,j)
和 Set(i,j)
不是同一个东西;
.
.
比如说, 假如对某些特定的j
来说 他的Dp(i,j)
值 一定可以来自S1集合, 也就是Dp(i,j) = Dp( S1)
, 那么你就没有必要 非得再让S2集合去尝试更新Dp(i,j)
, 因为已经知道了 Dp(i,j)
一定是等于Dp(S1)
; (LIS就是这样的例子);
.
一般来说, 先将Set(i,j)
划分为若干个子集, 那么对每一个子集 求DP最优解, 根据这些DP值 便可以得到当前DP值; (但要会变通, 比如上面讲过的)
递推式的有效性检查;
.
将每个实体节点里的(状态列表) 进行缩点操作, 则该图为DAG图;
.
单独看每一个实体节点里的子图, 也是DAG图;
Dp( i, j)
的初始化;
.
根据递推式 如果某节点的递推 涉及到非法节点 那么就需要进行特判处理;
.
比如(i,j) := (i-1,j) + (i, j-3)
, 当i=0
时 他用到了i-1
是非法的 因此i=0
要特判;
错误
Set(i, j)
的递推式;
.
@DEPRECATED( Set(i,j)
的定义很重要 因为Dp(i,j)
的定义 是用到Set(i,j)
的, 但研究Set(i,j)
的递推式 毫无意义, 因为Set(i,j)
是个抽象概念 实际算法只会用到Dp(i,j)
, 而Dp(i,j)
的递推 不等于 Set(i,j)
的递推, 因为 Dp(i,j)
是指的一个方案 (即最优方案) 只要能保证Dp(i,j)
始终是最优方案即可 不需要研究Set(i,j)
😉
.
.
举个例子; Set( i, j) := Set(a) U Set(b) U Set(c) U Set(d)
, 但假如对于特定的j
其实Dp(i,j)
的最优解 一定来自Set(a)
, 那么你只用Set(a)
来更新Dp(i,j)
即可; 不需要遍历Set(b/c/d)
因为他们一定不会是最优解;
.
.
比如LIS算法, Set(i, j)
: s[0,1,...,i]
中 所有长度为j
的子序列, 那么Set(i,j) := Set(i - 1, j) + Append( Set(i-1, j-1), s[i])
; Dp(i,j) := Set(i,j)中 末尾元素的最小值
;
.
.
那么, 假如说s[i] > Dp(i-1, j)
, 那么Dp(i, j)
一定等于Dp(i-1, j)
也就是Dp(i,j)
里 一定没有s[i]
(虽然说Set(i,j)
里 有属于s[i]
的方案, 但他一定不是最优解), 因此 递推式 要根据Dp(i,j)
定义 而不是Set(i,j)
;
.
说明哪些方案集合的并集 等于 Set(i,j)
(注意是等于, 不能是子集 也不能是超集);
.
比如
S
e
t
(
i
−
1
,
j
)
∪
S
e
t
(
i
,
j
−
1
)
Set( i - 1, j) \cup Set( i, j-1)
Set(i−1,j)∪Set(i,j−1) 等于 Set( i, j)
, 连接2条有向边: (i-1, j) -> (i,j)
和 (i, j-1) -> (i,j)
;
例题
LINK: https://editor.csdn.net/md/?not_checkout=1&articleId=130788670
;
LINK: https://editor.csdn.net/md/?not_checkout=1&articleId=130780560
;
338. 计数问题
LINK: https://www.acwing.com/problem/content/340/
;
(i,j,k): [i0...0 - i9...9]中(长度均为j+1) 所有*包含k*的字符串数
;
S1: [k ????]
;
S2: [!=k ???]
272. 最长公共上升子序列
状态节点(i,j): A[0...i]与B[0...j]的以`A[<=i],B[j]`为结尾的 最长公共LIS,
op: {x,y} 选择(A[x]与B[y]);
. 他俩A[x],B[y] 一定是相等的;
op的比较: `op(1).x < op(2).x`;
(i,j)的任一方案序列: [ op1...opN];
. 一定有op1.x < op2.x < ... < opN.x 且 A[op1.x](=B[op1.y]) < A[op2.x] < ...;
(i,j)的所有方案序列的集合: Set(i,j);
Set(i,j)的划分子集合: Set(i,j) = {Si};
. 根据`opN`是否为`op(i,j)`, 划分为2个子集合: S1, S2;
Set(i,j)的划分子集合的等效代换:
S1: [..., op(i,j)];
. S1 = 插入( Set(i-1,{0,1,...,j-1}), op(i,j));
. 好像乍一看是`O(N^2 * N^2)`的时间,
. 集合不为空的充要条件: `A[i] == B[j]`;
S2: [..., op(<i,j)], S2 = Set(i-1,j);
. DP(S2) = DP( Set(i-1, j));
1016. 最大上升子序列和
状态节点(i,j): A[0...i]的所有的末尾元素为j的LIS的 最大和;
op: {x} 选择x;
op的比较: `op(1).x < op(2).x`;
(i,j)的任一方案序列: [ op1...opN]
. opN 一定为 `op( A[i])`;
(i,j)的所有方案序列的集合: Set(i,j)
Set(i,j)的划分子集合: Set(i,j) = {Si};
. `opN` 要么是op(A[i]) 要么不是;
Set(i,j)的划分子集合的等效代换:
. S1: [..., op(A[i])], S1 = 合并( Set(i-1, {1,...,A[i] - 1}), op(A[i]));
. S2: [..., != op(A[i])], S2 = Set(i-1, j);
. 合并(a,b): 将b插入到a的所有方案序列的末尾;
. DP(S1) = max( DP( Set(i-1, {1,2,...,A[i] - 1})) + A[i];
. DP(S2) = DP( Set(i-1, j));
注意, 乍一看 好像是O(N * M * M) M为元素的最大值
, 但是对于(i, [1,2,…,ma]) 除了(i,A[i]) 其他所有状态的S1子集合 都是空的;
也就是, 只有(i,A[i])会用到一个O(M)的循环 其他(i,…)都是O(1);
子集合DP
把所有子集合的&
值相同的, 放到同一个DP状态里;
LINK: https://editor.csdn.net/md/?not_checkout=1&articleId=130552696
;
使两个字符串相同的 最小修改次数
https://www.acwing.com/problem/content/904/
节点i: 字符串A的下标;
节点j: 字符串B的下标;
// @DEPRE( 定义太模糊了) Set(i,j): 使得A[0...i]与B[0...j]相同的操作集合;
Set(i,j): 对A[0...i]进行操作[插入: 前后和中间缝隙都可以, 删除, 修改])得到str串, 且str与B[0...j]相同的操作集合;
方案序列: vector<对字符串A的操作> Lis;
方案权值: Lis.size();
Dp(i,j)的定义式: Set(i,j)里方案权值的 最小值;
Dp(i,j)的递推式:
不用考虑往`i`的前面插入, 因为这相当于往`i-1`的后面插入 (i=0的情况特判);
最多往`i`后面 插入1个, 如果插入多个 则等价于`Dp(i, j-1) + 插入1个`;
但难点是: [替换, 删除, 插入] 他们不是独立的, 也就是 (替换 + 插入) (删除 + 插入);
但是要分析, 如果是(替换+插入: 等价于 Dp(i,j-1) + 插入);
如果是(删除+插入: 等价于 Dp(i, j-1) + 插入);
总之, 在当前(i,j) 最多执行1个操作 [无操作/替换/插入/删除];
两个字符串的最长公共子序列LCS
同样是压缩DP, 类似于LIS;
{ // LCS
int Ind[ ?]; // `?`在`序列A`的下标;
int DP[ n + 1]; // 遍历节点为`i`时的`DP[x]`表示: B[0...i]与A[...]里 长度为`x`的LCS(如果有)的末尾元素(对应在A的)下标的最小值;
int lcs_maxLen = 0;
for( int i = 0; i < n; ++i){
if( Ind[ B[i]] == -1) continue;
if( lcs_maxLen == 0){
DP[ 1] = Ind[ B[i]];
lcs_maxLen = 1;
continue;
}
int l = 1, r = lcs_maxLen;
while( l < r){
auto mid = (l + r + 1) / 2;
if( Ind[ B[ i]] > DP[ mid]){
l = mid;
}
else r = mid - 1;
}
if( Ind[ B[ i]] > DP[ l]){
DP[ l + 1] = Ind[ B[i]];
lcs_maxLen = max( lcs_maxLen, l + 1);
}
else{
DP[ l] = Ind[ B[i]];
}
}
//>< 最长LCS长度为`lcs_maxLen`, `A[DP[1]], A[DP[1]], ..., A[DP[lis_maxLen]]`就是一个LCS;
} // LCS
背包模型4: 分组物品
有N个组 每个组里有若干个物品 且同一组内的物品 最多选1个;
节点i: 组号;
节点j: 体积[0,1,...,V]
;
Set(i, j)
: 从[0,...,i]
组里选择若干个物品 且总体积<= j
;
方案序列: [ A{i1,j1}, A{i2,j2}, ...]
(A{i?,j?}
表示一个物品, i?
表示组编号, j?
表示组内编号, 所有的i?
一定不同);
方案权值: 物品的价值之和;
Set(i, j)
递推式:
S
e
t
(
i
,
j
)
:
=
S
e
t
(
i
−
1
,
j
)
∪
(
∀
x
∈
S
[
i
]
,
A
p
p
e
n
d
(
S
e
t
(
i
−
1
,
j
−
v
[
x
]
)
,
x
)
Set( i, j) := Set(i-1,j) \cup (\forall x \in S[i], Append( Set(i-1, j - v[x]), x)
Set(i,j):=Set(i−1,j)∪(∀x∈S[i],Append(Set(i−1,j−v[x]),x); (S[i]
为第i组里的所有物品);
Dp(i, j)
最优解: Set(i, j)
里 方案权值的最大值;
背包模型3: 每种物品为有限个
方式1: 直接求
.
(i, k)
为: [0,...,i]
这些物品 每个物品最多s[]
个, 总体积为k
;
.
对于当前物品i
, 他在答案中 选择的个数 是0/1/2/.../s[i]
个, 那么直接枚举这个个数, 即for( k : [0,1,...,s[i]]){ vv = v[i] * k, ww = w[i] * k
, 相当于 将{vv, ww}
这个物品 放到(i - 1, ?)
里;
方式2: 转换为背包模型1;
.
对于第i种物品 (有k个), 最终答案里 该物品的个数 为0/1/2/3/.../k
个, 其实等价于 背包模型1里的 有k种不同的物品, 因为是背包模型1 每个物品只有1个 (选/不选) 因此这k个物品 最终答案里 选择的个数可能为0/1/2/.../k
个, 与上面对应;
.
时间为n * V
(n为总物品个数);
方式3: 转换为背包模型1;
.
基于方式2, 方式2是将k个同种物品 拆分为 背包模型1里的k种不同物品;
.
其实我们拆分的目的, 就是使得 拆分之后的选择方案 与0/1/2/..../k
是一致的;
.
其实就是整数拆分问题, 给定一个数k, 你需要将他拆分成一些数A 满足: A数集的任意子集里数之和记作Si, 所有Si组成的集合 等于 {0,1,2,...,k}
;
背包模型2: 每种物品为无限个
实体节点: [0,1,2,…,] 每个节点对应每个物品;
状态列表: {0,1,2,…,V};
Set(i, j)
方案集合: 从[0,1,...,i]
这些物品中 可放回的选择若干物品 且总体积
≤
j
\leq j
≤j的所有方案;
Set(i,j)
的递推式: Set(i-1, j) + {Set( i, j-v) <- w}
;
方案权值: 所选物品的价值之和;
Dp( i, j)
最优解: Set( i, j)
的所有方案里 方案权值的最大值;
背包模型1: 每种物品只有1个
节点: [0,1,2,…,] 每个节点对应每个物品;
状态: {0,1,2,…,V};
Set(i, j)
方案集合: 从[0,1,...,i]
这些物品中 不放回的选择若干物品 且总体积
≤
j
\leq j
≤j的所有方案;
方案权值: 物品的价值之和;
Dp( i, j)
最优解: Set( i, j)
中 方案权值的最大值;
Dp(i, j)
的计算: (初始化: …), (递推式: …);
因为答案求的是: 总体积<= V
下 最大的权值;
.
所以Set( i, j)
定义为: 总体积<= j
;
.
此时Dp( 0, j)
的初始时 Dp( 0, < v0) = 0, Dp( 0, >= v0) = w
(比如v0 = 5
, Dp( 0, 0) = 0
这是自然的 什么都不选 价值自然为0, 但是Dp( 0, 1/2/3/4)
也是为0
的 因为Set( 0, 1/2/3/4)
均有1个方案 (即什么都不选) 所以价值为0;
.
可是, 如果你把Set(i,j)
定义为: 总体积= j
; (此时递推式和之前一样)
.
.
但是, Set( 0, 1/2/3/4)
都是空的, 包括Set( 0, 6/7/8/...)
也是空的, 一旦某个方案集合为空 此时就需要特判 (因为Dp
最优解 是根据该方案集合里的方案来计算的) 需要将该Dp
值 置为一个非法值 (你需要特殊处理, 比如这里 你可以赋值为一个很大的负数);
二维网格的最值路径
节点: 每个网格点;
状态: {st_0};
(i,st_0)
的方案集合: 表示 从起点到当前点i的 所有路径;
Dp(i,st_0)
的最优解: 其方案集合里, 路径权值的最大值;
(i, st_0)
的递推式: A[i] + max( Dp(左侧, st_0), Dp( 上侧, st_0))
;