线性DP
最长公共子序列
给定两个字符串 text1 和 text2,返回这两个字符串的最长公共子序列的长度。
一个字符串的 子序列 是指这样一个新的字符串:它是由原字符串在不改变字符的相对顺序的情况下删除某些字符(也可以不删除任何字符)后组成的新字符串。
例如,”ace” 是 “abcde” 的子序列,但 “aec” 不是 “abcde” 的子序列。两个字符串的「公共子序列」是这两个字符串所共同拥有的子序列。
若这两个字符串没有公共子序列,则返回 0。
1 | int longestCommonSubsequence(string text1, string text2) { |
三角形最小路径和
给定一个三角形,找出自顶向下的最小路径和。每一步只能移动到下一行中相邻的结点上。
相邻的结点 在这里指的是 下标 与 上一层结点下标 相同或者等于 上一层结点下标 + 1 的两个结点。
例如,给定三角形:
1 | [ |
自顶向下的最小路径和为 11(即,2 + 3 + 5 + 1 = 11)。
- DP
对每个位置,它的最短路径可以通过上一层与它相邻的两个节点的最短路径加上它自身的长度得到。
1 | int minimumTotal(vector<vector<int>>& triangle) { |
最大子序和
给定一个整数数组 nums ,找到一个具有最大和的连续子数组(子数组最少包含一个元素),返回其最大和。
示例:
1 | 输入: [-2,1,-3,4,-1,2,1,-5,4] |
因为要求的是连续的,因此每次求最大和的时候,只需要与左边位置的最大和相加即可。那么只要左边的最大和是小于0的,就不用于左边部分保持连续了。每次只计算左边一个位置,因此只需要一个变量来保存当前位置左边的最大和。
1 | int maxSubArray(vector<int>& nums) { |
最长公共子序列
1 | for (int i = 1; i <= m; ++ i) { |
最长上升子序列
- 普通DP
1 | for (int i = 1; i <= n; i ++ ) cin >> a[i]; |
- 贪心 + 二分
,维护每个子序列的【结尾】的最小数值,这样后面的数如果小于它则新的子序列长度不会超过它,如果大于它则长度一定大于它,因此这个序列具有单调性。则可以通过二分来更新这个序列。
1 | int lengthOfLIS(vector<int>& nums) { |
鸡蛋掉落
- 动态规划
首先明确当只有一个鸡蛋时只能挨个挨个试,而鸡蛋充足时最快的方式是直接二分查找。现在可能出现的情况是鸡蛋不足,如何利用有限的步数保证能遍历所有情况。具体的任务就是如何确定每一步鸡蛋扔的楼层。鸡蛋扔出去无非两种情况,碎或者不碎。碎了的话就往下找,此时楼层为扔时楼层的下面部分,鸡蛋损失1个。不碎的话楼层为扔时楼层的上面部分,鸡蛋无损失。然后去这两个子问题中继续求解,因为最终步数由两个子问题中较大的决定,同时目标是最小化步数,于是得到状态转移方程:
这是一个关于楼层数和鸡蛋数的二维DP,并且求解一个状态转移的时候需要枚举当前可能的所有楼层,找到使得两问题中最大问题最小的楼层。因此直接计算的话复杂度是$O(N^2K)$ ,下面的代码会超时
1 | int superEggDrop(int K, int N) { |
- 动态规划+二分
再观察这两个子问题,发现它们随着选定的楼层一个单增一个单减,是一个此消彼长的变化。于是它们的最大值的最小点会在交点处取得,根据这个性质可以用二分来快速找到这个点
1 | int superEggDrop(int K, int N) { |
- 动规(逆向)
上面的思路是给定楼层和鸡蛋,然后在楼层区间搜索一个使得分割后的楼层的遍历步数总体最小的楼层位置,这样每次状态更新都需要在所有楼层中查找。如果不管需要验证的楼层有多少,也不管鸡蛋有多少,直接在当前层扔出鸡蛋,也会两个结果,鸡蛋碎或不碎。重新定义转移状态为$f(i, k)$表示在有$k$个鸡蛋的情况下,使用$i$ 步能确定的最大楼层。如果鸡蛋碎了,那么当前能够确认的层数是当前层 + 上一步用$k-1$个鸡蛋能确认的楼层数,即:$1 + f(i - 1, k - 1)$ ,同时还有鸡蛋未碎的情况,因此还得加上上一步用$k$个鸡蛋能确认的楼层数:$f(i-1, k)$ 。于是得到了关于步数和楼层的状态转移方程。
1 | int superEggDrop(int K, int N) { |
- 优化一维
1 | int superEggDrop(int K, int N) { |
俄罗斯套娃信封
这个问题就是将最长上升子序列从一维扩展到了二维,可对其中一维进行排序后使用LIS,但这里不允许相同的长度套娃,因此在排序时,需要将重复的数的另一维逆序。
1 | int maxEnvelopes(vector<vector<int>>& ev) { |
打家劫舍
不能偷相邻的,按理说相邻涉及到左相邻和右相邻,但是在最右端是没有右邻居的。考虑将每段序列看作一个子问题,那么只需计算最右端的最大盗窃数量即可,状态转移方程为
每个状态只与之前的两个状态相关,因此可用两个变量来保存,将一维DP优化到$O(1)$空间。
1 | int rob(vector<int>& a) { |
打家劫舍 2
2在之前1的条件下将左端与右端连了起来,这样便不能再用端点的特殊情况,即任何点都需要判断左邻居和右邻居。但是对于左端点和右端点,发现它俩只能同时取一个,那么可按端点分两种情况出来($[0,\;n-1],\;[1,\;n]$),只要去掉了端点,这个问题便退化成了1问。
1 | int rob(vector<int>& nums) { |
打家劫舍 3
1 | struct dp { |
股票系列
买卖股票的最佳时机
只能交易一次
此时遍历时维护之前最小的即可。
1 | int maxProfit(vector<int>& prices) { |
买卖股票的最佳时机 2
给定一个数组,它的第 i 个元素是一支给定股票第 i 天的价格。
设计一个算法来计算你所能获取的最大利润。你可以尽可能地完成更多的交易(多次交易)。
注意:你不能同时参与多笔交易(你必须在再次购买前出售掉之前的股票)。
这个题有较多做法,因为可以买入/卖出无限多次,那么只要是收益为正的交易都可以计算。
- 遍历序列,相邻股价之差只要是正的便加上。
- 搜索所有的波谷波峰,将它们的差值加上。
- 动态规划
对于动态规划,除了第i天的股价外,还需要确定当天是持有股票还是未持有,这样才能判断是买入还是卖出。设$dp(i, 0),\;dp(i, 1)$分别表示第$i$天未持股和持股,则可以写出它们的转移方程为:
1 | // 初始化 |
其中第二维实际只有两个变量,并且枚举的过程中只用到了相邻前一天的结果,因此可用两个变量来代替二维数组。
1 | int stock = - prices[0], cash = 0; |
按理说更新第一个式子前应该将它之前的数值保存的,因为下个式子的更新会用到。但是观察它更新后的结果,即使cash更新产生了变化,将变化带入二式它不会影响最终结果。
买卖股票的最佳时机 3
与上一题想比,这题限制最多只能交易两次。这就需要求出最高和次高收益 的交易,这样之前两个类似贪心的方法就不能用了。
本题增加了一个限制:交易次数。直接用之前的思路可以添加一维状态,用来表示第几次交易:比如$dp(i, k, 0)$表示第i天时处于第K 次交易的未持有状态(即还未买入),1表示第k次交易已持股状态。则对于某一天的第k次交易,其状态转移与上一题是一样的:
即当前未持股可以从之前就未持股或之前持股但是卖出 而转移过来,当前持股可以从之前就持股或之前未持股但是买入而转移过来。这里因为k比较小,只有2,所以可以不用循环,直接展开。后面的问题当k大于2时其实就是同样的方法扩展而已。
初始化: 这里需要将第一天进行第几次买入都初始化为 - prices[0],不然在计算时会取到没花钱的0作为买入状态。
int maxProfit(vector<int>& prices) {
int n = size(prices);
if (n < 2) return 0;
int dp[n][3][2];
memset(dp, 0, sizeof dp);
dp[0][0][1] = - prices[0]; //
dp[0][1][1] = - prices[0];
for (int i = 1; i < n; ++ i) {
dp[i][0][1] = max(dp[i-1][0][1], dp[i-1][0][0] - prices[i]); // 第一次买入
dp[i][1][0] = max(dp[i-1][1][0], dp[i-1][0][1] + prices[i]); // 第一次出售
dp[i][1][1] = max(dp[i-1][1][1], dp[i-1][1][0] - prices[i]); // 第二次买入
dp[i][2][0] = max(dp[i-1][2][0], dp[i-1][1][1] + prices[i]); // 第二次出售
}
return dp[n-1][2][0];
}
显然这个DP同之前一样可以优化,每个状态都只用到前一天的,因此不需要保存所有天数的状态。这里k为2,买入卖出占2个状态,实际也只需要4个变量就能表示,但是用一个3x2数组也无所谓。
1 | int maxProfit(vector<int>& prices) { |
这题还有个特殊的方法,因为k=2,所以这题相当于求最高和次高的收益。他俩一定是不重合的,因此可以通过顺序扫描和逆序扫描两次遍历来找出两个较高收益。因为不重合,所以用扫描经过的点将前段最高收益和后段最高收益区分开。
1 | int maxProfit(vector<int>& prices) { |
买卖股票的最佳时机 4
与之前类似,将变化的k纳入循环之中即可,由于k可能很大,这时问题实际已退化到可以交易任意次的情况,因此在前面加入判别即可。
1 | int maxProfit(int k, vector<int>& prices) { |
买卖股票含冷冻期
距离上次交易需要有一天冷冻期,即卖出后不能立马买。相当于依赖的状态需要用到前两天,只需稍微改动即可。
1 | int maxProfit(vector<int>& prices) { |
买卖股票含手续费
1 | int maxProfit(vector<int>& prices, int fee) { |
秋叶收藏集
小扣出去秋游,途中收集了一些红叶和黄叶,他利用这些叶子初步整理了一份秋叶收藏集 leaves, 字符串 leaves 仅包含小写字符 r 和 y, 其中字符 r 表示一片红叶,字符 y 表示一片黄叶。
出于美观整齐的考虑,小扣想要将收藏集中树叶的排列调整成「红、黄、红」三部分。每部分树叶数量可以不相等,但均需大于等于 1。每次调整操作,小扣可以将一片红叶替换成黄叶或者将一片黄叶替换成红叶。请问小扣最少需要多少次调整操作才能将秋叶收藏集调整完毕。输入:leaves = “rrryyyrryyyrr”
输出:2
解释:调整两次,将中间的两片红叶替换成黄叶,得到 “rrryyyyyyyyrr”
题意就是需要将序列分为三个区间,使得所有区间不合条件的叶子最少,比如左区间需要黄叶最少,中区间需要红叶最少,且需要它们总体最少。
字符串匹配
编辑距离
- 关键点
有三种操作,那么两个字符串在求解到子问题 i, j位置时,它们的上一个状态可以从三种情况转移过来,同时如果当前字符相等的话,就可以节省一次操作。
1 | int minDistance(string a, string b) { |
1 | int minDistance(string a, string b) { |
通配符匹配
给定一个字符串 (s) 和一个字符模式 (p) ,实现一个支持 ‘?’ 和 ‘*’ 的通配符匹配。
‘?’ 可以匹配任何单个字符。
‘*’ 可以匹配任意字符串(包括空字符串)。
问号可以匹配单个字符,那么直接将其看作一个万能字符即可,而星号可匹配任意字符串,包括空串,也就是星号可以匹配0次,1次和多次。先设出状态,令$f(i, j)$表示字符串$s$的$i$之前的子串和字符串$p$的$j$之前的子串是否匹配。那么遇到星号的状态转移为:
分别是表示匹配0次,1次和多次的情况,然而匹配1次和多次的情况是可以合并的,都用多次来表示,这样状态表示为:
这个转移方程也可以解释为是否使用星号。
初始化:
当字符串都为空时是匹配的,因此f(0, 0) 应该是匹配的,并且当s为空,p全为*时也是匹配的。
1 | bool isMatch(string s, string p) { |
正则表达式匹配
给你一个字符串 s 和一个字符规律 p,请你来实现一个支持 ‘.’ 和 ‘*’ 的正则表达式匹配。
‘.’ 匹配任意单个字符
‘*’ 匹配零个或多个前面的那一个元素
所谓匹配,是要涵盖 整个 字符串 s的,而不是部分字符串。
- 动态规划思路
先从爆搜的思路看这个问题,对于字符串p,因为*
的存在,它是可变长的。如果在搜索过程中贪心的让它匹配当前能匹配的最长串,就可能错过回溯能正确匹配的串。比如:
1 | aaa |
那么对于当前长度的s,可能p有多个能与它匹配的方案,为了在枚举这些方案的时候减少重复计算,就可以使用动态规划的思想,保存之前能够成功匹配的方案。
状态定义: $dp(i, j)$ 表示$s$的$[0, \;i]$范围子串和$p$的$[0, \; j]$范围子串是否能够匹配。
在已知$dp(i - 1, j - 1)$和$s[i], p[j]$的情况,可以根据$s[i] == p[j]$得到$dp(i, j) = dp(i-1,j-1)$。
.
表示任意单个字符,因此它可以纳入普通的比较单个字符是否相等的情况。
*
则可以复制任意次其前面的字符,其中对应关系为:
0次:$dp(i, j - 2)$
1次:$dp(i-1, j - 2)$
…
n次: $dp(i-n, j -2)$
这样罗列情况太多了,当复制多次(大于等于1)时,可以看作当前*
保留,而s串删除末尾一个,这样它将是相同情况,后面再访问到还可以执行同样的操作。得到
$dp(i - 1, j)$
整理得到转移方程:
- 代码细节
- 当两个串都是空串时应该是匹配的,并且当s是空串,p是形如”a*”这样的串应该也是匹配的,它需要初始化$dp(0, 0) = true$。那么$dp(0, 0)$便不能用来表示第一个字符是否匹配,为了方便,在s和p前添加一个空格来适配这种情况。这样如果是空串的话,添加空格后,$dp(1, 1)$表示空串的状 态·。
- 还须注意的是字符串下标从$0$开始,因此在判断$dp(i, j)$时,它们实际对应的字符是$s[i-1]$和$p[j - 1]$.
- 在$p[j] = ‘*’$的情况下,复制一次和复制多次的情况是可以合并的,可统一表示为$dp(i-1, j)$。
1 | bool isMatch(string s, string p) { |
区间DP
最长回文子串
1 | string longestPalindrome(string s) { |
最长回文子序列
1 | int longestPalindromeSubseq(string s) { |
两端DP
执行乘法运算的最大分数
给你两个长度分别
n
和m
的整数数组nums
和multipliers
,其中n >= m
,数组下标 从 1 开始 计数。初始时,你的分数为
0
。你需要执行恰好m
步操作。在第i
步操作(从 1 开始 计数)中,需要:
- 选择数组
nums
开头处或者末尾处 的整数x
。- 你获得
multipliers[i] * x
分,并累加到你的分数中。- 将
x
从数组nums
中移除。在执行
m
步操作后,返回 最大 分数。
题意就是需要在nums中依次为multipliers中的数找到一个数与它相乘,使得所有乘积的和最大。并且只能在nums的两端寻找,nums中的数只能用一次。
因为用过的数不能再用,所以不能贪心。具体点就是需要在nums中找m次,每次有两种不同的选择。将状态设置为左边取走l个数,右边取走r个数的和。观察选择后的结果,是取走左边或右边的一个数,这个状态可从前一个状态转移过来。并且与上一个状态之前的状态(左右分别是第几次取走的)无关。
因此只需要枚举左右取的数量:
1 | int maximumScore(vector<int>& nums, vector<int>& mp) |