Fork me on GitHub

算法-07| 动态规划

1. 分治 + 回溯 + 递归 + 动态规划

 它的本质即将一个复杂的问题,分解成各种子问题,寻找它的重复性。动态规划和分治、回溯、递归并没有很大的本质区别,只是小细节上的不同。

递归

代码模板

    public void recur(int level, int param) {
        // 1.terminator 递归终止条件 
        if (level > MAX_LEVEL) {
        // process result
            return;
        }
        // 2.process current logic 处理当前层的逻辑 
        process(level, param);
        // 3.drill down 递归到下一层去
        recur(level:level + 1, newParam);
        // 4.restore current status 有时候需要的话即恢复当前层的状态,如果改变的状态都是在参数上,因为递归调用时这个参数是会被复制的,如果是简单变量就不需要恢复这个过程。
    }

 

分治 Divide & Conquer

很多大的复杂的问题,它其实都是有所谓的自相似性即重复性,计算机可以飞快地循环运算。

把大的问题分解为几个子问题,同时每个子问题也类似地分解成其他的相同的小的子问题,然后分别运算,再把结果返回,同时把结果聚合在一起

def divide_conquer(problem, param1, param2, ...):
  # 1.recursion terminator 递归终止条件 
  if problem is None:
    print_result
    return
  # 2. prepare data 拆分子问题,
  data = prepare_data(problem)
  subproblems = split_problem(problem, data)
  # conquer subproblems 调子问题的递归函数(调分治函数递归求解) drill down
  subresult1 = self.divide_conquer(subproblems[0], p1, ...)
  subresult2 = self.divide_conquer(subproblems[1], p1, ...)
  subresult3 = self.divide_conquer(subproblems[2], p1, ...)
  …
  # 3. process and generate the final result 最后将这些结果合并在一块
  result = process_result(subresult1, subresult2, subresult3, …)
  # 4. revert the current level states 最后有可能当前层的状态需要进行恢复

 归并排序即分而治之,分治算法的递归树,分而治之如下:

 

1. 人肉递归低效、很累 
2. 找到最近最简方法,将其拆解成可重复解决的问题(找到重复性,找最近重复性--最大公约数
3. 养成数学归纳法的思维习惯(抵制人肉递归的诱惑)(先把基础的条件,n=1,n=2时想明白,n成立时,如何推到n+1,比如炮竹的爆炸)

本质:寻找重复性 —> 计算机指令集(if...else,for, 递归)

 对递归不熟时,可以人肉递归,画出递归状态树:

如斐波拉契数列递归状态树:

计算第6个数,但它的状态树是2n,它结点(状态树)的扩散是指数级的,所以它的计算复杂度也是指数级

 

 

2. 动态规划 Dynamic programming

1. Wiki 定义:
  https://en.wikipedia.org/wiki/Dynamic_programming

Dynamic programming,中文翻译叫动态规划,它本质要解决的就是一个递归或分治的问题,它们的不同处在于动态规划它有所谓的最优子结构


2.“Simplifying a complicated problem by breaking it down into
  simpler sub-problems”
  (in a recursive manner)

用递归的方式,将一个复杂的问题分解为子问题。


3.Divide & Conquer + Optimal substructure
 动态规划就是 分治 + 最优子结构

 一般动态规划的问题是求一个最优解,或者求一个最大值,或者求一个最少的方式,它有一个最优子结构的存在,每一步不需要把所有的状态都保存下来,只需存最优的状态即可。还需要证明:如果每一步都存一个最优值,最后可以推导出一个全局的最优值。

1)缓存(状态的存储数组)

2)在每一步把次优的状态给淘汰掉,只保留在这一步里面最优或者较优的状态来推导出最后的全局最优。

 关键点:

动态规划DP 和 递归或者分治 没有根本上的区别;(DP它的本质就是动态递归,关键看有无最优的子结构)

  如果没有最优的子结构,说明所有的子问题都需要计算一遍,同时把最后的结果给合并在一起,就叫分治(每次的最优解就是当前解,它没有所谓的每次比较和淘汰的一个过程)。
共性:找到重复子问题

  计算机只会for else loop 

差异性:最优子结构、中途可以淘汰次优解

 动态规划,有最优子结构,淘汰次优解,这时它的复杂度是更低更有效的,傻递归,傻分治经常是指数级的时间复杂度,如果进行了淘汰次优解,会变成 O(n2) 或者O(n) 时间复杂度。把复杂度从指数级降到了多项式的级别。

 

