关闭

dynamic programming

216人阅读 评论(0) 收藏 举报

五大常用算法之二:动态规划算法

http://www.cnblogs.com/steven_oyj/archive/2010/05/22/1741374.html

一、基本概念

    动态规划过程是:每次决策依赖于当前状态,又随即引起状态的转移。一个决策序列就是在变化的状态中产生出来的,所以,这种多阶段最优化决策解决问题的过程就称为动态规划。

二、基本思想与策略

    基本思想与分治法类似,也是将待求解的问题分解为若干个子问题(阶段),按顺序求解子阶段,前一子问题的解,为后一子问题的求解提供了有用的信息。在求解任一子问题时,列出各种可能的局部解,通过决策保留那些有可能达到最优的局部解,丢弃其他局部解。依次解决各子问题,最后一个子问题就是初始问题的解。

    由于动态规划解决的问题多数有重叠子问题这个特点,为减少重复计算,对每一个子问题只解一次,将其不同阶段的不同状态保存在一个二维数组中。

    与分治法最大的差别是:适合于用动态规划法求解的问题,经分解后得到的子问题往往不是互相独立的(即下一个子阶段的求解是建立在上一个子阶段的解的基础上,进行进一步的求解)

 


三、适用的情况

能采用动态规划求解的问题的一般要具有3个性质:

    (1) 最优化原理:如果问题的最优解所包含的子问题的解也是最优的,就称该问题具有最优子结构,即满足最优化原理。

    (2) 无后效性:即某阶段状态一旦确定,就不受这个状态以后决策的影响。也就是说,某状态以后的过程不会影响以前的状态,只与当前状态有关。

   (3)有重叠子问题:即子问题之间是不独立的,一个子问题在下一阶段决策中可能被多次使用到。(该性质并不是动态规划适用的必要条件,但是如果没有这条性质,动态规划算法同其他算法相比就不具备优势

 


四、求解的基本步骤

     动态规划所处理的问题是一个多阶段决策问题,一般由初始状态开始,通过对中间阶段决策的选择,达到结束状态。这些决策形成了一个决策序列,同时确定了完成整个过程的一条活动路线(通常是求最优的活动路线)。如图所示。动态规划的设计都有着一定的模式,一般要经历以下几个步骤。

   初始状态→│决策1│→│决策2│→…→│决策n│→结束状态

                      图1 动态规划决策过程示意图

    (1)划分阶段:按照问题的时间或空间特征,把问题分为若干个阶段。在划分阶段时,注意划分后的阶段一定要是有序的或者是可排序的,否则问题就无法求解。

    (2)确定状态和状态变量:将问题发展到各个阶段时所处于的各种客观情况用不同的状态表示出来。当然,状态的选择要满足无后效性。

    (3)确定决策并写出状态转移方程:因为决策和状态转移有着天然的联系,状态转移就是根据上一阶段的状态和决策来导出本阶段的状态。所以如果确定了决策,状态转移方程也就可写出。但事实上常常是反过来做,根据相邻两个阶段的状态之间的关系来确定决策方法和状态转移方程

    (4)寻找边界条件:给出的状态转移方程是一个递推式,需要一个递推的终止条件或边界条件。

    一般,只要解决问题的阶段状态状态转移决策确定了,就可以写出状态转移方程(包括边界条件)。

实际应用中可以按以下几个简化的步骤进行设计:

    (1)分析最优解的性质,并刻画其结构特征。

    (2)递归的定义最优解。

    (3)以自底向上或自顶向下的记忆化方式(备忘录法)计算出最优值

    (4)根据计算最优值时得到的信息,构造问题的最优解

 


五、算法实现的说明

    动态规划的主要难点在于理论上的设计,也就是上面4个步骤的确定,一旦设计完成,实现部分就会非常简单。

     使用动态规划求解问题,最重要的就是确定动态规划三要素

    (1)问题的阶段 (2)每个阶段的状态

    (3)从前一个阶段转化到后一个阶段之间的递推关系

     递推关系必须是从次小的问题开始到较大的问题之间的转化,从这个角度来说,动态规划往往可以用递归程序来实现,不过因为递推可以充分利用前面保存的子问题的解来减少重复计算,所以对于大规模问题来说,有递归不可比拟的优势,这也是动态规划算法的核心之处

    确定了动态规划的这三要素,整个求解过程就可以用一个最优决策表来描述最优决策表是一个二维表,其中行表示决策的阶段,列表示问题状态,表格需要填写的数据一般对应此问题的在某个阶段某个状态下的最优值(如最短路径,最长公共子序列,最大价值等),填表的过程就是根据递推关系,从1行1列开始,以行或者列优先的顺序,依次填写表格,最后根据整个表格的数据通过简单的取舍或者运算求得问题的最优解。

          f(n,m)=max{f(n-1,m), f(n-1,m-w[n])+P(n,m)}

 


六、动态规划算法基本框架
复制代码
代码
1 for(j=1; j<=m; j=j+1) // 第一个阶段 2   xn[j] = 初始值; 3 4  for(i=n-1; i>=1; i=i-1)// 其他n-1个阶段 5   for(j=1; j>=f(i); j=j+1)//f(i)与i有关的表达式 6 xi[j]=j=max(或min){g(xi-1[j1:j2]), ......, g(xi-1[jk:jk+1])}; 8 9 t = g(x1[j1:j2]); // 由子问题的最优解求解整个问题的最优解的方案 10 11 print(x1[j1]); 12 13 for(i=2; i<=n-1; i=i+1 15 { 17 t = t-xi-1[ji]; 18 19 for(j=1; j>=f(i); j=j+1) 21 if(t=xi[ji]) 23 break; 25 }
复制代码

1. 什么是动态规划?
         和分治法一样,动态规划(dynamicprogramming)是通过组合子问题而解决整个问题的解。
         分治法是将问题划分成一些独立的子问题,递归地求解各子问题,然后合并子问题的解。
         动态规划适用于子问题不是独立的情况,也就是各子问题包含公共的子子问题。
         此时,分治法会做许多不必要的工作,即重复地求解公共的子问题。动态规划算法对每个子问题只求解一次,将其结果保存起来,从而避免每次遇到各个子问题时重新计算答案。
2. 动态规划算法的设计
两种方法:
         自顶向下(又称记忆化搜索、备忘录):基本上对应着递归函数实现,从大范围开始计算,要注意不断保存中间结果,避免重复计算
         自底向上(递推):从小范围递推计算到大范围
动态规划的重点:
         递归方程+边界条件
3. 爬楼梯问题
         一个人每次只能走一层楼梯或者两层楼梯,问走到第80层楼梯一共有多少种方法。
         设DP[i]为走到第i层一共有多少种方法,那么DP[80]即为所求。很显然DP[1]=1, DP[2]=2(走到第一层只有一种方法:就是走一层楼梯;走到第二层有两种方法:走两次一层楼梯或者走一次两层楼梯)。同理,走到第i层楼梯,可以从i-1层走一层,或者从i-2走两层。很容易得到:
         递推公式:DP[i]=DP[i-1]+DP[i-2]
         边界条件:DP[1]=1   DP[2]=2
         (a)自顶向下的解法:

  1. long long dp[81] = {0};/*用于保存中间结果 
  2. 否则会重复计算很多重复的子问题*/  
  3. long long DP(int n)  
  4. {  
  5.     if(dp[n])  
  6.         return dp[n];  
  7.     if(n == 1)  
  8.         return 1;  
  9.     if(n == 2)  
  10.         return 2;  
  11.     dp[n] = DP(n-1) + DP(n-2);  
  12.     return dp[n];     
  13. }  

         (b)自底向上的解法:

  1. int i;  
  2. long long dp[81]; /* 注意当n超过75时,结果值将超过int范围 */  
  3. dp[1] = 1;  
  4. dp[2] = 2;  
  5. for(i=3; i <= 80; i++)  
  6.     dp[i] = dp[i-1] + dp[i-2];  

4. 最长上升子序列
         对于序列:4 1 2 24,它的最长上升子序列是1 2 4,长度为3。
         对于序列:4 2 4 25 6,它的最长上升子序列是2 4 5 6,长度为4
         设a[i]表示原序列,设DP[i]表示以第i个数结尾的最长上升序列的长度,那么很显然想导出DP[i]的值,需要在DP[k](1<=k&lt;i)中找出满足a[k]<a[i]最大的一项。假设第kk项是我们找到的答案,那么第i个数就可以接在第kk个数之后,成为以第i个数结尾的最长升序列。如果没有找到答案,换言之第i个数比前面的数都要小,那么DP[i]=1,也即生成了从自己开始又以自己结尾的最长升序列。综上,我们很容易得出:
         递推公式:DP[i]=max(DP[k]+1,DP[i])  1<=k<i
         边界条件:DP[i]=1                   1<=i<=n
         算法复杂度为O(n^2)

  1. void RiseSequence(int Array[], int num)  
  2. {  
  3. #define MAX_LENGTH  30  
  4.     struct  
  5.     {  
  6.         int SequenceValue;  /* max length ending with this num */  
  7.         int PreviousIndex;  /* record the previous number */  
  8.     }ArrayInfo[MAX_LENGTH], temp;  
  9.     int i;  
  10.     for(i = 0; i < num; i++)  
  11.     {  
  12.         int j;  
  13.         ArrayInfo[i].SequenceValue = 1;  
  14.         ArrayInfo[i].PreviousIndex = -1;  
  15.         for(j = 0; j < i; j++)  
  16.         {  
  17.             if(Array[j] < Array[i] && (ArrayInfo[j].SequenceValue + 1 > ArrayInfo[i].SequenceValue))  
  18.             {  
  19.                 ArrayInfo[i].SequenceValue = ArrayInfo[j].SequenceValue + 1;  
  20.                 ArrayInfo[i].PreviousIndex = j;  
  21.             }  
  22.         }  
  23.     }  
  24.     temp.SequenceValue = ArrayInfo[0].SequenceValue;  
  25.     for(i = 1; i < num; i++)  
  26.     {  
  27.         if(temp.SequenceValue < ArrayInfo[i].SequenceValue)  
  28.         {  
  29.             temp.SequenceValue = ArrayInfo[i].SequenceValue;  
  30.             temp.PreviousIndex = i;  
  31.         }  
  32.     }  
  33.     for(i = 0; i < temp.SequenceValue; i++)  
  34.     {  
  35.         printf("%d  ", Array[temp.PreviousIndex]);  /* in reverse order */  
  36.         temp.PreviousIndex = ArrayInfo[temp.PreviousIndex].PreviousIndex;  
  37.     }  
  38.     printf("\nthe max rising sequence length is %d\n", temp.SequenceValue);  
  39. }  

5. 最长公共子序列
         给定两个序列X和Y,称序列Z是X和Y的公共子序列如果Z既是X的一个子序列,又是Y的一个子序列。例如,如果X={a,b,c,b,d,a,b} Y={b,d,c,a,b,a} 那么序列{b,c,a}就是X和Y的一个公共子序列,但是它并不是X和Y的最长公共子序列,因为它的长度为3。而同为X和Y公共子序列的{b,c,b,a},长度为4,因为找不到长度为5或更大的公共子序列,所以X和Y的最长公共子序列长度就为4。
         假设两个序列数组分别为a,b。定义f(i,j)为计算到a数组第i个数、b数组第j个数时所得到的最长公共子序列的长度。这时有两种情况:
         1.假如a[i]=b[j],那么f(i,j)=f(i-1,j-1)+1
         2.假如a[i]!=b[j],那么f(i,j)=max(f(i-1,j),f(i,j-1))
         边界条件为:f(i,0)=0     1<=i<=len(a)
                               f(0,j)=0     1<=j<=len(b)
         算法复杂度:O(n^2),len(a)表示数组a的长度。


递归算法就是通过解决同一问题的一个或多个更小的实例来最终解决一个大问题的算法。为了在C语言中实现递归算法,常常使用递归函数,也就是说能调用自身的函数。递归程序的基本特征:它调用自身(参数的值更小),具有终止条件,可以直接计算其结果。

      在使用递归程序时,我们需要考虑编程环境必须能够保持一个其大小与递归深度成正比例的下推栈。对于大型问题,这个栈需要的空间可能妨碍我们使用递归的方法。

     一个递归模型为分治法,最本质的特征就是:把一个问题分解成独立的子问题。如果子问题并不独立,问题就会复杂的多,主要原因是即使是这种最简单算法的直接递归实现,也可能需要难以想象的时间,使用动态规划技术就可以避免这个缺陷。

     例如,斐波那契数列的递归实现如下:

    int F(int i)

    {

             if(i < 1)  return 0;

             if(i == 1) return 1;

              return F(i-1) + F(i - 2);

    }

    千万不要使用这样的程序,因为它的效率极低,需要指数级时间。相比之下,如果首先计算前N个斐波那契数,并把它们存储在一个数组中,就可以使用线性时间(与N成正比)计算F。

 

      F[0] = 0;F[1] = 1;

      for(i = 2; i <= N; i++)

            F[i] = F[i-1] + F[i-2];

     这个技术给了我们一个获取任何递归关系数值解的快速方法,在斐波那契数的例子中,我们甚至可以舍弃数组,只需要保存前两个值。

     由上面的讨论我们可以得出这样的结论:我们可以按照从最小开始的顺序计算所有函数值来求任何类似函数的值,在每一步使用先前已经计算出的值来计算当前值,我们称这项技术为自底向上的动态规划。只要有存储已经计算出的值的空间,就能把这项技术应用到任何递归计算中,就能把算法从指数级运行时间向线性运行时间改进。

    自顶向下的动态规划甚至是一个更简单的技术,这项技术允许我们执行函数的代价与自底向上的动态规划一样(或更小),但是它的计算是自动的。我们实现递归程序来存储它所计算的每一个值(正如它最末的步骤),并通过检查所存储的值,来避免重新计算它们的任何项(正如它最初的步骤)。这种方法有时也称作为备忘录法。

                       斐波那契数(动态规划)

通过把所计算的值存储在递归过程的外部数组中,明确地避免重复计算。这一程序计算的时间与N成正比。

                  int F(int i)

                  {

 

                          if(knownF[i] != unknown)

                                 return knownF[i];

                          if(i == 0) t = 0;

                          if(i == 1) t = 1;

                          if(i > 1)  t = F(i - 1) + F(i - 2);

                          return knownF[i] = t;

                  }

 

 

       性质:动态规划降低了递归函数的运行时间,也就是减少了计算所有小于或等于给定参数的递归调用所要求的时间,其中处理一次递归调用的时间为常量。

       我们不需要把递归参数限制到单整形参数的情况。当有一个带有多个整形参数的函数时,可以把较小子问题的解存储在多维数组中,一个参数对应数组的一维。其他那些完全不涉及整形参数的情形,就使用抽象的离散问题公式,它能让我们把问题分解为一个个的小问题。

      在自顶向下的动态规划中,我们存储已知的值;在自底向上的动态规划中,我们预先计算这些值。我们常常选择自顶向下的动态规划而不选自底向上动态规划,其原因如下:

     1 自顶向下的动态规划是一个自然的求解问题的机械转化。

     2 计算子问题的顺序能自己处理。

     3 我们可能不需要计算所有子问题的解。

     我们不能忽视至关重要的一点是,当我们需要的可能的函数值的数目太大以至于不能存储(自顶向下)或预先计算(自底向上)所有值时,动态规划就会变得低效。自顶向下动态规划确实是开发高效的递归算法实现的基本技术,这类算法应纳入任何从事算法设计与实现所需的工具箱。


对动态规划(Dynamic Programming)的理解:从穷举开始

动态规划Dynamic Programming,以下简称dp)是算法设计学习中的一道槛,适用范围广,但不易掌握。

