vlambda博客
学习文章列表

动态规划算法Dynamic Programming

动态规划与分治法相似,都是通过组合子问题的解来求解原问题。不同的是,分治法将问题划分为互不相交的子问题,递归的求解子问题,再将他们的解组合起来,求出原问题的解。与之相反,动态规划应用于子问题重叠的情况,即不同的子问题具有公共的子子问题。在这种情况下,分治法会做许多不必要的工作,他会反复求解那些公共子子问题。而动态规划只会对子子问题求解一次,将其保存在一个表格中,从而避免每次求解时都重新计算。现在我们来逐步的优化一个经典的问题——最长公共子序列,从中分析动态规划的思想方法。

0.问题描述:

最长公共子序列问题(longest - common - subsequence problem)给定两个序列X = <x1,x2,x3,...xm> 和Y = <y1,y2,y3...yn>,求X和Y的长度最长的公共子序列。

1.暴力搜索方法:

我们把问题分割成两个部分:遍历和判断。首先遍历X的所有的子序列,然后依次进行判断是否为Y的子序列,并比较得出其中最长的一个。

对于判断,可以从左向右扫描一遍即可实现,复杂度为O(n):

/**
   * 判断sub是否为a的子序列
   * @param a
   * @param sub
   * @return
   */
   private static boolean isSubsequence(char[] a, char[] sub) {
       int i = 0, j = 0;
       while (i < a.length && j < sub.length) {
           if (sub[j] == a[i]) {
               i++;
               j++;
          } else {
               i++;
          }
      }
       if (j < sub.length)
           return false;
       else
           return true;
  }

对于遍历,一个具有m个字符的序列,一共有2^m个子序列。然后逐一进行判断比较,这个算法的时间复杂度为O(2^m)*O(n).

我们可以借助深度优先搜索的思想进行子序列的遍历:

/**
   * 暴力穷举的方法取最长公共子序列
   * 遍历一个字符串的所有的子序列,共有2^n种结果
   */
   private static void bruteforceForLCS(char[] chars1, char[] chars, String res, int k) {
       if (k < 0) {
           System.out.println("k不能小于0");
      } else if (k == chars.length) {
           if (!res.isEmpty() && isSubsequence(chars1, res.toCharArray()) && longest.length() < res.length()) {
               longest = res;
          }
      } else {
           for (int i = 0; i <= 1; i++) {
               if (i == 0) {
                   bruteforceForLCS(chars1, chars, res, k + 1);
              } else {
                   bruteforceForLCS(chars1, chars, res + chars[k], k + 1);
              }
          }
      }
  }

2.基于最优子结构性质的改进


基本上,任何问题都是可以使用暴力搜索的方式去解决的,但可怕的是,当问题的规模达到一定的程度以后,即使使用最快的计算机,它也需要若干年甚至是若干世纪以后才能给出我们答案。人生苦短,暴力不值得。

通过对公共子序列形成规律的深入分析,我们发现这样的性质:

X = <x1,x2,...xm>,Y = <y1,y2,...yn>,令Z = <z1,z2,...zk> 位X和Y的任意最长公共子序列,则有:

如果Xm = Yn,那么Zk = Xm = Yn且Zk-1是Xm-1和Yn-1的一个最长公共子序列;如果Xm!= Yn,那么Zk!= Xm意味着Z是Xm-1和Y的一个最长公共子序列;如果Xm!= Yn,那么Zk!= Yn意味着Z是Xm和Yn-1的一个最长公共子序列;所以,我们用这个规律来建立一个描述最长公共子序列长度的递归公式:

有了这个递归公式,我们就可以写出一个指数时间的算法来进行递归计算了:

