两个回文子序列长度的最大乘积 图解题解
这道题到底在问什么
- 输入
- s = "leetcodecom"
- 输出
- 9(选 "ete" 和 "cdc",乘积 3×3)
- 输入
- s = "accbcaxxcxx"
- 输出
- 25(选 "accca" 和 "xxcxx",乘积 5×5)
- 输入
- s = "bb"
- 输出
- 1(两个 "b" 各占一个,乘积 1×1)
先想最直接的笨办法
这一种分法:绿色的下标 0、1、4 组成 A,读作 aba;蓝色的下标 3 组成 B,读作 b;灰色的位置不用。让 A 长一点试试:取下标 0、1、4 组成 aba。(动画第 13 步)
最优解:为什么这么做
一句话答案:LeetCode 2002 两个回文子序列长度的最大乘积:n≤12 让指数枚举成为正解,位掩码枚举每个下标子集、预判是否回文,再对补集枚举子掩码配对,两组下标不共用,取乘积最大,时间 O(3ⁿ+2ⁿ·n)、空间 O(2ⁿ)。
两个回文子序列不相交,到底在挑什么
从 s 里挑两个子序列(删掉若干字符、剩下保持原顺序),各自是回文(正读反读一样),互不相交——同一个下标不能两边都用,求长度乘积最大。题面 leetcodecom 里 ete(下标 1、3、7)配 cdc(4、6、8),零重叠,3×3=9。s 长度顶多 12,是整道题的方向盘。
先抢一条最长回文,为什么会亏
先拿一条最长回文、剩下再取第二条,在题面 accbcaxxcxx 上露馅:长 5 的最长回文不止一条——取 accca,剩下恰好凑出 xxcxx,5×5=25;取 ccbcc 就抢走了 xxcxx 中间那个 c,最多剩 xxxx,5×4=20。
每个下标三个去处(进第一条、进第二条、不用),3¹²≈53 万种分法,机器一眨眼;别硬套 DP(动态规划,把「区间内最长回文多长」存表复用,这题数得完,用不上),枚举就是正解。
选了哪些下标,怎么塞进一个整数里
用位掩码记「选了哪些下标」:整数二进制第 i 位是 1 表示选中下标 i,掩码最右一位对应下标 0,4095 个掩码(n=12 时从 1 数到 2¹²−1=4095)扫遍全部非空子集,这套压法就是状态压缩。两件事变简单:m1 & m2 == 0(没有一位同时是 1)就是两组下标不共用;掩码里 1 的个数就是子序列长度。
参考代码先开布尔表 p,对每个掩码 k 用两端指针验回文:为 0 的位直接跳过,撞上 s[i] != s[j] 就记 p[k] = False。
第二条为什么只能去补集里挑
对每个回文掩码 i,第二条只从补集 mx = ((1 << n) - 1) ^ i(1<<n 是把 1 左移 n 位;(1<<n)−1 就是 n 位全 1;^ 是异或——把 i 里是 1 的位翻成 0,得到补集)里挑:从中选的子掩码天然不和 i 相交。枚举从 j = mx 开始,每步 j = (j - 1) & mx,把减 1 后越出补集的位掐掉,不重不漏扫完全部子集,p[j] 为真就拿两边 1 的个数相乘更新答案。
bb 的三个掩码各自长什么样
题面 s = bb 只有三个掩码:01 选出单个 b,单字符自然回文,p[01] 为真;10 同理;11 选出 bb,两端都是 b,也为真。
配对:第一条取 01 时补集 mx=10,j 从 10 起步,p[10] 为真,1×1=1 记进答案;再走 j=(10-1)&10=00 结束。第一条取 10 时补集是 01,同样得 1。取 11 时补集是 00:整串占满,另一条只能空着。答案 1,对上题面。
子掩码枚举漏了与上 mx,会把哪些非法配对数进来
把 j = (j - 1) & mx 写成裸的 j = j - 1,会把和 i 抢下标的掩码也数进候选,答案跟着变大。空集天然被挡在外:j 从 mx 起步、条件 j 非零;n≥2 时答案至少 1,两个单字符各占一位就是一对。
验回文一步扫 2ⁿ 个掩码、每个指针走 O(n)(大 O,衡量数据变大时操作量怎么涨);配对的子掩码总数是 3ⁿ,还是三个去处那笔账。合计 O(3ⁿ + 2ⁿ·n),空间是布尔表 O(2ⁿ)。
▶ 动画逐步走查(共 28 步)——想跟着动画一帧帧对照就展开
- 3记牢这一句:每个下标要么进 A、要么进 B、要么不用,A 和 B 各自是回文、互不占用同一个下标。把所有分法试一遍,长度乘积最大的那个就是答案。下面用 abcba 这五个字符,一种分法一种分法地走。
- 4先把 s 等于 abcba 摆好,下标从 0 到 4。中间那个 c 只有一个,两端是对称的 a 和 b。绿色代表这个下标进 A 组,蓝色代表进 B 组,灰色代表这一位不用。右边的面板会记下我们试过的每一种分法和它的乘积。现在还没开始,面板是空的。
- 5强调一下规则再动手。abcba 有五个位置,每个位置只能落到一个去处:要么是 A 的一部分,要么是 B 的一部分,要么空着不选。所以两组永远不会抢同一个字符。下面我挑几种有代表性的分法,带你把判断流程走顺,你就明白代码在幕后是怎么把所有分法都扫一遍的。
- 6这一种分法:绿色的下标 0、4 组成 A,读作 aa;蓝色的下标 1、3 组成 B,读作 bb;灰色的位置不用。两端的 a 配 a 做 A,中间的两个 b 做 B,c 不用。
- 7先验 A 组是不是回文。用两个指针从 A 的两端往中间收,先比最外层:s[0] 是 a,s[4] 是 a。一路收到中间都对得上,aa 是回文,长度记作 2。
- 8再验 B 组。同样两端往里收,s[1] 是 b,s[3] 是 b。bb 是回文,长度 2,两组都合法,可以算乘积了。
- 9两组都合法。A 长 2,B 长 2,乘积 4。比之前记的最优 还高,把最优刷新成 4。面板里把这条记上。
- 10这一种分法:绿色的下标 0、1 组成 A,读作 ab;蓝色的下标 3、4 组成 B,读作 ba;灰色的位置不用。故意选一组不回文的 A,看看流程怎么把它淘汰。
- 11先验 A 组是不是回文。用两个指针从 A 的两端往中间收,先比最外层:s[0] 是 a,s[1] 是 b。这两端就对不上了,ab 不是回文,这种分法直接淘汰,连 B 都不用看了。
- 12A 组不是回文,那不管 B 怎么选,这种分法都不合法,把它标红作废。右边面板记一笔:这条淘汰。当前保持的最优乘积还是 4,接着换下一种分法。
- 13这一种分法:绿色的下标 0、1、4 组成 A,读作 aba;蓝色的下标 3 组成 B,读作 b;灰色的位置不用。让 A 长一点试试:取下标 0、1、4 组成 aba。
- 14先验 A 组是不是回文。用两个指针从 A 的两端往中间收,先比最外层:s[0] 是 a,s[4] 是 a。一路收到中间都对得上,aba 是回文,长度记作 3。
- 15再验 B 组。同样两端往里收,s[3] 是 b,s[3] 是 b。b 是回文,长度 1,两组都合法,可以算乘积了。
- 16两组都合法。A 长 3,B 长 1,乘积 3。没有超过已经记下的 4,最优不变。面板里把这条记上。
- 17这一种分法:绿色的下标 0、2、4 组成 A,读作 aca;蓝色的下标 1、3 组成 B,读作 bb;灰色的位置不用。这次让 A 跳过中间取 0、2、4 组成 aca,B 还是两个 b。
- 18先验 A 组是不是回文。用两个指针从 A 的两端往中间收,先比最外层:s[0] 是 a,s[4] 是 a。一路收到中间都对得上,aca 是回文,长度记作 3。
- 19再验 B 组。同样两端往里收,s[1] 是 b,s[3] 是 b。bb 是回文,长度 2,两组都合法,可以算乘积了。
- 20两组都合法。A 长 3,B 长 2,乘积 6。比之前记的最优 还高,把最优刷新成 6。面板里把这条记上。
- 21这一种分法:绿色的下标 1、2、3 组成 A,读作 bcb;蓝色的下标 0、4 组成 B,读作 aa;灰色的位置不用。换个对称中心:A 取中间三个 bcb,B 取两端的 aa。
- 22先验 A 组是不是回文。用两个指针从 A 的两端往中间收,先比最外层:s[1] 是 b,s[3] 是 b。一路收到中间都对得上,bcb 是回文,长度记作 3。
- 23再验 B 组。同样两端往里收,s[0] 是 a,s[4] 是 a。aa 是回文,长度 2,两组都合法,可以算乘积了。
- 24两组都合法。A 长 3,B 长 2,乘积 6。没有超过已经记下的 6,最优不变。面板里把这条记上。
- 25这一种分法:绿色的下标 0、1、2、3、4 组成 A,读作 abcba;蓝色的下标 暂无 组成 B,读作 空;灰色的位置不用。极端一点:整串 abcba 全给 A,看 B 会怎样。
- 26先验 A 组是不是回文。用两个指针从 A 的两端往中间收,先比最外层:s[0] 是 a,s[4] 是 a。一路收到中间都对得上,abcba 是回文,长度记作 5。
- 27A 已经把能用的字符占满了,剩给 B 的是空的。可题目要两个子序列,B 不能是空,长度 0 一乘乘积就是 0,这种分法虽然 A 很长也没意义。
- 28这种分法乘积是 0,对答案没有帮助,略过。当前最优乘积还是 6。
- 29所有代表性的分法都试完了,回放一下赢家。A 取下标 0、2、4 组成 aca,是长度 3 的回文;B 取下标 1、3 组成 bb,是长度 2 的回文;两组不占同一个字符。乘积 3 乘 2 等于 6,这就是最大乘积。面板里这一行正是最高的那个。
- 30再看一眼这个画面:绿色的 aca 和蓝色的 bb 拼在一起正好用掉五个位置,c 归 aca、两个 a 也归 aca、两个 b 归 bb。它俩长度相乘 6,胜过其它所有分法。参考代码做的就是把这种分法穷举一遍,只不过它用位掩码来枚举下标集合,速度更快。
⚠️ 容易写错的地方
✗ 错:以为要在字符串上跑一个复杂的区间动态规划
✓ 对:n 很小,直接位掩码枚举所有子集
约束 n ≤ 12 意味着子集数量 2ⁿ 最多四千多,枚举加判回文完全跑得动,不必套编辑距离那类 DP
✗ 错:把 B 允许取空、算出乘积 0 也去更新答案
✓ 对:要求两个子序列都非空,枚举 j 时从 1 开始、跳过空集
空子序列长度 0,乘积恒为 0,既不合题意也拉不高答案,让 B 非空才是正解
✗ 错:判回文时把没选中的下标也拿去比较
✓ 对:两端指针先跳过没被选中的位,只比选中的字符
子序列只由被选中的下标组成,没选的字符根本不在这个子序列里,比了就判错
✗ 错:让 A 和 B 共用同一个下标
✓ 对:B 只能在 A 的补集里选,保证两组下标不相交
题目要求两个子序列不相交,同一个字符不能算两次长度
完整代码(Python / C++ / Java)
Python
from __future__ import annotations
from typing import *
from collections import *
from functools import *
from itertools import *
from math import *
from heapq import *
from bisect import *
class Solution:
def maxProduct(self, s: str) -> int:
n = len(s)
p = [True] * (1 << n)
for k in range(1, 1 << n):
i, j = 0, n - 1
while i < j:
while i < j and (k >> i & 1) == 0:
i += 1
while i < j and (k >> j & 1) == 0:
j -= 1
if i < j and s[i] != s[j]:
p[k] = False
break
i, j = i + 1, j - 1
ans = 0
for i in range(1, 1 << n):
if p[i]:
mx = ((1 << n) - 1) ^ i
j = mx
a = i.bit_count()
while j:
if p[j]:
b = j.bit_count()
ans = max(ans, a * b)
j = (j - 1) & mx
return ansC++
#include <algorithm>
#include <array>
#include <cctype>
#include <climits>
#include <cmath>
#include <deque>
#include <functional>
#include <iostream>
#include <limits>
#include <map>
#include <numeric>
#include <queue>
#include <set>
#include <sstream>
#include <stack>
#include <string>
#include <tuple>
#include <unordered_map>
#include <unordered_set>
#include <utility>
#include <vector>
using namespace std;
class Solution {
public:
int maxProduct(string s) {
int n = s.size();
vector<bool> p(1 << n, true);
for (int k = 1; k < 1 << n; ++k) {
for (int i = 0, j = n - 1; i < j; ++i, --j) {
while (i < j && !(k >> i & 1)) {
++i;
}
while (i < j && !(k >> j & 1)) {
--j;
}
if (i < j && s[i] != s[j]) {
p[k] = false;
break;
}
}
}
int ans = 0;
for (int i = 1; i < 1 << n; ++i) {
if (p[i]) {
int a = __builtin_popcount(i);
int mx = ((1 << n) - 1) ^ i;
for (int j = mx; j; j = (j - 1) & mx) {
if (p[j]) {
int b = __builtin_popcount(j);
ans = max(ans, a * b);
}
}
}
}
return ans;
}
};Java
import java.util.*;
class Solution {
public int maxProduct(String s) {
int n = s.length();
boolean[] p = new boolean[1 << n];
Arrays.fill(p, true);
for (int k = 1; k < 1 << n; ++k) {
for (int i = 0, j = n - 1; i < n; ++i, --j) {
while (i < j && (k >> i & 1) == 0) {
++i;
}
while (i < j && (k >> j & 1) == 0) {
--j;
}
if (i < j && s.charAt(i) != s.charAt(j)) {
p[k] = false;
break;
}
}
}
int ans = 0;
for (int i = 1; i < 1 << n; ++i) {
if (p[i]) {
int a = Integer.bitCount(i);
int mx = ((1 << n) - 1) ^ i;
for (int j = mx; j > 0; j = (j - 1) & mx) {
if (p[j]) {
int b = Integer.bitCount(j);
ans = Math.max(ans, a * b);
}
}
}
}
return ans;
}
}复杂度
时间
O(3ⁿ + 2ⁿ·n)
n 是字符串长度。判回文那步扫 2 的 n 次方个子集、每个子集两端指针走 O(n),是 2ⁿ 乘 n;配对那步对每个子集枚举补集的所有子掩码,所有子集的子掩码总数是 3 的 n 次方。因为 n 最多 12,总量完全可控
空间
O(2ⁿ)
按峰值算。主要开销是记录每个子集是否回文的布尔数组 p,长度 2 的 n 次方。除此之外只用了几个指针和计数变量,是常数级
看不够?换成动画再走一遍
上面的推演每一步都对应一帧动画。点开交互动画版,能一步步看着 两个回文子序列长度的最大乘积 的数据怎么变、指针怎么走,还能切 Python / Java / C++ 跟着练。
面试官可能追问
判回文时跳过掩码里为 0 的位,为什么和先抽出子序列再判等价?+
子序列就是把选中的字符按原顺序排成一串。两端指针从整串两头往中间收、遇到未选中的位就跳,实际比较的正是这串字符的第一个对最后一个、第二个对倒数第二个,和把它抽出来再做常规回文判定的比较对完全一致,只是省掉了真的拼串。
j = (j - 1) & mx 为什么恰好把 mx 的全部子掩码各走一次?+
把 mx 里为 1 的那些位单独看成一个二进制计数器,j 的取值只在这些位上变化。j - 1 把最低的 1 借掉、更低位全变 1,再与上 mx 把不属于 mx 的位清零,效果就是这个计数器从全 1 一路倒数到 0,每种组合恰好出现一次,循环次数正是 mx 的子集数。
n 到多大这套枚举就撑不住了?+
主项是 3ⁿ:n=12 约 53 万,n=15 约 1400 万还能跑,n=20 约 35 亿就不行了。这题把 n 卡在 12,就是明确告诉你按指数枚举设计;约束若放大到几十,得换成完全不同的模型,那就是另一道题了。这套状压全枚举在 LeetCode 1947 最大兼容性评分和里是同款配方,m≤8 的一一配对走的同一条路。
只枚举 i 的补集子掩码,为什么不会漏掉最优的那对?+
设最优解是不相交的一对 (A, B)。B 和 A 不共用任何下标,B 的掩码必然只含 A 补集里的位,正是补集的一个子掩码。外层扫到 i=A 那一轮,内层一定枚举到 j=B,这对乘积就被算过;轮到 i=B 时还会以 (B, A) 再来一次,重复比较不影响取最大。
想听吴师兄把这道题讲给你听?
文字版和动画都随便看。开通图解算法年卡,可以听吴师兄把 两个回文子序列长度的最大乘积 一步步讲透(全站已上线 905 份讲透,持续扩充), 卡住的地方还有 AI 私教小欧就着动画帮你拆到懂。
本平台为独立第三方培训机构,与华为技术有限公司无任何关联;课程的服务内容与权益以购买协议为准,学习效果因个人情况而异。「华为 OD」「华为可信」等仅为对岗位与考试方向的客观描述,相关商标归各自权利人所有。