笔者也是一直不能很好地掌握dp的法门,于是这个寒假我系统地按着LRJ的《算法竞赛入门经典》来学习算法,对dp有了一个比过往都更系统\更深入的理解,并在这里写出来与大家分享。

笔者着重描述的是从穷举到dp的算法演进,并从中获取dp解法的思路,并给出多种思考的角度,务求解决的是LRJ提出的一种现象:

“每次碰到新题自己都想不出来,但一看题解就懂”的尴尬情况。

!--more--

DP与穷举

大多讲dp的文章都是以0-1背包为基础来讲解的,笔者想换个花样,以另一道题“划分硬币”(UVa-562 Dividing coins)来讲述。

现在有一袋硬币,里面最多有100个硬币,面值区间为[1, 500],要分给两个人,并使得他们所获得的金钱总额之差最小,并给出这个最小差值。

这种问题笔者称之为二叉树选择问题。假设袋中有N个硬币,我们从最原始的枚举法来一步步优化。

我们让其中一个人先挑硬币,挑剩的就是给另外一个人的。第一个人对于每一个硬币,都有“选”和“不选”两种选择。我们很容易穷举他全部的情况——全部硬币的任意大小的子集,共2N种,并选取其中两人差值最小的解。

具体作法可以用递归法,二进制法等。在这里给出笔者的递归解法的代码,因为它与后面的优化紧密关联。

