其他分享
首页 > 其他分享> > 动态规划问题(一)

动态规划问题(一)

作者:互联网

一、递推问题

递推问题的典型代表就是斐波那契数列问题,即已知斐波那契数列前几项(下标从0开始)为0,1,1,2,3,5,8……,求第n项。
通过观察前几项,发现每一项都是前两项的和,因此递推式为f(n) = \begin{cases} 0, & n = 0 \\ 1, & n = 1 \\ f(n - 1) + f(n - 2), & n \geqslant 2 \end{cases} 
C++部分代码如下:

fib[0] = 0;
fib[1] = 1;
for(int i = 2; i <= n; i ++)
    fib[i] = fib[i - 1] + fib[i - 2];

例题:Leetcode509 斐波那契数

二、记忆化搜索

在DP问题中,记忆化搜索往往指的是在递归中记录计算过的状态,并在后续的计算中跳过已经计算过的状态,从而大大减少递归的计算次数。

我们以上一部分的斐波那契数列问题为例,递归每一层f(n) = f(n - 1) + f(n - 2)\; \; (n \geqslant 2),C++递归部分代码如下:

int f(int n)
{
    if(n == 0) return 0;
    if(n == 1) return 1;
    return f(n - 1) + f(n - 2);
}

可以发现求解过程是一棵二叉树,高度为n,每个节点都计算一次,可以发现n越大,重复计算的次数越多,显然浪费了很多的时间。因此我们考虑如何将每个值只计算一次,这就是记忆化搜索的思想。我们可以将f[i]的值存入哈希数组h[i]中,这样就实现了记忆化搜索,每次需要的值只需要访问h[i],如果有值就直接使用即可,访问的时间复杂度为O(1)。

C++记忆化搜索部分代码如下:

int h[N];

void init(){
    memset(h, -1, sizeof h);   // 初始化哈希数组
}

int f(int n)
{
    if(h[n] != -1) return h[n];
    if(n == 0) return 0;
    if(n == 1) return 1;
    h[n] = f(n - 1) + f(n - 2);
    return h[n];
}

三、线性DP

1、最小花费

最小花费问题的典型代表就是爬楼梯问题

数组的每个下标作为一个阶梯,第 i 个阶梯对应着一个非负数的体力花费值 cost[i](下标从 0 开始)。

每当你爬上一个阶梯你都要花费对应的体力值,一旦支付了相应的体力值,你就可以选择向上爬一个阶梯或者爬两个阶梯。

请你找出达到楼层顶部的最低花费。在开始时,你可以选择从下标为 0 或 1 的元素作为初始阶梯。

我们令走到第 i 个阶梯的最小体力花费为 f(i),而第 i 个阶梯只可能由第 i - 1个阶梯或第 i - 2 个阶梯到达:由第i - 1个阶梯到达,有:f(i) = f(i - 1) + cost(i - 1)

                  由第i - 2个阶梯到达,有:f(i) = f(i - 2) + cost(i - 2)

又由于起点可以在第 0 个阶梯或者第 1 个阶梯,因此,状态转移方程为f(i) = \begin{cases} 0, & i = 0, 1 \\ min[f(i - 1) + cost(i - 1), f(i - 2) + cost(i -2)], & i \geqslant 2 \end{cases}

C++部分代码如下:

f[0] = f[1] = 0;
for(int i = 2; i <= n; i ++)
    f[i] = min(f[i - 1] + cost[i - 1], f[i - 2] + cost[i - 2]);

例题:Leetcode746 使用最小花费爬楼梯

2、最大子段和

最大子段和问题本身就是一个典型问题:

给定一个整数数组 nums ,找到一个具有最大和的连续子数组(子数组最少包含一个元素),返回其最大和。

由于是连续子数组,我们令以 i 号元素结尾的最大和为f(i),那么nums(i)一定包含在f(i)中。再分析nums(i - 1)nums(i - 2)……nums(k)是否包含于f(i)中,可以发现:

