• 算法-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]了 ; 所有都是正整数
     
  • 相关阅读:
    命令模式
    责任链模式
    代理模式
    享元模式
    195 Tenth Line
    test命令
    read命令
    echo命令
    java反射
    http状态码
  • 原文地址:https://www.cnblogs.com/shengyang17/p/13394671.html
Copyright © 2020-2023  润新知