int solve_by_brute_force(vector<int> &v, int cur, int limit, int sofar, int sum) {
    if (cur == limit) { // the border of recursion
        int other = sum - sofar;
        return sofar>other? sofar-other : other-sofar;
    }

    // choose or not the current coin
    int ans1 = solve_by_brute_force(v, cur+1, limit, sofar+v[cur], sum);
    int ans2 = solve_by_brute_force(v, cur+1, limit, sofar, sum);
    return ans1 < ans2? ans1 : ans2;
}

int main(){
    int t;
    cin >> t;
    for (int nc= 0; nc<t; nc++) {
        int m;
        cin >> m;
        vector<int> v(m);
        int tot = 0;
        for (int i= 0; i<m; i++) {
            cin >> v[i];
            tot += v[i];
        }
        int res = solve_by_brute_force(v, 0, m, 0, tot);
        cout << res << endl;
    }
    return 0;
}

O(2N)的指数复杂度始终是撑不过去的,我们需要优化,但从哪里开始优化呢?

我们以测试样例“2 3 5”来看看,下图是样例的穷举解答树,左树为不选当前硬币,右树为选入,叶结点为第一个人的硬币总额。

           root
          /    \
 2       /      \
        /\      /\
 3     /  \    /  \
      /\  /\  /\  /\
 5   /  \/  \/  \/  \
    0   5        5  2+3+5

