【数据结构与算法】动态规划经典题总结[leetcode]
- 1.爬楼梯
- 2.买卖股票的最佳时机
- 3.最长回文子串
- 4.不同路径I
- 5.不同路径II
- 6.最小路径和
- 7.最小路径和(三角形)
- 8.强盗抢劫
- 9.强盗在环形街区抢劫
- 10.数组区间和
- 11.数组中等差递增子区间的个数
- 12.分割整数的最大乘积
- 13.按平方数来分割整数
- 14.最长上升子序列
- 15.最长数对链
- 16.最长公共子序列
- 17.分割等和子集(背包)
- 18.目标和(背包)
- 19.一和零(背包)
- 20.零钱兑换
- 21.零钱兑换2
- 22.单词拆分
- 23.组合总和4
- 24.买卖股票的最佳时机含手续费
- 25.买卖股票的最佳时机3
- 26.买卖股票的最佳时机4
- 27.只有两个键的键盘
爬楼梯
** 五星 **
LeetCode:爬楼梯
题目描述:
假设你正在爬楼梯。需要 n 阶你才能到达楼顶。
每次你可以爬 1 或 2 个台阶。你有多少种不同的方法可以爬到楼顶呢?
注意:给定 n 是一个正整数。
示例:
输入: 3
输出: 3
解释: 有三种方法可以爬到楼顶。
1. 1 阶 + 1 阶 + 1 阶
2. 1 阶 + 2 阶
3. 2 阶 + 1 阶
思想:
1.动态规划,时间O(n)
到达某一个台阶方法数,等于抵达前两个台阶的方法数之和。
可使用滚动数组来优化,仅保存三个变量,使得空间复杂度为O(1)
2.矩阵快速幂
时间:O(log(n));空间:O(1)
3.斐波那契数列通项公式
时间:O(log(n));空间:O(1)
代码:
滚动数组,仅保存三个变量
class Solution {
public int climbStairs(int n) {
int r1=1,r2=2,res=n;//r1和r2分别表示当前位置的前两个数
for(int i = 3;i<=n;++i){
res = r1 + r2;
r1 = r2;
r2 = res;
}
return res;
}
}
自底向上动态规划法
class Solution {
public int climbStairs(int n) {
int[] dp = new int[n+1];//存储每一个台阶的方法数
dp[0]=1;//0号位是多余的,故数组要分配n+1项
dp[1]=1;
for(int i = 2;i<=n;++i){
dp[i] = dp[i-1] + dp[i-2];
}
return dp[n];
}
}
矩阵快速幂:
public int climbStairs3(int n) {
if (n < 2) return 1;
int[][] q = new int[][]{{1, 1}, {1, 0}};
q = pow(q, n - 1);
return q[0][0];
}
//计算一个矩阵的n次方
private int[][] pow(int[][] q, int n) {
int[][] res = new int[][]{{1, 0}, {0, 1}};
while (n > 0) {
if ((n & 1) == 1) res = multiply(res, q);
n >>= 1;
q = multiply(q, q);
}
return res;
}
private int[][] multiply(int[][] a, int[][] b) {
int[][] res = new int[2][2];
for (int i = 0; i < 2; ++i) {
for (int j = 0; j < 2; ++j) {
res[i][j] = a[i][0] * b[0][j] + a[i][1] * b[1][j];
}
}
return res;
}
通项公式:
public int climbStairs2(int n) {
double sqrt5 = Math.sqrt(5);
double res = (Math.pow((1 + sqrt5) / 2, n + 1) - Math.pow((1 - sqrt5) / 2, n + 1)) / sqrt5;
return (int) res;
}
补充:快速幂方法求x的k次方
double quickMul(double x, long k){
double res = 1.0;
while(k>0){
if((k&1)==1) res*=x;
k>>=1;
x*=x;
}
return res;
}
买卖股票的最佳时机
LeetCode:买卖股票的最佳时机
题目描述:
给定一个数组,它的第 i 个元素是一支给定股票第 i 天的价格。
如果你最多只允许完成一笔交易(即买入和卖出一支股票一次),设计一个算法来计算你所能获取的最大利润。
注意:你不能在买入股票前卖出股票。
示例:
输入: [7,1,5,3,6,4]
输出: 5
解释: 在第 2 天(股票价格 = 1)的时候买入,在第 5 天(股票价格 = 6)的时候卖出,最大利润 = 6-1 = 5 。
注意利润不能是 7-1 = 6, 因为卖出价格需要大于买入价格。
思想:
dp思想
- 记录【今天之前买入的最小值】
- 计算【今天之前最小值买入,今天卖出的获利】,也即【今天卖出的最大获利】
- 比较【每天的最大获利】,取最大值即可
代码:
class Solution {
public int maxProfit(int[] prices) {
int L = prices.length;
if(L == 0){
return 0;
}
//min表示当前位置之前的最小值
int min = prices[0],maxProfit = 0;
for(int i=1;i<L;++i){
//prices[i]-min表示当前位置抛售可获得的最大利润
maxProfit = Math.max(maxProfit,prices[i]-min);
min = Math.min(prices[i],min);
}
return maxProfit;
}
}
最佳买卖股票时机含冷冻期
** 五星 **
LeetCode:最佳买卖股票时机含冷冻期
题目描述:
给定一个整数数组,其中第 i 个元素代表了第 i 天的股票价格 。
设计一个算法计算出最大利润。在满足以下约束条件下,你可以尽可能地完成更多的交易(多次买卖一支股票):
你不能同时参与多笔交易(你必须在再次购买前出售掉之前的股票)。
卖出股票后,你无法在第二天买入股票 (即冷冻期为 1 天)。
示例:
输入: [1,2,3,0,2]
输出: 3
解释: 对应的交易状态为: [买入, 卖出, 冷冻期, 买入, 卖出]
思想:
注意状态是指每一天结束时的状态。
三种状态:无操作(冷冻或不持股),刚卖出,持股。
如果前一天“无操作”,今天可选择买入或不买入,分别转化为“持股”和“无操作”;
如果前一天“刚卖出”,今天必须进入冷冻,即转化为“无操作”状态;
如果前一天“持股”,今天可选择卖出或不卖出,分别可转化为“刚卖出”和“持股”;
这样状态转移方程就可以写出来了:
dp[i][0] = Math.max(dp[i-1][1],dp[i-1][0]);
dp[i][1] = dp[i-1][2] + prices[i];
dp[i][2] = Math.max(dp[i-1][2],dp[i-1][0] - prices[i]);
代码:
- 二维dp数组方法
class Solution {
public int maxProfit(int[] prices) {
int len = prices.length;
if(len<2) return 0;
int[][] dp = new int[len][3];
dp[0][0]=0;dp[0][1]=0;dp[0][2]=-prices[0];
for(int i=1;i<len;++i){
dp[i][0] = Math.max(dp[i-1][1],dp[i-1][0]);
dp[i][1] = dp[i-1][2] + prices[i];
dp[i][2] = Math.max(dp[i-1][2],dp[i-1][0] - prices[i]);
}
return Math.max(dp[len-1][0],dp[len-1][1]);
}
}
- 优化存储空间后的代码
class Solution {
public int maxProfit(int[] prices) {
int len = prices.length;
if(len<2) return 0;
int[] state = new int[3];
int temp;
state[0]=0;state[1]=0;state[2]=-prices[0];
for(int i=1;i<len;++i){
temp = Math.max(state[1],state[0]);
state[1] = state[2] + prices[i];
state[2] = Math.max(state[2],state[0] - prices[i]);
state[0] = temp;
}
return Math.max(state[0],state[1]);
}
}
这题非常难,看别人题解说得再详细也没用,还得自己一步一步理解
最长回文子串
LeetCode:最长回文子串
题目描述:
给定一个字符串 s,找到 s 中最长的回文子串。你可以假设 s 的最大长度为 1000。
示例:
输入: "babad"
输出: "bab"
注意: "aba" 也是一个有效答案。
思想:
- 动态规划,用boolean[][] dp记录每一对字符是否相等;
- 双循环遍历所有子串情况,每次遍历时,当前子串首尾相等且内层-1字符串dp值为true,则记录dp值为true;全部遍历完,取最长,即为最长子串;
- 临界条件很复杂,最好在循环之前把长度小于2的情况剔除;条件中有一个i-j<3,因为小于3且首尾相等的子串一定是回文串,不需要再往内层再判断dp。
代码:
class Solution {
public String longestPalindrome(String s) {
int len = s.length();
boolean[][] dp = new boolean[len][len];
int i,j,max=0,m=0,n=0;
if(len<2) return s;
for(i=0;i<len;++i){
for(j=0;j<=i;++j){
if(s.charAt(i) == s.charAt(j)&&(i-j<3||dp[j+1][i-1])){
dp[j][i]=true;
if(i-j>max){
max = i-j;
m=j;n=i;
}
}else{
dp[j][i]=false;
}
}
}
return s.substring(m,n+1);
}
}
不同路径
LeetCode:不同路径
题目描述:
一个机器人位于一个 m x n 网格的左上角 (起始点在下图中标记为“Start” )。
机器人每次只能向下或者向右移动一步。机器人试图达到网格的右下角(在下图中标记为“Finish”)。
问总共有多少条不同的路径?
示例:
输入: m = 3, n = 2
输出: 3
解释:
从左上角开始,总共有 3 条路径可以到达右下角。
1. 向右 -> 向右 -> 向下
2. 向右 -> 向下 -> 向右
3. 向下 -> 向右 -> 向右
思想:
方法1:
dp思想,每一个格子的路径数,等于上面一格+左边一格的路径数之和;
注意i=0和j=0时,所有格的dp值为1,直接判断一下赋值1即可,不用再算,太麻烦。
方法2:
对方法1的优化。可以使用一维dp数组 dp[n] 。
因为每次累加时(假设逐行遍历),只使用了一行的数据,并没有涉及到前面几行,所以考虑缩减为单维dp数组。
方法3:
数学方法。总共需要 m+n-2 步到达终点,需要向下走 m-1 步,仔细想想,这实际上是一个“组合”问题,在 Y=m+n-2 次移动中取 X=m-1 个向下的移动,C(Y,X) 即为结果。
组合公式的代码实现如下:
for(int i=1;i<m;++i){
res = res * (Y - X +i) / i;
}
注意:
-
有除法,但是不可能出现非整除的情况,结果必是整数。但是如果这样写:res *= (Y - X +i) / i 就可能出现错误,除不尽。
-
res * (Y - X +i)可能超出int范围,所以要用 long 来定义 res。
代码:
方法1:
class Solution {
public int uniquePaths(int m, int n) {
int[][] dp = new int[m][n];
for(int i=0;i<m;++i){
for(int j = 0;j<n;++j){
if(i==0 || j==0){
dp[i][j] = 1;
}else{
dp[i][j] = dp[i-1][j] + dp[i][j-1];
}
}
}
return dp[m-1][n-1];
}
}
方法2:
class Solution {
public int uniquePaths(int m, int n) {
int[] dp = new int[n];
Arrays.fill(dp,1);
for(int i=1;i<m;++i){
for(int j=1;j<n;++j){
dp[j] += dp[j-1];
}
}
return dp[n-1];
}
}
方法3:
class Solution {
public int uniquePaths(int m, int n) {
int Y = m + n -2;
int X = m - 1;
long res = 1;
for(int i=1;i<m;++i){
res = res * (Y - X +i) / i;
}
return (int) res;
}
}
不同路径2
LeetCode:不同路径 II
题目描述:
一个机器人位于一个 m x n 网格的左上角 (起始点在下图中标记为“Start” )。
机器人每次只能向下或者向右移动一步。机器人试图达到网格的右下角(在下图中标记为“Finish”)。
现在考虑网格中有障碍物。那么从左上角到右下角将会有多少条不同的路径?
示例:
输入:
[
[0,0,0],
[0,1,0],
[0,0,0]
]
输出: 2
解释:
3x3 网格的正中间有一个障碍物。
从左上角到右下角一共有 2 条不同的路径:
1. 向右 -> 向右 -> 向下 -> 向下
2. 向下 -> 向下 -> 向右 -> 向右
思想:
跟上一题差不多,加一点判断条件;
注意:这题i=0和j=0时,dp值不全为1,可能是0,因为前面可能有障碍物。判断条件需要做一些调整。
代码:
class Solution {
public int uniquePathsWithObstacles(int[][] obstacleGrid) {
int m = obstacleGrid.length;
int n = obstacleGrid[0].length;
for(int i=0;i<m;++i){
for(int j=0;j<n;++j){
if(obstacleGrid[i][j]==1){
obstacleGrid[i][j] = 0;
}else if(i==0&&j==0){
obstacleGrid[i][j] = 1;
}else{
obstacleGrid[i][j] = (i==0?0:obstacleGrid[i-1][j]) + (j==0?0:obstacleGrid[i][j-1]);
}
}
}
return obstacleGrid[m-1][n-1];
}
}
最小路径和
LeetCode:最小路径和
题目描述:
给定一个包含非负整数的 m x n 网格,请找出一条从左上角到右下角的路径,使得路径上的数字总和为最小。
说明:每次只能向下或者向右移动一步。
示例:
输入:
[
[1,3,1],
[1,5,1],
[4,2,1]
]
输出: 7
解释: 因为路径 1→3→1→1→1 的总和最小。
思想:
动态规划,可以用原数组作为dp数组
代码:
class Solution {
public int minPathSum(int[][] grid) {
int i=0,j=0;
for(i=0;i<grid.length;++i){
for(j=0;j<grid[0].length;++j){
if(i>0&&j>0){
grid[i][j]+= Math.min(grid[i-1][j],grid[i][j-1]);
}else{
grid[i][j]+= (i==0?0:grid[i-1][j]) + (j==0?0:grid[i][j-1]);
}
}
}
return grid[i-1][j-1];
}
}
三角形最小路径和
LeetCode:三角形最小路径和
题目描述:
给定一个三角形,找出自顶向下的最小路径和。每一步只能移动到下一行中相邻的结点上。
如果你可以只使用 O(n) 的额外空间(n 为三角形的总行数)来解决这个问题,那么你的算法会很加分。
示例:
例如给定三角形
[
[2],
[3,4],
[6,5,7],
[4,1,8,3]
]
自顶向下的最小路径和为 11(即,2 + 3 + 5 + 1 = 11)。
思想:
自底向上,修改dp数组
代码:
第一种方法:在原数组上修改。这样貌似效率不高。
class Solution {
public int minimumTotal(List<List<Integer>> triangle) {
for(int i=triangle.size()-2;i>=0;--i){
for(int j=0;j<i+1;++j){
triangle.get(i).set(j,triangle.get(i).get(j)+Math.min(triangle.get(i+1).get(j+1),triangle.get(i+1).get(j)));
}
}
return triangle.get(0).get(0);
}
}
第二种方法:设置dp数组,修改dp数组;注意这很巧妙,每一次修改都不会影响下次循环的判断;其次,每次循环,最后一个数都不会修改它,直到最后一轮,加上顶部的数,得到最终结果dp[0]。
class Solution {
public int minimumTotal(List<List<Integer>> triangle) {
int len = triangle.size();
int[] dp=new int[len];
for(int i=0;i<len;++i){
dp[i]=triangle.get(len-1).get(i);
}
for(int i=len-2;i>=0;--i){
for(int j=0;j<i+1;++j){
dp[j] = Math.min(dp[j],dp[j+1]) + triangle.get(i).get(j);
}
}
return dp[0];
}
}
打家劫舍
LeetCode:打家劫舍
题目描述:
你是一个专业的小偷,计划偷窃沿街的房屋。每间房内都藏有一定的现金,影响你偷窃的唯一制约因素就是相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。
给定一个代表每个房屋存放金额的非负整数数组,计算你 不触动警报装置的情况下 ,一夜之内能够偷窃到的最高金额。
示例:
输入:[2,7,9,3,1]
输出:12
解释:偷窃 1 号房屋 (金额 = 2), 偷窃 3 号房屋 (金额 = 9),接着偷窃 5 号房屋 (金额 = 1)。
偷窃到的最高金额 = 2 + 9 + 1 = 12 。
思想:
标准的动态规划,注意以下两点:
- 不需要额外的dp数组;
- 不需要修改dp数组,用几个变量即可;
代码:
class Solution {
public int rob(int[] nums) {
int len = nums.length;
int pre1=0;
int pre2=0;
for(int i=0;i<len;++i){
int cur = Math.max(pre2 , pre1+nums[i]);
pre1 = pre2;
pre2 = cur;
}
return pre2;
}
}
打家劫舍2
五星
LeetCode:打家劫舍2
题目描述:
强盗在环形街区抢劫
示例:
输入: [1,2,3,1]
输出: 4
解释: 你可以先偷窃 1 号房屋(金额 = 1),然后偷窃 3 号房屋(金额 = 3)。
偷窃到的最高金额 = 1 + 3 = 4 。
思想:
分别计算1n-1和0n-2的结果值,比较哪个大
不偷第一个房子情况下最大值,和不偷最后一个房子情况下最大值
代码:
class Solution {
public int rob(int[] nums) {
if(nums.length == 1) return nums[0];
return Math.max(rob(nums,0,nums.length-1),rob(nums,1,nums.length-1));
}
private int rob(int[] nums,int pos, int len){
int pre1=0,pre2=0;
int ret=0;
for(int i=pos;i<len+pos;++i){
ret = Math.max(pre1+nums[i], pre2);
pre1 = pre2;
pre2 = ret;
}
return ret;
}
}
数组区间和
LeetCode:区域和检索 - 数组不可变
题目描述:
给定一个整数数组 nums,求出数组从索引 i 到 j (i ≤ j) 范围内元素的总和,包含 i, j 两点。
示例:
给定 nums = [-2, 0, 3, -5, 2, -1],求和函数为 sumRange()
sumRange(0, 2) -> 1
sumRange(2, 5) -> -1
sumRange(0, 5) -> -3
思想:
最容易想到的笨方法:每次调用时都执行一次循环,获得结果。
可以在输入数组时就修改原数组,使得每个元素值为该元素之前所有元素之和。
这样,每次调用时,执行nums[j] - nums[i-1] 即可得到结果。
注意:边界问题,可以把数组长度声明为n+1,来解决。
代码:
class NumArray {
private final int[] arr;
public NumArray(int[] nums) {
arr = new int[nums.length + 1];
for(int i=1;i<arr.length;++i){
arr[i] = arr[i-1] + nums[i-1];
}
}
public int sumRange(int i, int j) {
return arr[j+1] - arr[i];
}
}
/**
* Your NumArray object will be instantiated and called as such:
* NumArray obj = new NumArray(nums);
* int param_1 = obj.sumRange(i,j);
*/
数组中等差递增子区间的个数
** 五星 **
LeetCode:等差数列划分
题目描述:
如果一个数列至少有三个元素,并且任意两个相邻元素之差相同,则称该数列为等差数列。
例如,以下数列为等差数列:
1, 3, 5, 7, 9
7, 7, 7, 7
3, -1, -5, -9
以下数列不是等差数列。
1, 1, 2, 5, 7
数组 A 包含 N 个数,且索引从0开始。数组 A 的一个子数组划分为数组 (P, Q),P 与 Q 是整数且满足 0<=P<Q<N 。
如果满足以下条件,则称子数组(P, Q)为等差数组:
元素 A[P], A[p + 1], ..., A[Q - 1], A[Q] 是等差的。并且 P + 1 < Q 。
函数要返回数组 A 中所有为等差数组的子数组个数。
示例:
A = [1, 2, 3, 4]
返回: 3, A 中有三个子等差数组: [1, 2, 3], [2, 3, 4] 以及自身 [1, 2, 3, 4]。
思想:
自己的方法
对于一个等差数组,n-1个元素,若增加一个元素(第n个)依然是等差数组,则子数组数量增加n-2个。找到此规律,就好做了。
dp方法:dp数组记录每一个元素为右边界的等差数列数量(注意不是右边界左边的数列总数量)
代码:
- 方法一:常数空间复杂度
class Solution {
public int numberOfArithmeticSlices(int[] A) {
int ret = 0;
int n=2;
for(int i=2;i<A.length;++i){
if(A[i-1]-A[i-2]==A[i]-A[i-1]){
n++;
}else{
n=2;
}
if(n>2) ret +=(n-2);
}
return ret;
}
}
- 方法二:dp
class Solution {
public int numberOfArithmeticSlices(int[] A) {
int len = A.length;
if(len<3) return 0;
int[] dp = new int[len];
int sum = 0;
for(int i=2;i<len;++i){
if(A[i]-A[i-1]==A[i-1]-A[i-2])
dp[i] = dp[i-1] + 1;
sum += dp[i];
}
return sum;
}
}
分割整数的最大乘积
LeetCode:整数拆分
题目描述:
给定一个正整数 n,将其拆分为至少两个正整数的和,并使这些整数的乘积最大化。 返回你可以获得的最大乘积。
示例:
输入: 10
输出: 36
解释: 10 = 3 + 3 + 4, 3 × 3 × 4 = 36。
思想:
方法一动态规划:对于小于n的每一个状态i而言,可以拆分成j和i-j,i-j可能拆也可以不拆,分别是j(i-j)和jdp[i-j],取最大值,因此状态转移方程为 dp[i] = Math.max(dp[i], Math.max(j*(i-j),j*dp[i-j]))
。
方法二:找规律。观察几个样例可以发现,数字拆分结果一定是多个 3 再加上若干个1、2。所以使用一个递归让数字不断减去3,直到小于某个阈值时返回,递归过程中计算乘积。
代码:
方法一:动态规划
class Solution {
public int integerBreak(int n) {
if(n<4) return n-1;
if(n==4) return n;
int[] dp = new int[n+1];
for(int i=2;i<=n;++i){
for(int j=1;j<i;++j){
dp[i] = Math.max(dp[i], Math.max(j*(i-j),j*dp[i-j]));
}
}
return dp[n];
}
}
方法二:规律
class Solution {
public int integerBreak(int n) {
if(n<4) return n-1;
if(n==4) return 4;
return count(n);
}
private int count(int n){
if(n<5) return n;
return count(n-3)*3;
}
}
按平方数来分割整数
LeetCode:完全平方数
题目描述:
给定正整数 n,找到若干个完全平方数(比如 1, 4, 9, 16, ...)使得它们的和等于 n。你需要让组成和的完全平方数的个数最少。
示例:
输入: n = 12
输出: 3
解释: 12 = 4 + 4 + 4.
思想:
这题可以看做一个完全背包问题。1、4、9、16这样的序列可看做一系列物品体积。背包问题的分析可参见我另一篇博客:背包问题总结梳理
代码:
class Solution {
public int numSquares(int n) {
List<Integer> squareList = generateSquareList(n);
int[] dp = new int[n+1];
for(int i=1;i<=n;++i){
dp[i] = n;
}
for(int item : squareList){
for(int j=item;j<=n;++j){
dp[j] = Math.min(dp[j],dp[j-item]+1);
}
}
return dp[n];
}
private List<Integer> generateSquareList(int n){
List<Integer> list = new ArrayList<>();
int add =1;
for(int i=1;i<=n;i+=add){
list.add(i);
add += 2;
}
return list;
}
}
最长上升子序列
LeetCode:最长上升子序列
题目描述:
给定一个无序的整数数组,找到其中最长上升子序列的长度。
示例:
输入: [10,9,2,5,3,7,101,18]
输出: 4
解释: 最长的上升子序列是 [2,3,7,101],它的长度是 4。
思想:
自己的思路
遍历到某个item时,前面已经有了各个长度的上升子序列,需要考虑把item挂在哪个子序列后面。前面的上升子序列(长度为k)的末尾元素与item进行比较,若item更大,则组成新的上升子序列(长度为k+1),否则以同样的方法比较长度为k-1的上升子序列与item的关系。这样一来,两层循环可以完成上述操作。
优化思路
内层循环可以用二分查找来优化。因为这实际上是一个查找的过程,在一串末尾元素中,查找适合挂item的元素,使其对应的长度加一即可。对于“7”而言,前置数组为[10, 9, 2, 5, 3],以每一个元素作为末尾元素的最长上升子序列长度为[1, 1, 1, 2, 2],实际上可以简化为[2 , 3] 和 [1, 2]的对应关系(2 是长度为1的最小元素,3是长度为2的最小元素),除了2和3之外的其它元素均可不用考虑。使用一个数组tails存储 [2, 3], 很明显是有序的,使用二分在tails 中寻找小于“7”的最大元素,下标加一即为以“7”为末尾的最大长度。
代码:
- 方法一:普通动态规划
class Solution {
public int lengthOfLIS(int[] nums) {
int[] dp = new int[nums.length+1];
int pos = 0;
dp[0] = Integer.MIN_VALUE;
for(int item : nums){
for(int i=pos;i>-1;i--){
if(item>dp[i]){
dp[i+1]=item;
pos = i==pos?(pos+1):pos;
break;
}
}
}
return pos;
}
}
- 方法二:二分查找优化
class Solution {
public int lengthOfLIS(int[] nums) {
int[] tails = new int[nums.length+1];
tails[0] = Integer.MIN_VALUE;
int res = 0;
for(int item : nums){
int index = binarySearch(tails, res, item);
tails[index+1] = item;
res = res==index?(res+1):res;
}
return res;
}
private int binarySearch(int[] tails, int len, int target){
int low = 0,high = len;
while(low<=high){
int mid = low + (high-low)/2;
if(tails[mid]<target){
low = mid + 1;
}else if(tails[mid]>target){
high = mid - 1;
}else{
return mid - 1;
}
}
return high;
}
}
最长数对链
LeetCode:最长数对链
题目描述:
给出 n 个数对。 在每一个数对中,第一个数字总是比第二个数字小。
现在,我们定义一种跟随关系,当且仅当 b < c 时,数对(c, d) 才可以跟在 (a, b) 后面。我们用这种形式来构造一个数对链。
给定一个对数集合,找出能够形成的最长数对链的长度。你不需要用到所有的数对,你可以以任何顺序选择其中的一些数对来构造。
示例:
输入: [[1,2], [2,3], [3,4]]
输出: 2
解释: 最长的数对链是 [1,2] -> [3,4]
思想:
动态规划
排序按照左边界来排序,否则会出问题。本题动态规划不是最优方法。
贪心
先按照右边界排序。假设A的下一个区间是B。若B能挂在A的后面,则数对链长度+1,若B不能挂在A的后面,则将B忽略即可,因为后续区间一定是挂在A后面比挂B后面收益要更大。
代码:
- 动态规划
class Solution {
public int findLongestChain(int[][] pairs) {
Arrays.sort(pairs, (a,b)->(a[0]-b[0]));
int[] dp = new int[pairs.length];
int n = 0;
int max = 0;
for(int[] item : pairs){
int val = 1;
for(int i=n-1;i>=0;i--){
if(pairs[i][1]<item[0]){
val += dp[i];
break;
}
}
dp[n++] = val;
max = Math.max(val,max);
}
return max;
}
}
- 贪心
class Solution {
public int findLongestChain(int[][] pairs) {
Arrays.sort(pairs,(a,b) -> a[1]-b[1]);
int preRight = pairs[0][0] - 1;
int res = 0;
for(int[] item : pairs){
if(item[0]>preRight){
res++;
preRight = item[1];
}
}
return res;
}
}
最长公共子序列
五星
LeetCode:最长公共子序列
题目描述:
给定两个字符串 text1 和 text2,返回这两个字符串的最长公共子序列的长度。
一个字符串的 子序列 是指这样一个新的字符串:它是由原字符串在不改变字符的相对顺序的情况下删除某些字符(也可以不删除任何字符)后组成的新字符串。
例如,"ace" 是 "abcde" 的子序列,但 "aec" 不是 "abcde" 的子序列。两个字符串的「公共子序列」是这两个字符串所共同拥有的子序列。
若这两个字符串没有公共子序列,则返回 0。
示例:
输入:text1 = "abcde", text2 = "ace"
输出:3
解释:最长公共子序列是 "ace",它的长度为 3。
思想:
像这种两个字符串的情况,应当考虑二维dp数组,一维无法解决。dp[i][j]表示text1第i位置之前与text2第j位置之前的最长公共子序列长度。
状态转移方程分两种情况:
- 当text1[i]==text2[j]时:dp[i][j] = dp[i-1][j-1] + 1;
- 当text1[i]!=text2[j]时:dp[i][j] = Math.max(dp[i-1][j],dp[i][j-1]);
两层循环遍历二维数组,可以保证dp[i-1][j-1]、dp[i-1][j]、dp[i][j-1]都在已经遍历过的位置。所以可以用此动态规划思想来做。
注意:为了避免临界溢出,可以把数组长度设为len+1:int[][] dp = new int[len1+1][len2+1];
代码:
class Solution {
public int longestCommonSubsequence(String text1, String text2) {
char[] arr1 = text1.toCharArray();
char[] arr2 = text2.toCharArray();
int len1 = arr1.length,len2 = arr2.length;
int[][] dp = new int[len1+1][len2+1];
for(int i=1;i<len1+1;++i){
for(int j=1;j<len2+1;++j){
if(arr1[i-1]==arr2[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[len1][len2];
}
}
分割等和子集
五星
LeetCode:分割等和子集
题目描述:
给定一个只包含正整数的非空数组。是否可以将这个数组分割成两个子集,使得两个子集的元素和相等。
注意:
每个数组中的元素不会超过 100
数组的大小不会超过 200
示例:
输入: [1, 5, 11, 5]
输出: true
解释: 数组可以分割成 [1, 5, 5] 和 [11].
思想:
问题看成背包大小为sum/2的01背包问题。
因为只需要从0的位置开始转化,dp数组可以定义为布尔类型。
代码:
class Solution {
public boolean canPartition(int[] nums) {
int sum = 0;
for(int item : nums){
sum +=item;
}
if(sum%2!=0) return false;
sum /= 2;
boolean[] dp = new boolean[sum+1];
dp[0] = true;
for(int item : nums){
for(int j=sum;j>=item;--j){
dp[j] = dp[j]||dp[j-item];
}
}
return dp[sum];
}
}
目标和
五星
LeetCode:目标和
题目描述:
给定一个非负整数数组,a1, a2, ..., an, 和一个目标数,S。现在你有两个符号 + 和 -。对于数组中的任意一个整数,你都可以从 + 或 -中选择一个符号添加在前面。
返回可以使最终数组和为目标数 S 的所有添加符号的方法数。
示例:
输入:nums: [1, 1, 1, 1, 1], S: 3
输出:5
解释:
-1+1+1+1+1 = 3
+1-1+1+1+1 = 3
+1+1-1+1+1 = 3
+1+1+1-1+1 = 3
+1+1+1+1-1 = 3
一共有5种方法让最终目标和为3。
思想:
- 动态规划-背包
转化为总容量为 sum = (S + sum)/2;的01背包问题。这点很难想到,想到了就很容易做。
把dp[0]标为1,状态转移dp[j] = dp[j] + dp[j-item];,最后结果即为方法数量。 - DFS暴力
代码:
- 背包
class Solution {
public int findTargetSumWays(int[] nums, int S) {
int len = nums.length;
int sum =0;
for(int item : nums){
sum += item;
}
if(S>sum) return 0;
if((S+sum)%2!=0) return 0;
sum = (S + sum)/2;
int[] dp = new int[sum+1];
dp[0] = 1;
for(int item : nums){
for(int j=sum;j>=item;--j){
dp[j] = dp[j] + dp[j-item];
}
}
return dp[sum];
}
}
- DFS
class Solution {
public int findTargetSumWays(int[] nums, int S) {
return count(nums, 0, S);
}
private int count(int[] nums, int i, int S){
if(i==nums.length){
return S==0?1:0;
}
return count(nums,i+1,S-nums[i]) + count(nums,i+1,S+nums[i]);
}
}
一和零
五星
LeetCode:一和零
题目描述:
在计算机界中,我们总是追求用有限的资源获取最大的收益。
现在,假设你分别支配着 m 个 0 和 n 个 1。另外,还有一个仅包含 0 和 1 字符串的数组。
你的任务是使用给定的 m 个 0 和 n 个 1 ,找到能拼出存在于数组中的字符串的最大数量。每个 0 和 1 至多被使用一次。
注意:
给定 0 和 1 的数量都不会超过 100。
给定字符串数组的长度不会超过 600。
示例:
输入: Array = {"10", "0001", "111001", "1", "0"}, m = 5, n = 3
输出: 4
解释: 总共 4 个字符串可以通过 5 个 0 和 3 个 1 拼出,即 "10","0001","1","0" 。
思想:
二维费用背包问题,计算dp两层循环
代码:
class Solution {
public int findMaxForm(String[] strs, int m, int n) {
int[][] dp = new int[m+1][n+1];
for(String str : strs){
int n0 = 0;
int n1 = 0;
for(int i=0;i<str.length();++i){
if(str.charAt(i)=='0') n0++;
else n1++;
}
for(int i=m;i>=n0;--i){
for(int j=n;j>=n1;--j){
dp[i][j] = Math.max(dp[i][j],dp[i-n0][j-n1]+1);
}
}
}
return dp[m][n];
}
}
零钱兑换
LeetCode:零钱兑换
题目描述:
给定不同面额的硬币 coins 和一个总金额 amount。编写一个函数来计算可以凑成总金额所需的最少的硬币个数。如果没有任何一种硬币组合能组成总金额,返回 -1。
你可以认为每种硬币的数量是无限的。
示例:
输入:coins = [1, 2, 5], amount = 11
输出:3
解释:11 = 5 + 5 + 1
思想:
完全背包的变种。因为要求恰好能达到目标容量,所以程序运行到最后时,dp[amount]一定是从dp[0]转移过来的。所以可以把dp[0]初始化为 -amount-1 ,每次放置硬币时,价值+1,总价值不可能超过amount。所以最后结果从dp[0]转化而来的结果一定小于0,从其它项转化来的都大于等于0。最后的dp[amount]如果大于等于0,则不存在硬币组合,返回-1,否则返回 dp[amount]+amount+1 即为需要的硬币数量。
代码:
public int coinChange(int[] coins, int amount) {
int[] dp = new int[amount+1];
dp[0] = -amount-1;
for(int coin : coins){
for(int i=coin;i<=amount;++i){
dp[i] = Math.min(dp[i],dp[i-coin]+1);
}
}
if(dp[amount]>=0) return -1;
return dp[amount]+amount+1;
}
零钱兑换2
LeetCode:零钱兑换2
题目描述:
给定不同面额的硬币和一个总金额。写出函数来计算可以凑成总金额的硬币组合数。假设每一种面额的硬币有无限个。
示例:
输入: amount = 5, coins = [1, 2, 5]
输出: 4
解释: 有四种方式可以凑成总金额:
5=5
5=2+2+1
5=2+1+1+1
5=1+1+1+1+1
思想:
基于完全背包的变种。amount相当于总容量,coin相当于每个物品的体积。但是本体的dp[i]的值就不代表容量为i时的总价值了,应该指代物品容量为i时的总组合数,相对应的,状态转移方程也需要调整。每次遍历到容量为 i 且需要放置物品时,不放该物品的组合数为dp[i],放该物品组合数为dp[i-coin],此处的组合数dp[i]应该等于两者之和。
注意:需要把dp[0]初始化为1,保证在放置第一个物品时,容量i=coin情况下,组合数为1
代码:
public int change(int amount, int[] coins) {
int[] dp = new int[amount+1];
dp[0] = 1;
for(int coin : coins){
for(int i=coin;i<=amount;++i){
dp[i] = dp[i] + dp[i-coin];
}
}
return dp[amount];
}
单词拆分
五星
LeetCode:单词拆分
题目描述:
给定一个非空字符串 s 和一个包含非空单词的列表 wordDict,判定 s 是否可以被空格拆分为一个或多个在字典中出现的单词。
说明:
- 拆分时可以重复使用字典中的单词。
- 你可以假设字典中没有重复的单词。
示例:
输入: s = "leetcode", wordDict = ["leet", "code"]
输出: true
解释: 返回 true 因为 "leetcode" 可以被拆分成 "leet code"。
思想:
不一定非得套着背包问题的模板来做,回归dp思想。dp[i] 表示s中以 i 为结尾的左半字符串是否可以由字典中的词组成。于是很自然得想到外层遍历s每个字符,内层遍历字典中每个词 str,比较dp[i] 和减去str长度位置的dp值,对dp数组进行更新。
优化:内层循环若遇到dp[i]值为true了,可以直接break,无需继续遍历字典其它词了。
代码:
public boolean wordBreak(String s, List<String> wordDict) {
int len = s.length();
boolean[] dp = new boolean[len+1];
dp[0] = true;
for(int i=1;i<=len;++i){
for(String item : wordDict){
if(dp[i]) break;
int k = i - item.length();
if(k<0||!item.equals(s.substring(k,i))) continue;
dp[i] = dp[k]||dp[i];
}
}
return dp[len];
}
组合总和4
LeetCode:组合总和4
题目描述:
给定一个由正整数组成且不存在重复数字的数组,找出和为给定目标正整数的组合的个数。
示例:
nums = [1, 2, 3]
target = 4
所有可能的组合为:
(1, 1, 1, 1)
(1, 1, 2)
(1, 2, 1)
(1, 3)
(2, 1, 1)
(2, 2)
(3, 1)
请注意,顺序不同的序列被视作不同的组合。
因此输出为 7。
思想:
规律:像这种要考虑顺序的背包问题,要把对容量的遍历放在外层,物品的循环放在内层。
换种思路去理解:假设物品1到n,对于每一个容量K而言(K<=target),要从前一步抵达K的位置,有1到n种可能。假设某物品体积为v,对于容量K-v也同样是遍历过n个物品,所以应该在内层循环遍历n个物品,这样一定枚举了所有排列情况。
代码:
public int combinationSum4(int[] nums, int target) {
int[] dp = new int[target+1];
dp[0] = 1;
for(int i=1;i<=target;++i){
for(int item : nums){
if(i>=item) dp[i] += dp[i-item];
}
}
return dp[target];
}
买卖股票的最佳时机含手续费
LeetCode:买卖股票的最佳时机含手续费
题目描述:
给定一个整数数组 prices,其中第 i 个元素代表了第 i 天的股票价格 ;非负整数 fee 代表了交易股票的手续费用。
你可以无限次地完成交易,但是你每笔交易都需要付手续费。如果你已经购买了一个股票,在卖出它之前你就不能再继续购买股票了。
返回获得利润的最大值。
注意:这里的一笔交易指买入持有并卖出股票的整个过程,每笔交易你只需要为支付一次手续费。
示例:
输入: prices = [1, 3, 2, 8, 4, 9], fee = 2
输出: 8
解释: 能够达到的最大利润:
在此处买入 prices[0] = 1
在此处卖出 prices[3] = 8
在此处买入 prices[4] = 4
在此处卖出 prices[5] = 9
总利润: ((8 - 1) - 2) + ((9 - 4) - 2) = 8.
思想:
我最开始想到如下方法:
public int maxProfit(int[] prices, int fee) {
int[] dp = new int[prices.length];
int max = 0;
for(int i=0;i<prices.length;++i){
dp[i] = max;
for(int j=0;j<i;++j){
int profit = prices[i] - prices[j]- fee + dp[j];
if(profit>max) dp[i] = max = profit;
}
}
System.out.println(Arrays.toString(dp));
return max;
}
使用dp数组存储每一天的最大利润,外层对每一天进行遍历,内层循环遍历该天之前的所有天数,第i天与第j天进行买卖交易的利润加上第j天自身积累的利润之和,与第i天不进行卖出的当前积累的利润,二者进行比较取最大值,可以完成动态规划操作。但是,该做法会报超时。实际上,顺着这个思路可以优化代码,将内层循环去掉。
主要理解最核心的一点:第j天结束时的利润,相当于变相降低了第j天的价格,每天结束时,用当天价格减去当前利润,得到一个值,这里把它称为“实际价格”吧。用一个变量minPrice记录第 i 天以前的最小“实际价格”,在第 i 天只需要跟最小实际价格比较判断就行,不需要循环遍历 i 之前的所有元素。用一个变量 maxProfit 记录最大利润,这样甚至都能把 dp 数组省略。
代码:
public int maxProfit(int[] prices, int fee) {
int maxProfit = 0;
int minPrice = 50000;
for(int i=0;i<prices.length;++i){
maxProfit = Math.max(prices[i] - minPrice - fee, maxProfit);
minPrice = Math.min(minPrice,prices[i]-maxProfit);
}
return maxProfit;
}
买卖股票的最佳时机3
五星
LeetCode:买卖股票的最佳时机3
题目描述:
给定一个数组,它的第 i 个元素是一支给定的股票在第 i 天的价格。
设计一个算法来计算你所能获取的最大利润。你最多可以完成 两笔 交易。
注意: 你不能同时参与多笔交易(你必须在再次购买前出售掉之前的股票)。
示例:
输入: [3,3,5,0,0,3,1,4]
输出: 6
解释: 在第 4 天(股票价格 = 0)的时候买入,在第 6 天(股票价格 = 3)的时候卖出,这笔交易所能获得利润 = 3-0 = 3 。
随后,在第 7 天(股票价格 = 1)的时候买入,在第 8 天 (股票价格 = 4)的时候卖出,这笔交易所能获得利润 = 4-1 = 3 。
思想:
这题是在121的基础上改进。第一次交易的利润,相当于抵消一部分第二次购买付出的钱,想明白这一点就很好做了。在循环遍历数组的过程中,能算出以每一天为截止日期第一次交易的最大利润,设为profit1,第二次交易的买入价格为数组的每一项减去遍历到该项时的profit1,可以想象成形成了一个新的价格数组,对这个新数组求第二次交易的最大利润,即为两次次交易的最大利润。
代码:
class Solution {
public int maxProfit(int[] prices) {
if(prices.length==0) return 0;
int maxProfit1=0;
int min1 = prices[0];
int maxProfit2=0;
int min2 = prices[0];
for(int item : prices){
min1=Math.min(min1,item);
maxProfit1 = Math.max(maxProfit1,item-min1);
min2=Math.min(min2,item-maxProfit1);
maxProfit2=Math.max(maxProfit2,item-min2);
}
return maxProfit2;
}
}
买卖股票的最佳时机4
五星
LeetCode:买卖股票的最佳时机4
题目描述:
给定一个整数数组 prices ,它的第 i 个元素 prices[i] 是一支给定的股票在第 i 天的价格。
设计一个算法来计算你所能获取的最大利润。你最多可以完成 k 笔交易。
注意:你不能同时参与多笔交易(你必须在再次购买前出售掉之前的股票)。
示例:
输入:k = 2, prices = [3,2,6,5,0,3]
输出:7
解释:在第 2 天 (股票价格 = 2) 的时候买入,在第 3 天 (股票价格 = 6) 的时候卖出, 这笔交易所能获得利润 = 6-2 = 4 。
随后,在第 5 天 (股票价格 = 0) 的时候买入,在第 6 天 (股票价格 = 3) 的时候卖出, 这笔交易所能获得利润 = 3-0 = 3 。
思想:
这题是上一题的改进版,由两次股票交易扩展到k次。于是需要一个数组 int[] min 记录第j天之前最小价格,和数组 int[] maxProfit 记录第j天之前的最大利润。本题的解法上上一题的基础上,增加了一层for循环遍历k。除了这一点,还有一点也不太一样。上一题中,第二次交易的利润profit2,就是两笔交易加起来的总利润,本题中maxProfit[k-1]并不是总利润,而仅仅是第k次交易的最大利润,需要将maxProfit[0]~maxProfit[k-1] 全部加起来,才能得到总利润。所以代码中才会有这一句 if(i==prices.length-1) res+=maxProfit[j];
累加得到总利润
这是为什么呢?
因为上一题中,通过 item-min2 计算第二次交易的利润,使用的 item 依然是原始价格,而min2是减去第一次利润值之后取得最小值,因此 item-min2 相当于把两次交易利润都囊括进去了。但是,在本题中,每一次交易完,都进行price = price - maxProfit[j];
操作。而当前交易使用 price-min[j] 来计算最大利润,使用的是减过上一次交易利润的价格,min[j]也是减过上一次交易利润的最小值,相当于抵消了上一次交易利润值,所以内层的每一次循环而言,都是对一个全新的prices数组求单次股票交易的最大利润。所以maxProfit[j]记录的是第j次交易的最大利润,最后需要累加求和。
代码:
class Solution {
public int maxProfit(int k, int[] prices) {
int[] min = new int[k];
int[] maxProfit = new int[k];
int res = 0;
Arrays.fill(min,1001);
for(int i=0;i<prices.length;++i){
int price = prices[i];
for(int j=0;j<k;++j){
min[j] = Math.min(min[j],price);
maxProfit[j] = Math.max(maxProfit[j],price-min[j]);
price = price - maxProfit[j];
if(i==prices.length-1) res+=maxProfit[j];
}
}
return res;
}
}
只有两个键的键盘
五星
LeetCode:只有两个键的键盘
题目描述:
最初在一个记事本上只有一个字符 'A'。你每次可以对这个记事本进行两种操作:
1.Copy All (复制全部) : 你可以复制这个记事本中的所有字符(部分的复制是不允许的)。
2.Paste (粘贴) : 你可以粘贴你上一次复制的字符。
给定一个数字 n 。你需要使用最少的操作次数,在记事本中打印出恰好 n 个 'A'。输出能够打印出 n 个 'A' 的最少操作次数。
示例:
输入: 3
输出: 3
解释:
最初, 我们只有一个字符 'A'。
第 1 步, 我们使用 Copy All 操作。
第 2 步, 我们使用 Paste 操作来获得 'AA'。
第 3 步, 我们使用 Paste 操作来获得 'AAA'。
思想:
方法一(动态规划):先要找出状态转移方程。设dp[k]为n等于k时的最小操作数。若k能被2整除,在k/2基础上进行copy All+Paste两次操作一定是最优的,即dp[k]=dp[k/2]+2。若k能被3整除,dp[k]=dp[k/3]+3,即在k/3基础上进行copy All+Paste+Paste三次操作。于是可以想到,用一个循环在2~k之间逐一试探,找到可以被k整除的 j,dp[k]=dp[k/j]+j。目的是要使增加的操作数尽可能小,所以j一定是从最小开始遍历,即从2开始。按照直观的动态规划思想,外层遍历dp[2]dp[n],内层循环遍历2n寻找可以被k整除的最小的 j 值,于是有了下方的代码。
方法二(优化):上述方法中外层遍历了dp[2]~dp[n]的所有情况,有很多不必要的冗余计算,因为实际上只需要遍历抵达dp[n]的所有路径结点即可。那么如何做呢。正向从1向n遍历不太好做,因为不知道哪个方向能抵达终点,但是如果从终点向起点遍历就很容易了。于是,也很容易想到可以借助递归。
代码:
方法一(动态规划):
public int minSteps(int n) {
int[] dp = new int[n+1];
for(int i=2;i<=n;++i){
for(int j=2;j<=n;++j){
if(i%j==0){
dp[i] = dp[i/j]+j;
break;
}
}
}
return dp[n];
}
方法二(优化):
public int minSteps(int n) {
if(n==1) return 0;
int j = 2;
while(n%j!=0){
j++;
}
return minSteps(n/j)+j;
}