基础-9:一文搞定动态规划

1 概述

前面的课程中讲到了图的基本遍历算法和简单的应用,本来想接着往后面继续讲,后来有童鞋说讲讲动态规划吧,看书有些晕,再联想到图算法中也会用到动态规划和贪心算法,就先把这两个写了。

动态规划,英文名称为dynamic programming,语义为:在运行过程中找最优解。第一次接触,可能有些模糊,本文会通过三个例子对动态规划这种思想进行阐述,动态规划不是一个固定的算法,它代表的是一种与递归类似的思想,在中小学就接触过,它没有深奥的数学推导,仔细琢磨就能搞定。本文通过最少硬币数、矩阵乘法和最长公共子串三个最常用的例子对动态规划进行说明。

2 动态规划实践

2.1 最少硬币数

问题:假设有1分、2分、5分、9分、1角的硬币若干枚,给定任意的一个金额,如1角8分,最少的硬币数是多少?
答:直觉貌似是1个1角+1个5分+1个2分+1个1分,这样是4个硬币。其实,对这个问题,用2个9分的硬币就行了。

对于上面的问题,最终的答案并不重要,关键是解题的过程。下面讨论如何得到这个答案。

假设对于任意的金额n(分),其最小的硬币数为rn,则有:

  • r1 = 1
  • r2 = 2
  • r3 = 2
  • r4 = 2
  • r5 = 1
  • r6 = 2
  • r7 = 2
  • r8 = 3
  • r9 = 1
  • r10 = 1

显然,并不是随着金额数的增加,所需的硬币数简单增加。若用k1、k2、k5、k9、k10分别表示组合成n分的硬币数,则有:

k1 * 1 + k2 * 2 + k5 * 5 + k9 * 9 + k10 * 10 = n

要求:

min(k1 + k2 + k5 + k9 + k10)

分析到这一步,可以开始用暴力求解了,即找出k1、k2、k5、k9、k10所有的组合,具体的算法如下:

// r[i]表示金额为i时的最小硬币数, n为金额
r := make([]int, 0){1, 2, 2, 2, 1, 2, 2, 3, 1, 1}
func MinCoins(n int) int{ 
    if n == 0{
        return 0
    }

    if n <= 10{
        return r[n - 1]
    }
    q := n
    for i := 1; i < n; i++{
        // min是求最小数的函数
        q = min(q, MinCoins( i) + MinCoins(n - i))
    }
    return q
}

对于任何n,MinCoins都需要重新计算1~n的MinCoins,其时间复杂度为T(n) = 2n。仔细分析算法MinCoins,会发现一个问题:即计算MinCoins(k)时,需要计算MinCoins(m)(m < k),而当计算MinCoins(k+1)时,仍然需要重复计算MinCoins(m)(m < k),这会产生庞大的时间开销(注:递归算法的开销请查看关于递归)。

针对这个问题,很自然的一个想法是:如果将每次计算的MinCoins值保存起来,则其计算开销将显著减小。此时,算法变成:

back := make([]int, 0){1, 2, 2, 2, 1, 2, 2, 3, 1, 1}
func MinCoinBack(n int) int{
    if n == 0{
        return 0
    }
    // 如果已经保存相应的解,直接返回
    if n <= len(back) - 1{
        return back[n]
    }

    q := n
    for i := 1; i < n; i++{
        if v, ok := back[i]; !ok{
            back = append(back, MinCoinBack(i))
        }else{
            q = min(q, v + MinCoinBack(n - i))
        }
    }
    return q    
}

此时,虽然也用到了递归,但在back中保存了每次的MinCostsBack的值,每个值只需计算一次,其时间复杂度为O(n2).

分析:在求最小硬币数的过程中,MinCoins(m)依赖于MinCoins(i)和MinCoins(m - i),而MinCoins(i)又依赖于MinCoins(j)和MinCoins(i - j),即每个问题,都依赖于更小的具有相同结构的子问题,如果每次都重复求解子问题,则需要的时间开销与规模呈幂集增长。若在计算过程中,将子问题的解保存,则每个子问题恰好只需要计算一次,其时间复杂度降为O(n2)。这就是一个简单的动态规划问题,其核心为子问题。

动态规划思想通常用来解决最优化问题,如本例中的最小硬币数,其基本特征是:规模为n的问题可以划分为数量有限的若干类子问题求解,且与子问题之间存在直接或间接递归,其解决方法也很直接,以空间换时间,提升算法效率。

2.2 矩阵乘法

矩阵乘法是代数计算的核心基础,是matlab的计算核心,如何优化矩阵乘法是关键。先看矩阵乘法:

若有矩阵A、B,且row(A) = p, col(A) = row(B) = m, col(B) = q,这A、B可以相乘,若C=AB,则Cij = Ai1B1j + Ai2B2j + ... + AimBmj. 矩阵乘法的代价主要由各标量元素乘法的次数决定,C=AB的计算复杂度为:pmq

如果有矩阵A1, A2, A3三个矩阵,A1为10*100矩阵、A2为100*5矩阵、A3为5*50矩阵,若按照(A1*A2)*A3的顺序计算,则需要标量乘法次数为:10*100*5 + 10*5*50 = 7500;若按照A1*(A2*A3),则需要的标量乘法次数为:100*5*50+10*100*50=75000。显然,不同的乘法顺序意味着不同的计算效率,本例中,前者的计算效率是后者的10倍,这正是算法的魅力所在。由此,也引出了一个著名的矩阵链乘法问题

