1. 内容
- Fibonacci 数列中的递归问题:递归树;递归出口的设计;存储记忆计算过的递归子问题结果,可以大大降低程序的时间复杂度;对递归树从下而上递归计算 Fibonacci 数列。
- 0/1背包问题:动态规划(借助Fibonacci数列中的概念解释动态规划;动态规划的实质是“存储记忆 + 递归”;自下而上递归实现(迭代法);
- 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(n−1)+Fib(n−2),n≤2n>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):
由此可见,每当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)之后的子树都被砍掉不再计算:
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 数列小节
关于计算方向:
- 图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)
- 而自下而上的递归法,即迭代法。与上面求解方向相反。从递归出口开始往上计算,即从子问题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 样例
通过输入样例来说明 递归+存储记忆优化解法解法的思路过程。主要内容是两部分:子问题划分、子问题递归求解。
最关键一步:如何将问题“将哪些物品装入背包,可使这些物品的总体积不超过背包容量,且总价值最大。” 分解为一系列子问题。对问题分解要满足如下要求:
子问题划分要求:
- 每个子问题的最优解之和是整个问题的全局最优解。
- 子问题有时间先后顺序,且当前子问题的解决只受到前一个子问题的影响。即无后效性,也是所谓的马尔科夫性。
3.3 子问题划分
定义1::假设 f i ( y ) f_i(y) fi(y)表示背包剩余容量为y,剩余物品是第i,i+1,…, n时,能装入背包的物品最大总价值。
这个是0/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)是要求解的问题。
- 子问题: 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)是问题分解成的子问题。
- f i ( y ) f_i(y) fi(y)中的决策只包含第i个物品,即选或是不选第i个物品,不考虑第i+1, …,n个物品。
- f i ( y ) f_i(y) fi(y)中最大总价值的理解是问题的关键,初学很难。其图形化理解较为容易,请移步下文的图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)到节点递归出口所有路线中,收益之和最大那条路线的总收益。其他的同理可得。
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,y≥volume[n]0≤y<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(y−volume[i])+profit[i]},fi+1(y),y≥volume[i]0≤y<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/