算法Balabala背包问题

前言

前文用较多篇幅介绍了动态规划算法的套路,以后的文章可能就不用这么多篇幅描述详细的思考过程。

现在再来解决一个更加实际的问题,背包问题

背包问题

给你一个背包,它的最大可承载的重量是W kg。 另外,再给你N个物品,每个物品有它自己的重量(kg)和价值($)。

:得出该背包可以装载的最大价值的物品组合。

显然,这是一个求最值的问题,它求的是在所有允许的物品组合之下的最大价值是多少?

基于这个题目,我设定:

  • 背包容量W = 18 kg

N 个物品,用容量为N的数组来表示:

  • N个物品的各自重量 = [1,4,9,7,2] kg

  • N个物品的各自价值 = $ [3,6,8,1,5]

先用肉眼来看一下这个问题的思考方式:

一共只能装18kg,那么,我现在,

选择装入重量为 1+9+7 = 18 kg的三件物品,

其对应的价值总数为:3+8+1 = $12

这便是其中一种物品组合,选择重量为 1,9,7 的物品,总价值为$12.

如果我们可以求出所有的这种组合方式,那就可以直接得出最大价值的物品组合

状态转移方程

要列出状态转移方程,首先要明确状态是什么?

这个问题中的状态是,只允许对前几件物品进行选择,以及 背包允许的总重量 ,要求的最优解是 最大物品价值

这里状态有两个,之前凑零钱问题的状态只有一个,那就是 零钱总额, 现在这里有两个状态,情况复杂了一倍。我选择使用二维数组来表示 本问题的最优解。

f(a,w) = v( 0 < a <= n )

  • a 表示,只允许对前i件物品进行选择。
  • n 表示 物品的总件数。
  • w 表示,背包允许的总重量。
  • v 这种情况下的最大物品价值。

根据这个定义,我们要求的最终答案就是: f(n,w)

根据这个定义写出伪代码

val f[n][w]
val wt = [1,4,9,7,2]// 所有物品的各自重量
val vt = [3,6,8,1,5]// 所有物品的各自价值
f[0][]=0 // 物品数目为0,最有价值自然就是0,因为没有物品
f[][0]=0 // 背包最大重量为0,装不下任何物品,最大价值也是0

// 双重遍历,列举所有可能情况,所有物品的都要面临放进去,和不放进去的选择
for(a in 1..n){// 遍历所有物品
    for(x in 1..w){// 遍历所有重量 
        f[a][x]=max(//择优求较大值
            不把物品a装进背包,
            把物品a装进背包
        )
    }
}

解读上面的伪代码

物品一共n件,重量一共w,外层循环 从1一直遍历到n,内层循环,x从1遍历到w,最大价值进行max择优求较大值

接下来的问题就是,如何将 不把物品a装进背包 以及 把物品a装进背包, 转化成伪代码

思考,

如果没有把 物品a装进背包,那么,按照 f(a,w)的定义,最大价值应该等于 f(a-1,x), 因为少了一个第a件物品。

如果再把物品a装进背包,那么,此时的最大价值应该等于,去掉这件物品时的最大价值,加上,这件物品的价值, 也就是:f(a-1,x-wt[a])+vt[a]

再整合一下伪代码,就变成了:

val f[n][w]
val wt = [1,4,9,7,2]// 所有物品的各自重量
val vt = [3,6,8,1,5]// 所有物品的各自价值
f[0][]=0 // 物品数目为0,最优价值自然就是0,因为没有物品
f[][0]=0 // 背包最大重量为0,装不下任何物品,最大价值也是0

// 双重遍历,列举所有可能情况,所有物品的都要面临放进去,和不放进去的选择
for(a in 1..n){// 遍历所有物品
    for(x in 1..w){// 遍历所有重量 
        f(a,x)= max(//择优求较大值
            f(a-1,x),
            f(a-1,x-wt[a])+vt[a]
        )
    }
}

如果到了这里还有点晕,举个实例:

  • 按照上面的伪代码,当a=1(只允许选第一件物品),且x=1时,

    f(1,1) = max(
      f(0,1),
        f(0,1-wt[1])+vt[1]
    )
    

    得到

    f(1,1) = max(0,3) = 3
    
  • 而当a=2(允许选择前两件物品是否放入背包),x=1

    f(1,1)=3
    f(2,1)=max(
      f(1,1),
        f(1,1-1)+vt(2)
    ) = max(3,6) = 6
    

当a=2,其实前面已经计算出了f(1,1)可以直接使用。

以此类推,内外双重循环足以列举出所有物品,在所有最大重量下的最优总价值。

思路已经明确了,那么可以开始写状态转移方程了吧。

