算法精讲:动态规划_字符串

介绍

动态规划并不是一种具体的算法,而是一种思想,个人觉得就是缓存+枚举,把求解的问题分成许多阶段或者多个子问题,然后按顺序求解各子问题。前一子问题的解为后一子问题提供了有用的信息。在求解任一子问题时,列出各种可能的局部解,通过决策保留那些有可能达到最优的局部解,丢弃其他局部解。依次解决各子问题,最后一个子问题就是初始问题的解。

所以动态规划一般用来求最优解(对子问题进行决策),求种类数(对子问题进行加和)

先分享几个经典的动态规划实现,后续再分析几个面试题

最长上升子序列

来源:LeetCode 300.最长上升子序列
描述:给定一个无序的整数数组,找到其中最长上升子序列的长度。

示例:

输入: [10,9,2,5,3,7,101,18]
输出: 4
解释: 最长的上升子序列是 [2,3,7,101],它的长度是 4

可能会有多种最长上升子序列的组合,你只需要输出对应的长度即可。
你算法的时间复杂度应该为 O(n2) 。
进阶: 你能将算法的时间复杂度降低到 O(n log n) 吗?

思路:子序列有很多,最长的长度为4

算法精讲:动态规划_状态转移_02

我们假设dp[i]存的是到第i个元素时,数组的最长子序列,则对应的状态转移方程为

dp[i] = max{1, dp[j] + 1 | j < i  arr[j] < arr[i]}

其中1为只有自己一个元素,则递增子序列的长度为1

public class Solution {

public int lengthOfLIS(int[] nums) {
int max = 0;
int[] dp = new int[nums.length];
for (int i = 0; i < nums.length; i++) {
dp[i] = 1;
for (int j = 0; j < i; j++) {
if (nums[i] > nums[j] && (dp[j] + 1) > dp[i]) {
dp[i] = dp[j] + 1;
}
}
if (dp[i] > max) {
max = dp[i];
}
}
return max;
}
}

数塔问题

来源:LeetCode 120. 三角形最小路径和
描述:给定一个三角形,找出自顶向下的最小路径和。每一步只能移动到下一行中相邻的结点上。

相邻的结点 在这里指的是 下标 与 上一层结点下标 相同或者等于 上一层结点下标 + 1 的两个结点。

例如,给定三角形:

[
[2],
[3,4],
[6,5,7],
[4,1,8,3]
]

自顶向下的最小路径和为 11(即,2 + 3 + 5 + 1 = 11)
说明:
如果你可以只使用 O(n) 的额外空间(n 为三角形的总行数)来解决这个问题,那么你的算法会很加分。

思路:把这个图形换一下,方便讲递推公式

[2],
[3,4],
[6,5,7],
[4,1,8,3]

算法精讲:动态规划_子序列_03


我们可以从底到顶来算最优值。

dp[i][j]为从最底部到第i行第j列的最小路径和,value[i][j]为第i行第j列的值,状态转移方程为

dp[i][j] = max(dp[i-1][j-1], dp[i-1][j]) + triangle[i][j]
public class Solution {

public int minimumTotal(List<List<Integer>> triangle) {
if (triangle == null || triangle.size() == 0) {
return 0;
}
// 这里行和列加1,是为了不用处理最下面一行的边界
int[][] dp = new int[triangle.size() + 1][triangle.size() + 1];
for (int i = triangle.size() - 1; i >= 0; i--) {
List<Integer> rows = triangle.get(i);
for (int j = 0; j < rows.size(); j++) {
dp[i][j] = Math.min(dp[i + 1][j], dp[i + 1][j + 1]) + rows.get(j);
}
}
return dp[0][0];
}
}

最长公共子串

来源:LeetCode 1143. 最长公共子序列
描述:给定两个字符串 text1 和 text2,返回这两个字符串的最长公共子序列的长度。

一个字符串的 子序列 是指这样一个新的字符串:它是由原字符串在不改变字符的相对顺序的情况下删除某些字符(也可以不删除任何字符)后组成的新字符串。
例如,“ace” 是 “abcde” 的子序列,但 “aec” 不是 “abcde” 的子序列。两个字符串的「公共子序列」是这两个字符串所共同拥有的子序列。

若这两个字符串没有公共子序列,则返回 0。
示例 1:

输入:text1 = "abcde", text2 = "ace" 
输出:3
解释:最长公共子序列是 "ace",它的长度为 3

示例 2:

输入:text1 = "abc", text2 = "abc"
输出:3
解释:最长公共子序列是 "abc",它的长度为 3

