小白学DP(动态规划)
Posted ZSYL
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了小白学DP(动态规划)相关的知识,希望对你有一定的参考价值。
动态规划刷题笔记
动态规划
动态规划算法通常用于求解具有某种最优性质的问题。在这类问题中,可能会有许多可行解。每一个解都对应于一个值,我们希望找到具有最优值的解。
动态规划算法与分治法类似,其基本思想也是将待求解问题分解成若干个子问题,先求解子问题,然后从这些子问题的解得到原问题的解。与分治法不同的是,适合于用动态规划求解的问题,经分解得到子问题往往不是互相独立的。若用分治法来解这类问题,则分解得到的子问题数目太多,有些子问题被重复计算了很多次。
如果我们能够保存已解决的子问题的答案,而在需要时再找出已求得的答案,这样就可以避免大量的重复计算,节省时间。我们可以用一个表来记录所有已解的子问题的答案。不管该子问题以后是否被用到,只要它被计算过,就将其结果填入表中。这就是动态规划法的基本思路。
概念
动态规划(英语: Dynamic programming,简称 DP) 是一种在数学、管理科学、计算机科学、经济学和生物信息学中使用的,通过把原问题分解为相对简单的子问题的方式求解复杂问题的方法。
动态规划算法的核心就是记住已经解决过的子问题的解;而记住求解的方式有两种:
自顶向下的备忘录法
比如:斐波拉契数列 Fibonacci。
public static int fibonacci(int n) {
if (n <= 1)
return 1;
if (n == 2)
return 2;
return fibonacci(n-1) + fibonacci(n-2);
}
我们分析以前写过的递归就会发现有很多节点被重复执行,如果在执行的时候把执行过的子节点保存起来,后面要用到的时候直接查表调用的话可以节约大量的时间。
public class Fibonacci {
public static void main(String[] args) {
//创建备忘录
int[] memo = new int[n+1];
System.out.println(fibonacci(7));
}
/**
* 自顶向下备忘录法
* @param n
* @param memo 备忘录
* @return
*/
public static int fibonacci(int n, int[] memo) {
// 如果已经求出了fibonacci(n)的值直接返回
if(memo[n] != 0) return memo[n];
// 否则将求出的值保存在 memo 备忘录中。
if(n<=2)
memo[n]=1;
else {
memo[n] = fibonacci(n-1, memo) + fibonacci(n-2, memo);
}
return memo[n];
}
}
这个方法是由上至下,比如求f(5),我们要求f(4)和f(3),求出来后放入备忘录,当求f(4)时需要f(3)和f(2),我们可以直接从备忘录取f(3)而不是再去求一遍。
自底向上的动态规划
备忘录法是利用了递归,上面算法不管怎样,计算 fib(6)的时候最后还是要计算出 fib(1), fib(2), fib(3) ……,那么何不先计算出 fib(1), fib(2), fib(3) ……,呢?这也就是动态规划的核心,先计算子问题,再由子问题计算父问题。
public class FibonacciPlus {
/**
* 自底向上的动态规划
* @param n
* @return
*/
public static int fib(int n) {
if(n<=0)return -1;
//创建备忘录
int[] memo = new int[n+1];
memo[0]=0;
memo[1]=1;
for(int i=2;i<=n;i++) {
memo[i]=memo[i-1]+memo[i-2];
}
return memo[n];
}
/**
* 参与循环的只有 i, i-1 , i-2 三项,可以优化空间
* @param n
* @return
*/
public static int fibPlus(int n) {
if(n<=0)return -1;
int memo_i_2=0;
int memo_i_1=1;
int memo_i=1;
for(int i=2;i<=n;i++) {
memo_i = memo_i_1+memo_i_2;
memo_i_2 = memo_i_1;
memo_i_1 = memo_i;
}
return memo_i;
}
}
例题
区域和检索-数组不可变
问题描述:
给定一个整数数组 nums,求出数组从索引 i 到 j(i ≤ j)范围内元素的总和,包含 i、j 两点。
解法:
- 暴力循环从下标i到j求和,如果检索次数较多,则会超出时间限制。
- 降低时间复杂度,最理想情况O(1),求前缀和,sumRange(i, j) = (0-j+1)-(0-i-1的和)
代码:
Java版
class NumArray {
int[] sums;
public NumArray(int[] nums) {
int n = nums.length;
sums = new int[n + 1];
for (int i = 0; i < n; i++) {
sums[i + 1] = sums[i] + nums[i];
}
}
public int sumRange(int i, int j) {
return sums[j + 1] - sums[i];
}
}
Python版
class NumArray:
def __init__(self, nums: List[int]):
self.sums = [0]
_sums = self.sums
for num in nums:
_sums.append(_sums[-1] + num)
def sumRange(self, i: int, j: int) -> int:
_sums = self.sums
return _sums[j+1] - _sums[i]
复杂度分析:
- 时间复杂度:
初始化需要O(n),每次检索、O(1),其中n是nums的长度。
初始化需要检索遍历数组nums的前缀和,时间复杂度O(n)。
- 空间复杂度:
空间复杂度:O(n)O(n),其中 nn 是数组 \\textit{nums}nums 的长度。需要创建一个长度为 n+1n+1 的前缀和数组
使用最小花费爬楼梯
问题描述:
数组的每个下标作为一个阶梯,第 i 个阶梯对应着一个非负数的体力花费值 cost[i](下标从 0 开始)。
每当你爬上一个阶梯你都要花费对应的体力值,一旦支付了相应的体力值,你就可以选择向上爬一个阶梯或者爬两个阶梯。
请你找出达到楼层顶部的最低花费。在开始时,你可以选择从下标为 0 或 1 的元素作为初始阶梯。
解法:
假设数组 cost 的长度为 n,则 n 个阶梯分别对应下标 0 到 n-1,楼层顶部对应下标 n,问题等价于计算达到下标 nn 的最小花费。可以通过动态规划求解。
创建长度为n+1的数组dp,dp[i]代表达到下标i的最小花费。
由于可以选择下标 00 或 11 作为初始阶梯,因此dp[0]=dp[1]=0。
当 2≤i≤n 时,可以从下标 i-1 使用 cost[i-1]的花费达到下标 i,或者从下标 i−2 使用cost[i-2] 的花费达到下标 i。为了使总花费最小,dp[i] 应取上述两项的最小值,因此状态转移方程如下:dp[i]=min(dp[i−1]+cost[i−1],dp[i−2]+cost[i−2])
Java版:
class Solution {
public int minCostClimbingStairs(int[] cost) {
int n = cost.length;
int[] dp = new int[n+1];
dp[0] = dp[1] = 0;
for (int i = 2; i <= n; i++) {
dp[i] = Math.min(dp[i-1]+cost[i-1], dp[i-2] + cost[i-2]);
}
return dp[n];
}
}
Python版:
class Solution:
def minCostClimbingStairs(self, cost: List[int]) -> int:
dp = [0, 0]
n = len(cost)
for i in range(2, n+1):
dp.append(min(dp[i-1]+cost[i-1], dp[i-2]+cost[i-2]))
return dp[n]
优化版:
上述代码的时间复杂度和空间复杂度都是 O(n)。注意到当 i≥2 时,dp[i] 只和 dp[i−1] 与 dp[i−2] 有关,因此可以使用滚动数组的思想,将空间复杂度优化到 O(1)。
class Solution {
public int minCostClimbingStairs(int[] cost) {
int n = cost.length;
int pre = 0, cur = 0, next = 0;
for (int i = 2; i <= n; i++) {
next = Math.min(cur+cost[i-1], pre + cost[i-2]);
pre = cur;
cur = next;
}
return cur;
}
}
复杂度分析:
时间复杂度:O(n),其中 n 是数组 cost 的长度。需要依次计算每个 dp 值,每个值的计算需要常数时间,因此总时间复杂度是 O(n)。
空间复杂度:O(1)。使用滚动数组的思想,只需要使用有限的额外空间。
小白上楼梯(三步问题)
爬楼梯一次可以爬一步,两步,三步,问爬到n阶台阶的方法
Java版 递归:
static int f(int n) {
if (n == 0) return 1;
if (n == 1) return 1;
if (n == 2) return 2;
return f(n-1)+f(n-2)+f(n-3);
}
Python版 递推:
class Solution:
def waysToStep(self, n: int) -> int:
if n == 1:
return 1
if n == 2:
return 2
if n == 3:
return 4
a = 1; b = 2; c = 4; d = 0
for i in range(4, n+1):
d = ((a+b) % 1000000007 + c) % 1000000007
a = b
b = c
c = d
return d
小白上楼梯2
爬楼梯每次至少爬d阶,爬到n阶台阶的方案数
动态规划,每次向前推d,第i-d的阶数方案会对下一次造成影响。
int sum = 1;
int mod = 1000000007;
System.out.println(mod);
for (int i = d; i <= n; i++) {
sum += x[i-d];
x[i] += sum;
x[i] %= mod;
sum %= mod;
}
System.out.println(x[n]);
最大字段和
给定序列,找出最大的子段和
累加每个元素,当sum<0,将其初始化为0(前缀和的思想)
每次都有前面最长连续和+上当前与不加当前值的两种选择
long sum = 0, max = 0;
for (int i = 0; i < n; i++) {
sum += x[i];
max = Math.max(max, sum);
if (sum < 0)
sum = 0;
}
System.out.println(max);
class Solution:
def maxSubArray(self, nums: List[int]) -> int:
for i in range(1, len(nums)):
nums[i] += max(nums[i-1], 0)
return max(nums)
最长公共子串
在字符串中任意个连续的字符组成的子序列成为该串的子串,给定两个字符串,求出最长的公共子串的长度
思想:从头遍历一个字符串,当字符串不包含则将指针++
String s = sc.nextLine();
String x = sc.nextLine();
int max = 0, i = 0;
StringBuilder sb = new StringBuilder();
for (char c : s.toCharArray()) {
sb.append(c);
String ss = sb.substring(i);
if (x.contains(ss)) {
max = Math.max(max, ss.length());
} else {
i++;
}
}
System.out.println(max);
最长公共子序列
-
最长公共子序列(longest common sequence)和最长公共子串(longest common substring)不是一回事儿。什么是子序列呢? 即一个给定的序列的子序列,就是将给定序列中零个或多个元素去掉之后得到的结果。什么是子串呢 给定串中任意个连续的字符组成的子序列称为该串的子串。
-
举个例子(S1={1,3,4,5,6,7,7,8}和S2={3,5,7,4,8,6,7,8,2}),
- 假如S1的最后一个元素与S2的最后一个元素相等,那么S1和S2的LCS就等于 {S1减去最后一个元素} 与 {S2减去最后一个元素} 的 LCS 再加上 S1和S2相等的最后一个元素。
- 假如S1的最后一个元素与S2的最后一个元素不等(本例子就是属于这种情况),那么S1和S2的LCS就等于: {S1减去最后一个元素} 与 S2 的LCS, {S2减去最后一个元素} 与 S1 的LCS 中的最大的那个序列。
int[] x = {0, 2, 5, 7, 3, 6, 8, 4};
int[] y = {0, 3, 4, 7, 3, 6, 4};
int[][] xx = new int[x.length+1][y.length+1];
for (int i = 1; i < x.length; i++) {
for (int j = 1; j < y.length; j++) {
if (x[i] == y[j])
xx[i][j] = xx[i-1][j-1]+1;
else
xx[i][j] = Math.max(xx[i-1][j], xx[i][j-1]);
}
}
System.out.println(xx[x.length-1][y.length-1]);
- 输出最长公共子序列
类似的倒推回去:当x[n] i= y[n]时,比较xx[i-1][j]与x[i][j-1]如果若大于就选i-1与j反之选择i与j-1,当等于时选择不同的方向有不同的结果。
int i = x.length-1, j = y.length-1;
StringBuilder sb = new StringBuilder();
while (xx[i][j] > 0) {
if (x[i] == y[j]) {
sb.append(x[i]);
i--;
j--;
} else if (x[i] != y[j]) {
if (xx[i - 1][j] > xx[i][j - 1]) {
i--;
} else {
j--;
}
}
}
System.out.println(sb.reverse().toString());
最长递增子序列
- DP-动态规划
状态设计:用一个维护数组dp[i]表示以a[i]结尾的最长递增子序列的长度
状态转移:之后向前找到一个小于a[i]的进行状态转移dp[i] = Math.max(dp[i], dp[j]+1);
边界处理:dp[i]=1(0<=j<n)
时间复杂度:O(n2)
int[] x = {3, 1, 2, 1, 8, 5};
// dp[i]表示以a[i]结尾的最长递增子序列的长度
dp = new int[x.length];
int ans = 0;
for (int i = 0; i < x.length; i++) {
// 初始化每一个dp[i]=1
dp[i] = 1;
for (int j = 0; j < i; j++) {
if (x[i] > x[j])
dp[i] = Math.max(dp[i], dp[j]+1); // 状态转移
}
ans = Math.max(ans, dp[i]); 信息学奥赛一本通 5.2 树形动态规划