斐波拉契数列

斐波拉契数列,傻递归,它的时间复杂度是指数级的,可以递归但是它是指数级的; ---->>  简化(速度上、表达方式上)

 

 

 

 

 

 

 它的状态树为什么是指数级的,它每一层都是指数级的结点:

  第一层1个结点,fib(6)

  第二层2个,fib(5)、fib(4)

  第三次4个,fib(4)、fib(3)、 fib(3)、 fib(2)

  ...

每一层乘 2,加一起就是2n,所以它是指数级的。

简化:

int fib (int n) {
  return n <= 1 ? n : fib (n - 1) + fib (n - 2);
}

简化代码,并没有改变它的时间复杂度,但是代码清爽一点。

如何改变时间复杂度,加一个缓存,可以存在一个数组里边,这种方法叫记忆化搜索 Memoization

 

   比如fib(3),第一次计算出来就把fib(3)存在memo里面的3的位置,后边fib(3)就不用计算了直接复用,不然又要从fib(1)和fib(2)算起;

继续将上边代码(逻辑不是特别清洗)优化:

 

 

fib (int n, int[] memo) {
  if (n <= 1) {
    return n;
  }
  if (memo[n] == 0) { //没有被计算过,就从头开始计算并存在数组中,如果 != 0就直接return,把重复的结点就会砍掉,时间复杂度从指数级降为O(n)的时间复杂度。
    memo[n] = fib (n - 1) + fib (n - 2);
  }
  return memo[n];
}

优化后的:

 

 Bottom Up 自底向上

 记忆化递归不如直接写一个for循环,

• F[n] = F[n-1] + F[n-2]
• a[0] = 0, a[1] = 1;
  for (int i = 2; i <= n; ++i) {
    a[i] = a[i-1] + a[i-2];
  }
• a[n]
• 0, 1, 1, 2, 3, 5, 8, 13,

递归,一开始从最上面这个问题开始一步步向下探,最后探到它的叶子结点,叶子结点的值是确定的,<= n 时 return n;

这种方法叫自顶(自顶向下的顺序), 递归 + 记忆化搜索, 也比较符合人脑的思维习惯(要解决fib(6),就算fib(5)和fib(4)...中间结果算过了就直接复用);

从计算机的思维,初始值已经有了,0和1,写fib(6),0,1,1,2,3,5,8,13   0和1相加1,1+1即2, 2+2即4, 2+3即5,...递推...可直接用循环,即自底向上(直接从最下面,循环累加上去即可)。

PS:对于初学者或面试,可以先递归分治然后进行记忆化搜索, 再转化为自底向上的循环;

  但对于一个熟练的选手,或者追求DP的功力深厚,尤其在计算机竞赛时,只要开始写递归,所有竞赛选手都可以写for循环,即全部都是自底向上的循环,开始递推。

DP最好的翻译是动态递推,动态规划最终极的一个形态就是自底向上的循环。

路径计数

复杂一点的DP:

1)它的维度变化了,它的状态有时候是二维空间或者三维

2)它中间会有所谓的取舍最优子结构。

 

 这个人他只能向右或者向下走(不能向左或者向上走),棋盘实心表障碍物不能走,求他从start走到end,有多少种走法?

用分治思想,或者找重复性的思想:

假设棋盘没有障碍物,棋盘大小 1*1, 2*2,....

  他只有2中走法,向右到B,向下到A (转化为了2个子问题,从B到End有多少种走法,从A到End有多少种走法, 这两个子问题加起来就是绿人这个大问题的解)

 

C( Start  --->  End) =   C(A --> End)   +   C( B --> End)

有点像斐波拉契数列,只不过它是二维的。

 

用递推的思想:

 

 自底向上推:

  把靠近End的格子全推一遍,向右走,那一排都是1,不能往下走,往下走就出去了;同理可得上边一排也全都是1

 

 

 

 看任何一个空格子,它的走法  =  右边格子走法 + 下边格子的走法

如果格子是石头,那么它的走法即是0,得到一个递推公式:

状态转移方程(DP方程)
opt[i , j] = opt[i + 1, j] + opt[i, j + 1]
完整逻辑:
if a[i, j] = ‘空地’:
  opt[i , j] = opt[i + 1, j] + opt[i, j + 1]