示例 3:

输入:text1 = "abc", text2 = "def"
输出:0
解释:两个字符串没有公共子序列,返回 0

思路:这个题确实比较抽象,上图

算法精讲:动态规划_字符串_04


s1=a s2=a,最长公共子串长度为1

s1=ac s2=abc,对应的公共子串长度为2

dp[i][j]为第一个字符串长度为i和第二个字符串长度为j时对应的最长公共子串
状态转移方程为

if(s1.charAt(i) == s2.charAr(j))
dp[i][j] = dp[i-1][j-1] + 1;
else
dp[i][j] = Math.max(dp[i-1][j], dp[i][j -1]);

还是画图演示一下递推公式

算法精讲:动态规划_子序列_05

public class Solution {

public int longestCommonSubsequence(String text1, String text2) {
if (text1 == null || text2 == null || text1.length() == 0 || text2.length() == 0) {
return 0;
}
int[][] dp = new int[text1.length() + 1][text2.length() + 1];
for (int i = 1; i <= text1.length(); i++) {
for (int j = 1; j <= text2.length(); j++) {
if (text1.charAt(i - 1) == text2.charAt(j - 1))
dp[i][j] = dp[i - 1][j - 1] + 1;
else
dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
}
}
return dp[text1.length()][text2.length()];
}
}

背包问题

是男人就看《背包九讲》,作为动态规划的入门课,《背包九讲》必不可少。这次就只分享背包九讲中最简单的01背包

来源:蓝桥杯
问题描述:给定N个物品,每个物品有一个重量W和一个价值V.你有一个能装M重量的背包.问怎么装使得所装价值最大.每个物品只有一个.
输入格式
  输入的第一行包含两个整数n, m,分别表示物品的个数和背包能装重量。
  以后N行每行两个数Wi和Vi,表示物品的重量和价值
输出格式
  输出1行,包含一个整数,表示最大价值。
样例输入
3 5
2 3
3 5
4 7
样例输出
8
数据规模和约定
1<=N<=200,M<=5000.

思路:这是最简单的01背包,都不带变形的,每种物品仅有一件,可以选择放或不放。

第i件物品的重量是w[i],价值是v[i]
用dp[i][j]表示前i件物品放入一个承重为j的背包可以获得的最大价值,状态转移方程为

dp[i][j] = max{dp[i-1][j], dp[i-1][j-w[i]] + c[i]}

dp[i][j] = max{不放第i件物品,放第i件物品}

你可以照着这个状态转移方程自己写一下,我下面这种写法直接用了滚动数组,把数组从二维变成了一维,节省了空间,有兴趣的可以参考其他博客学习这种写法,本文就不深入了。

public class Main {

public static void main(String[] args) {
Scanner in = new Scanner(System.in);
int n = in.nextInt();
int m = in.nextInt();
int[] widght = new int[210];
int[] value = new int[210];
int[] dp = new int[5010];
for (int i = 0; i < n; ++i) {
widght[i] = in.nextInt();
value[i] = in.nextInt();
}
for (int i = 0; i < n; ++i) {
for (int j = m; j >= widght[i]; j--) {
dp[j] = Math.max(dp[j], dp[j - widght[i]] + value[i]);
}
}
System.out.println(dp[m]);
}
}

不同路径

来源:LeetCode 62不同路径

描述

一个机器人位于一个 m x n 网格的左上角 (起始点在下图中标记为“Start” )。

机器人每次只能向下或者向右移动一步。机器人试图达到网格的右下角(在下图中标记为“Finish”)。

问总共有多少条不同的路径?

算法精讲:动态规划_子序列_06


例如,上图是一个7 x 3 的网格。有多少可能的路径?

说明:m 和 n 的值均不超过 100。

示例:输入: m = 3, n = 2,输出: 3
解释:从左上角开始,总共有 3 条路径可以到达右下角。

  1. 向右 -> 向右 -> 向下
  2. 向右 -> 向下 -> 向右
  3. 向下 -> 向右 -> 向右

思路:这个大家一下就会想到用递归解决,假设f(m,n)表示移动到点(m,n)的路径数,因为机器人智能向下或者向右移动,所以点(m,n)只能从点(m-1,n)和(m,n-1)移动而来,递归公式就是f(m,n)=f(m-1,n)+f(m,n-1),递归的出口呢?当然就是网格的边界了,网格边界上的点都只有一种方法,按照这种思路写出来如下代码

