算法学习--动态规划

动态规划(DP)通过分解成子问题解决了给定复杂的问题,并存储子问题的结果,以避免再次计算相同的结果。我们通过下面这个问题来说明这两个重要属性:

1)重叠子问题

2)最优子结构

1)重叠子问题:

像分而治之,动态规划也把问题分解为子问题。动态规划主要用于:当相同的子问题的解决方案被重复利用。在动态规划中,子问题解决方案被存储在一个表中,以便这些不必重新计算。因此,如果这个问题是没有共同的(重叠)子问题, 动态规划是没有用的。例如,二分查找不具有共同的子问题。下面是一个斐波那契函数的递归函数,有些子问题被调用了很多次。

/* simple recursive program for Fibonacci numbers */
int fib(int n)
{
   if ( n <= 1 )
      return n;
   return fib(n-1) + fib(n-2);
}

执行 fib(5) 的递归树

                              
                         fib(5)
                     /             \
               fib(4)                fib(3)
             /      \                /     \
         fib(3)      fib(2)         fib(2)    fib(1)
        /     \        /    \       /    \
  fib(2)   fib(1)  fib(1) fib(0) fib(1) fib(0)
  /    \
fib(1) fib(0)

我们可以看到,函数f(3)被称执行2次。如果我们将存储f(3)的值,然后避免再次计算的话,我们会重新使用旧的存储值。有以下两种不同的方式来存储这些值,以便这些值可以被重复使用。

A)记忆化(自上而下):
B)打表(自下而上):

一)记忆化(自上而下):记忆化存储其实是对递归程序小的修改,作为真正的DP程序的过渡。我们初始化一个数组中查找所有初始值为零。每当我们需要解决一个子问题,我们先来看看这个数组(查找表)是否有答案。如果预先计算的值是有那么我们就返回该值,否则,我们计算该值并把结果在数组(查找表),以便它可以在以后重复使用。

下面是记忆化存储程序:

/* Memoized version for nth Fibonacci number */
#include<stdio.h>
#define NIL -1
#define MAX 100

int lookup[MAX];

/* Function to initialize NIL values in lookup table */
void _initialize()
{
  int i;
  for (i = 0; i < MAX; i++)
    lookup[i] = NIL;
}

/* function for nth Fibonacci number */
int fib(int n)
{
   if(lookup[n] == NIL)
   {
    if ( n <= 1 )
      lookup[n] = n;
    else
      lookup[n] = fib(n-1) + fib(n-2);
   }

   return lookup[n];
}

int main ()
{
  int n = 40;
  _initialize();
  printf("Fibonacci number is %d ", fib(n));
  getchar();
  return 0;
}


一)打表(自下而上)

下面我们给出自下而上的打表方式,并返回表中的最后一项。

/* tabulated version */
#include<stdio.h>
int fib(int n)
{
  int f[n+1];
  int i;
  f[0] = 0;   f[1] = 1; 
  for (i = 2; i <= n; i++)
      f[i] = f[i-1] + f[i-2];

  return f[n];
}

int main ()
{
  int n = 9;
  printf("Fibonacci number is %d ", fib(n));
  getchar();
  return 0;
}


这两种方法都能存储子问题解决方案。在第一个版本中,记忆化存储只在查找表存储需要的答案。而第二个版本,所有子问题都会被存储到查找表中,不管是否是必须的。比如LCS问题的记忆化存储版本,并不会存储不必要的子问题答案。

2)最优子结构:如果问题的最优解所包含的子问题的解也是最优的,我们就称该问题具有最优子结构性质(即满足最优化原理)。最优子结构性质为动态规划算法解决问题提供了重要线索。

例如,最短路径问题有以下最优子结构性质:如果一个节点x是到源节点ü的最短路径,同时又是到目的节点V的最短路径,则最短路径从u到v是结合最短路径:u到x和x到v。解决任意两点间的最短路径的算法的Floyd-Warshall算法贝尔曼-福特是动态规划的典型例子。

另一方面最长路径问题不具有最优子结构性质。这里的最长路径是指两个节点之间最长简单路径(路径不循环)。

Floyd-Warshall算法,简称Floyd算法,用于求解任意两点间的最短距离,时间复杂度为O(n^3)。我们平时所见的Floyd算法的一般形式如下:

 void Floyd(){
     int i,j,k;
     for(k=1;k<=n;k++)
         for(i=1;i<=n;i++)
             for(j=1;j<=n;j++)
                 if(dist[i][k]+dist[k][j]<dist[i][j])
                     dist[i][j]=dist[i][k]+dist[k][j];
 }

 注意下第6行这个地方,如果dist[i][k]或者dist[k][j]不存在,程序中用一个很大的数代替。最好写成if(dist[i][k]!=INF && dist[k][j]!=INF && dist[i][k]+dist[k][j]<dist[i][j]),从而防止溢出所造成的错误。
  上面这个形式的算法其实是Floyd算法的精简版,而真正的Floyd算法是一种基于DP(Dynamic Programming)的最短路径算法。
  设图G中n 个顶点的编号为1到n。令c [i, j, k]表示从i 到j 的最短路径的长度,其中k 表示该路径中的最大顶点,也就是说c[i,j,k]这条最短路径所通过的中间顶点最大不超过k。因此,如果G中包含边<i, j>,则c[i, j, 0] =边<i, j> 的长度;若i= j ,则c[i,j,0]=0;如果G中不包含边<i, j>,则c (i, j, 0)= +∞。c[i, j, n] 则是从i 到j 的最短路径的长度。
  对于任意的k>0,通过分析可以得到:中间顶点不超过k 的i 到j 的最短路径有两种可能:该路径含或不含中间顶点k。若不含,则该路径长度应为c[i, j, k-1],否则长度为 c[i, k, k-1] +c [k, j, k-1]。c[i, j, k]可取两者中的最小值。
  状态转移方程:c[i, j, k]=min{c[i, j, k-1], c [i, k, k-1]+c [k, j, k-1]},k>0。
  这样,问题便具有了最优子结构性质,可以用动态规划方法来求解。

为了进一步理解,观察上面这个有向图:若k=0, 1, 2, 3,则c[1,3,k]= +∞;c[1,3,4]= 28;若k = 5, 6, 7,则c [1,3,k] = 10;若k=8, 9, 10,则c[1,3,k] = 9。因此1到3的最短路径长度为9。
  下面通过程序来分析这一DP过程,对应上面给出的有向图:

 void floyd_dp(){
     int i,j,k;
     for(i=1;i<=n;i++)
         for(j=1;j<=n;j++)
             dist[i][j][0]=map[i][j];
     for(k=1;k<=n;k++)
         for(i=1;i<=n;i++)
             for(j=1;j<=n;j++){
                 dist[i][j][k]=dist[i][j][k-1];
                 if(dist[i][k][k-1]+dist[k][j][k-1]<dist[i][j][k])
                     dist[i][j][k]=dist[i][k][k-1]+dist[k][j][k-1];
             }
 }

Floyd-Warshall算法不仅能求出任意2点间的最短路径,还可以保存最短路径上经过的节点。下面用精简版的Floyd算法实现这一过程,程序中的图依然对应上面的有向图。


 void floyd(){
     int i,j,k;
     for(i=1;i<=n;i++)
         for(j=1;j<=n;j++)
             dist[i][j]=map[i][j],path[i][j]=0;
     for(k=1;k<=n;k++)
         for(i=1;i<=n;i++)
             for(j=1;j<=n;j++)
                 if(dist[i][k]+dist[k][j]<dist[i][j])
                     dist[i][j]=dist[i][k]+dist[k][j],path[i][j]=k;
 }
 void output(int i,int j){
     if(i==j) return;
     if(path[i][j]==0) cout<<j<<' ';
     else{
         output(i,path[i][j]);
         output(path[i][j],j);
     }
 }

最长递增子序列

现在我们讨论最长递增子序列(LIS)的问题,可以使用动态规划要解决的问题,例如,
最长递增子序列(LIS)的问题是要找到一个给定序列的最长子序列的长度,使得子序列中的所有元素被排序的顺序增加。

例如,{10,22,9,33,21,50,41,60,80}  LIS的长度是6和 LIS为{10,22,33,50,60,80}。

最优子结构:

对于长度为N的数组A[N] = {a0, a1, a2, …, an-1},假设假设我们想求以aj结尾的最大递增子序列长度,设为L[j],那么L[j] = max(L[i]) + 1, where i < j && a[i] < a[j], 也就是i的范围是0到j – 1。这样,想求aj结尾的最大递增子序列的长度,我们就需要遍历j之前的所有位置i(0到j-1),找出a[i] < a[j],计算这些i中,能产生最大L[i]的i,之后就可以求出L[j]。之后我对每一个A[N]中的元素都计算以他们各自结尾的最大递增子序列的长度,这些长度的最大值,就是我们要求的问题——数组A的最大递增子序列。

重叠子问题:

以下是简单的递归实现LIS问题(先不说性能和好坏,后面讨论)。这个实现我们遵循上面提到的递归结构。使用 max_ending_here 返回 每一个LIS结尾的元素,结果LIS是使用指针变量返回。

/* LIS 简单的递归实现 */
#include<stdio.h>
#include<stdlib.h>

