背包问题

背包 DP

1. 0-1 背包问题

DP[i][j] 前i个物品构成的任意一个组合放入到容量为j构成的背包里面所组成的最大的价值.并且每个物品只能选择1次
假设array存储对应数占据的容量,value[i]代表i物品的价值
对于i物品来说,只有两个种状态,选它放入或者不放入,那么前一个使得i物品可以放入的容量状态就是j-array[i]

DP[i][j] =  DP[i-1][j-array[i]] + value[i] , DP[i-1][j] 二者求最大值

DP[i-1][j-array[i]] + value[i]  代表选择i物品放入后得到的价值。

DP[i-1][j]  代表不选择i物品在容量为j的条件下可以得到的价值

/*494. 目标和
给一个整数数组 nums 和一个整数 target 。

向数组中的每个整数前添加 '+' 或 '-' ,然后串联起所有整数,可以构造一个 表达式 :

例如,nums = [2, 1] ,可以在 2 之前添加 '+' ,在 1 之前添加 '-' ,然后串联起来得到表达式 "+2-1" 。
返回可以通过上述方法构造的、运算结果等于 target 的不同 表达式 的数目。

*/
func findTargetSumWays(nums []int, target int) int {
    // 横轴方向上是表达式的运算结果
    // 纵轴方向上是nums的前面i个元素
    //  -5 -4 -3 -2 -1 0 1 2 3 4 5
    // 0 0  0  0  0  1 0 1 0 0 0 0
    // 1 0  0  0  1  0 2 0 1 0 0 0
    // 2 0  0  1  0  3 0 3 0 1 0 0
    // 3 0  0  0  4  0 6 0 4 1 1 0
    // 4 0  0  4  0 10 0 10 1 5 1 1
    // DP[i][j] = max(0,DP[i-1][j-nums[i] ] + DP[i-1][j+nums[i]])
    min,max:=getMinAndMax(nums)
    DP:= make([]map[int]int,len(nums))
    for i:=0;i<len(nums);i++{
        DP[i] = map[int]int{}
        for j:= min;j<max+1;j++{
            DP[i][j]=0
        }
    }
    // 初始化
    DP[0][-nums[0]] =DP[0][-nums[0]]+1
    DP[0][nums[0]] = DP[0][nums[0]] +1

    // 遍历
    for i:=1;i<len(nums);i++{
        for k,_ := range DP[i]{
            if v2,ok:= DP[i-1][k-nums[i]];ok{
                DP [i][k]= v2+DP[i][k]
            }
            if v2,ok:= DP[i-1][k+nums[i]];ok{
                 DP[i][k]= v2+DP[i][k]
            }
        }
    }
    return DP[len(nums)-1][target]
}


func getMinAndMax(nums []int) (int,int){
    min:=0
    max:=0
    for i:=0;i< len(nums);i++{
        min = min - nums[i]
        max = max + nums[i]
    }
    return min,max
}