但是臣妾做不到 = =!不是我不想,是我不知道怎么用数学公式来表示双重循环,临时去研究word数学公式又觉得划不来,所以算了吧。上面的伪代码已经可以代表完整的解题思路了,接下来只需要把伪代码换成真正的可执行kotlin代码就行了。

开始撸码

var wt = intArrayOf(1, 4, 9) // 物品各自重量
var vt = intArrayOf(3, 6, 8) // 物品各自价值

var totalWeigh = 10 // 要求的总重量
var res = Array(wt.size + 1) { IntArray(totalWeigh + 1) } // 结果二维数组

/**
 * 因为当最大重量是0时,或者当物品前N件物品,N=0时,都没有意义。所以此时的最优价值都是0
 */
fun initRes() {
    for (i in wt.indices) {
        for (j in 0 until totalWeigh) {
            if (i == 0 || j == 0) {
                res[i][j] = 0
            }
        }
    }
}

fun cal(): Int {
    initRes()
    for (a in 1..wt.size) {
        for (x in 1..totalWeigh) {
            val before = res[a - 1][x] // 不放进去的值
            if (x - wt[a - 1] < 0) { // 背包容量比物品重量还要小,那就不能放进去
                res[a][x] = before
            } else {
                val now1 = res[a - 1][x - wt[a - 1]]
                val now2 = vt[a - 1]
                res[a][x] = max(before, now1 + now2)
            }
        }
    }
    return res[wt.size][totalWeigh]
}


fun main() {
    val res = cal()
    println(res)
}

运行结果:

11

此结果表示:

在物品各自重量(1, 4, 9)kg,各自价值(3, 6, 8), 且,要求的总重量不超过10kg的前提下,能够达成的最大价值是11

问题解决。

时间/空间复杂度

  • 子问题的个数

    假设物品数组的size是N,背包总重量是:W*N,因为存在双重遍历

  • 解决一个子问题花费的时间

    只有一次max运算,所以花费时间1

  • 解决一个子问题花费的空间

    每一个子问题都要记录一次 res [a] [x]····所以空间1

时间复杂度 O(WN) , 空间复杂度 O(WN)

问题变形

上面的背包问题的算法,说到底还是数学思维转变成程序代码,其本质还是数学。然而数学思维,解决的并不是一个问题,而是一类问题。比如下面的问题:

子集分割问题

给定一个正整数的数组 , 问,能否把这个数组分隔成两个子集,使得两个子集中元素之和相等。

例如:

数组为:[1,5,11,5],输出:可以分割为两个子集[11],[1,5,5]

数组为:[1,2,3,5], 输出,无法分隔为两个元素之和相等的子集.

这个问题看似与本文的背包问题毫无关联,但是,如果换一种形式来说明:

给定一个承重为 sum/2 的背包,以及n件物品,每一件物品都有它的重量,是否存在一种方法,用这些物品刚好填满背包。

这恰好就是一个标准的背包问题,比上一节的背包问题还简单一些.

状态转移方程

明确状态:

一共有n件物品可以选择,第一个状态就是 前N件物品可供选择。第二个状态是,背包的承重W。每一个物品都面临两种选择,装进背包不装进背包.

背包问题的套路, 每一个状态都由一个数组维度表示,多重循环所有状态,然后对结果择优:

本题的结果值表示成: f [i] [j] = x

f 表示函数,i 表示前i件物品可供选择,j 表示背包可承重。x 表示是否有刚好填满背包的方法,值 true/false

如,f [4] [11] = true ,则表示,用前4件物品,背包承重为11,存在刚好填满背包的方法。

我们最终要求的答案就是:f [N] [sum/2] = ?

明确了状态,那么状态是如何转移的?

f [i] [j] = x 表示的是前i个物品可以选择,总承重为j,是否存在刚好填满的方法。

状态是存在依赖性的,后一个状态,会依赖于前一个状态。

思考

那么如果不把第i个物品装入背包,背包承重仍然是 j,是否仍然存在刚好填满的方法? 这取决于 f [i-1] [j]

如果我把第i个物品装入背包,是否存在刚好填满的方法, 写成数学式子:f [i-1] [ j - wt[i] ] (wt是一个数组,wt[i] 表示第i个物品的重量)

举个实例

假设物品的重量是 [1, 3, 4, 6, 6], 那么 sum/2 = (1+3+4+6+6) = 10 ,背包承重是10.

基准状态:f [1] [0] = true 表示,使用前1个物品,背包承重为0,一定有办法可以装的下,那就是,不放入任何物品

要求:f [1] [1] ,由于第一个物品的重量是1,背包承重也是1,所以,放的下去,

f [i-1] [ j - wt[i] ] 替换成实际值:f [1] [1] = f[1-1] [1-1] = f [0] [0] = true, 表示,使用前1个物品,存在刚好装满背包的方法。