class Solution {
public int uniquePaths(int m, int n) {
// 在网格边界的格子只能有一种走法
if (m == 1 || n == 1) {
return 1;
}
// m,n这个位置只能从(m - 1 , n)和(m, n - 1)移动而来
return uniquePaths(m - 1, n) + uniquePaths(m, n - 1);
}
}

其实这个代码效率还是很低的,因为有很多重复的计算,如下图

算法精讲:动态规划_子序列_07


当m和n为(3,3)时,(2,2)被计算了2次,而且m和n越大,重复计算的次数最多,我们可以把已经算出来的值保存一下,这样下次再用的时候就不用算了,直接取就行,叫做备忘录算法,grid[m][n]表示走到(m,n)这个点时的路径数。

class Solution {

public static int[][] grid = new int[110][110];

public int uniquePaths(int m, int n) {
if (grid[m][n] != 0)
return grid[m][n];
if (m == 1 || n == 1) {
return 1;
}
return grid[m][n] = uniquePaths(m - 1, n) + uniquePaths(m, n - 1);
}
}

当值不为0的时候说明已经被算过了,直接取就行了,否则就得计算并保存结果,这样效率提高了不少,但是如果m和n特别大,递归层数过多时会造成堆栈溢出的,该怎么办?这个时候就得用到动态规划了

递归是从上至下开始计算的,有没有可能从下而上的计算呢?,如先算出(1,2)和(2,1),然后就能算出(2,2)了,我们得按照一定的规律计算,保证在算(2,2)之前,(1,2)和(2,1)已经算完了,我们只要按行从左到右计算,或者按列从上到下即可

dp[i][j]表示到达第i行第j列的路径数,所以状态转移方程为

dp[i][j] = dp[i][j-1] + dp[i-1][j]
class Solution {

public static int[][] grid = new int[110][110];

public int uniquePaths(int m, int n) {

for (int i = 1; i <= n ; i++) {
for (int j = 1; j <= m ; j++) {
if (i == 1 || j == 1)
grid[i][j] = 1;
else
grid[i][j] = grid[i][j-1] + grid[i-1][j];
}
}
return grid[n][m];
}
}

减绳子

来源:《剑指offer》第二版

描述:给你一根长度为n的绳子,请把绳子剪成m段 (m和n都是整数,n>1并且m>1)每段绳子的长度记为k[0],k[1],…,k[m].请问k[0]k[1]…*k[m]可能的最大乘积是多少?例如,当绳子的长度为8时,我们把它剪成长度分别为2,3,3的三段,此时得到的最大乘积是18.

思路:定义函数f(n)为长度为n的绳子剪成若干段后各段长度乘积的最大值。在剪第一刀的时候,我们有n-1种可能的选择,也就是剪出来的第一段绳子的长度分别为1,2…n-1。因此f(n)=max(f(i)*f(n-i)),其中0<i<n。这是一个从上至下的递归公式,递归会有很多重复的子问题。我们可以从下而上的顺序计算,也就是说我们先得到f(2),f(3),再得到f(4),f(5),直到得到f(n)

假设dp[i]表示长度为i的绳子能得到的最大乘积,则状态转移方程为

dp[i] = max(dp[i], dp[j] * dp[i-j])
public class Solution {

public int maxNumAfterCutting(int n) {
if (n < 2)
return 0;
// 绳子长度为2时,只能剪成1和1
if (n == 2)
return 1;
// 只可能为长度为1和2的2段或者长度都为1的三段,最大值为2
if (n == 3)
return 2;
// 当长度大于3时,长度为3的段的最大值是3
int product[] = new int[n+1];
product[0] = 0;
product[1] = 1;
product[2] = 2;
product[3] = 3;
int max = 0;
for (int i = 4; i <= n; i++) {
max = 0;
for (int j = 1; j <= i / 2; j++) {
int sum = product[j] * product[i - j];
if (sum > max) {
max = sum;
product[i] = max;
}
}
}
return product[n];
}

}

代码中第一个for循环变量i是顺序递增的,这意味着计算顺序是自下而上的。因此再求f(i)之前,对于每一个j(0<i<j)而言,f(j)都已经求解出来了,并且保存在product[j]里。为了求解f(i),我们需要求出所有可能的f(i)*f(i-j)并比较得出他们的最大值,这就是代码中第二个for循环的功能

这个面试题又比第一个面试题难了一点,因为第一个面试题仅仅是将一个大问题划分成几个子问题,并没有根据局部解进行决策得到最优解,而这个面试题体现了决策的过程