/*
1049. 最后一块石头的重量 II
有一堆石头,用整数数组 stones 表示。其中 stones[i] 表示第 i 块石头的重量。

每一回合,从中选出任意两块石头,然后将它们一起粉碎。假设石头的重量分别为 x 和 y,且 x <= y。那么粉碎的可能结果如下:

如果 x == y,那么两块石头都会被完全粉碎;
如果 x != y,那么重量为 x 的石头将会完全粉碎,而重量为 y 的石头新重量为 y-x。
最后,最多只会剩下一块 石头。返回此石头 最小的可能重量 。如果没有石头剩下,就返回 0。
*/
func lastStoneWeightII(stones []int) int {
    if len(stones) ==1{
        return stones[0]
    }
    sum:=getSum(stones)
    DP:= make([][]int,len(stones))
    for i:=0;i<len(stones);i++{
        DP[i] = make([]int,sum/2+1)
    }
    //定义 DP[i][j] 代表考虑前 ii 个物品(数值),凑成总和不超过 jj 的最大价值。
    for i:=0;i<sum/2+1;i++{
        if i>= stones[0]{
            DP[0][i] =  stones[0]
        }
    }
    for i:=1;i<len(stones);i++{
        for j:=0;j<sum/2+1;j++{
            // 先保留上一个状态
            DP[i][j] = DP[i-1][j]
            if j-stones[i] >= 0 {
                DP[i][j] = max(DP[i-1][j],DP[i-1][j-stones[i]]+ stones[i] )
            }
        }
    }
    /*
    [
        [0 0 2 2 2 2 2 2 2 2 2 2 2]
        [0 0 2 2 2 2 2 7 7 9 9 9 9]
        [0 0 2 2 4 4 6 7 7 9 9 11 11]
        [0 1 2 3 4 5 6 7 8 9 10 11 12]
        [0 1 2 3 4 5 6 7 8 9 10 11 12]
        [0 1 2 3 4 5 6 7 8 9 10 11 12]

    ]
    */
    return abs ( sum - DP[len(stones)-1][sum/2] - DP[len(stones)-1][sum/2] )
}

func getSum(stones []int) int{
    sum:=0
    for i:=0;i<len(stones);i++{
        sum = sum + stones[i]
    }
    return sum
}

func max(a int , b int)int {
    if a > b {
        return a
    }
    return  b
}

func abs(a int) int{
    if  a>=0{
        return a
    }
    return -a
}

// 为 stonesstones 中的每个数字添加 +/-+/−,使得形成的「计算表达式」结果绝对值最小。
// 进一步转换为两堆石子相减总和,绝对值最小。
// 差值最小的两个堆
// 从 stonesstones 数组中选择,两堆凑成总和不超过 sum/2的最大价值
// DP[i][j]= max(DP[i-1][j],DP[i-1][j-stones[i]] + stones[i]



2.完全背包问题

DP[i][j] 前i个物品构成的任意一个组合放入到容量为j构成的背包里面所组成的最大的价值.每个物品选择多次
假设array存储对应数占据的容量,value[i]代表i物品的价值
对于i物品来说,只有两个种状态,选它放入或者不放入,那么前一个使得i物品可以放入的容量状态就是j-array[i]

DP[i][j] =  max( DP[i-1][j-array[i]*1] + value[i]*1 , DP[i-1][j-array[i]*2] + value[i]*2 ,  DP[i-1][j-array[i]*3] + value[i]*3 .......DP[i-1][j])

