算法合集:动态规划(一)——线性dp

动态规划(一):线性dp

    • 一、常规线性dp
      • 1、简单的线性dp
      • 2、稍复杂的线性dp
      • 3、复杂的线性dp
    • 二、字符串类线性dp专讲
      • 1、简单的字符串线性dp
      • 2、稍复杂的字符串线性dp
      • 3、复杂的字符串线性dp

dp可能简单算法中的难度天花板了,但dp并非完全摸不头脑,下面我们从简单题入手来讲解dp
开头便抛出dp的终极奥义:除了初始化之外,dp的每一步递推都是一个一般性的问题,只需要考虑——如果这一步就要得到结果,我需要做什么?

一、常规线性dp

线性dp:从一口气头推到尾,各个子问题相对独立

线性dp一般比较明显的看出递推公式,并且比较容易优化

1、简单的线性dp

简单是指:dp的定义简单,子问题容易找,但实现起来并不简单,有些细节需要注意
1)例题:LeetCode 322 零钱兑换
dp定义比较清晰

  • 维护数组dpdp[i]表示i为金额时有最少的硬币个数

想法有些类似于贪心,对coins[]中的任意零钱面额coin,只要i - coin > 0就说明有新的组合,我们只要尝试让dp[i]的硬币数最少即可

  • dp[i] = Math.min(dp[i], dp[i - coin] + 1)
public int coinChange(int[] coins, int amount) {
	int[] dp = new int[amount + 1];
	Arrays.fill(dp, (int)1e9);
	dp[0] = 0;
	for (int coin : coins)
		for (int i = coin; i <= amount; i++)
			dp[i] = Math.min(dp[i - coin] + 1, dp[i]);
	return dp[amount] == (int)1e9 ? -1 : dp[amount];
}

2)实战题目
还有非常多类似的题目可以自己去刷,这里只选出典型的题目
LeetCode 63 不同路径Ⅱ
LeetCode 120 三角形最小路径和
LeetCode 53 最大子序和
LeetCode 413 等差数列划分
LeetCode 300 最长递增子序列
LeetCode 剑指Offer46 把数字翻译成字符串
LeetCode 45 跳跃游戏Ⅱ
LeetCode 983 最低价票

其中一些题,dp并非是最优解,但dp的思想可以解题,并为贪心的思路提供基础。

2、稍复杂的线性dp

稍复杂是指:dp定义稍许复杂,子问题并非十分清晰

1)题目一:LeetCode 152 乘积最大子数组
若只维护一个dp便得不到正确答案,当前的max不一定能推出下一个max
本题要求乘积最大,我们知道,两个较大的正数相乘得到大的正数,两个较小负数相乘也会的到大的正数

所以定义两个dp数组:

  • 维护数组maxmax[i]表示以i结尾乘积最大的子数组
  • 维护数组minmin[i]表示以i结尾乘积最小的子数组

max[i]遇到较大的负数,便可能转化为min[i],同理当min[i]遇到较小的负数,便可能转化位max[i]

递推公式:

  • max[i] = Math.max(nums[i], Math.max(max[i - 1] * nums[i], min[i - 1] * nums[i]))
  • min[i] = Math.min(nums[i], Math.min(max[i - 1] * nums[i], min[i - 1] * nums[i]))
public int maxProduct(int[] nums) {
    int[] max = new int[nums.length];
    int[] min = new int[nums.length];
    int ans = 0;
    ans = max[0] = min[0] = nums[0];
    for (int i = 1; i < nums.length; i++) {
        max[i] = Math.max(nums[i], Math.max(max[i - 1] * nums[i], min[i - 1] * nums[i]));
        min[i] = Math.min(nums[i], Math.min(max[i - 1] * nums[i], min[i - 1] * nums[i]));
        ans = Math.max(ans, max[i]);
    }
    return ans;
}

2)题目二:LeetCode 673 最长递增子序列的个数
利用dp可以找到最长递增子序列,只是加上了个数的需求,所以我们便多维护一个dp数组

  • 维护数组lengthlength[i]表示以i结尾的最长递增子序列长度
  • 维护数组countcount[i]表示以i结尾的醉成递增子序列的个数

数组定义出来之后便很好的找到递推方程:长度等于当前维护的最大长度则使count增加,长度大于当前维护的最大长度,则修改count

// 对于任意的 i > j
if (nums[i] > nums[j]) { // 产生了递增
	if (length[j] + 1 == length[i]) { // 若此递增与当前维护的"以i结尾的最长递增子序列"的长度相等
		count[i] += count[j];
	} else if (length[j] + 1 > length[i]) { // 若此递增使"以i结尾的最长递增子序列"的长度增加
		length[i] = length[j] + 1; // 则更新以i结尾的最大长度
		count[i] = count[j]; // 由于以i结尾的最大长度改变了,计数也需要改变
	}
}

