文章目录
一、概述——递归、分治与动态规划
1、递归
递归是一种直接或者间接调用自身函数或者方法的算法。
通俗来说,递归的实质是把问题分解成规模缩小的同类问题的子问题,然后递归调用方法来表示问题的解。它有如下特点:
- 一个问题可以分解为若干子问题进行求解;
- 这个问题与分解之后的子问题,除了数据规模不同,求解思路完全一样;
- 有一个明确的递归结束条件,即规模很小时能够直接得到问题的解。
2、分治
分治法求解问题可分为两大步:
- 分(Divide):将原问题分解为若干个规模较小但类似于原问题的子问题进行求解;
- 治(Conquer):合并这些子问题的解来建立原问题的解。
3、动态规划
动态规划和分治法类似,也是将原问题分解为若干个规模较小的子问题进行求解,然后合并子问题的解得到原问题的解。区别在于这些子问题可能会有重叠,一个子问题在求解后,可能会再次求解,于是我们想到将这些子问题的解存储起来,当下次再次求解这个子问题时,直接拿过来用。因而省掉很多重复运算,得到更小的运行时间。
因此,分治法一般用来解决子问题相互对立的问题,称为标准分治,而动态规划用来解决子问题重叠的问题。
二、动态规划
1、应用动态规划求解的问题的四个特点
如果题目是求一个问题的最优解(最大值或者最小值),而且该问题能够分解为若干个子问题,并且子问题之间还有重叠的更小的子问题,就可以考虑用动态规划来解决这个问题。
可以应用 动态规划 求解的问题的四个特点:
- 我们要求解的是一个问题的最优解(最大值或者最小值);
- 整体问题可以分解成若干子问题,并且整体问题的最优解依赖于各个子问题的最优解;
- 这些小问题之间存在相互重叠的更小的子问题;
- 为避免重复求解子问题,我们把小问题的解存储下来,以此为基础求解大问题的解。即从上往下分析问题,从下往上求解问题。
我们以面试题 14. 剪绳子为例,来看看动态规划分析问题的过程。题目的要求是,如何把长度为 n n n 的绳子剪成若干段,使得得到的各段长度的乘积最大。
问题的目标是求剪出的各段绳子长度的乘积最大值,也就是求一个问题的最优解。
我们定义函数 f ( n ) f(n) f(n) 表示把长度为 n n n 的绳子剪成若干段后得到的乘积的最大值。假设我们第一刀剪在长度为 i ( 0 < i < n ) i(0 < i<n) i(0<i<n) 的位置,得到长度为 i i i 和 n − i n - i n−i 的两段。要得到 f ( n ) f(n) f(n),那么就要把 i i i 和 n − i n - i n−i 也分别剪成若干段,使得它们各自剪出的每段绳子的长度乘积最大。也就是说,整体问题的最优解依赖于各个子问题的最优解。
我们假设 n = 10 n = 10 n=10,第一次把绳子剪成 4 和 6 的两段,即 f ( 4 ) f(4) f(4) 和 f ( 6 ) f(6) f(6) 是 f ( 10 ) f(10) f(10) 的子问题。接下来,我们把 4 剪成两个 2,即 f ( 2 ) f(2) f(2) 是 f ( 4 ) f(4) f(4) 的子问题;把 6 剪成 2 和 4 的两段,即 f ( 2 ) f(2) f(2) 和 f ( 4 ) f(4) f(4) 是 f ( 6 ) f(6) f(6) 的子问题。我们注意到, f ( 2 ) f(2) f(2) 是 f ( 4 ) f(4) f(4) 和 f ( 6 ) f(6) f(6) 重叠的更小的子问题。也就是说,我们把大问题分解为若干个小问题,这些小问题之间存在相互重叠的更小的子问题。
为了避免重复求解子问题,我们用从下往上的顺序先计算小问题的最优解并存储下来(一般存储在一维或者二维数组里),再以此为基础求取大问题的最优解。也就是,从上往下分析问题,从下往上求解问题。
在应用动态规划的时候,我们每一步都可能面临若干个选择。比如剪绳子,我们在剪第一刀的时候就有 n − 1 n - 1 n−1 个选择,我们可以剪在长度为 1 , 2 , . . . , n − 1 1, 2, ... , n - 1 1,2,...,n−1 的任意位置。但事先我们不知道哪个是最优的,所以只好把所有的可能都尝试一遍,然后比较得出最优解。用公式表示就是 f ( n ) = m a x ( f ( i ) × f ( n − i ) ) f(n) = max(f(i) \times f(n - i)) f(n)=max(f(i)×f(n−i)),其中 0 < i < n 0 < i < n 0<i<n。这个过程其实就是建立大问题和小问题之间具体联系的过程,并用数学公式表示出来,即状态转移方程。
2、“三步走” 解决动态规划问题
- 明确目标,定义状态: 明确我们要求的目标是什么。比如在剪绳子问题中,我们把 f ( n ) f(n) f(n) 定义为 “把长度为 n n n 的绳子剪成若干段后得到的乘积的最大值”。
- 问题拆解,推导状态转移方程: 把大问题分解成小问题,并用递推公式表示出大问题和小问题之间的具体关系。比如,剪绳子问题中,找到 f ( n ) = m a x ( f ( i ) × f ( n − i ) ) f(n) = max(f(i) \times f(n - i)) f(n)=max(f(i)×f(n−i)),其中 0 < i < n 0 < i < n 0<i<n。
- 寻找边界条件: 我们得到的状态转移方程是一个递推式,需要找到递推的终止条件。比如,剪绳子问题中,若 n = 2 n = 2 n=2,只能剪成长度为 1 的两段,所以有 f ( 2 ) = 1 f(2) = 1 f(2)=1;若 n = 3 n = 3 n=3,可以剪成长度为 1 和 2 的两段或者长度为 1 的三段,又 1 × 2 > 1 × 1 × 1 1 \times 2 > 1 \times 1 \times 1 1×2>1×1×1,所以有 f ( 3 ) = 2 f(3) = 2 f(3)=2。据此,我们可以得到 f ( 4 ) f(4) f(4), f ( 5 ) f(5) f(5),直到 f ( n ) f(n) f(n)。
三、案例
案例一——剪绳子
思路:
- 明确目标,定义状态: 我们要求的是“把长度为 n n n 的绳子剪成若干段后得到的乘积的最大值”,因此,我们定义 f ( i ) f(i) f(i) 表示 “把长度为 i i i 的绳子剪成若干段后各段长度乘积的最大值”。
- 问题拆解,推导状态转移方程: 长度为 i i i 的绳子,我们在剪第一刀的时候有 i − 1 i - 1 i−1 个选择,我们可以剪在长度为 1 , 2 , . . . , i − 1 1, 2, ... , i - 1 1,2,...,i−1 的任意位置,从中找出乘积最大的剪法,即 f ( i ) = m a x ( f ( j ) × f ( i − j ) ) , 0 < j < i f(i) = max(f(j) \times f(i - j)),0 < j < i f(i)=max(f(j)×f(i−j)),0<j<i
- 寻找边界条件: 我们得到的状态转移方程是一个递推式,需要找到递推的终止条件。比如,若 n = 2 n = 2 n=2,只能剪成长度为 1 的两段,所以有 f ( 2 ) = 1 f(2) = 1 f(2)=1;若 n = 3 n = 3 n=3,可以剪成长度为 1 和 2 的两段或者长度为 1 的三段,又 1 × 2 > 1 × 1 × 1 1 \times 2 > 1 \times 1 \times 1 1×2>1×1×1,所以有 f ( 3 ) = 2 f(3) = 2 f(3)=2。据此,我们可以得到 f ( 4 ) f(4) f(4), f ( 5 ) f(5) f(5),直到 f ( n ) f(n) f(n)。
参考代码:
class Solution {
public int cuttingRope(int n) {
if(n < 2)
return 0;
if(n == 2)
return 1;
if(n == 3)
return 2;
int[] product = new int[n + 1];
product[1] = 1;
product[2] = 2;
product[3] = 3;
int maxProduct = 0;
for(int i = 4; i <= n; i++){
for(int j = 1; j <= i / 2; j++){
maxProduct = Math.max(maxProduct, product[j] * product[i - j]);
}
product[i] = maxProduct;
}
return product[n];
}
}
案例二——爬台阶问题
题目描述:
一个人爬楼梯,每次只能爬 1 个或 2 个台阶,假设有 n 个台阶,那么这个人有多少种不同的爬楼梯方法?
动态规划(一):
- 明确目标,定义状态: 我们要求的是爬上 n 个台阶有多少种爬法。所以,我们可以定义 dp[i] 表示 “爬上 i 个台阶的爬法”。
- 问题拆解,推导状态转移方程: 由于每次只能爬 1 个或 2 个台阶,所以到达第 n 个台阶可以从第 n - 1 个台阶或者第 n - 2 个台阶到达,因此得到递推方程: d p [ i ] = d p [ i − 1 ] + d p [ i − 2 ] dp[i] = dp[i - 1] + dp[i - 2] dp[i]=dp[i−1]+dp[i−2]
- 寻找边界条件: dp[0] = 0,表示起点;第 1 层台阶只能从起点到达,因此 dp[1] = 1;第 2 层台阶可以从起点直接爬两层台阶到达,或者第 1 层台阶到达,因此 dp[2] = 2。
参考代码:
public int climbStairs(int n) {
if (n <= 2)
return n;
// 创建数组,保存状态信息
int[] dp = new int[n + 1]; // 多开一位,考虑起始位置
dp[0] = 0; dp[1] = 1; dp[2] = 2;
for (int i = 3; i <= n; ++i) {
dp[i] = dp[i - 1] + dp[i - 2];
}
return dp[n];
}
递归:
我们也可以使用递归来解决这个问题。由于每次只能爬 1 个或 2 个台阶,根据第一步的走法把所有走法分为两类:
- 第一步爬了 1 个台阶;
- 第一步爬了 2 个台阶;
所以 n 个台阶的走法就等于先爬 1 阶后,剩余 n-1 个台阶的走法 ,然后加上先爬 2 阶后,剩余 n-2 个台阶的走法,得到递推公式:
f
(
n
)
=
f
(
n
−
1
)
+
f
(
n
−
2
)
f(n) = f(n-1)+f(n-2)
f(n)=f(n−1)+f(n−2) 递归结束条件:
- 当 n = 1 时,f(1) = 1;
- 当 n = 2 时,f(2) = 2。
参考代码:
int f(int n) {
if (n <= 2)
return n;
return f(n-1) + f(n-2);
}
动态规划(二):
上面的递归方法是自顶而下的进行运算,也就是先算 f(n),再算f(n - 1)、f(n-2) … …但是,计算 f(n) 时, f ( n ) = f ( n − 1 ) + f ( n − 2 ) f(n) = f(n-1)+f(n-2) f(n)=f(n−1)+f(n−2);计算 f(n-1) 时, f ( n − 1 ) = f ( n − 2 ) + f ( n − 3 ) f(n - 1) = f(n-2)+f(n-3) f(n−1)=f(n−2)+f(n−3),又要计算一次 f(n-2)。所以整个的递归过程中存在许多重复计算。这种方法的时间复杂度为 O ( 2 n ) O(2^n) O(2n)。
动态规划是从下往上求解问题的过程,即:
- 当 n = 1 时,f(1) = 1;
- 当 n = 2 时,f(2) = 2;
- 当 n = 3 时,f(3) = f(2) + f(1) = 3;
- 当 n = 4 时,f(4) = f(3) + f(2) = 5;
… …
可以看出,在每一次迭代过程中,只需要知道之前的两个状态,就可以推导出新的状态。所以,我们可以对第一种动态规划进行优化,计算每个状态时,只保存它的前两个状态并不断更新,而不用记录所有的状态。
参考代码:
int f(int n) {
if (n <= 2)
return n;
// a 保存第 i-2 个状态的数据,b 保存第 i-1 个状态的数据,
int a = 1, b = 2;
int temp = 0;
for (int i = 3; i <= n; i++) {
temp = a + b;// temp 保存当前状态的数据
a = b;
b = temp;
}
return temp;
}
优化后的代码,时间复杂度降为 O(n)。
案例三——三角形最小路径和
来自 LeetCode 第 120 号问题:三角形最小路径和。
题目描述:
给定一个三角形,找出自顶向下的最小路径和。每一步只能移动到下一行中相邻的结点上。
例如,给定三角形:
[
[2],
[3,4],
[6,5,7],
[4,1,8,3]
]
自顶向下的最小路径和为 11(即,2 + 3 + 5 + 1 = 11)。
说明:
如果你可以只使用 O(n) 的额外空间(n 为三角形的总行数)来解决这个问题,那么你的算法会很加分。
思路:
我们的目标是找出自顶向下的最小路径和。如果没有好的着手点,就先分析几种简单的情况:
-
如果三角形只有两层,如
[2], [3,4]
我们找到最后一层的最小值,再加上第一层的数,就是最小路径和,即 2 + 3 = 5。
-
如果三角形有三层,如
[2], [3,4], [6,5,7]
我们先找左下角 3、6、5 的最小路径和 3 + 5 = 8,再找右下角 4、5、7 的最小路径和 4 + 5 = 9,两者的最小值再加上第一层的数就是整个三角形的最小路径和,即 2 + 8 = 10。
可以看出,分析三层三角形的过程,是从下往上进行计算的,我们也可以用这个思路分析层数更多的三角形。也就是,从最底下的一层逐层向上计算,计算至顶层时,就得到了整个三角形的最小路径和。
因此,我们定义状态:
d
p
[
i
]
[
j
]
dp[i][j]
dp[i][j] 表示“从三角形的最底层到当前位置的最小路径和”。由于每一步只能移动到下一行中相邻的节点上,所以,当前位置的最小路径和就是它的下一行的两个相邻节点最小路径和的最小值加上当前位置的数字,可得到递推方程:
d
p
[
i
]
[
j
]
=
M
a
t
h
.
m
i
n
(
d
p
[
i
+
1
]
[
j
]
,
d
p
[
i
+
1
]
[
j
+
1
]
)
+
t
r
i
a
n
g
l
e
[
i
]
[
j
]
dp[i][j] = Math.min(dp[i + 1][j], dp[i + 1][j + 1]) + triangle[i][j]
dp[i][j]=Math.min(dp[i+1][j],dp[i+1][j+1])+triangle[i][j] 数组初始化: 我们采用自下而上的策略,首先考虑的是最底下一行的元素,最底下一行的元素就表示它们到倒数第二行元素的路径。因此直接将最后一行的元素填入状态数组中,然后从下往上计算。
参考代码:
class Solution {
public int minimumTotal(List<List<Integer>> triangle) {
int n = triangle.size();
int[][] dp = new int[n][n];
// 数组初始化:三角形最后一行的元素填入数组
List<Integer> lastRow = triangle.get(n - 1);
for(int i = 0; i < n; i++){
dp[n - 1][i] = lastRow.get(i);
}
// 自下而上计算
for(int i = n - 2; i >= 0; i--){
List<Integer> curRow = triangle.get(i);
for(int j = 0; j < i + 1; j++){
dp[i][j] = Math.min(dp[i + 1][j], dp[i + 1][j +1]) + curRow.get(j);
}
}
return dp[0][0];
}
}
更多案例:
leetcode 刷题(87)——64. 最小路径和
leetcode 刷题(88)——53. 最大子序和
leetcode 刷题(89)——221. 最大正方形