必须了解的编程基础:动态规划 -- 从Fibonacci数列到0/1背包问题

本文深入探讨了编程基础中的动态规划,通过Fibonacci数列和0/1背包问题进行阐述。首先介绍了Fibonacci数列的递归法、存储记忆优化的递归法和自下而上的递归法(迭代法)。接着详细讲解了0/1背包问题,包括问题描述、子问题划分和两种解法:存储记忆+递归法及自下而上的递归法。文章提供了递归树和决策过程的图形化理解,并强调了动态规划的本质是存储记忆与递归的结合。
摘要由CSDN通过智能技术生成

1. 内容

  1. Fibonacci 数列中的递归问题:递归树;递归出口的设计;存储记忆计算过的递归子问题结果,可以大大降低程序的时间复杂度;对递归树从下而上递归计算 Fibonacci 数列。
  2. 0/1背包问题:动态规划(借助Fibonacci数列中的概念解释动态规划;动态规划的实质是“存储记忆 + 递归”;自下而上递归实现(迭代法);
  3. 0/1背包代码的正确性,可以在参考文献[4]上得到验证。

2. Fibonacci 数列

Fibonacci数列是:1,1,2,3,5,8,…
F i b ( n ) = { 1 , n ≤ 2 F i b ( n − 1 ) + F i b ( n − 2 ) , n > 2 Fib(n)=\left\{ \begin{aligned} 1, && n \leq 2 \\ Fib(n-1) + Fib(n-2), && n > 2 \\ \end{aligned} \right. Fib(n)={1,Fib(n1)+Fib(n2),n2n>2
Fibonacci 数列的问题是求Fib(n)的值, 而Fib(1), Fib(2), …, Fib(n-1)都是其子问题。

2.1 递归法、递归树和存储记忆优化的递归法

下面是最原始的递归解法,时间复杂度最大,在编程中要极力避免。

// -1- 递归方法实现fibonacci数列
int fib(int n){    
    // 从1开始编号    
    if(n <= 2)     // 递归出口
        return 1;       
    else 
        return fib(n-1) + fib(n-2);
}	

用递归树表示递归法计算Fib(5):

图1 Fib(5)的递归树
蓝色部分表示递归出口。当求F(6)时,递归树增加了近一倍。增加部分是黄色框内的F(6)的右子树。
图2 Fib(6)的递归树

由此可见,每当n增加1,递归树的增加近2倍,因此,F(n)的时间复杂度近似为 Θ ( 2 n ) \Theta (2^n) Θ(2n),指数级别,方法不可取。

由图2可以分析得到,时间复杂度庞大的原因是,n每增加1,就出现一个需要重复计算的子树(黄色框内)。
如果新增加的重复子树(黄色框内)可以调用之前已经计算过的结果,那么,n每增加1,而新增的时间复杂度就为 Θ ( 1 ) \Theta (1) Θ(1),那么Fibonacci的时间复杂度将由 Θ ( 2 n ) \Theta (2^n) Θ(2n)降为 Θ ( n ) \Theta (n) Θ(n)

使用数组存储中间过程结果(哈希结构,查找复杂度为 Θ ( 1 ) \Theta (1) Θ(1)),程序如下:

const int N = 1010;
int FibArray[N];   // 初始化为0.
int fibDP(int n)
{    
    // 是否计算过    
    if(FibArray[n] != 0)        
        return FibArray[n];    
    // 没有计算过    
    if(n <= 2){        
        FibArray[n] = 1;        
        return FibArray[n];    
    }            
    else{        
        FibArray[n] =   fibDP(n-1) + fibDP(n-2);        
        return FibArray[n];        
    }     
}

上述程序反应到递归树上的情况就是,如图3,两个黄色框之外的红色节点是需要计算的,而黄色框内的根节点F(3)和F(4)是通过递归结果的存储数组FibArray[i]调用对应的值, 并且F(3)和F(4)之后的子树都被砍掉不再计算:

图3 存储记忆优化的Fib(6)的递归树(黄框内根节点F(3),F(4)从数组调用,无须再递归计算)

2.2 自下而上的递归法(又叫迭代法)

个人认为,所谓的迭代法的称呼并没有反映其实质,准确的叫法应该是“自下而上的递归”。 从递归树的底层(蓝色节点, 即递归出口),往递归树的上面开始迭代计算。该方法与图3的区别在于,递归方向不同,图3是先自上而下递归到递归出口,然后再回溯到函数调用处。 其余基本相同。也就是说该方法也需要数组(或其他哈希结构)存储中间过程结果。
代码如下:

int BottomUpDP(int n)
{    
    int f[n + 1];    
    // 从递归树的底部往上计算,即i从小到大。    
    for(int i = 0; i <= n; i ++){        
        if(i <= 2) f[i] = 1;        
        else  f[i] =  f[i-1] + f[i-2];    
    }    
    return f[n];
}

2.3 Fibonacci 数列小节

关于计算方向:

  1. 图3代表的存储记忆优化的递归法求解方向是从问题子问题i-1、子问题i-2、…、子问题1,其中子问题1就是递归出口。在Fibonacci数列上就是 Fib(n) -> Fib(n-1) -> … -> Fib(3)-> {Fib(2), Fib(1) } -> Fib(3) … -> Fib(n-1) -> Fib(n)
  2. 而自下而上的递归法,即迭代法。与上面求解方向相反。从递归出口开始往上计算,即从子问题1、子问题2、…、子问题i-1问题。在Fibonacci数列上就是 {Fib(1), Fib(2) } -> … -> Fib(n-1) -> Fib(n)

3. 0/1背包问题

3.1 问题描述

有 num_of_objects 件物品和一个承重是knapsack_capacity 的背包。每件物品只能使用一次。第 i 件物品的体积是 volume[i],价值是 profit[I]。求解将哪些物品装入背包,可使这些物品的总体积不超过背包容量,且总价值最大。
输出最大价值。
输入格式:
第一行两个整数,num_of_objects,knapsack_capacity,用空格隔开,分别表示物品数量和背包容积。
接下来有 num_of_objects 行,每行两个整数 volume[i], profit[i],用空格隔开,分别表示第 i件物品的体积和价值。
输出格式:
输出一个整数,表示最大价值。
输入样例
4 5
1 2
2 4
3 4
4 5
输出样例:
8
问题出处:https://www.acwing.com/problem/content/2/

3.2 样例

通过输入样例来说明 递归+存储记忆优化解法解法的思路过程。主要内容是两部分:子问题划分、子问题递归求解。

图4 0/1背包例子

最关键一步:如何将问题“将哪些物品装入背包,可使这些物品的总体积不超过背包容量,且总价值最大。” 分解为一系列子问题。对问题分解要满足如下要求:
子问题划分要求

  1. 每个子问题的最优解之和是整个问题的全局最优解。
  2. 子问题有时间先后顺序,且当前子问题的解决只受到前一个子问题的影响。即无后效性,也是所谓的马尔科夫性。

3.3 子问题划分

定义1::假设 f i ( y ) f_i(y) fi(y)表示背包剩余容量为y,剩余物品是第i,i+1,…, n时,能装入背包的物品最大总价值。

这个是0/1背包问题的子问题的划分方式之一,还有其他的划分方式。篇幅有限,不做介绍。

定义1说明:

  1. 问题: f 1 ( y ) f_1(y) f1(y)表示从第1,2,3,4个物品中选择,使得放入容量是y的背包中的最大总价值。这个是我们要求解的问题结果。3.2.1样例的0/1背包问题是确定 f 1 ( 5 ) f_1(5) f1(5)的值是多少。即 i = 1 i=1 i=1时, f 1 ( y ) f_1(y) f1(y)是要求解的问题。
  2. 子问题: f 2 ( y ) f_2(y) f2(y)表示从第2,3,4个物品中选择,使得放入容量是y的背包中的最大总价值。即 i > 1 i > 1 i>1时, f 1 ( y ) f_1(y) f1(y)是问题分解成的子问题。
  3. f i ( y ) f_i(y) fi(y)中的决策只包含第i个物品,即选或是不选第i个物品,不考虑第i+1, …,n个物品。
  4. f i ( y ) f_i(y) fi(y)最大总价值的理解是问题的关键,初学很难。其图形化理解较为容易,请移步下文的图5.
  5. 理解前四个说明后,很容易明白,定义1的划分满足子问题划分要求

下面通过图5来说明定义1下样例中0/1背包问题的决策过程
其中,

  • 节点 F i F_i Fi的左节点 F i + 1 F_{i+1} Fi+1或者权重为0的子节点 F i + 1 F_{i+1} Fi+1表示的是不选第i个物品。
  • 节点 F i F_i Fi的右节点 F i + 1 F_{i+1} Fi+1或者权重不为0的子节点 F i + 1 F_{i+1} Fi+1表示的是选第i个物品。
  • 两个节点 F i F_{i} Fi F i + 1 F_{i+1} Fi+1之间的权重表示对第i个物品决策(选或是不选)带来的价值收益。
  • f 1 ( 5 ) f_1(5) f1(5)定义1下的0/1背包问题是确定 f 1 ( 5 ) f_1(5) f1(5)的值,等价于是从图5的节点 F 1 ( 5 ) F_1(5) F1(5)到节点递归出口所有路线中,收益之和最大那条路线的总收益。
  • f 2 ( 4 ) f_2(4) f2(4):是指从节点 F 2 ( 4 ) F_2(4) F2(4)到节点递归出口所有路线中,收益之和最大那条路线的总收益。其他的同理可得。
图5 定义1下0/1背包问题的决策过程

3.4 样例解决方法1 – 存储记忆+递归法

到目前为止,样例中的背包问题转化为求 f 1 ( 5 ) f_1(5) f1(5)值的问题。 f 1 ( 5 ) f_1(5) f1(5)递归求解过程的递归树如图5所示。用数学公式表达:
递归出口
f n ( y ) = { p r o f i t [ n ] , y ≥ v o l u m e [ n ] 0 , 0 ≤ y < v o l u m e [ n ] f_n(y)=\left\{ \begin{aligned} profit[n], && y \geq volume[n] \\ 0, && 0 \leq y < volume[n] \\ \end{aligned} \right. fn(y)={profit[n],0,yvolume[n]0y<volume[n]
其中,n是物品的数量,是个常量。y是背包剩余容积,是个变量。
状态转移部分
f i ( y ) = { m a x { f i + 1 ( y ) , f i + 1 ( y − v o l u m e [ i ] ) + p r o f i t [ i ] } , y ≥ v o l u m e [ i ] f i + 1 ( y ) , 0 ≤ y < v o l u m e [ i ] f_i(y)=\left\{ \begin{aligned} max\{f_{i+1}(y), f_{i+1}(y-volume[i])+profit[i]\}, && y \geq volume[i] \\ f_{i+1}(y), && 0 \leq y < volume[i] \\ \end{aligned} \right. fi(y)={max{fi+1(y),fi+1(yvolume[i])+profit[i]},fi+1(y),yvolume[i]0y<volume[i]
和2.1一样,使用带有内存存储记忆的递归方法实现上述递归公式:
这个就是动态规划, 存储记忆 + 递归 = 动态优化, 其中的关键就是子问题划分部分,也就是如何发现动态优化中的递归内容。

#include<algorithm>
#include<iostream>
#include<vector>
using namespace std;
const int N = 1010;
// 保存f(i, theCapacity)的结果
int fArray[N][N];   
int num_of_objects, knapsack_capacity;
int volume[N], profit[N];
int DP_f(int i, int theCapacity){            
    // 检查结果是否已经计算过        
    if(fArray[i][theCapacity] >= 0)                
        return fArray[i][theCapacity];    
    // 递归出口        
    if(i == num_of_objects){                
         fArray[i][theCapacity] = (theCapacity < volume[num_of_objects] ? 0 : profit[num_of_objects]);                                                
         return  fArray[i][theCapacity];        
         }    
     // 状态转移        
     if(theCapacity < volume[i])        
         // 无法装入物品i                
         fArray[i][theCapacity] = DP_f(i + 1, theCapacity);        
     else{            
     // 物品i可以装入背包            
     // 两种决策方式                
     fArray[i][theCapacity] = max( DP_f(i + 1, theCapacity),DP_f(i + 1, theCapacity - volume[i]) + profit[i]);                                       
     }    
     return  fArray[i][theCapacity]; 
}
int main()
{           
    // -1- 输入部分        
    // 物品数量和背包容积        
    cin >> num_of_objects >> knapsack_capacity;        // 物品的体积和价值   
    for(int i = 1; i <= num_of_objects; i ++) cin >> volume[i] >> profit[i];       
    // -2- 动态规划 -- 递归 + 内存存储子问题结果: f(1, knapsack_capacity) 是最终的结果。                
    // 初始化存储结果的数组值为-1        
    for(int i = 0; i <= num_of_objects; i ++)                
        for(int j = 0; j <= knapsack_capacity; j ++)                        
            fArray[i][j] = -1;        
    int res = DP_f(1, knapsack_capacity);        
    cout << res << endl;        
    return 0;
}

3.5 样例解决方法2 – 自下而上的递归解法

参考2.2 自下而上的递归法(又叫迭代法), 设计自下而上的递归解法。以样例题的递归树图5为例,因为要从递归出口处开始逐层往上计算,因此第一个for循环要表示这种计算方法,即从最大的物品序号开始降序循环。对于递归树的每一层而言,背包的剩余容量y变化范围是 [ 0 , 5 ] [0, 5] [0,5],因此第二个for循环要实现背包容量的遍历。而状态转移部分和上面程序相似。

#include<algorithm>
#include<iostream>
#include<vector>
using namespace std;
const int N = 1010;
int f[N][N];
int num_of_objects, knapsack_capacity;
int volume[N], profit[N];
int KPDP(int num_of_objects, int knapsack_capacity)
{    
   for(int i = num_of_objects; i >= 1; i --)   // 从递归出口开始迭代。        
       for(int theCapacity = 0; theCapacity <= knapsack_capacity; theCapacity ++){
       // 递归出口            
       if(i == num_of_objects) 
           f[i][theCapacity] = (theCapacity < volume[num_of_objects]? 0 : profit[num_of_objects]);                                        
       else{            
       // 状态转移部分                            
           f[i][theCapacity] = f[i + 1][theCapacity];                
           if(theCapacity >= volume[i])                    
              f[i][theCapacity] = max(f[i + 1][theCapacity], f[i + 1][theCapacity - volume[i]] + profit[i]); 
        }                    
    }
    return f[1][knapsack_capacity];  
}
int main(){    
    // -1- 输入部分    
    // 物品数量和背包容积    
    cin >> num_of_objects >> knapsack_capacity;    
    // 物品的体积和价值    
    for(int i = 1; i <= num_of_objects; i ++) cin >> volume[i] >> profit[i];
    // -2- 动态规划  -- 从递归树的底往上计算,使用二维数组存储递归结果 --> f[1,knapsack_capacity]中是最终结果    
    cout << KPDP(num_of_objects, knapsack_capacity) << endl;    
    return 0;
}

代码的正确性,可以在参考文献[4]上得到验证。

参考文献

[1] 萨特吉 ⋅ \cdot 萨尼《数据结构、算法与应用》第2版.机械工业出版社.
[2] https://www.bilibili.com/video/BV18x411V7fm?t=1263
[3] https://www.bilibili.com/video/BV1qt411Z7nE?from=search&seid=327826239589966617
[4] https://www.acwing.com/problem/content/2/

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值