f(i - 1) \leqslant 0时,nums(i - 1)nums(i - 2)……nums(k)如果包含在f(i)中,那么f(i) \leqslant nums(i),此时f(i)不是最大连续子数组,所以这种情况下f(i) = nums(i)

f(i - 1) > 0时, nums(i - 1)nums(i - 2)……nums(k) 包含进f(i)中可以使f(i) > nums(i),因此f(i) = f(i - 1) + nums(i)

综上分析,可以得出状态转移方程为

 f(i) = \begin{cases} nums(0), & i = 0 \\ nums(i), & f(i - 1) \leqslant 0 \\ nums(i) + f(i - 1), & f(i - 1) > 0 \end{cases}

C++部分代码如下:

f[0] = nums[0];
int maxn = nums[0];
for(int i = 1; i < n; i ++){
    if(f[i - 1] <= 0) f[i] = nums[i];
    else f[i] = f[i - 1] + nums[i];
    maxn = max(maxn, f[i]);
}

例题:Leetcode53 最大子序和

3、最长单调子序列

最长递增子序列问题为例:

给你一个整数数组 nums ,找到其中最长严格递增子序列的长度。

子序列是由数组派生而来的序列,删除(或不删除)数组中的元素而不改变其余元素的顺序。例如,[3,6,2,7] 是数组 [0,3,1,6,2,2,7] 的子序列。

对于序列a_{i}(1 \leqslant i \leqslant n),我们令f(i)表示以第 i 个数为结尾的最长递增子序列的长度,在这个序列中,a_{i}的前一个数一定是a_{j}(1 \leqslant j < i)中的一个,可以得出f(i) = f(j) + 1,其中a_{j} < a_{i}

因此,状态转移方程为

f(i) = max(f(j)) + 1 \; \; \; (a_j < a_i, \; \; 1 \leqslant j \leqslant i - 1)

这里max(f(j))f(i)的一个最优子结构,自然会想到,有一种特殊情况就是f(i)不存在最优子结构,此时f(i) = 1

C++部分代码如下:

int length = 0;
for(int i = 0; i < n; i ++){
    f[i] = 1;
    for(int j = 0; j < i; j ++)
        if(nums[j] < nums[i]) f[i] = max(f[i], f[j] + 1);
}
for(int i = 0; i < n; i ++)
    length = max(length, f[i]);

例题:Leetcode300 最长递增子序列

三、二维DP

1、最长公共子序列

字符串最长公共子序列问题为例:

给定两个字符串 text1 和 text2,返回这两个字符串的最长 公共子序列 的长度。如果不存在 公共子序列 ,返回 0 。

一个字符串的 子序列 是指这样一个新的字符串:它是由原字符串在不改变字符的相对顺序的情况下删除某些字符(也可以不删除任何字符)后组成的新字符串。

例如,"ace" 是 "abcde" 的子序列,但 "aec" 不是 "abcde" 的子序列。
两个字符串的 公共子序列 是这两个字符串所共同拥有的子序列。

对于两个字符串text1的第 i 项text1(i)和text2的第 j 项text2(j),记dp[i][j]为text1以第 i 项结尾和text2以第 j 项结尾的公共子序列长度,分为两种情况:

第一种情况是text1(i) = text2(j),可以看作text1以第 i - 1 项结尾和text2以第 j - 1 项结尾的公共子序列长度加上1,即dp[i][j] = dp[i - 1][j - 1] + 1

第二种情况是text1(i) \neq text2(j),又可以分成两类,一类是看作text1以第 i 项结尾、text2以第 j - 1 项结尾;另一类是看作text1以第 i 项结尾、text2以第 j - 1 项结尾,再取两类中的最大值即可,故dp[i][j] = max(dp[i - 1][j], dp[i][j - 1])

再考虑边界情况:当i = 0j = 0时,很明显dp[0][j] = 0, dp[i][0] = 0

综上分析,可以得出状态转移方程为

