算法导论真的好(厚)啊,才看到动态规划

331 阅读2分钟

动态规划

动态规划(dynamic programming)常用来求解最优化问题。

设计的步骤

设计动态规划算法分为四步:

  1. 刻画一个最优解的结构特征
  2. 递归的定义最优解的值
  3. 计算最优解的值,通常采用自底向上的方法
  4. 利用计算出的信息构造一个最优解

钢条切割

问题描述:

给定一段长度为n英寸的钢条和一个价格表pi(i = 1,2,...,单位为美元),求切割钢条方案,使得销售收益rn最大。

长度为n英寸的钢条有2^n-1^种不同的切割方案,因为在钢条的左端i(i = 1,2,...,n - 1英寸处,我们总是选择切割或者不切割。

如果一个最优解钢条切割为k段(1<= k <= n),那么最优切割方案为 n = i1 + i2 +...+ik,最大收益为rn = pi1 + pi2 +...+ pin

对于rn可以用更短的钢条的最优切割收益来描述:rn = max(pn,r1 + rn-1 ,r2 + rn-2,rn-1 + r1)

首先将钢条切割为长度为i和n - i的两段,接着求解这两段的最优切割收益ri和rn-i(每种方案的最优收益为两段的最优收益之和),由于无法预知哪种方案会获得最优收益,我们必须考察所有可能的i,选取其中收益最大者。如果直接出售原钢条会获得最大收益,当然选择不作切割。

钢条切割问题满足最优子结构性质:问题的最优解由相关子问题的最优解组合而成,而这些子问题可以独立求解。

求解该问题有两种方法

  • 方法一:使用自顶向下的递归方法求解

    public static int cutRod(int[] p,int n) {
        if (n == 0) {
            return 0;
        }
        int q = Integer.MIN_VALUE;
        for (int i = 1; i <= n; i++) {
            q = Integer.max(q,p[i] + cutRod(p,n - i));
        }
        return q;
    }
    

    该方法的时间复杂度达到了指数级O(2^n^),效率极低,因为它考察了所有2^n-1^种可能的切割方案,反复求解相同的子问题,递归调用树中共有2^n-1^个叶节点,每个叶节点对应一种可能的钢条切割方案,路径上的标号给出了每次切割前右边剩余部分的长度(子问题的规模)。也就是说标号给出了切割点。

    因为时间大量耗费在反复求解相同的子问题,可以通过保存已经求过的子问题的解来优化此方法,即带备忘的递归算法。

    public static int memoizedCutRod(int[] p,int n) {
        int[] r = new int[n + 1];
        for (int i = 0; i <= n; i++) {
            r[i] = Integer.MIN_VALUE;
        }
        return memoizedCutRodAux(p,n,r);
    }
    
    public static int memoizedCutRodAux(int[] p,int n,int[] r) {
        if (r[n] >= 0) {
            return r[n];
        }
        int q;
        if (n == 0) {
            q = 0;
        } else {
            q = Integer.MIN_VALUE;
            for (int i = 1; i <= n; i++) {
                q = Integer.max(q,p[i] + memoizedCutRodAux(p,n - i,r));
            }
        }
        r[n] = q;
        return q;
    }
    

    时间复杂度为O(n^2^),空间复杂度为O(n)。

  • 方法二:使用自底向上的动态规划求解

    public static int bottomUpCutRod(int[] p,int n) {
        int[] r = new int[n + 1];
        r[0] = 0;
    
        for (int j = 1; j <= n; j++) {
            int q = Integer.MIN_VALUE;
            for (int i = 1; i <= j; i++) {
                q = Integer.max(q,p[i] + r[j - i]);
            }
            r[j] = q;
        }
        return r[n];
    }
    

    时间复杂度为O(n^2^),空间复杂度为O(n)。

    该方法将子问题按规模排序,按由小到大的顺序进行求解。当求解某个子问题时,它所依赖的那些更小的子问题都已经求解完毕,结果已经保存。每个子问题只需求解一次,当我们求解它时(也是第一次遇到),它的所有子问题都已经求解完成。

这两种方法相比,具有相同的渐进运行时间,由于没有频繁的递归函数调用的开销,自底向上的方法的时间复杂度函数通常具有更小的系数。

扩展:

上述算法只求出最大收益的值,并没有求出对应的切割方案,可利用扩展算法来完成这项工作。

public static void extendedBottomCutRod(int[] p,int n) {
    int[] r = new int[n + 1];
    int[] s = new int[n + 1];
    r[0] = 0;
    for (int j = 1; j <= n; j++) {
        int q = Integer.MIN_VALUE;
        for (int i = 1; i <= j; i++) {
            if (q < p[i] + p[j - i]) {
                q = p[i] + p[j - i];
                s[j] = i;
            }
        }
        r[j] = q;
    }
    System.out.println("最大收益:" + r[n]);
    System.out.print("最佳切割方案:");
    while (n > 0) {
        System.out.print(s[n] + " ");
        n = n - s[n];
    }
}

矩阵链乘法

问题描述:给定一个n个矩阵的序列(矩阵链)(A1,A2,...,An),计算它们的乘积A1A2...An

我么可以先用括号明确计算次序,然后利用标准的矩阵相乘算法进行计算。由于矩阵乘法满足结合律,因此任何加括号的方法都会得到相同的计算结果。我们称为如下性质的矩阵乘积链为完全括号化的。

对矩阵加括号的方式会对乘积运算产生巨大的影响。

两矩阵相乘的标准算法:

public static int[][] matrixMultiply(int[][] A,int[][] B) {
    if (A[0].length != B.length) {
        return null;
    } 
    int[][] C = new int[A.length][B[0].length];
    for (int i = 0; i < A.length; i++) {
        for (int j = 0; j < B[0].length; j++) {
            for (int k = 0; k < A[0].length; k++) {
                C[i][j] += A[i][k] * B[k][j];
            }
        }
    }
    return C;
}

时间复杂度为O(n^3^)。

矩阵链乘法问题:

给定一个n个矩阵的序列(矩阵链)(A1,A2,...,An),矩阵Ai的规模为pi-1 x pi(1 <= i <= n),求完全括号方案,使得计算乘积A1A2...An所需标量乘法次数最少。这里不是真正进行矩阵相乘运算,只是确定代价最小的计算顺序。

应用动态规划方法

步骤一:最优括号化方案的结构特征

动态规划的第一步就是寻找最优子结构,然后就可以利用这种子结构从子问题的最优解构造出原问题的最优解。

用Ai..j(i <= j)表示AiAi+1..Aj乘积的结果矩阵。为了对AiAi+1..Aj进行括号化,我们就必须在某个Ak和Ak+1之间将矩阵链划分开(i <= k < j)。我们可以将问题划分为两个子问题AiAi+1..Ak和AkAk+1..Aj的最优括号化问题。

步骤二:一个递归求解方案

用m[i,j]表示AiAi+1..Aj的最低代价。原问题的最优解为m[1,n]。m[i,j]就等于计算AI..k和Ak+1..j的代价加上两者相乘的代价的最小值。

由于Ai的大小为pi-1 x pi(pi代表第i个矩阵的列数,p0表示第一个矩阵的行数)。AI..k和Ak+1..j相乘的代价为pi-1 x pk x pj次标量运算。可得 m[i,j] = m[i,k]+m[k+1,j]+pi-1 x pk x pj。k有j-i种可能的取值,即k = i,i+1,...,j - 1。由于最优分割点必在其中,只需检查所有可能的情况,找到最优者即可。用s[i,j]保存最佳分割点k。

步骤三:计算最优代价

用数组p[0...n] (长度为n)存储每个矩阵的列数。

数组m[1...n,1..n]来保存代价m[i,j]。(i <= j)

用s[i,j]保存最佳分割点k。

动态规划方法

算法按长度递增的顺序求解矩阵括号化问题

public static void matrixChainOrder(int[] p) {
    int n = p.length - 1;
    int[][] m = new int[n + 1][n + 1];
    int[][] s = new int[n + 1][n + 1];
    int q = 0;
    for (int l = 2;l <= n; l++) {
        for (int i = 1;i <= n - l + 1; i++) {
            int j = i + l - 1;
            m[i][j] = Integer.MAX_VALUE;
            for (int k = i; k <= j - 1; k++) {
                q = m[i][k] + m[k + 1][j] + p[i - 1] * p[k] * p[j];
                if (q < m[i][j]) {
                    m[i][j] = q;
                    s[i][j] = k;
                }
            }
        }
    }
    printOptimalParens(s,1,n);
}

运行的时间复杂度为O(n^3^),空间复杂度为O(n^2^)。

步骤四:构造最优解

public static void printOptimalParens(int[][] s,int i,int j) {
    if (i == j) {
        System.out.print("A"+ i + " ");
    } else {
        System.out.print("(");
        printOptimalParens(s,i,s[i][j]);
        printOptimalParens(s,s[i][j] + 1,j);
        System.out.print(")");
    }
}

递归方法

public static int recursiveMatrixChain(int[] p,int[][] m,int i,int j) {
    if (i == j) {
        return 0;
    }
    m[i][j] = Integer.MAX_VALUE;
    for (int k = i; k <= j - 1; k++) {
        int q = recursiveMatrixChain(p,m,i,k) + recursiveMatrixChain(p,m,k + 1,j) + p[i - 1] * p[k] * p[j];
        if (q < m[i][j]) {
            m[i][j] = q;
        }
    }
    return m[i][j];
}

时间复杂度为O(2^n^)。

带备忘的递归算法

public static int memoizedMatrixChain(int[][] p) {
    int n = p.length - 1;
    int[][] m = new int[n + 1][n + 1];
    for (int i = 1;i <= n; i++) {
        for (int j = 1; j <= n; j++) {
            m[i][j] = Integer.MAX_VALUE;
        }
    }
    return lookUpChain(m,p,1,n);
}

public static int lookUpChain(int[][] m,int[] p,int i,int j) {
    if (m[i][j] < Integer.MAX_VALUE) {
        return m[i][j];
    }
    if (i == j) {
        m[i][j] = 0;
    } else {
        for (int k = i;k <= j - 1; k++) {
            int q = lookUpChain(m,p,i,k) + lookUpChain(m,p,k + 1,j) + p[i - 1] * p[k] * p[j];
            if (q < m[i][j]) {
                m[i][j] = q;
            }
        }
    }
    return m[i][j];
}

时间复杂度为O(^3^)。

带备忘的自顶向下的动态规划算法和自底向上的动态规划算法,时间复杂度均为O(n^3^)。两种方法都利用了重叠子问题的性质。对每个子问题都只求解一次,而没有备忘的递归算法由于反复求解相同的子问题,运行时间为指数阶。

选择:

  • 如果每个子问题至少求解一次,自底向上的动态规划算法比自顶向下的备忘规划算法快(运行时间相差一个常量系数,自底向上没有递归的开销,维护开销也更小)。
  • 如果子问题空间中的某些子问题完全不必求解,备忘方法比较好,因为它只求解那些绝对必要的子问题。

动态规划原理

最优子结构

如果一个问题的最优解包含其子问题的最优解,称此问题具有最优子结构性质。使用动态规划方法时,我们用子问题的最优解来构造原问题的最优解。

发掘最优子结构的过程中,遵循如下通用模式:

  1. 做出第一个选择。做出这次选择会产生很多子问题
  2. 对于一个给定问题,在其可能的一步选择中,假定知道得到最优解的选择。
  3. 获得最优解的选择后,确定这次选择会产生的子问题
  4. 作为构成原问题最优解的组成部分,每个子问题的最优解就是它本身的最优解。

刻画子空间的最好方法就是保持子空间尽可能简单,只在必要时才扩展它。

对于不同的问题,最优子结构的不同主要体现在两个方面:

  1. 原问题的最优解中涉及多少个子问题
  2. 在确定最优解使用哪些子问题时,需要考察多少种选择

可以用子问题的总数和每个子问题需要考察多少种选择这两个因素的乘积来粗略分析动态规划算法的运行时间。

在动态规划方法中,我们通常自底向上地使用最优子结构。也就是说,首先求的子问题的最优解,然后求原问题的最优解。原问题的代价通常就是子问题最优解的代价再加上由此次选择直接产生的代价。

只有当子问题无关时,即同一个原问题的一个问题的解不影响另一个子问题的解,这时候问题才具有最优子结构性质,才可以使用动态规划方法。如求无权最短路径就可以用动态规划,而无权最长路径不能是用此方法,因为不具备最优子结构性质。

重叠子问题

适合用动态规划方法求解的最优化问题应该具备的第二个性质是子问题空间必须足够小,即问题的递归算会反复求和相同的子问题,而不是一直生成新的子问题。如果递归算法反复求解相同的子问题,就称最优化问题具有重叠子问题性质。

动态规划通常这样利用重叠子问题性质:

对每个子问题求解一次,将解存入一个表中,当再次需要这个子问题时直接查表,每次查表的代价为常量时间。

最长公共子序列

问题描述:给定两个序列X={x1,x2,...xm}和Y={y1,y2,...,yn},求X和Y长度最长的公共子序列。

计算LCS的长度

public static void lcsLength(String X,String Y) {
    int m = X.length();
    int n = Y.length();
    char[][] b = new char[m][n];
    int[][] c = new int[m][n];

    for (int i = 1; i <= m - 1; i++) {
        for (int j = 1; j <= n - 1; j++) {
            if (X.charAt(i) == Y.charAt(j)) {
                c[i][j] = c[i - 1][j - 1] + 1;
                b[i][j] = '↖';
            } else if (c[i - 1][j] >= c[i][j - 1]) {
                c[i][j] = c[i - 1][j];
                b[i][j] = '↑';
            } else {
                c[i][j] = c[i][j - 1];
                b[i][j] = '←';
            }
        }
    }
    printLcs(b,X,m - 1,n - 1);
}
public static void printLcs(char[][] b,String X,int i,int j) {
    if (i == 0 || j == 0) {
        return;
    }
    if (b[i][j] == '↖') {
        printLcs(b,X,i - 1,j - 1);
        System.out.print(X.charAt(i) + " ");
    } else if (b[i][j] == '↑') {
        printLcs(b,X,i - 1,j);
    } else {
        printLcs(b,X,i,j - 1);
    }
}