但由于要找到全局的最大长度,需要维护全局的最大长度maxlen,同时维护ans来记录长度为maxlen的递增子序列有多少个

if (length[i] > maxlen) { // 如果此次"以i结尾的最长递增子序列"的长度 > 最大长度
	maxlen = length[i];
	ans = count[i]; // 最大长度发生改变,计数也要改变
} else if (length[i] == maxlen) { // 如果此次"以i结尾的最长递增子序列"的长度 == 最大长度
	ans += count[i];
}

换句话说,j的for循环来修改i的相关变量,对i的for循环来修改全局变量

public int findNumberOfLIS(int[] nums) {
    int len = nums.length;
    int ans = 1;
    int maxlen = 1;
    int[] count = new int[len]; // 最长递增序列数量
	int[] length = new int[len]; // 最长递增子序列长度
	Arrays.fill(length, 1);
	Arrays.fill(count, 1);
	for (int i = 1; i < len; i++) {
		for (int j = 0; j < i; j++) // 对j的for循环来修改i的相关变量
			if (nums[i] > nums[j]) {
				if (length[i] < length[j] + 1) {
					length[i] = length[j] + 1;
					count[i] = count[j];
				} else if (length[i] == length[j] + 1) {
					count[i] += count[j];
				}
			}
		if (length[i] > maxlen) { // 对i的for循环来修改全局变量
			maxlen = length[i];
			ans = count[i];
		} else if (length[i] == maxlen) {
			ans += count[i];
		}
	}
	return ans;
}

3)实战题目
LeetCode 376 摆动序列
LeetCode 1567 乘积为正数的最长子数组长度
LeetCode 221 最大正方形
LeetCode 96 不同的二叉搜索树
LeetCode 85 最大矩形

3、复杂的线性dp

更复杂是指:dp的定义模糊不清,子问题之间的转化更加难找
实战题目:
LeetCode 剑指Offer60 n个骰子的点数

二、字符串类线性dp专讲

字符串的dp一般是颇为头疼的,但其实本质仍是dp,而且其dp的定义可能比其他类型更为简单,一般字符串dp分成两种:

给定一个字符串s
dp[i]表示从0 ~ i的题目所求变量 表示从i ~ s.len的题目所求变量
dp[i][j]表示从i ~ j的题目所求变量
给定两个字符串st
dp[i][j]表示s0 ~ it0 ~ j的匹配情况 表示si ~ s.lentj ~ t.len的匹配情况

然后去寻找递推方程,找到两个子问题之间的连接方式即可

1、简单的字符串线性dp

1)例题:LeetCode 32 最长有效括号
dp的定义比较清晰:

  • 维护数组dpdp[i]表示以i结尾的子串最长有效括号的长度

我们知道,一段有效括号,必然是)结尾
所以可以找到递推方程:

  • )的前面是(,则左右括号刚好凑成一对,dp[i] = dp[i - 2] + 2,也就是当前这一对括号的长度 + 这一对括号之前已经配对了的长度
  • )的前面是),则当前的)需要绕过其前方的一段有效括号去寻找(,而这一段有效括号的长度为dp[i - 1],绕过这一段长度后,我们希望在i - dp[i - 1] - 1的位置找到(,如果找到了:dp[i] = i - ((i - dp[i - 1] - 1) + 1) + dp[i - dp[i - 1] - 2]

下面的代码中,dp的长度多开了一位,所以s.charAt(i)对应的是dp[i + 1]

public int longestValidParentheses(String s) {
	int len = s.length();
	int[] dp = new int[len + 1];
	int ans = 0;
	for (int i = 1; i < len; i++) // 对应的是s的下标
		if (s.charAt(i) == ')') {
			if (s.charAt(i - 1) == '(')
				dp[i + 1] = dp[i - 1] + 2;
			else if (i - dp[i] - 1 >= 0 && s.charAt(i - dp[i] - 1) == '(')
				dp[i + 1] = dp[i] + 2 + dp[i - dp[i] - 1];               
			ans = Math.max(ans, dp[i + 1]);
		}
	return ans;
}

2)实战题目:
LeetCode 5 最长回文子串
LeetCode 139 单词拆分

2、稍复杂的字符串线性dp

一旦字符串类问题复杂了起来,我们常常忘记的是空串的力量,如果在递推时想到借用空串,难度会降低
1)例题:LeetCode 10 正则表达式匹配
先定义dp,由于有两个字符串,则采用二维数组

  • 维护数组dpdp[i][j]表示s0 ~ it0 ~ j的匹配情况

