DP ?贪心?

1,385 阅读12分钟

1.前言

最近在学习算法的时候发现了一些有趣的东西,于是想写下来跟大家分享。跟大家分享的内容首先从DP(dynamic programming,动态规划)开始说起。。。

2.DP

2.1从分割钢条说起

2.1.1问题描述

问题背景: Serling 公司购买长钢条,将其切割为短钢条出售。切割工序本身没有成本支出。公司管理层希望知道最佳的切割方案。
假设我们知道 Serling 公司出售一段长度为 i 英寸的钢条的价格为 p_i(i = 1,2,3,…, 单位为美元)。刚调的长度均为整英寸

长度 i12345678910
价格 p_i1589101717202430

问题描述: 给定一段长度为 n 英寸的钢条和一个价格表 p_i(i = 1, 2, 3, … ,n)。求切割钢条的方案,使得销售收益 r_n 最大。
假设: 取表中前四个元素,即 n=4

2.1.2问题分析

我自己看到这个问题,最直接的想法是暴力求解。即找到所有的切割方案,在这中间找出收益最大的。仔细看的话,分割的结果不就是一棵树嘛,然后遍历树,在中间找最大的值。ok,问题解决掉了。很轻松。。我接下来说的都是废话。。
然而,事情并没有那么简单。遍历就可以的话,要DP做什么。那么,问题出在哪里了呢?我们来分析一波:
分析的起点必然是如何切割了:对于一个 n 的钢条而言,可以将其建模成一个长度为 n-1 的存储布尔(true/false)类型变量的数组。这样建模有什么用呢?
钢条切割建模 如图所示,一个长为 n 的钢条,能够有多少个位置是可切割的,显然是n-1。(小学生数学题)。然后,在每个位置可以存放的值只有0/1两个。通过这个分析,不难发现,长度为 n 的钢条有2^(n-1)种切割方案。算法的时间复杂度是指数级。too slow!!!
因此,在问题规模变得很大的时候,暴力求解是行不通的。那么,我们就需要寻找新的解决方案了。

2.1.3用暴力递归求解

更好的算法还是来源于暴力。因此,第一步是实现暴力递归算法。
想要实现一个递归算法,必须要搞清楚两个问题:

  1. 递归的主体部分是怎样的表达式?
  2. 递归的边界是什么? 首先来回答第一个问题:递归的主体部分取决于我们对实际问题建立的数学模型。关于数学模型,在2.1.2部分,进行问题分析的时候就建立起来了。 钢条切割建模! 为了方便表示,这里将表示可切割位置的数组定义为 index[n-1]. 假设,index[i] 的值为 true,所有 index[j] (j<i) 的值都为 false。表示在 i 的位置切了第一刀,但是 i 之后的位置不知道是否切割。这种情况下的最大收益为 "p[i] + 切割第一刀之后剩下的长度为n-i的刚调的切割方案中的最大收益"。如果假设 r_n 为最大收益,则r_n的表达式为 r_nd表达式
    根据这个递归表达式,可以得出递归的主体部分:
   var q = -1  // 初始化保存当前的最大值
        for(let i = 0; i < n; i++){    // 遍历所有的单价,从而找出最大的收益
            q = q > p[i]+cutRob(p,n-i-1) ? q : p[i]+cutRob(p,n-i-1)
        }

接下来要确定的就是递归的边界,即递归什么时候结束。显然在这里的递归边界是钢条的长度为0。此时,无论怎样切割,最大收益始终为0。 于是得到最终的递归算法:

function cutRob(p, n){
    if(n === 0) {  // 递归边界
        var q = 0
    } else {   // 递归主体部分
        var q = -1
        for(let i = 0; i < n; i++){
            q = q > p[i]+cutRob(p,n-i-1) ? q : p[i]+cutRob(p,n-i-1)
        }
    }
    return q
}

2.1.4递归树中发现的华点

现在,我们可以通过递归的方法计算得出 n=4 时的最大收益了。画出递归树如图所示: n=4时的递归树 从这棵递归树中,不难发现,红色的部分跟前面的黑色部分是重合的。显然,随着n的增大,重合的部分会更多。也就是说,除了第一棵子树,剩下的很多运算全部都是多余的。想要改进算法,最好的办法就是避免重复的计算。
那么,问题来了,如何避免重复的计算呢?最直接的办法就是将计算过的值保存起来。这样,需要的时候直接拿过来用就可以了。废话不多说,贴代码!

function cutRodMemoized(p, n){
    let r = []   // 用一个数组来保存计算过的结果
    for(let i = 0; i <= n; i++){
        r[i] = -1
    }

    return cutRobMemoizedAux(p, n, r)
}

function cutRobMemoizedAux(p, n, r){
    if(r[n]>=0) {   // 判断之前是否计算过
        return r[n]
    } 
    if(n === 0) {
        var q = 0
    } else {
        var q = -1
        for(let i = 0; i < n; i++){
            q = q > p[i]+cutRobMemoizedAux(p,n-i-1,r) ? q : p[i]+cutRobMemoizedAux(p,n-i-1,r)
        }
    }
    
    r[n] = q
    return q
}