DP[i-1][j-array[i]*0] + value[i] *0代表不选择i物品1个放入后得到的价值。
DP[i-1][j-array[i]*1] + value[i] *1 代表选择i物品1个放入后得到的价值。
DP[i-1][j-array[i]*2] + value[i] *2 代表选择i物品2个放入后得到的价值。
DP[i-1][j-array[i]*3] + value[i] *3 代表选择i物品2个放入后得到的价值。
DP[i-1][j-array[i]*4] + value[i] *4 代表选择i物品2个放入后得到的价值。
/*
518. 零钱兑换 II
给一个整数数组 coins 表示不同面额的硬币,另给一个整数 amount 表示总金额。

请计算并返回可以凑成总金额的硬币组合数。如果任何硬币组合都无法凑出总金额,返回 0 。

假设每一种面额的硬币有无限个。

题目数据保证结果符合 32 位带符号整数。

*/
func change(amount int, coins []int) int {
    if amount ==0 {
        return 1
    }
    DP:= make([][]int,len(coins))
    for i:=0; i<len(coins); i++{
        DP[i] = make([]int,amount+1)
    }

    for i:=1;i<amount+1;i++{
        j:=1
        for {
            if i-coins[0]*j == 0{
                DP[0][i]=   DP[0][i]+1
                j++
            }else if i-coins[0]*j<0{
                break
            }else{
                j++
            }
        }
    }
    //fmt.Println(DP[0])
    for i:=1;i < len(coins);i++{
        for j:=0;j < amount+1;j++{

            DP[i][j] = DP[i][j] +  DP[i-1][j]
            k:=1
            // 从选一个该元素开始
            for {
                // 不选择该元素的时候还有以前的
                if j-coins[i]*k <0{
                    break
                }else if j-coins[i]*k > 0{
                    // 选择k个该元素
                    DP[i][j] =  DP[i][j] + DP[i-1][j-coins[i]*k]
                }else{
                    // 选择了k个该元素
                    DP[i][j] =  DP[i][j]+1
                }

                k++
            }
        }
        //fmt.Println (DP[i])
    }
    return DP[len(coins)-1][amount]
    //fmt.Println(DP[0])
    //    0 1 2 3 4 5
    // [0]0 1 1 1 1 1
    // [1]0 1 2 2 3 3
    // [2]0 1 2 2 3 4
    // 1 1 1 1 1
    // 1 2 2
    // 1 1 1 2

}
/*
这里有 n 个一样的骰子,每个骰子上都有 k 个面,分别标号为 1 到 k 。

给定三个整数 n ,  k 和 target ,返回可能的方式(从总共 kn 种方式中)滚动骰子的数量,使正面朝上的数字之和等于 target 。

答案可能很大,需要对 109 + 7 取模 。

*/
func numRollsToTarget(d int, f int, target int) int {
    // 前i个骰子,凑出traget的方法种类数
    // DP[i][j] = DP[i-1][j]
    // DP[i][j] = DP[i-1][j] + DP[i-1][j-1]+1 + DP[i-1][j-2]+1 + DP[i-1][j-3]+1 + DP[i-1][j-k]+1)
    // 每个骰子都扔出最大的数的时候,仍然不满足

    if d*f < target{
        return 0
    }
    if d*f == target{
        return 1
    }
    mod := 1000000007
    DP:= make([][]int,d+1)
    for i:=0;i<d+1;i++{
        DP[i] = make([]int,target+1)
    }

    minTemp:= min(f, target)
    // 初始化
    for i:=1;i<= minTemp;i++{
        DP[1][i] = 1
    }
    // 枚举前i个骰子,并且这前面i个骰子都是要扔的,不存在有的骰子没扔
    for i:=2; i<= d;i++{
        //  枚举 凑成target为j的方法数
        //  不是j
        for j:=0;j<= target ;j++{
            // 可以不选择仍第i这个骰
            // DP[i][j] = DP[i-1][j]
            // 这个骰子必须要扔,加上会报错
            // 该骰仍出k的时候,那么需要的前一个状态为j-k
            for k:=1;k<= f && j-k>=0 ;k++{
                DP[i][j] =   (DP[i][j] + DP[i-1][j-k])% mod
            }
        }
    }

    return DP[d][target]
}

func min(a int , b int) int {
    if a<b {
        return a
    }
    return b
}

3.0-1 背包问题和完全背包问题的压缩优化

DP[i][j] 表示前i个物品组成的任意子集,放入容量为j的背包可以获得的最大的价值
由于每件物品可以被选择多次,因此对于某个  而言,其值应该为以下所有可能方案中的最大值:

DP[i][j] =  DP[i-1][j-array[i]*1] ,value[i]*1 , DP[i-1][j-array[i]*2] +value[i]*2 ,  DP[i-1][j-array[i]*3] +value[i]*3 .......DP[i-1][j] 求最大值

01 背包能够使用「一维空间优化」解法,是因为当我们开始处理第 i 件物品的时候,数组中存储的是已经处理完的第 i-1 件物品的状态值。

然后配合着我们容量维度「从大到小」的遍历顺序,可以确保我们在更新某个状态时,所需要用到的状态值不会被覆盖。 所以状态转移方程是 0-1 背包问题

