【算法笔记】动态规划基础(一):dp思想、基础线性dp
目录
- 前言
- 动态规划的精髓
- 什么叫“状态”
- 动态规划的概念
- 动态规划的三要素
- 动态规划的框架
- 无后效性
- dfs -> 记忆化搜索 -> dp
- 暴力写法
- 记忆化搜索写法
- 记忆化搜索优化了什么?怎么转化成dp?
- dp写法
- dp其实也是图论
- 首先先说结论:
- 状态DAG是怎样的?
- dp问题的初始化
- dp问题的答案
- 分析dp题的步骤
- 数字三角形
- 状态表示
- 状态计算
- 初始化
- AC代码
- 最长上升子序列(LIS)
- 状态表示
- 状态计算
- 初始化
- AC代码
- 优化
- 最长公共子序列(LCS)
- 状态表示
- 状态计算
- 初始化
- AC代码
- 最短编辑距离
- 状态表示
- 状态计算
- 初始化
- AC代码
前言
计算机归根结底只会做一件事:穷举。
所有的算法都是在让计算机【如何聪明地穷举】而已,动态规划也是如此。
动态规划的精髓
什么叫“状态”
每一个 DP 子问题,都可以用一组变量来唯一标识。把这组变量称为一个 “状态”,用 d p [ 状态 ] dp[状态] dp[状态]来表示该子问题的最优解(或方案数、合法性等)。
举个例子,我在实验室坐着敲代码,小王在图书馆站着处对象、小明在水房倒立洗头…
- 这里的(我,实验室,坐着,敲代码),(小王,图书馆,站着,处对象),(小明,水房,倒立,洗头)就是三组状态,可以用一个四维的数组来表示, d p [ i ] [ j ] [ k ] [ l ] dp[i][j][k][l] dp[i][j][k][l]就表示 i i i这个人,在 j j j这个地方,以 k k k这样的动作,干 l l l这件事时的某种性质。
状态转移也好理解,举个例子:在游戏中,有几个技能,其中一个技能叫“转换”,可以让人物在跑、走、跳之间来回切换,消耗 a a a点体力。其中一个状态转移就是消耗 a a a点体力将走切换成跑,如果用 d p dp dp数组表示放 i i i次技能,达到当前状态消耗的体力的最小值,也就是 d p [ i ] [ 跑 ] = m i n ( d p [ i ] [ 跑 ] , d p [ i − 1 ] [ 走 ] + a ) dp[i][跑] = min(dp[i][跑], dp[i - 1][走] + a) dp[i][跑]=min(dp[i][跑],dp[i−1][走]+a) ,这个式子就叫做状态转移方程。
动态规划的概念
前置知识:递归、递推
动态规划(Dynamic programming,简称DP) 是一种通过将原问题分解成几个彼此之间有关联的、相对简单的子问题来求解复杂问题的算法。
动态规划把多阶段过程转化为一系列单阶段问题,利用各阶段之间的关系,逐个求解。
动态规划常常适用于有重叠子问题和最优子结构性质的问题,动态规划方法所耗时间往往远少于朴素解法。
动态规划背后的基本思想非常简单。大致上,若要解一个给定问题,我们需要解其不同部分(即子问题),再根据子问题的解以得出原问题的解。
通常许多子问题非常相似,为此动态规划法试图仅仅解决每个子问题一次,从而减少计算量:一旦某个给定子问题的解已经算出,则将其记忆化存储,以便下次需要同一个子问题解之时直接查表。这种做法在重复子问题的数目关于输入的规模呈指数增长时特别有用。
动态规划的三要素
动态规划有很经典的三要素:重叠子问题、最优子结构、状态转移方程。
首先,虽然动态规划的核心思想就是穷举求最值或方案数,但是问题可以千变万化,穷举所有可行解其实并不是一件容易的事,需要你熟练掌握递归思维,只有列出正确的 「状态转移方程」 ,才能正确地穷举。而且,你需要判断算法问题是否具备 「最优子结构」,是否能够通过子问题的最值得到原问题的最值。另外,动态规划问题存在 「重叠子问题」,如果暴力穷举的话效率会很低,所以需要你使用 「记忆化」 来优化穷举过程,避免不必要的计算。
动态规划的框架
// 自顶向下递归的动态规划
int dp(状态1, 状态2, ...){for(int 选择 : 所有的选择){# 此时的状态已经因为做了选择而改变res = 求最值(res, dp(状态1, 状态2, ...));}return res;
}// 自底向上递推的动态规划
# 初始化 base case
dp[0][0][...] = base case;
# 进行状态转移
for(状态1 : 状态1的所有取值){for(状态2 : 状态2的所有取值){for(状态3 : 状态3的所有取值){dp[状态1][状态2][...] = 求最值(选择1, 选择2, ...);}}
}
无后效性
tips:动态规划的题要求你的转移无后效性,那什么叫无后效性呢
无后效性,即前面的选择不会影响后面的游戏规则。
寻路算法中,不会因为前面走了 B 路线而对后面路线产生影响。斐波那契数列因为第 N 项与前面的项是确定关联,没有选择一说,所以也不存在后效性问题。
什么场景存在后效性呢?比如你的人生是否能通过动态规划求最优解?其实是不行的,因为你今天的选择可能影响未来人生轨迹,比如你选择了计算机这个专业,会直接影响到你大学四年学的课程、接触到的人,四年的大学生活因此就完全变了,所以根本无法与选择了土木工程的你进行比较。
有同学可能觉得这样局限是不是很大?其实不然,无后效性的问题仍然很多,比如背包放哪件物品、当前走哪条路线、用了哪些零钱,都不会影响整个背包大小、整张地图的地形、以及你最重要付款的金额…
dfs -> 记忆化搜索 -> dp
821. 跳台阶
暴力写法
#include <iostream>
#define endl '\n'
using namespace std;int f(int n){if(n == 0 || n == 1) return 1;return f(n - 1) + f(n - 2);
}int main(){ios::sync_with_stdio(false); cin.tie(0); cout.tie(0);int n;cin >> n;cout << f(n) << endl;return 0;
}
可以看出,暴力做法有很多递归的分支都是重复的,也就是有很多重复的子问题。这些重复的子问题在下一次遇到时如果每次都重新计算一遍就会很浪费时间,所以可以用一个数组记录一下,下次再遇到直接查表即可,这也就是记忆化搜索。
记忆化搜索写法
#include <iostream>
#define endl '\n'
using namespace std;int dp[16];int f(int n){if(dp[n]) return dp[n];if(n == 0 || n == 1) return 1;return dp[n] = f(n - 1) + f(n - 2);
}int main(){ios::sync_with_stdio(false); cin.tie(0); cout.tie(0);int n;cin >> n;cout << f(n) << endl;return 0;
}
记忆化搜索优化了什么?怎么转化成dp?
- 暴力:每个子问题都考虑一次。
- 记忆化搜索:对于相同的子问题,只考虑一次。
那是不是可以理解为:dp就是在暴力的基础上,把所有效果相同的状态都放在了一个集合里?暴力是单一状态和单一状态之间某种属性的转移,而dp是集合和集合之间某种属性的转移?
比如这道题, d p [ i ] dp[i] dp[i]表示的集合就是:所有从第 0 0 0级台阶走到第 i i i级台阶的合法方案。
属性就是:集合中元素的数量。
所以综上, d p [ i ] dp[i] dp[i]就表示:所有从第 0 0 0级台阶走到第 i i i级台阶的方案数。
dp写法
#include <iostream>
#define endl '\n'
using namespace std;int dp[16];int main(){ios::sync_with_stdio(false); cin.tie(0); cout.tie(0);int n;cin >> n;dp[0] = 1, dp[1] = 1;for(int i = 2; i <= n; i++){dp[i] = dp[i - 1] + dp[i - 2];}cout << dp[n] << endl;return 0;
}
dp其实也是图论
首先先说结论:
动态规划 (DP) 很多时候其实就是在一个状态图(严格来说是一个有向无环图,DAG)上做一次「拓扑遍历+松弛」的过程。
在动态规划(DP)中,我们把“子问题”看作图中的“节点”,把“从一个子问题推导到另一个子问题”的依赖关系看作有向边,那么所有这些状态和边就构成了一张有向无环图(DAG)。而对这张DAG做一次拓扑排序——恰好保证了:当我们要计算某个状态的最优值(或计数、最小/最大代价……)时,它依赖的所有前驱状态都已经计算完毕。
最大值、最小值,是不是相当于是求最长路、最短路,求方案数是不是就相当于是沿着拓扑序列累加?
状态DAG是怎样的?
-
节点(state)
每一个 DP 子问题 S S S 都对应 DAG 中的一个节点。
例如,经典“爬楼梯”问题中,让 d p [ i ] dp[i] dp[i] 表示到达第 i i i 级台阶的方案数,
那么节点就是 0 , 1 , 2 , … , n 0, 1, 2, \ldots, n 0,1,2,…,n。 -
有向边(依赖)
如果计算 d p [ v ] dp[v] dp[v] 需要用到 d p [ u ] dp[u] dp[u],就在 DAG 中加一条从 u u u 指向 v v v 的边。
爬楼梯里, d p [ i ] dp[i] dp[i] 可由 d p [ i − 1 ] dp[i-1] dp[i−1] 和 d p [ i − 2 ] dp[i-2] dp[i−2] 推出,就有两条边:
( i − 1 ) → i , ( i − 2 ) → i (i-1) \rightarrow i,\quad (i-2) \rightarrow i (i−1)→i,(i−2)→i。 -
无环
DP 的定义通常是“从小问题推向大问题”,不存在循环依赖,图上必然是无环的。
dp问题的初始化
dp问题的初始化经常不是很好理解,但如果你站在拓扑排序、最短路问题的角度来看,就会相对好理解一些。
回忆一下你学过的所有的最算路问题,代码是不是都有这样的两部分初始化:
memset(dist, 0x3f, sizeof dist); // 所有点dist初始化成正无穷
dist[s] = 0; // 源点dist初始化成0
dp问题的初始化其实也是这样的,分成两部分:
- 对所有的点进行初始化:比如求最小值,
memset(dp, 0x3f, sizeof dp)
。
对于这种初始化,一般如果是求最小值,就初始化成一个极大值;如果求最大值,就初始化成一个极小值;如果是求数量,就初始化成0。 - 对“源点”进行初始化:单源最短路的问题源点只有一个,就给源点初始化成0,而拓扑排序就相当于是一个多源的最短路,源点就是所有入度为0的点,在dp中也就是那些最基本的状态(
base case
),这种初始化只需将所有的base case都按dp状态表示的含义,初始化成对应的数即可。比如数字三角形问题中:最顶端的点不能由任何点走过来,于是直接将其初始化成对应的值,或者你可以看做最顶端的点可以由上面下标为0的点走过来,所以也可以将所有下标为0的点初始化成0。
很多问题对“源点”的初始化经常会初始化下标为 0 0 0的位置,像下面这样:
for(int i = 1; i <= n; i++){dp[i][0] = i;}for(int i = 1; i <= m; i++){dp[0][i] = i;}
怎么确定base case呢?一般来说,如果你的dp遍历是for(int i = a; ...)
,那base case就很有可能是你的i = a
的前一个状态(前驱结点),多维也是同样的道理。
简单来讲,一个dp问题,你循环遍历的所有点都是状态DAG上入度不为0的点,你单拎出来初始化的点都是状态DAG入度为0的点。
dp问题的答案
和初始化相同,初始化是初始化所有的“起点”(入度为0的点),那答案就是所有的“终点”(出度为0的点),如果“终点”有很多个,就要定义一个结果变量,对于求最值,需要遍历一下所有出度为0的状态取个最值;对于求方案数,需要逐个判断、累加一下。
分析dp题的步骤
- 做dp题,首先就要把dp数组写出来,首先要想:dp数组要开几维:一般来说,如果不优化状态表示的话,有几个状态dp数组就要开几维。
- 然后,搞明白你dp数组的含义,也就是搞明白集合的表示和集合的属性。集合的属性常见的可能有最大值(max)、最小值(min)、数量(cnt)…
- 最后,写出状态转移方程,对应的过程就是将问题分成若干子问题的过程,也就是集合的划分。
- 然后,想怎么初始化,求最大值就初始化成极小值、求最小值就初始化成极大值、求数量就初始化成0…
- 然后,写代码:定义dp数组、初始化、for循环枚举所有的状态,输出结果。
具体各个过程都是什么意思:看下面的几个线性dp经典模型。
数字三角形
898. 数字三角形
状态表示
因为有行和列,所以状态要开二维: d p [ i ] [ j ] dp[i][j] dp[i][j]
- 集合:所有从 a [ 1 ] [ 1 ] a[1][1] a[1][1]走到 a [ i ] [ j ] a[i][j] a[i][j]的路线
- 属性:(路线经过数字和的)最大值(max)
综上: d p [ i ] [ j ] dp[i][j] dp[i][j]:所有从 a [ 1 ] [ 1 ] a[1][1] a[1][1]走到 a [ i ] [ j ] a[i][j] a[i][j]的路线的数字和的最大值
状态计算
集合划分:将这个集合划分成几个不同的集合,就要找一个不同的步骤。
可以看出,走到 a [ i ] [ j ] a[i][j] a[i][j]的这最后一步可以是从 a [ i − 1 ] [ j ] a[i - 1][j] a[i−1][j]走过来的,也可以是从 a [ i − 1 ] [ j − 1 ] a[i - 1][j - 1] a[i−1][j−1]走过来的,所以可以从最后一步来划分这个集合,前一个集合是 d p [ i − 1 ] [ j ] dp[i - 1][j] dp[i−1][j],后一个集合是 d p [ i − 1 ] [ j − 1 ] dp[i - 1][j - 1] dp[i−1][j−1]。
显然: d p [ i ] [ j ] = m a x ( d p [ i − 1 ] [ j − 1 ] + a [ i ] [ j ] , d p [ i − 1 ] [ j ] + a [ i ] [ j ] ) dp[i][j] = max(dp[i - 1][j - 1] + a[i][j], dp[i - 1][j] + a[i][j]) dp[i][j]=max(dp[i−1][j−1]+a[i][j],dp[i−1][j]+a[i][j])
初始化
要求最大值,并且有负数,先将所有位置都初始化成负无穷。
因为和要从0开始加,而不是负无穷,所以要将所有的 d p [ 0 ] [ j ] dp[0][j] dp[0][j]和 d p [ i ] [ 0 ] dp[i][0] dp[i][0]都初始化成0。
但上面这样比较麻烦,因为所有路线都是从起点开始走的,所以直接将起点初始化成 a [ 1 ] [ 1 ] a[1][1] a[1][1],循环从 2 2 2开始遍历即可。
AC代码
#include <iostream>
#include <cstring>
#define endl '\n'
using namespace std;
const int N = 505;int a[N][N];
int dp[N][N];int main(){ios::sync_with_stdio(false); cin.tie(0); cout.tie(0);int n;cin >> n;for(int i = 1; i <= n; i++){for(int j = 1; j <= i; j++){cin >> a[i][j];}}memset(dp, -0x3f, sizeof dp);dp[1][1] = a[1][1];for(int i = 2; i <= n; i++){for(int j = 1; j <= i; j++){dp[i][j] = max(dp[i - 1][j - 1], dp[i - 1][j]) + a[i][j];}}int res = -0x3f3f3f3f;for(int i = 1; i <= n; i++) res = max(res, dp[n][i]);cout << res << endl;return 0;
}
最长上升子序列(LIS)
895. 最长上升子序列
状态表示
序列只有一维,dp数组也开一维就够了: d p [ i ] dp[i] dp[i]
- 集合:所有以 a [ i ] a[i] a[i]结尾的上升子序列
- 属性:(长度的)最大值
综上: d p [ i ] dp[i] dp[i]:所有以 a [ i ] a[i] a[i]结尾的上升子序列的长度的最大值。
状态计算
集合划分:将这个集合划分成几个不同的集合,就要找一个不同的步骤。
因为最后一步都是相同的,都是将 a [ i ] a[i] a[i]加到序列中,无法划分,所以看倒数第二步:也就是加 a [ i ] a[i] a[i]前这个上升子序列是以什么元素结尾的。想一下,如果 a [ i ] a[i] a[i]能加到一个以 a [ j ] a[j] a[j]结尾的上升子序列的后面,是不是一定要满足 a [ i ] > a [ j ] a[i] > a[j] a[i]>a[j] ?
所以这个集合就可以划分成:所有满足 a [ i ] > a [ j ] a[i] > a[j] a[i]>a[j]( 0 0 0 ≤ \le ≤ j j j < < < i i i)的以 a [ j ] a[j] a[j]结尾的上升子序列接上一个 a [ i ] a[i] a[i]。
状态转移方程就出来了: d p [ i ] = m a x ( d p [ i ] , d p [ j ] + 1 ) ( a [ j ] < a [ i ] ) dp[i] = max(dp[i], dp[j] + 1) (a[j] < a[i]) dp[i]=max(dp[i],dp[j]+1)(a[j]<a[i])。
初始化
求最大值,上升子序列最短都是1,都初始化成1即可。
AC代码
#include <iostream>
#include <cstring>
#define endl '\n'
using namespace std;
const int N = 1010;int a[N];
int dp[N];int main(){ios::sync_with_stdio(false); cin.tie(0); cout.tie(0);int n;cin >> n;for(int i = 1; i <= n; i++){cin >> a[i];dp[i] = 1;}for(int i = 1; i <= n; i++){for(int j = 1; j < i; j++){if(a[i] > a[j]) dp[i] = max(dp[i], dp[j] + 1);}}int res = 1; for(int i = 1; i <= n; i++) res = max(res, dp[i]);cout << res << endl;return 0;
}
优化
很显然,用dp求LIS是 O ( n 2 ) O(n^2) O(n2)的,如果 n n n比较大会超时,但可以基于贪心+二分、单调栈、单调队列等多种方式进行优化到 O ( n l o g n ) O(nlogn) O(nlogn)、 O ( n ) O(n) O(n),因为这是dp专题,就不细讲了。
最长公共子序列(LCS)
897. 最长公共子序列
状态表示
有两个序列,一个序列开一维,两个序列就开二维: d p [ i ] [ j ] dp[i][j] dp[i][j]
- 集合:所有 a a a的前 i i i个元素和 b b b的前 j j j个元素的公共子序列
- 属性:(长度的)最大值
综上: d p [ i ] [ j ] dp[i][j] dp[i][j]表示所有 a a a的前 i i i个元素和 b b b的前 j j j个元素的公共子序列的长度的最大值。
状态计算
集合划分:将这个集合划分成几个不同的集合,就要找一个不同的步骤。
看最后一步, a a a的前 i i i个元素的子序列里,是不是有的带 a [ i ] a[i] a[i],有的不带 a [ i ] a[i] a[i]? b b b的前 j j j个元素的子序列里,是不是有的带 b [ j ] b[j] b[j],有的不带 b [ j ] b[j] b[j]?那二者的公共子序列是不是也如此?
所以可以以 a [ i ] a[i] a[i]和 b [ j ] b[j] b[j]是否包含在公共子序列中为依据进行划分。
然后想这几个集合的状态怎么表示,可以分成下面四种情况:
- 情况1: a [ i ] a[i] a[i], b [ j ] b[j] b[j] 均存在于 最长公共子序列中 (前提 a [ i ] = = b [ j ] a[i]==b[j] a[i]==b[j])
- 情况2: a [ i ] a[i] a[i] 在, b [ j ] b[j] b[j] 不在 (无前提)
- 情况3: a [ i ] a[i] a[i], b [ j ] b[j] b[j] 均不在 (无前提)
- 情况4: a [ i ] a[i] a[i]不在, b [ j ] b[j] b[j]在 (无前提)
初步想一下,是不是可以表示成下面这样:
- 情况1:暂用 d p [ i − 1 ] [ j − 1 ] + 1 dp[i-1][j-1]+1 dp[i−1][j−1]+1表示
- 情况2:暂用 d p [ i ] [ j − 1 ] dp[i][j-1] dp[i][j−1]表示
- 情况3:暂用 d p [ i − 1 ] [ j − 1 ] dp[i-1][j-1] dp[i−1][j−1]表示
- 情况4:暂用 d p [ i − 1 ] [ j ] dp[i-1][j] dp[i−1][j]表示
但这样表示真的对吗?事实上是不严谨的,举个例子: d p [ i ] [ j − 1 ] dp[i][j- 1] dp[i][j−1]的集合表示的是所有 a a a的前 i i i个元素和 b b b的前 j − 1 j - 1 j−1个元素的公共子序列, b b b的前 j j j个元素一定是不包含 b [ j ] b[j] b[j]的,但 a a a的前 i i i个元素是不是可能包含 a [ i ] a[i] a[i],也可能不包含 a [ i ] a[i] a[i]? d p [ i − 1 ] [ j ] dp[i - 1][j] dp[i−1][j]也是一样的道理
所以实际上 d p [ i − 1 ] [ j ] dp[i - 1][j] dp[i−1][j]表示的是情况2+情况3, d p [ i ] [ j − 1 ] dp[i][j - 1] dp[i][j−1]表示的是情况3+情况4 。
因为这个模型dp的属性是求最大值,集合之间有交集不会影响答案,保证不漏就行(就好像你要求10个数的最大值,你知道前7个数的最大值和后7个数的最大值,只需要这两个集合取max就可以了),所以状态计算直接取 d p [ i − 1 ] [ j − 1 ] + 1 dp[i-1][j-1]+1 dp[i−1][j−1]+1、 d p [ i ] [ j − 1 ] dp[i][j-1] dp[i][j−1]、 d p [ i − 1 ] [ j ] dp[i-1][j] dp[i−1][j]三者的最大值就可以了。
状态转移方程也就出来了:
d p [ i ] [ j ] = m a x ( d p [ i − 1 ] [ j ] , d p [ i ] [ j − 1 ] ) dp[i][j] = max(dp[i - 1][j], dp[i][j - 1]) dp[i][j]=max(dp[i−1][j],dp[i][j−1])
d p [ i ] [ j ] = m a x ( d p [ i ] [ j ] , d p [ i − 1 ] [ j − 1 ] + 1 ) ( a [ i ] = = b [ j ] ) dp[i][j] = max(dp[i][j], dp[i - 1][j - 1] + 1)(a[i] == b[j]) dp[i][j]=max(dp[i][j],dp[i−1][j−1]+1)(a[i]==b[j])
也可以换种方式理解:对于字符串 a a a和 b b b中的每个字符都有且只有两种状态:在公共子序列中和不在公共子序列中。那是不是可以从后往前遍历每一个字符:如果 a [ i ] = = b [ j ] a[i] == b[j] a[i]==b[j],这个字符就一定在公共子序列中,也就是情况1,而如果 a [ i ] ≠ a [ j ] a[i] \neq a[j] a[i]=a[j],这两个字符就至少有一个不在公共子序列中,需要丢弃一个,也就是情况2、3、4。
初始化
求最大值,公共子序列最小都是0,都初始化成0即可。
AC代码
#include <iostream>
#include <cstring>
#define endl '\n'
using namespace std;
const int N = 1010;int dp[N][N];int main(){ios::sync_with_stdio(false); cin.tie(0); cout.tie(0);int n, m;cin >> n >> m;string a, b;cin >> a >> b;a = " " + a, b = " " + b;for(int i = 1; i <= n; i++) cin >> a[i];for(int i = 1; i <= m; i++) cin >> b[i];for(int i = 1; i <= n; i++){for(int j = 1; j <= m; j++){dp[i][j] = max(dp[i - 1][j], dp[i][j - 1]);if(a[i] == b[j]) dp[i][j] = max(dp[i][j], dp[i - 1][j - 1] + 1);}}cout << dp[n][m] << endl;return 0;
}
最短编辑距离
902. 最短编辑距离
状态表示
有两个字符串,开两维, d p [ i ] [ j ] dp[i][j] dp[i][j]
- 集合:所有让 a a a的前 i i i个字符和 b b b的前 j j j个字符变得相同的操作方式
- 属性:(操作次数的)最小值
综上: d p [ i ] [ j ] dp[i][j] dp[i][j]表示所有让 a a a的前 i i i个字符和 b b b的前 j j j个字符变得相同的操作次数的最小值。
状态计算
集合划分:将这个集合划分成几个不同的集合,就要找一个不同的步骤。
还是看最后一步,最后一步肯定是改变 a a a的一个字符后 a [ 1 a[1 a[1 ~ i ] i] i] 变得和 b [ 1 b[1 b[1 ~ j ] j] j]相同,而改变字符有三种方式:增加、删除和替换。
首先要弄清楚一点,插入操作实际上是在末尾添加一个字符,删除操作一定是删掉最后一个字符。
为什么呢?拿删除举例子,首先,如果你某一步的操作是删除中间的字符,删除位置后面的整个序列都会改变,这就不满足无后效性。其次,如果某一步删除操作删除的是中间的字符,那么这个操作在之前一定可以通过删除最后的字符来实现,也就相当于是之前漏删了,后面来补,并且末尾的 b [ j ] b[j] b[j]也没动到,这一定不是最优的子结构,并且如果你某一步的操作是删除中间的字符,每次删除的位置都难以决定,这也就不是重叠的子问题。
而如果你每次都删最后的字符,每次操作都是相同的,也不会影响后面的决策,并且是最优的。
任何最优路径的最后一步,绝不会故意留一段未对上的后缀不管,再去中间做事然后回来补尾巴。
-
如果最后一步是增加一个字符:为了让 a [ 1 a[1 a[1 ~ i ] i] i] 变得和 b [ 1 b[1 b[1 ~ j ] j] j]相同,最后加的字符就一定是 b [ j ] b[j] b[j],而 a [ 1 a[1 a[1 ~ i ] + b [ j ] i] + b[j] i]+b[j]和 b [ 1 b[1 b[1 ~ j ] j] j]相同,也就说明 a [ 1 a[1 a[1 ~ i ] + b [ j ] i] + b[j] i]+b[j]和 b [ 1 b[1 b[1 ~ j − 1 ] + b [ j ] j - 1] + b[j] j−1]+b[j]相同,也就说明原来的 a [ 1 a[1 a[1 ~ i ] i] i] 和 b [ 1 b[1 b[1 ~ j − 1 ] j - 1] j−1]是相同的,也就是 d p [ i ] [ j − 1 ] + 1 dp[i][j - 1] + 1 dp[i][j−1]+1
-
如果最后一步是删除某个字符:删掉的字符一定是 a [ i ] a[i] a[i],而如果删 a [ i ] a[i] a[i]后 a [ 1 a[1 a[1 ~ i ] i] i] 变得和 b [ 1 b[1 b[1 ~ j ] j] j]相同,就说明 a [ 1 a[1 a[1 ~ i ] − a [ i ] i] - a[i] i]−a[i] 和 b [ 1 b[1 b[1 ~ j ] j] j]相同,也就是 a [ 1 a[1 a[1 ~ i − 1 ] + a [ i ] − a [ i ] i - 1] + a[i] - a[i] i−1]+a[i]−a[i] 和 b [ 1 b[1 b[1 ~ j ] j] j]相同,也就是 d p [ i − 1 ] [ j ] + 1 dp[i - 1][j] + 1 dp[i−1][j]+1
-
如果最后一步是改变某个字符:
如果 a [ i ] ≠ b [ j ] a[i] \neq b[j] a[i]=b[j],就一定要把 a [ i ] a[i] a[i]改成 b [ j ] b[j] b[j],如果改之后相同了,就说明 a [ 1 a[1 a[1 ~ i − 1 ] + b [ j ] i - 1] + b[j] i−1]+b[j] 和 b [ 1 b[1 b[1 ~ j ] j] j]相同,也就是 a [ 1 a[1 a[1 ~ i − 1 ] + b [ j ] i - 1] + b[j] i−1]+b[j] 和 b [ 1 b[1 b[1 ~ j − 1 ] + b [ j ] j - 1] + b[j] j−1]+b[j]相同,也就是 a [ 1 a[1 a[1 ~ i − 1 ] i - 1] i−1] 和 b [ 1 b[1 b[1 ~ j − 1 ] j - 1] j−1]相同,也就是 d p [ i − 1 ] [ j − 1 ] + 1 dp[i - 1][j - 1] + 1 dp[i−1][j−1]+1如果 a [ i ] = b [ j ] a[i] = b[j] a[i]=b[j],最后的字符就不用管,只需将 a [ 1 a[1 a[1 ~ i − 1 ] i - 1] i−1] 和 b [ 1 b[1 b[1 ~ j − 1 ] j - 1] j−1]变得相同,也就是 d p [ i − 1 ] [ j − 1 ] dp[i - 1][j - 1] dp[i−1][j−1]。
然后可以得出状态转移方程:
d p [ i ] [ j ] = m i n ( d p [ i − 1 ] [ j ] + 1 , d p [ i ] [ j − 1 ] + 1 ) dp[i][j] = min(dp[i - 1][j] + 1, dp[i][j - 1] + 1) dp[i][j]=min(dp[i−1][j]+1,dp[i][j−1]+1)
d p [ i ] [ j ] = m i n ( d p [ i ] [ j ] , d p [ i − 1 ] [ j − 1 ] + ( a [ i ] ≠ b [ j ] ) ) dp[i][j] = min(dp[i][j], dp[i - 1][j - 1] + (a[i] \neq b[j])) dp[i][j]=min(dp[i][j],dp[i−1][j−1]+(a[i]=b[j]))
初始化
求最小值,最大的编辑距离也就是全改一遍的情况,状态转移的起点也就是空串的时候,这时候恰巧是全改,只需给所有的 d p [ i ] [ 0 ] 、 d p [ 0 ] [ i ] dp[i][0]、dp[0][i] dp[i][0]、dp[0][i]都初始化成 i i i即可。
AC代码
#include <iostream>
#include <cstring>
#define endl '\n'
using namespace std;
const int N = 1010;int dp[N][N];int main(){ios::sync_with_stdio(false); cin.tie(0); cout.tie(0);int n, m;string a, b;cin >> n >> a >> m >> b;a = " " + a;b = " " + b;for(int i = 1; i <= n; i++){dp[i][0] = i;}for(int i = 1; i <= m; i++){dp[0][i] = i;}for(int i = 1; i <= n; i++){for(int j = 1; j <= m; j++){dp[i][j] = min(dp[i - 1][j], dp[i][j - 1]) + 1; // 增和删if(a[i] == b[j]) dp[i][j] = min(dp[i][j], dp[i - 1][j - 1]); // 不用改else dp[i][j] = min(dp[i][j], dp[i - 1][j - 1] + 1); // 改}}cout << dp[n][m] << endl;return 0;
}
相关文章:
【算法笔记】动态规划基础(一):dp思想、基础线性dp
目录 前言动态规划的精髓什么叫“状态”动态规划的概念动态规划的三要素动态规划的框架无后效性dfs -> 记忆化搜索 -> dp暴力写法记忆化搜索写法记忆化搜索优化了什么?怎么转化成dp?dp写法 dp其实也是图论首先先说结论:状态DAG是怎样的…...
C++入门基础(2)
Hello~,欢迎大家来到我的博客进行学习! 目录 1.缺省参数2.函数重载3.引用3.1 引用的概念和定义3.2 引用的特性3.3引用的使用3.4 const引用3.5 指针和引用的关系扩展 4. nullptr 1.缺省参数 缺省参数是声明或定义函数时为函数的参数指定⼀个缺省值。在调用该函数时&…...
OpenCV-Python (官方)中文教程(部分一)_Day15
18.图像梯度 梯度简单来说就是求导。 OpenCV 提供了三种不同的梯度滤波器,或者说高通滤波器:Sobel, Scharr和Laplacian。Sobel,Scharr 其实就是求一阶或二阶导数。Scharr 是对 Sobel(使用小的卷积核求解求解梯度角度时)的优化。Laplacian 是…...
大厂面试:MySQL篇
前言 本章内容来自B站黑马程序员java大厂面试题和小林coding 博主学习笔记,如果有不对的地方,海涵。 如果这篇文章对你有帮助,可以点点关注,点点赞,谢谢你! 1.MySQL优化 1.1 定位慢查询 定位 一个SQL…...
软件工程的13条“定律”:从Hyrum定律到康威定律,再到Zawinski定律
每周跟踪AI热点新闻动向和震撼发展 想要探索生成式人工智能的前沿进展吗?订阅我们的简报,深入解析最新的技术突破、实际应用案例和未来的趋势。与全球数同行一同,从行业内部的深度分析和实用指南中受益。不要错过这个机会,成为AI领…...
Linux删除大文件df空间avail空间不增加
背景 根磁盘被同事写满,使用> 删除一些安装包后,df中的avail空间还是0 排除有进程正在占用文件,已使用lsof命令检测过我所删的文件是没有进程在使用 原因 是文件系统预留空间在作祟 解决 # 文件系统预留块 tune2fs -l /dev/vda2 | gr…...
【C语言-选择排序算法】实现对十个数进行排序
目录 前言 一、选择排序算法原理 二、选择排序算法实现对十个数进行排序 三、代码运行示例 四、选择排序算法的时间复杂度和空间复杂度分析 五、选择排序算法的优缺点 六、总结 前言 在计算机科学领域,排序算法是基石般的存在,它们就像是整理杂乱…...
驱动开发硬核特训 · Day 18:深入理解字符设备驱动与子系统的协作机制(以 i.MX8MP 为例)
日期:2025年04月23日 回顾:2025年04月22日(Day 17:Linux 中的子系统概念与注册机制) 本日主题:字符设备驱动 子系统协作机制剖析 学习目标:理解字符设备的注册原理,掌握其与子系统间…...
SQL Server 2022 常见问题解答:从安装到优化的全场景指南
SQL Server 2022 作为微软最新的数据库管理系统,在性能、安全性和云集成方面带来了多项革新。然而,用户在实际使用中仍可能遇到各类问题。本文将围绕安装配置、性能优化、备份恢复、安全设置、高可用性方案、兼容性问题及错误代码解析等核心场景…...
软件开发版本库命名规范说明
背景:近期一直再更新自己所开发的一个前端大文件上传npm库(enlarge-file-upload),为了让库的发版更加规范,于是参考了各种文档写下了这篇关于软件开发库的版本命名规范,且不仅局限于前端的版本命名规范,适用于整个软件…...
Kafka 详解
1.基本概念:Kafka 是分布式发布 - 订阅消息系统,具有高吞吐量、可扩展性等优势,支持点对点和发布订阅两种消息模式,涉及 Broker、Topic、Partition 等多种角色。 2.安装步骤:需先安装 JDK 和 Zookeeper,下…...
【Qwen2.5-VL 踩坑记录】本地 + 海外账号和国内账号的 API 调用区别(阿里云百炼平台)
API 调用 阿里云百炼平台的海内外 API 的区别: 海外版:需要进行 API 基础 URL 设置国内版:无需设置。 本人的服务器在香港,采用海外版的 API 时,需要进行如下API端点配置 / API基础URL设置 / API客户端配置…...
硬核解析:整车行驶阻力系数插值计算与滑行阻力分解方法论
引言:阻力优化的核心价值 在汽车工程领域,行驶阻力是影响动力性、经济性及排放的核心因素。根据统计,车辆行驶中约60%的燃油消耗用于克服阻力(风阻、滚阻、传动内阻等)。尤其在电动化趋势下,阻力降低1%可提…...
【网络原理】TCP提升效率机制(一):滑动窗口
目录 一. 前言 二. 滑动窗口 三. 丢包现象 1)ACK报文丢失 2)数据丢失 四. 总结 一. 前言 TCP最核心的机制就是可靠传输 ,确认应答,超时重传,连接管理这些都保证了可靠传输,得到了可靠传输,…...
移动端使用keep-alive将页面缓存和滚动缓存具体实现方法 - 详解
1. 配置组件名称 确保列表页组件设置了name选项,(组合式API额外配置): <!-- vue2写法 --> export default {name: UserList // 必须与 <keep-alive> 的 include 匹配 }<!-- vue3写法 --> defineOptions({na…...
工作记录9
1.点击按钮发送AJAX请求 <!DOCTYPE html> <html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>Document</title&…...
Java 异常 SSLException: fatal alert: protocol_version 全解析与解决方案
在 Java 网络通信中,SSLException: fatal alert: protocol_version 是典型的 TLS/SSL 协议版本不兼容异常。本文结合 Java 官方规范、TLS 协议标准及实战经验,提供体系化解决方案,帮助开发者快速定位并解决协议版本冲突问题。 一、异常本质&…...
连锁美业管理系统「数据分析」的重要左右分析︳博弈美业系统疗愈系统分享
美业管理系统中的数据分析功能在提升运营效率、优化客户体验、增强决策科学性等方面具有重要作用。 数据分析功能将美业从“经验驱动”升级为“数据驱动”,帮助商家在客户管理、成本控制、服务创新等环节实现精细化运营,最终提升盈利能力与品牌竞争力…...
Openharmony 和 HarmonyOS 区别?
文章目录 OpenHarmony 与 HarmonyOS 的区别:开源生态与商业发行版的定位差异一、定义与定位二、技术架构对比1. OpenHarmony2. HarmonyOS 三、应用场景差异四、开发主体与生态支持五、关键区别总结六、如何选择?未来展望 OpenHarmony 与 HarmonyOS 的区别…...
26.OpenCV形态学操作
OpenCV形态学操作 形态学操作(Morphological Operations)源自二值图像处理,主要用于分析和处理图像中的结构元素,对图像进行去噪、提取边缘、分割等预处理步骤。OpenCV库中提供了丰富的形态学函数,常见的包括…...
uniapp小程序使用echarts
1、引入插件 在Dcloud插件市场下载echarts插件:插件地址 2、页面使用简单示例: <template><view class"pie-view flex-center"><view style"width: 100%; height: 600rpx"><l-echart ref"chartRef&quo…...
Vue 中 使用 Mixins 解决 多页面共用相同组件的相关问题
1. 需要解决的问题 最近在vue项目中,有多个页面需要用到同一个组件,至于是什么组件,这里不重要,重要的这个组件需要被多个文件引用,而且有组件有一些控制逻辑。 1.1代码展示 <template><div class"ap…...
Rust 学习笔记:Rust 简介
Rust 学习笔记:Rust 简介 Rust 学习笔记:Rust 简介历史与发展历程核心特性优点缺点应用领域 Rust 学习笔记:Rust 简介 Rust 是一种系统级编程语言,由 Mozilla 研究院的 Graydon Hoare 于 2006 年设计,旨在提供内存安全…...
第六节:进阶特性高频题-自定义指令实现场景
示例:v-lazy(图片懒加载)、v-permission(权限控制) 钩子函数:mounted、updated、unmounted等 一、自定义指令核心机制 指令生命周期钩子 const myDirective {// 元素插入父节点时调用(初始化…...
未曾设想的道路1
写在前面: 与其转去读博,倾向自学就业。 中国科学技术大学数学科学学院拥有一支优秀的师资团队,以下是部分教授的简介: 陈发来教授: 荣誉:2024年6月13日,在德国莱布尼茨信息科学中心召开的国际…...
Axure按钮设计分享:打造高效交互体验的六大按钮类型
在产品设计过程中,按钮作为用户与界面交互的核心元素,其设计质量直接影响用户体验与操作效率。Axure作为一款强大的原型设计工具,为设计师提供了丰富的按钮设计选项。本文将围绕基础按钮、禁用按钮、圆角按钮、动态按钮、渐变按钮和图标按钮六…...
MySQL 8.4企业版 安装和配置审计插件
在最新的MySQL 8.4.4企业版上启用审计日志功能 操作系统:Ubuntu 24.04 数据库:8.4.4-commercial for Linux on x86_64 (MySQL Enterprise Server - Commercial) 1.查看安装脚本 下面2个脚本位于mysql安装目录 share 下,一个是window一个是linux可以用…...
AI大模型学习十一:尝鲜ubuntu 25.04 桌面版私有化sealos cloud + devbox+minio,实战运行成功
一、说明 用了ubuntu 25.04,内核为GNU/Linux 6.14.0-15-generic x86_64,升级了部分image,过程曲折啊 sealos 能干啥 对集群生命周期进行管理,一键安装高可用 Kubernetes 集群,增删节点清理集群自恢复等 通过 sealos…...
idea无法下载源代码
通过idea找到用户设置文件路径 查看 setting.xml 文件,找到了以下相关的配置,注释掉这个maven-default-http-blocker的镜像,这个东西阻碍了去阿里的镜像库查找依赖,注释掉。 然后重启idea就能下载了...
【敏矽微ME32G030系列】介绍、环境搭建、工程测试
【敏矽微ME32G030系列】介绍、环境搭建、工程测试 本文介绍了敏矽微ME32G030系列单片机及开发板、包括参数特点、原理图、应用场景,以及开发环境搭建、工程测试等流程。 简介 本节介绍了开发板主控、特点、开发板原理图、板载资源等信息。 主控 开发板采用 ME3…...
Hooks的使用限制及原因
Hooks的使用限制及原因 Hooks的核心限制 只能在函数组件顶层调用 ⭐不能在条件语句、循环、嵌套函数中调用 ⭐只能在React函数组件或自定义Hooks中调用 ⭐ 为什么有这些限制? 根本原因:React依赖Hooks的调用顺序 React内部使用数组来存储每个组件的…...
【JavaScript】二十六、正则表达式
文章目录 1、正则表达式1.1 定义1.2 校验 2、元字符2.1 边界符2.2 量词2.3 字符类2.3.1 方括号[ ]2.3.2 小点.2.3.3 预定义 2.4 案例:用户名验证 3、修饰符3.1 语法3.2 案例:过滤敏感词 1、正则表达式 Regular Expression,正则表达式&#x…...
Geek强大的电脑卸载软件工具,免费下载
一款强大的卸载电脑软件工具,无需安装 免费下载...
tomcat Server 连接服务器 进展
由于机房的环境变更,所接触的问题也不一样!!!! 但后来出现以下提示: 已连接到服务器 配置错误: 部署源 springmvc:war 无效[2025-04-23 11:19:50,192] 工件 springmvc:war: 部署工件时出错。请参阅服务器日…...
Elasticsearch 集群节点下线方案
Elasticsearch 集群节点下线方案 在 Elasticsearch(ES)集群中,节点(Node)下线可能会影响数据的可用性和集群的健康状态。因此,正确的下线步骤需要确保数据不会丢失,并且不会影响查询或写入。 &…...
【模板匹配】图像处理(OpenCV)-part10
19.1模板匹配 模板匹配就是用模板图(通常是一个小图)在目标图像(通常是一个比模板图大的图片)中不断的滑动比较,通过某种比较方法来判断是否匹配成功,找到模板图所在的位置。 不会有边缘填充。 类似于卷积,…...
VMware中CentOS 7虚拟机设置固定IP(NAT模式)完整教程
前言 在VMware中为CentOS 7虚拟机配置固定IP是搭建稳定服务环境的关键步骤。本文基于用户提供的最新配置文件,详细演示如何从DHCP自动获取IP调整为固定IP(192.168.89.129),并提供修改前后的配置对比及操作验证。 一、当前配置状态…...
Ragflow、Dify、FastGPT、COZE核心差异对比与Ragflow的深度文档理解能力和全流程优化设计
一、Ragflow、Dify、FastGPT、COZE核心差异对比 以下从核心功能、目标用户、技术特性等维度对比四款工具的核心差异: 核心功能定位 • Ragflow:专注于深度文档理解的RAG引擎,擅长处理复杂格式(PDF、扫描件、表格等)的…...
飞帆控件:在编辑模式下额外加载的库
飞帆是一个自由的控件设计平台。在飞帆中,我们可以很方便地创建基于 Vue 2 组件的控件,并使用控件来搭建网页。 他山之石,可以攻玉。在创建控件中,使用 js 、css 依赖库能让我们的控件更强大。 有些时候,在编辑模式下…...
Agentic AI——当AI学会主动思考与决策,世界将如何被重塑?
一、引言:2025,Agentic AI的元年 “如果ChatGPT是AI的‘聊天时代’,那么2025年将开启AI的‘行动时代’。”——Global X Insights[1] 随着Agentic AI(自主决策型人工智能)的崛起,AI系统正从被动应答的“工具…...
68元撬动未来:明远智睿2351开发板重塑嵌入式开发生态
在嵌入式开发领域,价格与性能的矛盾始终存在:高端开发板功能强大但成本高昂,低价产品则往往受限于性能与扩展性。明远智睿2351开发板以68元(含税)的定价打破这一僵局,通过四核1.4G处理器、全功能Linux系统与…...
C# 全局 Mutex 是否需使用 `Global\` 前缀
回顾一下Mutex在Windows中的作用。Mutex是用于同步多个进程或线程的机制,确保同一时间只有一个实例访问资源。当创建Mutex时,如果命名时没有指定Global\前缀,默认可能是在会话内创建的,也就是只在当前用户会话中可见。这样的话&am…...
C# 中的 `lock` 关键字本质
C# 中的 lock 关键字本质上是基于 Monitor 类实现的线程同步机制,其核心是通过 互斥锁(Mutex) 确保代码块的原子性执行。以下是其实现本质的分步解析: 1. 语法糖的转换 当使用 lock 关键字时: lock (obj) {// 临界区…...
Java 集合:泛型、Set 集合及其实现类详解
参考资料:java入门到飞起 Java;泛型;Set 集合;TreeSet;HashSet;数据结构 一、引言 在 Java 编程中,集合框架是一个重要的组成部分,它提供了丰富的数据结构和算法来存储和操作数据。泛型与 Set…...
前端基础之《Vue(8)—内置组件》
一、Vue2.0中的内置组件 1、<slot> 插槽 2、<keep-alive> 动态组件 被keep-alive所包裹的组件: (1)不会被销毁。 (2)还会多两个生命周期钩子:activated()、deactivated()。 (3&a…...
Zookeeper是什么?基于zookeeper实现分布式锁
zookeeper听的很多,但实际在应用开发中用的不错,主要是作为中间件配合使用的,例如:Kafka。 了解zk首先需要知道它的数据结构,可以想象为树、文件夹目录。每个节点有基本的信息,例如:创建时间、…...
计算机网络 第二章:应用层(四)
2.6 视频流和内容分发网 对如何在因特网中实现流行的视频流服务进行概述。它们的实现方式是使用应用层协议和以像高速缓存那样方式运行的服务器。 2.6.1 因特网视频 在流式存储视频应用中,基础的媒体是预先录制的视频,例如电影、电视节目、录制好的体育…...
什么是数据库的DDL和DML,有什么区别?
数据库中的 DDL 和 DML 是两类不同的 SQL 语言,用于不同的数据库操作目的。以下是它们的定义、区别和具体说明: 1. DDL(Data Definition Language,数据定义语言) 作用:定义或修改数据库的结构(…...
HCIP实验二(OSPF网络配置与优化)
一.拓扑图与题目 1.R5为ISP,其上只能配置IP地址; R5与其他所有直连设备间均使用公有IP;环回地址为100.1.1.1/3 2.R4设备为企业出口路由器 3.整个0SPF环境IP基于172.16.0.0/16划分 4.所有设备均可访问R5的环回; 5.减少LSA的更新里,加快收敛࿰…...
第十六讲、isaaclab中使用任务空间(task-space)控制
0 前言 官方教程:https://isaac-sim.github.io/IsaacLab/main/source/tutorials/05_controllers/run_diff_ik.html IsaacsimIsaaclab安装:https://blog.csdn.net/m0_47719040/article/details/146389391?spm1001.2014.3001.5502 在之前的教程中我们利…...