不知道什么是动态规划的,传送门在这儿:[干货]动态规划十问十答
动态规划进阶:动态规划:从新手到专家
相信看完上面两个链接的博客后,应该对于动态规划有一个新的认识和了解了。接下来就来看看LintCode上DP(下文我将以DP或者Dynamic Programming表示动态规划)的题目。
强烈推荐次博客,将动规分类总结做的很好:动态规划完整笔记
常见的动态规划题类型有好几种,有的动规是可以用滚动数组优化的,以下分别归类介绍:
1 坐标型动态规划题:
state: f[x]表示我从起点走到坐标x, f[x][y] 表示我从起点走到坐标x,y....
437. Copy Books
给了一些书,让几个人去拷贝,一个人只能拷贝连续的几本书(比如拷贝了第1、2、3本),但是一个人不能跳着拷贝(比如就不能拷贝第1、3本,必须得连续的拷贝才行)。每本书都有不同的页数,翻译一页需要1分钟。
给定一个包含了页数的数组,和翻译员的人数。问你最少需要多少分钟才能把这些书翻译完。
这其实是一道坐标型动态规划题,假设动态规划的方程为dp[m][n],这就代表前m本书被前n个人拷贝所需的最少时间。接下来我们来找出动规的转移方程。为了方便起见,我们假设数组的起始index是从1开始的。
假设书的数组是[3, 2, 4],人数K=2. 那么我们需要求的就是dp[3][2],也就是前3本书被前2个人拷贝所需要的最少时间。我们从后往前倒推,不难得知dp[3][2]是有可能从dp[3][1],max(dp[2][1], pages[3]), max(dp[1][1], pages[2] + pages[3]) 演变而来的。但是这里面可以排除dp[3][1],因为如果前3本书都给一个人翻译了,那第二个人就无用武之地了,所以这样的转换是无意义的。
因此我们可以得到dp[3][2]的转移方程如下:
dp[3][2] = Min(Max(dp[2][1], pages[3]), Max(dp[1][1], pages[2] + pages[3]));
而且不难得知dp[1][n] = dp[1][3] = dp[1][2] = dp[1][[1] = pages[1]; 因为5个人翻译1本书跟1个人翻译一本书的时间是一样的,剩下的4个人没有用武之地,所以毫无意义。
也不难得知dp[n][1] = pages[1] + pages[2] + ... + pages[n]; 因为一个人翻译n本书的时间等于n本书的页数之和。
所以以上两个“不难得知”可以用于初始化dp方程。
我们把dp方程generalize一下如下所示:
DP[m][n] = MIN( MAX(DP[m-1][n-1], pages[m]), MAX(DP[m-2][n-1], pages[m-1]+pages[m]), ... , MAX(DP[1][n-1], pages[2]+pages[3]+...+pages[m]) );
数组长度是N,人数是K,则时间复杂度是O(N * N * K)
public class Solution {
/**
* @param pages: an array of integers
* @param k: an integer
* @return: an integer
*/
public int calSum(int[] sum, int startIndex, int endIndex) {
return sum[endIndex] - sum[startIndex];
}
public int copyBooks(int[] pages, int k) {
if (pages == null || pages.length <= 0 || k < 1) {
return 0;
}
int[][] f = new int[pages.length + 1][k + 1];
int[] sum = new int[pages.length + 1];
sum[0] = 0;
// initialization
f[0][0] = f[1][0] = f[0][1] = 0;
f[1][1] = pages[0];
for (int i = 1; i < f.length; i++) {
f[i][1] = pages[i - 1] + f[i - 1][1];
sum[i] = sum[i - 1] + pages[i - 1];
}
for (int i = 1; i < f[0].length; i++) {
f[1][i] = f[1][1];
}
// DP function
for (int i = 2; i <= pages.length; i++) {
for (int j = 2; j <= k; j++) {
f[i][j] = Integer.MAX_VALUE;
for (int x = 1; x <= i - 1; x++) {
int result = Math.max(f[x][j - 1], calSum(sum, x, i));
f[i][j] = Math.min(result, f[i][j]);
}
}
}
// result
return f[pages.length][k];
}
}
438. Copy Books II
上一道题的变种,还是一个人只能翻译连续的几本书,不过数组变成了每个人翻译一本书所需要的时间了。
Given n books( the page number of each book is the same) and an array of integer with size k means k people to copy the book and the i th integer is the time i th person to copy one book). You must distribute the continuous id books to one people to copy. (You can give book A[1],A[2] to one people, but you cannot give book A[1], A[3] to one people, because book A[1] and A[3] is not continuous.) Return the number of smallest minutes need to copy all the books.
Given n = 4
, array A = [3,2,4]
, .
Return 4
( First person spends 3 minutes to copy book 1, Second person spends 4 minutes to copy book 2 and 3, Third person spends 4 minutes to copy book 4. )
不难得知,dp[1][1] = arr[1] * 1, dp[2][1] = arr[1] * 2, dp[n][1] = arr[1] * n
我们把dp方程generalize一下如下所示:
DP[m][n] = MIN( MAX(DP[m][n-1], arr[n] * 0), MAX(DP[m-1][n-1], arr[n] * 1), ... , MAX(DP[0][n-1], arr[n] * m) );
需要注意的是,如果发现arr[n] * m 已经大于DP[k][n-1]的时候,就不用再继续往下循环了,以此可以减少运行时间。然后还可以用滚动数组来优化空间复杂度。
public int copyBooksII(int n, int[] times) {
return copyBooksWithRollingArray(n, times);
}
public int copyBooksWithRollingArray(int n, int[] times) {
int len = times.length;
// state
int[][] dp = new int[n + 1][2];
// initialize
for (int i = 0; i <= n; i++) {
dp[i][0] = times[0] * i;
}
// dp function
for (int j = 1; j < len; j++) {
for (int i = 1; i <= n; i++) {
int a = j % 2;
int b = 1 - a;
dp[i][a] = Integer.MAX_VALUE;
for (int k = 0; k <= i; k++) {
int tmp = Math.max(dp[i-k][b], times[j] * k);
dp[i][a] = Math.min(tmp, dp[i][a]);
if (dp[i-k][b] <= times[j] * k) {
break;
}
}
}
}
// result
return dp[n][(len-1)%2];
}
public int copyBooksII2D(int n, int[] times) {
int len = times.length;
// state
int[][] dp = new int[n + 1][len + 1];
// initialize
for (int i = 0; i <= n; i++) {
dp[i][1] = times[0] * i;
}
// dp function
for (int i = 1; i <= n; i++) {
for (int j = 2; j <= len; j++) {
dp[i][j] = Integer.MAX_VALUE;
for (int k = 0; k <= i; k++) {
int tmp = Math.max(dp[i-k][j-1], times[j-1] * k);
dp[i][j] = Math.min(tmp, dp[i][j]);
if (dp[i-k][j-1] <= times[j-1] * k) {
break;
}
}
}
}
// result
return dp[n][len];
}
这是一道二维坐标型动态规划。思路: 分析大问题的结果与小问题的相关性 f[i][j] 表示以i和j作为正方形的右下角可以扩展的最大边长
eg: 1 1 1
1 1 1
1 1 1 (traget)
traget 的值与3部分相关:
1. 青蓝色的正方形部分 f[i - 1][j - 1]
2. 紫粉红色 target上面的数组 up[i - 1][j] 即target上面的点 往上延伸能达到的最大长度
3. 红色的target左边的数组 left[i][j - 1]
如果 target == 1
f[i][j] = min (left[i][j - 1], up[i - 1][j], f[i - 1][j - 1]) + 1;
对于left和up数组 可以在intialization的时候用o(n^2)扫描整个矩阵实现!
优化思路1:
因为
f[i - 1][j] = left[i - 1][j]
f[i][j - 1] = up[i][j - 1]
这样 不需要额外的建立left和up数组
if (target == 1)
f[i][j] = min (f[i - 1][j - 1], f[i][j - 1],f[i - 1][j]) + 1;
public int maxSquare(int[][] matrix) {
int ans = 0;
if (matrix == null || matrix.length == 0 || matrix[0].length == 0) {
return ans;
}
int n = matrix.length;
int m = matrix[0].length;
// 状态
int[][] f = new int[n][m];
// 初始化
for (int i = 0; i < n; i++) {
f[i][0] = matrix[i][0];
ans = Math.max(f[i][0], ans);
}
for (int i = 0; i < m; i++) {
f[0][i] = matrix[0][i];
ans = Math.max(f[0][i], ans);
}
// 方程
for (int i = 1; i < n; i++) {
for (int j = 1; j < m; j++) {
if (matrix[i][j] == 1) {
f[i][j] = Math.min(Math.min(f[i-1][j-1], f[i][j-1]), f[i-1][j]) + 1;
} else {
f[i][j] = 0;
}
ans = Math.max(ans, f[i][j]);
}
}
// 结果
return ans * ans;
}
优化思路2:
public int maxSquare_rollingArray(int[][] matrix) {
int ans = 0;
if (matrix == null || matrix.length == 0 || matrix[0].length == 0) {
return ans;
}
int n = 2;
int m = matrix[0].length;
// 状态
int[][] f = new int[n][m];
// 初始化
for (int i = 0; i < n; i++) {
f[i][0] = matrix[i][0];
ans = Math.max(f[i][0], ans);
}
for (int i = 0; i < m; i++) {
f[0][i] = matrix[0][i];
ans = Math.max(f[0][i], ans);
}
// 方程
for (int i = 1; i < matrix.length; i++) {
for (int j = 1; j < m; j++) {
if (matrix[i][j] == 1) {
f[i%2][j] = Math.min(Math.min(f[(i-1)%2][j-1], f[i%2][j-1]), f[(i-1)%2][j]) + 1;
} else {
f[i%2][j] = 0;
}
ans = Math.max(ans, f[i%2][j]);
}
}
// 结果
return ans * ans;
}
求最大的正方形的面积,这个正方形要满足对角线为1,其他地方全是0。问能不能找到这样的正方形。
dp[m][n]表示以matrix[m][n]为右下角的最大的正方形的边长。up[m][n]代表紧挨着matrix[m][n]上面有多少个连续的0。left[m][n]代表紧挨着matrix[m][n]左边多少个连续的0。那么不难得知如下的状态转移方程:
if matrix[m][n] == 1:
dp[m][n] = MIN ( dp[m-1][n-1], left[m][n-1], up[m-1][n] ) + 1
left[m][n] = 0;
up[m][n] = 0;
else:
dp[m][n] = 0;
if (left[m][n-1] > 0) {
left[m][n] = left[m][n-1] + 1;
} else {
left[m][n] = 1;
}
if (up[m-1][n] > 0) {
up[m][n] = up[m-1][n] + 1;
} else {
up[m][n] = 1;
}
代码如下:
public int maxSquare2(int[][] matrix) {
if (matrix == null || matrix.length == 0 || matrix[0].length == 0) {
return 0;
}
int n = matrix.length;
int m = matrix[0].length;
int[][] dp = new int[n][m];
int[][] left = new int[n][m];
int[][] up = new int[n][m];
// initialization
for (int i = 0; i < n; i++) {
left[i][0] = (matrix[i][0] == 0) ? 1 : 0;
dp[i][0] = (matrix[i][0] == 1) ? 1 : 0;
}
for (int i = 0; i < m; i++) {
up[0][i] = (matrix[0][i] == 0) ? 1 : 0;
dp[0][i] = (matrix[0][i] == 1) ? 1 : 0;
}
// result
int result = dp[0][0];
// state function
for (int i = 1; i < n; i++) {
for (int j = 1; j < m; j++) {
if (matrix[i][j] == 1) {
dp[i][j] = Math.min(Math.min(dp[i-1][j-1], left[i][j-1]), up[i-1][j]) + 1;
left[i][j] = 0;
up[i][j] = 0;
} else {
dp[i][j] = 0;
if (left[i][j-1] > 0) {
left[i][j] = left[i][j-1] + 1;
} else {
left[i][j] = 1;
}
if (up[i-1][j] > 0) {
up[i][j] = up[i-1][j] + 1;
} else {
up[i][j] = 1;
}
}
result = Math.max(result, dp[i][j]);
}
}
return result * result;
}
76. Longest Increasing Subsequence
最长递增的子序列,这道题让我们求最长递增子串Longest Increasing Subsequence的长度,简称LIS的长度。
我们用dp[i]表示以坐标i为结尾的子串的最大长度。我们维护一个一维dp数组,其中dp[i]表示以nums[i]为结尾的最长递增子串的长度,对于每一个nums[i],我们从第一个数再搜索到i,如果发现某个数小于nums[i],我们更新dp[i],更新方法为dp[i] = max(dp[i], dp[j] + 1),即比较当前dp[i]的值和那个小于num[i]的数的dp值加1的大小,我们就这样不断的更新dp数组,到最后dp数组中最大的值就是我们要返回的LIS的长度。那么dp[i] = max(dp[i], dp[j] + 1).
public int longestIncreasingSubsequence(int[] nums) {
// state
int[] dp = new int [nums.length];
// intialize
for (int i = 0; i < nums.length; i++) {
dp[i] = 1;
}
// dp function
for (int i = 0; i < nums.length; i++) {
for (int j = 0; j < i; j++) {
if (nums[j] < nums[i]) {
dp[i] = Math.max(dp[j] + 1, dp[i]);
}
}
}
// result
int res = 0;
for (int i = 0; i < nums.length; i++) {
res = Math.max(res, dp[i]);
}
return res;
}
2 序列型动态规划题:
state: f[i]表示前i个位置/数字/字符, 第i个...
function: f[i] = f[j]... j是i之前的一个位置
initialize: f[0]...
answer: f[n]...
一般answer是f(n)而不是f(n - 1) 因为对于n个最富,包含前0个字符(空串),前1个字符...前n个字符。
注意: 如果不是跟坐标相关的动态规划,一般有n个数/字符,就开n+1个位置的数组。 第0个位置单独留出来作初始化
392. House Robber
有一条街上有一些商店,每个商店里都有一定数量的钱,让你去抢劫,但是你不能抢相邻的两个店,比如你不能抢第1、2个店,但是你可以抢第1、3个店或者第1、4个店。反正就是店不能挨着。然后问你从街头到尾一路抢过去最多能抢多少钱。商店和钱是以数组的形式给出来的。比如给定数组[3, 8, 4] 问你最多能抢多少钱。其实想出动规方程就很简单了。dp[i]代表前i个商店最多能抢多少钱。dp[i] = MAX(dp[i-1], dp[i-2] + A[i])。
public long withoutRollingArray(int[] A) {
if (A == null || A.length == 0) {
return 0;
}
if (A.length == 1) {
return A[0];
}
long[] f = new long[A.length + 1];
f[0] = 0;
f[1] = A[0];
for (int i = 2; i < f.length; i++) {
f[i] = Math.max(f[i - 2] + A[i - 1], f[i - 1]);
}
return f[f.length - 1];
}
仔细观察下,这道题能不能把空间复杂度优化一下呢,我们用到了一个长度N的数组去存储各个状态。但是其实我们并不需要把每个状态都存下来。所以我们可以用滚动数组rolling array去优化。数组的长度只需要为2就行。这样空间复杂度就优化到了O(1):
public long withRollingArray(int[] A) {
// corner cases
if (A == null || A.length == 0) {
return 0;
}
if (A.length == 1) {
return A[0];
}
// state
int n = A.length;
long[] f = new long[2];
// initialization
f[0] = 0;
f[1] = A[0];
// function
for (int i = 2; i <= n; i++) {
f[i%2] = Math.max(f[(i-2)%2]+A[i-1], f[(i-1)%2]);
}
// result
return f[n%2];
}
是上一道题的变种,一条直线的街道变成了环形街道,第一个店铺跟最后一个店铺相邻了。其他规则不变。可以考虑把环形街道拆分成两个数组,一个数组去掉第一个店铺,一个数组去掉最后一个店铺。分别对这2个数组应用上一题的代码,然后取2个结果的较大值就可以了:
public int houseRobber1(int[] nums) {
if (nums == null || nums.length <= 0) {
return 0;
}
if (nums.length == 1) {
return nums[0];
}
int size = nums.length;
int[] f = new int[size];
f[0] = nums[0];
f[1] = Math.max(nums[0], nums[1]);
for (int i = 2; i < size; i++) {
f[i] = Math.max(f[i-1], f[i-2]+nums[i]);
}
return Math.max(f[size-1], f[size-2]);
}
public int houseRobber2(int[] nums) {
if (nums == null || nums.length <= 0) {
return 0;
}
if (nums.length == 1) {
return nums[0];
}
int size = nums.length;
int[] nums1 = new int[size - 1];
int[] nums2 = new int[size - 1];
for (int i = 0; i < size - 1; i++) {
nums1[i] = nums[i];
}
for (int i = 0; i < size - 1; i++) {
nums2[i] = nums[i + 1];
}
return Math.max(houseRobber1(nums1), houseRobber1(nums2));
}
而House Robber III这道题也是变种,不过是一棵树了,而且方法是DFS,所以就不在DP这里面描述了。
394. Coins in a Line
两个人轮流从同一边拿硬币,一次能拿1个或者2个,谁能拿到最后一个谁就是王者。问你第一个人能不能是王者。这是一道典型的博弈问题。
state:
dp[i] 表示现在还剩下i个硬币,前者最后输赢状况
function:
dp[n] = (!dp[n - 1]) || (!dp[n - 2])
intialize:
dp[0] = false;
dp[1] = true;
dp[2] = true;
answer:
dp[n]
不难写出如下代码并AC:
public boolean dp(int n) {
if (n == 0) {
return false;
}
if (n <= 2) {
return true;
}
boolean[] dp = new boolean[n + 1];
dp[1] = dp[2] = true;
for (int i = 3; i <= n; i++) {
dp[i] = (!dp[i-1] || !dp[i-2]);
}
return dp[n];
}
仔细观察下,其实我们可以用滚动数组来优化空间复杂度:
public boolean dp_rollingArray(int n) {
if (n == 0 || n == 3) {
return false;
}
if (n <= 2) {
return true;
}
boolean[] dp = new boolean[3];
dp[0] = dp[1] = true;
for (int i = 3; i < n; i++) {
dp[(i-1)%3] = (!dp[(i-2)%3] || !dp[(i-3)%3]);
}
return dp[(n-1)%3];
}
再仔细观察下,发现其实是有规律的,可以用时空复杂度均为O(1)的贪心算法:
public boolean greedy(int n) {
if (n % 3 == 0) {
return false;
}
return true;
}
395. Coins in a Line II
还是两个人轮流从同一边拿硬币,也是一次能拿2个或者1个,谁拿的硬币的总价值最高谁就是王者。问第一个拿硬币的人能否成王。
state:
DP[i]表示从i到end能取到的最大value
function:
当我们走到i时,有两种选择
取values[i]
取values[i] + values[i+1]
1. 我们取了values[i],对手的选择有 values[i+1]或者values[i+1] + values[i+2] 剩下的最大总value分别为DP[i+2]或DP[i+3],
对手也是理性的所以要让我们得到最小value,
所以 value1 = values[i] + min(DP[i+2], DP[i+3])
2. 我们取了values[i]和values[i+1] 同理 value2 = values[i] + values[i+1] + min(DP[i+3], DP[i+4])
最后
DP[I] = max(value1, value2)
public boolean firstWillWin(int[] values) {
int n = values.length;
if (n <= 2) {
return true;
}
if (n == 3) {
return (values[0] + values[1]) > values[2];
}
int[] dp = new int[n];
// initialization
dp[n - 1] = values[n - 1];
dp[n - 2] = values[n - 2] + values[n - 1];
dp[n - 3] = values[n - 3] + values[n - 2];
dp[n - 4] = values[n - 4] + Math.min(dp[n - 2], dp[n - 1]);
dp[n - 4] = Math.max(dp[n - 4], values[n - 4] + values[n - 3]);
// dp function
for (int i = n - 5; i >= 0; i--) {
dp[i] = values[i] + Math.min(dp[i + 2], dp[i + 3]);
dp[i] = Math.max(dp[i], values[i] + values[i + 1] + Math.min(dp[i + 3], dp[i + 4]));
}
int sum = 0;
for (int i = 0; i < n; i++) {
sum += values[i];
}
// result
return dp[0] > (sum - dp[0]);
}
396. Coins in a Line III
还是两个人轮流从拿硬币,也是一次能拿2个或者1个,谁拿的硬币的总价值最高谁就是王者。但是条件改了,两个人不一定要在同一边拿,可以在选择任意边来拿硬币(可以是左边也可以是右边)
state:
dp[i][j] 现在还有第i个到第j个硬币,现在先手取得硬币的最高价值
function:
pick_left = min(dp[i + 2][j], dp[i + 1][j - 1]) + coin[i];
pick_right = min(dp[i][j - 2], dp[i + 1][j - 1]) + coin[j];
dp[i][j] = max(pick_left, pick_right);
intialize:
dp[i][i] = coin[i];
dp[i][i + 1] = max(coin[i], coin[i + 1]);
answer:
dp[0][n - 1]
public boolean firstWillWin(int[] values) {
int n = values.length;
if (n <= 2) {
return true;
}
int[][] dp = new int[n][n];
boolean[][] visit = new boolean[n][n];
int res = memorySearch(0, n - 1, dp, visit, values);
int sum = 0;
for (int i = 0; i < values.length; i++) {
sum += values[i];
}
return res > (sum - res);
}
public int memorySearch(int l, int r, int[][] dp, boolean[][] visit, int[] coin) {
if (l < 0 || l >= coin.length || r < 0 || r >= coin.length) {
return 0;
}
if (visit[l][r]) {
return dp[l][r];
}
visit[l][r] = true;
if (l > r) {
dp[l][r] = 0;
} else if (l == r) {
dp[l][r] = coin[l];
}else {
int pick_left = coin[l] + Math.min(memorySearch(l+1, r-1, dp, visit, coin), memorySearch(l+2, r, dp, visit, coin));
int pick_right = coin[r] + Math.min(memorySearch(l+1, r-1, dp, visit, coin), memorySearch(l, r-2, dp, visit, coin));
dp[l][r] = Math.max(pick_left, pick_right);
}
return dp[l][r];
}
3 双序列动态规划题:
state:
f[i][j]代表了第一个sequence的前i个数字/字符,配上第二个sequence的前j个...
function:
f[i][j] = 研究第i个和第j个的匹配关系
initialize:
f[i][0] 和f[0][i]
answer:
f[n][m]
n = s1.length(); m = s2.length()
77. Longest Common Subsequence
LCS经典问题
state:f[i][j] 表示第一个字符串的前i个字符配上第二个字符串的前j个字符的LCS长度
function:注意研究的是第i个字符和第j个字符的关系 a[i - 1] 与 b[j - 1]的关系
if (a[i - 1] == b[j - 1]) {
f[i][j] = f[i - 1][j - 1] + 1;
} else {
f[i][j] = Math.max(f[i - 1][j], f[i][j - 1]);
}
initialize:f[i][0] = 0; f[0][j] = 0;
answer:f[n][m];
public int longestCommonSubsequence(String A, String B) {
int n = A.length();
int m = B.length();
// 状态
int[][] f = new int[n + 1][m + 1];
// 初始化
for (int i = 0; i <= n; i++) {
f[i][0] = 0;
}
for (int i = 0; i <= m; i++) {
f[0][i] = 0;
}
// 方程
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= m; j++) {
f[i][j] = Math.max(f[i - 1][j], f[i][j - 1]);
if (A.charAt(i - 1) == B.charAt(j - 1)) {
f[i][j] = Math.max(f[i][j], f[i - 1][j - 1] + 1);
}
}
}
// 结果
return f[n][m];
}
f[i][j] 表示第一个字符串的前i个字符配上第二个字符串的前j个字符的LCS长度,是上道题的变种,注意状态转移方程的公式稍微做了变化。
public int longestCommonSubstring(String A, String B) {
int n = A.length();
int m = B.length();
// 状态
int[][] f = new int[n + 1][m + 1];
// 初始化
for (int i = 0; i <= n; i++) {
f[i][0] = 0;
}
for (int i = 0; i <= m; i++) {
f[0][i] = 0;
}
// 方程
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= m; j++) {
if (A.charAt(i - 1) == B.charAt(j - 1)) {
f[i][j] = f[i - 1][j - 1] + 1;
} else {
f[i][j] = 0;
}
}
}
int max = 0;
for (int i = 0; i <= n; i++) {
for (int j = 0; j <= m; j++) {
if (f[i][j] > max) {
max = f[i][j];
}
}
}
// 结果
return max;
}
581. Longest Repeating Subsequence
是77那道题的变种,geeksforgeeks上有描述:http://www.geeksforgeeks.org/longest-repeating-subsequence/
public int longestRepeatingSubsequence(String str) {
int n = str.length();
// state
int[][] dp = new int[n + 1][n + 1];
// initialize
for (int i = 0; i < n; i++) {
dp[i][0] = 0;
dp[0][i] = 0;
}
// dp function
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= n; j++) {
if (str.charAt(i - 1) == str.charAt(j - 1) && i != j) {
dp[i][j] = dp[i - 1][j - 1] + 1;
} else {
dp[i][j] = Math.max(dp[i-1][j], dp[i][j-1]);
}
}
}
// result
return dp[n][n];
}
119. Edit Distance
给定单词A和B,其中一个单词可以通过给定的3种操作(插入一个字符,删除一个字符,替换一个字符)变换到另一个单词。完成这样的转换所需要的最少步骤是多少。
state:
f[i][j]表示A的前i个字符最少要用几次编辑可以变成B的前j个字符
function:
f[i][j] = f[i-1][j-1] // A[i - 1] == B[j - 1]
= MIN(f[i-1][j]+1, f[i][j-1]+1, f[i-1][j-1]+1) // A[i - 1] != B[j - 1]
initialize:
f[i][0] = i, f[0][j] = j
answer:
f[n][m]
public int minDistance(String A, String B) {
int n = A.length();
int m = B.length();
// 状态
int[][] f = new int[n + 1][m + 1];
// 初始化
for (int i = 0; i <= n; i++) {
f[i][0] = i;
}
for (int i = 0; i <= m; i++) {
f[0][i] = i;
}
// 方程
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= m; j++) {
if (A.charAt(i - 1) == B.charAt(j - 1)) {
f[i][j] = f[i - 1][j - 1];
} else {
f[i][j] = 1 + Math.min(f[i-1][j-1], Math.min(f[i][j-1], f[i-1][j]));
}
}
}
// 结果
return f[n][m];
}
623. K Edit Distance
给定一个字符串数组和一个目标字符串,对每个字符串可以进行3种类型的操作(插入一个字符,删除一个字符,替换一个字符)使之变成target。然后再给你一个k,代表只能通过k次操作转换,把字符串数组里能通过k此变换变成target的字符串找出来并且输出。比如Given words = ["abc", "abd", "abcd", "adc"] and target = "ac", k = 1 Return ["abc", "adc"]
这道题的思路得结合trie树和动态规划:
class TrieNode {
// Initialize your data structure here.
public TrieNode[] children;
public boolean hasWord;
public String str;
// Initialize your data structure here.
public TrieNode() {
children = new TrieNode[26];
for (int i = 0; i < 26; ++i)
children[i] = null;
hasWord = false;
}
// Adds a word into the data structure.
static public void addWord(TrieNode root, String word) {
TrieNode now = root;
for(int i = 0; i < word.length(); i++) {
Character c = word.charAt(i);
if (now.children[c - 'a'] == null) {
now.children[c - 'a'] = new TrieNode();
}
now = now.children[c - 'a'];
}
now.str = word;
now.hasWord = true;
}
}
public class Solution {
/**
* @param words a set of stirngs
* @param target a target string
* @param k an integer
* @return output all the strings that meet the requirements
*/
public List kDistance(String[] words, String target, int k) {
// Write your code here
TrieNode root = new TrieNode();
for (int i = 0; i < words.length; i++)
TrieNode.addWord(root, words[i]);
List result = new ArrayList();
int n = target.length();
int[] dp = new int[n + 1];
for (int i = 0; i <= n; ++i)
dp[i] = i;
find(root, result, k, target, dp);
return result;
}
public void find(TrieNode node, List result, int k, String target, int[] dp) {
int n = target.length();
// dp[i] 表示从Trie的root节点走到当前node节点,形成的Prefix
// 和 target的前i个字符的最小编辑距离
if (node.hasWord && dp[n] <= k) {
result.add(node.str);
}
int[] next = new int[n + 1];
for (int i = 0; i <= n; ++i)
next[i] = 0;
for (int i = 0; i < 26; ++i)
if (node.children[i] != null) {
next[0] = dp[0] + 1;
for (int j = 1; j <= n; j++) {
if (target.charAt(j - 1) - 'a' == i) {
next[j] = Math.min(dp[j - 1], Math.min(next[j - 1] + 1, dp[j] + 1));
} else {
next[j] = Math.min(dp[j - 1] + 1, Math.min(next[j - 1] + 1, dp[j] + 1));
}
}
find(node.children[i], result, k, target, next);
}
}
}
4 划分型动态规划
在一个大的区间内找一个小的区间 划分类的题目,基本思路都是用一个local数组和一个gobal数组,然后进行遍历。 之所以可以用变量来代替数组,是因为做了滚动数组的优化.
5 区间类DP
特点:
1. 求一段区间的解max/min/count
2. 转移方程通过区间更新
3. 从大到小的更新
476. Stone Game
有一个石头数组,每个元素代表每个石头的重量。每次可以合并相邻的2个石头,合并后的总重量就这次合并操作的分数。合并一只进行,知道所有石头都合并成了1个石头堆。求最小的合并分数之和。比如[4, 1, 1, 4], in the best solution, the total score is 18:
1. Merge second and third piles => [4, 2, 4], score +2
2. Merge the first two piles => [6, 4],score +6
3. Merge the last two piles => [10], score +10
如果我们模拟这种操作,很容易会想到brute force的方法。我们得换种思路,从结果往出发点倒推来看。最后得到的元素是10,那么10最后一步可能是从这么几种可能组合变来的:[4,1 | 1,4] 或者 [4, | 1,1,4] 或者 [4,1,1 | 4],假如我们用dp[i][j]表示从下标i到j能获得的最小的分数。那么dp[i][j] = min(dp[i][k]+dp[k+1][j]+sum[i,j]) 对于所有k属于{i,j}
State:
dp[i][j] 表示把第i到第j个石子合并到一起的最小花费
Function:
预处理sum[i,j] 表示i到j所有石子价值和
dp[i][j] = min(dp[i][k]+dp[k+1][j]+sum[i,j]) 对于所有k属于{i,j}
Intialize:
for each i
dp[i][i] = 0
Answer:
dp[0][n-1]
public int stoneGame(int[] A) {
if (A == null || A.length == 0) {
return 0;
}
int m = A.length;
// state
int[][] sum = new int[m][m];
int[][] dp = new int[m][m];
boolean[][] visit = new boolean[m][m];
// initialize sum array
for (int i = 0; i < m; i++) {
sum[i][i] = A[i];
dp[i][i] = 0;
visit[i][i] = true;
for (int j = i + 1; j < m; j++) {
sum[i][j] = sum[i][j - 1] + A[j];
}
}
// dp function
for (int i = 0; i < m; i++) {
for (int j = 0; j < m; j++) {
dp[i][j] = memoryDP(dp, visit, sum, i, j);
}
}
// result
return dp[0][m - 1];
}
public int memoryDP(int[][] dp, boolean[][] visit, int[][] sum, int i, int j) {
if (visit[i][j]) {
return dp[i][j];
}
dp[i][j] = Integer.MAX_VALUE;
for (int k = i; k < j; k++) {
int tmp = memoryDP(dp, visit, sum, i, k) + memoryDP(dp, visit, sum, k + 1, j) + sum[i][j];
dp[i][j] = Math.min(tmp, dp[i][j]);
}
visit[i][j] = true;
return dp[i][j];
}
593. Stone Game II
是上道题的变种,变化是数组首尾元素是可以相邻的。For [1, 4, 4, 1], in the best solution, the total score is 18:
1. Merge 1st and 4th piles => [2, 4, 4], score +2
2. Merge the first two piles => [6, 4],score +6
3. Merge the last two piles => [10], score +10
那么我们就把数组长度翻翻,把所有的再复制一份,放在最后一个石子的后面,这样就可以“连”着了。只不过结果不再是dp[0][m-1]了,结果是{dp[0][m-1], dp[1][m], ... , dp[m-1][2m-2]}的最小值:
public int stoneGame2(int[] A) {
if (A == null || A.length == 0) {
return 0;
}
int m = A.length;
// state
int[][] sum = new int[m * 2][m * 2];
int[][] dp = new int[m * 2][m * 2];
boolean[][] visit = new boolean[m * 2][m * 2];
// initialize sum array
for (int i = 0; i < m * 2; i++) {
sum[i][i] = A[i % m];
dp[i][i] = 0;
visit[i][i] = true;
for (int j = i + 1; j < m * 2; j++) {
sum[i][j] = sum[i][j - 1] + A[j % m];
}
}
// dp function
for (int i = 0; i < m * 2; i++) {
for (int j = 0; j < m * 2; j++) {
dp[i][j] = memoryDP(dp, visit, sum, i, j);
}
}
// result
int ans = Integer.MAX_VALUE;
for (int i = 0; i < m; i++) {
ans = Math.min(ans, dp[i][i + m - 1]);
}
return ans;
}
public int memoryDP(int[][] dp, boolean[][] visit, int[][] sum, int i, int j) {
if (visit[i][j]) {
return dp[i][j];
}
dp[i][j] = Integer.MAX_VALUE;
for (int k = i; k < j; k++) {
int tmp = memoryDP(dp, visit, sum, i, k) + memoryDP(dp, visit, sum, k + 1, j) + sum[i][j];
dp[i][j] = Math.min(tmp, dp[i][j]);
}
visit[i][j] = true;
return dp[i][j];
}
打气球,规则如下:
nums = [4, 1, 5, 10] burst 1, get coins 4 * 1 * 5 = 20
nums = [4, 5, 10] burst 5, get coins 4 * 5 * 10 = 200
nums = [4, 10] burst 4, get coins 1 * 4 * 10 = 40
nums = [10] burst 10, get coins 1 * 10 * 1 = 10
Total coins 20 + 200 + 40 + 10 = 270
同样的这道题我们要用逆向思维处理,从结果往开始倒推。
State:
dp[i][j] 表示把第i到第j个气球打爆的最大价值
Function:
对于所有k属于{i,j}, 表示第k号气球最后打爆。
midValue = arr[i- 1] * arr[k] * arr[j+ 1];
dp[i][j] = max(dp[i][k-1]+dp[k+1][j]+midvalue)
Intialize:
for each i
dp[i][i] = 0
Answer:
dp[0][n-1]
枚举最后一个打破的气球是哪个。深刻理解,从最后剩下一个气球还是递归,分别求左右两边。对于边界情况的处理,新开了一个数组,两边分别放了一个1
public int maxCoins(int[] nums) {
if (nums == null || nums.length == 0) {
return 0;
}
int n = nums.length + 2;
// state
int[][] dp = new int[n][n];
boolean[][] visit = new boolean[n][n];
int[] arr = new int[n];
// initialize
arr[0] = arr[n - 1] = 1;
for (int i = 1; i < n - 1; i++) {
arr[i] = nums[i - 1];
}
// dp function
for (int i = 1; i < n - 1; i++) {
for (int j = 1; j < n - 1; j++) {
dp[i][j] = memorySearch(dp, visit, arr, i, j);
}
}
return dp[1][n - 2];
}
public int memorySearch(int[][] dp, boolean[][] visit, int[] arr, int i, int j) {
if (visit[i][j]) {
return dp[i][j];
}
int res = 1;
for (int k = i; k <= j; k++) {
int left = memorySearch(dp, visit, arr, i, k - 1);
int right = memorySearch(dp, visit, arr, k + 1, j);
int mid = arr[k] * arr[i - 1] * arr[j + 1];
dp[i][j] = Math.max(dp[i][j], mid + left + right);
}
visit[i][j] = true;
return dp[i][j];
}
430. Scramble String
我们提出维护量res[i][j][n],其中i是s1的起始字符,j是s2的起始字符,而n是当前的字符串长度,res[i][j][len]表示的是以i和j分别为s1和s2起点的长度为len的字符串是不是互为scramble。 有了维护量我们接下来看看递推式,也就是怎么根据历史信息来得到res[i][j][len]。判断这个是不是满足,其实我们首先是把当前s1[i...i+len-1]字符串劈一刀分成两部分,然后分两种情况:第一种是左边和s2[j...j+len-1]左边部分是不是scramble,以及右边和s2[j...j+len-1]右边部分是不是scramble;第二种情况是左边和s2[j...j+len-1]右边部分是不是scramble,以及右边和s2[j...j+len-1]左边部分是不是scramble。如果以上两种情况有一种成立,说明s1[i...i+len-1]和s2[j...j+len-1]是scramble的。而对于判断这些左右部分是不是scramble我们是有历史信息的,因为长度小于n的所有情况我们都在前面求解过了(也就是长度是最外层循环)。 上面说的是劈一刀的情况,对于s1[i...i+len-1]我们有len-1种劈法,在这些劈法中只要有一种成立,那么两个串就是scramble的。 总结起来递推式是res[i][j][len] = || (res[i][j][k]&&res[i+k][j+k][len-k] || res[i][j+len-k][k]&&res[i+k][j][len-k]) 对于所有1<=k 如此总时间复杂度因为是三维动态规划,需要三层循环,加上每一步需要线行时间求解递推式,所以是O(n^4)。虽然已经比较高了,但是至少不是指数量级的,动态规划还是相当有用的,空间复杂度是O(n^3)。注意处理for循环的边界条件,代码如下:
public boolean isScramble(String s1, String s2) {
if (s1 == null || s2 == null || s1.length() != s2.length()) {
return false;
}
int n = s1.length();
// state
boolean[][][] dp = new boolean[n][n][n + 1];
// initialize
for (int i = 0; i < n; i++) {
for (int j = 0; j < n; j++) {
dp[i][j][1] = s1.charAt(i) == s2.charAt(j);
}
}
// dp function
for (int len = 2; len <= n; len++) {
for (int i = 0; i < n && i + len <= n; i++) {
for (int j = 0; j < n && j + len <= n; j++) {
for (int k = 1; k < len; k++) {
dp[i][j][len] = dp[i][j][len] ||
dp[i][j][k] && dp[i+k][j+k][len-k]
|| dp[i][j+len-k][k] && dp[i+k][j][len-k];
}
}
}
}
// result
return dp[0][0][n];
}
6 记忆化搜索题:
我们常见的动态规划问题,比如流水线调度问题,矩阵链乘问题等等都是“一步接着一步解决的”,即规模为 i 的问题需要基于规模 i-1 的问题进行最优解选择,通常的递归模式为DP(i)=optimal{DP(i-1)}。而记忆化搜索本质上也是DP思想,当子问题A和子问题B存在子子问题C时,如果子子问题C的最优解已经被求出,那么子问题A或者是B只需要“查表”获得C的解,而不需要再算一遍C。记忆化搜索的DP模式比普通模式要“随意一些”,通常为DP(i)=optimal(DP(j)), j < i。
397. Longest Increasing Continuous Subsequence
在一个数组中找到最长递增子序列的长度,子序列必须是连续的。方法就是从左往右扫一遍,从右往左也扫一遍。然后扫描的时候更新最大值就好。
public int longestIncreasingContinuousSubsequence(int[] A) {
if (A == null || A.length == 0) {
return 0;
}
int n = A.length;
int ans = 1;
int length = 1;
for (int i = 1; i < n; i++) {
if (A[i] > A[i-1]) {
length++;
} else {
length = 1;
}
ans = Math.max(ans, length);
}
length = 1;
for (int i = n - 2; i >= 0; i--) {
if (A[i] > A[i+1]) {
length++;
} else {
length = 1;
}
ans = Math.max(ans, length);
}
return ans;
}
398. Longest Increasing Continuous subsequence II
在一个矩阵里找到最长递增的子序列有多长。比如给定如下矩阵的话,就返回25:
[
[1 ,2 ,3 ,4 ,5],
[16,17,24,23,6],
[15,18,25,22,7],
[14,19,20,21,8],
[13,12,11,10,9]
]
这道题可以用记忆化搜索来解决。
state:
dp[x][y] 以x,y作为结尾的最长子序列
function:
遍历x,y上下左右4个格子
dp[x][y] = dp[nx][ny] + 1 (if a[x][y] > a[nx][ny]);
intialize:
dp[x][y]是极小值时,初始化为1 //表示以xy作为结尾的最长子序列至少是有1个!
answer:
dp[x][y]中的最大值
这种做法保证了以每个点作为最长子序列的结尾的情况,只会遍历一次。对于已经遍历过的点dp[x][y] 一定是最优解!
这道题的另外一个马甲是滑雪题,在二维数组上,只能从大的数往小的数滑,问最长滑多远。
public int longestIncreasingContinuousSubsequenceII(int[][] A) {
if (A.length == 0) {
return 0;
}
int n = A.length;
int m = A[0].length;
int ans = 0;
int[][] dp = new int[n][m];
int[][] flag = new int[n][m];
for (int i = 0; i < n; i++) {
for (int j = 0; j < m; j++) {
dp[i][j] = search(i, j, A, dp, flag);
ans = Math.max(ans, dp[i][j]);
}
}
return ans;
}
public int search(int x, int y, int[][] A, int[][] dp, int[][] flag) {
if (flag[x][y] != 0) {
return dp[x][y];
}
int []dx = {1, -1, 0, 0};
int []dy = {0, 0, 1, -1};
int ans = 1;
for (int i = 0; i < 4; i++) {
int nx = x + dx[i];
int ny = y + dy[i];
int n = A.length;
int m = A[0].length;
if (0 <= nx && nx < n && 0 <= ny && ny < m) {
if (A[x][y] > A[nx][ny]) {
ans = Math.max(ans, search(nx, ny, A, dp, flag) + 1);
}
}
}
flag[x][y] = 1;
dp[x][y] = ans;
return ans;
}
在一个字符串中找到最长的回文子串,这道题可能有2种变型,一种是让你求这个最长回文子串的长度,另外一种是求出这个子串是啥。这道题是要求求出子串的string。
可以用“中出法”解决这道题,从每个点i往两边扩散,如果arr[i-1] == arr[i+1],那么回文子串的长度就扩大了,一直扩散直到不能扩散为止。对每个点都进行这样的操作,中途用变量记录更新最大值就好。然后记得注意处理字符串长度为偶数的情况,时间复杂度是O(n*n),空间复杂度O(1):
public String longestPalindrome(String s) {
return middleOut(s);
}
public String middleOut(String s) {
if (s == null || s.length() <= 0) {
return null;
}
String res = "";
for (int i = 0; i < s.length(); i++) {
String tmp = searchPalindrome(s, i, i);
res = tmp.length() > res.length() ? tmp : res;
if (i - 1 >= 0 && s.charAt(i-1) == s.charAt(i)) {
tmp = searchPalindrome(s, i - 1, i);
res = tmp.length() > res.length() ? tmp : res;
}
}
return res;
}
public String searchPalindrome(String s, int l, int r) {
while (l >= 0 && r < s.length()) {
int nextL = l - 1, nextR = r + 1;
if (nextL >= 0 && nextR < s.length() && s.charAt(nextL) == s.charAt(nextR)) {
l--;
r++;
} else {
break;
}
}
return s.substring(l, r + 1);
}
DP的方法请参见博文: http://www.cnblogs.com/grandyang/p/4464476.html
LeetCode上有一道很类似的题目,只不过是子序列而不是子串了
LeetCode 516. Longest Palindromic Subsequence
这道题给了我们一个字符串,让我们求最大的回文子序列,子序列和子字符串不同,不需要连续。而关于回文串的题之前也做了不少,处理方法上就是老老实实的两两比较吧。像这种有关极值的问题,最应该优先考虑的就是贪婪算法和动态规划,这道题显然使用DP更加合适。我们建立一个二维的DP数组,其中dp[i][j]表示[i,j]区间内的字符串的最长回文子序列,那么对于递推公式我们分析一下,如果s[i]==s[j],那么i和j就可以增加2个回文串的长度,我们知道中间dp[i + 1][j - 1]的值,那么其加上2就是dp[i][j]的值。如果s[i] != s[j],那么我们可以去掉i或j其中的一个字符,然后比较两种情况下所剩的字符串谁dp值大,就赋给dp[i][j],那么递推公式如下:
/ dp[i + 1][j - 1] + 2 if (s[i] == s[j])
dp[i][j] =
\ max(dp[i + 1][j], dp[i][j - 1]) if (s[i] != s[j])
我们也可以用记忆化搜索的方式来求得dp方程的值,时空复杂度都是O(n*n):
public int longestPalindromeSubseq(String s) {
return dpMethod(s);
}
public int dpMethod(String s) {
int n = s.length();
// state
int[][] dp = new int[n][n];
boolean[][] visit = new boolean[n][n];
// initialize
for (int i = 0; i < n; i++) {
dp[i][i] = 1;
visit[i][i] = true;
}
// memorized search
for (int i = 0; i < n; i++) {
for (int j = 0; j < n; j++) {
memorySearch(s, dp, visit, i, j);
}
}
// result
return dp[0][n - 1];
}
public int memorySearch(String s, int[][] dp, boolean[][] visit, int i, int j) {
if (i < 0 || i > s.length() || j < 0 || j > s.length()) {
return 0;
}
if (visit[i][j]) {
return dp[i][j];
}
if (i > j) {
dp[i][j] = 0;
return 0;
}
if (s.charAt(i) == s.charAt(j)) {
dp[i][j] = memorySearch(s, dp, visit, i+1, j-1) + 2;
} else {
dp[i][j] = Math.max(memorySearch(s, dp, visit, i, j-1), memorySearch(s, dp, visit, i+1, j));
}
visit[i][j] = true;
return dp[i][j];
}
我们先通过一道题目来认识一下DP:
109. Triangle
有一个数字三角形,从上往下走,找到一条最短的从根到叶节点的路径,即各个节点上的数字加起来的和最小。比如:
[
[2],
[3,4],
[6,5,7],
[4,1,8,3]
]
那么最小的路径和就是2 + 3 + 5 + 1 = 11。
这道题有点类似于求二叉树的最小路径和,但是和二叉树有区别,因为二叉树每个节点只有2个子节点。递归的时候,子递归不会进入兄弟节点的后代。
如果这道题直接套用二叉树的那种递归思想,那我们会写出如下的DFS的代码:
但是这个代码超时了,时间复杂度是O(2^n)。
接下来再用divide & conquer分治法来看这道题:
然并卵,这个解法和DFS的时间复杂度一样,一样会TLE超时
以上方法超时的原因,就在于有些点被访问了不止一次,而是多次,所以我们要加入Hash表,来进行记忆化搜索。代码如下:
这样的代码就是可以AC的。一共有O(n*n)个节点,每个节点只被访问一次,所以总的时间复杂度是O(n*n)。这就是加了记忆化搜索的分治法。
而DP和分治法的区别就在于DP有脑子,去掉了重复的运算。动态规划快,就在于它去掉了重复运算。