/* 要利用递归调用,此函数必须返回两件事情:
   1) Length of LIS ending with element arr[n-1]. We use max_ending_here for this purpose
   2) Overall maximum as the LIS may end with an element before arr[n-1]  max_ref is used this purpose.
The value of LIS of full array of size n is stored in *max_ref which is our final result
*/
int _lis( int arr[], int n, int *max_ref)
{
    /* Base case */
    if(n == 1)
        return 1;

    int res, max_ending_here = 1; // 以arr[n-1]结尾的 LIS的长度

    /* Recursively get all LIS ending with arr[0], arr[1] ... ar[n-2]. If 
       arr[i-1] is smaller than arr[n-1], and max ending with arr[n-1] needs
       to be updated, then update it */
    for(int i = 1; i < n; i++)
    {
        res = _lis(arr, i, max_ref);
        if (arr[i-1] < arr[n-1] && res + 1 > max_ending_here)
            max_ending_here = res + 1;
    }

    // Compare max_ending_here with the overall max. And update the
    // overall max if needed
    if (*max_ref < max_ending_here)
       *max_ref = max_ending_here;

    // Return length of LIS ending with arr[n-1]
    return max_ending_here;
}

// The wrapper function for _lis()
int lis(int arr[], int n)
{
    // The max variable holds the result
    int max = 1;

    // The function _lis() stores its result in max
    _lis( arr, n, &max );

    // returns max
    return max;
}

/* 测试上面的函数 */
int main()
{
    int arr[] = { 10, 22, 9, 33, 21, 50, 41, 60 };
    int n = sizeof(arr)/sizeof(arr[0]);
    printf("Length of LIS is %d\n",  lis( arr, n ));
    getchar();
    return 0;
}


根据上面的实现方式,以下是递归树大小4的调用。 LIS(N)为我们返回arr[]数组的LIS长度。

    
                     lis(4)           
                 /       |      \
         lis(3)      lis(2)    lis(1)  
        /     \        /         
  lis(2)  lis(1)   lis(1) 
  /    
lis(1)
我们可以看到,有些重复的子问题被多次计算。 所以我们可以使用memoization (记忆化存储)的或打表 来避免同一子问题的重新计算。 以下是打表方式实现的LIS。

/* LIS 的动态规划方式实现*/
#include<stdio.h>
#include<stdlib.h>
/* lis() returns the length of the longest increasing subsequence in 
    arr[] of size n */
int lis( int arr[], int n )
{
   int *lis, i, j, max = 0;
   lis = (int*) malloc ( sizeof( int ) * n );

   /* Initialize LIS values for all indexes */
   for ( i = 0; i < n; i++ )
      lis[i] = 1;

   /* Compute optimized LIS values in bottom up manner */
   for ( i = 1; i < n; i++ )
      for ( j = 0; j < i; j++ )
         if ( arr[i] > arr[j] && lis[i] < lis[j] + 1)
            lis[i] = lis[j] + 1;

   /* Pick maximum of all LIS values */
   for ( i = 0; i < n; i++ )
      if ( max < lis[i] )
         max = lis[i];

   /* Free memory to avoid memory leak */
   free( lis );

   return max;
}

/* 测试程序 */
int main()
{
  int arr[] = { 10, 22, 9, 33, 21, 50, 41, 60 };
  int n = sizeof(arr)/sizeof(arr[0]);
  printf("Length of LIS is %d\n", lis( arr, n ) );

  getchar();
  return 0;
}

注意,上面动态的DP解决方案的时间复杂度为O(n ^ 2),其实较好的解决方案是 O(nlogn),这篇文章的目的是解释DP一个简单的例子,不做介绍了。

下面更好的解决方案来了,O(nlogn)的,维护一个数组,数组是递增的,遍历数组,遇到nums[i],把nums[i]放到数组合适的位置,本质是贪心,不翻译了,直接copy:

Given an array of random numbers. Find longest increasing subsequence (LIS) in the array. I know many of you might have read recursive and dynamic programming (DP) solutions. There are few requests forO(N log N) algo in the forum posts.

For the time being, forget about recursive and DP solutions. Let us take small samples and extend the solution to large instances. Even though it may look complex at first time, once if we understood the logic, coding is simple.

Consider an input array A = {2, 5, 3}. I will extend the array during explanation.

By observation we know that the LIS is either {2, 3} or {2, 5}. Note that I am considering only strictly increasing sequences.

Let us add two more elements, say 7, 11 to the array. These elements will extend the existing sequences. Now the increasing sequences are {2, 3, 7, 11} and {2, 5, 7, 11} for the input array {2, 5, 3, 7, 11}.

Further, we add one more element, say 8 to the array i.e. input array becomes {2, 5, 3, 7, 11, 8}. Note that the latest element 8 is greater than smallest element of any active sequence (will discuss shortly about active sequences). How can we extend the existing sequences with 8? First of all, can 8 be part of LIS? If yes, how? If we want to add 8, it should come after 7 (by replacing 11).

Since the approach is offline (what we mean by offline?), we are not sure whether adding 8 will extend the series or not. Assume there is 9 in the input array, say {2, 5, 3, 7, 11, 8, 7, 9 …}. We can replace 11 with 8, as there is potentially best candidate (9) that can extend the new series {2, 3, 7, 8} or {2, 5, 7, 8}.

Our observation is, assume that the end element of largest sequence is E. We can add (replace) current element A[i] to the existing sequence if there is an element A[j] (j > i) such that E < A[i] < A[j] or (E > A[i] < A[j] – for replace). In the above example, E = 11, A[i] = 8 and A[j] = 9.

In case of our original array {2, 5, 3}, note that we face same situation when we are adding 3 to increasing sequence {2, 5}. I just created two increasing sequences to make explanation simple. Instead of two sequences, 3 can replace 5 in the sequence {2, 5}.

I know it will be confusing, I will clear it shortly!

The question is, when will it be safe to add or replace an element in the existing sequence?

Let us consider another sample A = {2, 5, 3}. Say, the next element is 1. How can it extend the current sequences {2,3} or {2, 5}. Obviously, it can’t extend either. Yet, there is a potential that the new smallest element can be start of an LIS. To make it clear, consider the array is {2, 5, 3, 1, 2, 3, 4, 5, 6}. Making 1 as new sequence will create new sequence which is largest.

The observation is, when we encounter new smallest element in the array, it can be a potential candidate to start new sequence.

From the observations, we need to maintain lists of increasing sequences.

In general, we have set of active lists of varying length. We are adding an element A[i] to these lists. We scan the lists (for end elements) in decreasing order of their length. We will verify the end elements of all the lists to find a list whose end element is smaller than A[i] (floor value).

Our strategy determined by the following conditions,

1. If A[i] is smallest among all end candidates of active lists, we will start new active list of length 1.

2. If A[i] is largest among all end candidates of active lists, we will clone the largest active list, and extend it by A[i].

3. If A[i] is in between, we will find a list with largest end element that is smaller than A[i]. Clone and extend this list by A[i]. We will discard all other lists of same length as that of this modified list.

Note that at any instance during our construction of active lists, the following condition is maintained.

“end element of smaller list is smaller than end elements of larger lists”.

It will be clear with an example, let us take example from wiki {0, 8, 4, 12, 2, 10, 6, 14, 1, 9, 5, 13, 3, 11, 7, 15}.

A[0] = 0. Case 1. There are no active lists, create one.
0.
-----------------------------------------------------------------------------
A[1] = 8. Case 2. Clone and extend.
0.
0, 8.
-----------------------------------------------------------------------------
A[2] = 4. Case 3. Clone, extend and discard.
0.
0, 4.
0, 8. Discarded
-----------------------------------------------------------------------------
A[3] = 12. Case 2. Clone and extend.
0.
0, 4.
0, 4, 12.
-----------------------------------------------------------------------------
A[4] = 2. Case 3. Clone, extend and discard.
0.
0, 2.
0, 4. Discarded.
0, 4, 12.
-----------------------------------------------------------------------------
A[5] = 10. Case 3. Clone, extend and discard.
0.
0, 2.
0, 2, 10.
0, 4, 12. Discarded.
-----------------------------------------------------------------------------
A[6] = 6. Case 3. Clone, extend and discard.
0.
0, 2.
0, 2, 6.
0, 2, 10. Discarded.
-----------------------------------------------------------------------------
A[7] = 14. Case 2. Clone and extend.
0.
0, 2.
0, 2, 6.
0, 2, 6, 14.
-----------------------------------------------------------------------------
A[8] = 1. Case 3. Clone, extend and discard.
0.
0, 1.
0, 2. Discarded.
0, 2, 6.
0, 2, 6, 14.
-----------------------------------------------------------------------------
A[9] = 9. Case 3. Clone, extend and discard.
0.
0, 1.
0, 2, 6.
0, 2, 6, 9.
0, 2, 6, 14. Discarded.
-----------------------------------------------------------------------------
A[10] = 5. Case 3. Clone, extend and discard.
0.
0, 1.
0, 1, 5.
0, 2, 6. Discarded.
0, 2, 6, 9.
-----------------------------------------------------------------------------
A[11] = 13. Case 2. Clone and extend.
0.
0, 1.
0, 1, 5.
0, 2, 6, 9.
0, 2, 6, 9, 13.
-----------------------------------------------------------------------------
A[12] = 3. Case 3. Clone, extend and discard.
0.
0, 1.
0, 1, 3.
0, 1, 5. Discarded.
0, 2, 6, 9.
0, 2, 6, 9, 13.
-----------------------------------------------------------------------------
A[13] = 11. Case 3. Clone, extend and discard.
0.
0, 1.
0, 1, 3.
0, 2, 6, 9.
0, 2, 6, 9, 11.
0, 2, 6, 9, 13. Discarded.
-----------------------------------------------------------------------------
A[14] = 7. Case 3. Clone, extend and discard.
0.
0, 1.
0, 1, 3.
0, 1, 3, 7.
0, 2, 6, 9. Discarded.
0, 2, 6, 9, 11.
----------------------------------------------------------------------------
A[15] = 15. Case 2. Clone and extend.
0.
0, 1.
0, 1, 3.
0, 1, 3, 7.
0, 2, 6, 9, 11.
0, 2, 6, 9, 11, 15. <-- LIS List
----------------------------------------------------------------------------

