面试DP考察范围
序列型30%
双序列型30%
坐标型15%
划分型10%
背包型10%
区间型5%
一.状态压缩
1.概念
就是使用某种方法,简明扼要地以最小代价来表示某种状态,通常是用一串01数字(二进制数)来表示各个点的状态。这就要求使用状态压缩的对象的点的状态必须只有两种,0或1;当然如果有三种状态用三进制来表示也未尝不可
2.适用条件
①解法需要保存一定的状态数据(表示一种状态的一个数据值),每个状态数据通常情况下是可以通过二进制来表示的。这就要求状态数据的每个单元只有两种状态,比如说棋盘上的格子,放棋子或者不放,或者是硬币的正反两面。这样用0或者1来表示状态数据的每个单元,而整个状态数据就是一个一串0和1组成的二进制数
②解法需要将状态数据实现为一个基本数据类型,比如int,long等,即所谓的状态压缩。状态压缩的目的一方面是缩小了数据存储的空间,另一方面是在状态对比和状态整体处理时能够提高效率。这样就要求状态数据中的单元个数不能太大,比如用int来表示一个状态的时候,状态的单元个数不能超过32(32位的机器),所以题目一般都是至少有一维的数据范围很小
3.模板(公平组合游戏)
//dfs + memo 模板(Eddie)
private boolean canIWin(int n) {
Boolean[] memo = new Boolean[n+1]; //设置memo
return dfs(n,memo); //call dfs
}
private boolean dfs(int n,Boolean[] memo) {
if (n < 0) return false; //index边界结束
if (memo[n] != null) return memo[n];//memo已有结束
boolean res = false; //initial res
for (int i = 1;i < 4; i++) //核心递推公式
if (n >= i) res |= !dfs(n: n - i,memo);
return memo[n] = res; //返回结果并保存到memo
}
4.总结
1.使用bitMask可以把状态O(1)的时间加入和查找,是非常快速的操作,一般被用在压缩visited or used array into int(long) for memorization
2.很多状态压缩题目难的不是压缩,而是本身的DP思维,模拟下一步如何操作,这就很考验基本功,因为这里用到的大部分写法是top-down的,就需要先能够写出DFS的版本,描述出是哪几个condition来描述当前的state
二.Game Theory
1.总结
1.dfs + memo或者dp iterative写法都可以解答,个人认为dfs方式比较适合这类型
2.prefixSum或者minimax都可以作为得到当前位置res的方式
3.时间复杂度在memo或者iterative下一般为O(n),因为每次的选择都是有限的,常见的有,从一头拿,从两头拿,从一头按各种规则拿
4.这类题目比较好分辨,一般为2人游戏,要注意不要陷入greedy的陷阱,每一次操作都需要minimal对手下一步的max收益
2.模板
//dfs + memo 模板(Eddie)
private boolean canIWin(int n) {
Boolean[] memo = new Boolean[n+1]; //设置memo
return dfs(n,memo); //call dfs
}
private boolean dfs(int n,Boolean[] memo) {
if (n < 0) return false; //index边界结束
if (memo[n] != null) return memo[n]; //memo已有结束
boolean res = false; //initial res
for (int i = 1;i < 4;i++) //核心递推公式
if (n >= 1) res |= !dfs(n:n-i,memo);
return memo[n] = res; //返回结果并保存到memo
}
三.背包问题
1. 0-1背包:物品每种只有一个
状态:目前数到第 i 个物品,目前背包里的剩余空间 w
选择:第 i 个物品装入背包增加钱,减少空间;第 i 个物品不装入背包,维持不变
dp[i][w]定义:对于前 i 个物品,当前背包的容量为 w,这种情况下可以装的最大价值是dp[i][w]
int dp[N+1][W+1]
dp[0][..] = 0
dp[..][0] = 0
for i in [1..N]:
for w in [1..W]:
dp[i][w] = max(
把物品 i 装进背包,
不把物品 i 装进背包
)
return dp[N][W]
for i in [1..N]:
for w in [1..W]:
dp[i][w] = max(
dp[i-1][w],
dp[i-1][w-wt[i-1]] + val[i-1]
)
return dp[N][W]
2.完全背包:物品数量无限
有一个背包,最大容量为amount,有一系列物品coins,每个物品的重量为coins[i],每个物品的数量无限。请问有多少种方法,能够把背包恰好装满?
dp[i][j] 的定义如下:若只使用前 i 个物品,当背包容量为 i 时,有dp[i][j]种方法可以装满背包
换句话说,题目的意思是:若只使用coins中的前 i 个硬币的面值,若想凑出金额 j,有dp[i][j]种凑法,最后的目标是 dp[N][amount]
如果你不把这第 i 个物品装入背包,也就是说你不使用 coins[i] 这个面值的硬币,那么凑出面额 j 的方法数 dp[i][j] 应该等于 dp[i-1][j] 应该等于 dp[i-1][j],继承之前的结果
如果你把这第 i 个物品装入了背包,也就是说你使用 coins[i] 这个面值的硬币,那么 dp[i][j] 应该等于 dp[i][j-coins[i-1]]
public int change(int amount,int[] coins) {
int[][] dp = new int[coins.length + 1][amount + 1];
for (int i = 0;i <= coins.length; i++)
dp[i][0] = 1;
for (int i = 1;i <= coins.length; i++) {
for (int j = 1;j <= amount; j++) {
dp[i][j] = dp[i - 1][j];
if (j - coins[i - 1] >= 0)
dp[i][j] += dp[i][j - coins[i - 1]];
}
}
return dp[coins.length][amount];
}
模板:i 是前 i 个物品,j 是剩余背包的容量,value是能够填满当前容量为 j 背包的组合方法数
public int change(int amount,int[] coins) {
int[][] dp = new int[coins.length + 1][amount + 1];
for (int i = 0; i <= coins.length; i++) dp[i][0] = 1;
for (int i = 1; i <= coins.length; i++)
for (int j = 1; j <= amount; j++) {
dp[i][j] = dp[i - 1][j];
if (j - coins[i - 1] >= 0) dp[i][j] += dp[i][j - coins[i - 1]];
}
return dp[coins.length][amount];
}
3.多重背包:每种物品本身数量有限
题目:有N种物品和一个容量为V的背包。第 i 种物品最多有 n[i] 件可用,每件费用是 c[i],价值是w[i]。求解将哪些物品装入背包可使这些物品的费用总和不超过背包容量,且价值总和最大
基本算法:这题目和完全背包问题很类似。基本的方程只需将完全背包问题的方程略微一改即可,因为对于第 i 种物品有 n[i] + 1种策略:取0件,取1件......取n[i]件。令f[i][v]表示前 i 种物品恰放入一个容量为 v 的背包的最大权值,则有状态转移方程:
f[i][v] = max{f[i-1][v-k*w[i]|0<=k<=n[i]}
复杂度:O(V*∑n[i])
模板:需要考虑每一种物品本身的数量有限
public boolean canPartition(int n,int[] prices,int[] weight,int[] amounts) {
boolean[][] dp = new boolean[prices.length + 1][n + 1];
for (int i = 1; i <= prices.length; i++) //i是前i类物品
for (int j = 1; j <= n; j++) { //j是背包空间
dp[i][j] = dp[i - 1][j];
for (k = 0;k <= amounts[i - 1]; k++) //k是第i类物品取几件
if (j - k * prices[i - 1] >= 0)
dp[i][j] = Math.max(dp[i][j], dp[i - 1][j - k * prices[i - 1]] + k * weight[i - 1]);
}
return dp[prices.length][n];
}
四. 坐标类
坐标类动态规划一般就是给你一个矩阵,然后状态数组的两个维度就分别代表矩阵两个方向的坐标。这类题目通常会包含矩阵matrix,矩形rectangle,路径path等关键词
dp[i][j] 表示的是坐标从起点走到了当前 (i,j) 时的状态
一般用来处理,pathSum(max,min),pathCount,是否能到达,maxArea等
注意和序列型动态规划区分,序列型为前 i 个位置里,第 i 个位置一定选的答案,比如LIS,前面的位置可以是跳过的subsequence,我们这里坐标型不可以跳跃
回忆一下DP的常规4步走
1.状态:status指的是dp数组的定义,最大步数,还是最大sum,最大面积等。还要考虑这个状态是否只有xy来确定,或者双坐标的话是否需要3,4维来保存当前状态
2.选择:也叫transition equation,指的是从当前状态到下一个状态有哪几个路可以走,比如常规的之后往右往下,或者对角线的走等等,(i + 1,j)(i,j + 1)
3.起点:初始化dp array,一般是(0,0)点起步,或者第一行第一列
4.终点:到达那里我们就结束开始取max,一般为(m - 1,n - 1)
优化?
1.二维空间压缩为一维,因为很多题目只能往下往右走,导致下一行的状态只能从当前行得到,和当前行头上的走过的其他行完全没有关系。空间从O(n^2)优化为O(n)
2.4维压缩为3维,如果4维的坐标之间有和的关系,可以通过算数减法压缩为3维
模板?
提供recursive写法和iterative写法,具体transition equation需要根据题目改变
//iterative
public int uniquePaths(int m,int n) {
int[][] dp = new int[m][n];
//初始化
for (int i = 0; i < m; i++) dp[i][0] = 1;
for (int j = 0; j < n; j++) dp[0][j] = 1;
//从第一行到最后一行遍历
for (int i = 1; i < m; i++)
for (int j = 1; j < n; j++)
dp[i][j] = dp[i - 1][j] + dp[i][j - 1];
//最后终点取结果
return dp[m - 1][n - 1];
}
public int uniquePaths(int m,int n) {
int[][] memo = new int[m][n];
return helper(m - 1,n - 1,memo);
}
public int helper(int i,int j,int[][] memo) {
//出界检查
if (i < 0 || j < 0) return 0;
//终点检查
if (i == 0 && j == 0) return 1;
//memo已经计算过,跳过
if (memo[i][j] > 0) return memo[i][j];
//对各种选择进行比较,选择最大的
return memo[i][j] = helper(i - 1,j,memo) + helper(i,j - 1,memo);
}
五.单序列
什么是单序列型动态规划?在基本动态规划中,题目给你一个(这里区别于双序列一般是给 2 个string)序列,这个序列中可以跳过某些元素(这里区别于坐标型dp之前的路径必须一步一步走)或者前/第 i 个
单序列举例:
1.一串数字 1234567
2.字符串 ‘GuChengSixSixSix’
3.一排楼梯,第一个台阶,第二个台阶
4.一排房子或者栅栏,让你偷或者刷油漆
5.隐含关系表示一个序列前后的关系,可以是时间日期等
状态是什么?选择是什么?
状态使用序列顺序 dp[i] 表示
1.第 i 个/前 i 个位置的答案 jump game
2.前 i 个位置里,第 i 个一定选择的答案(这里我们不管 0 ~ i - 1是如何拿到的,可以是跳过了其中一些元素)Longest Increasing subsequence
3.当 dp[i] 不足表示所有状态的时候,加入二维 dp 表示所有状态比如卖股票
六.双序列
双序列顾名思义就是给你两个序列放一块问你怎么做
一般有两个数组或者两个字符串,计算其匹配关系。双序列中常用二维数组表示状态转移关系,但往往可以使用滚动数组的方式对空间复杂度进行优化
state:dp[i][j]代表了第一个sequence的前 i 个数字/字符,配上第二个sequence的前 j 个数字/字符所能表示的状态,可以是longest common subsequence,也可以是minimal edit distance
常常和string在一起出现,number的情况也不少,一般为二维dp
总结
1.LCS很暴力很有用,建议iterative写法熟练掌握
2.一般情况下双序列类型的题目,用dfs找出转移方程式,画表格改写成iterative写法比较方便
3.一般dp定义为source的前 i 个字符和target的前 j 个字符
4.主要是找状态转移方程,初始条件一般可以举例得出
模板
public int longestCommonSubsequence(String text1,String text2) {
int N1 = text1.length(),N2 = text2.length();
int[][] dp = new int[N1 + 1][N2 + 1];
for (int i = 1; i <= N1; i++)
for (int j = 1; j <= N2; 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[i][j - 1],dp[i - 1][j]);
return dp[N1][N2];
}
七.区间类
区间类动态规划是线性动态规划的扩展,它在分阶段地划分问题时,与阶段中元素出现的顺序和由前一阶段的哪些元素合并而来有很大的关系
特点
1.合并:将两个或多个部分进行整合,当然也可以反过来
2.特征:能将问题分解为两两合并的形式
3.求解:对整个问题社最优值,枚举合并点,将问题分解为左右两个部分,最后合并两个部分的最优值得到原问题的最优值
模板
public int maxCoins(int[] nums) {
int N = nums.length;
Integer[][] memo = new Integer[N][N];
return dfs(nums,0,N - 1,memo);
}
public int dfs(int[] nums,int start,int end,Integer[][] memo) {
if (start > end) return 0;
if (memo[start][end] != null) return memo[start][end];
int max = Integer.MIN_VALUE;
for (int i = start; i <= end; i++) {
int left = dfs(nums, start, i - 1, memo);
int right = dfs(nums, i + 1; end, memo);
int cur = get(nums,i) * get(nums, start - 1) * get(nums, end + 1);
max = Math.max(max, left + right + cur);
}
return memo[start][end] = max;
}
public int get(int[] nums, int i) {
if (i == -1 ||i == nums.length) return 1;
return nums[i];
}
总结
1.dfs + memo会比较好处理类似问题,可以从两边互相逼近直到长度为1
2.我们在确定了区间的两个边界之后,暴力尝试不同的区间分割位置,然后recursive call去找切开后的两个部分自己的值,所有切割尝试取最大值
3.如果使用iterative写法建议面试举简单的例子填充二维DP
4.时间复杂度一般是N^3
class Solution {
public int maxCoins(int[] nums) {
int N = nums.length;
Integer[][] memo = new Integer[N][N];
return dfs(nums,0,N - 1,memo);
}
public int dfs(int[] nums, int start, int end, Integer[][] memo) {
if (start > end) return 0;
if (memo[start][end] != null) return memo[start][end];
int max = Integer.MIN_VALUE;
for (int i = start; i < end; i++) {
int cur = get(nums, i) * get(nums, start - 1) * get(nums, end + 1);
max = Math.max(max, left + right + cur);
}
return memo[start][end] = max;
}
八.杨辉三角
Integer[][] memo;
private int nCk(int n, int k) {
if (k == 0 || n == k) return 1;
if (memo[n][k] != null) return memo[n][k];
int res = nCk(n - 1, k - 1) + nCk(n - 1, k);
return memo[n][k] = res;
}
double[] dp = new double[100];
dp[0] = 1;
for (int i = 1; i < 100; i++) dp[i] = dp[i - 1] * i;
private double nCk(int a, int b) {
return dp[a] / dp[b] / dp[a - b];
}
九.打家劫舍
作为背包问题的扩展,或者是股票问题的简化,这类题目都有自己独特的故事背景,但是核心仍然是状态(当前房屋的位置)和选择(抢或不抢)
打家劫舍系列一般可以时间复杂度O(N),在处理Tree类型题目的时候也可以将重复子问题转化为正常的单次遍历,只需要去除掉call每个node时候的不同状态即可。我们重新定义了状态,重新寻找了状态转移,从逻辑上减少了无效的子问题个数,从而提高了算法的效率