给定n的矩阵A1, A2, ..., An,矩阵Ai的规模为pi * q i,求解A1*A2* ...*An的计算次序(矩阵乘法满足结合律),使其所需的标量乘法最少。

这里有两个地方需要注意:

  • 计算次序就是给矩阵加括号,确定先算哪两个矩阵的乘积,再算哪两个矩阵的成绩,通常称之为括号化方案;
  • 这里要确定括号化方案,并不是真正对矩阵实施乘法,而是确定以那种括号化方案计算最为高效。

分析:对于这类上来就带n的规模的问题,通常的思路是:先分析n=1、2、3、4时,再分析n=k时的问题,总结出一般的规律,最后对一般规律求解,下面分情况讨论:

  • n = 1, 2时,括号化方案是唯一的
  • n = 3时,有两种方案:(A1*A2)*A3和A1*(A2*A3)
  • n = 4时,有((A1*A2)*A3)*A4, (A1*(A2*A3))*A4, (A1*A2)*(A3*A4), A1*(A2*(A3*A4)), A1*((A2*A3)*A4)

当分析到n=4的时候,可以观察出规律了,可以将A1*A2*A3*A4看成:

  • A1*A2*A3与A4两部分,前者即是n=3的情形;
  • A1*A2 与A3*A4两部分,两部分均是n=2的情形;
  • A1与A2*A3*A4两部分,后者为n=3的情形。

这三种情形中,n=4可以分解为n=2, 3时的问题,即找到了这种递推规律:


基础-9:一文搞定动态规划_第1张图片
矩阵链乘法递推规律

此时,剩下的问题时如何求括号化方案了。显然,这一问题要比最小硬币数要困难,虽然找到了规模为n的问题与规模小于n之间的关系,但是并未找到解之间的关系(这里的解是标量乘法次数)。

若有M=Ai*Ai+1* ...*Aj,row(Ai) = pi - 1, col(Ai) = pi不妨取M中标量的乘法次数为m[i, j],则有:m[i, j] = m[i, k] + m[k + 1, j] + pi-1pkpj,最小括号化方案为:

最小括号化方案

m[1, n]即为最小化括号方案的解。到这里,问题的本质就变成和最少硬币数一样的问题了,即需要将在求解m[i, j]过程中所求取的m[i, k]和m[k+1, j]的值暂存,规避重复求解,其算法如下所示(摘自算法导论):

基础-9:一文搞定动态规划_第2张图片
矩阵最小括号化算法

同样,其算法规模由递归的O(2n)降为O(n2),感兴趣的童鞋可以深入分析。

2.3 最长公共子序列LCS

这个问题是企业面试中很常见的一个问题,本人帮朋友面试的时候,也经常会问这个问题,很少见童鞋能直接提到动态规划四个字。在具体的工程实践中,需要码农们分析归结到这个问题,才可以高效地实现算法。

先看子序列的定义:

若有序列X=< x1, x2, ..., xm >,存在另一个序列Z=< z1, z2, ..., zk >,且存在严格递增的X的下标序列< i1, i2, ..., ik >,满足(xi)j = zj (1 <= j <= k).

如果从数学分析中数列的角度看,Z就是X的子列。最长公共子序列就是求两个序列的最长公共子序列。如何求解最长公共子序列呢?

分析:假设有X,Y两个序列,X、Y长度分别为m、n,不妨取X,Y的最长公共子序列为LCS(X, Y), 可以按照下面的思路分析:

  • 当m = 0或n = 0时,没有公共子序列
  • 当m = i、 n = j时, 如果X[i]=Y[j],则有LCS(X, Y) = LCS(X1..i-1, Y1..j-1) + X[i],否则,LCS(X, Y) =max{ LCS(X1..i-1, Y), LCS(X, Y1..j-1)}

由上分析知,最长公共子序列变成了和最少硬币数、矩阵链乘法一样的问题。在实现时,需要逐元素比较两个序列,其复杂度为O(mn),算法描述如下:

// lcs表示最长公共子序列,用于暂存计算中的最长公共子序列,伪代码,需要修改才能运行,后续将在github上列出
lcs := make([][]string, 0) 
func LCS(X, Y string) string{
    for i := 0; i < len(X) ; i++{
        lcs[i][0] = ""
    }
    for j := 0; j < len(Y) ; j++{
        lcs[0][j] = ""
    }
    for i := 1; i <= len(X); i++{
        for j := 1; j <= len(Y); j++{
            if X[i] == Y[j]{
                lcs[i][j] = lcs[i-1][j-1] + X[i]
            }else if len(lcs[i - 1][j]) > len(lcs[i][j - 1]){
                lcs[i][j] = lcs[i - 1][j]
            }else{
                lcs[i][j] = lcs[i][j - 1]
            }
        }
    }
}

3 小结

最少硬币数、矩阵乘法、和LCS是三个典型的动态规划问题,它代表的是一种思考方式,应对的问题通常是优化问题,这三个例子中分别求解的问题是:最少的硬币数、最少的标量乘法数、最长的公共子序列,大部分情况下,是规模为n的问题通过类似递归演变为规模更小的子问题,有些书上称之为最优解结构。用动态规划时,一定要保证最终的子问题种类是有限的,而不能不停产生新的子问题

总体而言,这三个例子很好地代表了动态规划问题以及其解决方案,其本质都递归为有限的子问题,并且用空间换时间对其进行高效处理。

你可能感兴趣的:(基础-9:一文搞定动态规划)