It is required to understand above strategy to devise an algorithm. Also, ensure we have maintained the condition, “end element of smaller list is smaller than end elements of larger lists“. Try with few other examples, before reading further. It is important to understand what happening to end elements.

Algorithm:

Querying length of longest is fairly easy. Note that we are dealing with end elements only. We need not to maintain all the lists. We can store the end elements in an array. Discarding operation can be simulated with replacement, and extending a list is analogous to adding more elements to array.

We will use an auxiliary array to keep end elements. The maximum length of this array is that of input. In the worst case the array divided into N lists of size one (note that it does’t lead to worst case complexity). To discard an element, we will trace ceil value of A[i] in auxiliary array (again observe the end elements in your rough work), and replace ceil value with A[i]. We extend a list by adding element to auxiliary array. We also maintain a counter to keep track of auxiliary array length.

Bonus: You have learnt Patience Sorting technique partially :).

Here is a proverb, “Tell me and I will forget. Show me and I will remember. Involve me and I will understand.” So, pick a suit from deck of cards. Find the longest increasing sub-sequence of cards from the shuffled suit. You will never forget the approach. :)

Given below is code to find length of LIS,

// Java program to find length of longest increasing subsequence
// in O(n Log n) time
import java.io.*;
import java.util.*;
import java.lang.Math;
 
class LIS
{
    // Binary search (note boundaries in the caller)
    // A[] is ceilIndex in the caller
    static int CeilIndex(int A[], int l, int r, int key)
    {
        while (r - l > 1)
        {
            int m = l + (r - l)/2;
            if (A[m]>=key)
                r = m;
            else
                l = m;
        }
 
        return r;
    }
 
    static int LongestIncreasingSubsequenceLength(int A[], int size)
    {
        // Add boundary case, when array size is one
 
        int[] tailTable   = new int[size];
        int len; // always points empty slot
 
        tailTable[0] = A[0];
        len = 1;
        for (int i = 1; i < size; i++)
        {
            if (A[i] < tailTable[0])
                // new smallest value
                tailTable[0] = A[i];
 
            else if (A[i] > tailTable[len-1])
                // A[i] wants to extend largest subsequence
                tailTable[len++] = A[i];
 
            else
                // A[i] wants to be current end candidate of an existing
                // subsequence. It will replace ceil value in tailTable
                tailTable[CeilIndex(tailTable, -1, len-1, A[i])] = A[i];
        }
 
        return len;
    }
 
    // Driver program to test above function
    public static void main(String[] args)
    {
        int A[] = { 2, 5, 3, 7, 11, 8, 10, 13, 6 };
        int n = A.length;
        System.out.println("Length of Longest Increasing Subsequence is "+
                            LongestIncreasingSubsequenceLength(A, n));
    }
}
/* This code is contributed by Devesh Agrawal*/



最长公共子序列

继续用典型问题来讨论动态规划的两个特性(重叠子问题和最优子结构)。最长公共子序列(LCS)问题描述:

给定两个序列,找出在两个序列中同时出现的最长子序列的长度。一个子序列是出现在相对顺序的序列,但不一定是连续的。例如,“ABC”,“ABG”,“BDF”,“AEG”,“acefg“,..等都是”ABCDEFG“ 序列。因此,长度为n的字符串有2 ^ n个不同的可能的序列。

注意最长公共子串(Longest CommonSubstring)和最长公共子序列(LongestCommon Subsequence, LCS)的区别:子串(Substring)是串的一个连续的部分,子序列(Subsequence)则是从不改变序列的顺序,而从序列中去掉任意的元素而获得的新序列;更简略地说,前者(子串)的字符的位置必须连续,后者(子序列LCS)则不必。比如字符串acdfg同akdfc的最长公共子串为df,而他们的最长公共子序列是adf。LCS可以使用动态规划法解决。

这是一个典型的计算机科学问题,基础差异(即输出两个文件之间的差异文件比较程序),并在生物信息学有较多应用。

例子:
输入序列“ABCDGH”和“AEDFHR” 的LCS是“ADH”长度为3。
输入序列“AGGTAB”和“GXTXAYB”的LCS是“GTAB”长度为4。

这个问题的直观的解决方案是同时生成给定序列的所有子序列,找到最长匹配的子序列。此解决方案的复杂性是指数的。让我们来看看如何这个问题 (拥有动态规划(DP)问题的两个重要特性):

1)最优子结构:
设输入序列是X [0 .. m-1]和Y [0 .. n-1],长度分别为m和n。和设序列 L(X [0 .. m-1],Y[0 .. n-1])是这两个序列的LCS的长度。

以下为L(X [0 .. M-1],Y [0 .. N-1])的递归定义:

如果两个序列的最后一个元素匹配(即X [M-1] == Y [N-1])
L(X [0 .. M-1],Y [0 .. N-1])= 1 + L(X [0 .. M-2],Y [0 .. N-1])

如果两个序列的最后字符不匹配(即X [M-1]!= Y [N-1])
L(X [0 .. M-1],Y [0 .. N-1])= MAX(L(X [0 .. M-2],Y [0 .. N-1]),L(X [0 .. M-1],Y [0 .. N-2])

例子:

1)考虑输入字符串“AGGTAB”和“GXTXAYB”。最后一个字符匹配的字符串。这样的LCS的长度可以写成:
L(“AGGTAB”, “GXTXAYB”) = 1 + L(“AGGTA”, “GXTXAY”)

2)考虑输入字符串“ABCDGH”和“AEDFHR。最后字符不为字符串相匹配。这样的LCS的长度可以写成:
L(“ABCDGH”, “AEDFHR”) = MAX ( L(“ABCDG”, “AEDFHR”), L(“ABCDGH”, “AEDFH”) )

因此,LCS问题有最优子结构性质!

2)重叠子问题:
以下是直接的递归实现,  遵循上面提到的递归结构。(有兴趣的读者可以按照前面讲的记忆化存储来实现)

/* 简单的递归实现LCS问题 */
#include<stdio.h>
#include<stdlib.h>

int max(int a, int b);

/* Returns length of LCS for X[0..m-1], Y[0..n-1] */
int lcs( char *X, char *Y, int m, int n )
{
   if (m == 0 || n == 0)
     return 0;
   if (X[m-1] == Y[n-1])
     return 1 + lcs(X, Y, m-1, n-1);
   else
     return max(lcs(X, Y, m, n-1), lcs(X, Y, m-1, n));
}

/* Utility function to get max of 2 integers */
int max(int a, int b)
{
    return (a > b)? a : b;
}

/* 测试上面的函数 */
int main()
{
  char X[] = "AGGTAB";
  char Y[] = "GXTXAYB";

  int m = strlen(X);
  int n = strlen(Y);

  printf("Length of LCS is %d\n", lcs( X, Y, m, n ) );

  getchar();
  return 0;
}
上面直接的递归方法的时间复杂度为O(2 ^ n).(在最坏的情况下。X和Y不匹配的所有字符即LCS的长度为0)。
按照到上述的实现,下面是对输入字符串“AXYT”和“AYZX”的部分递归树:

                         lcs("AXYT", "AYZX")
                       /                 \
         lcs("AXY", "AYZX")            lcs("AXYT", "AYZ")
         /            \                  /               \
lcs("AX", "AYZX") lcs("AXY", "AYZ")   lcs("AXY", "AYZ") lcs("AXYT", "AY")
在上述部分递归树,LCS(“AXY”,“AYZ”)被调用两次。 如果我们绘制完整的递归树,那么我们可以看到,我们可以看到很多重复的调用。 所以这个问题有重叠的子结构性质,可使用memoization的或打表来避免重新计算。 下面是用动态规划(打表)解决LCS问题:

/ *动态规划实现的LCS问题* /
#include<stdio.h>
#include<stdlib.h>

int max(int a, int b);

/* Returns length of LCS for X[0..m-1], Y[0..n-1] */
int lcs( char *X, char *Y, int m, int n )
{
   int L[m+1][n+1];
   int i, j;

   /* Following steps build L[m+1][n+1] in bottom up fashion. Note 
      that L[i][j] contains length of LCS of X[0..i-1] and Y[0..j-1] */
   for (i=0; i<=m; i++)
   {
     for (j=0; j<=n; j++)
     {
       if (i == 0 || j == 0)
         L[i][j] = 0;

       else if (X[i-1] == Y[j-1])
         L[i][j] = L[i-1][j-1] + 1;

       else
         L[i][j] = max(L[i-1][j], L[i][j-1]);
     }
   }

   /* L[m][n] contains length of LCS for X[0..n-1] and Y[0..m-1] */
   return L[m][n];
}