接雨水

来源:LeetCode 42. 接雨水
描述:给定 n 个非负整数表示每个宽度为 1 的柱子的高度图,计算按此排列的柱子,下雨之后能接多少雨水

算法精讲:动态规划_子序列_08


上面是由数组 [0,1,0,2,1,0,1,3,2,1,2,1] 表示的高度图,在这种情况下,可以接 6 个单位的雨水(蓝色部分表示雨水)

示例:
输入: [0,1,0,2,1,0,1,3,2,1,2,1]输出: 6

思路:思路还是比较简单,对每一个柱子能存多少水求和即可,这样只需要获取这个柱子左边的最高高度和这个柱子右边的最高高度,2者的最小值减去柱子的高度就是这个柱子的存水量

class Solution {

public int trap(int[] height) {
int sum = 0;
for (int i = 0; i < height.length; i++) {
int maxLeft = 0, maxRight = 0;
// 对i这个柱子,左边柱子的最高值
for (int left = 0; left < i; left++) {
maxLeft = Math.max(maxLeft, height[left]);
}
// 对i这个柱子,右边柱子的最高值
for (int right = i + 1; right < height.length ; right++) {
maxRight = Math.max(maxRight, height[right]);
}
// i这个柱子能存的水量
int temp = Math.min(maxLeft, maxRight) - height[i];
if (temp > 0)
sum += temp;
}
return sum;
}
}

每次都要算某个柱子的左右最值,时间复杂度是O(n2),能不能把算左右最值的效率提高呢?这就用到动态规划了,假如说

我们用dp[i],表示到第i个柱子(包括第i个柱子)左边的最大值,height[i]为第i个柱子的高度,右边同理,则状态转移方程为

dp[i] = max(dp[i-1], height[i])
class Solution {

public int trap(int[] height) {
int sum = 0;
int len = height.length;
if (len == 0)
return 0;
int[] maxLeft = new int[len];
int[] maxRight = new int[len];
maxLeft[0] = height[0];
for (int i = 1; i < len; i++) {
maxLeft[i] = Math.max(height[i], maxLeft[i-1]);
}
maxRight[len - 1] = height[len - 1];
for (int i = len - 2; i >= 0; i--) {
maxRight[i] = Math.max(height[i] ,maxRight[i+1]);
}
for (int i = 0; i < height.length; i++) {
sum += Math.min(maxLeft[i], maxRight[i]) - height[i];
}
return sum;
}
}

这样时间复杂度就变成O(n)了

分割等和子集

来源:LeetCode 416. 分割等和子集
描述:给定一个只包含正整数的非空数组。是否可以将这个数组分割成两个子集,使得两个子集的元素和相等。

注意:

每个数组中的元素不会超过 100
数组的大小不会超过 200
示例 1:

输入: [1, 5, 11, 5]
输出: true
解释: 数组可以分割成 [1, 5, 5] [11].

示例 2:

输入: [1, 2, 3, 5]
输出: false
解释: 数组不能分割成两个元素和相等的子集.

思路:很典型的01背包,背包的容量为元素和的一半,最后看背包是否能填满即可
之前已经说了01背包的状态转移方程

第i件物品的重量是w[i],价值是v[i]
用dp[i][j]表示前i件物品放入一个承重为j的背包可以获得的最大价值,状态转移方程为

dp[i][j] = max{dp[i-1][j], dp[i-1][j-w[i]] + c[i]}

dp[i][j] = max{不放第i件物品,放第i件物品}

在这个题目中,承重和价值都是这个值的大小,因为上一次例子用到了压缩数组的写法,这次就换一种写法,完全按照状态转移方程来

public class Solution {

public boolean canPartition(int[] nums) {
int sum = 0;
for (int i = 0; i < nums.length; i++) {
sum += nums[i];
}
// 奇数直接false
if ((sum & 1) == 1) {
return false;
}
int target = sum >> 1;
int[][] dp = new int[nums.length][target + 1];
for (int i = 0; i < nums.length; i++) {
for (int j = 0; j <= target; j++) {
if (j >= nums[i]) {
if (i == 0) {
dp[i][j] = nums[i];
} else {
dp[i][j] = Math.max(dp[i - 1][j], dp[i - 1][j - nums[i]] + nums[i]);
}
}
}
}
return dp[nums.length - 1][target] == target;
}
}

最后留个思考题,分割等和子集这个题的状态转移方程除了我写的这个,你还能写出其他的吗?

参考博客