开头先讲个真实场景。回文子串系列问题在C++算法题里的出现频率,高到我一度以为出题人只会这一招:面试被问过三次LeetCode 5最长回文子串,牛客笔试里被回文子串计数恶心过,周赛里又被分割回文串折腾到心态崩。最基础的那版暴力写法——枚举所有子串再逐个双指针判断——几乎所有新手都能在五分钟内写出来,但数据范围一到10^4就原形毕露。这篇文章我不打算贴那种"最优解三行代码"的答案,而是把我自己写过的几条路线串起来讲清楚:中心扩展是怎么救场的、区间DP为什么遍历方向经常写反、马拉车那套半径数组到底在干什么,以及回文子序列这类变形题的DP定义差异。代码全部用C++给出,最后还会聊几个vector 和substr层面的工程坑,适合正在刷题准备面试的读者,也适合那些Manacher代码背了三遍还是不敢用的朋友。
1. 暴力解法的边界陷阱:中心扩展的奇偶两路
1.1 为什么"枚举所有子串再判断"不是好方案
先把大家最熟悉的写法摆出来,看看它到底输在哪:
bool isPalindrome(const string& s, int l, int r) { while (l < r && s[l] == s[r]) { ++l; --r; } return l >= r; } string longestPalindromeBruteForce(string s) { int n = s.size(); for (int len = n; len >= 1; --len) { for (int i = 0; i + len <= n; ++i) { if (isPalindrome(s, i, i + len - 1)) { return s.substr(i, len); } } } return ""; }这段代码的逻辑没错,但子串总数是O(n^2),每个子串再做一次O(n)的双指针扫描,整体就是O(n^3)。当n等于5000时,大约要执行10^11次字符比较,正常评测环境根本跑不动。更本质的问题是,它完全没有利用回文串内部的对称结构:判断"abacabad"时没记住"abacaba"是回文,判断"abacaba"时也没记住"aca"是回文。每次都是从零开始重新扫描一遍,重复劳动严重。
我见过不少刷题教程宣称"暴力也能过",前提往往是数据量小或者加了长度倒序的剪枝。但面试官问出这题的时候,期待的不是"能跑",而是"能优化"。所以暴力版本只适合当作思考起点,不适合当作答案。
1.2 中心扩展:把每个位置当作回文中心
回文串有一个非常朴素的性质:从中心向外,左右两侧的字符逐对相等。那么反过来想,我可以枚举所有可能的中心,然后像抽绳子一样向外扩张,直到左右字符不相等为止。
这里必须先搞清楚一个问题:字符串里的"回文中心"到底有几种?
- 奇数长度的回文,比如"aba",中心是一个字符'b'。这种中心有n个。
- 偶数长度的回文,比如"abba",中心是两个字符'b'和'b'之间的那条缝隙。这种中心有n-1个。
所以总共是2n-1个中心。对每个中心向外扩展,最坏情况下每个中心要比较O(n)次,总复杂度O(n^2),空间O(1)。这个复杂度在n=5000时完全能接受,而且代码比很多人想象中简单:
string longestPalindromeByExpand(const string& s) { int n = s.size(); if (n == 0) return ""; int bestStart = 0, bestLen = 1; auto expand = [&](int l, int r) -> pair<int, int> { while (l >= 0 && r < n && s[l] == s[r]) { --l; ++r; } // 结束时 l 和 r 分别落在合法回文区间外一格 return {l + 1, r - l - 1}; }; for (int i = 0; i < n; ++i) { auto [l1, len1] = expand(i, i); // 奇数长度中心 auto [l2, len2] = expand(i, i + 1); // 偶数长度中心 if (len1 > bestLen) { bestLen = len1; bestStart = l1; } if (len2 > bestLen) { bestLen = len2; bestStart = l2; } } return s.substr(bestStart, bestLen); }这个版本里我让expand函数直接返回一对值:左边起始位置和回文长度。这样做的好处是省去了在循环里手动推算起始位置的心智负担。如果你用start = i - (len - 1) / 2这种方式去算奇数长度回文的起点,还得分别处理奇偶两种公式,非常容易在边界上差一位。
1.3 边界收缩的细节与代码模板
中心扩展这版代码里其实藏着一个经典的差一错误,值得单独拎出来说。
while循环停止的时候,l和r有两种可能:要么是越界(l < 0 或 r >= n),要么是s[l] != s[r]。无论哪种情况,真正合法的回文区间是[l+1, r-1],而不是[l, r]。所以代码里必须做一次回退操作,也就是我写的return那行:
return {l + 1, r - l - 1};这里的r - l - 1等价于(r - 1) - (l + 1) + 1。很多人在这一行上写错过:直接在while结束后return {l, r - l + 1},结果算出的长度比真实回文长度大2,导致substr越界或者返回了错误子串。
另外还有一个小的优化习惯。在for循环里同时处理奇偶两个中心时,可以先判断len1再判断len2,也可以先更新bestStart再更新bestLen。代码本身对顺序不敏感,但为了可读性,我习惯先更新bestLen,再更新bestStart,逻辑上更顺。
中心扩展虽然只是暴力优化的第一步,但它其实是后面所有高级算法的思想源头。马拉车之所以能优化到O(n),本质上是减少了中心扩展过程中的重复比较;DP之所以让人觉得遍历方向反直觉,也是因为要先有"从中心向外"这个空间感。所以这一步值得写扎实。
2. 区间DP:回文矩阵的遍历顺序决定代码成败
2.1 状态定义:dp[i][j] 是一张布尔表
用动态规划做最长回文子串,状态定义本身并不难:令dp[i][j]表示字符串s从第i个字符到第j个字符这一段是否为回文。如果s[i] == s[j]且内部区间s[i+1...j-1]也是回文,那么s[i...j]就是回文。于是得到递推关系:
dp[i][j] = (s[i] == s[j]) && dp[i+1][j-1]这句话看起来人畜无害,写代码时却很容易栽跟头。难点在于:dp[i][j]依赖的是dp[i+1][j-1],也就是矩阵里"左下方"的格子,而不是通常遍历二维数组时刚算过的"上方"或"左方"格子。如果按照直觉,i从0到n-1、j从i到n-1这样双重循环下去,你会发现计算dp[i][j]时,dp[i+1][j-1]根本没有被计算过,读到的全是初始值。
所以这个题目看似简单,实则是在考察你对状态依赖方向的理解。很多人在LeetCode提交时Recursion或者Wrong Answer,压测数据一大就爆内存,往往都是卡在这一步。
2.2 递推关系的隐藏依赖:为什么要读左下角
把dp矩阵画出来的话,它是一个上三角矩阵。每行i从左往右j递增,每个格子的值依赖的是它的左下方格子dp[i+1][j-1]。
这个依赖方向注定了两个常用的遍历策略:
第一种是按长度枚举。长度从1到n递增,对每个长度len,枚举所有可能的左端点i,右端点j = i + len - 1。因为计算长度为len的区间时,长度为len-2的区间已经被算过了,所以dp[i+1][j-1]一定已经准备就绪。
第二种是从底向上逐行处理。i从n-1循环到0,j从i+1循环到n-1。因为i在递减,处理第i行时,第i+1行的数据是上一轮循环已经计算完的。
两种思路对应的代码我都写过,这里给出按长度枚举的版本:
string longestPalindromeByDP(const string& s) { int n = s.size(); if (n == 0) return ""; vector<vector<char>> dp(n, vector<char>(n, 0)); int bestStart = 0, bestLen = 1; // 长度 1 和长度 2 的基础情况 for (int i = 0; i < n; ++i) { dp[i][i] = 1; if (i + 1 < n && s[i] == s[i + 1]) { dp[i][i + 1] = 1; bestStart = i; bestLen = 2; } } // 从长度 3 开始递推 for (int len = 3; len <= n; ++len) { for (int i = 0; i + len - 1 < n; ++i) { int j = i + len - 1; if (s[i] == s[j] && dp[i + 1][j - 1]) { dp[i][j] = 1; bestStart = i; bestLen = len; } } } return s.substr(bestStart, bestLen); }从底向上逐行处理的版本写起来更短,判断条件里可以顺手处理长度小于等于3的区间:
for (int i = n - 1; i >= 0; --i) { dp[i][i] = 1; for (int j = i + 1; j < n; ++j) { if (s[i] == s[j] && (j - i <= 2 || dp[i + 1][j - 1])) { dp[i][j] = 1; int len = j - i + 1; if (len > bestLen) { bestLen = len; bestStart = i; } } } }这里j - i <= 2这个条件很多人第一次看会愣一下。其实它表示的是区间长度不超过3:长度2时j-i等于1,长度3时j-i等于2。当区间长度不超过3时,只要两端字符相等就一定是回文,不需要查内部状态。长度3的区间"aba"中间只有一个字符,自动回文;长度2的区间"aa"中间为空,也自动回文。这个条件本质上是在兜底dp[i+1][j-1]越界的情况。
2.3 两种遍历顺序与滚动数组优化
按长度遍历和从底向上遍历本质上是在描述同一种依赖关系,但它们的扩展性不一样。从底向上版本有一个天然优势:可以进一步优化空间。
观察递推公式,dp[i][j]只依赖同一行右边的dp[i][j-1]吗?不对,它依赖的是下一行的dp[i+1][j-1]和dp[i+1][j]。具体来说,在逐行计算的版本里,计算第i行时,只需要第i+1行的数据。所以我们可以用一个一维数组滚动保存上一行的结果,把空间从O(n^2)压到O(n)。
代码是这样写的:
string longestPalindromeByDP1D(const string& s) { int n = s.size(); if (n == 0) return ""; vector<char> dp(n, 0); int bestStart = 0, bestLen = 1; for (int i = n - 1; i >= 0; --i) { dp[i] = 1; // 单字符自身回文 for (int j = n - 1; j > i; --j) { if (s[i] == s[j] && (j - i <= 2 || dp[j - 1])) { dp[j] = 1; int len = j - i + 1; if (len > bestLen) { bestLen = len; bestStart = i; } } else { dp[j] = 0; } } } return s.substr(bestStart, bestLen); }这个版本里有一个非常容易忽略的细节:内层循环j必须从大到小遍历,不能从小到大。
原因是,dp[j]这个一维数组在计算第i行时,里面存的是第i+1行的结果。我们要读的dp[i+1][j-1]其实是上一行中位置j-1的值,也就是数组里的dp[j-1]。如果j从小到大遍历,计算到j的时候,dp[j-1]已经被当前行覆盖改写过了,读到的就不是上一行的值了。只有让j从大到小遍历,才能保证在读dp[j-1]时,它仍然是上一行留下的旧值。
这里有一个小细节需要说明:当j - i <= 2时,因为短路求值,dp[j-1]不会被真正读取,所以即使它的值不可靠也不会出错。但一旦区间长度大于3,dp[j-1]就必须是上一行的旧值,这时倒序遍历就变得至关重要。
2.4 滚动数组为什么必须倒序遍历j
我用一个具体例子帮大家加深记忆。假设字符串是"abcba",n=5。
在i=3这一步,dp[3]被置为1,然后j从4开始倒着向前。由于j=4时依赖的是dp[3]——此时dp[3]刚被设为1,代表dp[4][3]?这里要稍微想一下:在i=3这一轮,dp[4]这个位置代表的是dp[3][4],而不是dp[4][4]。我们去读取dp[3]的时候,它恰好是i=3这一轮自己写进去的"单字符回文"标记,表示的是dp[3][3]=1。当j=4时,j-i=1<=2,所以根本不读dp[3],而是直接根据s[3]==s[4]判断。这样逻辑完全自洽。
反过来,如果j从小到大遍历,在i=3之前,i=4那一轮计算的dp[4]已经被覆盖了;轮到j=5(如果存在)时,想要读取上一轮的dp[4]即dp[4][4],早已被当前行的dp[4]覆盖。所以顺序反了,结果就是各种灵异bug。
一句话总结:滚动数组在二维DP里压缩掉的是"行号"这个维度,靠的是"上一行数据暂时还在数组里"这一事实。为了保证在需要读取旧值的时候旧值还没被覆盖,遍历方向必须和更新方向相反。
3. 马拉车算法:在线性时间内求全部回文半径
3.1 预处理:插入分隔符,把所有回文变成奇数长度
中心扩展有一个绕不开的麻烦:回文长度有奇偶之分,处理时要分开讨论。马拉车解决这个问题的方式非常巧妙——在原始字符串的每个字符之间以及首尾插入一个分隔符,比如'#'。这样一来,无论原始回文是奇数长度还是偶数长度,在预处理后的字符串里都会变成奇数长度的回文,而且中心一定落在某个字符上(原来的字符或插入的'#')。
举个例子,"abba"在插入#后变成"#a#b#b#a#",原始偶数回文"abba"对应到新串里是以中间那个#为中心的"#a#b#b#a#";"abcba"变成"#a#b#c#b#a#",原始奇数回文"abcba"对应到以'c'为中心的"#a#b#c#b#a#"。
代码里的预处理我习惯这样写:
string preProcess(const string& s) { if (s.empty()) return "^$"; string t = "^"; for (char c : s) { t += '#'; t += c; } t += "#$"; return t; }注意我在开头和结尾额外加上了'^'和'$'两个哨兵字符。它们的作用是充当"永不匹配"的边界守卫,这样在后面的扩展循环里就不需要每次判断数组下标是否越界,因为一旦扩展到了哨兵位置,字符比较必然失败,循环自然停下来。哨兵字符的选择原则是:绝对不能出现在原始字符串中。如果原串可能包含任意ASCII字符,可以选择控制字符或者用一个额外的bool数组记录,但在竞赛和面试场景下,用'^'、'$'这类不会出现在输入串里的字符足够安全。
3.2 半径数组p[i]的含义与"回文长度=p[i]-1"的换算逻辑
预处理后,我们需要维护一个数组p,其中p[i]表示以新字符串t[i]为中心的最大回文半径。这个半径是包含中心字符本身的。换句话说,如果p[i]=3,说明t[i-3+1]=t[i-1]、t[i-2]=t[i+2]这一对对的字符匹配了2次,加上中心自己,总共覆盖了从i-2到i+2的5个字符。
有一个特别重要的换算关系:以t[i]为中心的回文,在原始字符串中对应的回文长度是p[i] - 1。
这个结论看起来像魔术,其实可以从字符数的守恒关系推出来。原始字符串长度为n,预处理后长度为2n+3(算上首尾哨兵)。每插入一个#,原来相邻的两个字符之间多了一个分隔符。假设一个回文在预处理串中的总长度为2 * p[i] - 1,其中包含的原始字符个数正好是(p[i] - 1),因为每隔一个位置才是一个原始字符。所以p[i] - 1就是原始回文长度。
同理,如果我们要根据p[i]反推这个回文在原始字符串中的起始位置,公式是(start = (i - p[i]) / 2)。这个公式看起来有点怪,尤其是当i - p[i]是负数的时候。实际上在C++里,负数对2的整数除法是向零取整的,所以"aa"这种情况代入后start会正确得到0。如果你担心负索引问题,可以在循环里顺手记录bestCenter和bestLen,最后再统一换算,避免中途反复算start。
3.3 对称性加速的完整推导:为什么要取min(p[mirror], right - i)
马拉车最核心的加速思想是:利用已经计算过的回文半径,减少对后续位置的重复扩展。
维护两个变量:center表示当前已知最右回文的中心,right表示这个最右回文的右边界(包含右边界,即回文覆盖到right位置)。当遍历到某个位置i时,如果i小于right,说明i落在这个已知回文覆盖的范围内。这个时候,取i关于center的镜像位置mirror = 2 * center - i。由于已知回文具有对称性,mirror处的回文结构可以给我们提供关于i处回文结构的重要信息。
关键来了:p[i]至少能取多大?答案是min(p[mirror], right - i)。
为什么是这个最小值?
- 如果p[mirror]完全落在已知回文[left, right]的内部,那么因为回文对称性,i处的回文半径至少和mirror处一样大。
- 但i处的回文半径不可能在未经验证的right之外肆意扩张,超出right右侧的部分我们完全没有信息,不能假设它也匹配。所以下界最多只能取到right - i,剩下的部分必须靠扩展循环去确认。
这里有一个非常容易被误解的点,我必须强调:min运算得到的结果只是p[i]的下界,不是最终值。拿到这个下界之后,代码里依然要做while扩展,继续比较t[i + p[i]]和t[i - p[i]],直到失配为止。不要因为p[i]已经在镜像处继承了一个较大的值就觉得可以跳过扩展。
3.4 完整实现与两个容易写错的边界
马拉车的完整C++代码并不长,但两个边界千万不要写错:
string longestPalindromeManacher(const string& s) { if (s.empty()) return ""; string t = "^"; for (char c : s) { t += '#'; t += c; } t += "#$"; int n = t.size(); vector<int> p(n, 0); int center = 0, right = 0; int bestCenter = 0, bestLen = 0; for (int i = 1; i < n - 1; ++i) { p[i] = (i < right) ? min(right - i, p[2 * center - i]) : 1; while (t[i + p[i]] == t[i - p[i]]) { ++p[i]; } if (i + p[i] > right) { right = i + p[i]; center = i; } if (p[i] - 1 > bestLen) { bestLen = p[i] - 1; bestCenter = i; } } int start = (bestCenter - bestLen) / 2; return s.substr(start, bestLen); }第一个边界是主循环的范围。我让i从1循环到n-2,正好跳过最左边的'^'和最右边的'$'哨兵。如果让i从0开始,t[i - p[i]]会访问到t[0]之前的位置,越界;如果让i到n-1结束,while里t[i + p[i]]会访问到字符串末尾之后。
第二个边界是right的更新时机。只有当i + p[i] > right时才更新center和right。这里用严格大于还是大于等于都可以,因为如果新回文右边界刚好等于right,那么它并不会给我们带来更远的已知范围。用大于可以减少无意义的center切换。
我还想再提一个亲测踩过的坑:镜像点p[2 * center - i]在极端情况下可能还没被计算过。会不会出现这种情况?当i < right时,因为i在center右边,mirror = 2 * center - i在center左边,一定小于i。而主循环i是从左到右递增的,所以mirror位置的p值一定已经被计算过了。这个约束保证了代码不会读到垃圾值。
4. 变形题拆解:从"找最长"到"数数量"再到"子序列"
4.1 回文子串计数:中心扩展天然适合计数
LeetCode 647要求统计字符串中所有回文子串的数量。很多人的第一反应是继续用DP数:dp[i][j]为true就cnt++。但事实上中心扩展法做这道题更顺手,因为每个中心向外扩展时,每成功比较一对字符,就恰好产生一个新的回文子串。
比如字符串"aaa",以中间那个'a'为中心,向外扩展三次分别得到"a"、"aaa"、单个"a"?这里要严谨地说:以索引1的'a'为中心向外扩展,第一次就是它本身,计1个,再向外扩展一步得到"aaa",又计1个。所以一个中心扩展几轮,就贡献几个回文子串。
实现代码很短:
int countSubstrings(const string& s) { int n = s.size(); int cnt = 0; auto expand = [&](int l, int r) { while (l >= 0 && r < n && s[l] == s[r]) { ++cnt; --l; ++r; } }; for (int i = 0; i < n; ++i) { expand(i, i); // 奇数长度中心 expand(i, i + 1); // 偶数长度中心 } return cnt; }注意这里奇数中心第一次扩展就把单个字符本身计入回文了,所以不会漏掉长度为1的情况。中心扩展法在O(n^2)时间内解决问题,空间O(1)。如果数据范围到了10^5,其实马拉车也能做O(n)计数:每个中心的半径为p[i],它贡献的原串回文子串个数是p[i] / 2(向下取整),把全部中心的贡献加起来即可。原理是每个中心向外扩张的每一步都对应原串一个不同长度的回文子串。这个技巧笔试里不太常用,但这个思路可以帮你理解马拉车半径数组到底存储了多少信息。
4.2 最长回文子序列:类型换成"长度"后的状态转移差异
最长回文子序列是另一道高频变形题,但它和最长回文子串有一个本质区别:子序列不要求字符连续。
正因为不连续,DP的状态含义就变了。dp[i][j]不再是"是否为回文"的布尔值,而是"s[i...j]这段区间内最长回文子序列的长度"。递推公式也换成标准的区间DP:
- 如果s[i] == s[j],那么这两个字符可以同时加入回文子序列的两端:dp[i][j] = dp[i+1][j-1] + 2。
- 如果s[i] != s[j],说明这两端不能同时参与最终答案,取丢弃左端或丢弃右端的最大值:dp[i][j] = max(dp[i+1][j], dp[i][j-1])。
代码如下:
int longestPalindromeSubseq(const string& s) { int n = s.size(); if (n == 0) return 0; vector<vector<int>> dp(n, vector<int>(n, 0)); for (int i = n - 1; i >= 0; --i) { dp[i][i] = 1; for (int j = i + 1; j < n; ++j) { if (s[i] == s[j]) { dp[i][j] = dp[i + 1][j - 1] + 2; } else { dp[i][j] = max(dp[i + 1][j], dp[i][j - 1]); } } } return dp[0][n - 1]; }这里遍历方向依然是i从底向上、j从小到大,因为dp[i][j]依赖dp[i+1][j]、dp[i+1][j-1]这些下一行的值。很多人在这个题上犯的错误是把回文子串的布尔DP套过来,试图用布尔值推导长度,结果发现转移完全对不上。根源在于子串版本是"区间是否构成回文",子序列版本是"区间内能选出的最长长度",两者是不同维度的问题。
如果想压缩空间,这里就不能用单数组滚动,因为转移同时依赖上一行的dp[i+1][j]和当前行的dp[i][j-1],以及上一行的dp[i+1][j-1],一个数组无法同时保留新旧两个时间点的数据。正确做法是用两个一维数组滚动:
int longestPalindromeSubseq(const string& s) { int n = s.size(); vector<int> dp(n, 0), prev(n, 0); for (int i = n - 1; i >= 0; --i) { dp[i] = 1; for (int j = i + 1; j < n; ++j) { if (s[i] == s[j]) { dp[j] = prev[j - 1] + 2; } else { dp[j] = max(prev[j], dp[j - 1]); } } prev = dp; } return dp[n - 1]; }这个版本里prev保存的是上一行即第i+1行的完整结果,dp是当前行正在计算的结果。每次内层循环结束后把dp整体拷贝给prev,供下一轮使用。理解这一点后,你会发现所有区间DP的滚动数组本质上都是在"旧行"和"新行"之间腾挪,思路是统一的。
4.3 分割回文串:DP预处理与回溯的组合拳
LeetCode 131要求把所有分割方案都列出来,比如"aab"要返回"a a b"和"aa b"两种。这道题的核心思路分两步:先用回文DP预处理出所有可能的回文区间,再DFS回溯枚举分割点。
预处理部分直接复用第二节的布尔DP,把所有isPal[i][j]标记好。然后从字符串起始位置开始,尝试每一个可能的切割点end,如果s[pos...end]是回文,就加入当前路径,递归处理end+1,回溯时弹出。
vector<vector<string>> partition(const string& s) { int n = s.size(); vector<vector<char>> isPal(n, vector<char>(n, 0)); for (int i = n - 1; i >= 0; --i) { isPal[i][i] = 1; for (int j = i + 1; j < n; ++j) { isPal[i][j] = (s[i] == s[j]) && (j - i <= 2 || isPal[i + 1][j - 1]); } } vector<vector<string>> ans; vector<string> path; function<void(int)> dfs = [&](int pos) { if (pos == n) { ans.push_back(path); return; } for (int end = pos; end < n; ++end) { if (isPal[pos][end]) { path.push_back(s.substr(pos, end - pos + 1)); dfs(end + 1); path.pop_back(); } } }; dfs(0); return ans; }这里有一个实用的小优化:DFS之前先用一个一维数组minCut[pos]记录从pos到末尾至少还需要切几次,如果当前路径长度已经超过某个已知最优值就剪枝。虽然题目本身不要求输出最小分割次数,但在数据量大时这个剪枝能省下不少时间。
4.4 各类题型的复杂度与代码量对照
我把这些变形的核心差异整理成一张表,方便你刷题时快速定位:
| 题目 | 状态定义 | 转移特点 | 时间复杂度 | 空间复杂度 |
|---|---|---|---|---|
| 最长回文子串 | dp[i][j]是否为回文 | 依赖左下角 | O(n^2) | O(n^2),可压O(n) |
| 回文子串计数 | 中心扩展计数 | 每个中心逐轮累加 | O(n^2),马拉车可O(n) | O(1) |
| 最长回文子序列 | dp[i][j]区间内最长回文子序列长度 | 两端相等+2,不等取max | O(n^2) | O(n^2),可压O(n) |
| 分割回文串 | 预处理isPal + DFS回溯 | 枚举分割点 | O(n^2 + 2^n)最坏 | O(n^2) |
从这个表能看出来,同样是"回文"主题,有的题考的是布尔矩阵的遍历顺序,有的考的是长度DP的特征方程,有的考的是搜索剪枝。把它们放在一起对比,比孤立刷题效率高得多。
5. C++实现里的工程细节:从能跑到高效
5.1 vector 的陷阱:bool数组不是普通数组
C++里最反直觉的一个设计就是vector 。它并不是真正的bool数组,而是一个按位存储的特化版本。每个元素只占1 bit,为了返回元素引用,它返回的是一个代理对象,而非真正的bool&。这意味着你不能对vector 里面的元素取地址,不能把它当作普通数组自由操作,而且在某些编译器实现下遍历性能也不理想。
在回文DP这类场景中,我强烈建议用vector 或者vector 来代替vector 。虽然每个元素多占7个bit,但换来的是:可以正常取下标、可以传给fill算法、性能也不会有代理对象的额外开销。实际跑起来,DP填表那点内存差异根本不值一提。
5.2 高频分配与substr的成本
回文子串问题里最常见的性能陷阱之一是频繁调用substr。以分割回文串为例,DFS每层递归都substr一段,会产生大量临时字符串对象。在n不大的时候没问题,但如果n到了几十,方案数量爆炸,字符串拷贝会成为很大的开销。
一个改进思路是:DFS过程中不立刻生成子串,而是记录当前回文区间的起止位置[l, r],最后构造答案时一次性substr。另一个思路是配合string_view使用,但要注意string_view不拥有底层数据,回溯过程中如果原始s被修改或者栈帧里持有悬空引用,会有生命周期风险。C++里稳妥的做法是:在最终压入ans的那一刻才substr,中间只传索引。这样既清晰又安全。
5.3 手写面试代码时的函数签名与风格习惯
面试手撕算法时,代码风格也是隐性评分项。我自己的习惯是:
- 函数签名用const string& s,而不是string s,避免不必要的拷贝。
- 空字符串单独处理,不要让后续逻辑去猜边界。
- lambda捕获列表尽量用引用,尤其当外层变量较多时,[&]直截了当。
- 在关键循环前加一行注释说明"这里为什么从大到小遍历",既提醒自己也方便面试官理解。
马拉车这种代码比较长的题,面试官更看重的是你能不能讲清楚right和center维护的语义,以及min(p[mirror], right-i)为什么是一个安全下界。如果逻辑讲不清楚,代码背得再熟也容易在细节上翻车。
我个人的建议是:面试中最稳的起手式是中心扩展,它代码短、好验证、边界也不容易出错;如果面试官明确要求O(n),再切换到马拉车,并且先花30秒说清楚预处理和半径数组的含义,再动笔写。
5.4 数据范围与算法选型:500还是5000还是50000
刷题刷久了你会形成一套条件反射式的选型经验,回文子串这块尤其明显:
- n <= 50:怎么暴力都行,O(n^3)也能过。
- n <= 5000:中心扩展或区间DP都是安全的,复杂度O(n^2)完全够用。为了省内存可以上滚动数组。
- n >= 10^5:必须马拉车,O(n)是唯一活路。
- 回文子序列问题:本质上很难压到O(n^2)以下,因为状态必须记录所有区间,所以数据范围通常不会给得太大。
另外还有一个实战技巧:如果题目只要求判断"是否存在长度至少为k的回文子串",可以先扫一遍字符串,检查k以内有没有对称结构,提前剪枝。比如k非常大时,往往直接判断最长回文子串长度是否达到k即可,此时马拉车一次搞定。
我在实际刷题过程中的一个体会是,回文子串这块代码量不大,但非常考"状态定义"和"边界控制"。无论是DP矩阵的遍历方向、中心扩展的奇偶分类,还是马拉车里那个一直让人犯迷糊的right和center,本质上都是在围绕"从中心向外镜像比较"这一条核心性质打转。理解了这一条,不管是哪一版代码,都能随时自己推导出来。
最后再分享一个小技巧:笔试里面如果只要求输出回文子串数量,而不要求具体子串,中心扩展通常是最稳的,因为它不占额外内存、不容易写崩;但如果要处理10^5级别的数据,提前背熟马拉车的模板,能帮你省下大量调试时间。还有,那些让无数人困惑的差一错误,解决方式其实很简单——每次写完边界判断之后,拿"aa"和"aba"各跑一遍,再拿"abba"跑一遍,覆盖奇偶两种回文,绝大多数边界坑都能当场现形。