动态规划是一种思维方法,没有统一的、具体的模式。动态规划可以从多方面去考察,不同的方面对动态规划有不同的表述。我们不打算强加一种统一的表述,而是从多个角度对动态规划的思维方法进行讨论,希望大家在思维具体问题时,也能够从多个角度展开,这样收获会更大。
逆向思维法是指从问题目标状态出发倒推回初始状态或边界状态的思维方法。如果原问题可以分解成几个本质相同、规模较小的问题,很自然就会联想到从逆向思维的角度寻求问题的解决。
你也许会想,这种将大问题分解成小问题的思维不就是分治法吗?动态规划是不是分而治之呢?其实,虽然我们在运用动态规划的逆向思维法和分治法分析问题时,都使用了这种将问题实例归纳为更小的、相似的子问题,并通过求解子问题产生一个全局最优值的思路,但动态规划不是分治法:关键在于分解出来的各个子问题的性质不同。
分治法要求各个子问题是独立的(即不包含公共的子问题),因此一旦递归地求出各个子问题的解后,便可自下而上地将子问题的解合并成原问题的解。如果各子问题是不独立的,那么分治法就要做许多不必要的工作,重复地解公共的子问题。
动态规划与分治法的不同之处在于动态规划允许这些子问题不独立(即各子问题可包含公共的子问题),它对每个子问题只解一次,并将结果保存起来,避免每次碰到时都要重复计算。这就是动态规划高效的一个原因。
动态规划的逆向思维法的要点可归纳为以下三个步骤:
(1)分析最优值的结构,刻画其结构特征;
(2)递归地定义最优值;
(3)按自底向上或自顶向下记忆化的方式计算最优值。
【例题5】背包问题描述:
有一个负重能力为m的背包和n种物品,第i种物品的价值为v[i],重量为w[i]。在不超过背包负重能力的前提下选择若干个物品装入背包,使这些的物品的价值之和最大。每种物品可以不选,也可以选择多个。假设每种物品都有足够的数量。
分析:
从算法的角度看,解决背包问题一种最简单的方法是枚举所有可能的物品的组合方案并计算这个组合方案的价值之和,从中找出价值之和最大的方案。显然,这种靠穷举所有可能方案的方法不是一种有效的算法。
但是这个问题可以使用动态规划加以解决。下面我们用动态规划的逆向思维法来分析这个问题。
(1)背包问题最优值的结构
动态规划的逆向思维法的第一步是刻画一个最优值的结构,如果我们能分析出一个问题的最优值包含其子问题的最优值,问题的这种性质称为最优子结构。一个问题的最优子结构性质是该问题可以使用动态规划的显著特征。
对一个负重能力为m的背包,如果我们选择装入一个第 i 种物品,那么原背包问题就转化为负重能力为 m-w[i] 的子背包问题。原背包问题的最优值包含这个子背包问题的最优值。若我们用背包的负重能力来划分状态,令状态变量s[k]表示负重能力为k的背包,那么s[m]的值只取决于s[k](k≤m)的值。因此背包问题具有最优子结构。
(2)递归地定义最优值
动态规划的逆向思维法的第二步是根据各个子问题的最优值来递归地定义原问题的最优值。对背包问题而言,有状态转移方程:
/max{s[k-w[i]]+v[i]}(其中1≤i≤n,且k-w[i]≥0)
s[k]= 若k>0且存在1≤i≤n使k-w[i]≥0,
\0 否则。
有了计算各个子问题的最优值的递归式,我们就可以直接编写对应的程序。下述的函数knapsack是输入背包的负重能力k,返回对应的子背包问题的最优值s[k]:
functionknapsack(k:integer):integer;
begin
knapsack:=0;
for i:=1 to n do
if k-w[i]>=0 then
begin
t:=knapsack(k-w[i])+v[i];
if knapsack < t then knapsack:=t;
end;
end;
上述递归算法在求解过程中反复出现了一个子问题,且对每次重复出现的子问题都要重新解一次,这需要多花费不少时间。下面先考虑一个具体的背包问题。例如,当
m=3,n=2,v[1]=1,w[1]=1,v[2]=2,w[2]=2,
图1示出了由调用knapsack(3)所产生的递归树,每一个结点上标有参数k的值,请注意某些数出现了多次。
3
/\
2 1
/\ \
1 0 0
/
0
图1
例如,knapsack(1)被引用了两次:在计算knapsack(3)和knapsack(2)中分别被引用;而knapsack(0)更是被引用了三次。如果knapsack(1)和knapsack(0)每次都要被重新计算,则增加的运行时间相当可观。
下面,我们来看看动态规划是如何解决这个问题的。
(3)按自顶向下记忆化或自底向上的方式求最优解
一般地,如果一个解最优化问题的递归算法经常反复地解重复的子问题,而不是总在产生新的子问题时,我们说该最优化问题包含重迭子问题。这类问题不宜用分治法求解,因为分治法递归的每一步要求产生相异的子问题。在这种情况下,采用动态规划是很合适的,因为该方法对每一个子问题只解一次,然后把解存放在一个表中,以便在解同样的子问题时查阅,充分利用了重迭子问题。一般来说,解决重迭子问题的方式有两种。
①自顶向下的记忆化方式
该方式的程序流程基本按照原问题的递归定义,不同的是,它专门设置了一张表,以记忆在求解过程中得出的所有子问题的解。一个记忆的递归算法为每个子问题的解在表中记录一个表项。初始时每个表项都包含一个特殊值,以示该表项的解有待填入。例如背包问题中:
s[i]=-1;(0≤i≤m)
当在递归算法的执行中第一次遇到一个子问题时(即s[i]=-1),计算其解并填入表中,以后每遇到该子问题,只要查看表中先前填入的值即可。
下面,我们按照自上而下的记忆化方式,写出求背包问题的递归算法。
函数memorized_knapsack输入背包的负重能力k,返回背包问题的解s[k]。s表应设为全局变量,使得函数执行后,传出所有子问题的解s[i](0≤i≤m)。
function memorized_knapsack(k:integer):integer;
begin
if s[k]=-1 then
begin
s[k]:=0;
for i:=1 to n do
if k-w[i]>=0 then
begin
t:=memorized_knapsack(k-W[i])+V[i];
if s[k] < t then s[k]:=t;
end;
end;
memorized_knapsack:=s[k];
end;
我们在主程序通过调用memorized_knapsack(m)即可获得背包问题的解。
显然这种自顶向下记忆化的算法效率比重复解重迭子问题的knapsack算法要强一些。此外,在程序中加入判别哪些子问题需要求解的语句,只解那些肯定要解的子问题,从而节省了时间。
②自底向上的方式
假设我们要解决在分析函数knapsack当中提出的那个具体的背包问题。观察一下在调用memorized_knapsack(4)的过程中,s[k]被计算出来的顺序。我们发现,首先是s[0]被计算出来,然后是s[1],s[2],最后s[3]被计算出来,这与调用的次序正好相反。
动态规划的执行方式是自底向上,所有的子问题计算一次,充分利用重迭子问题。因此,我们很自然就想到与这种自底向上的执行方式相应的采用自底向上的方式递推所有子问题的最优值。
我们知道,计算负重能力为k的背包问题的解仅依赖于计算负重能力小于k的背包问题的解,因此填s表的方式与解决负重能力递增的背包问题相对应:
最初设定负重能力为0的背包问题的解s[0]=0。然后依次考虑负重能力为1,2,…,m的背包问题的解。每填入一个s[k](0≤k≤m)时,充分利用所有重迭子问题的解:s[k-w[i]](0≤i≤n,k-w[i]≥0)
下面是按照自底向上方式计算背包问题的算法流程。过程knapsack_order输入背包的负重能力k,s表设为全局变量。过程执行后所有子问题的解通过s表传给主程序。
procedure knapsack_order(k:integer);
begin
for i:=1 to k do s[i]:=0;
for j:=1 to k do
for i:=1 to n do
if j-w[i]>=0 then
begin
t:=s[j-w[i]]+v[i];
if s[j] < t then s[j]:=t;
end;
end;
我们在主程序调用knapsack_order(m),过程执行后s[m]即为背包问题的解。
最后,我们分析一下背包问题的动态规划解法的复杂度。所需的存储开销主要在s表上,其空间复杂度是O(m)。而整个s表用两重循环求出,循环内语句的执行只需常数时间,因此,时间复杂度是O(m,n)。
自顶向下的记忆化是动态规划的一种变形。动态规划的执行方式是自底向上,因此自底向上的计算方式是与动态规划的执行方式相一致的。它无需递归代价,且维护记忆表的开销也要小些,因此其效率通常好于自顶向下的记忆法。
但是,在动态规划的执行过程中,并不是所有的子问题都要用到它。对某个具体问题而言,可能有大部分子问题的最优值是不必计算的。自底向上的计算方式无法判断那些子问题是需要求解的,所以它将一视同仁地处理所有的子问题,这就可能会把大量的时间都花在计算不必解决的子问题上;而自顶向下的记忆法可以判断那些子问题是需要求解的,只解那些肯定要解的子问题,在这个意义上,自顶向下的算法效率又好于自底向上。所以到底那种方式效率更高,我们要具体问题具体分析。
最后,给出求解背包问题的程序如下:
{//背包问题程序}
program knapsack;
const
maxn=100;
maxm=1000;
var
m,n:integer;
S:array[0..maxm] of integer;
v,w:array[1..maxn] of integer;
{//输入数据}
procedure read_data;
var i:integer;
begin
read(m,n);
for i:=1 to n do read(v[i],w[i]);
end;
{//采用自底向上的方式求解背包问题}
procedure knapsack_order;
var i,j,t:integer;
begin
for i:=1 to m do s[i]:=0;
for j:=1 to m do
for i:=1 to n do
if j-w[i]>=0 then
begin
t:=s[j-w[i]]+v[i];
if s[j] < t then s[j]:=t;
end;
end;
{//主程序}
begin
read_data;
knapsack_order;
writeln(s[m]);
end.
如果想知道选择了哪些物品,那么应将程序作些改动,具体就是对选中的物品做一标记。见参考程序,其中的数据输入采用文件输入,输入文件为bbinput.txt(第1行为背包负重能力和物品种数,第2行为每种物品的价值,第三行为每种物品的重量)。
Reference: Copy from internet