2.1.5自顶向下 VS 自底向上

由上述部分计算得到的结果,还是之前那棵递归树。但是实际运算的部分只有黑色的部分。将实际运算的部分重新画一下得到: 实际的运算规模 这幅图简单明了的表示出了在实际计算中,要计算的问题的规模为:n + (n-1) + (n-2) + … + 2 + 1,即O(n^2)。其中,递归的压栈方向为箭头的指向。这种算法即为带备忘录的自顶向下法(top-down with memoization)
但是,对于解决一个特定的问题而言,倒着往回递归总是觉得看起来,不舒服。那么,如何将这种倒着递归的算法掰正呢?
从这幅图中可以发现,从 4 出发往外指的箭头有 4 条,指向 0 的箭头也有四条。同理,1 和 3 也有这种对应关系,2 指出和指向的箭头都是一样的。这意味着,从最大值往最小值递归调用和从最小值往最大值循环这两个过程是等价的。因此,用循环代替递归在理论上是可行的。
要用循环代替递归,还需要解决一个问题,即 n-i 的最大收益从哪里找?要解决这个问题就要思考在递归算法中备忘录里究竟保存的是什么东西。显然,递归算法中的备忘录 r[i] 中保存的为长度为 i 英尺时的最大收益。现在,可以使用自底向上的遍历算法(bottom-up method) 代替带备忘录的自顶向下的递归算法了。
又到了贴代码的时候了:

function cutRodBottomUP(p,n){
    let r =[]
    r[0] = 0

    for(let i = 1; i <= n; i++){
        let q = 0
        for(var j = 0; j < i; j++) {
            q = q>p[j]+r[i-j-1] ? q : p[j]+r[i-j-1]
        }
        r[i] = q
    }
    return r[n]
}

2.2 DP 解决问题的原理

在《算法导论》中,设计动态规划算法的四个步骤为:

  1. 刻画一个最优解的结构特征
  2. 递归地定义最优解的值
  3. 计算最优解的值,通过采用自底向上的方法
  4. 利用计算出的信息构造出一个最优解 对应这四个步骤,来看解决钢条切割问题这个例子。在2.1.3用暴力递归求解这一部分,我们得出了问题的最优子结构;在2.1.4在递归树中发现华点这一部分中采用递归求解除了最优解的值;在2.1.5自顶向下VS自底向上这部分中采用自底向上的方法求解出了最优解的值;至于第4个步骤,则只需要在第三步的代码中将i保存起来,然后按照顺序将i打印出来,就得到了最优解。
    这个时候应该注意到,在钢条切割这个例子中,后面的三个部分都可以对应到动态规划的设计步骤。那么,2.1.2问题分析这一步是在做什么呢?这部分内容讲了对于DP问题的一般建模方式:
    动态规划就是一个动态地选择过程。 并且总是存在一个评价的标准,每一次选择都会带来一个后果。例如这个问题中的,每次选择切一刀的时候总会带来一个单独的 i 的价值,以及剩余部分的最大价值。

2.3另一个例子:寻找最大公共子串

2.3.1 问题描述

  • 先导定义:
    1. 子序列:给定一个序列 X=<x_1, x_2, … , x_m>和另一个序列 Z=<z_1, z_2, … , z_k>, 满足存在一个严格递增的 X 下标序列<i_1, i_2, …,i_k>,对所有的 j=1,2,…,k, 满足x_i_j = z_j,则称 Z 是 X 的子序列
    2. 公共子序列:给定两个序列 X 和 Y ,如果 Z 既是 X 的子序列,又是 Y 的子序列,那么称Z 是 X 和 Y 的公共子序列
  • 最长公共子序列(LCS)问题:
    给定两个序列 X=<x_1, x_2, … , x_n> 和 Y=<y_1, y_2, … , y_m>,求 X 和 Y长度最长的公共子序列。
  • 例子:
    x = ['A','B','C','B','D','A','B']
    y = ['B','D','C','A','B','A']

2.3.2 利用DP的思想分析问题

首先,对这个问题进行建模。对于字符串 x 而言,有 x.length = m 中选择,对于字符串 y 而言,有 y.length = n 中选择。因此,需要建立起两个长度分别为 m 和 n 的数组用来存放选择的情况。 存储选择情况的两个数组
数组的每个位置存储的都是布尔类型的值,表示是否选择改下标对应位置的元素。如果用暴力解法,那么需要将 x 和 y 的子串都写出来,并且分别判断他们是否相等。太慢了,不可取。这时候需要换一种思路,那就是在选择元素的规则上面做文章。显然,比较好的一个想法就是判断分别属于两个子串中两个元素的是否相等,如果相等的话,选择该元素,否则跳过。
将这个简单的思路用数学的语言来描述,即 算法思想的数学描述
令 c[i, j] 表示当前的公共子串的最大值,如果对应的元素 x[i] 和 y[j] 相等,则在之前的公共子串最大长度加一,否则选择当前最大的公共子串长度。当运算到 m,n 的时候肯定可以得到两个字符串的最长公共子串。上述就是动态规划的思想

