最长回文子序列的动态规划优化,你以为很简单?但真的是这样吗?
你以为最长回文子序列问题不过是个小小的动态规划问题,稍微动动脑子,随便写几行代码就能搞定?哈哈,太天真了!别急,先别走开,今天我就带你见识一下,如何通过动态规划玩出花样,还能把空间复杂度狠狠压缩,真正实现算法的优雅与高效。
问题定义
给定一个字符串 s
,需要找到 s
的最长回文子序列的长度。回文子序列是指正读和反读相同的子序列。
DP 基本思路
设 dp[i][j]
表示字符串 s
在区间 [i, j]
内的最长回文子序列长度。我们可以通过以下递推关系来构建 DP 表:
-
初始状态:
- 当
i == j
时,dp[i][j] = 1
,因为单个字符本身就是一个回文子序列。 - 当
i > j
时,dp[i][j] = 0
,这种情况是非法的,不存在子序列。
- 当
-
状态转移方程:
- 如果
s[i] == s[j]
,那么dp[i][j] = dp[i+1][j-1] + 2
。这是因为s[i]
和s[j]
相等,可以将它们包围在dp[i+1][j-1]
的最长回文子序列的两端,形成新的回文子序列。 - 如果
s[i] != s[j]
,那么dp[i][j] = max(dp[i+1][j], dp[i][j-1])
。此时只能选择丢弃其中一个字符,来寻找剩余部分的最长回文子序列。
- 如果
你以为就是一张二维表?但真的是这样吗?
很多人都会第一时间想到二维DP表。是的,没错,我们可以通过一个 n * n
的表来存储子问题的解,甚至用 O(n^2)
的空间来轻松搞定问题。但等等,你真的以为这样就是最佳解法了吗?难道我们就甘心随便写写,浪费那么多宝贵的空间吗?作为一个技术追求完美的程序员,我们必须逼自己一把,把这玩意优化到极致!
滚动数组,原来空间可以这么省!
现在,问题来了,怎么做才能既保持时间复杂度的高效,又把空间复杂度优化得更低呢?答案就是——滚动数组!没错,只需要两个简单的一维数组,你就可以让空间复杂度从 O(n^2)
降到 O(n)
,对,就是这么酷炫!
让我们深入看看这个骚操作。你还记得我们的DP表是如何更新的吗?是不是只用到了 dp[i+1][j-1]
、dp[i+1][j]
和 dp[i][j-1]
?嘿,这就是关键!我们其实只需要当前行和上一行的状态来推导新的一行。其他的?统统扔掉,根本不需要记!
这里就是具体的代码实现,准备好了吗?
int longestPalindromeSubseq(string s) {
int n = s.size();
vector<int> dp_curr(n, 0), dp_prev(n, 0); // 两个一维数组
for (int i = n - 1; i >= 0; --i) {
dp_curr[i] = 1; // 单个字符的回文长度为1
for (int j = i + 1; j < n; ++j) {
if (s[i] == s[j]) {
dp_curr[j] = dp_prev[j - 1] + 2; // 如果两边字符相等
} else {
dp_curr[j] = max(dp_curr[j - 1], dp_prev[j]); // 否则取最大值
}
}
dp_prev = dp_curr; // 更新前一行
}
return dp_curr[n - 1]; // 返回最长回文子序列的长度
}
你看,就是这么简单的几行代码,却能让你在算法竞赛中迅速脱颖而出。注意到关键点了吗?每次我们计算 dp_curr[j]
的时候,只用关心上一行 dp_prev
的值和当前行 dp_curr
的前一个值。所有不需要的信息都被无情抛弃了!这就是空间优化的力量。
如何理解这段代码?
为什么这段代码如此有效?深挖一下,我们可以发现:
- 初始状态:对于每个
i
,dp_curr[i]
初始化为 1,表示每个单字符的回文长度。 - 双层循环:外层循环从字符串尾部向前遍历,内层从
i+1
开始向后遍历。这样可以保证每次计算dp_curr[j]
时,前面的值都已经被正确更新。 - 状态转移:当
s[i] == s[j]
时,表示两边字符可以围成一个更长的回文,所以加上dp_prev[j-1]
的值;否则,我们只能选择dp_curr[j-1]
或dp_prev[j]
中较大的一个。
这段代码不仅逻辑清晰,而且极具扩展性。当你掌握了这个优化技巧,类似的DP问题,你都可以用类似的方法来进行空间优化。
学会了吗?那就赶紧实践一下!
到这里,很多人可能已经迫不及待地想去实践了!但我得提醒你,理论与实践结合,才是让你真正掌握这个优化技巧的唯一途径。下次再遇到类似的问题,别忘了这次的学习!这不仅仅是为了优化空间复杂度,而是为了让你在算法的世界里如鱼得水!