AI summary
type
status
date
slug
summary
category
tags
icon
password
对于动态规划类型的问题,读者总是说不知道如何设置
dp 函数/数组的定义。其实也不要畏惧,想办法往已知的题目、算法框架上靠,步步为营,总能找到一些突破口。打家劫舍问题模式
解决智力问题
给你一个下标从 0 开始的二维整数数组questions,其中questions[i] = [pointsi, brainpoweri]。这个数组表示一场考试里的一系列题目,你需要 按顺序 (也就是从问题0开始依次解决),针对每个问题选择 解决 或者 跳过 操作。解决问题i将让你 获得pointsi的分数,但是你将 无法 解决接下来的brainpoweri个问题(即只能跳过接下来的brainpoweri个问题)。
和打家劫舍基本一样的问题。但是这里需要反向定义 dp[i] 表示题目 i 开始到最后的最大得分,因为状态转移方程需要 brainpoweri 向前传递。代码如下:
统计放置房子的方式数
一条街道上共有n * 2个 地块 ,街道的两侧各有n个地块。每一边的地块都按从1到n编号。每个地块上都可以放置一所房子。现要求街道同一侧不能存在两所房子相邻的情况,请你计算并返回放置房屋的方式数目。由于答案可能很大,需要对10^9 + 7取余后再返回。
因为两侧的房屋互不影响,所以分别考虑两侧,最后结果就是两侧方案数的乘积。
定义 dp[i] 表示从地块 i 到 n 的方案数。
最低票价
在一个火车旅行很受欢迎的国度,你提前一年计划了一些火车旅行。在接下来的一年里,你要旅行的日子将以一个名为days的数组给出。每一项是一个从1到365的整数。火车票有 三种不同的销售方式 :
- 一张 为期一天 的通行证售价为
costs[0]美元;
- 一张 为期七天 的通行证售价为
costs[1]美元;
- 一张 为期三十天 的通行证售价为
costs[2]美元。通行证允许数天无限制的旅行。 例如,如果我们在第2天获得一张 为期 7 天 的通行证,那么我们可以连着旅行 7 天:第2天、第3天、第4天、第5天、第6天、第7天和第8天。返回 你想要完成在给定的列表days中列出的每一天的旅行所需要的最低消费 。
和打家劫舍几乎一样,198 题是让你在
nums[i] 做两种选择,抢或者不抢,且抢了之后不能再抢相邻的位置,最后求最大收益。这道题是让你在
days[i] 做三种选择,买一天的、七天的或三十天的,且买了之后,在这段时间内不用再买,最后求最小花费。这题可以正向定义了(当然反向定义也可以),因为一天,七天或三十天,是确定的,方便状态转移,体会一下和「解决智力问题」的区别。
定义 dp[i] 表示 days[:i] 的最低消费,代码如下:
删除并获得点数
给你一个整数数组nums,你可以对它进行一些操作。每次操作中,选择任意一个nums[i],删除它并获得nums[i]的点数。之后,你必须删除 所有 等于nums[i] - 1和nums[i] + 1的元素。开始你拥有0个点数。返回你能通过这些操作获得的最大点数。
题目有点点难理解,意思是选 nums[i] 就不能选 nums[i] ± 1。那其实就是打家劫舍问题。只需要将 nums[i] 按值的大小存起来作为 points。因为题目说
1 <= nums[i] <= 10000,所以我们可以用一个大小为 10001 的 points 数组来存储这个映射表。要想得到最大点数,就要删除的少,那么既然选了 nums[i],就把所有值等于它的全选了,所以 points 里存的实际上是数字和。
老鼠和奶酪
有两只老鼠和n块不同类型的奶酪,每块奶酪都只能被其中一只老鼠吃掉。下标为i处的奶酪被吃掉的得分为:
- 如果第一只老鼠吃掉,则得分为
reward1[i]。
- 如果第二只老鼠吃掉,则得分为
reward2[i]。给你一个正整数数组reward1,一个正整数数组reward2,和一个非负整数k。请你返回第一只老鼠恰好吃掉k块奶酪的情况下,最大 得分为多少。
翻译一下题目,就是 reward1 数组选 k 个,然后再加上 reward2 数组的剩下 n-k 个。
定义 dp[i][k] 表示前 i 个奶酪第一只恰好吃掉 k 块的最大得分。
但是内存爆炸了。上空间压缩大法:
时间又爆炸了,哈哈哈。
看下数据规模,
n, k 都在 10^5 动态规划的复杂度是 O(n * k),在 10^10 量级,确实很容易超时。我们动态规划是无遗漏、无冗余地穷举了所有可能的解,依然超时,那就说明这道题大概率要用贪心的思路,找出一些巧妙的方法,避免穷举所有可行解来计算最优解。
我们尝试换个思路,这道题相当于就是让你在
reward1 中选 k 个元素,在 reward2 中选 n - k 个元素,使得元素之和最大。想要让总的元素之和最大,可以让
reward1 中选择的 k 个元素尽可能「占便宜」,即选择 reward1[i] - reward2[i] 最大的 k 个元素。那么有一个思路,就是把
reward1 和 reward2 的差值进行排序,然后选择前 k 个最大的差值作为从 reward1 中选择的元素,剩下的从 reward2 中选择。优雅!
合并后数组中的最大元素
给你一个下标从 0 开始、由正整数组成的数组nums。你可以在数组上执行下述操作 任意 次:
- 选中一个同时满足
0 <= i < nums.length - 1和nums[i] <= nums[i + 1]的整数i。将元素nums[i + 1]替换为nums[i] + nums[i + 1],并从数组中删除元素nums[i]。返回你可以从最终数组中获得的 最大 元素的值。
动态规划不行,但是贪心算法应该比较容易想到。
相当于要把所有非递减的相邻元素合并,肯定要先对靠后的元素进行合并,因为这样不会破坏已有的非递减关系,而且由于合并的关系,还可能创造新的非递减关系,使合并的非递减元素对尽可能多。
背包问题经典习题
背包问题是一类经典的动态规划问题,主要是在某个限制内,选择最优的元素组合。
其特点在于
dp 数组的定义方式,第一个维度的定义是「仅使用前 i 个物品」,之后的维度用来定义限制条件。最后一块石头的重量 Ⅱ
有一堆石头,用整数数组stones表示。其中stones[i]表示第i块石头的重量。每一回合,从中选出任意两块石头,然后将它们一起粉碎。假设石头的重量分别为x和y,且x <= y。那么粉碎的可能结果如下:
- 如果
x == y,那么两块石头都会被完全粉碎;
- 如果
x != y,那么重量为x的石头将会完全粉碎,而重量为y的石头新重量为y-x。最后,最多只会剩下一块 石头。返回此石头 最小的可能重量 。如果没有石头剩下,就返回0。
这题的难点在于,如何看出它是一个背包问题。不过你看过「子集背包问题」,可能可以想到把这题转化成背包问题的方法。
任意两个石头会互相抵消,问你最后留下的石头重量,相当于是把
stones 数组分成两个子集,使得两个子集的重量差最小。想要两个子集的重量差最小,那么两个子集的重量和应该尽可能接近
sum/2。那么问题就转化为了背包问题:给定一个容量为
sum/2 的背包和若干石头 stones,请问这个背包能够装下的最大重量是多少?这就是标准的 0-1 背包问题。定义 dp[i][j] 表示使用前 i 个石头,背包容量为 j 时,装下的最大重量。
一和零
给你一个二进制字符串数组strs和两个整数m和n。请你找出并返回strs的最大子集的长度,该子集中 最多 有m个0和n个1。如果x的所有元素也是y的元素,集合x是集合y的 子集 。
标准的 0-1 背包问题,是在有限的背包容量下,计算物品的最大价值;这道题相当于背包有两个容量(0 和 1 的个数),然后计算最多能装多少个物品。
其中 0 和 1 的个数可以通过
strs.count() 计算。执行操作可获得的最大总奖励 Ⅰ
给你一个整数数组rewardValues,长度为n,代表奖励的值。最初,你的总奖励x为 0,所有下标都是 未标记 的。你可以执行以下操作 任意次 :
- 从区间
[0, n - 1]中选择一个 未标记 的下标i。
- 如果
rewardValues[i]大于 你当前的总奖励x,则将rewardValues[i]加到x上(即x = x + rewardValues[i]),并 标记 下标i。以整数形式返回执行最优操作能够获得的 最大 总奖励。
首先看数据规模,数组长度是 2000 的规模,那么你的算法复杂度大概不能超过
O(N^2) 的级别,所以回溯这种暴力穷举算法肯定不考虑。再看题目求的是最值,那么大概率是动态规划了,属于 0-1 背包的变体。
这个题的
rewardValues 就相当于是物品列表,rewardValues[i] 就相当于是第 i 个物品的价值,也是让你选择物品,使得物品的价值最大。唯一的区别是,这道题并没有给一个固定的背包最大容量作为限制,而是把当前背包中的总价值
x 作为限制,你仅可以选择价值大于 x 的物品装进背包。另外注意,这道题在进行状态转移之前需要对
rewardValues 进行排序。因为一般的背包问题给定了一个固定的背包容量,而这道题相当于你背包里的物品价值用来限制你的选择了,所以这个题要尽可能先装小价值的物品,如果你直接装一个大价值的物品进去,会导致后面的小价值物品装不进去,最终的结果偏小。定义 dp[i][j] 表示前 i 个数能否得到奖励 j。和普通背包问题定义不同,因为背包没有确定的容量。
j 最大枚举到 2m-1,其中 m 是数组的最大值,证明如下:
如果最后一步选的数是 x,而 x<m,那么把 x 替换成 m 也符合要求,矛盾,所以最后一步选的一定是 m。在选 m 之前,元素和至多是 m−1,选了 m 之后,元素和至多是 2m−1。我们无法得到比 2m−1 更大的元素和。
下一题和这题描述一样,但是扩大了数据范围,上面的解会爆内存。上空间压缩大法,还是超时。只能祭出位运算大法了:
进一步地,把一维数组压缩成一个二进制数 f,其中二进制从低到高第 j 位为 1 表示 f[j]=true,为 0 表示 f[j]=false。转换后 ∨ 就是或运算(OR)了。
位运算可以去掉第二个循环。观察上述状态转移方程 ,其中 ,这相当于取 f 的低 v 位,再左移 v 位,然后与原来的 f 进行或运算。
小优化:代码实现时,可以先把 rewardValues 中的重复数字去掉再 DP,毕竟无法选两个一样的数。
小技巧:
(1 << v) - 1 获得低 v 位。优雅!
但是还可以用奇淫巧技继续优化。如果数组中包含 m-1,则答案为 2m-1;如果有两个不同元素之和等于 m−1,也可以直接返回 2m−1。由于在随机数据下,几乎 100% 的概率有两数之和等于 m−1,所以在大多数情况下,本题就是一道「两数之和」。
动态规划经典习题 Ⅰ
列举一些经典的动态规划习题,帮助大家加深理解前面文章中讲到的知识点。
整数拆分
给定一个正整数n,将其拆分为k个 正整数 的和(k >= 2,k 不是常量),并使这些整数的乘积最大化。返回 你可以获得的最大乘积 。
简单动态规划,设 dp[n] 表示拆分 n 获得的最大乘积。选择拆或者不拆。由于对称性,j 只需要到 i//2。
可以通过数学推导获得一个 O(1) 的算法,证明就不写了,结论是:若拆分的数量 k 确定,则 各拆分数字相等时 ,乘积最大;将数字 n 尽可能以因子 3 等分时,乘积最大。
不同路径 Ⅱ
一个机器人位于一个m x n网格的左上角 (起始点在下图中标记为 “Start” )。机器人每次只能向下或者向右移动一步。机器人试图达到网格的右下角(在下图中标记为 “Finish”)。现在考虑网格中有障碍物。那么从左上角到右下角将会有多少条不同的路径?网格中的障碍物和空位置分别用1和0来表示。
简单动态规划,设 dp[i][j] 表示到坐标 (i,j) 的路径数。
可被三整除的最大和
给你一个整数数组nums,请你找出并返回能被三整除的元素 最大和。
定义 dp[i][j] 表示前 i 个元素除以三余数为 j 的元素最大和。
三角形最小路径和
给定一个三角形triangle,找出自顶向下的最小路径和。每一步只能移动到下一行中相邻的结点上。相邻的结点 在这里指的是 下标 与 上一层结点下标 相同或者等于 上一层结点下标 + 1 的两个结点。也就是说,如果正位于当前行的下标i,那么下一步可以移动到下一行的下标i或i + 1。
dp[i][j] 表示从顶到下标 (i,j) 的最小路径和。
最大整除子集
给你一个由无重复正整数组成的集合 nums,请你找出并返回其中最大的整除子集 answer,子集中每一元素对 (answer[i], answer[j]) 都应当满足:
answer[i] % answer[j] == 0,或
answer[j] % answer[i] == 0如果存在多个有效解子集,返回其中任何一个均可。
这题有点难,不好想。需要先排序,排序很重要,假设一个符合题目要求的子集
[1,2,4],那么由于我排了序,如果想扩充这个集合,接下来我只要到一个能够整除 4 的数就行了,比如 8。我不用考虑前面的数字了,因为只要能整除最大的 4 就一定能整除别的。这样一来,题目就变成了:寻找一个最长递增子序列,且要求每个元素都能够整除前面那个元素。
dp[i] 表示以 nums[i] 结尾的最长递增子序列。
copy() 函数用于复制列表,类似于 a[:],浅拷贝。
最长重复子数组
给两个整数数组nums1和nums2,返回 两个数组中 公共的 、长度最长的子数组的长度 。
dp[i][j] 表示以 nums1[i] 和 nums2[j] 结尾的最长公共子数组长度。
动态规划经典习题 Ⅱ
交错字符串
给定三个字符串s1、s2、s3,请你帮忙验证s3是否是由s1和s2交错 组成的。两个字符串s和t交错 的定义与过程如下,其中每个字符串都会被分割成若干 非空 子字符串:
s = s1 + s2 + ... + sn
t = t1 + t2 + ... + tm
|n - m| <= 1
- 交错 是
s1 + t1 + s2 + t2 + s3 + t3 + ...或者t1 + s1 + t2 + s2 + t3 + s3 + ...注意:a + b意味着字符串a和b连接。