可以看到,叶结点中有两个相同的值,思考一下,如果再多加一个硬币(解答树变成4层),相同的叶结点会引起无谓的重复计算,造成浪费。

我们从1到N逐个做硬币的选择决策

  • 以树的结构来记录当前硬币的累计金钱(中间结果)是低效的,因为它允许存在值相同的结点。我们思考,那么我们何不换一种方式来记录中间结果呢?我们何不采用数组的方式?假设一袋硬币存在int Coins[]数组中,中间结果的范围是可求的,区间为[0, Coins[0]+Coins[1]+..+Coins[cur]],或者干脆是全部硬币的总和[0,SUM]

  • 以树分叉的方式来做决策也是可以改变的。改变中间结果的记录方式后,我们可以这样来递推:假设有一个二维数组bool mat[N+1][SUM+1],第一维表示当前已选择到第几个硬币,第二维表示中间结果,比如mat[3][10]表示在“2, 3, 5”样例中,我们选择到第3个硬币(最后一个),并且中间结果(正好是最后的叶结点)为10的情况,如果为true,则这种情况成立,是存在的,否则不存在,比如mat[3][9],你是拼不出和为9的情况的。这时递推公式变为mat[cur][i] |= mat[cur-1][i-Coins[cur]],我们只要在每个硬币(每层)把i在区间[0,SUM]枚举一遍就可以了。

