斐波那契数列
- 爬楼梯
题目描述:有 N 阶楼梯,每次可以上一阶或者两阶,求有多少种上楼梯的方法。
定义一个数组 dp 存储上楼梯的方法数(为了方便讨论,数组下标从 1 开始),dp[i] 表示走到第 i 个楼梯的方法数目。
可以只用两个变量来存储 dp[i - 1] 和 dp[i - 2],使得原来的 O(N) 空间复杂度优化为 O(1) 复杂度。
- 强盗抢劫
题目描述:抢劫一排住户,但是不能抢邻近的住户,求最大抢劫量。
定义 dp 数组用来存储最大的抢劫量,其中 dp[i] 表示抢到第 i 个住户时的最大抢劫量。
由于不能抢劫邻近住户,如果抢劫了第 i -1 个住户,那么就不能再抢劫第 i 个住户,所以 dp[i]=max(dp[i-2]+mat[i], dp[i-1])
- 强盗在环形街区抢劫
return Math.max(rob(nums, 0, n - 2), rob(nums, 1, n - 1));
- 信件错排
题目描述:有 N 个 信 和 信封,它们被打乱,求错误装信方式的数量。
定义一个数组 dp 存储错误方式数量,dp[i] 表示前 i 个信和信封的错误方式数量。假设第 i 个信装到第 j 个信封里面,而第 j 个信装到第 k 个信封里面。根据 i 和 k 是否相等,有两种情况:
i==k,交换 i 和 j 的信后,它们的信和信封在正确的位置,但是其余 i-2 封信有 dp[i-2] 种错误装信的方式。由于 j 有 i-1 种取值,因此共有 (i-1)*dp[i-2] 种错误装信方式。
i != k,交换 i 和 j 的信后,第 i 个信和信封在正确的位置,其余 i-1 封信有 dp[i-1] 种错误装信方式。由于 j 有 i-1 种取值,因此共有 (i-1)*dp[i-1] 种错误装信方式。
综上所述,错误装信数量方式数量为:
dp[i]= (i-1)*dp[i-2] + (i-1)*dp[i-1]
- 母牛生产
程序员代码面试指南-P181
题目描述:假设农场中成熟的母牛每年都会生 1 头小母牛,并且永远不会死。第一年有 1 只小母牛,从第二年开始,母牛开始生小母牛。每只小母牛 3 年之后成熟又可以生小母牛。给定整数 N,求 N 年后牛的数量。
第 i 年成熟的牛的数量为:dp[i]=dp[i-1] + dp[i-3]
矩阵路径
- 矩阵的最小路径和
题目描述:求从矩阵的左上角到右下角的最小路径和,每次只能向右和向下移动。
dp[i][j] = nums[i][j] + min(dp[i-1][j], dp[i][j-1]
- 矩阵的总路径数
题目描述:统计从矩阵左上角到右下角的路径总数,每次只能向右或者向下移动。
dp[i][j] = dp[i-1][j] + dp[i][j-1]
也可以直接用数学公式求解,这是一个组合问题。机器人总共移动的次数 S=m+n-2,向下移动的次数 D=m-1,那么问题可以看成从 S 中取出 D 个位置的组合数量,这个问题的解为 C(S, D)。
数组区间
- 数组区间和. Range Sum Query - Immutable (Easy)
求区间 i ~ j 的和,可以转换为 sum[j+1] - sum[i],其中 sum[i] 为 0 ~ i-1 的和。
- 数组中等差递增子区间的个数. Arithmetic Slices (Medium)
A = [0, 1, 2, 3, 4]
dp[i] 表示以 A[i] 为结尾的等差递增子区间的个数。
当 A[i] - A[i-1] == A[i-1] - A[i-2],那么 [A[i-2], A[i-1], A[i]] 构成一个等差递增子区间。而且在以 A[i-1] 为结尾的递增子区间的后面再加上一个 A[i],一样可以构成新的递增子区间。
因为递增子区间不一定以最后一个元素为结尾,可以是任意一个元素结尾,因此需要返回 dp 数组累加的结果。
public int numberOfArithmeticSlices(int[] A) {
if (A == null || A.length == 0) {
return 0;
}
int n = A.length;
int[] dp = new int[n];
for (int i = 2; i < n; i++) {
if (A[i] - A[i - 1] == A[i - 1] - A[i - 2]) {
dp[i] = dp[i - 1] + 1;
}
}
int total = 0;
for (int cnt : dp) {
total += cnt;
}
return total;
}
分割整数
- 分割整数的最大乘积 Integer Break (Medim)
题目描述:given n = 2, return 1 (2 = 1 + 1);
given n = 10, return 36 (10 = 3 + 3 + 4).
解1:dp[i] = Math.max(dp[i], Math.max(j * dp[i - j], j * (i - j)))
解2:dp[i]=max(2×(i−2),2×dp[i−2],3×(i−3),3×dp[i−3])
解3:如果余数为 0,即 n=3m,则将 n 拆分成 m 个 3;
如果余数为 1,即 n=3m+1,由于 2 * 2 > 3 *1,因此将 n 拆分成 m-1个 3和 2 个 2;
如果余数为 2,即 n=3m+2,则将 nn 拆分成 m 个 3和 1 个 2。
- 按平方数来分割整数 Perfect Squares(Medium)
题目描述:given n = 12, return 3 because 12 = 4 + 4 + 4;
given n = 13, return 2 because 13 = 4 + 9.
min = Math.min(min, dp[i - square] + 1);
- 分割整数构成字母字符串Decode Ways (Medium)
题目描述:Given encoded message “12”, it could be decoded as “AB” (1 2) or “L” (12)
最长子序列
- 最长递增子序列 Longest Increasing Subsequence (Medium)
dp[n] 表示以 Sn 结尾的序列的最长递增子序列长度。
dp[n] = max{ dp[i]+1 | Si < Sn && i < n} 。
对于一个长度为 N 的序列,最长递增子序列并不一定会以 SN 为结尾,因此 dp[N] 不是序列的最长递增子序列的长度,需要遍历 dp 数组找出最大值才是所要的结果,max{ dp[i] | 1 <= i <= N} 即为所求。
for (int i = 1; i < n; i++) {
for (int j = 0; j < i; j++) {
if (nums[j] < nums[i]) {
dp[i] = Math.max(dp[i], dp[j] + 1);
}
}
}
- 一组整数对能够构成的最长链 Maximum Length of Pair Chain (Medium)
Input: [[1,2], [2,3], [3,4]]
Output: 2
Explanation: The longest chain is [1,2] -> [3,4]
题目描述:对于 (a, b) 和 (c, d) ,如果 b < c,则它们可以构成一条链。
for (int i = 1; i < n; i++) {
for (int j = 0; j < i; j++) {
if (pairs[j][1] < pairs[i][0]) {
dp[i] = Math.max(dp[i], dp[j] + 1);
}
}
}
- 最长摆动子序列 Wiggle Subsequence (Medium)
要求:使用 O(N) 时间复杂度求解。
int up = 1, down = 1;
for (int i = 1; i < nums.length; i++) {
if (nums[i] > nums[i - 1]) {
up = down + 1;
} else if (nums[i] < nums[i - 1]) {
down = up + 1;
}
}
return Math.max(up, down);
- 最长公共子序列 Longest Common Subsequenc
dp[i][j] 表示 S1 的前 i 个字符与 S2 的前 j 个字符最长公共子序列的长度。考虑 S1i 与 S2j 值是否相等,分为两种情况:
当 S1i==S2j 时, dp[i][j] = dp[i-1][j-1] + 1。
当 S1i != S2j 时,dp[i][j] = max{ dp[i-1][j], dp[i][j-1] }。
与最长递增子序列相比,最长公共子序列有以下不同点:
针对的是两个序列,求它们的最长公共子序列。
在最长递增子序列中,dp[i] 表示以 Si 为结尾的最长递增子序列长度,子序列必须包含 Si ;在最长公共子序列中,dp[i][j] 表示 S1 中前 i 个字符与 S2 中前 j 个字符的最长公共子序列长度,不一定包含 S1i 和 S2j。
在求最终解时,最长公共子序列中 dp[N][M] 就是最终解,而最长递增子序列中 dp[N] 不是最终解,因为以 SN 为结尾的最长递增子序列不一定是整个序列最长递增子序列,需要遍历一遍 dp 数组找到最大者。
在这里插入代码片
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(dp[i - 1][j], dp[i][j - 1]);
}
- 最长回文子序列
dp[i][j] 表示 S1 的前 i 个字符与 S2 的前 j 个字符最长回文子序列的长度。考虑 S1i 与 S2j 值是否相等,分为两种情况:
当 S1i==S2j 时, dp[i][j] = dp[i+1][j-1] + 2。
当 S1i != S2j 时,dp[i][j] = max{ dp[i+1][j], dp[i][j-1] }。
dp[i][i]=1。
由于状态转移方程都是从长度较短的子序列向长度较长的子序列转移,因此需要注意动态规划的循环顺序。
def longestPalindromeSubseq(self, s: str):
n = len(s)
dp = [[0] * n for _ in range(n)]
for i in range(n - 1, -1, -1):
dp[i][i] = 1
for j in range(i + 1, n):
if s[i] == s[j]:
dp[i][j] = dp[i + 1][j - 1] + 2
else:
dp[i][j] = max(dp[i + 1][j], dp[i][j - 1])
return dp[0][n - 1]
0-1 背包
有一个容量为 N 的背包,要用这个背包装下物品的价值最大,这些物品有两个属性:体积 w 和价值 v。
其中 dp[i][j] 表示前 i 件物品体积不超过 j 的情况下能达到的最大价值。
设第 i 件物品体积为 w,价值为 v,根据第 i 件物品是否添加到背包中,可以分两种情况讨论:
第 i 件物品没添加到背包,总体积不超过 j 的前 i 件物品的最大价值就是总体积不超过 j 的前 i-1 件物品的最大价值,dp[i][j] = dp[i-1][j]。
第 i 件物品添加到背包中,dp[i][j] = dp[i-1][j-w] + v。
第 i 件物品可添加也可以不添加,取决于哪种情况下最大价值更大
// W 为背包总体积
// N 为物品数量
// weights 数组存储 N 个物品的重量
// values 数组存储 N 个物品的价值
public int knapsack(int W, int N, int[] weights, int[] values) {
int[][] dp = new int[N + 1][W + 1];
for (int i = 1; i <= N; i++) {
int w = weights[i - 1], v = values[i - 1];
for (int j = 1; j <= W; j++) {
if (j >= w) {
dp[i][j] = Math.max(dp[i - 1][j], dp[i - 1][j - w] + v);
} else {
dp[i][j] = dp[i - 1][j];
}
}
}
return dp[N][W];
}
空间优化
在程序实现时可以对 0-1 背包做优化。观察状态转移方程可以知道,前 i 件物品的状态仅与前 i-1 件物品的状态有关,
因此可以将 dp 定义为一维数组,其中 dp[j] 既可以表示 dp[i-1][j] 也可以表示 dp[i][j]。此时,
因为 dp[j-w] 表示 dp[i-1][j-w],因此不能先求 dp[i][j-w],防止将 dp[i-1][j-w] 覆盖。也就是说要先计算 dp[i][j] 再计算 dp[i][j-w],在程序实现时需要按倒序来循环求解。
public int knapsack(int W, int N, int[] weights, int[] values) {
int[] dp = new int[W + 1];
for (int i = 1; i <= N; i++) {
int w = weights[i - 1], v = values[i - 1];
for (int j = W; j >= 1; j--) {
if (j >= w) {
dp[j] = Math.max(dp[j], dp[j - w] + v);
}
}
}
return dp[W];
}
无法使用贪心算法的解释
0-1 背包问题无法使用贪心算法来求解,也就是说不能按照先添加性价比最高的物品来达到最优,这是因为这种方式可能造成背包空间的浪费,从而无法达到最优。
考虑下面的物品和一个容量为 5 的背包,如果先添加物品 0 再添加物品 1,那么只能存放的价值为 16,浪费了大小为 2 的空间。最优的方式是存放物品 1 和物品 2,价值为 22.
变种
完全背包:物品数量为无限个
多重背包:物品数量有限制
多维费用背包:物品不仅有重量,还有体积,同时考虑这两种限制
其它:物品之间相互约束或者依赖
- 划分数组为和相等的两部分Partition Equal Subset Sum (Medium)
Input: [1, 5, 11, 5]
Output: true
Explanation: The array can be partitioned as [1, 5, 5] and [11].
可以看成一个背包大小为 sum/2 的 0-1 背包问题。
public boolean canPartition(int[] nums) {
int sum = computeArraySum(nums);
if (sum % 2 != 0) {
return false;
}
int W = sum / 2;
boolean[] dp = new boolean[W + 1];
dp[0] = true;
for (int num : nums) { // 0-1 背包一个物品只能用一次
for (int i = W; i >= num; i--) { // 从后往前,先计算 dp[i] 再计算 dp[i-num]
dp[i] = dp[i] || dp[i - num];
}
}
return dp[W];
}
- 改变一组数的正负号使得它们的和为一给定数
Input: nums is [1, 1, 1, 1, 1], target is 3.
Output: 5
There are 5 ways to assign symbols to make the sum of nums be target
记数组的元素和为sum,添加 -号的元素之和为neg,则其余添加+的元素之和为sum−neg,得到的表达式的结果为
(sum−neg)−neg=sum−2⋅neg=target
因此只要找到一个子集,令它们都取正号,并且和等于 (target + sum(nums))/2,就证明存在解。
all_sum = sum(nums)
W = all_sum + target
if W % 2 != 0 or W < 0:
return 0
n = len(nums)
pos = W / 2
dp = [[0 for i in range(pos+1)] for j in range(n+1)]
dp[0][0] = 1
for i in range(1, n+1):
num_i = nums[i-1]
for j in range(pos+1):
dp[i][j] = dp[i-1][j]
if j >= num_i:
dp[i][j] += dp[i-1][j-num_i]
print(i, dp[i])
return dp[n][pos]
- 01 字符构成最多的字符串 Ones and Zeroes (Medium)
Input: Array = {“10”, “0001”, “111001”, “1”, “0”}, m = 5, n = 3
该子集中 最多 有 m 个 0 和 n 个 1
Output: 4
Explanation: There are totally 4 strings can be formed by the using of 5 0s and 3 1s, which are “10”,“0001”,“1”,“0”
这是一个多维费用的 0-1 背包问题,有两个背包大小,0 的数量和 1 的数量。
int[][] dp = new int[m + 1][n + 1];
for s in str:
ones = 0
zeros = 0
for c in s:
if c == 0: zeros+=1
else:ones+=1
for i in range(m, -1,-1):
for j in range(n, -1,-1):
dp[i][j]=Math.max(dp[i][j],dp[i-zeros][j-ones]+ 1);
return dp[m][n];
- 找零钱的最少硬币数Coin Change (Medium)
题目描述:给一些面额的硬币,要求用这些硬币来组成给定面额的钱数,并且使得硬币数量最少。硬币可以重复使用。
因为硬币可以重复使用,因此这是一个完全背包问题。完全背包只需要将 0-1 背包的逆序遍历 dp 数组改为正序遍历即可。
public int coinChange(int[] coins, int amount) {
if (amount == 0 || coins == null) return 0;
int[] dp = new int[amount + 1];
for (int coin : coins) {
for (int i = coin; i <= amount; i++) { //将逆序遍历改为正序遍历
if (i == coin) {
dp[i] = 1;
} else if (dp[i] == 0 && dp[i - coin] != 0) {
dp[i] = dp[i - coin] + 1;
} else if (dp[i - coin] != 0) {
dp[i] = Math.min(dp[i], dp[i - coin] + 1);
}
}
}
return dp[amount] == 0 ? -1 : dp[amount];
}
- 找零钱的硬币数组合Coin Change 2 (Medium)
Input: amount = 5, coins = [1, 2, 5]
Output: 4
Explanation: there are four ways to make up the amount:
完全背包问题,使用 dp 记录可达成目标的组合数目。
public int change(int amount, int[] coins) {
if (coins == null) {
return 0;
}
int[] dp = new int[amount + 1];
dp[0] = 1;
for (int coin : coins) {
for (int i = coin; i <= amount; i++) {
dp[i] += dp[i - coin];
}
}
return dp[amount];
}
- 字符串按单词列表分割Word Break (Medium)
s = “leetcode”,
dict = [“leet”, “code”].
Return true because “leetcode” can be segmented as “leet code”.
dict 中的单词没有使用次数的限制,因此这是一个完全背包问题。
该问题涉及到字典中单词的使用顺序,也就是说物品必须按一定顺序放入背包中,例如下面的 dict 就不够组成字符串 “leetcode”:
[“lee”, “tc”, “cod”]
求解顺序的完全背包问题时,对物品的迭代应该放在最里层,对背包的迭代放在外层,只有这样才能让物品按一定顺序放入背包中。
public boolean wordBreak(String s, List<String> wordDict) {
int n = s.length();
boolean[] dp = new boolean[n + 1];
dp[0] = true;
for (int i = 1; i <= n; i++) {
for (String word : wordDict) {// 对物品的迭代应该放在最里层
int len = word.length();
if (len <= i && word.equals(s.substring(i - len, i))) {
dp[i] = dp[i] || dp[i - len];
}
}
}
return dp[n];
}
股票交易
- 买卖股票的最佳时间
只需要遍历一次数组,用一个变量记录遍历过数中的最小值,然后每次计算当前值和这个最小值之间的差值最为利润,然后每次选较大的利润来更新。当遍历完成后当前利润即为所求
dp[i] 表示当前状态下的最大利润
- 买卖股票的最佳时间—无限次买入和卖出
dp[i][0] 表示第 i 天交易完后手里没有股票的最大利润,
dp[i][1] 表示第 i 天交易完后手里持有一支股票的最大利润(i 从 0 开始)。
dp[i][0]=max{dp[i−1][0], dp[i−1][1]+prices[i]}
dp[i][1]=max{dp[i−1][1], dp[i−1][0]−prices[i]}
return dp[n−1][0]。
- 只能进行两次的股票交易Best Time to Buy and Sell Stock III (Hard)
buy1 只进行过一次买操作;
sell1 进行了一次买操作和一次卖操作,即完成了一笔交易;
buy2 在完成了一笔交易的前提下,进行了第二次买操作;
sell2完成了全部两笔交易。
buy1 =max{buy1 ,−prices[i]}
sell1 =max{sell1 ,buy1 +prices[i]}
buy2 =max{buy2 ,sell1 −prices[i]}
sell2 =max{sell2 ,buy2 +prices[i]}
- 只能进行 k 次的股票交易Best Time to Buy and Sell Stock IV (Hard)
for p in prices:
for i in range(1, k+1):
buy[i] = max(buy[i], sell[i - 1] - p); // 卖了买
sell[i] = max(sell[i], buy[i] + p); // 买了卖
return sell[k];
- 手续费的股票交易–在3的基础上加上手续费
Input: prices = [1, 3, 2, 8, 4, 9], fee = 2
题目描述:每交易一次,都要支付一定的费用。
s1[0] = buy[0] = -prices[0];
sell[0] = s2[0] = 0;
for (int i = 1; i < N; i++) {
buy[i] = Math.max(sell[i - 1], s2[i - 1]) - prices[i];
s1[i] = Math.max(buy[i - 1], s1[i - 1]);
sell[i] = Math.max(buy[i - 1], s1[i - 1]) - fee + prices[i];
s2[i] = Math.max(s2[i - 1], sell[i - 1]);
}
return Math.max(sell[N - 1], s2[N - 1]);
- 需要冷却期的股票交易
题目描述:交易之后需要有一天的冷却时间。
s1[0] = buy[0] = -prices[0];
sell[0] = s2[0] = 0;
for (int i = 1; i < N; i++) {
buy[i] = s2[i - 1] - prices[i];
s1[i] = Math.max(buy[i - 1], s1[i - 1]);
sell[i] = Math.max(buy[i - 1], s1[i - 1]) + prices[i];
s2[i] = Math.max(s2[i - 1], sell[i - 1]);
}
return Math.max(sell[N - 1], s2[N - 1]);
}
字符串编辑
- 删除两个字符串的字符使它们相等Delete Operation for Two Strings (Medium)
Input: “sea”, “eat” Output: 2
可以转换为求两个字符串的最长公共子序列问题。
for i in range(1, m+1):
for j in range(1, n+1):
if word1[i-1] == word2[j-1]:
dp[i][j] = dp[i-1][j-1] + 1;
else:
dp[i][j] = Math.max(dp[i][j-1], dp[i-1][j])
return m + n - 2 * dp[m][n];
- 编辑距离 Edit Distance (Hard)
题目描述:修改一个字符串成为另一个字符串,使得修改次数最少。一次修改操作包括:插入一个字符、删除一个字符、替换一个字符。
D[i][j] 为 A 的前 i 个字符和 B 的前 j 个字符编辑距离的子问题
D[i][j]=min(D[i][j−1]+1,D[i−1][j]+1,D[i−1][j−1]),相等
D[i][j] = 1 + min(D[i][j - 1], D[i - 1][j], D[i - 1][j-1]),不相等
- 复制粘贴字符2 Keys Keyboard (Medium)
题目描述:最开始只有一个字符 A,问需要多少次操作能够得到 n 个字符 A,每次操作可以复制当前所有的字符,或者粘贴。
设:dp[i]表示得到i个A所需要的操作数,则我们的目的是求dp[n]
状态转移方程:dp[i] = dp[j] + dp[i/j] (j是一个小于i的数,且能被i整除,其实就是i的因数,注意把j==1的情况排除掉)
说明:上述方程对应了前面介绍的规律,dp[6] = dp[2] + dp[3]
初始化:dp[i] = i (表示一个A一个A的粘贴,是实现步骤最多的方法)
参考:
github