2.3.3 贴代码

function lcs(x,y){
    const m = x.length
    const n = y.length
    let c = []

    // 初始化存放最长子串的数组
    for(let i = 0; i <= m; i++){
        c[i] = []  
        for(let j=0; j <= n; j++){
            c[i][j] = 0             
        }
    }

    for(let i = 0; i <= m; i++){
        for(let j = 0; j <= n; j++){
            if(i === 0 || j === 0) {    // 设置DP的边界
                c[i][j] = 0             
            } else if(x[i-1] === y[j-1]) {
                c[i][j] = c[i-1][j-1] + 1      // 往右上方寻找
            } else {
                c[i][j] = c[i-1][j] > c[i][j-1]     // 判断条件,寻找两个值中的最大值
                        ?   c[i-1][j]               // 往上走                 
                        :   c[i][j-1]               // 往左走
            }
        }
    }
    return c[m][n]
}

3.贪心

3.1听说:每一个贪心后面都有一个复杂的DP?

每个贪心后面都有一个DP,这要从贪心算法的设计步骤说起了。贪心算法的设计步骤:

  1. 确定问题的最优子结构
  2. 设计一个递归算法
  3. 证明如果我们做出一个贪心选择,则只剩下一个子问题
  4. 证明贪心选择总是安全的
  5. 设计一个递归算法实现贪心策略
  6. 将递归算法转换为迭代算法 仔细对比贪心和 DP 的设计步骤,不难发现第一步确定问题的最优子结构和第二步设计递归算法是重合的。而之后的步骤就会发生分歧对于 DP 而言,是直接根据最优子结构设计带备忘录的自顶向下的递归算法和自底向上的迭代算法;而对于贪心而言,则是选择局部最优解。

3.2举个栗子:活动选择问题

3.2.1问题描述

假定有一个 n 个活动的集合 S={a_1, a_2, … , a_n},这些活动使用同一个资源(例如一个阶梯教室),而这个资源在某个时刻只能供一个活动使用。每个活动 a_i 都有一个开始时间 s_i 和结束时间 f_i。如果被选中,任务 a_i 发生在版开版闭区间 [s_i, f_i) 期间。如果两个活动 a_i 和 a_j 满足 [s_i, f_i) 和 [s_j, f_j) 不重合,则称他们是兼容的
在获得那个问题的选择中,我们希望选出一个最大兼容活动集。假定活动已按结束时间的单调递增顺序排序: 活动时间表

3.2.2解决思路

跟前面的问题一样,这也是一个动态选择的问题每一次选择带来的代价就是该时间段不能安排其他的活动。我们要找到一个区间,在该区间内可以安排的活动最多。其递归表达式如下: 活动选择问题的递归表达式 在计算出结论之后将 a_i 和 a_j 加上即可得到最终的结果。根据这个结论可以得出一个 DP 算法来解决活动安排问题。然而,这个例子又是满足当我们每一次都做贪心选择的时候,最终得到的结果也是最佳的这个特性的。因此可以用贪心算法来做。对于活动选择这个问题而言,显然活动结束的最早最有利于我们安排活动,因此每次的贪心选择是活动结束最早的那个活动。我们只要在每次选择都选择活动结束最早的那个即可。

3.2.3废话不多说,贴代码 

  • DP 的代码:
function activitySelect(start, final){
    let len = (start.length === final.length) ? start.length : null
    var dp = []
    let max = 0

    for(let i = 0; i <= len + 1; i++){
        dp[i] = new Array(len+2).fill(0)
    }

    for(let j = 1; j <= len+1; j++) {
        for(let i = 0; i < j; i++){             // i < j
            for(let k = i; k < j; k++){
                if(final[i]<=start[k] && final[k] <= start[j]){ // 边界条件
                    const res = dp[i][k] + dp[k][j] + 1
                    max = max > res ? max : res
                }
                dp[i][j] = max
                max = 0
            }
        }
    }
    console.log(dp)
    return dp[0][len+1]
}
  • 贪心的代码:
function greedyActivitySelector(s,f){
    let n = s.length
    let selectRes = [`a1`]
    let k = 0

    for(let i = 1; i<n; i++){
        if(s[i]>=f[k]){
            selectRes.push(`a${i + 1}`)
            k = i
        }
    }

    return selectRes
} 
  • 说明:按照这个DP的解法来看的话,他只是找到了a_i和a_j之间的不重叠的元素,但是永远比正确值少2,而这少了的两个元素就是a_i 和 a_j

3.3贪心?DP?

DP和贪心都是动态选择问题,但是贪心每次做的选择都是当前的最优解,然而DP则是要遍历子问题,在多个子问题中选出最优的那一个。因此可以这么说:能够用贪心解决的问题都可以用DP来解决,但是能用DP来解决的问题却不一定能用贪心来解决。如果一个问题可以用贪心来解决的话,那么该问题一定满足对所有的局部最优解求并集可以得到该问题的全局最优解,即满足贪心选择性质。