这个方法的完整代码:

const int MAX_SUM = 100000;
bool arr[MAX_SUM];

int solve_by_dp(vector<int> &v, int sum) {
    fill(arr, arr+MAX_SUM, false);
    arr[0] = true;
    for (int i= 0; i<v.size(); i++) {
        for (int j= v[i]; j<=sum; j++) {
            if (arr[j-v[i]])
                arr[j] = true;
        }
    }
    int mid = sum/2;
    for (int i=0; i<=mid; i++) {
        int one = mid-i;
        if (arr[one]) {
            int other = sum - one;
            return other>one? other-one : one-other;
        }
    }
    return -1;  // never been here
}

这样复杂度变为\(O(NSUM)\),即\(O(500N^2)\),从指数降到多项式了。而空间复杂度,考虑到可以当前硬币的中间结果,只与上一层有关,我们使用滚动数组的方法就可以避免开一个二维数组的耗费了。

其实中间数组的每个元素,就是dp中的“状态”。对dp的理解首先要从枚举开始,发现穷举过程中做无用功的地方,改变记录和递推的方式去优化它。关于格举的解答树和dp的状态之间的关系,LRJ有一个很好的总结。

状态及其转移类似于回溯法听解答树。解答树中的“层数”,也就是递归函数中的“当前填充位置”cur,描述的是即将完成的决策序号,在动态规划中被称为“阶段”。