/* Utility function to get max of 2 integers */
int max(int a, int b)
{
    return (a > b)? a : b;
}

/*测试上面的函数 */
int main()
{
  char X[] = "AGGTAB";
  char Y[] = "GXTXAYB";

  int m = strlen(X);
  int n = strlen(Y);

  printf("Length of LCS is %d\n", lcs( X, Y, m, n ) );

  getchar();
  return 0;
}


最小编辑距离(Edit Distance)

继续动态规划系列案例讲解–编辑距离,一个很有趣的算法。

问题:给定一个长度为m和n的两个字符串,设有以下几种操作:替换(R),插入(I)和删除(D)且都是相同的操作。寻找到转换一个字符串插入到另一个需要修改的最小(操作)数量。

PS:最短编辑距离算法右许多实际应用,参考Lucene的 API。另一个例子,对一个字典应用,显示最接近给定单词\正确拼写单词的所有单词。

找递归函数:

这个案例的子问题是什么呢?考虑寻找的他们的前缀子串的编辑距离,让我们表示他们为

[1 ... i]和[1 ....j] , 1<i<m 和1 <j <n

显然,这是解决最终问题的子问题,记为E(i,j)。我们的目标是找到E(m,n)和最小的编辑距离。

我们可以用三种方式: (i, -), (-, j) 和(i, j)右对齐两个前缀字符串。连字符符号( – )表示没有字符。看一个例子或许会更清楚:

假设给定的字符串是 SUNDAY 和 SATURDAY。如果 i= 2 ,  j = 4,即前缀字符串分别是SU和SATU(假定字符串索引从1开始)。这两个字串最右边的字符可以用三种不同的方式对齐:

1  (i, j): 对齐字符U和U。他们是相等的,没有修改的必要。我们仍然留下其中i = 1和j = 3,即问题E(1,3)

2 (i, -) : 对第一个字符串右对齐,第二字符串最右为空字符。我们需要一个删除(D)操作。我们还留下其中i = 1和j = 4的 子问题 E(i-1,j)。

3 (-, j)  :  对第二个字符串右对齐,第一个字符串最右为空字符。在这里,我们需要一个插入(I)操作。我们还留下了子问题 i= 2 和 j = 3,E(i,j-1)。

对于这三种操作,我可以得到最少的操作为:

E(i, j) = min( [E(i-1, j) + D], [E(i, j-1) + I],  [E(i-1, j-1) + R (如果 i,j 字符不一样)] )

到这里还没有做完。什么将是基本情况?

当两个字符串的大小为0,其操作距离为0。当其中一个字符串的长度是零,需要的操作距离就是另一个字符串的长度. 即:

E(0,0)= 0,E(i,0)= i,E(0,j)= j

为基本情况。这样就可以完成递归程序了。

动态规划解法:

我们先计算出上面递归表达式的时间复杂度:T(m, n) = T(m-1, n-1) + T(m, n-1) + T(m-1, n) + C

T(M,N)的复杂性,可以通过连续替代方法或结二元齐次方程计算。结果是指数级的复杂度。

这是显而易见的,从递归树可以看出这将是一次又一次地解决子问题。

我们对重复子问题的结果打表存储,并在有需要时(自下而上)查找。

动态规划的解法时间复杂度为 O(mn) 正是我们打表的时间.

通常情况下,D,I和R操作的成本是不一样的。在这种情况下,该问题可以表示为一个有向无环图(DAG)与各边的权重,并且找到最短路径给出编辑距离。

实现代码如下:

public class Solution {
    public int minDistance(String word1, String word2) {
        int n = word1.length();
        int m = word2.length();
        int [][] dp = new int [n+1][m+1];
        for(int i = 0;i <= n;i++){
            dp[i][0] = i;
        }
        for(int j = 0;j <= m;j++){
            dp[0][j] = j;
        }
        for(int i = 0;i < n;i++){
            for(int j = 0;j < m;j++){
                if(word1.charAt(i)==word2.charAt(j)){
                    dp[i+1][j+1] = dp[i][j];
                }
                else{
                   dp[i+1][j+1] = Math.min(Math.min(dp[i][j+1],dp[i+1][j]),dp[i][j])+1; 
                }
            }
        }
        return dp[n][m];
    }
}


最小花费路径

给定一个矩阵cost[][]和其中的一个位置(m,n),写一个函数,返回从到达(0,0)到(M,N)最小成本路径的花费。该矩阵的每个格子代表遍历该格子的花费。到达(M,N)的路径的总成本是该路径上(包括源和目标)所有的费用总和。你只能从开始位置 向右、下和右下走,也就是说,从一个给定的格子(I,J),只有(i+1,j)的(i,j +1)和(i +1, j +1)的可以通过。你可以假设所有的花费都是正整数。

1)最优子结构
的路径到达(M,N)必须通过3格子中的一个:(M-1,N-1)或(m-1,n)或(M,N-1)。到达(M,N),所以最小的花费路径可以写成“3个格子最小的 加[M] [N]的花费”。

minCost(m, n) = min (minCost(m-1, n-1), minCost(m-1, n), minCost(m, n-1)) + cost[m][n]

2)重叠子问题
以下是直接的递归实现的最小花费路径的问题,用的上面的递归函数。

/ *动态规划实现的MCP问题* /
#include<stdio.h>
#include<limits.h>
#define R 3
#define C 3

int min(int x, int y, int z);

int minCost(int cost[R][C], int m, int n)
{
     int i, j;
     int tc[R][C];  

     tc[0][0] = cost[0][0];

     /* 初始化第一列 cost(tc) array */
     for (i = 1; i <= m; i++)
        tc[i][0] = tc[i-1][0] + cost[i][0];

     /* 初始化第一行 */
     for (j = 1; j <= n; j++)
        tc[0][j] = tc[0][j-1] + cost[0][j];

     /* Construct rest of the tc array */
     for (i = 1; i <= m; i++)
        for (j = 1; j <= n; j++)
            tc[i][j] = min(tc[i-1][j-1], tc[i-1][j], tc[i][j-1]) + cost[i][j];

     return tc[m][n];
}

/* 返回3个整数中最小的 */
int min(int x, int y, int z)
{
   if (x < y)
      return (x < z)? x : z;
   else
      return (y < z)? y : z;
}

/* 测试 */
int main()
{
   int cost[R][C] = { {1, 2, 3},
                      {4, 8, 2},
                      {1, 5, 3} };
   printf(" %d ", minCost(cost, 2, 2));
   return 0;
}


硬币找零

问题:

假设有m种面值不同的硬币,个个面值存于数组S ={S1,S2,… Sm}中,现在用这些硬币来找钱,各种硬币的使用个数不限。 求对于给定的钱数N,我们最多有几种不同的找钱方式。硬币的顺序并不重要。

例如,对于N = 4,S = {1,2,3},有四种方案:{1,1,1,1},{1,1,2},{2,2},{1, 3}。所以输出应该是4。对于N = 10,S = {2,5, 3,6},有五种解决办法:{2,2,2,2,2},{2,2,3,3},{2,2,6 },{2,3,5}和{5,5}。所以输出应该是5。

1)最优子结构
要算总数的解决方案,我们可以把所有的一整套解决方案在两组 (其实这个方法在组合数学中经常用到,要么包含某个元素要么不包含,用于递推公式等等,)。
1)解决方案不包含 第m种硬币(或Sm)。
2)解决方案包含至少一个 第m种硬币。
让数(S [] , M, N)是该函数来计算解的数目,则它可以表示为计数的总和(S [], M-1, N)和计数(S [],M,N-Sm)。

因此,这个问题具有最优子结构性质的问题。

2) 重叠子问题

下面是一个简单的递归实现硬币找零问题。遵循上面提到的递归结构。

#include<stdio.h>
int count( int S[], int m, int n )
{
    // 如果n为0,就找到了一个方案
    if (n == 0)
        return 1;
    if (n < 0)
        return 0;
    // 没有硬币可用了,也返回0
    if (m <=0 )
        return 0;
    // 按照上面的递归函数
    return count( S, m - 1, n ) + count( S, m, n-S[m-1] );
}

// 测试
int main()
{
    int i, j;
    int arr[] = {1, 2, 3};
    int m = sizeof(arr)/sizeof(arr[0]);
    printf("%d ", count(arr, m, 4));
    getchar();
    return 0;
}


应当指出的是,上述函数反复计算相同的子问题。见下面的递归树为S = {1,2,3},且n = 5。
的函数C({1},3)被调用两次。如果我们绘制完整的树,那么我们可以看到,有许多子问题被多次调用。

C() --> count()
                              C({1,2,3}, 5)                     
                           /                \
                         /                   \              
             C({1,2,3}, 2)                 C({1,2}, 5)
            /     \                        /         \
           /        \                     /           \
C({1,2,3}, -1)  C({1,2}, 2)        C({1,2}, 3)    C({1}, 5)
               /     \            /    \            /     \
             /        \          /      \          /       \
    C({1,2},0)  C({1},2)   C({1,2},1) C({1},3)    C({1}, 4)  C({}, 5)
                   / \      / \       / \        /     \    
                  /   \    /   \     /   \      /       \ 
                .      .  .     .   .     .   C({1}, 3) C({}, 4)
                                               /  \
                                              /    \  
                                             .      .

所以,硬币找零问题具有符合动态规划的两个重要属性。像其他典型的动态规划(DP)的问题,可通过自下而上的方式打表,存储相同的子问题。当然上面的递归程序也可以改写成记忆化存储的方式来提高效率。