/**
   * 直接根据递归公式进行求解最长公共子序列
   * @return
   */
   private static int dpForLCS(char[] a, char[] b, int i, int j, Stack<Character> stack, boolean[] aflag) {
       count++;
       System.out.println("递归程序正在执行i=" + i + ",j=" + j);
       if (i == -1 || j == -1) {
           return 0;
      } else {
           if (a[i] == b[j]) {
               if (!aflag[i]) {
                   stack.push(a[i]);
                   aflag[i] = true;
              }
               return dpForLCS(a, b, i - 1, j - 1, stack, aflag) + 1;
          } else {
               return Math.max(dpForLCS(a, b, i - 1, j, stack, aflag), dpForLCS(a, b, i, j - 1, stack, aflag));
          }
      }
  }

3.基于缓存思想的动态规划算法


可以看出上面直接根据递归公式进行求解的方法会有很多的重复计算,比如为了求X和Y的一个LCS,我们可能需要求X和Yn-1的一个LCS以及Xm-1和Y的一个LCS,这几个问题都会包含Xm-1和Yn-1的LCS这个子问题,对于这种情况,我们将它描述为具有重叠子问题性质。解决的思路是借助缓存的思想,将已经计算出的子问题存储在一个表格里以避免重复进行计算。一般的,可以有如下的递归和迭代两种实现方式。

3.1自顶向下的递归法(top-down with memoization)

此方法依然按照自然的递归形式编写函数,但函数中会保存每个字问题的解(通常保存在一个数组或者散列表中)。当需要一个子问题的解时,函数会首先检查是否保存过此解。如果是,直接返回保存的值,从而节省了计算时间。

/**
   * 自顶向下法(递归形式的动态规划)
   */
   private static int dpForLCSDown(char[] a, char[] b, int i, int j, int[][] length) {
       if (i == 0 || j == 0) {
           return 0;
      } else {
           if (a[i] == b[j]) {
               length[i][j] = (length[i][j] == -1 ? (dpForLCSDown(a, b, i - 1, j - 1, length) + 1) : length[i][j]);
               return length[i][j];
          } else {
               length[i - 1][j] = length[i - 1][j] == -1 ? dpForLCSDown(a, b, i - 1, j, length) : length[i - 1][j];
               length[i][j - 1] = length[i][j - 1] == -1 ? dpForLCSDown(a, b, i, j - 1, length) : length[i][j - 1];
               return Math.max(length[i - 1][j], length[i][j - 1]);
          }
      }
  }

3.2自底向上的迭代法(bottom-up method)

把子问题按照规模进行排序,按照由小至大的顺序进行求解。当解决某个子问题时,它所依赖的更小的子问题都已经求解完毕,结果已经保存,每个子问题都只需求解一次。

/**
   * 自底向上法(迭代形式的动态规划)
   */
   private static int dpForLCSUp(char[] a, char[] b) {
       int[][] length = new int[a.length][b.length];
       for (int i = 0; i < a.length; i++) {
           length[i][0] = 0;
      }
       for (int j = 0; j < b.length; j++) {
           length[0][j] = 0;
      }
       for (int i = 1; i < a.length; i++) {
           for (int j = 1; j < b.length; j++) {
               if (a[i] == b[j]) {
                   length[i][j] = length[i - 1][j - 1] + 1;
              } else {
                   length[i][j] = Math.max(length[i - 1][j], length[i][j - 1]);
              }
          }
      }
       return length[a.length - 1][b.length - 1];
  }

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

4.总结

从上面的讨论过程中,我么可以看到适用动态规划算法求解的最优化问题应该具有两个要素:最优子结构性质和子问题重叠。首先,最优子结构性质可是使我们避免暴力遍历所有的子序列;其次,子问题重叠性质可以使我们借助缓存思想,避免对相同的子问题进行重复求解,从而达到比传统分治法更优的性能。最后,我们提炼出设计一个动态规划算法的步骤:

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

参考文献: Thomas H.Cormen Charles E.Leiserson.Introduction To Algorithms (Third Edition) [M]. China Machine Press, 2013




(文章首发于ruixuchen.github.io,请点击阅读原文)