else:
opt[i , j] = 0

 

 

 绿人从Start 到End 走法 = 17 + 10 = 27

  通过递推只要保证初始值是对的,逻辑就是 右 + 下; 这就是数学归纳法的思维。

动态规划关键点:

1. 最优子结构 opt[n] = best_of(opt[n-1], opt[n-2], …)    (推导出的第n步它的值是前面几个值的最佳值,这个最佳值有时候就是简单累加,有时取最大值或最小值)

2. 储存中间状态:opt[i]  (必须定义和存储这个中间态,这个跟分治是有区别的,分治一般把这步放递归里边了,)

3. 递推公式(美其名曰:状态转移方程或者 DP 方程)
  Fib: opt[i] = opt[n-1] + opt[n-2]
  二维路径:opt[i,j] = opt[i+1][j] + opt[i][j+1] (且判断a[i,j]是否空地)

 

DP,它有一个筛选过程,上例是累加,有可能是最大值或最小值,

 

最长公共子序列

斐波拉契数列是一维数组的简单DP,不同路径是二维数组的DP

最长公共子序列是 字符串进行变化的DP,要进行思维层级的转换,当进行DP时,它已不是一个简单的字符串,扩展为一个二维数组来定义状态,前两个题目状态的定义相对简单,即它本身的数组的结构,以及棋盘的二维数组结构即状态空间,状态数组。

最长公共子序列:给定两个字符串 text1 和 text2,返回这两个字符串的最长公共子序列的长度。 例如:

  输入:text1 = "abcde", text2 = "ace" 
  输出:3  
  解释:最长公共子序列是 "ace",它的长度为 3。

方法一暴力求解: 枚举它所有的子序列,看这个子序列是否也在第二个里面也是子序列,对text1中每个字母都是取或不取,就生产一个子序列,看这个子序列是否也在text2中存在,一个个字母对比看是否在text2中。这种办法的时间复杂度是2n, 枚举每一个就像括号问题(左括号 右括号)。

方法二 找重复性

  1. S1 = “”
    S2 = 任意字符串
  2. S1 =“A”
    S2 = 任意
  3. S1 =“…….A”
    S2 = “.….A”

假设两个text都为空,最长公共子序列就是空;假设一个为空,另外一个为任意字符串,最长公共子序列也是空;

假设S1 = “A”,S2为任意字符串,看A是否存在S2中,只要存在最长公共子序列就是1,否则0;

假设S1为“....A”,S2也是“....A”, 第一种经验是从最后一个字符开始看,第二个经验是比较这两个字符串A之前是否存在最长公共子序列,存在再 + 1,第三个经验就是把它们转化为二维数组的行和列。

 两个字符串: "ABAZDC", "BACBAD",把它们一个在行上排列,一个在列上面排下来。

  行列相交的值就是公共子序列,如下图中4,即text1 C -> A 和text2  D -> B的公共子序列。

 

 先初始化,行 0 1 1 1 1 ...(比如行A与列B为0,行A B 与列B为1, 行A B A 与列B为1...), 列 0 1 1 1 1 ...;

后边的进行递推,假设最后一个字符都相同S1 =“…….A”,S2 = “.….A”,即前面前缀的子问题 再 +1,否则,S1的“.......” 与S2的A 减1 或者 S2的 “.....”与 S1的A 减1;

比如第2行,第2列的1,它们的最后一个字符不相同,就变成text1中A B 和text2中B的最长公共子序列1 或者 text2中B A和text1中A的最长公共子序列1 。

  第3行,第6列的3,它最后一个字符都是C,就转化为求3之前的字符串的最长公共子序列,求text1 A B A Z D和 text2 B A它们的最长公共子序列;

 子问题:
  • S1 = “ABAZDC”
    S2 = “BACBAD”
  • If S1[-1] != S2[-1]: LCS[s1, s2] = Max(LCS[s1-1, s2], LCS[s1, s2-1])  //S1和 S2最后一个字符不相同,  -1表最后一个字符,python的写法 或java中S1.length - 1

    // S1去掉一个字符和S2比较,或者S2去掉一个字符和S1比较,它们之间的最长公共子序列的子问题,从中再选一个较大者
     LCS[s1, s2] = Max(LCS[s1-1, s2], LCS[s1, s2-1], LCS[s1-1, s2-1])

  • If S1[-1] == S2[-1]: LCS[s1, s2] = LCS[s1-1, s2-1] + 1
     LCS[s1, s2] = Max(LCS[s1-1, s2], LCS[s1, s2-1], LCS[s1-1, s2-1], LCS[s1-1][s2-1] + 1)