下面是动态规划的程序:

值得注意的是:dp[i][j]表示用0~j硬币找i元钱,分为不包含j和至少包含一个j的情况,

1:当不包含j的时候,就是dp[i][j] += dp[i][j-1]

2 :当包含j的时候,一开始想dp[i][j] += dp[i-s[j]][j-1],这样的话肯定至少包含一个j,对吧,但是其实dp[i-s[j]][j-1]已经被算进了dp[i-s[j]][j]里了,而dp[i-s[j]][j]表示了用0~j,不管有莫有j,凑了i-s[j]那么多钱,那么加上s[j]凑成i,则肯定至少包含一个j.可以多个,而如果是+= dp[i-s[j]][j-1]则就只包含一个j了。

另外,对于i等于0时,只有一种找法,就是什么都不给,无论j是多少都好。

#include<stdio.h>

int count( int S[], int m, int n )
{
    int i, j, x, y;

    // 通过自下而上的方式打表我们需要n+1行
    // 最基本的情况是n=0
    int table[n+1][m];

    // 初始化n=0的情况 (参考上面的递归程序)
    for (i=0; i<m; i++)
        table[0][i] = 1;

    for (i = 1; i < n+1; i++)
    {
        for (j = 0; j < m; j++)
        {
            // 包括 S[j] 的方案数
            x = (i-S[j] >= 0)? table[i - S[j]][j]: 0;

            // 不包括 S[j] 的方案数
            y = (j >= 1)? table[i][j-1]: 0;

            table[i][j] = x + y;
        }
    }
    return table[n][m-1];
}

// 测试
int main()
{
    int arr[] = {1, 2, 3};
    int m = sizeof(arr)/sizeof(arr[0]);
    int n = 4;
    printf(" %d ", count(arr, m, n));
    return 0;
}

时间复杂度:O(mn)

以下为上面程序的优化版本。这里所需要的辅助空间为O(n)。因为我们在打表时,本行只和上一行有关,类似01背包问题。


int count( int S[], int m, int n )
{
    int table[n+1];
    memset(table, 0, sizeof(table));
    //初始化基本情况
    table[0] = 1;

    for(int i=0; i<m; i++)
        for(int j=S[i]; j<=n; j++)
            table[j] += table[j-S[i]];

    return table[n];
}

矩阵连乘

给定n个矩阵{A1,A2,…,An},其中AiAi+1是可乘的,i=1,2,…,n-1。考察这n个矩阵的连乘积A1A2…An。由于矩阵乘法满足结合律,故计算矩阵的连乘积可以有许多不同的计算次序,这种计算次序可以用加括号的方式来确定。若一个矩阵连乘积的计算次序完全确定,则可以依此次序反复调用2个矩阵相乘的标准算法(有改进的方法,这里不考虑)计算出矩阵连乘积。若A是一个p×q矩阵,B是一个q×r矩阵,则计算其乘积C=AB的标准算法中,需要进行pqr次数乘。

例如,如果我们有四个矩阵A,B,C和D,我们将有:

1 (ABC)D =(AB)(CD)= A(BCD)= ....

不同组合得到的运算次数是不同的,例如A为  10 × 30 , B为 30 × 5 , C 为 5 × 60 那么

1 (AB)C = (10×30×5) + (10×5×60) = 1500 + 3000 = 4500 次运算
2 A(BC) = (30×5×60) + (10×30×60) = 9000 + 18000 = 27000 次运算

很明显第一种运算更为高效。

问题:给定一个数组P[]表示矩阵的链,使得第i个矩阵Ai  的维数为 p[i-1] x p[i].。我们需要写一个函数MatrixChainOrder()返回这个矩阵连相乘最小的运算次数。

示例:

01 输入:P [] = {40,20,30,10,30}  
02 输出:26000 
03 有4个矩阵维数为 40X20,20X30,30×10和10X30。
04 运算次数最少的计算方式为:
05 (A(BC))D  - > 20 * 30 * 10 +40 * 20 * 10 +40 * 10 * 30
06  
07 输入:P[] = {10,20,30,40,30}
08 输出:30000
09 有4个矩阵维数为 10×20,20X30,30X40和40X30。
10 运算次数最少的计算方式为:
11   ((AB)C)D  - > 10 * 20 * 30 +10 * 30 * 40 +10 * 40 * 30

1)最优子结构:

一个简单的解决办法是把括号放在所有可能的地方,计算每个位置的成本,并返回最小值。对于一个长度为n的链,我们有n-1种方法放置第一组括号。

例如,如果给定的链是4个矩阵。让矩阵连为ABCD,则有3种方式放第一组括号:A(BCD),(AB)CD和(ABC)D。

所以,当我们把一组括号,我们把问题分解成更小的尺寸的子问题。因此,这个问题具有最优子结构性质,可以使用递归容易解决。

2)重叠子问题
以下是递归的实现,只需用到上面的最优子结构性质。

//直接的递归解决
#include<stdio.h>
#include<limits.h>
//矩阵 Ai 的维数为 p[i-1] x p[i] ( i = 1..n )
int MatrixChainOrder(int p[], int i, int j)
{
    if(i == j)
        return 0;
    int k;
    int min = INT_MAX;
    int count;

    // 在第一个和最后一个矩阵直接放置括号
    //递归计算每个括号,并返回最小的值
    for (k = i; k <j; k++)
    {
        count = MatrixChainOrder(p, i, k) +
                MatrixChainOrder(p, k+1, j) +
                p[i-1]*p[k]*p[j];

        if (count < min)
            min = count;
    }

    return min;
}

// 测试
int main()
{
    int arr[] = {1, 2, 3, 4, 3};
    int n = sizeof(arr)/sizeof(arr[0]);
    printf("Minimum number of multiplications is %d ", 
                          MatrixChainOrder(arr, 1, n-1));

    getchar();
    return 0;
}

上面直接的递归方法的复杂性是指数级。当然可以用记忆化存储优化。应当指出的是,上述函数反复计算相同的子问题。请参阅下面的递归树的大小4的矩阵链。函数MatrixChainOrder(3,4)被调用两次。我们可以看到,有许多子问题被多次调用。

MatrixChain



动态规划解决方案
以下是C / C + +实现,使用动态规划矩阵链乘法问题。

#include<stdio.h>
#include<limits.h>

int MatrixChainOrder(int p[], int n)
{

    /* 第0行第0列其实没用到 */
    int m[n][n];

    int i, j, k, L, q;

    //单个矩阵相乘,所需数乘次数为0
    for (i = 1; i < n; i++)
        m[i][i] = 0;

     //以下两个循环是关键之一,以6个矩阵为例(为描述方便,m[i][j]用ij代替)
     //需按照如下次序计算
     //01 12 23 34 45
     //02 13 24 35
     //03 14 25
     //04 15
     //05
     //下面行的计算结果将会直接用到上面的结果。例如要计算14,就会用到12,24;或者13,34等等
    for (L=2; L<n; L++)   
    {
        for (i=1; i<=n-L+1; i++)
        {
            j = i+L-1;
            m[i][j] = INT_MAX;
            for (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;
            }
        }
    }

    return m[1][n-1];
}

int main()
{
    int arr[] = {1, 2, 3, 4};
    int size = sizeof(arr)/sizeof(arr[0]);

    printf("Minimum number of multiplications is %d ",
                       MatrixChainOrder(arr, size));

    getchar();
    return 0;
}


二项式系数

以下是常见的二项式系数的定义:

1) 一个二项式系数 C(n, k)  可以被定义为(1 + X)^n 的展开式中  X^k  的系数。

2) 二项式系数对组合数学很重要,因它的意义是从n件物件中,不分先后地选取k件的方法总数,因此也叫做组合数.

问题

写一个函数,它接受两个参数n和k,返回二项式系数C(n,k)。例如,你的函数应该返回6 当n = 4 k = 2时,返回10当 n = 5 k = 2时。

1) 最优子结构

C(n,k)的值可以递归地使用以下标准公式计算,这个应该是高二的数学:

1 C(n, k) = C(n-1, k-1) + C(n-1, k)
2 C(n, 0) = C(n, n) = 1
这个式子的解释:
把n个元素分成两组
第一组n-1个,第二组1个
从中取出k个元素【方法有 C(n,k)种】 ,取法有两种
(1)从第一组中取出k个
方法有C(n-1,k)种
(2) 从第一组中取出k-1个,从第二组中取出1个
方法有C(n-1,k-1)种
所以,
C(n,k) =C(n-1,k)+C(n-1,k-1)

2) 重叠子问题

下面是一个直接用上面的公式写的递归程序解决:

// 直接递归实现
#include<stdio.h>

// 返回二项式系数的值 C(n, k)
int binomialCoeff(int n, int k)
{
  // 基本情况
  if (k==0 || k==n)
    return 1;

  // Recur
  return  binomialCoeff(n-1, k-1) + binomialCoeff(n-1, k);
}

/* 测试程序 */
int main()
{
    int n = 5, k = 2;
    printf("Value of C(%d, %d) is %d ", n, k, binomialCoeff(n, k));
    return 0;
}