dp[i][j] = max(dp[i-1][j],dp[i-1][j-array[i]]+value[i])
遍历方向从大到小
压缩优化的方法:
在计算的时候需要确定dp[j-array[i]]的数值,那么就要保证dp[j-array[i]]是计算好的,如果是从小到大遍历j,那么dp[j-array[i]](如果是在二维就代表是上一行的[j-array[i]])下一行的j依赖上一行的j-array[i],但是如果合并为一行,那么j-array[i]依赖j-array[i-1]被更新了,但是j-array[i]还没被更新,所以需要从大到小遍历。

「01 背包将容量维度「从大到小」遍历代表每件物品只能选择一件,而完全背包将容量维度「从小到大」遍历代表每件物品可以选择多次。」

完全背包问题

dp[i][j] = max(dp[i-1][j],dp[i][j-array[i]]+value[i])
遍历方向 从小到大,确保dp[j-array]是被更新的,因为dp[i][j-array[i]]+value[i]和dp[i][j]是同一行。
dp[i][j] = max( dp[i][j] ,dp[i][j-array[i]]+value[i], dp[i][j-2*array[i]]+2*value[i] ,dp[i][j-3*array[i]]+3*value[i] ,dp[i][j-4*array[i]]+4*value[i] )

目标是求dp[i][j]
而dp[i-1][j]是上一个物品决策后得到的结果,所以就转化为了

dp[i][j] = max( dp[i][j-array[i]]+value[i], dp[i][j-2*array[i]]+2*value[i] ,dp[i][j-3*array[i]]+3*value[i] ,dp[i][j-4*array[i]]+4*value[i] )

dp[i][j] 的部分情况和dp[i][j-array[i]]的情况具有等差的特性,总是相差 array[i]
因为问题又被转化为了
dp[i][j] = max(dp[i-1][j],dp[i][j-array[i]]+value[i])

再进行i维度的消除得到
dp[j] = max(dp[j],dp[j-array[i]]+ value[i])

dp[i][j] = max(dp[i-1][j],dp[i][j-array[i]]+value[i])
遍历方向 从小到大,确保dp[j-array]是被更新的,因为dp[i][j-array[i]]+value[i]和dp[i][j]是同一行。

4.多维背包

约束条件有元素个数和元素和两个

/*
1995. 统计特殊四元组
给一个 下标从 0 开始 的整数数组 nums ,返回满足下述条件的 不同 四元组 (a, b, c, d) 的 数目 :

nums[a] + nums[b] + nums[c] == nums[d] ,且
a < b < c < d

*/
func countQuadruplets(nums []int) int {
    DP := make([][][]int,len(nums))
    max:= getMax(nums)
    for i:=0; i< len(nums);i++{
        DP[i] = make([][]int,max+1)
        for j:=0; j< max+1 ;j++ {
            DP[i][j]= make([]int,4)
        }
    }
    // 第一次提交的时候没有这个报错
    DP[0][0][0] =1
    DP[0][nums[0]][1] =1
    // DP[i][j][k] 为考虑前 i 个物品(下标从 11 开始),凑成数值恰好 j,使用个数恰好为 k 的方案数。
    for i:=1;i<len(nums);i++{
        // j是元素和约束
        for j:=0;j< max+1 ;j++{
            // k是 元素个数约束
            for k:=0; k< 4;k++{
                //
                DP[i][j][k] = DP[i][j][k] +  DP[i-1][j][k]
                if  j-nums[i] >=0 && k-1 >=0 {
                    // 两种情况
                    // DP[i-1][j][k] 不参与组成
                    // DP[i-1][j-nums[i]][k-1]  参与组成
                    DP[i][j][k] =  DP[i][j][k]+ DP[i-1][j-nums[i]][k-1]
                }

            }

        }
    }
    sum:=0
    for k,v := range nums{
        sum = DP[k][v][3] +sum
    }
    return sum
}

func getMax(nums []int)int{
    if len(nums) == 0{
        return 0
    }
    max:= nums[0]
    for _,v := range nums{
        if v >max{
            max = v
        }
    }
    return max
}

5.记忆化搜索

如果给定了某个「形状」的数组(三角形或者矩形),使用 题目给定的起点 或者 自己枚举的起点 出发,再结合题目给定的具体转移规则(往下方/左下方/右下方进行移动)进行转移。