子问题其实很好找,如果当前的ij匹配上了,则寻找dp[i - 1][j - 1]的匹配情况
i表示s的下标,j表示p的下标

ji字符相同,或j'.',则匹配成功
if (charAt(j) == '.' or charAt(i)) dp[i][j] = dp[i - 1][j - 1]
这种情况很好解释,在没有'*'时,要么是p中的字符匹配上了,要么是'.'匹配上了
且本次的dp[i][j]dp[i - 1][j - 1]之间挂钩

j'*',则要么'*'使得其前面的字符消失,要么'*'复制前面的字符
if (charAt(j) == '*') dp[i][j] = dp[i][j - 2] || (charAt(j - 1) == '.' or charAt(i) && dp[i - 1][j])
dp[i][j - 2]表示跳过当前的'*'及其前面的字符
charAt(j - 1) == '.' or charAt(i) && dp[i - 1][j]表示若'*'前的字符与i对应的字符匹配,则复制。'*'对前面的字符复制可以视作i对应的字符被删去

递推的方法体,dp的长度多开了一位,所以s.charAt(i)对应的是dp[i + 1]

boolean[][] dp = new boolean[slen + 1][plen + 1];
char pre = '0';
for (int i = 0; i < slen; i++) {
	char c = s.charAt(i);
    for (int j = 0; j < plen; j++) {
        char cur = p.charAt(j);
        if (cur == '.' || cur == c)
            dp[i + 1][j + 1] = dp[i][j];
        else if (cur == '*') // 空 || 多次
            dp[i + 1][j + 1] = dp[i + 1][j - 1] || ((pre == '.' || pre == c) && dp[i][j + 1]);
        pre = cur;
    }
}

还有一步重要的:需要让字符串p与空串进行匹配来保证'*'的使用有效性,因为'*'无论是删去其前方的字符,还是复制其前方的字符,在dp时都是在削减匹配长度,为防止匹配时s或p被削减至空,需额外的初始化

dp[0][0] = true;
for (int i = 0; i < plen; i++) // 让p与空串进行匹配
	if (p.charAt(i) == '*')
		dp[0][i + 1] = dp[0][i - 1];

2)实战题目:
LeetCode 97 交错字符串
LeetCode 44 通配符匹配

3、复杂的字符串线性dp

对于复杂的dp,最理想的方法是画表
1)例题:LeetCode 72 编辑距离

LeetCode 72 编辑距离 官方题解
官方题解的表的动画做得很好,这里不重复画了

dp:

  • dp[i][j]表示s0 ~ it0 ~ j的匹配情况

这里解释一下递推公式

  • 若 A 和 B 的最后一个字母相同:
  • dp[i][j] = min(dp[i - 1][j] + 1, dp[i][j - 1] + 1, dp[i - 1][j - 1])
  • 若 A 和 B 的最后一个字母不同:
  • dp[i][j] = min(dp[i - 1][j] + 1, dp[i][j - 1] + 1, dp[i - 1][j - 1] + 1)

增和删时相对的,对word1的删除就等于对word2的增加

  • dp[i][j]总是可以由dp[i - 1][j]i处增一位得到,也总是可以由dp[i][j - 1]j处增一位得到
  • 如果A 和 B 的最后一个字母相同:那么相当于只需要考虑dp[i - 1][j - 1]
  • 如果A 和 B 的最后一个字母不同:相当于在dp[i - 1][j - 1]之上多了一步,将他们最后一位换成相同的字母
public int minDistance(String word1, String word2) {
    int len1 = word1.length();
	int len2 = word2.length();
	if (len1 * len2 == 0)
		return len1 + len2;
	int[][] dp = new int[len1 + 1][len2 + 1];
	for (int i = 1; i <= len1; i++)
		dp[i][0] = i;
	for (int i = 1; i <= len2; i++)
		dp[0][i] = i;
	for (int i = 1; i <= len1; i++) {
		char c1 = word1.charAt(i - 1);
        for (int j = 1; j <= len2; j++) {
            char c2 = word2.charAt(j - 1);
            if (c1 == c2)
                dp[i][j] = 1 + Math.min(dp[i - 1][j - 1] - 1, Math.min(dp[i - 1][j], dp[i][j - 1]));
            else
                dp[i][j] = 1 + Math.min(dp[i - 1][j - 1], Math.min(dp[i - 1][j], dp[i][j - 1]));
        }
    }
    return dp[len1][len2];
}

2)实战题目:
LeetCode 115 不同的子序列

你可能感兴趣的:(算法,算法,动态规划)