dp[i][j] = \begin{cases} 0, & i = 0 \; \; or \; \; j = 0 \\ dp[i - 1][j - 1] + 1, & i, j > 0,\; \; text1[i] = text2[j] \\ max(dp[i][j - 1], dp[i - 1][j]), & i, j > 0,\; \; text1[i] \neq text2[j] \end{cases}

C++部分代码如下:

for(int i = 1; i <= n1; i ++)   // 这里n1表示text1字符串的长度,n2表示text2字符串的长度
    for(int j = 1; j <= n2; j ++){
        if(text1[i - 1] == text2[j - 1])
            dp[i][j] = dp[i - 1][j - 1] + 1;
        else
            dp[i][j] = max(dp[i][j - 1], dp[i - 1][j]);
    }

例题:Leetcode1143 最长公共子序列

2、最小编辑距离

编辑距离计算的是将源字符串修改成目标字符串需要操作的次数,例题如下:

给你两个单词 word1 和 word2,请你计算出将 word1 转换成 word2 所使用的最少操作数 。

你可以对一个单词进行如下三种操作:

  • 插入一个字符
  • 删除一个字符
  • 替换一个字符

设源字符串word1的每个字符为a_{i}(1 \leqslant i \leqslant n_{1})n_1表示字符串word1的长度),目标字符串word2的每个字符为b_{j}(1 \leqslant j \leqslant n_{2})n_2表示字符串word2的长度),我们令dp[i][j]表示源字符串变成目标字符串的最小操作数,对于三种不同的操作,就有三种不同的情况:

第一种,假设a_1,a_2,......,a_i变成b_1,b_2,......,b_{j-1}最小操作数为dp[i][j - 1],只需再插入一个字符即可变成目标字符串,因此dp[i][j] = dp[i][j - 1] + 1

第二种,假设a_1,a_2,......,a_{i -1}变成b_1,b_2,......,b_{j}最小操作数为dp[i - 1][j],只需删除a_i即可变成目标字符串,因此dp[i][j] = dp[i - 1][j] + 1

第三种,假设a_1,a_2,......,a_{i - 1}变成b_1,b_2,......,b_{j - 1}最小操作数为dp[i - 1][j - 1],这里又有两种情况,如果a_i = b_j,那么不需要操作就已经变成目标字符串了,即dp[i][j] = dp[i - 1][j - 1];如果a_i \neq b_j,那么只需将a_i替换为b_j即可变成目标字符串,即dp[i][j] = dp[i - 1][j - 1] + 1

再考虑边界情况:如果i = 0j = 0,即两个字符串中至少有一个为空字符串:

        如果源字符串是空字符串,目标字符串不是空字符串,那么每次操作都是插入目标字符串中的一个字符,因此最小操作数dp[0][j] = j

        如果源字符串不是空字符串,目标字符串是空字符串,那么每次操作都是删除源字符串中的一个字符,因此最小操作数dp[i][0] = i

        如果源字符串和目标字符串都是空字符串,那么不需要进行操作,因此最小操作数dp[i][j] = 0

综上分析,可以得出状态转移方程为

dp[i][j] = \begin{cases} i + j, & i = 0 \; \; or \; \; j = 0 \\ min(dp[i][j - 1] + 1, dp[i - 1][j] + 1, \begin{cases} dp[i - 1][j - 1], & a_i = b_j \\ dp[i - 1][j - 1] + 1, & a_i \neq b_j \end{cases}), & i, j \neq 0 \end{cases}

C++部分代码如下:

// n1表示字符串word1的长度,n2表示字符串word2的长度
if(n1 * n2 == 0) return n1 + n2;   // 至少一个字符串为空串的情况

// 初始化边界状态
for(int i = 0; i <= n1; i ++) dp[i][0] = i;
for(int j = 0; j <= n2; j ++) dp[0][j] = j;

