多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

C++回文子串全解析:从中心扩展到马拉车算法

C++回文子串全解析:从中心扩展到马拉车算法 开头先讲个真实场景。回文子串系列问题在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)。这个复杂度在n5000时完全能接受而且代码比很多人想象中简单string longestPalindromeByExpand(const string s) { int n s.size(); if (n 0) return ; int bestStart 0, bestLen 1; auto expand [](int l, int r) - pairint, 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]。无论哪种情况真正合法的回文区间是[l1, 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[i1...j-1]也是回文那么s[i...j]就是回文。于是得到递推关系dp[i][j] (s[i] s[j]) dp[i1][j-1]这句话看起来人畜无害写代码时却很容易栽跟头。难点在于dp[i][j]依赖的是dp[i1][j-1]也就是矩阵里左下方的格子而不是通常遍历二维数组时刚算过的上方或左方格子。如果按照直觉i从0到n-1、j从i到n-1这样双重循环下去你会发现计算dp[i][j]时dp[i1][j-1]根本没有被计算过读到的全是初始值。所以这个题目看似简单实则是在考察你对状态依赖方向的理解。很多人在LeetCode提交时Recursion或者Wrong Answer压测数据一大就爆内存往往都是卡在这一步。2.2 递推关系的隐藏依赖为什么要读左下角把dp矩阵画出来的话它是一个上三角矩阵。每行i从左往右j递增每个格子的值依赖的是它的左下方格子dp[i1][j-1]。这个依赖方向注定了两个常用的遍历策略第一种是按长度枚举。长度从1到n递增对每个长度len枚举所有可能的左端点i右端点j i len - 1。因为计算长度为len的区间时长度为len-2的区间已经被算过了所以dp[i1][j-1]一定已经准备就绪。第二种是从底向上逐行处理。i从n-1循环到0j从i1循环到n-1。因为i在递减处理第i行时第i1行的数据是上一轮循环已经计算完的。两种思路对应的代码我都写过这里给出按长度枚举的版本string longestPalindromeByDP(const string s) { int n s.size(); if (n 0) return ; vectorvectorchar dp(n, vectorchar(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[i1][j-1]越界的情况。2.3 两种遍历顺序与滚动数组优化按长度遍历和从底向上遍历本质上是在描述同一种依赖关系但它们的扩展性不一样。从底向上版本有一个天然优势可以进一步优化空间。观察递推公式dp[i][j]只依赖同一行右边的dp[i][j-1]吗不对它依赖的是下一行的dp[i1][j-1]和dp[i1][j]。具体来说在逐行计算的版本里计算第i行时只需要第i1行的数据。所以我们可以用一个一维数组滚动保存上一行的结果把空间从O(n^2)压到O(n)。代码是这样写的string longestPalindromeByDP1D(const string s) { int n s.size(); if (n 0) return ; vectorchar 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行时里面存的是第i1行的结果。我们要读的dp[i1][j-1]其实是上一行中位置j-1的值也就是数组里的dp[j-1]。如果j从小到大遍历计算到j的时候dp[j-1]已经被当前行覆盖改写过了读到的就不是上一行的值了。只有让j从大到小遍历才能保证在读dp[j-1]时它仍然是上一行留下的旧值。这里有一个小细节需要说明当j - i 2时因为短路求值dp[j-1]不会被真正读取所以即使它的值不可靠也不会出错。但一旦区间长度大于3dp[j-1]就必须是上一行的旧值这时倒序遍历就变得至关重要。2.4 滚动数组为什么必须倒序遍历j我用一个具体例子帮大家加深记忆。假设字符串是abcban5。在i3这一步dp[3]被置为1然后j从4开始倒着向前。由于j4时依赖的是dp[3]——此时dp[3]刚被设为1代表dp[4][3]这里要稍微想一下在i3这一轮dp[4]这个位置代表的是dp[3][4]而不是dp[4][4]。我们去读取dp[3]的时候它恰好是i3这一轮自己写进去的单字符回文标记表示的是dp[3][3]1。当j4时j-i12所以根本不读dp[3]而是直接根据s[3]s[4]判断。这样逻辑完全自洽。反过来如果j从小到大遍历在i3之前i4那一轮计算的dp[4]已经被覆盖了轮到j5如果存在时想要读取上一轮的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-31]t[i-1]、t[i-2]t[i2]这一对对的字符匹配了2次加上中心自己总共覆盖了从i-2到i2的5个字符。有一个特别重要的换算关系以t[i]为中心的回文在原始字符串中对应的回文长度是p[i] - 1。这个结论看起来像魔术其实可以从字符数的守恒关系推出来。原始字符串长度为n预处理后长度为2n3算上首尾哨兵。每插入一个#原来相邻的两个字符之间多了一个分隔符。假设一个回文在预处理串中的总长度为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(); vectorint 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[i1][j-1] 2。如果s[i] ! s[j]说明这两端不能同时参与最终答案取丢弃左端或丢弃右端的最大值dp[i][j] max(dp[i1][j], dp[i][j-1])。代码如下int longestPalindromeSubseq(const string s) { int n s.size(); if (n 0) return 0; vectorvectorint dp(n, vectorint(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[i1][j]、dp[i1][j-1]这些下一行的值。很多人在这个题上犯的错误是把回文子串的布尔DP套过来试图用布尔值推导长度结果发现转移完全对不上。根源在于子串版本是区间是否构成回文子序列版本是区间内能选出的最长长度两者是不同维度的问题。如果想压缩空间这里就不能用单数组滚动因为转移同时依赖上一行的dp[i1][j]和当前行的dp[i][j-1]以及上一行的dp[i1][j-1]一个数组无法同时保留新旧两个时间点的数据。正确做法是用两个一维数组滚动int longestPalindromeSubseq(const string s) { int n s.size(); vectorint 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保存的是上一行即第i1行的完整结果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]是回文就加入当前路径递归处理end1回溯时弹出。vectorvectorstring partition(const string s) { int n s.size(); vectorvectorchar isPal(n, vectorchar(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]); } } vectorvectorstring ans; vectorstring path; functionvoid(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不等取maxO(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跑一遍覆盖奇偶两种回文绝大多数边界坑都能当场现形。
返回列表