题目巴拉巴拉说了一大堆,实则就是一个使用双指针技巧合并两个字符串的过程。
但本题跟普通的数组/链表双指针技巧不同的是,这里需要穷举所有情况。比如
s1[i], s2[j] 都能匹配 s3[k] 的时候,到底应该让谁来匹配,才能完全合并出 s3 呢?回答这个问题很简单,我不知道让谁来,那就都来试一遍好了。
设 dp[i][j] 表示 s1[:i] 和 s2[:j] 是否可以交错拼成 s3[i+j]。
乘积最大子数组
给你一个整数数组nums,请你找出数组中乘积最大的非空连续子数组(该子数组中至少包含一个数字),并返回该子数组所对应的乘积。测试用例的答案是一个 32-位 整数。
这道题可以采用类似的思路,但需要注意的是,在 53 题中,子数组
nums[0..i] 的最大元素和是由 nums[0..i-1] 的最大元素和推导出的,但本题变成子数组的乘积则不一定。比如
nums[i] = -1,nums[0..i-1] 子数组的最大元素乘积为 10,那么我能不能说 nums[0..i] 的最大元素乘积为 max(-1, -1 * 10) = -1 呢?其实不行,因为可能
nums[0..i-1] 子数组的最小元素乘积为 -6,那么 nums[0..i] 的最大元素乘积应该为 max(-1, -1 * 10, -1 * -6) = 6`。所以这道题和 53 题的最大区别在于,要同时维护「以
nums[i] 结尾的最大子数组」和「以 nums[i] 结尾的最小子数组」,以便适配 nums[i] 可能为负的情况。最大正方形
在一个由'0'和'1'组成的二维矩阵内,找到只包含'1'的最大正方形,并返回其面积。
定义 dp[i][j] 表示以坐标 (i,j) 为右下角的只包含 1 的最大正方形。
注意状态转移方程,三个相邻的取最小值。dp[i-1][j] 控制最后一列为 1,dp[i][j-1] 控制最后一行为 1,dp[i-1][j-1] 控制剩下的左上角为 1,matrix[i][j] 控制右下角的一格为 1。
另外,只记录边长比较方便,最后再取面积。
矩阵中的最长递增路径
给定一个m x n整数矩阵matrix,找出其中 最长递增路径 的长度。对于每个单元格,你可以往上,下,左,右四个方向移动。 你 不能 在 对角线 方向上移动或移动到 边界外(即不允许环绕)。
好难。
「记忆化 DFS」
将矩阵看成一个有向图,每个单元格对应图中的一个节点,如果相邻的两个单元格的值不相等,则在相邻的两个单元格之间存在一条从较小值指向较大值的有向边。问题转化成在有向图中寻找最长路径。
「拓扑排序」
动态规划的状态定义和转移方程很简单,但是边界条件以及按照什么顺序来转移比较复杂。
如果一个单元格的值比它的所有相邻单元格的值都要大,那么这个单元格对应的最长递增路径是 1,这就是边界条件。这个边界条件并不直观,而是需要根据矩阵中的每个单元格的值找到作为边界条件的单元格。
仍然使用方法一的思想,将矩阵看成一个有向图,计算每个单元格对应的出度,即有多少条边从该单元格出发。对于作为边界条件的单元格,该单元格的值比所有的相邻单元格的值都要大,因此作为边界条件的单元格的出度都是 0。
基于出度的概念,可以使用拓扑排序求解。从所有出度为 0 的单元格开始广度优先搜索,每一轮搜索都会遍历当前层的所有单元格,更新其余单元格的出度,并将出度变为 0 的单元格加入下一层搜索。当搜索结束时,搜索的总层数即为矩阵中的最长递增路径的长度。
规划兼职工作
你打算利用空闲时间来做兼职工作赚些零花钱。这里有n份兼职工作,每份工作预计从startTime[i]开始到endTime[i]结束,报酬为profit[i]。给你一份兼职工作表,包含开始时间startTime,结束时间endTime和预计报酬profit三个数组,请你计算并返回可以获得的最大报酬。注意,时间上出现重叠的 2 份工作不能同时进行。如果你选择的工作在时间X结束,那么你可以立刻进行在时间X开始的下一份工作。

「先按照结束时间排序」
定义 dp[i] 表示前 i 个工作的最大报酬。
如果不选第 i 个工作,那么最大报酬就是 dp[i-1];
如果选第 i 个工作,那么就找满足 endTime[j] ≤ startTime[i] 的最大的 j,最大报酬为 dp[j] + profit[i]。由于已经按照结束时间排序了,所以 j 可以二分查找计算。
算法小课堂:标准库二分的灵活运用
- bisect和bisect_right返回大于x的第一个下标(相当于C++中的upper_bound)
- bisect.bisect_left返回大于等于x的第一个下标(相当于C++中的lower_bound)。
在写二分题目时,经常会遇到形如「在有序数组中查询大于某个数的最小数」这类问题。具体来说有四类:
- ≥:在有序数组中查询大于或等于某个数的最小数;
- >:在有序数组中查询大于某个数的最小数;
- ≤:在有序数组中查询小于或等于某个数的最大数;
- <:在有序数组中查询小于某个数的最大数。
上面的一些编程语言用到了标准库中的二分,但这些二分在设计的时候,只提供了查询 ≥ 和 > 的功能,并没有提供查询 ≤ 和 < 的功能。
没有关系,稍微转换下就能解决。比如查询 > 得到了下标 i,那么 i−1 就是 ≤ 的结果了(假设数组为升序),同理 < 可以用 ≥ 算出来。
注:> 和 ≥ 也可以转换,对于整数来说,>x 等价于 ≥x+1。