写在前面的话
本文主题是讲动态规划,涉及题目近40题,每题超过3种方法解决,共100多种写法,思路和目录结构来源于收藏夹中的一个课程Grokking Dynamic Programming Patterns for Coding Interviews,其中grok是深刻领会,深入掌握的意思,我翻译成畅游,加上自己的理解,消化(截取的课程的目录),得到本文,文章很长,其实人生的路也很长,是吧?
- 这是一篇摘要文章,具体内容见文末的跳转链接
0.什么是动态规划
动态规划(DP)是一种求解优化问题的算法,它将问题分解为更简单的子问题,并利用整体问题的最优解取决于子问题的最优解这一事实
看个例子斐波那契数列,众所周知,斐波那契数列是一系列的数字,其中每个数字都是前面两个数字的和,前几个斐波那契数列是0、1、1、2、3、5和8,以此类推。
如果我们要求计算第n个斐波那契数列,我们可以用下面的方程来做:
Fib(n) = Fib(n-1) + Fib(n-2), for n > 1
我们可以清楚地看到,为了解决整个问题(即Fib(n)
),我们将其分解为两个更小的子问题(Fib(n-1)
和Fib(n-2)
)。这说明我们可以用DP来解决这个问题
动态规划的特点
在理解DP问题的不同方法之前,让我们先看看一个问题的哪些特征告诉我们可以应用DP来解决它。
1.重叠子问题
子问题是原始问题的更小粒度。如果找到它的解决方案涉及到多次解决相同的子问题,那么任何问题都有重叠的子问题。以斐波那契数列为例,为了找到fib(4)
,我们需要将其分解为以下子问题:
我们可以清楚地看到重叠的子问题模式,因为fib(2)
已经计算了两次,fib(1)
已经计算了三次。
2.最优子结构性质
如果它的整体最优解可以由其子问题的最优解构造出来,那么任何问题都具有最优子结构性质。对于斐波那契数列,我们知道
Fib(n) = Fib(n-1) + Fib(n-2)
这清楚地表明大小为n
的问题已经被简化为大小为n-1
和n-2
的子问题。因此,斐波那契数具有最优子结构性质。
动态规划的方法
DP问题一般有两种方式去解决
2.1.自顶向下记忆化(Top-down)
在这种方法中,我们试图通过递归地找到较小子问题的解决方案来解决较大的问题。每当我们解决一个子问题时,对子问题的结果缓存起来,这样当它被多次调用时,我们就不会重复地解决它。相反,我们可以只返回保存的结果。这种存储已经解决的子问题的结果的技术叫做记忆化。
我们将在斐波那契数列的例子中看到这种技术。首先,让我们看看寻找第n
个斐波那契数列的非DP
递归解:
class Fibonacci {
public int CalculateFibonacci(int n) {
if(n < 2) return n;
return CalculateFibonacci(n-1) + CalculateFibonacci(n-2);
}
public static void main(String[] args) {
Fibonacci fib = new Fibonacci();
System.out.println("5th Fibonacci is ---> " + fib.CalculateFibonacci(5));
System.out.println("6th Fibonacci is ---> " + fib.CalculateFibonacci(6));
System.out.println("7th Fibonacci is ---> " + fib.CalculateFibonacci(7));
}
}
output
5th Fibonacci is ---> 5
6th Fibonacci is ---> 8
7th Fibonacci is ---> 13
正如我们上面所看到的,这个问题显示了重叠子问题模式,所以让我们在这里利用记忆。我们可以使用一个数组来存储已经解决的子问题(请参阅高亮显示的行中的更改)。
class Fibonacci {
public int CalculateFibonacci(int n) {
int memoize[] = new int[n+1];
return CalculateFibonacciRecursive(memoize, n);
}
public int CalculateFibonacciRecursive(int[] memoize, int n) {
if(n < 2)
return n;
// if we have already solved this subproblem, simply return the result from the cache
if(memoize[n] != 0)
return memoize[n];
memoize[n] = CalculateFibonacciRecursive(memoize, n-1) + CalculateFibonacciRecursive(memoize, n-2);
return memoize[n];
}
public static void main(String[] args) {
Fibonacci fib = new Fibonacci();
System.out.println("5th Fibonacci is ---> " + fib.CalculateFibonacci(5));
System.out.println("6th Fibonacci is ---> " + fib.CalculateFibonacci(6));
System.out.println("7th Fibonacci is ---> " + fib.CalculateFibonacci(7));
}
}
2.2.自底向上填表(Bottom-up)
填表与自顶向下方法相反,避免了递归。在这种方法中,我们自底向上地解决问题(即首先解决所有相关的子问题)。这通常通过填充一个n
维表来完成。根据表中的结果,然后计算顶部/原始问题的解决方案。
填表与记忆化是相反的,因为在记忆化中,我们解决问题,并维护已经解决的子问题的映射。换句话说,在记忆化中,我们从上到下,也就是说我们先解决最上面的问题(通常递归下来解决子问题)。
让我们将填表应用到我们的斐波那契数列的例子中。因为我们知道每个斐波那契数都是前面两个数的和,所以我们可以使用这个事实来填充我们的表。
下面是自底向上动态规划的代码:
class Fibonacci {
public int CalculateFibonacci(int n) {
if (n==0) return 0;
int dp[] = new int[n+1];
//base cases
dp[0] = 0;
dp[1] = 1;
for(int i=2; i<=n; i++)
dp[i] = dp[i-1] + dp[i-2];
return dp[n];
}
public static void main(String[] args) {
Fibonacci fib = new Fibonacci();
System.out.println("5th Fibonacci is ---> " + fib.CalculateFibonacci(5));
System.out.println("6th Fibonacci is ---> " + fib.CalculateFibonacci(6));
System.out.println("7th Fibonacci is ---> " + fib.CalculateFibonacci(7));
}
}
我们将始终从一个暴力递归解决方案开始,这是开始解决任何DP问题的最佳方式。一旦我们有了递归的解决方案,我们就会应用记忆化和填表。
让我们应用这些知识来解决一些常见的DP问题。