很明显,这个问题可以用动态规划来解决,因为包含了动态规划的两个基本属性(见重叠子问题最优子结构

和经典的动态规划解决办法一样,这里通过自下而上的构建数组 C[][] 保存子问题的值。


#include<stdio.h>
int min(int a, int b);
// 返回二项式系数 C(n, k)
int binomialCoeff(int n, int k)
{
    int C[n+1][k+1];
    int i, j;

    // 通过自下而上的方式打表
    for (i = 0; i <= n; i++)
    {
        for (j = 0; j <= min(i, k); j++)
        {
            if (j == 0 || j == i)
                C[i][j] = 1;
             else
                C[i][j] = C[i-1][j-1] + C[i-1][j];
        }
    }
    return C[n][k];
}
int min(int a, int b)
{
    return (a<b)? a: b;
}

/* 测试程序*/
int main()
{
    int n = 5, k = 2;
    printf ("Value of C(%d, %d) is %d ", n, k, binomialCoeff(n, k) );
    return 0;
}

时间复杂度: O(n*k)

空间复杂度: O(n*k)

其实,空间复杂度可以优化到 O(k).  但是实际应用中,还是直接用二维数组打表使用比较多。

// 空间优化
int binomialCoeff(int n, int k)
{
    int* C = (int*)calloc(k+1, sizeof(int));
    int i, j, res;

    C[0] = 1;

    for(i = 1; i <= n; i++)
    {
        for(j = min(i, k); j > 0; j--)
            C[j] = C[j] + C[j-1];
    }
    res = C[k];  // 在释放内存前存储结果
    free(C); 
    return res;
}

01背包问题

在M件物品取出若干件放在空间为W的背包里,每件物品的体积为W1,W2……Wn,与之相对应的价值为P1,P2……Pn。求出获得最大价值的方案。

注意:在本题中,所有的体积值均为整数。01的意思是,每个物品都是一个整体,要么整个都要,要么都不要。

1)最优子结构

考虑所有物品的子集合,考虑第n个物品都有两种情况: 1. 包括在最优方案中  2. 不在最优方案中

因此,能获得的最大价值,即为以下两个值中较大的那个

1) 在剩下 n-1 个物品中(剩余 W 重量可用)的情况能得到的最大价值 (即排除了 第n个物品)

2)  第n个物品的价值 加上 剩下   剩下的 n-1 个物品(剩余W- wn的重量)能得到的最大价值。(即包含了第n个物品)

如果第n个物品的重量,超过了当前的剩余重量W,那么只能选情况1), 排除第n个物品。

2) 重叠子问题

下面是一个递归的实现,按照上面的最优子结构。

/* 朴素的递归实现  0-1 背包 */
#include<stdio.h>

int max(int a, int b) { return (a > b)? a : b; }

// 返回  前n个物品在容量为W时,能得到的最大价值
int knapSack(int W, int wt[], int val[], int n)
{
   // 没有物品了
   if (n == 0 || W == 0)
       return 0;

   // 如果当前第n个物品超重了,就排除在外
   if (wt[n-1] > W)
       return knapSack(W, wt, val, n-1);

   //返回两种情况下最大的那个 (1) 包括第n个物品 (2) 不包括第n个物品
   else return max( val[n-1] + knapSack(W-wt[n-1], wt, val, n-1),
                    knapSack(W, wt, val, n-1)
                  );
}

// 测试
int main()
{
    int val[] = {60, 100, 120};
    int wt[] = {10, 20, 30};
    int  W = 50;
    int n = sizeof(val)/sizeof(val[0]);
    printf("%d", knapSack(W, wt, val, n));
    return 0;
}


这种方法其实就是搜索了所有的情况,但是有很多重复的计算。时间复杂度是指数级的 O(2^n)。

可见相同的子问题被计算多次。01背包满足动态规划算法的两个基本属性(重叠子问题最优子结构)。可以通过自下而上的打表,存储中间结果,来避免重复计算。动态规划解法如下:

#include<stdio.h>
int max(int a, int b) { return (a > b)? a : b; }

int knapSack(int W, int wt[], int val[], int n)
{
   int i, w;
   int dp[n+1][W+1];

   for (i = 0; i <= n; i++)
   {
       for (w = 0; w <= W; w++)
       {
           if (i==0 || w==0)
               dp[i][w] = 0;
           else if (wt[i-1] <= w)
                 dp[i][w] = max(val[i-1] + dp[i-1][w-wt[i-1]],  dp[i-1][w]);
           else
                 dp[i][w] = dp[i-1][w];
       }
   }
   return dp[n][W];
}

int main()
{
    int val[] = {60, 100, 120};
    int wt[] = {10, 20, 30};
    int  W = 50;
    int n = sizeof(val)/sizeof(val[0]);
    printf("%d", knapSack(W, wt, val, n));
    return 0;
}


扔鸡蛋问题

据说这是一道google的面试题. 看似是一个智力题,实际是编程题。

两个软硬程度一样但未知的鸡蛋,它们有可能都在一楼就摔碎,也可能从一百层楼摔下来没事。现有座36层的建筑,要你用这两个鸡蛋确定哪一层是鸡蛋可以安全落下的最高位置,可以摔碎两个鸡蛋,要求用最少的测试次数。

1 如果你从某一层楼扔下鸡蛋,它没有碎,则这个鸡蛋你可以继续用
2 如果这个鸡蛋摔碎了,则你可以用来测试的鸡蛋减少一个
3 所有鸡蛋的质量相同(都会在同一楼层以上摔碎)
4 对于一个鸡蛋,如果其在楼层i扔下的时候摔碎了,对于任何不小于i的楼层,这个鸡蛋都会被摔碎
5 如果在楼层i扔下的时候没有摔碎,则对于任何不大于i的楼层,这颗鸡蛋也不会摔碎
6 从第1层扔下,鸡蛋不一定完好,从第36层扔下,鸡蛋也不一定会摔碎。

实际上,我们的终极目的是要找出连续的两层楼i,i+1。在楼层i鸡蛋没有摔碎,在楼层i+1鸡蛋碎了,问题的关键之处在于,测试之前,你并不知道鸡蛋会在哪一层摔碎,你需要找到的是一种测试方案,这种测试方案,无论鸡蛋会在哪层被摔碎,都至多只需要m次测试,在所有这些测试方案中,m的值最小。

为什么是两个鸡蛋呢?如果只有一个鸡蛋,我们只能从下往上一层一层的测试。对于2个鸡蛋,比较容易想到的就是使用二分的方法,现在18层测试,如果这颗碎了,则你从第1层,到第17层,依次用第2颗鸡蛋测试。否则继续用两个鸡蛋测试上半部分的楼层,最多需要18次测试,减少了一半。看似是个不错的方法,可惜正确答案是8次。

其实,对于任何连续的M层,这M层在下面或在下面,对于这M层来说需要的测试次数都没有影响。因此,可以把这个问题一般化,考虑n个鸡蛋 k层楼,记为E(n,k)。解决的办法是试着从每一层掉落一个鸡蛋(从1到k)并递归计算需要在最坏的情况下需要的最小测试次数。考虑用程序来穷举所有情况找到答案。

1) 最优子结构

当我们从一个楼层x扔下鸡蛋时,有可能出现两种情况(1)鸡蛋破(2)鸡蛋不破。

1)鸡蛋破,那么我们只需要用剩下的鸡蛋测试 x层以下的楼层; 所以问题简化为x-1层和n-1个鸡蛋
2)如果鸡蛋没有破,那么我们只需要检查比x较高的楼层; 所以问题简化为 k-x 和n个鸡蛋。

最优子结构可以表示为:

1 k ==> 楼层数
2 n ==> 鸡蛋数
3   eggDrop(n, k) ==>最少需要的测试次数(考虑所有情况)
4   eggDrop(n, k) = 1 + min{max(eggDrop(n - 1, x - 1), eggDrop(n, k - x)):
5                  x 属于 {1, 2, ..., k}}

下面用递归的方法解决这个问题:

# include <stdio.h>
# include <limits.h>

int max(int a, int b) { return (a > b)? a: b; }

int eggDrop(int n, int k)
{
    // 基本情况
    if (k == 1 || k == 0)
        return k;

    //如果只有一个鸡蛋,最坏的情况下需要k测试
    if (n == 1)
        return k;

    int min = INT_MAX, x, res;

    // 考虑从第1层到底k层扔下鸡蛋的所有情况 的最小结果
    for (x = 1; x <= k; x++)
    {
        res = max(eggDrop(n-1, x-1), eggDrop(n, k-x));
        if (res < min)
            min = res;
    }
    return min + 1;
}

/* 测试 */
int main()
{
    int n = 2, k = 10;
    printf ("\nMinimum number of trials in worst case with %d eggs and "
             "%d floors is %d \n", n, k, eggDrop(n, k));
    return 0;
}

动态规划:
# include <stdio.h>
# include <limits.h>
int max(int a, int b) { return (a > b)? a: b; }
int eggDrop(int n, int k)
{
    /* eggFloor[i][j] 表示对于 i个鸡蛋 j 层楼,需要的最少测试次数 */
    int eggFloor[n+1][k+1];
    int res;
    int i, j, x;
    // 初始化
    for (i = 1; i <= n; i++)
    {
        eggFloor[i][1] = 1;
        eggFloor[i][0] = 0;
    }

    //只有一个鸡蛋,没得优化,需要j次
    for (j = 1; j <= k; j++)
        eggFloor[1][j] = j;

    // 最优子结构的递推
    for (i = 2; i <= n; i++)
    {
        for (j = 2; j <= k; j++)
        {
            eggFloor[i][j] = INT_MAX;
            for (x = 1; x <= j; x++)
            {
                res = 1 + max(eggFloor[i-1][x-1], eggFloor[i][j-x]);
                if (res < eggFloor[i][j])
                    eggFloor[i][j] = res;
            }
        }
    }
    return eggFloor[n][k];
}

