动态规划之-Rod Cutting 问题(2)
上一篇Rod-cutting动态规划问题中,我们提到了《算法导论》中提到的动态规划问题解决方案的CRCC步骤,并介绍动态的两大特征:
- 最优子结构
- 重叠子问题
我们在rod cutting问题中对这两点进行了分析和阐述,本文继续利用方法2中提到的递归公式,对top-down和bottom-up两种方法进行说明,同时对记录的保存进行说明。
Top-Down 方式
一般采用递归结构,其典型的思维是“大处着眼,小处着手”,对问题的分析,以总的问题(大处)为根节点,然后不断分裂子树,解决子树问题(小处),从而最后得到总问题的解。值得一提的是,由于利用递归,所以需要输入递归的结束条件,这样才能有效求出问题的解。让我们以Rod-cutting问题为例进行说明,首先我们从顶层出发,求解r(n)的最大化利润,要求得r(n)的最大利润,就需要利用关系式, 不断求解:
r
(
n
)
=
m
a
x
{
p
(
i
)
+
r
(
n
−
i
)
,
1
≤
i
≤
n
}
r(n)=max \left\{p(i)+r(n-i), 1≤i≤n\right\}
r(n)=max{p(i)+r(n−i),1≤i≤n}
求解的结构过程类似 深度优先遍历DFS(Depth First Search)的过程,整个遍历路径以Start point为起点,沿着深蓝色路径,一直来到终点,为了展现整个过程,下图中未引入重复子问题查询的基本概念。
Bottom-up 方式
Bottom-up 解决问题的思路是从小处开始,遵循从底层到顶层的解决问题之道。从某种意义上讲,bottom-up解决问题是利用迭代的思路进行问题解决,迭代之前需要确认底层的起始点(也就是递归的结束点),利用循环语句结果问题。一般情况下,如果子问题的形式只有一个,那么bottom-up采用双重循环即可求得结果;如果子问题有两个,那么至少需要3重循环才能解决问题,用不专业的属于表述,bottom-up至少比递归多一层循环,因为递归的过程,计时函数体内没有循环语句,其过程中也隐含循环的过程。
其实对于动态规划问题的求解,如果我们把每个子问题(sub-problem)抽象为有向图(DG)的结点,那么此有向图一定不存在环,也就是说,它是拓扑有序的,属于DAG图。如果某几个子问题的结点组成的一个环,那么此动态规划问题将无法求解,因为子问题之间不是相互独立的,子问题相互独立是动态规划的重要条件之一。
我们回到rod-cutting问题上来,以长度n=4为例,说明 bottom-up的工作方式,我们以DAG 图为辅助阐释条件。
在上述有向图中,我们实际上是对Top-down形成的递归树进行压缩,对于相同结点的子问题,我们进行同类问题的合并,对于同类问题,我们不仅合并结点,而且也合并有向边,我们对这个过程进行说明:
a) 对于底层结点#0的值r(0),它属于最bottom结点,在bottom之前,我们将对其进行赋值r(0)=0
b) 接下来我们从下到上,可以遍历各个顶点,接着关注结点#1的r(1),由于r(1)紧紧依附于顶点r(0),顶点r(0)已经求出,再加上某些权值,作为选择的代价;
c) r(0)和r(1)的值明确后,我们开始关注结点2,结点2依附于结点0和结点1,如果要求出结点2的值,我们需要分别关注路径②→①和②→0, 由于r(0)和r(1)已经已知,我们只要加上选择的代价p[i],最终比较两个路径的长度,选择最大的值作为r(2)的值
d) 对于③,④,⑤结点,我们套用同样的逻辑,求得r(3),r(4)和r(5)
值得一提的是,在bottom-up过程中,我们始终坚持先求“小”的子问题,然后再求解“大”的子问题,自然而然的求得最终问题的解。
上面已经介绍完成top-down和bottom-up的执行概念,对于同一个问题,两个概念的时间复杂度基本相同,对于rod-cutting问题,二者的时间复杂度都为:
O
(
n
2
)
O(n^2)
O(n2)
因为对于二者问题的求解都最终都形成类似一个等差数列,
- 对于top-down而言,我们可以定义其复杂度为=n+(n-1)+…(n-i)+2+1, 通过求和我们得到:
T ( n ) = ( n + 1 ) ∗ n / 2 = ( n 2 + n ) / 2 ≈ O ( n 2 ) T(n)=(n+1)*n/2=(n^2+n)/2≈O(n^2) T(n)=(n+1)∗n/2=(n2+n)/2≈O(n2)
- 对于bottom-up而言,我们可以定义其复杂度=1+2+3+4…n,通过求和得到相同的结论
接下来,我们要介绍动态规划的实际效果,也就是保存重复子问题的值,通过查询结果直接返回值或引用,这是典型的用空间换取时间的例子,如果用物理中的杠杆原理比例,那就是用合理的空间“ 杠杆” 极大撬动(降低)运行的时间复杂度,从而有效提升程序的运行效率。
查询数组概念
- Top-down 方式的查询
我们回到本次rod-cutting 问题本身,从top-down方式开始介绍,如何保存运算结果到数组当中,从而直接进行查询或引用,从而提升程序效率。我们还是用图来说明执行的过程。
当用top-down递归方式遍历到最左边的0号节点时候,递归将要退出,我们在递归退出之前,利用数组r[0]记录求出的值,紧接着退出①号节点,这是我们r[1]记录节点1的值;接下来就是见证奇迹的时刻,当遍历②号节点的右子树O的时候,由于我们已经求得r[0],此时便直接返回r[0]的值,而无需进行任何递归运算,如果说这是一道小菜,那么接下来回退到②号节点,我们保存r[2]的结果,对于rod-cutting问题,自此以后,我们无需利用递归对节点进行任何遍历,只需要利用已经存在的记录直接返回值即可,圈内空白表示直接返回值,无需递归计算。
那么程序上如何实现呢?每次递归运算之前,我们需要对数组的值进行判断,确认此节点之前是否已经有运算值。所以数组内的值需要有两类状态(未填充 和 已赋值),未填充可以用INT_MIN或某个特殊数表示,原则上需要避免和后面的赋值不冲突,所以NULL, INT_MIN或INT_MAX都是不错的选择。具体Top-down的实现程序如下:
我们对函数进行说明:
-
主函数,int cut_rod(int *p, int n), p表示不同长度钢棒的出售价格,n表示未切割前的钢棒长度,函数返回最大化收益。在函数里面定义r[N]数组,用来记录已经求得的子问题的最优解,通过初始化赋值未INT_MIN.
-
辅助函数,int cut_rod_aux(int *p, int *r, int n) 先判断目前长度n的钢棒的最优解是否存在r数组中,如果存在数组中,那么就是直接返回至原来递归处;如果不存在数组中,那么就进行常规的递归运算,关键的一点是,对于n长度的钢棒求出最优解之后,我们需要利用r数组对其结果进行保存,以便下次查询使用。
/**
* @file cut_rod_recursive_memo.c
* @author your name (you@domain.com)
* @brief
* @version 0.1
* @date 2023-02-19
*
* @copyright Copyright (c) 2023
*
*/
#ifndef CUT_ROD_RECURSIVE_MEMO_C
#define CUT_ROD_RECURSIVE_MEMO_C
#include "cut_rod_recursive_memo.h"
int cut_rod(int *p, int n)
{
int r[N];
for(int i=0;i<=n;i++)
{
*(r+i)=INT_MIN;
}
return cut_rod_aux(p,r,n);
}
int cut_rod_aux(int *p, int *r, int n)
{
int q;
int i;
//Look up the optimal solution if it is available in the table(array)
//By checking if is more than zero to make the decision
if(*(r+n)>=0)
{
return *(r+n);
}
if(n==0)
{
q=0;
}
else
{
// The program will check the cut point from the right-hand side
// instead of left-hand side
q = INT_MIN;
for (i = 1; i <= n; i++) //p={0,1, 5, 8, 9, 10, 17, 17, 20, 24, 30};
{
q = max_revenue(q, p[i] + cut_rod_aux(p, r, n - i));
}
}
//Record the optimal solution in order for looking up in the beginning of program
r[n]=q;
return q;
}
int max_revenue(int r1, int r2)
{
return(r1>r2?r1:r2);
}
#endif
- Bottom-up 方式的查询
一般情况下,采用Bottom-up方式对最优解(optimal solution)进行保存,形式上会更为优雅,但是理解与程序实施上会比较困难,一则是需要找到最底层节点的位置,二则循环层数至少比top-down多1层。但是,许多程序仍然选择bottom-up形式,此行可以节省递归调用过程中,对函数栈管理和调用的额外开销,减少不必要的栈管理麻烦。bottom-up方式仅需要一个函数即可实现程序目的,程序简洁而紧凑。
/**
* @file cut_rod_bottomup.c
* @author your name (you@domain.com)
* @brief
* @version 0.1
* @date 2023-02-19
*
* @copyright Copyright (c) 2023
*
*/
#ifndef CUT_ROD_BOTTOMUP_C
#define CUT_ROD_BOTTOMUP_C
#include "cut_rod_bottomup.h"
void cut_rod(int *p, int *r,int n)
{
//from smaller subproblem to the bigger original problem solution
int j;
int i;
int q;
r[0]=0; //bottom value defintion, all the iterative will be based on this number
for(j=1;j<=n;j++) //Listing the rod from small to big with regard to rod length
{
q=INT_MIN;
for(i=1;i<=j;i++) // it should include j index(right hand rule)
{
q=max_revenue(q,p[i]+r[j-i]);
}
r[j]=q;
}
return;
}
int max_revenue(int r1, int r2)
{
return(r1>r2?r1:r2);
}
#endif
再次就不赘述函数的实现过程,请读者对比代码自行理解。
总结:
本文对rod-cutting 问题的动态规划的两类实现方式进行探索,加深对动态规划的理解,由于初学动态规划,难免存在理解瑕疵和表述不足,希望多多指正。
参考文献:
- 《Introduciton to Algorithm, 4ed, Edition》