如何寻找DP的思路

在做题的时候,我有意识地注意对自己dp的思路寻找过程,而不满足于单纯地套模板。

我往往先考虑最朴素的穷举思路,并努力发现隐藏其中的“最优子结构”,寻找子问题的递推关系。我相信这也是LRJ在书中按“穷举-分治-dp”的顺序编排章节的原因。

LCS问题(Longest Common Sequence)

比如一个典型的dp问题——LCS(Longest Common Sequence),求两个字符串的最长公共子串(不要求连续),比如这道题UVA - 10405

假设两个字符串为a, b,最后结果为子串s,按枚举的思路,对a的每一个字符进行枚举,假设它作为公共子串的首字符,并在b中从左往右寻找与它相同的字符,假设a[i]==b[j],这样就得出子问题——现在问题变成了求a, b匹配字符的后缀的LCS,即lcs(a+i+1, b+j+1)。文字描述不仔细,请看看代码。

// example
// string a = “a1b2c3d4e”, b = “zz1yy2xx3ww4vv”;
// cout << lcs(a, b, 0, 0, 0); 
int lcs(string &a, string &b, int ia, int ib, int cur) {
    int max = cur;
    for (int i= ia; i<a.length(); i++) {
        char c = a[i];
        int j;
        for (j= ib; j<b.length(); j++) {
            if (c == b[j])
                break;
        }
        if (j == b.length()) continue;
        int ans = lcs(a, b, i+1, j+1, cur+1);
        if (ans > max) max = ans;
    }
    return max;
}

上述的代码复杂度非常大,原因在于相同的lcs(a, b, i, j, cur)可能会被多次调用,比如lcs(a, b, 3, 4, 1)lcs(a, b, 3, 4, 2)(数据乱编的),它们的目的都是相同的——想求得lcs(a+i, b+j)。发现穷举过程中隐藏的子模式\子问题之后,可以把中间结果先算出来,并且它们正好可以存在一个二维表中,代码如下。这就是LCS问题的思考过程了。

const int N = 100;
int mat[N][N];
int lcs_dp(string &a, string &b) {
    fill(&mat[0][0], &mat[0][0]+N*N, 0);
    for (int i= a.length()-1; i>=0; i--) {
        for (int j= b.length()-1; j>=0; j--) {
            int &cur = mat[i][j];
            if (a[i] == b[j])
                cur = 1 + mat[i+1][j+1];
            else
                cur = mat[i][j+1]>mat[i+1][j] ? mat[i][j+1] : mat[i+1][j];
        }
    }
    return mat[0][0];
}

有些子问题也穷举思路中也许不明显,需要读者自己去寻找一个划分点,然后创造重叠子问题。本节问题的划分点是公共子串的第一个字符,再如“表达式链”问题(如UVA - 348)的划分点则是“最后一次运算”,都可以从穷举的思路中发现问题子模式。

