算法 {动态规划DP}

算法 {动态规划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(i1,j)Set(i,j1) 等于 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(i1,j)(xS[i],Append(Set(i1,jv[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));

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值