浅谈最长公共子序列引发的经典动态规划问题
前言
关注公众号【程序员白泽】,带你走近一个不一样的程序猿/学生党,公众号平时会同步更新博客文章,回复【简历】即可获得我使用的简历模板。
希望上海疫情尽早过去,其实有一段稳定的时间是比较适合沉淀一下技术的,多少还是自己有些散漫,近期应该会恢复更新《手撕MySQL》系列文章。这篇文章通过一道经典例题:最长公共子序列,给大家讲讲动态规划,并且给出一道LeetCode周赛动态规划题作为练手并讲解,相信看完文章之后,你会对动态规划有更深的理解。
关于后面的dp练手题,是某次周赛的第四题,借助这题,我会在后面分析部分讲解如何从读题开始,沉浸式一步一步解决一个算法题。这个过程适用于所有的题目,比较重要,当然我们先从经典的最长公共子序列入手。
最长公共子序列
题目链接:LeetCode 1143
题目
给定两个字符串 text1 和 text2,返回这两个字符串的最长公共子序列的长度。如果不存在公共子序列,返回0。
一个字符串的子序列是指这样一个新的字符串:它是由原字符串在不改变字符的相对顺序的情况下删除某些字符(也可以不删除任何字符)后组成的新字符串,如ace
是abcde
的子序列。
两个字符串的 公共子序列 是这两个字符串所共同拥有的子序列。
分析
设dp[i] [j]为text1的前i个字符组成的串str1和text2的前j个字符组成的串str2的最长公共子序列,初始化时:dp[0] [j]与dp[i] [0]都为0,因为str1为空或者str2为空都将无法构成子序列
根据上面的表述,text1[i-1]是str1的最后一个字符,而不是text1[i],因为下标从0开始;同理test2[j-1]表示str2的最后一个字符
那么就可以开始讨论状态转移方程: 如果 text[i-1] == text2[j-1],表示str1的最后一个字符和str2的最后一个字符相等,那么dp[i] [j] = dp[i-1] [j-1] + 1,可以理解成两个字符串都去掉相等的末尾字符,然后在前面剩余的字符中再求最长公共子序列,最后结果+1,因为这个过程是可以追溯的,因此满足动态规划的要求
如果 text[i-1] != text2[j-1],则dp[i] [j] = max(dp[i-1] [j], dp[i] [j-1]) ,因为dp[i-1] [j]和dp[i] [j-1]都是已经求出来的字问题的解,所以可以追溯,既然str1和str2的末尾不一样,那么就让str1去掉末尾和str2求解或者str2去掉末尾和str1求解,两者取最大值即可
代码
用的是go语言,但语言不是障碍~
func longestCommonSubsequence(text1 string, text2 string) int {
dp := make([][]int, len(text1)+1)
for i := range dp {
dp[i] = make([]int, len(text2)+1)
}
for i := 1; i <= len(text1); i++ {
for j := 1; j <= len(text2); j++ {
if text1[i-1] == text2[j-1] {
dp[i][j] = dp[i-1][j-1] + 1
} else {
dp[i][j] = max(dp[i-1][j], dp[i][j-1]) //为节约篇幅max函数就不写明了,这里需要自己实现
}
}
}
return dp[len(text1)][len(text2)]
}
用地毯覆盖后的最少白色砖块
题目链接:LeetCode 2209
题目
给你一个下标从 0 开始的 二进制 字符串 floor ,它表示地板上砖块的颜色。
- floor[i] = '0' 表示地板上第 i 块砖块的颜色是 黑色 。
- floor[i] = '1' 表示地板上第 i 块砖块的颜色是 白色 。
同时给你 numCarpets 和 carpetLen 。你有 numCarpets 条 黑色 的地毯,每一条 黑色 的地毯长度都为 carpetLen 块砖块。请你使用这些地毯去覆盖砖块,使得未被覆盖的剩余 白色 砖块的数目 最小 。地毯相互之间可以覆盖。
请你返回没被覆盖的白色砖块的 最少 数目。
分析
其实在拿到题的一开始,如果看不太明白题意,建议先对照输入输出样例去梳理,比如对于如下输入输出
输入:floor = "10110101", numCarpets = 2, carpetLen = 2
输出:2,表示最少有2块白色没有被覆盖到(1表示白色)
结合样例你大概懂了题意,好像是要用黑色的地毯尽可能去覆盖白色的连续区域,使得最后剩余的白色最少。大概明白做什么之后,去看输入输出数据的取值范围,因为这涉及到你设计的算法的时间复杂度(主要是时间),如下:
1 <= carpetLen <= floor.length <= 1000
floor[i] 要么是 '0' ,要么是 '1' 。
1 <= numCarpets <= 1000
错误思路
floor长度1000,看样子可以写一个O(N^2)的算法,你放心了。然后想:既然是尽可能去覆盖白色连续区域,且每次就是拿一个长度为L的地毯去覆盖,那么我只要每次找一个长度为L的拥有最多白色的块的区间去给他覆盖不就行了,然后把白色改成黑色,外循环是地毯数量,核心是贪心!我又行了!
下面给出一组测试数据:
输入:floor = "101111", numCarpets = 2, carpetLen = 3
输出:0
如果是贪心,那么首先会找到连续的3个1的部分,然后将其修改为100001,然后再找到包含一个1的长度为3的区间,将其修改为000001或者100000,无法到达最优效果:第一次覆盖前3块,第二次覆盖后3块。
正确思路
对于给出的数据,思考是否能使用dp求解,对于动态规划来说,首先要确定规模,一维的dp本题无法胜任,因为地毯数量有多块。
如果是二维dp,那么i和j分别表示什么,一般来说:我习惯于将j设置为被具体操作的“对象”空间(就像是0-1背包我会将背包空间设置为j,而物品的种类设置为i,因为所有i种物品都会放置在j大小的空间中,背包空间此时是被操作"对象"),本题所有的地毯覆盖到一个floor上,因此j的纬度是地砖数量(那么i的维度就是地毯的数量)
最终dp[i] [j]表示使用i块地毯覆盖前j块砖,所剩余的白色的地砖的最少数量
下面给出状态转移方程:
如果第i块地毯选择覆盖下标为j的地砖,则dp[i] [j] = dp[i-1] [j-carpetLen] ,表示只考虑前i-1块地毯去覆盖前j-carpetLen位置的最少白色块数量(因为覆盖了第i块的位置全都变黑)
如果第i块地毯选择不覆盖下标为j的地砖,则dp[i] [j] = dp[i] [j-1]+(floor[j] - '0') ,相当于只考虑用i块地毯去覆盖j-1的地砖,且可能第j块砖是白色的,因此要加上
代码
func minimumWhiteTiles(floor string, numCarpets int, carpetLen int) int {
dp := make([][]int, numCarpets+1)
for i := range dp {
dp[i] = make([]int, len(floor))
}
num := 0
for i := 0; i < len(floor); i++ {
num += int(floor[i] - '0')
dp[0][i] = num
}
for i := 1; i <= numCarpets; i++ {
// 注意j的起始位置,前carpetLen长度就是一块地毯的空间,此时的dp[i][j]一定是0
for j := carpetLen; j < len(floor); j++ {
// 对于j位置,需要在两种情况中选择白色数量最少的一种保留
dp[i][j] = min(dp[i][j-1]+int(floor[j] - '0'), dp[i-1][j-carpetLen])
}
}
return dp[numCarpets][len(floor)-1]
}
结束
建议在看过文章之后自己去做一下1143和2209两道题,相信你对动态规划的掌握一定会更上一层。算法水平在面试笔试当中还是十分重要的,经典动态规划题更是很多题目的模板出处,值得学习。
关注公众号【程序员白泽】,带你走近一个不一样的程序猿/学生党,公众号平时会同步更新博客文章,回复【简历】即可获得我使用的简历模板。