背包问题与动态规划
最近我一直在复习面试算法的系列问题,其中动态规划一直是我需要加强的薄弱环节。本文是我在学习动态规划相关算法问题时的笔记和总结。
动态规划是一种解决复杂问题的算法方法,它的思想是将一个大问题分解为多个小问题,并且将这些小问题的解保存在一个表格中,以便之后的使用。动态规划常用于优化问题,其目标是找到满足一定限制条件下的最优解。它在计算机科学中有着广泛的应用,例如图像处理、自然语言处理、人工智能等领域。
背包问题是动态规划问题中的一个经典问题,它的经典场景为:给定一个背包和一些物品,每个物品有其对应的价值和重量,需要决定如何将这些物品装入背包中,使得背包中物品的总价值最大化,同时不能超过背包的承载能力。
而 01 背包问题是背包问题中的一个基础问题。它的场景为:一个旅行者需要在旅行时携带一个背包,背包只能装载一定重量的物品。旅行者需要从一堆物品中选择一些物品装入背包,使得背包中物品的总价值最大化。01 背包问题与其他背包问题的一个不同点在于 01 背包问题中,物品只有拿或者不拿两种选择,所以被称之为 01 背包问题。
01 背包问题
接下来我们将通过一个最基本的模板例题来探索 01 背包问题的通用解法和优化思路,以此为基础打开更多更复杂的动态规划问题的大门。
在尝试解题之前,我们先来了解一下动态规划问题的基本解题思路。
动态规划问题通常包括以下几个步骤:
- 划分阶段:将原问题分解为若干个子问题;
- 确定状态:描述每个子问题的状态;
- 确定决策:对每个子问题,确定采取哪些决策是最优的;
- 建立状态转移方程:根据子问题之间的转移关系,建立状态转移方程;
- 通过递推或者递归的方式,求解出问题的最优解。
在我看来,动态规划最难的部分就是在确定状态这一步,在确定了状态之后,如何确定决策步骤和建立状态转移方程就相对容易一些。如何设计出一个合理的状态表达决定了我们能否高效的解决问题,而这一步最好的练习方式就只有大量的训练。
下面我们来尝试着用通用的思路来解决一个 01 背包模板问题。
经典背包问题
有 N 件物品和一个容量是 V 的背包。每件物品只能使用一次。
第 i 件物品的体积是 vi ,价值是 wi。
求解将哪些物品装入背包,可使这些物品的总体积不超过背包容量,且总价值最大。
输出最大价值。
输入格式
第一行两个整数,N,V,用空格隔开,分别表示物品数量和背包容积。
接下来有 N 行,每行两个整数 vi,wi,用空格隔开,分别表示第 i 件物品的体积和价值。
输出格式
输出一个整数,表示最大价值。
数据范围
0<N,V≤10000≤1000
0<vi, wi≤1000
这道例题非常的直白,从题目的题干我们就可以看出这是一道有限集合求最值的问题,适合用动态规划的思路来解决。此外他还规定了每件物品只能使用一次,完美契合了我们 01 背包问题的定义。不过不是每一道 01 背包问题都会如此的直白,我们在练习题部分会尝试解决一道来自 google kickstart 不那么明显的 01 背包问题。
我们来尝试套用一下最基础的解题思路
划分阶段
我们的目标是找出总价值最大的一组物品,所以我们可以将问题划分为多个阶段,每个阶段对应于决定是否将一个特定的物品放入背包。也就是说,对于每一件物品,我们有两个选择:放或不放。
确定状态
我们需要记录两个状态信息:
i:当前考虑到的物品编号,范围是**0 ~ N-1**。v:当前背包的剩余空间,范围是**0 ~ V**。
我们将用**dp[i][v]来表示这个状态,即考虑前i件物品,且背包的剩余空间为v**时,能达到的最大价值。
确定决策
对于每个状态,我们有两个选择:放入第**i**件物品,或者不放。如果我们选择放入,那么背包的剩余空间会减少,但价值会增加;如果我们选择不放,那么背包的空间和价值都不会变化。
建立状态转移方程
我们根据决策结果,可以得到以下状态转移方程:
- 如果不放第**
i件物品,那么dp[i][v] = dp[i - 1][v]**; - 如果放入第**
i件物品,那么dp[i][v] = dp[i - 1][v - vi] + wi,其中vi和wi分别表示第i件物品的体积和价值。注意,这里需要确保v >= vi**。
因此,整体的状态转移方程为:dp[i][v] = max(dp[i - 1][v], dp[i - 1][v - vi] + wi)。
通过递推求解最优解
根据状态转移方程,我们可以从小到大依次计算出所有的**dp[i][v]。注意到每个状态只依赖于前一个状态,所以我们可以通过两层循环,外层遍历物品,内层遍历背包容量从大到小,来计算出所有的状态。最后,dp[N][V]**就是我们要求的答案。
代码实现
接下来我们将根据以上思路写出一个基本的代码框架(go 语言实现)
package main
import (
"fmt"
"math"
)
func main(){
//数据规模不大于1000,方便起见我们使用1010
const N = 1010
value := make([]int,N)
volume := make([]int,N)
var dp [N][N] int
var n,v int
fmt.Scan(&n,&v)
// 读入数据
for i := 1;i <= n;i++{
fmt.Scan(&volume[i],&value[i])
}
// 从1开始遍历,省却一些处理边际问题的麻烦
for i := 1; i <= n;i++{
for j := 0 ; j <= v;j++{
//不选第i个物品的情况
dp[i][j] = dp[i-1][j]
if j >= volume[i]{
dp[i][j] = int(math.Max(float64(dp[i][j]), float64(dp[i-1][j-volume[i]] + value[i])))
}
}
}
// f[n][v]就是我们要求的答案
fmt.Print(dp[n][v])
}
代码已经写完了,现在我们简单分析一下上述实现的时间和空间复杂度。首先是时间复杂度,除了读取数据之外,我们有两层嵌套循环,所以时间复杂度为 n _ v。关于空间复杂度,为了存储我们的动态规划计算表格,我们开辟了一个 n _ n 的空间。实际上,我们只需要 n _ v 的空间就足够了。在上述解法中,为了节省时间并处理一些边界情况,我们直接使用了 n _ n 的空间。
这种类型的问题一般被称为”伪多项式时间”问题,因为它的时间复杂度并不是基于输入的大小,而是基于输入的数值。在这个问题中,输入的大小是 N(物品的数量)和 V(背包的容量),但时间复杂度是基于这两个输入的数值的乘积。
代码优化
通常情况下,如果我们已经达到了时间复杂度为 n * v,很难在时间复杂度上做更多的优化,因为我们需要遍历所有可能性来确定最优解。在这种情况下,我们可以更多地考虑空间上的优化,这种常见的优化方法通常被称为状态压缩。
观察一下上面的解法,为了做到这一点,我们需要注意到,dp[i][j] 只依赖于上一行dp[i-1][*] 的值。所以,我们只需要保留一行 dp 数组,然后逆序计算 dp[j]。为什么要逆序计算呢?这是因为在计算 dp[j] 的时候,我们需要用到 dp[j-vi] 的值,如果我们正序计算,那么 dp[j-vi] 的值就已经在当前这一行被更新过了,它表示的是当前这一行的值,而不是上一行的值。如果我们逆序计算,那么在计算 dp[j] 的时候,dp[j-vi] 还没有被更新,它仍然表示的是上一行的值。
package main
import (
"fmt"
"math"
)
func main(){
//数据规模不大于1000,方便起见我们使用1010
const N = 1010
value := make([]int,N)
volume := make([]int,N)
dp := make([]int, N)
var n,v int
fmt.Scan(&n,&v)
// 读入数据
for i := 1;i <= n;i++{
fmt.Scan(&volume[i],&value[i])
}
// 从1开始遍历,省却一些处理边际问题的麻烦
for i := 1; i <= n;i++{
//逆序计算
for j := v ; j >= volume[i];j--{
dp[j] = int(math.Max(float64(dp[j]), float64(dp[j-volume[i]] + value[i])))
}
}
// dp[v]就是我们要求的答案
fmt.Print(dp[v])
}
总结
在本篇博客中,我们深入探讨了动态规划,特别是 01 背包问题,这是动态规划中的一种经典问题。我们首先介绍了动态规划的基本概念,包括如何划分问题阶段、确定状态、确定决策,以及建立状态转移方程等关键步骤。随后,我们通过一个具体的例子,详细展示了如何运用这些步骤来解决 01 背包问题,包括了如何进行空间优化以提升程序的效率。通过学习和理解这个问题,我们可以更好地理解动态规划的内在逻辑,为解决更复杂的动态规划问题打下坚实的基础。下一篇文章我们会透过一个不那么直观的例子来巩固我们对 01 背包这个简单的动态规划问题的理解。