DP方程:
  • If S1[-1] != S2[-1]: LCS[s1, s2] = Max(LCS[s1-1, s2], LCS[s1, s2-1])
  • If S1[-1] == S2[-1]: LCS[s1, s2] = LCS[s1-1, s2-1] + 1

 

1. 打破自己的思维惯性,形成机器思维(找重复性);

2. 理解复杂逻辑的关键;

3. 也是职业进阶的要点要领;

动态规划的思维要点:

  ①化繁为简成为各种子问题; ② 定义好状态空间; ③动态规划的方程即DP方程。

 

 

5 easy steps to dp:

  • ① define subproblems(分进分治,把当前复杂问题转换成一个简单的子问题)
  • ② guess(part of solution)(猜递推方程是如何递推的)
  • ③ relate subproblem solutions (把子问题的解合并起来 merge)
  • ④ recurse & memoize (递归转成记忆化搜索或者 把DP的状态表建立起来, 自底向上进行递推)
  • ⑤ solve original problem (废话)

①②③就是分治的思想,找重复性(计算机的指令只能循环或递归)
第一步分治找它的重复性和子问题;
第二步定义出状态空间, 可以用记忆化搜索递归, 或者从下到上进行DP的顺推, 自底向上进行推导;

三角形最小路径和

1. brute-force,递归,n层可以左或者可以右; 时间复杂度是2^n
2. DP:
  a. 重复性(分治); (类似于不同路径问题) problem(i, j) = min(sub(i+1, j), sub(i+1, j+1)) + a[i, j]
  b. 定义状态数组; f[i, j]
  c. DP方程; f[i, j] = min(f[i+1, j], f[i+1, j+1]) + a[i, j]

最大子序和

1. 暴力求解: n^2
2. DP:
  a. 分治(子问题) max_sum(i) = Max( max_sum( i - 1 ),  0 )  +  a[ i ]
  b. 状态数组定义 f[ i ]
  c. DP方程 f[ i ] = Max( f [ i - 1 ],  0 )  +  a[ i ]

1. dp问题,公式为:dp[i] = max((nums[i], nums[i] + dp[i - 1]))
2. 最大子序和 = 当前元素自身最大, 或者包含之前的sum+自己的最大

Coin change

状态树

  

1. 暴力:递归-时间复杂度指数级
2. BFS
3. DP(与上楼梯或者斐波拉契数列差不多)
    a.subproblems
    b.DP array : 
        f(n) = f(n-1) + f(n-2) + f(n-3)每次走一步或者走两步或者走三步,它是一个累加的
        这里不再是1 2 3常量, 变成了一个数组,f(n)表凑成面值为n所需的最小硬币数量,它取自于k的面值  f(n) = f(n-k) k在数组里边取所有值;
        f(n) = min{(f(n-k), for k in [1,2,5]}) + 1,加1是因为最少需要一个硬币,这个硬币的面值就是k
    c.DP方程
    

打家劫舍

DP:
  a. 子问题
  b. 状态定义
  c. DP方程
数组a表示偷盗从0开始一直到第i个房子它最大可以偷的金额是多少, 返回的结果是a[n-1]
第一维表房子的下标,再加一维表示是否被偷:a[i][0, 1], 0 i表偷、 1 i表不偷
状态转移方程:
  a[i]表示从0到第i个房子能偷到的房子的最大值, 同时第i个房子不偷;
  a[i][0] = Max(a[i-1][0], a[i-1][1]) //表一直到第i个房子,且第i个房子不偷, i-1个房子不偷或者i-1个房子偷的最大值,但不偷第i个房子
  a[i][1] = a[i-1][0] + nums[i] //第i个房子偷,i-1个房子只能不偷,当前的房子偷

 

简化操作:
  a[i]表0-i天,且nums[i]必偷的最大值, 结果就是max(a)
状态方程:
  a[i] = Max(a[i-1], a[i-2] + nums[i])
  第i-1天必偷就不能偷i天了,或者第i-2最大值或i-2天偷了 则就可以偷num[i]了 ; 所有都是正整数
 

 

posted @ 2020-08-11 19:28  kris12  阅读(336)  评论(0编辑  收藏  举报
levels of contents