基础的模型是: 特定的起点或者枚举的起点,有确定的移动方向,(转移方向)求解所有状态的最优值。

如果给移动规则,但是没有告诉如何移动,那么这种问题可以理解为另外一种路径问题。

单纯的 DFS 由于是指数级别的复杂度,通常数据范围不出超过 30 实现 DFS 的步骤是: 1.设计递归函数的入参和出参数。 2.设计好递归函数的出口,BASE CASE 3.编写最小的处理单元

如何找到 BASE CASE? 明确在什么情况下算一次有效,即在 DFS 的过程中不断累加有效的情况。

/*
403. 青蛙过河
一只青蛙想要过河。 假定河流被等分为若干个单元格,并且在每一个单元格内都有可能放有一块石子(也有可能没有)。 青蛙可以跳上石子,但是不可以跳入水中。

给石子的位置列表 stones(用单元格序号 升序 表示), 请判定青蛙能否成功过河(即能否在最后一步跳至最后一块石子上)。开始时, 青蛙默认已站在第一块石子上,并可以假定它第一步只能跳跃 1 个单位(即只能从单元格 1 跳至单元格 2 )。

如果青蛙上一步跳跃了 k 个单位,那么它接下来的跳跃距离只能选择为 k - 1、k 或 k + 1 个单位。 另请注意,青蛙只能向前方(终点的方向)跳跃。
*/
var allStones []int
// [石子列表下标][跳跃步数]int
// 记忆化搜索
// 在考虑加入「记忆化」时,我们只需要将 DFS 方法签名中的【可变】参数作为维度,DFS 方法中的返回值作为存储值即可。
// boolean[石子列表下标][跳跃步数] 这样的数组,但使用布尔数组作为记忆化容器往往无法区分「状态尚未计算」和「状态已经计算
// int[石子列表下标][跳跃步数],默认值 0 代表状态尚未计算,-1代表计算状态为 false,1 代表计算状态为 true。
// var cache [i][j]int
// map["i"+"j"]int
var cache map[string]int

// 因为存储的是单元格编号,所以不会重复
var hashMap map[int]int
// cache[i]到达第stones个石子的时候,需要跳几步
func canCross(stones []int) bool {
    // 特殊情况
    if len(stones) ==2{
        if  stones[1] -stones[0] ==1 {
           return true
        }
        return false
    }
    if len(stones)>2 && stones[1]!=1{
        return false
    }
    // 为了快速判断需要的子节点存不存在
    hashMap= make(map[int]int,len(stones))
    for k, v:= range stones{
        hashMap[v] = k
    }

    // 全局的石子数组
    allStones = stones
    // 记忆化搜索需要的记忆二维切片,稍微可以改进一下变成map
    cache = make(map[string]int,len(stones))
    return DFS(1,1)
}

func DFS(i int,lastStep int) bool{
    key := strconv.Itoa(i)+ strconv.Itoa(lastStep)
    if v,ok:=cache[key];ok && v!=0{
        if v==1{
            return true
        }
        return false
    }
    //如果之前已经发现可以到达,那么就不需要向下递归了
    if i == len(allStones)-1{
        return  true
    }

    // 模拟跳lastStep-1, lastStep ,lastStep +1步
    for j:=-1;j<=1;j++{
        // 意味着是原地
        if lastStep + j == 0{
            continue
        }
        if index,ok:= hashMap[allStones[i]+lastStep + j];ok{
            success:=DFS(index,lastStep + j)
            cache[key] = boolToInt(success)
            if success == true{
                return true
            }
        }
    }
    cache[key]= -1
    return false
}

func getMaxStep(stones []int) int{
    return stones[len(stones)-1] - 1
}

func boolToInt(success bool)int{
    if success == true{
        return 1
    }
    return -1
}

6.博弈论 DP

Tags: 背包问题
Share: X (Twitter) Facebook LinkedIn