常见编程模式之动态规划:0-1背包问题
16. 动态规划(DP)Part 1
动态规划是编程问题中最常见的一种模式。本质上来说,动态规划是一种对递归的优化,通过记忆化存储的方式减少重复计算的次数。在尝试用动态规划解决问题时,我们可以遵循如下的四个步骤:
- 先思考题目中的递归关系
- 观察递归过程中是否存在重复的运算
- 尝试通过记忆化的方法消除重复运算(即「记忆化搜索」)
- 尝试调整计算顺序,自底向上通过「查表」的方式顺序计算
本篇将介绍一种经典的动态规划类问题:「0-1 背包」问题。
0-1 背包问题(0/1 Knapsack)
0-1 背包问题是最基本的背包问题,其本质是一个优化问题。0-1 背包问题的通用形式为:给定
件物品和一个容量为
的背包,放入第
件物品耗费的「费用」是
(即背包容量),得到的「价值」是
,求解将哪些物品装入背包可使得价值总和最大。其特点是:每种物品仅有「一件」,可以选择放或者不放。
用
表示前
件物品恰放入一个容量为
的背包可以获得的最大价值,则我们可以定义如下的状态转移方程:
对于“将前
件物品放入容量为
的背包中”这个子问题,如果只考虑第
件物品的策略(放或者不放),则可以将其转化为一个只和前
件物品相关的问题。如果不放第
件物品,则问题转化为“前
件物品放入容量为
的背包中”,价值为
;如果放第
件物品,则问题转化为“前
件物品放入剩下的容量为
的背包中”,此时的价值为
加上第
件物品获得的价值
。「注意」:如果当前的
小于
,则
。伪代码如下:
上述代码省略了
的处理情况。对于上述代码,其时间和空间复杂度均为
。对于「空间复杂度」,我们可以将其优化为
。具体来说,由于我们只关心最终的结果
,所以可以只记录当
时的结果,即使用一维数组
。这时我们需要保证在每一次循环时,
是由
和
递推而来。对于
,由于
是从小到大遍历的,所以可以满足要求;而对于
,我们需要确保当前循环下
在
之后更新,这样即保留了上一次循环时的值,满足条件。因此我们只需要对于
「从大到小遍历」即可,注意:此时上述
的处理不需要再执行,因为
变成了一维数组。伪代码如下:
在上述伪代码中,当物品数量为 0 时初始化的值均为 0,这可能需要基于题目的要求而进行变化。如果题目要求恰好装满背包,则除了
为 0 外,其它的
均应设为
,因为此时没有合法解。
此外,我们还可以对第二重循环的「下限」进行进一步优化,减少循环数量:
这种优化主要适用于背包容量
很大的情况。考虑在最后一个循环时,我们最终需要计算
,因此我们需要在上一个循环中至少计算出
。因此在第
次循环中只需要遍历
即可 ,同理,由于
,因此在第
次循环中只需要遍历
即可。以此类推,在第
次循环时,内层循环的下限只需要设为
即可(注意上限始终为
)。
「注意」:在《背包问题九讲 2.0 beta1.2》中,作者给出的常数优化公式的下标有误,同时错误地使用了价值
。
416. 分割等和子集(Medium)
给定一个「只包含正整数」的「非空」数组。是否可以将这个数组分割成两个子集,使得两个子集的元素和相等。
「示例」:
输入: [1, 5, 11, 5]
输出: true
解释: 数组可以分割成 [1, 5, 5] 和 [11].
这道题看似和背包问题无关,但如果我们将元素和看做背包容量,则该问题可以转化为:
给定 N 个物品和一个容量为 sum/2 的背包,每个物品对应的容量为其元素大小,那么是否可以挑选出部分物品使得背包恰好装满?
这种背包问题与标准 0-1 背包的区别在于,不需要考虑物品价值,且要求背包恰好装满。因此我们需要对原问题的解法做两处修改,一是初始化的值,这里前面已经叙述过;二是状态转移方程的调整,数组返回的值为布尔值(能否恰好装满),且无需再最大化价值。基于这一解法,我们可以给出如下的 python 实现:
class Solution:
def canPartition(self, nums: List[int]) -> bool:
s = sum(nums)
if s % 2 != 0:
return False # 不能分为两份
s = int(s / 2)
dp = [[False for _ in range(s + 1)] for _ in range(len(nums))] # 创建二维数组,默认初始值为False,注意0表示放置一个元素(不需要考虑没有元素的情况)
for i in range(len(nums)): # 容量为0,不放任何物品就完事,因此可以设为True
dp[i][0] = True
for j in range(1, s + 1):
dp[0][j] = nums[0] == j # 只能放一个元素时,需要第一个元素和目标容量相等
for i in range(1, len(nums)): # 这里从1开始即可,第一个元素已经初始化
for j in range(1, s + 1):
if nums[i] <= j: # 可以放第i个元素时
dp[i][j] = dp[i - 1][j - nums[i]] or dp[i - 1][j]
else: # 不能放第i个元素时
dp[i][j] = dp[i - 1][j]
return dp[len(nums) - 1][s]
根据之前的空间复杂度优化方法,我们可以将二维数组优化为一维数组:
class Solution:
def canPartition(self, nums: List[int]) -> bool:
s = sum(nums)
if s % 2 != 0:
return False
s = int(s / 2)
dp = [False for _ in range(s + 1)] # 创建一维数组,默认初始值为False
dp[0] = True
if nums[0] <= s # Leetcode下不写该初始化也通过(可能测试用例不全)
dp[nums[0]] = True # 参考二维时的初始化设置(看做dp[0][j])
for i in range(1, len(nums)):
for j in range(s, nums[i] - 1, -1): # 逆序遍历
dp[j] = dp[j] or dp[j - nums[i]]
return dp[s]
474. 一和零(Medium)
在计算机界中,我们总是追求用有限的资源获取最大的收益。 现在,假设你分别支配着 「m」 个 0 和 「n」 个 1。另外,还有一个仅包含 0 和 1 字符串的数组。 你的任务是使用给定的 「m」 个 0 和 「n」 个 1 ,找到能拼出存在于数组中的字符串的最大数量。每个 0 和 1 至多被使用一次。
「示例」:
输入: Array = {"10", "0001", "111001", "1", "0"}, m = 5, n = 3
输出: 4
解释: 总共 4 个字符串可以通过 5 个 0 和 3 个 1 拼出,即 "10","0001","1","0" 。
这道题实际上是一个「二维费用」的 0-1 背包问题,即对于每件物品,具有两种不同的费用,选择这件物品必须同时付出这两种费用。对于每种费用都有一个可付出的最大值(背包容量)。对于本题来说,将数组中的每个元素看做物品,选择该元素需要付出 0 和 1 两种费用,0 对应的背包容量为 m,1 对应的背包容量为 n,每件元素的价值均为 1,求可以放入背包的元素的最大价值(即数量)。
对于这种背包问题,我们需要添加一维状态,设
表示前
件物品付出两种费用分别为
和
时可获得的最大价值。则状态转移方程为:
基于前述优化空间复杂度的方法,可以只使用二维的数组,采用逆序循环。下面给出该方法的 python 实现:
class Solution:
def findMaxForm(self, strs: List[str], m: int, n: int) -> int:
dp = [[0 for _ in range(n + 1)] for _ in range(m + 1)] # 初始化二维数组
for i in range(0, len(strs)): # 注意需要从0开始循环,遍历所有字符串
c_i = strs[i].count('0')
d_i = strs[i].count('1')
for v in range(m, c_i - 1, -1):
for u in range(n, d_i - 1, -1):
dp[v][u] = max(dp[v][u], dp[v - c_i][u - d_i] + 1)
return dp[m][n]
关于背包问题的更多介绍可以参考著名的《背包问题九讲》。
- 【编程基础】C语言内存使用的常见问题
- kmeans聚类理论篇K的选择(轮廓系数)
- 算法之旅 | 选择排序法
- 【专业技术】Android数据保存之SharedPreferences
- 【答疑解惑】Java中的高精度数字
- 2000! | 看上去如此简单的面试题,让太多“前端”英雄好汉折戟
- 【Windows编程】系列第六篇:创建Toolbar与Statusbar
- arguments,想说爱你不容易
- 【android开发】Android binder学习一:主要概念
- 高考啦! JavaScript高考全国卷
- 2017 JavaScript高考全国卷 参考答案与解析
- 用贝叶斯判别分析方法预测股票涨跌
- 开发 | 在 Mac OS X 装不上 TensorFlow?看了这篇就会装
- 【答疑解惑】Java中的默认构造器和equals方法
- JavaScript 教程
- JavaScript 编辑工具
- JavaScript 与HTML
- JavaScript 与Java
- JavaScript 数据结构
- JavaScript 基本数据类型
- JavaScript 特殊数据类型
- JavaScript 运算符
- JavaScript typeof 运算符
- JavaScript 表达式
- JavaScript 类型转换
- JavaScript 基本语法
- JavaScript 注释
- Javascript 基本处理流程
- Javascript 选择结构
- Javascript if 语句
- Javascript if 语句的嵌套
- Javascript switch 语句
- Javascript 循环结构
- Javascript 循环结构实例
- Javascript 跳转语句
- Javascript 控制语句总结
- Javascript 函数介绍
- Javascript 函数的定义
- Javascript 函数调用
- Javascript 几种特殊的函数
- JavaScript 内置函数简介
- Javascript eval() 函数
- Javascript isFinite() 函数
- Javascript isNaN() 函数
- parseInt() 与 parseFloat()
- escape() 与 unescape()
- Javascript 字符串介绍
- Javascript length属性
- javascript 字符串函数
- Javascript 日期对象简介
- Javascript 日期对象用途
- Date 对象属性和方法
- Javascript 数组是什么
- Javascript 创建数组
- Javascript 数组赋值与取值
- Javascript 数组属性和方法
- Docker实用debug调试技巧锦集
- shell 命名管道,进程间通信, ncat作http server
- Linux atop监控
- go modules中使用replace用法
- 分布式文件系统Ceph的挂载方式
- 在CentOS 7上挂载CephFS
- Qt音视频开发14-mpv读取和控制
- 深入理解Nginx的rewrite模块
- max os 安装各种问题/Error解决方法
- 不要启用 net.ipv4.tcp_tw_recycle
- 利用SSH(无密码)免登录来节省你的生命
- 深入linux下磁盘Disk,分区Partition,挂载Mount
- 净化Git之rebase变基的使用
- Linux内存管理 一个进程究竟占用多少空间?-VSS/RSS/PSS/USS
- Bash:选择结构之case