/* 测试*/
int main()
{
    int n = 2, k = 36;
    printf ("\nMinimum number of trials in worst case with %d eggs and "
             "%d floors is %d \n", n, k, eggDrop(n, k));
    return 0;
}

划分问题

划分问题是指,有一个集合,判断是否可以把这个结合划分为总和相等的两个集合。

例如:
arr[] = {1, 5, 11, 5}
Output: true
这个数组可以划分为: {1, 5, 5} 和 {11}

arr[] = {1, 5, 3}
Output: false
无法划分为总和相等的两部分

如果划分后的两个集合总和相等,则原集合的总和肯定为偶数,假设为总和为sum。问题即为是否有子集合的总和为sum/2.

递归解决

设函数 isSubsetSum(arr, n, sum/2) 返回true如果存在arr的一个子集合的总和为 sum/2
isSubsetSum函数为分为下面两个子问题
1) 不考虑最后一个元素。问题递归到 isSubsetSum(arr, n-1. sum/2)
2) 考虑最后一个元素。问题递归到 isSubsetSum(arr, n-1. sum/2-arr[n])
上面两种情况有一个返回TRUE即可
isSubsetSum (arr, n, sum/2) = isSubsetSum (arr, n-1, sum/2) ||
isSubsetSum (arr, n-1, sum/2 – arr[n-1])

#include <iostream>
#include <stdio.h>

bool isSubsetSum (int arr[], int n, int sum)
{
   // 基本情况
   if (sum == 0)
     return true;
   if (n == 0 && sum != 0)
     return false;

   // 如果最后一个元素比sum大,就不考虑该元素
   if (arr[n-1] > sum)
     return isSubsetSum (arr, n-1, sum);

  //分别判断包括最后一个元素 和 不包括最后一个元素
   return isSubsetSum (arr, n-1, sum) || isSubsetSum (arr, n-1, sum-arr[n-1]);
}

bool findPartiion (int arr[], int n)
{
    int sum = 0;
    for (int i = 0; i < n; i++)
       sum += arr[i];

    // 奇数不可能划分
    if (sum%2 != 0)
       return false;

    return isSubsetSum (arr, n, sum/2);
}

// 测试
int main()
{
  int arr[] = {3, 1, 5, 9, 12};
  int n = sizeof(arr)/sizeof(arr[0]);
  if (findPartiion(arr, n) == true)
     printf("Can be divided into two subsets of equal sum");
  else
     printf("Can not be divided into two subsets of equal sum");
  return 0;
}

时间复杂度:最快情况为 O(2^n),即每个元素有选或不选的两种选择

动态规划

如果所有元素的总和sum不是特别大时可以用动态规划来解决。问题可以转化为 是否有子集合的总和为sum/2.
这里通过自下向上打表的方法来记录子问题的解, part[i][j] 表示对于子集合 {arr[0], arr[1], ..arr[j-1]} 其总和是否为i.

其实这个问题和01背包问题是一样的。背包的最大容量为sum/2,如果最大价值可以达到sum/2则返回TRUE。

bool findPartiion (int arr[], int n)
{
    int sum = 0;
    int i, j;

    for (i = 0; i < n; i++)
      sum += arr[i];

    if (sum%2 != 0)  
       return false;

    bool part[sum/2+1][n+1];

    for (i = 0; i <= n; i++)
      part[0][i] = true;

    for (i = 1; i <= sum/2; i++)
      part[i][0] = false;     

     for (i = 1; i <= sum/2; i++)  
     {
       for (j = 1; j <= n; j++)  
       {
         part[i][j] = part[i][j-1];
         if (i >= arr[j-1])
           part[i][j] = part[i][j] || part[i - arr[j-1]][j-1];
       }        
     }    

    /** //测试打表数据
     for (i = 0; i <= sum/2; i++)  
     {
       for (j = 0; j <= n; j++)  
          printf ("%4d", part[i][j]);
       printf("\n");
     } */

     return part[sum/2][n];
}

数字转字母的编码方式的个数

问题

假设1到26之间的26个数据,可以编码为对应的’A'-’B',对于一个给定的数字字符串,可以有多少种编码方式?假定输入都是合法的(不会有”30″)。

举例:

1 输入:  digits[] = "121"
2 输出: 3
3 // "ABA", "AU", "LA"
4  
5 输入: digits[] = "1234"
6 输出: 3
7 // "ABCD", "LCD", "AWD"

 分析

很容易想到递归的方法,化解为小的子问题,从最后一个数字开始:1) 最后一个数字是非零的,递归剩下的 (n-1) 个数字。

2) 最后两个数字在小于27,则把最后两个数字结合,递归剩下的(n-2)个数字。

public class CountDecoding {
    static int countDecodingDp(String digits){
        if(digits.length() < 2) return 1;
        char[] chars = digits.toCharArray();
        int dp[] = new int[chars.length+1];
        dp[1] = 1;
        dp[0] = 1;
        for(int i=2; i<=chars.length; i++){
            //如果是0的话, 0肯定是和前面的一个数字组合成10,20
            if(chars[i-1] > '0')
                dp[i] = dp[i-1];
            //如果当前的数字可以和前面的数字组合成一个字母
            if( chars[i-2] < '2' || (chars[i-2] == '2' && chars[i-1] <= '6' ) )
                dp[i] += dp[i-2];
        }
        return dp[chars.length];
    }

    public static void main(String args[]){
        String test = "1234";
        System.out.println(countDecodingDp(test));
    }

}


最长公共子串

最长公共子序列 & 最长公共子串的区别:

找两个字符串的最长公共子串,这个子串要求在原字符串中是连续的。而最长公共子序列则并不要求连续。最长公共子序列的问题参考:最长公共子序列

这两个都可以使用动态规划解决,但是思路不太一样。

我们采用一个二维矩阵来记录中间的结果。这个二维矩阵怎么构造呢?直接举个例子吧:”bab”和”caba”(当然我们现在一眼就可以看出来最长公共子串是”ba”或”ab”)

1    b  a  b
2  
3 c  0  0  0
4  
5 a  0  1  0
6  
7 b  1  0  1
8  
9 a  0  1  0

我们看矩阵的斜对角线最长的那个就能找出最长公共子串。

不过在二维矩阵上找最长的由1组成的斜对角线也是件麻烦费时的事,下面改进:当要在矩阵是填1时让它等于其左上角元素加1。

1    b  a  b
2  
3 c  0  0  0
4  
5 a  0  1  0
6  
7 b  1  0  2
8  
9 a  0  2  0

这样矩阵中的最大元素就是 最长公共子串的长度。

public class LongestSubString {

    public static String longestSubstring(String str1, String str2) {

        StringBuilder sb = new StringBuilder();
        if (str1 == null || str1.isEmpty() || str2 == null || str2.isEmpty())
            return "";

        int[][] num = new int[str1.length()][str2.length()];
        int maxlen = 0; //记录最长字串的长度
        int lastSubsBegin = 0; //记录最长子串开始的位置

        for (int i = 0; i < str1.length(); i++) {
            for (int j = 0; j < str2.length(); j++) {
                if (str1.charAt(i) == str2.charAt(j)) {
                    if ((i == 0) || (j == 0))
                        num[i][j] = 1;
                    else
                        num[i][j] = 1 + num[i - 1][j - 1];

                    if (num[i][j] > maxlen) {
                        maxlen = num[i][j];
                        //当前最长的子串,在str1中开始的位置
                        int thisSubsBegin = i - num[i][j] + 1;

                        //如果是同一个子串
                        if (lastSubsBegin == thisSubsBegin) {
                            sb.append(str1.charAt(i));
                        } else {
                            //不是话的重新生成一个新的子串
                            lastSubsBegin = thisSubsBegin;
                            sb = new StringBuilder();
                            sb.append(str1.substring(lastSubsBegin, i + 1));
                        }
                    }
                }
            }}

        return sb.toString();
    }

    public static void main(String args[]){
        String str = longestSubstring("hello world","cpp hello java");
        System.out.println(str);
    }
}

Bellman-Ford最短路径算法

单源最短路径:给定一个图,和一个源顶点src,找到从src到其它所有所有顶点的最短路径,图中可能含有负权值的边。
关于这个问题我们已经讨论了迪杰斯特拉算法。
Dijksra的算法是一个贪婪算法,时间复杂度是O(VLogV)(使用最小堆)。但是迪杰斯特拉算法在有负权值边的图中不适用,
Bellman-Ford适合这样的图。在网络路由中,该算法会被用作距离向量路由算法。
Bellman-Ford也比迪杰斯特拉算法更简单和同时也适用于分布式系统。但Bellman-Ford的时间复杂度是O(VE),E为边的个数,这要比迪杰斯特拉算法慢。
算法描述
输入:图 和 源顶点src
输出:从src到所有顶点的最短距离。如果有负权回路(不是负权值的边),则不计算该最短距离,
没有意义,因为可以穿越负权回路任意次,则最终为负无穷。

算法步骤:

1.初始化:将除源点外的所有顶点的最短距离估计值 d[v] ← +∞, d[s] ←0;
2.迭代求解:反复对边集E中的每条边进行松弛操作,使得顶点集V中的每个顶点v的最短距离估计值逐步逼近其最短距离;(运行|v|-1次)
3.检验负权回路:判断边集E中的每一条边的两个端点是否收敛。如果存在未收敛的顶点,则算法返回false,表明问题无解;否则算法返回true,并且从源点可达的顶点v的最短距离保存在 d[v]中。

关于该算法的证明也比较简单,采用反证法,具体参考:http://courses.csail.mit.edu/6.006/spring11/lectures/lec15.pdf
该算法是利用动态规划的思想。该算法以自底向上的方式计算最短路径。
它首先计算最多一条边时的最短路径(对于所有顶点)。然后,计算最多两条边时的最短路径。外层循环需要执行|V|-1次。

#include <iostream>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <limits.h>
using namespace std;
//表示一条边
struct Edge
{
    int src, dest, weight;
};

//带权值的有向图
struct Graph
{
    // V 顶点的数量, E 边的数量
    int V, E;

    // 用边的集合 表示一个图
    struct Edge* edge;
};

// 创建图
struct Graph* createGraph(int V, int E)
{
    struct Graph* graph = (struct Graph*) malloc( sizeof(struct Graph) );
    graph->V = V;
    graph->E = E;

    graph->edge = (struct Edge*) malloc( graph->E * sizeof( struct Edge ) );

    return graph;
}

// 打印结果
void printArr(int dist[], int n)
{
    printf("Vertex   Distance from Source\n");
    for (int i = 0; i < n; ++i)
        printf("%d \t\t %d\n", i, dist[i]);
}

// 获得单源最短路径,同时检测 负权回路
void BellmanFord(struct Graph* graph, int src)
{
    int V = graph->V;
    int E = graph->E;
    int dist[V];

    // 第一步初始化
    for (int i = 0; i < V; i++)
        dist[i]   = INT_MAX;
    dist[src] = 0;

    // 第二步:松弛操作
    for (int i = 1; i <= V-1; i++)
    {
        for (int j = 0; j < E; j++)
        {
            int u = graph->edge[j].src;
            int v = graph->edge[j].dest;
            int weight = graph->edge[j].weight;
            if (dist[u] + weight < dist[v])
                dist[v] = dist[u] + weight;
        }
    }

    // 第三步: 检测负权回路.  上面的操作保证没有负权回路的存在,
    // 如果找到了更短的路径,则说明存在负权回路
    for (int i = 0; i < E; i++)
    {
        int u = graph->edge[i].src;
        int v = graph->edge[i].dest;
        int weight = graph->edge[i].weight;
        if (dist[u] + weight < dist[v])
            printf("Graph contains negative weight cycle");
    }

    printArr(dist, V);
    return;
}

// 测试
int main()
{
    /* 创建 例子中的那个图的结构 */
    int V = 5;
    int E = 8;
    struct Graph* graph = createGraph(V, E);

    // add edge 0-1 (or A-B in above figure)
    graph->edge[0].src = 0;
    graph->edge[0].dest = 1;
    graph->edge[0].weight = -1;

    // add edge 0-2 (or A-C in above figure)
    graph->edge[1].src = 0;
    graph->edge[1].dest = 2;
    graph->edge[1].weight = 4;

    // add edge 1-2 (or B-C in above figure)
    graph->edge[2].src = 1;
    graph->edge[2].dest = 2;
    graph->edge[2].weight = 3;

    // add edge 1-3 (or B-D in above figure)
    graph->edge[3].src = 1;
    graph->edge[3].dest = 3;
    graph->edge[3].weight = 2;

    // add edge 1-4 (or A-E in above figure)
    graph->edge[4].src = 1;
    graph->edge[4].dest = 4;
    graph->edge[4].weight = 2;

    // add edge 3-2 (or D-C in above figure)
    graph->edge[5].src = 3;
    graph->edge[5].dest = 2;
    graph->edge[5].weight = 5;

    // add edge 3-1 (or D-B in above figure)
    graph->edge[6].src = 3;
    graph->edge[6].dest = 1;
    graph->edge[6].weight = 1;

    // add edge 4-3 (or E-D in above figure)
    graph->edge[7].src = 4;
    graph->edge[7].dest = 3;
    graph->edge[7].weight = -3;

    BellmanFord(graph, 0);

    return 0;
}


How to print maximum number of A’s using given four keys

This is a famous interview question asked in GooglePaytm and many other company interviews.

Below is the problem statement.

Imagine you have a special keyboard with the following keys: 
Key 1:  Prints 'A' on screen
Key 2: (Ctrl-A): Select screen
Key 3: (Ctrl-C): Copy selection to buffer
Key 4: (Ctrl-V): Print buffer on screen appending it
                 after what has already been printed. 

If you can only press the keyboard for N times (with the above four
keys), write a program to produce maximum numbers of A's. That is to
say, the input parameter is N (No. of keys that you can press), the 
output is M (No. of As that you can produce).

Examples:

Input:  N = 3
Output: 3
We can at most get 3 A's on screen by pressing 
following key sequence.
A, A, A

Input:  N = 7
Output: 9
We can at most get 9 A's on screen by pressing 
following key sequence.
A, A, A, Ctrl A, Ctrl C, Ctrl V, Ctrl V

Input:  N = 11
Output: 27
We can at most get 27 A's on screen by pressing 
following key sequence.
A, A, A, Ctrl A, Ctrl C, Ctrl V, Ctrl V, Ctrl A, 
Ctrl C, Ctrl V, Ctrl V

We strongly recommend to minimize your browser and try this yourself first.

Below are few important points to note.
a) For N < 7, the output is N itself. b) Ctrl V can be used multiple times to print current buffer (See last two examples above). The idea is to compute the optimal string length for N keystrokes by using a simple insight. The sequence of N keystrokes which produces an optimal string length will end with a suffix of Ctrl-A, a Ctrl-C, followed by only Ctrl-V's (For N > 6).
The task is to find out the break=point after which we get the above suffix of keystrokes. Definition of a breakpoint is that instance after which we need to only press Ctrl-A, Ctrl-C once and the only Ctrl-V’s afterwards to generate the optimal length. If we loop from N-3 to 1 and choose each of these values for the break-point, and compute that optimal string they would produce. Once the loop ends, we will have the maximum of the optimal lengths for various breakpoints, thereby giving us the optimal length for N keystrokes.

// this function returns the optimal length string for N keystrokes
int findoptimal(int N)
{
    // The optimal string length is N when N is smaller than 7
    if (N <= 6)
        return N;
 
    // An array to store result of subproblems
    int screen[N];
 
    int b;  // To pick a breakpoint
 
    // Initializing the optimal lengths array for uptil 6 input
    // strokes.
    int n;
    for (n=1; n<=6; n++)
        screen[n-1] = n;
 
    // Solve all subproblems in bottom manner
    for (n=7; n<=N; n++)
    {
        // Initialize length of optimal string for n keystrokes
        screen[n-1] = 0;
 
        // For any keystroke n, we need to loop from n-3 keystrokes
        // back to 1 keystroke to find a breakpoint 'b' after which we
        // will have ctrl-a, ctrl-c and then only ctrl-v all the way.
        for (b=n-3; b>=1; b--)
        {
            // if the breakpoint is at b'th keystroke then
            // the optimal string would have length
            // (n-b-1)*screen[b-1];
            int curr = (n-b-1)*screen[b-1];
            if (curr > screen[n-1])
                screen[n-1] = curr;
        }
    }
 
    return screen[N-1];
}

Dynamic Programming | Set 13 (Cutting a Rod)

Given a rod of length n inches and an array of prices that contains prices of all pieces of size smaller than n.Determine the maximum value obtainable by cutting up the rod and selling the pieces. For example, if length of the rod is 8 and the values of different pieces are given as following, then the maximum obtainable value is 22 (by cutting in two pieces of lengths 2 and 6)

length   | 1   2   3   4   5   6   7   8  
--------------------------------------------
price    | 1   5   8   9  10  17  17  20

And if the prices are as following, then the maximum obtainable value is 24 (by cutting in eight pieces of length 1)

length   | 1   2   3   4   5   6   7   8  
--------------------------------------------
price    | 3   5   8   9  10  17  17  20

dp[i] = prices[i]
dp[i] = Max(dp[j]+dp[i-j]) 1<j<=i/2

#include<bits/stdc++.h>
using namespace std;
void printCuts(int dp[],int path[],int n)
{
    if(path[n]==n) cout<<n+1;
    else
    {
        cout<<path[n]+1<<"+";
        printCuts(dp,path,n-path[n]-1);
    }
}
void cutRod(int a[],int n)
{
    int *dp=new int[n],*path=new int[n];
    //dp[i] will give best value for length=i+1
    for(int i=0;i<n;i++)
    {
        dp[i]=a[i];
        path[i]=i;
        for(int j=0;j<(i+1)/2;j++)
        {
            int temp=dp[j]+dp[i-j-1];
            if(temp>dp[i])
            {
                dp[i]=temp;
                path[i]=j;
            }
        }
    }
    cout<<"Ans="<<dp[n-1]<<endl<<"Cuts: ";
    printCuts(dp,path,n-1);
}
int main()
{
    int arr[] = {1, 5, 8, 9, 10, 17, 17, 20};
    int size = sizeof(arr)/sizeof(arr[0]);
    cutRod(arr,size);
}





  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值