经典的0-1背包问题也是如此,本来每个物品均是两种选择——放和不放,穷举法是用一种二叉树来表示,可以用本文第一节所说的方法来解决——记录并更新全部可能重量的数组。也可以用另一种递归思路,即从第1个物品开始,放和不放,影响的是背包的剩余容积,很容易可以构造出重叠子问题——在特定容积下,余下的物品可以放入的最大重量。

多阶段决策问题

还有一类dp问题,是多阶段决策中需要“分层”或者“分阶段”的dp问题。

比如找零钱的方案数(UVA - 357)问题。

有$50, $25, $10, $5 and $1不同面值的纸钞,现在给定一个需要找零的金额数$N,问一共有多少种不同的找零方法。

如果按照上节所说的穷举方法,我们很容易得到一个4叉树(有4种面值的纸钞),这棵树一直延伸发散,直到从根结点到叶结点的路径总和等于或大于N才停止,然后统计路径总和为N的路径数。

但这个穷举思路有问题,比如现在N=15,穷举树先伸出5再伸出10,和先伸出10再伸出5,都可以达到路径和为15,并算作两条路径,但其实它们是等价的。这类问题需要加入一个“先后顺序”的概念,LRJ的原话是:

原来的状态转移太敌了,任何时候都允许使用任何一种物品,难以控制。为了消除这种混乱,需要让状态转移(也就是决策)有序化。

我们可以从每种硬币的个数上进行穷举,代码如下。

int coins[] = {1, 5, 10, 25, 50};

int count_ways(int n) {
    int i1, i5, i10, i25, i50;
    int cnt = 0;
    for(i1= 0; i1<=n; i1++) {
        for(i5= 0; i5*5<=n; i5++) {
            for(i10= 0; i10*10<=n; i10++) {
                for(i25= 0; i25*25<=n; i25++) {
                    for(i50= 0; i50*50<=n; i50++) {
                        int tmp = i1 + i5*5 + i10*10 + i25*25 + i50*50;
                        if (tmp == n) cnt++;
                    }
                }
            }
        }
    }
    return cnt;
}

同样是穷举,不同之处在于第二种方案中有“顺序”\“层”\“阶段”的概念,各种硬币并不允许随时被加入最终的总和。不像第一种方案,为了得到$15,可以按$5+$10和$10+$5多种顺序得到,第二种只能是$5+$10,不会重复。

反映在dp方法上,我们也得做出改变,我们要依次记录不同阶段的中间结果——用arr[N]表示N有多少种拼溱方法,只用$1硬币计算一遍,再加上$5硬币计算一遍,再加入……直到5种硬币都加入了,最终结果就出来了,而且结果不会重复。

const int N = 1000;
int arr[N];
int count_ways_dp(int n) {
    fill(arr, arr+N, 0);
    arr[0] = 1; // initialization
    for (int i= 0; i<NCOINS; i++) {
        for (int j = coins[i]; j<=n; j++) {
            arr[j] += arr[j-coins[i\\);
        }
    }
    return arr[n];
}

“阶段”只是帮助我们思考的,在动态规划的状态描述中最好避免“阶段”\“层”这样的术语。

用这种方式来描述“阶段”这种概念,笔者也略感到勉力,希望各位看官能反馈回来更好的见解。

最后的话

笔者的算法学习经历并不多,大一的时候读了半本《算法概论》,选了一门水水的算法课(作用几可忽略==),课余参加过一些Codeforces的在线比赛,最认真的,就是这个大学最后的寒假闭关在家按着LRJ的《算法竞赛入门经典》苦练一番,收获颇丰,而其中以dp为最难,故才有此一文来总结这个寒假的算法学习。

不得不说,整个算法的思考过程对我来说很不容易。以在Quora看来的一句与算法(数学)学习相关的话跟诸君共勉。

Most people simply don't put in the time to make something intuitive.

参考资料



0
0

查看评论
* 以上用户言论只代表其个人观点,不代表CSDN网站的观点或立场
    个人资料
    • 访问:157870次
    • 积分:2527
    • 等级:
    • 排名:第15192名
    • 原创:0篇
    • 转载:635篇
    • 译文:1篇
    • 评论:3条
    文章分类
    最新评论