代码改变世界

动态规划(dynamic programming)

2013-08-20 23:37  夜与周公  阅读(401)  评论(0编辑  收藏  举报

  动态规划(dynamic programming)是一种高效的程序设计技术,一般应用与最优化问题,即当我们面临多组选择时,选择一个可行解让问题达到最优。动态规划的一个显著特点是:原问题可以划分成更小的子问题的最优化问题,而这些子问题的解往往有着重叠的部分。

  动态规划算法的解决一个问题,可以分成四个步骤:

  1)描述最优解结构,需找最优子结构

  2)递归定义最优值的解

  3)自底向上的求解问题

  4)依据计算过程,构造一个最优解

  1)与 2)是这个问题可以用动态规划解决的理论基础,3)可以看出是动态规划算法的编程实践,4)是算法输出,依赖于3)。下面分别用三个经典的动态规划案例,阐释动态规划算法的用法。

  案例一:矩阵连乘

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

  Example:先考察3个矩阵{A1,A2,A3}连乘,设这三个矩阵的维数分别为10×100100×55×50

  • 若按((A1A2A3)方式需要的数乘次数为10×100×510×5×507500
  • 若按(A1A2A3))方式需要的数乘次数为100×5×5010×100×5075000

  解决方案:动态规划

  1)描述最优解结构,寻找最优子结构

  记AiAi+1…Aj为A[i:j],考察A[1:n]的最优计算次序问题:假设这个计算次序在k(1<=k<=n)处断开,那么A[1:k]和A[k+1:n]两个子序列的中的计算次序也是最优的。

  为证明子结构与原问题也是一个相同的最优问题,一般采用反证法:

  如果A[1:k]或者A[k+1:n]不是最优的,那么可以必然可以找到一个新的计算次序将A[1:k]或是A[k+1:n]替换,新的计算序列需要的计算次数更少,但这与A[1:n]是最有解序列矛盾。

  2)递归定义最优值

  令m[i][j]表示A[i:j]最小的计算次数,那么递归定义的表达式如下:

  3)自底向上的求解

  有了2)的递归表达式,,代码实现将会变得简单,代码如下:

/************************************************************************/
/* p: 输入参数,存储矩阵序列的中行列值
 * m: m[i][j], 存放A[i:j]的计算次数
   s: s[i][j], 记录A[i:j]断开的位置
*/
/************************************************************************/
int matrix_chain(int* p, int n, int** m, int** s)
{
    for (int i = 0; i != n; i++)
    {
        m[i][i] = 0;
    }

    for (int r = 2; r <= n; r++)
    {
        for (int i = 0; i <= n-r; i++)
        {
            int j = i + r - 1;
            m[i][j] = m[i+1][j] + p[i]*p[i+1]*p[j+1];               //从i处断开
            s[i][j] = i;
            
            for (int k = i+1; k <= j; k++)
            {
                int t = m[i][k] +m[k+1][j] + p[i]*p[k+1]*p[j+1];   //从k处断开
         if (t< m[i][j]) 
         {
           m[i][j]
= t;
           s[i][j]
= k;
         }
       }
     }
  }
}

  4)构造最优解

  在第三步自底向上的求解过程中,记录了构建最优解的最优的必要形式(A[i:j]该断开的位置),构造最优解的过程如下:

void trace_back(int** s, int i, int j)
{
    if (i == j)
    {
        return;
    }

    trace_back(s, i, s[i][j]);
    trace_back(s, s[i][j] + 1, j);

    cout<<"A("<<i<<","<<s[i][j]<<")"<<"\t";
    cout<<"Multiply\t"<<"A("<<s[i][j]+1<<","<<j<<")"<<endl;
}

  程序的主函数如下:

int main()
{
    const int n = 6;
    int p[] = {30, 35, 15, 5, 10, 20, 25};
    int **m, **s;

    m = new int*[n];
    for( int i = 0; i < n; i++)
        m[i] = new int[n];

    s = new int*[n];
    for(int i=0; i<n; i++)
        s[i] = new int[n];  

    matrix_chain(p, n, m, s);
    trace_back(s, 0, n-1);

    for(int i=0;i<n;i++)  
    {  
        delete []m[i];
        delete []s[i];
    }  
    delete []m;   
    delete []s;  

    system("pause");
    return 0;
}

  案例二:最长公共子序列

  问题描述:子序列是指,在原序列中删除若干元素后所得的序列。公共子序列是指,给定两个序列X和Y,另一个序列Z既是X的子序列又是Y的子序列,那么Z则被称为X与Y的公共子序列。而最长公共子序列,则是求X与Y最长的子序列中长度最大的一个序列。

  Example: X = {A,B, C, B, D, A, B}, Y = {B, D, C, A, B, A},他们的一个最长公共子序列是Z = {B, C, B, A }

  解决方案:动态规划

  步骤一:描述最优解结构,寻找最优子结构

  设序列X = {x1, x2,....xn}, 序列Y = {y1, y2,...ym},它们的最长公共子序列是Z = {z1,z2, ...zk},他们之间有如下的最优结构性质:

  1)若xn=ym,则zk=xn=ym,zk-1将是Xn-1与Yn-1的最长公共子序列

  2)若xn=ym且zk≠xn,那么Z是Y与Xn-1最长公共子序列

  3)若xn=ym且zk≠ym,那么Z是X与Yn-1最长公共子序列

  有关最优子结构性质,依然可以采用反证法证明。

  2)递归定义最优值

  记c[i][j]表示序列Xi与序列Yj最长公共子序列的长度,其中Xi = {x1, x2,....xi}, Yj = {y1, y2,...yj},有如下的递归定义表达式:

  

 

  3)自底向上的求解

  依照上面的公式,自底向上的求解代码的代码如下:

