动态规划(dp)思想
动态规划 Dynamic programming => DP
动态规划:普通递归 + dp数组记录,达到空间换时间
动态规划一般都不会很高效,因为dp[]记录了途中每个状态最优解
尽量找巧妙的方法,来做到比dp效果更好的解法
dp三大性质:
- 最优子结构
- 子问题重叠
- 无后效性(算好的dp[]不会再改)
dp四步走:
- 拆分出子问题
- 子问题的递推公式(状态转移方程)
- 确定 DP 数组的计算顺序、并初始化
- 空间优化(可选)
动态规划vs贪心算法
贪心算法是一种特殊的动态规划算法
对于一个动态规划问题,问题的最优解往往包含重复的子问题的最优解,动态规划就是为了消除重复的子问题
而贪心算法由于每一次都贪心选取一个子问题,所以不会重复计算子问题的最优解
dp问题大致分类
- 【斐波拉切】(跳台阶系列)
- 【递推型】(丑数、剪绳子、圆圈最后数)
- 【划分型】间断序列-最值(打家劫舍、股票类、不连续子序列)
- 【二维坐标型】机器人棋盘路径
- 【区间型】连续序列-最值(拿石子、连续子序列)=》二维dp[][],双指针
- 【背包型】(背包(sum<k)、和为K的序列(未必连续)、零钱兑换)
- 【树型】(树状递归、dp;经常划到树而不是动态规划)
上面前三个是一维dp[],然后是两个二维dp[][],背包型可能1维、2维、多维
斐波拉切型
1. 斐波那契数列
大家都知道斐波那契数列,现在要求输入一个整数n,请你输出斐波那契数列的第n项(从0开始,第0项为0,第1项是1)。n≤39
public class Solution {
public int Fibonacci(int n) {
int[] fi=new int[40];
fi[0]=0;fi[1]=1;
for(int i=2;i<=n;i++){
fi[i]=fi[i-1]+fi[i-2];
}
return fi[n];
}
}
2. 跳台阶
一只青蛙一次可以跳上1级台阶,也可以跳上2级。求该青蛙跳上一个n级的台阶总共有多少种跳法(先后次序不同算不同的结果)。
1)斐波拉切-O(N)动态规划
public class Solution {
public int JumpFloor(int target) {
int frog[]=new int[100];
frog[1]=1;frog[2]=2;
for (int i=3;i<=target;i++){
frog[i]=frog[i-1]+frog[i-2];
}
return frog[target];
}
}
2)空间O(1)的方法
public class Solution {
public int jumpFloor(int target) {
if(target<=2)return target;
int lastOne = 2;
int lastTwo = 1;
int res = 0;
for(int i=3; i<=target; ++i){
res = lastOne + lastTwo;
lastTwo = lastOne;
lastOne = res;
}
return res;
}
}
3. 跳台阶扩展问题
1)找出公式
public class Solution {
public int JumpFloorII(int target) {
int way=1;for(int i=1;i<target;i++)way*=2;return way;
}
}
2)(动态规划)硬算
public class Solution {
public int jumpFloorII(int target) {
int[] array =new int[100];
array[1] = 1;
for(int i=2; i<=target; ++i){
int sum = 0;
for(int j=1; j<=i-1; ++j)sum+=array[j];
array[i] = sum +1;
}
return array[target];
}
}
4. 矩形覆盖
我们可以用21的小矩形横着或者竖着去覆盖更大的矩形。请问用n个21的小矩形无重叠地覆盖一个2n的大矩形,总共有多少种方法? 比如n=3时,23的矩形块有3种覆盖方法:
public class Solution {
public int rectCover(int target) {
int fi[] = new int[100];
for(int i= 0; i<=2; ++i)fi[i]=i;
for(int i=3; i<=target; ++i)fi[i]=fi[i-1]+fi[i-2];
return fi[target];
}
}
递推型
1. 丑数
把只包含质因子2、3和5的数称作丑数(Ugly Number)。例如6、8都是丑数,但14不是,因为它包含质因子7。 习惯上我们把1当做是第一个丑数。求按从小到大的顺序的第N个丑数。
import java.lang.Math;
public class Solution {
public int GetUglyNumber_Solution(int index) {
int ugly [] = new int [2000];
ugly[1] = 1;
int t2 = 1;
int t3 = 1;
int t5 = 1;
for(int i=2; i<=index; ++i){
ugly[i] = Math.min(Math.min(2*ugly[t2],3*ugly[t3]),5*ugly[t5]);
if(ugly[i] == 2*ugly[t2]) ++t2;
if(ugly[i] == 3*ugly[t3]) ++t3;
if(ugly[i] == 5*ugly[t5]) ++t5;
}
return ugly[index];
}
}
2. 剪绳子
给你一根长度为n的绳子,请把绳子剪成整数长的m段(m、n都是整数,n>1并且m>1),每段绳子的长度记为k[0],k[1],…,k[m]。请问k[0]xk[1]x…xk[m]可能的最大乘积是多少?例如,当绳子的长度是8时,我们把它剪成长度分别为2、3、3的三段,此时得到的最大乘积是18。(2 <= n <= 60)
方法一:数学函数求导法
【总体思路】构造函数求导,得到:m=n/e (小数的情况下),也就是说尽量拆成一堆:2、3(最接近e的整数)
数学函数求导法:针对性规律 result= f(m) = (n/m) ^m,设n为定值,m为自变量,f(m)为乘积结果。 max{ f(m) }= max{ ln f(m) },取对数。 求 ln f(m)= m*( ln n- ln m )最值点的m值,求导并令f(m)’=0,得到m=n/e. e=2.718,然后因为取整数,所以是拆成一堆2、3; 具体看下:4>>>22;5>>>23;6>>>3*3 符合分析的结果。
public class Solution {
public int cutRope(int target) {
if(target == 2)return 1;
if(target == 3)return 2;
int res = 1;
while(target > 4 ){
target -= 3;
res *= 3;
}
return res * target;
}
}
方法二:动态规划
【总体思路】dp[] 存一步步的最优 + 找到 递推公式
public class Solution {
int[] dp = new int[60];
public int cutRope(int target) {
if(target == 2) return 1;
if(target == 3) return 2;
dp[2] = 2;
dp[3] = 3;
for(int i=4; i<=target; ++i){
int max = Integer.MIN_VALUE;
for(int j=2; j<=i-1; ++j){
if(max < dp[j]*(i-j)) max = dp[j]*(i-j);
}
dp[i] = max;
}
return dp[target];
}
}
3. 孩子们的游戏(圆圈中最后剩下的数)
每年六一儿童节,牛客都会准备一些小礼物去看望孤儿院的小朋友,今年亦是如此。HF作为牛客的资深元老,自然也准备了一些小游戏。其中,有个游戏是这样的:首先,让小朋友们围成一个大圈。然后,他随机指定一个数m,让编号为0的小朋友开始报数。每次喊到m-1的那个小朋友要出列唱首歌,然后可以在礼品箱中任意的挑选礼物,并且不再回到圈中,从他的下一个小朋友开始,继续0…m-1报数…这样下去…直到剩下最后一个小朋友,可以不用表演,并且拿到牛客名贵的“名侦探柯南”典藏版(名额有限哦!!_)。请你试着想下,哪个小朋友会得到这份礼品呢?(注:小朋友的编号是从0到n-1;报数0到m-1)
如果没有小朋友,请返回-1
方法一:朴素模拟法 O(m*n)
public class Solution {
public int LastRemaining_Solution(int n, int m) {
if(n<=0 || m<=0)return -1;
ListNode head = new ListNode(0);
ListNode p = head;
for(int i=1; i<=n-1; ++i){
ListNode node = new ListNode(i);
p.next = node;
p = p.next;
}
p.next = head;
for(int i=1; i<=n-1; ++i){
for(int j=1; j<=m-1; ++j){
p=p.next;
}
p.next = p.next.next;
}
return p.val;
}
}
方法二:数学归纳法 O(n) [分析是难点]
public class Solution {
public int LastRemaining_Solution(int n, int m) {
if(n<=0)return -1;
int res = 0;
for(int i=2; i<=n; i++){
res = (res + m) % i;
}
return res;
}
}
数学归纳法分析:
数学归纳法: f(n,m)表示:【相对参考系:从0位置开始,最终到达f(n,m)位置】//例如:从0开始,最终到达f(5,3)=3的位置 f(1,m)=0;//首项 f(n,m)=[(m%n) + f(n-1,m)]%n;//公式化简为:f(n,m)=[m + f(n-1,m)]%n //相邻项关系【重点,推导如下】: 例如: f(5,3)=[f(4,3)+ 3%5 ] %5=f(4,3)+3 什么意思? f(5,3)从0开始,0-1-2,删除2节点,然后来到3==》这时的情况就类似f(4,3)。但还有一点不一样,就是标准的f(4,3)从0开始,而这里从3开始 f(4,3)根据定义,必须从0开始(所有的f(i,m)的定义需要一致),而不是从3。所以必须进行【对齐操作】: 于是f(5,3)的参靠系里:先走3步,然后以3为起点,走f(4,3)步 ==》f(5,3)=3+ f(4,3) [这里是简化,再考虑%n的细节优化下就ok了] 有了递推公式,用递归法or迭代法,求解都几乎同理 迭代法:f(1,m)=0 f(2,m)=[m + f(1,m)]%n … 一直算到f(n,m)
划分型
1. 买卖股票的最佳时机 III
给定一个数组,它的第 i 个元素是一支给定的股票在第 i 天的价格。
设计一个算法来计算你所能获取的最大利润。你最多可以完成 两笔 交易。
注意:你不能同时参与多笔交易(你必须在再次购买前出售掉之前的股票)。
class Solution {
public int maxProfit(int[] prices) {
int buy1 = prices[0];
int buy2 = prices[0];
int sell1 = 0;
int sell2 = 0;
for(int i=0; i<=prices.length-1; ++i){
buy1 = Math.min(buy1, prices[i]);
sell1 = Math.max(sell1, prices[i]-buy1);
buy2 = Math.min(buy2, prices[i]-sell1);
sell2 = Math.max(sell2, prices[i]-buy2);
}
return sell2;
}
}
类似题目1 题目:只有一次买卖机会 解法:上面方法留下buy1和sell1即可
类似题目2 题目:k次买卖次数 解法:(难)上面方法加数组,加层循环
2. 买卖股票的最佳时机含手续费
每笔交易你只需要为支付一次手续费。
输入:prices = [1, 3, 2, 8, 4, 9], fee = 2 输出:8
class Solution {
public int maxProfit(int[] prices, int fee) {
int profit =0;
int buy = prices[0] + fee;
for(int i = 0; i<=prices.length-1; ++i){
if(buy > prices[i]+ fee)buy = prices[i]+ fee;
if(prices[i] > buy){
profit += (prices[i] - buy);
buy = prices[i];
}
}
return profit;
}
}
类似题目 题目:不限制买卖次数 解法:每步都操作,所有 “上坡” 都收下
3. 打家劫舍(不相邻最大子序列和)
给定一个代表每个房屋存放金额的非负整数数组,在不偷相邻两屋情况下 ,一夜之内能够偷窃到的最高金额。
关键点:
状态转移方程:dp[i] = dp[i-1] > dp[i-2]+nums[i-1] ? dp[i-1] : dp[i-2]+nums[i-1];
注意分析 dp[i]只需要和dp[i-1]、dp[i-2]的关系
class Solution {
public int rob(int[] nums) {
int len = nums.length;
int dp[] = new int[len + 1];
dp[0] = 0;
dp[1] = nums[0];
for(int i = 2; i<=len; ++i){
dp[i] = dp[i-1] > dp[i-2]+nums[i-1] ? dp[i-1] : dp[i-2]+nums[i-1];
}
return dp[len];
}
}
空间优化:
class Solution {
public int rob(int[] nums) {
int len = nums.length;
int two = 0;
int one = nums[0];
int zero = nums[0];
for(int i = 2; i<=len; ++i){
zero = one > two+nums[i-1] ? one : two+nums[i-1];
two = one;
one = zero;
}
return zero;
}
}
4. 最长递增子序列
方法一:两重循环完全dp
描述: 一维数组dp[i]用于存储0-i序列的最大res,初始化为1 i、j 两重循环,当 nums[j] < nums[i] 时: dp[i] = Math.max(dp[i], dp[j] + 1); 时间O(N^2) 空间O(N)
方法二:设置辅助数组 时间O(N*logN) 空间O(N)
import java.util.ArrayList;
class Solution {
public int lengthOfLIS(int[] nums) {
ArrayList<Integer> min = new ArrayList<Integer>();
min.add(nums[0]);
for(int num:nums){
int left = 0;
int right = min.size()-1;
if(num > min.get(right))
min.add(num);
else{
while(left < right){
int mid = (left+right)/2;
if(num <= min.get(mid)) right = mid;
else left = mid+1;
}
min.set(right,num);
}
}
return min.size();
}
}
代码简短些的方法:
public class Solution {
public int LIS(int[] arr) {
int[] min = new int[arr.length];
int count = 0;
for(int num:arr){
int left = 0;
int right = count;
while(left<right){
int mid = (left+right)/2;
if(num>min[mid])left = mid+1;
else right = mid;
}
if(count == right) ++count;
min[right]=num;
}
return count;
}
}
深化:要求输出最优序列(长度相同时,要求每个位置尽量小)
public class Solution {
public int[] LIS(int[] arr) {
int n = arr.length;
int[] min = new int[n];
int[] index = new int[n];
int count = 0;
for(int i=0; i<=n-1; ++i){
int left = 0;
int right = count;
while(left<right){
int mid = (left+right)/2;
if(arr[i]>min[mid])left = mid+1;
else right = mid;
}
if(count == right) ++count;
min[right]=arr[i];
index[i] = right;
}
int[] res = new int[count];
for(int i= n-1,k = count-1; i>=0; --i){
if(k == index[i])res[k--]=arr[i];
}
return res;
}
}
二维坐标型
1. 不同路径
方法一:动态规划
关键点:
1)状态转移方程:path[ i ][ j ] = path[ i-1 ][ j ] + path[ i ][ j-1 ];
2)初始化:第一行第一列初始化为1(因为都是只有一种方法)
class Solution {
public int uniquePaths(int m, int n) {
int[][] path = new int [m][n];
for(int i=0; i<=m-1; ++i)path[i][0]=1;
for(int j=0; j<=n-1; ++j)path[0][j]=1;
for(int i=1; i<=m-1; ++i){
for(int j=1; j<=n-1; ++j){
path[i][j]=path[i-1][j]+path[i][j-1];
}
}
return path[m-1][n-1];
}
}
时间 O(mn)、空间 O(mn)
方法二:组合数学 M x N 网格,从左上到右下 分析:一共(M-1)+(N-1)步,其中向右(M-1)步,所以是: 时间 O(n) 、空间O(1)
2. 最小路径和
给定一个包含非负整数的 m x n 网格 grid ,请找出一条从左上角到右下角的路径,使得路径上的数字总和为最小。
public class Solution {
public int minPathSum(int[][] matrix) {
int row = matrix.length;
int col = matrix[0].length;
int[][]sum = new int[row][col];
int s = 0;
for(int i=0; i<=row-1; ++i){
s += matrix[i][0];
sum[i][0] = s;
}
s = 0;
for(int j=0; j<=col-1; ++j){
s += matrix[0][j];
sum[0][j] = s;
}
for(int i=1; i<=row-1; ++i){
for(int j=1; j<=col-1; ++j){
sum[i][j] = sum[i-1][j]<sum[i][j-1] ? sum[i-1][j]+matrix[i][j] : sum[i][j-1]+matrix[i][j];
}
}
return sum[row-1][col-1];
}
}
优化 Tips:
1)可以直接修改题目提供的grid数组,这样空间就是O(1)了。
2)如果使用sum[row+1][col+1]数组,虚拟的边上为0,就可以统一步骤、不用初始化的两个循环了。
类似题目 题目:三角形最小路径和 解法: 设置一个最小和的辅助dp数组 第1步:左边界、右边界只有一个"父";先把边计算好 第2步:里面的点来自两个"父"的min 第3步:取最下面一层的最小值 (空间优化:只保留计算的上一行即可)
3. 矩阵中的路径
请设计一个函数,用来判断在一个矩阵中是否存在一条包含某字符串所有字符的路径。路径可以从矩阵中的任意一个格子开始,每一步可以在矩阵中向左,向右,向上,向下移动一个格子。如果一条路径经过了矩阵中的某一个格子,则该路径不能再进入该格子。例如: 矩阵中包含一条字符串"bcced"的路径,但是矩阵中不包含"abcb"路径,因为字符串的第一个字符b占据了矩阵中的第一行第二个格子之后,路径不能再次进入该格子。
【总体思路】(dfs+剪枝) x 多个起点
public class Solution {
public boolean hasPath (char[][] matrix, String word) {
boolean flag[][] = new boolean[matrix.length][matrix[0].length];
for(int i = 0; i<= matrix.length-1; ++i){
for(int j=0; j<=matrix[0].length-1; ++j){
if(dfs(matrix, word, i, j, 0, flag)==true)return true;
}
}
return false;
}
public boolean dfs(char[][] matrix, String word, int i, int j, int count, boolean flag[][]){
if(0<=i && i<= matrix.length-1 && 0<=j && j<=matrix[0].length-1){
if(matrix[i][j] == word.charAt(count) && flag[i][j]==false){
++count;
if(count == word.length())return true;
flag[i][j] = true;
if(dfs(matrix, word, i+1, j, count, flag)
|| dfs(matrix, word, i-1, j, count, flag)
|| dfs(matrix, word, i, j+1, count, flag)
|| dfs(matrix, word, i, j-1, count, flag)
)return true;
flag[i][j] = false;
}
}
return false;
}
}
4. N皇后问题
牛客N皇后 两种方法都是基于动态规划(回溯):
方法一:二维数组+4个方向探索+起点只从第一行开始+设置二维flag表示是否可能新落子
方法二:一位数组(i、A[i]分别表示行、列)+单向探索
回溯的本质是" 科学地 穷举 "
本题时间复杂度O( N! )
5. 机器人的运动范围
地上有一个m行和n列的方格。一个机器人从坐标0,0的格子开始移动,每一次只能向左,右,上,下四个方向移动一格,但是不能进入行坐标和列坐标的数位之和大于k的格子。 例如,当k为18时,机器人能够进入方格(35,37),因为3+5+3+7 = 18。但是,它不能进入方格(35,38),因为3+5+3+8 = 19。请问该机器人能够达到多少个格子?
【总体思路】dfs+剪枝
public class Solution {
int count = 0;
public int movingCount(int threshold, int rows, int cols) {
boolean flag[][] = new boolean[rows][cols];
dfs(threshold, rows, cols, 0, 0, flag);
return count;
}
public void dfs(int threshold, int rows, int cols, int i, int j, boolean flag[][]){
if(0<=i && i<=rows-1 && 0<=j && j<=cols-1){
if(flag[i][j] == false && i/10 + i%10 + j/10 + j%10 <= threshold){
++count;
flag[i][j] = true;
dfs(threshold, rows, cols, i+1, j, flag);
dfs(threshold, rows, cols, i-1, j, flag);
dfs(threshold, rows, cols, i, j+1, flag);
dfs(threshold, rows, cols, i, j-1, flag);
}
}
}
}
区间型
1. 最长回文子串
给定字符串A以及它的长度n,请返回最长回文子串的长度。 输入:“abc1234321ab”,12 返回值:7
方法一:暴力求解(穷举) i、j两重循环表示开始结束的坐标,然后O(N)判断是否对称 时间O(N^3) 空间O(1)
方法二:动态规划 dp [ i ] [ j ]存储:i开始、j结束的串 P(i,j) <-- P(i+1,j?1) 时间O(N^2) 空间O(N^2)
方法三:中心扩展法 从每个点开始, 同时向左右扩展、判断是否相等 时间O(N) x O(N) = O(N^2) 空间O(1)
public class Solution {
public int getLongestPalindrome(String A, int n) {
int maxLen = 0;
for(int i=0; i<=n-2; ++i){
int len1 = expand(A, i, i);
int len2 = expand(A, i, i+1);
int len = len1 > len2 ? len1 : len2;
if(len > maxLen) maxLen = len;
}
return maxLen;
}
public int expand(String A, int left, int right){
while(0<=left && right<=A.length()-1 && A.charAt(left)==A.charAt(right)){
--left;
++right;
}
return right-left-1;
}
}
方法四:Manacher 算法 时间O(N) 空间O(N) 算法较为复杂,后面再记一下
2. 石子游戏
A和B用几堆石子在做游戏。偶数堆石子排成一行,每堆都有正整数颗石子。 游戏以谁手中的石子最多来决出胜负。石子的总数是奇数,所以没有平局。 A和B轮流进行,A先开始。 每回合,玩家从行的开始或结束处取走整堆石头。 这种情况一直持续到没有更多的石子堆为止,此时手中石子最多的玩家获胜。 假设A和B都发挥出最佳水平,当A赢得比赛时返回 true ,当B赢得比赛时返回 false 。
方法一:动态规划
三角形阶梯dp数组,i,j分别是连续石子堆的左右边界,dp[i][j]表示领先石子数 初始化底层n个为石子堆数量,然后每次在边界增加一堆石子(左/右) dp[i][j] = Math.max(piles[i] - dp[i + 1][j], piles[j] - dp[i][j - 1]); res= dp[0][len-1] 时间O(N^2) 空间O(N^2)可优化为O(N)
方法二:直接 return true
==> 如果两人都是最佳策略,先手必胜
3. 连续子数组的最大和
输入一个整型数组,数组里有正数也有负数。数组中的一个或连续多个整数组成一个子数组。求所有子数组的和的最大值。要求时间复杂度为 O(n).
public class Solution {
public int FindGreatestSumOfSubArray(int[] array) {
if(array.length ==0)return 0;
int max = Integer.MIN_VALUE;
int currentSum = 0;
for(int i =0; i<=array.length-1; ++i){
currentSum += array[i];
if(currentSum > max) max=currentSum;
if(currentSum<0)currentSum=0;
}
return max;
}
}
背包型
1. 分割等和子集【0-1背包问题】
==》问题等价于:不连续子集和为 k(本题k=sum/2)
和【0-1背包问题】不同的是:背包要 <= k,这里是==k
class Solution {
public boolean canPartition(int[] nums) {
int sum = 0;
for(int num : nums)sum += num;
if((sum & 1) == 1)return false;
int target = sum/2;
boolean[] dp = new boolean[1+target];
dp[0] = true;
for(int i=0; i<=nums.length-1; ++i){
boolean[] mem = new boolean[1+target];
for(int k=0; k<=target; ++k){
if(dp[k]==true) mem[k]=true;
}
for(int k=0; k<=target; ++k){
if(mem[k]==true && k + nums[i]<=target){
dp[k + nums[i]]=true;
}
}
}
return dp[target];
}
}
空间优化:
class Solution {
public boolean canPartition(int[] nums) {
int sum = 0;
for(int num : nums)sum += num;
if((sum & 1) == 1)return false;
int target = sum/2;
boolean[] dp = new boolean[1+target];
dp[0] = true;
for(int i=0; i<=nums.length-1; ++i){
for(int k=target; k>=0; --k){
if(k-nums[i]>=0 && dp[k-nums[i]]==true)dp[k]=true;
}
}
return dp[target];
}
}
2. 零钱兑换【完全背包问题】
- 【0/1背包问题】:每个元素最多选取一次
- 【完全背包问题】:每个元素可以重复选择
- 【分类背包问题】:有多个背包,分别装不同东西,需要多重遍历
class Solution {
public int coinChange(int[] coins, int amount) {
int[] dp = new int[amount + 1];
Arrays.fill(dp, Integer.MAX_VALUE-1);
dp[0] = 0;
for(int i=1; i<=amount; ++i){
for(int j=0; j<=coins.length-1; ++j){
if(i-coins[j]>=0 && dp[i-coins[j]]+1 < dp[i]){
dp[i] = dp[i-coins[j]]+1;
}
}
}
if(dp[amount]<=amount) return dp[amount];
else return -1;
}
}
3. 一和零【分类背包问题】
给你一个二进制字符串数组 strs 和两个整数 m 和 n 。 请你找出并返回 strs 的最大子集的大小,该子集中 最多 有 m 个 0 和 n 个 1 。 如果 x 的所有元素也是 y 的元素,集合 x 是集合 y 的 子集 。
class Solution {
public int findMaxForm(String[] strs, int m, int n) {
int[][] dp = new int[m+1][n+1];
for(String str:strs){
int zeros = 0;
int ones = 0;
for(char c:str.toCharArray()){
if(c=='1')++ones;
else ++zeros;
}
for(int i=m; i>=0; --i){
for(int j=n; j>=0; --j){
if(i-zeros>=0 && j-ones>=0 && dp[i][j] < dp[i-zeros][j-ones] + 1){
dp[i][j] = dp[i-zeros][j-ones] + 1;
}
}
}
}
return dp[m][n];
}
}
|