零基础理解动态规划(DP) - 开篇 - 01
这个是一个系列文章 大概有5篇文章,会持续去更新敬请期待
字节跳动最近开始了暑期实习生的海量面试,每天安排的面试量和很大,通过最近一段时间的面试情况来说算法题目又是必须考查项目,如果应届生想拿下大厂的实现offer的话算法一定是必须要面临的考验。
就宇宙条的面试题库来说,其中动态规划的算法题目是很重要的一类题目,本文主要是对动态规划类题目的总结,让大家在遇到这类题目的时候能够得心应手。
一、何为动态规划?
定义
动态规划(Dynamic Programming)是运筹学的一个分支,是求解决策过程(decision process)最优化的数学方法。
动态规划一般可分为线性动规,区域动规,树形动规,背包动规四类。
个人理解
动态规划其实和递归算法有千丝万缕的关系,绝大部分的暴力的递归算法都可以通过动态规划将时间复杂降低一个维度。通过将递归算法过程中的部分中间数据通过一定的空间开销保存起来,再在循环过程中利用前面循环过程中算出来的值来计算。
二、开胃小菜之斐波那契数列的动态规划解法
先来个开胃小菜,直观的理解下动规的魔力。
斐波那契数列:
又称黄金分割数列、因数学家列昂纳多·斐波那契以兔子繁殖为例子而引入,
故又称为“兔子数列”,指的是这样一个数列:1、1、2、3、5、8、13、21、34、……
从第三项开始 F(N) = F(N-1) + F(N-2)
三、常规暴力递归的解法
func fib(N int) int {
if N == 1 || N == 2 {
return 1
}
return fib(N - 1) + fib(N - 2);
}
这种算法非常好理解,代码实现也非常简单。我们来计算下暴力递归解法的时间复杂度是多少
F(N) = F(N-1) + F(N-2)
F(N-1) = F(N-2) + F(N-3)
F(N-2) = F(N-3) + F(N-4)
...
明显中间的所有变量都被重复计算了两次,最终递归算法的时间复杂度是O(2^n).
四、带备忘录的递归解法
func fibByTEMP(N int) int{
if N < 1 {
return 0;
}
// 备忘录全初始化为 0
temp := make([]int,N+1,2*N)
// 初始化最简情况
temp[1] = 1
temp[2] = 1;
return midFibTEMP(temp, N);
}
func midFibTEMP(temp []int, n int) int {
// 未被计算过
if n > 0 && temp[n] == 0 {
temp[n] = midFibTEMP(temp, n - 1) +
midFibTEMP(temp, n - 2);
}
return temp[n];
}
实际上,动态规划算法其实就是在运算过程中新增了一个中间变量去存储计算的中间值(备忘录),如果有重复计算的直接在中间值变量表去找对应的值即可,不需要重复计算。
这么写是不是太复杂了?我们还是有递归算法的影子,理解起来还是有点复杂,是不是有更加好理解的做法,递归的做饭还是通过F(N-1)和F(N-2)去往下算 是从上向下延伸,都是从一个规模较大的原问题比如说 f(20),向下逐渐分解规模,直到 f(1) 和 f(2) 触底,然后逐层返回答案,这就叫「自顶向下」。
反过来?
反过来,我们直接从最底下,最简单,问题规模最小的 f(1) 和 f(2) 开始往上推,直到推到我们想要的答案 f(20),这就是动态规划的思路,这也是为什么动态规划一般都脱离了递归,而是由循环迭代完成计算。
五、动态规划算法
func fibByDP(N int) int {
// DP 全初始化为 0
dp := make([]int,N+1,N+1)
dp[1] = 1
dp[2] = 1
for i := 3; i <= N; i++ {
dp[i] = dp[i - 1] + dp[i - 2];
}
return dp[N];
}
动态规划问题最困难的就是写出状态转移方程
其中上面的动态还有优化的空间,其实斐波那契数列有其特殊性,F(N)= F(N-1) + F(N-2),所有在状态方程转换过程中其实只要保持最近两个值即可,那么可以将空间复杂度从O(N)降低到O(1)
func fibByDPBetter(N int) int {
if (N < 2) {
return N;
}
prev := 0
curr := 1;
for i := 0; i < N - 1; i++ {
sum := prev + curr;
prev = curr
curr = sum
}
return curr;
}
对话窗口回复 动态规划01 获取go源码