#include <iostream>

using namespace std;

int lcs_length(char* x, int m, char* y, int n, int** c)
{
    for (int i = 0; i <= m ; i++)
        c[i][0] = 0;
    for (int j = 0; j <= n; j++)
        c[0][j] = 0;

    for (int i = 1; i <=m; i++)
    {
        for (int j = 1; j <= n; j++)
        {
            if (x[i-1] == y[j-1])             //下标从l开始
            {
                c[i][j] = c[i-1][j-1] + 1;   
            }
            else
            {
                c[i][j] =  max(c[i-1][j], c[i][j-1]);
            }
        }
    }

    return c[m][n];
}

void print_lcs(int i, int j, char* x, int** c)
{
    if (i == 0 || j == 0)
    {
        return;
    }
    
    if (c[i][j] == c[i-1][j-1] + 1)
    {
        print_lcs(i-1, j-1, x, c);
        cout<<x[i-1]<<endl;
    }
    else if (c[i][j] == c[i-1][j])
    {
        print_lcs(i-1, j, x, c);
    }
    else
    {
        print_lcs(i, j-1, x, c);
    }

}

int main()
{
    char x[] = {"ABCBDAB"};
    char y[] = {"BDCABA"};

    int m = strlen(x);
    int n = strlen(y);

    int** c = new int*[m+1];
    for (int i = 0; i<=m; i++ )
    {
        c[i] = new int[n+1];
    }

    int t = lcs_length(x, m, y, n, c);
    for (int i = 0; i <= m; i++)
    {
        for (int j = 0; j <= n; j++)
        {
            cout<<c[i][j]<< " ";
        }
        cout<<endl;
    }
    print_lcs(m, n, x, c);

    system("pause");
    return 0;
}

代码的执行图解如下:

  案例三:最长递增子序列

  问题描述:求一个一维数组(N个元素)中的最长递增子序列的长度。

  Example:在序列1-12-34-56-7中,其最长的递增子序列为1246

  解决方案:动态规划

  继续依据动态规划的解决思想,解决过程省略,这里直接给出最优解的递归结构表达式,令m(i, j)表示以i为起点j为终点(包括原始array[i])的子序列,增长序列的最长长度,则递归表达式为:

  

  实现代码如下:

#include <iostream>

using namespace std;

/************************************************************************/
/*
  array: 存放序列
  m: m[i][j],表示以i为起点j为终点(包括原始array[i])的子序列,增长序列长度
  n: array长度
*/
/************************************************************************/
int lis_length(int* array, int** m, int n)
{
    for (int i = 0; i < n; i++)
    {
        m[i][i] = 1;
    }

    for (int r = 2; r <= n; r++)
    {
        for (int i = 0; i <= n - r; i++)
        {
            int j = i + r -1;
            if (array[i] < array[i+1])
            {
                m[i][j] = m[i+1][j] + 1;
            }
            else
            {
                m[i][j] = m[i+1][j];
            }
        }
    }
    
    return m[0][n-1];
}

void print_lis(int* array, int** m, int n)
{
    int lic_len = m[0][n-1];

    //m[0][i]记录了序列array[0:i],最长递增长度
    //m[0][i] = k,序列第k个增长元素出现
    for (int i = 0, lic_tag = 1; i != n; i++)
    {
        if (m[0][i] == lic_tag)
        {
            cout<<array[i]<<" ";
            lic_tag += 1;
        }
    }
}

int main()
{
    const int n = 8;
    int array[n] = {1, 4, 2, -3, 4, 8, 6, -7};

    int** m = new int*[n];
    for (int i = 0; i != n; i++)
    {
        m[i] = new int[n];
    }

    int t = lis_length(array, m, n);
    print_lis(array, m, n-1);

    for (int i = 0; i != n; i++)
    {
        delete[] m[i];
    }
    delete[] m;

    system("pause");
    return 0;

}

  总结:1)能够最优子结构性质,是使用动态规划算法的先决条件;2)重复解结构,动态规划算法能够高效使用的原因在于记录了子结构的解,而这些解又会在后续的求解过程中被我们使用(优于递归算法的原因);3)最后在编程实践技巧上,利用了自底向上的求解技术,从子问题开始求解。

  在编程实践的过程,特别需要主要以下问题:1)初始化,零界情况下表格的初始化必须提前完成;2)子问题的求解一定要现优于原问题,如出现某个子问题未求解出,但算法开始处理该原问题,将会引入错误(错误将非常隐晦);3)解的信息保存。