求 : f [1] [2] , 第一个物品的重量是1,背包总重是 2,所以放得下,f [1] [2] = f [1-1] [2-1] = f [0] [1] = false ,不存在刚好装满背包的方法。这也是基准情况的一种,没有任何物品,背包容量大于0,肯定是装不满背包的,所以结果是 false.

f [2] [1] 第二件物品的重量是3,背包总承重是 1,所以放不下第二件物品,所以f [2] [1] = f [1] [1] = true. 也就是说,第二件物品相当于没有参与计算。

f [2] [3] 背包总承重是3,放得下第二件物品,所以 f [2] [3] = f [1] [3-3] = f [1] [0] = true , 所以,存在刚好装满的方法。

撸码

把上面的思路,写成kotlin程序代码,考虑一些特殊情况,比如数组总数是奇数,背包总承重是0 等:

fun canPartition(arr: IntArray): Boolean {
    var sum = 0
    for (num in arr) sum += num
    if (sum % 2 != 0) return false// 和为奇数时,不可能划分成两个和相等的集合
    val n = arr.size
    sum /= 2
    val dp = Array(n + 1) { BooleanArray(sum + 1) }
    // 初始化,除了总重量为0的情况之外,所有的值都变成false
    for (i in dp.indices) {
        for (j in dp[i].indices) {
            dp[i][j] = j == 0
        }
    }

    //开始遍历
    for (i in 1..n) {
        for (j in 1..sum) {
            if (j - arr[i - 1] < 0) { // 背包容量不足,不能装入第 i 个物品
                dp[i][j] = dp[i - 1][j] // 直接忽略第i个物品
                //println("装不下,继承之前的结果:dp[$i][$j] = ${dp[i][j]}")
            } else {// 装的下
                val notPut = dp[i - 1][j] // 如果不装下去,是否存在刚好填满的情况
                val put = dp[i - 1][j - arr[i - 1]] // 如果装下去,是否存在刚好填满的情况
                dp[i][j] = notPut or put // 这两种情况,只要有一种满足刚好填满,就是能够刚好填满
                //println("装得下,继承之前的结果:dp[$i][$j] = ${dp[i][j]}")
            }
        }
    }
    return dp[n][sum]
}

fun main() {
    val s: Boolean = canPartition(intArrayOf(1, 3, 4, 6, 6))
    println("s:$s")
}

执行结果:

s:true

在 正整数数组 1, 3, 4, 6, 6 中,存在平均分割子集的情况。心算一下,[1,3,6] [4,6] . 问题解决。

时间/空间复杂度

  • 子问题的个数

    假设物品数组的size是N,背包总重量是:sum/2,因为存在双重遍历

  • 解决一个子问题花费的时间

    只有一次or运算,所以花费时间1

  • 解决一个子问题花费的空间

    每一个子问题都要记录一次 dp[i][j]····所以空间1

时间复杂度 O(sumN/2) , 空间复杂度 O(sumN/2)

总结

经过这几篇动态规划案例的解析,对于动态规划算法的完整套路基本上可以归纳如下:

  1. 明确状态选择

    像背包问题,状态就是 前N件物品可供选择这里的N,以及 当前背包的承重W,像凑零钱问题,状态就是 当前目标金额, 所谓状态,就是可以推移演变的量,如果最终要求的是 f [N] [W] , 那就让状态推移,有几个状态,就几重遍历,外层遍历从 1 到 N ,内层遍历 从 1 到 W 。

    选择:背包问题的选择, 就是这个物品,放进去,或者 不放进去,这是一个选择。凑零钱问题,该零钱放进去或者不放进去,是一个选择。

  2. 定义结果数组:

    背包问题由于有两个状态在推移,所以,用了二维数组来表示结果 f [N] [W] ,凑零钱问题则只需要 一维数组 f[n] 即可.

  3. 根据 选择,得出状态转移的逻辑

    当前结果: f [N] [W] 是经过状态转移,一步一步,从初始状态 (Base case)f [0] [...] 和 f [..] [0] 慢慢往后推移的出来的。而,背包问题中,在选择 要不要把 当前物品放进去,放进去是一种计算方式,不放进去,是另一种计算方式。按照我们的目标,对不同选择造成的结果 进行择优,保存到结果数组中,就得出了后续的结果的依赖。

    本来我很想写成数学公式的,但是介于数学修养实在有限,只能写伪代码来表现逻辑。

  4. 上面的过程都确认无误的话,最后一步才是编写程序代码

算法的学习,水很深,路漫漫,共勉!

版权声明:
作者:lichengxin
链接:https://www.techfm.club/p/16429.html
来源:TechFM
文章版权归作者所有,未经允许请勿转载。

THE END
分享
二维码
< <上一篇
下一篇>>