// 计算所有DP值
for(int i = 1; i <= n1; i ++){
    for(int j = 1; j <= n2; j ++){
        int in = dp[i - 1][j] + 1;
        int de = dp[i][j - 1] + 1;
        int re = dp[i - 1][j - 1];
        if(word1[i - 1] != word2[j - 1])   // 判断要替换的两个字符是否相同,不同则次数+1
            re += 1;
        dp[i][j] = min(in, min(de, re));
    }
}

例题:Leetcode72 编辑距离

3、串匹配

串匹配的经典题目是带通配符的匹配问题

给定一个字符串 (s) 和一个字符模式 (p) ,实现一个支持 '?' 和 '*' 的通配符匹配。

'?' 可以匹配任何单个字符。
'*' 可以匹配任意字符串(包括空字符串)。
两个字符串完全匹配才算匹配成功。

说明:

s 可能为空,且只包含从 a-z 的小写字母。
p 可能为空,且只包含从 a-z 的小写字母,以及字符 ? 和 *。

我们令字符串s每个字符为s_i(1 \leqslant i \leqslant n1)n_1表示字符串s的长度),字符模式p每个字符为p_j(1 \leqslant j \leqslant n2)n_2表示字符模式p的长度),令bool型数组dp[i][j]表示字符串s到s_i和字符模式p到p_j是否匹配,匹配为true,不匹配为false。根据字符的类型分成三种情况:

第一种,如果p_j为小写字母,那么只有当s_ip_j相同时,二者才能匹配成功,因此dp[i][j] = true当且仅当s_i = p_jdp[i - 1][j - 1] = true

第二种,如果p_j'?',那么无论s_i是什么,都能匹配成功,因此dp[i][j] = true当且仅当dp[i - 1][j - 1] = true

第三种,如果p_j'*',由于'* '有两种用法, 所以分两类讨论:

        如果用来匹配空字符串,那么p_js_i的匹配由p_{j - 1}s_{i}的匹配转移而来,因此dp[i][j] = true        当且仅当dp[i][j - 1] = true

        如果用来匹配任意字符串,那么p_js_i的匹配由p_is_{i - 1}的匹配转移而来,因此                ​​​​​​​        dp[i][j] = true当且仅当dp[i - 1][j] = true

再考虑边界情况:

        如果字符串s是空字符串,字符模式p不是空字符串,由于只有'*'可以匹配空字符串,因此p的前j个字符必须均为'*'才能匹配成功;

        如果字符串s不是空字符串,字符模式p是空字符串,由于空字符串无法匹配空字符串,因此这种情况是无法匹配成功的;

        如果字符串s和字符模式p均为空字符串,那么一定匹配成功。

综上分析,可以得到状态转移方程为

dp[i][j] = \begin{cases} dp[i - 1][j - 1], & p_j = s_i \; \; or \; \; p_j = '?' \\ dp[i - 1][j] \vee dp[i][j - 1], & p_j = '*' \\ true, & i, j = 0 \\ false, & i \neq 0, \; \; j = 0 \\ \begin{cases} true, & p_k \equiv '*'(1 \leqslant k \leqslant j) \\ false, & p_k \not\equiv '*'(1 \leqslant k \leqslant j) \end{cases}, & i = 0, \; \; j \neq 0 \end{cases}

C++部分代码如下:

dp[0][0] = true;
for(int i = 1; i <= n2; i ++){
    if(p[i - 1] == '*') dp[0][i] = true;
    else break;
}
for(int i = 1; i <= n1; i ++){
    for(int j = 1; j <= n2; j ++){
        if(p[j - 1] == '*') dp[i][j] = dp[i - 1][j] | dp[i][j - 1];
        else if(p[j - 1] == '?' || p[j - 1] == s[i - 1]) dp[i][j] = dp[i - 1][j - 1];
    }
}

例题:Leetcode44 通配符匹配

标签:字符,匹配,int,问题,5C%,20%,字符串,动态,规划
来源: https://blog.csdn.net/qq_58207591/article/details/120389512