我知道很多人第一次看到"回文排列 II"这道题时,第一反应都是:先全排列,再逐个检查是不是回文。这个思路不能说错,但如果你真这么写了,估计面试官脸上的表情会非常微妙。今天这篇就专门聊聊这道题,以及为什么说剪枝才是真正的关键。
先说清楚这题到底在干什么。给定一个字符串,需要返回所有由它重排列之后能形成的回文串。比如输入"aabb",返回["abba", "baab"];输入"abc",那就什么都返回不了,因为根本没法排成回文。题目本身不复杂,复杂的是它的规模:一旦字符串长度上去了,全排列的数量会瞬间爆炸,而其中真正能构成回文的只是极小一部分。核心矛盾就在这里:你为了找一小撮合法答案,遍历了整个根本不可能合法的搜索空间,而剪枝就是把你从这种巨大浪费里捞出来的那根绳子。
接下来的内容,我会按照"问题本质 -> 剪枝思路 -> 代码落地 -> 复杂度验证 -> 实际经验"的顺序展开,争取让你看完之后不光能把这题写出来,还能真正理解每一行代码背后的取舍逻辑。这题在很多大厂的算法面试里属于"中等偏上"的热门题,也是回溯算法与剪枝思想结合得非常典型的一道,值得多花点时间吃透。
1. 先搞清楚题目在问什么:从回文数到回文串的思维拐点
1.1 回文排列到底是一个什么问题
如果你做过"回文数""回文子串"这类题,可能会觉得回文排列就是把它们串起来的一个进阶版。但这里有一个容易忽略的思维拐点:回文数和回文子串是"判断"问题,回文排列是"构造"问题。判断只需要你对给定的字符串做一次扫描,构造却要求你生成所有满足条件的字符串。生成所有结果,意味着你天然要面对"数量"这个维度的问题,而这恰恰是回溯算法的宿命。
那么,一个字符串的字符要满足什么条件,才能重排成回文?答案很简单:出现奇数次的字符最多只能有一个。原理也非常直接,回文串是对称的,每一个出现在左半边的字符,都必须有一个相同的字符出现在右半边对称的位置上,所以所有字符的出现次数必须是偶数。唯一的例外是最中间的那个字符,因为它不需要配对,所以可以出现奇数次。比如"aabbc"中c出现一次,可以放在正中间,a和b都在左右两侧成对出现,所以它能构成回文。
这个判断是整个算法的基础筛子。如果没有这个前置判断,后面所有的回溯、剪枝、构造全都白搭。很多人在写这题时容易犯的一个错误是:上来就回溯,在回溯过程中判断“当前前缀能不能构成回文的前半部分”。这是想反了,能不能构成回文不是逐步决定的,而是由字符频率一次性决定的。
1.2 全排列方案的时间和空间代价有多大
我们先把"傻办法"的代价算清楚,你才能直观理解为什么要剪枝。
假设输入字符串长度为n。生成所有不重复排列的数量是:
n! / (∏ count[c]!)也就是全排列总数除以重复字符的排列数。当字符串里大多数都是不重复字符时,这个值接近n!。如果n = 10,n! = 3,628,800,三百多万个排列;如果n = 12,那就是 4.79 亿;n = 15时已经到 1.3 万亿。
而回文排列实际上有多少个?假设左半边的长度为m = n // 2,并且左半边使用的有效字符种类数为k,那么有效回文串数量是:
m! / (∏ count[c] / 2)!什么意思?就是当我们确定了"哪些字符可以参与构造"之后,真正需要枚举的只是半边的排列数,而这个半边长度只有n/2。一个n!,一个(n/2)!,这两者的差距在n稍微大一点的时候,不是几倍的关系,而是几百倍几千倍乃至上亿倍的关系。
所以全排列再判断回文,浪费在两部分:一是第一步就产生了大量"不可能构成回文"的排列,比如"aabb"的全排列有 6 个,其中只有 2 个是回文,浪费了三分之二;二是当字符串更长、重复字符更少时,这个浪费比例会急剧拉大。更致命的是,你还得为每个排列做一次 O(n) 的回文判断,总复杂度就是O(n! * n),这在n稍微大一点的测试用例下直接超时。
1.3 判断回文可能性:不是排列的出发点,而是前置筛子
在动手写回溯之前,必须先把频率统计和可行性判断搞定。这个前置筛子的作用不只是"过滤掉不可能的情况",它还能顺带帮你确定一个关键信息:中间字符是什么。
如果奇数次字符的数量大于 1,直接返回空列表,没有例外。如果有且只有一个奇数字符,那它就是回文串正中间的那个字符。如果所有字符出现次数都是偶数,那就没有中间字符,回文串长度是偶数。
这一步做完之后,你的搜索空间就已经从"所有字符的全排列"缩小到了"构成回文所需的半边字符的排列"。
打个比方,这就好比你要用一堆乐高积木搭一个完全对称的城堡。全排列的做法是:把所有积木的所有摆放方式都试一遍,然后看哪些是对称的。剪枝后的做法是:先数清楚每块积木有多少个,如果某个颜色只出现了一次,那它只可能放在最中间的塔尖上;然后把剩下的积木分成完全相同的两堆,只搭一半,另一半镜像复制。
2. 剪枝的前提:用字符串构建图景替代暴力枚举
2.1 为什么只要能构造"一半"就能得到全部
这是这道题最核心的洞察,也是剪枝的真正依据。
一个回文串长这样:
left + mid + right其中right是left的逆序,mid要么为空,要么是那个唯一的奇数字符。
这意味着什么?意味着你只需要构造left,整个回文串就确定了。不需要枚举右半边,也不需要枚举中间之后的所有位置,因为右半边是左半边的镜像,是机械复制出来的,不是搜索出来的。
这个转化把搜索空间直接砍掉一半还多。原因很简单:左半边的长度是n/2,搜索的是(n/2)!量级的排列;如果你在完整长度n上做搜索,哪怕你只枚举前半部分位置,也需要在每个位置判断"这个字符放这里,后面还能不能凑出合法的对称结构",这个判断本身又需要额外的信息维护,远不如直接构造半边来得干净。
很多人的第一个版本可能是在完整字符串上做回溯,中间位置特殊处理,每次添加字符时都判断"当前字符串的字符频数是否还能构成回文"。这么做理论上也能得到正确答案,但每一层递归都需要对剩余字符做一次频率统计,整体开销非常大,而且代码写起来又绕又容易出错。
直接构造半边,逻辑上有一种"降维打击"的感觉:把二维的对称关系压缩成一维的线性排列。
2.2 奇偶计数:识别那一个"对称轴"候选
既然要先统计字符频率,那就牵涉到一个实现上的细节:用什么数据结构来存频率。
我的建议是:用 Python 的 Counter 或者 C++ 的 unordered_map,在统计阶段就顺手把"能用哪些字符、各能用几次"整理好。
具体来说,统计完频率后,你需要构建一个half_chars列表,其中每个元素是(字符, 该字符在单侧可用的次数)。单侧可用次数等于原始出现次数 // 2。比如'a'出现 4 次,那它可以在半边出现 2 次;'b'出现 1 次,那它半边可用次数为 0,并且它是中间字符的候选。
这个half_chars就是回溯时的候选字符池。注意,这里不是简单地记录"还有哪些字符没用完",而是记录"每个字符最多还能用几次"。因为回溯的核心操作是"选一个字符放到当前位置",所以你需要知道每个字符的剩余可用次数。
举个具体例子:
输入: "aabbc" 频率统计: a: 2, b: 2, c: 1 奇数字符: c(作为中间字符 mid = "c") 半边可用字符: a 可用 1 次, b 可用 1 次 半边长度 = 2接下来要做的就是在['a', 'b']这两个字符中,每个用一次,生成所有排列。显然结果是"ab"和"ba",分别对应最终回文"abcba"和"bacab"。
2.3 边界条件:空串、单字符、无法构成回文的输入
边界情况看起来简单,但恰恰是这些简单的地方最容易翻车。
空串:输入"",按照定义,空串是回文,所以应该返回[""]。但注意,这里有个分歧点。LeetCode 原题里空串返回[""]是合理的,但有些变种题会要求返回空数组。我建议以题目要求为准,但实现上要保证空串进入算法时不会因为mid为空、half_chars为空就产生错误。
单字符:输入"a",频率统计显示a出现 1 次,奇数字符为a,半边为空,所以结果应该是["a"]。这个用例测试的是你对"半边长度为 0"的处理能力,递归函数在path长度等于half_len时应该能正确收尾。
无法构成回文:输入"abc",a、b、c各出现 1 次,奇数频率字符有 3 个,不满足"最多一个奇数"的条件,直接返回空数组。这个判断必须放在所有回溯之前,一旦提前返回,后面什么都不用做。
这三个边界条件我建议你把它们当成"测试用例三连",每次写完代码先跑一遍再交。很多时候你以为稳了,结果挂在""或者"a"这种用例上,面试的时候很尴尬。
3. 核心算法设计:回溯框架下的剪枝策略
3.1 从选字母到选位置的思维转换
写回溯的人通常有两种思路:一种是从字母的角度出发,考虑"这个字母放在哪里";另一种是从位置的角度出发,考虑"这个位置放哪个字母"。
在这道题里,第二种思路是绝对的主流,也是剪枝最自然的方式。因为我们已经确定了要构造的是左半边,每个位置就是一个槽位,我们从左到右依次往槽位里填字符。每填一个字符,就消耗它在半边可用次数中的一次;填满half_len个槽位后,镜像生成右半边,拼上中间字符,就得到了一个完整回文串。
为什么第一种思路不好剪枝?因为"这个字母放在哪里"意味着你要遍历所有位置,检查每个位置是否可用,这个逻辑在位置数量较多时非常笨重,而且很容易产生重复排列(因为相同字母出现在不同位置会被视为不同排列)。
而"这个位置放哪个字母"的思路天然就是标准的回溯模板:
def backtrack(path, counter): if len(path) == half_len: result.append(path + mid + path[::-1]) return for ch in counter: if counter[ch] > 0: counter[ch] -= 1 backtrack(path + ch, counter) counter[ch] += 1这个模板看起来平淡无奇,但它已经是经过剪枝的版本。剪枝体现在哪里?体现在:我们只考虑counter[ch] > 0的字符,那些已经用完的字符直接跳过,不为它们做任何递归调用。这在概念上就是"剪枝":每一层递归,我们都剪掉了"那些已经耗尽可用次数的字符分支"。
3.2 三处必须剪枝的位置:重复字符、剩余长度、提前失败
在基础模板之外,还有三处剪枝是真正让代码"豪华"起来的关键。你不剪也行,代码能跑,但剪了之后效率和代码优雅度完全不同。
第一处:相同字符的重复分支
假如half_chars里有'a'出现 2 次,在回溯时,path的第一个位置选择第一个'a'和选择第二个'a'产生的字符串前缀是一样的。如果你在循环里从0遍历到n-1,就会对相同的字符产生重复的递归分支,最终导致结果中出现大量重复的回文串。
怎么剪掉?一个经典做法是:在循环之前对字符集合做排序,然后在循环中跳过与上一个字符相同且上一个字符已经完成回溯的字符。
for i, ch in enumerate(chars): if i > 0 and chars[i] == chars[i-1] and not used[i-1]: continue ...这个not used[i - 1]判断的含义是:"上一个相同字符刚刚被撤销,说明这条路已经走过并回溯了,再走一遍只会得到重复结果,直接跳过。"这是回溯去重里非常经典的一个技巧,理解它在哪,基本上就理解了排列去重的精髓。
第二处:剩余位置不足以容纳剩余字符时提前终止
这个更多是一个优化思想。在回溯过程中,如果发现path已经很长了,但counter里还有大量剩余字符,而这些字符种类数远远填不满剩下的位置,理论上可以提前终止递归。不过说实话,在这个问题里,因为我们在构造前已经保证了字符次数是恰好匹配半边长度的,所以出现这种情况的概率比较低。更常见的是你在处理变种题时可能遇到类似情况,作为一个可选的剪枝加上就行。
第三处:前置可行性检查失败直接返回空结果
这是最狠的一刀,也是"提前失败"剪枝。如果一个字符串根本不可能构成回文,那它的全排列里一个合法结果都不会有。所以时间复杂度是O(1)的频率统计 + 一次遍历判断,就可以直接返回空数组,连回溯都不用启动。
这三处剪枝合起来,形成一个完整的攻防体系:前置可行性判断从入口处掐掉不可能的情况,重复字符去重从过程中剔除重复分支,剩余容量判断在递归深层避免无谓的深度递归。虽然第一处和第三处才是这个问题的关键,但第二处也可以在变种题中发挥很大的作用。
3.3 剪枝的实际效果:搜索树如何从指数级缩成小树
为了让你直观感受剪枝带来的变化,我用一个实际例子来算算。
输入"aabbcc",长度 6,所有字符出现次数都是偶数。全排列总数是6! / (2! * 2! * 2!) = 90个。也就是说,如果采用"全排列再判断"的思路,你要生成 90 个字符串,再做 90 次回文判断。
而正确做法呢?半边长度为 3,半边可用的字符是'a'、'b'、'c'各一次。回溯搜索的规模是3! = 6种排列,也就是 6 个半边长度的排列,然后镜像生成 6 个回文串。搜索空间从 90 缩小到 6,直接缩了 15 倍。
如果长度继续增加,比如"aabbccddeeff",长度 12,全排列总数是12! / (2!^6) = 7,484,400,大约 748 万。而正确做法是半边长度 6,可用字符 6 种各一次,搜索空间是6! = 720。这个差距已经是 1 万倍了。如果再进一步,字符串长度到 16,那差距已经超过千万倍。
这就是为什么我一直强调:剪枝不是可有可无的优化,而是这道题能通过测试用例的生命线。不剪枝,你交个全排列的版本上去,LeetCode 上用n=12或n=14的中型测试用例就能直接超时,更不用提n=16的大型用例了。
4. 代码落地:一份可以直接抄作业的实现
4.1 Python实现:清晰优先的写法
Python 写这种回溯题,最大的优势就是语法灵活,代码可以写得很短。但短不等于好读,我的习惯是:优先保证逻辑清晰,可读性第一。
from collections import Counter from typing import List class Solution: def generatePalindromes(self, s: str) -> List[str]: n = len(s) if n == 0: return [""] # 1. 频率统计 freq = Counter(s) # 2. 判断能否构成回文,并收集奇数字符 odd_chars = [ch for ch, cnt in freq.items() if cnt % 2 == 1] if len(odd_chars) > 1: return [] mid = odd_chars[0] if odd_chars else "" # 3. 构建半边可用字符列表:每个字符的可用次数是 cnt // 2 half_chars = [] for ch, cnt in freq.items(): half_chars.extend([ch] * (cnt // 2)) # 4. 排序,为去重剪枝做准备 half_chars.sort() half_len = len(half_chars) result = [] used = [False] * half_len def backtrack(path: list): if len(path) == half_len: result.append("".join(path) + mid + "".join(reversed(path))) return for i in range(half_len): # 去重剪枝:如果当前字符和前一个相同,且前一个字符尚未使用(说明已经回溯过),跳过 if i > 0 and half_chars[i] == half_chars[i - 1] and not used[i - 1]: continue if not used[i]: used[i] = True path.append(half_chars[i]) backtrack(path) path.pop() used[i] = False backtrack([]) return result这段代码有几个值得单独说明的设计决策。
half_chars是我刻意选择的数据结构。它不是用字典记录"每个字符剩余次数",而是直接展开成一个列表,每个可用字符在列表里出现cnt // 2次。这样做的好处是:回溯时used数组做去重剪枝非常方便,直接用下标判断是否已经使用过,和标准排列模板完全一致。坏处是:如果字符种类非常多,half_chars列表会比较长,但反正长度就是n // 2,不会超过原字符串长度,空间上完全可接受。
mid字符直接放在最终拼接的字符串中间,不必参加回溯。这个决策省去了在回溯过程中判断"当前位置是不是中间位置"的麻烦。有些实现会把中间字符放进回溯里特殊处理,逻辑上也可以,但代码会明显变长,而且容易出错。我强烈建议把中间字符从回溯中剥离出来,让它成为一个纯粹的外部变量。
4.2 C++实现:性能敏感场景的写法
如果你在面试或者竞赛时需要更高性能的实现,C++ 版本更值得参考。
class Solution { public: vector<string> generatePalindromes(string s) { int n = s.size(); vector<string> res; if (n == 0) { res.push_back(""); return res; } unordered_map<char, int> freq; for (char c : s) freq[c]++; char mid = 0; for (auto& [ch, cnt] : freq) { if (cnt % 2 == 1) { if (mid != 0) return {}; mid = ch; } } string half; for (auto& [ch, cnt] : freq) { half.append(cnt / 2, ch); } sort(half.begin(), half.end()); int halfLen = half.size(); vector<bool> used(halfLen, false); string path; dfs(half, used, path, mid, halfLen, res); return res; } private: void dfs(const string& half, vector<bool>& used, string& path, char mid, int halfLen, vector<string>& res) { if (path.size() == halfLen) { string rev = path; reverse(rev.begin(), rev.end()); if (mid != 0) { res.push_back(path + mid + rev); } else { res.push_back(path + rev); } return; } for (int i = 0; i < halfLen; i++) { if (used[i]) continue; if (i > 0 && half[i] == half[i - 1] && !used[i - 1]) continue; used[i] = true; path.push_back(half[i]); dfs(half, used, path, mid, halfLen, res); path.pop_back(); used[i] = false; } } };C++ 版本里最值得注意的一点是:path作为一个string类型在递归过程中被反复push_back和pop_back,避免了反复构造字符串的开销。rev是在到达叶子节点时才构造的,中间过程完全不需要做字符串拼接,这个对性能的提升非常明显。
另一个细节是:在判断奇数字符时,用mid != 0而不是用一个布尔标记,省去了一次循环。C++ 的char默认初始化为 0,所以完全可以用0表示"尚未找到奇数字符"。
4.3 关键代码行逐条拆解
很多初学者不理解为什么去重剪枝里用的是!used[i - 1]而不是used[i - 1]。这里我展开讲一下,因为它太重要了。
在回溯过程中,used数组的状态是不断变化的。假设有half_chars = ['a', 'a', 'b'],当我们在第一个位置选了half_chars[0]的'a'之后递归下去,此时used[0] = true。如果最终回溯回来,used[0]被重置为false,然后循环继续到i = 1,发现half_chars[1] == half_chars[0]都是'a',而且used[0] = false,说明从'a'开头的所有分支已经全部搜索完了。这时如果再从'a'开始,得到的排列集合和之前从half_chars[0]开始时是完全一样的,没有意义,所以跳过i = 1。
如果用used[i - 1]作为条件,那当used[0] = true时,i = 1的'a'也会被放进 path,这样会产生类似['a'(0), 'a'(1)]和['a'(1), 'a'(0)]这样的重复排列,无法去重。
这个细节值得你在本地跑一下调试器,把used数组的变化过程打出来,比较容易获得直观的理解。如果只是看代码,背下"去重要用!used[i-1]",但不知道原理,换个环境很容易写错。
5. 复杂度与正确性:剪枝到底快了多少
5.1 时间复杂度:不是 O(n!),而是 O((n/2+1)!)
先做前置可行性判断:需要 O(n) 的时间扫描字符串统计频率,以及 O(k) 的时间判断奇数频率字符数量,其中 k 是字符种类数。这个开销是必须的,而且非常小。
回溯过程的时间复杂度是递归树的节点总数。我们在深度为halfLen的树上做回溯,每个节点都尝试所有halfLen个候选字符,并且有去重剪枝。在最坏情况下(所有字符都不相同),回溯树的节点数是:
halfLen! * (1 + 1/halfLen + 1/(halfLen*(halfLen-1)) + ...)这个级数收敛于e * halfLen!,所以时间复杂度是O(halfLen!)。
如果写成关于n的表达,就是O((n/2)!)。而全排列方案是O(n! * n)。这两个复杂度的差异可以用一个表格直观地看出来:
| n | 全排列方案 | 剪枝方案 | 加速比 |
|---|---|---|---|
| 8 | 40320 * 8 = 322560 | 24 | 13440 |
| 10 | 3628800 * 10 = 36288000 | 120 | 302400 |
| 12 | 479001600 * 12 ≈ 5.7e9 | 720 | 约 800万 |
| 14 | 87178291200 * 14 ≈ 1.2e12 | 5040 | 约 2.4亿 |
注意这个表格是"所有字符都不同"的最坏情况。如果有重复字符,全排列方案的总数会除以重复字符的排列数,差距会缩小,但剪枝方案的搜索空间同样会缩小,两者之间的数量级差距依然巨大。
5.2 空间复杂度分析
空间复杂度由三部分组成:freq字典占 O(k)(k 为字符种类数)、half_chars列表占 O(n/2)、递归调用栈深度占 O(n/2)。所以总空间复杂度是 O(n)。另外,result列表存储所有结果,如果结果数量为 r,每个结果长度为 n,那么输出占用的空间是 O(r * n)。这是所有解法都无法避免的输出开销,不能算进算法的额外空间。
递归栈深度是n/2,这是一个非常浅的深度,完全不用担心栈溢出问题。即使是n = 1000的极端情况(虽然不太可能出现,因为回文排列数量会爆炸),递归深度也只有 500 层左右,远小于 Python 默认的递归上限 1000。
5.3 用测试用例验证正确性
代码写完之后,至少要用下面这些用例跑一遍验证:
用例1:基本场景
输入: "aabb" 预期输出: ["abba", "baab"](顺序可能不同)用例2:中间字符
输入: "aabbc" 预期输出: ["abcba", "bacab"]用例3:无法构成回文
输入: "abc" 预期输出: []用例4:单字符和空串
输入: "a" 预期输出: ["a"] 输入: "" 预期输出: [""] 或 [](按题目要求)用例5:大量重复字符
输入: "aaaaaa" 预期输出: ["aaaaaa"]这个用例非常关键。六个'a'的全排列只有一种,但是如果你不加去重剪枝,可能生成 720 个完全相同的排列,然后再逐一判断,最终通过某种方式去重后才得到 1 个结果。加去重剪枝后,直接只生成 1 个。这个用例能快速验证去重是否生效。
用例6:中等规模的性能测试
输入: "aabbccddeeff" 长度 12,预期输出数量为 720这个用例可以直观测试运行时长。如果你的实现正确且带剪枝,运行时间应该在毫秒级别;如果用的是全排列方案,会明显感觉到卡顿,甚至在 LeetCode 上限时超时。
6. 实战踩坑与经验:那些文档里不会写的细节
6.1 去重最容易出错的地方
以前面提到的!used[i - 1]为例,这个条件在很多人的代码里被写反过。我自己带过不少实习生,他们第一次写去重时,如果不是照着模板抄,大概率会写成used[i - 1]或者干脆忘了加这个条件。
为什么容易写反?因为直觉上觉得"如果上一个相同的字符已经被使用了,那我现在用这一个就不会重复了",这个直觉在部分排列的场景下是对的,但在全排列场景下是错的。判断条件的关键在于:是否已经完成过相同起点的所有分支的搜索。
具体到代码层面,当used[i - 1] = false时,说明从字符half_chars[i-1]开始的所有排列已经全部被"生成、加入结果、回溯回来"了,此时再让half_chars[i]作为同样的起点,必然产生重复。此时才应该跳过。
另一种实现方式是通过set记录每一层已经尝试过的字符,也就是在for循环内部维护一个used_char集合:
def backtrack(path, counter): if len(path) == half_len: result.append(...) return for ch in list(counter.keys()): if counter[ch] == 0: continue if ch in used_in_this_level: continue used_in_this_level.add(ch) counter[ch] -= 1 backtrack(path + ch, counter) counter[ch] += 1这种方式在每一层递归开头创建used_in_this_level集合,它只对当前层的循环生效。代码稍微啰嗦一点,但理解起来比!used[i - 1]直观,也不容易写反。如果是在面试中,你用这种方式去重,面试官反而更容易看懂你的思路。两种方式我都写过,从代码简洁度来看!used[i - 1]更短,但如果是现场编码,我更推荐set版本,因为它不容易出错,而且解释起来更自然。
6.2 递归参数设计:传引用还是传值
Python 版本的递归里,path是一个列表,递归时直接path.append()再path.pop(),本质上是在模拟传引用的效果。这当然没问题,但有一个隐藏风险:如果你在递归里把path传给了剪枝函数或用于生成结果,必须确保生成结果时是"快照",而不是"引用"。
比如下面这段代码就有 bug:
def backtrack(path, counter): if len(path) == half_len: result.append(path) # 错误!path 在后面会被修改 return ...因为path在回溯过程中会不断被修改,直接append(path)会让result里的所有元素最终指向同一个列表对象,最终输出一堆相同的空列表或中间状态。
正确做法是result.append("".join(path) + mid + ...),或者result.append(path[:])。这个 bug 在写回溯题时非常常见,尤其是字符串转列表之后再回溯时,更容易踩到。我建议你在每次append时都问自己一句:"我 append 的是一个会变的对象吗?"
C++ 版本里同样存在这个问题。如果你用res.push_back(path),在path后续被修改后,res里已经保存的字符串不受影响,因为 C++ 的string是深拷贝的。这个和 Python 的列表引用行为不一样,也是两种语言混着写时容易糊涂的地方。
6.3 面试与竞赛中的延伸变形题
回文排列这道题本身是一个基础题,但它在很多场景下会以变形的方式出现。面试官可能不会直接给你"回文排列 II",而是把它藏在别的问题里。
变形1:求回文排列的数量
如果题目不要求返回所有排列,只要求返回数量,那就不需要回溯了。直接用公式:
halfLen! / ∏ (count[c] / 2)!这本质上是一个组合数学问题。如果你只能想到回溯再数一遍,面试官可能会继续问你"如果halfLen很大,回溯超时怎么办",这时候就要切换到公式解法。
变形2:判断两个字符串能否通过重排列形成互为回文
这个其实是回文排列的"判定版",只需要判断两个字符串的频率统计在某种意义下是否一致,或者更常见的是:一个字符串能否重排成另一个字符串的回文形式。核心还是频率计数。
变形3:在回文排列的基础上加限制条件
比如,"返回字典序最小的那个回文排列"。这个变形就更简单了,你只需要把half_chars排序后直接贪心构造即可,不需要回溯。甚至可以把half_chars从最小到最大排列,直接拼出字典序最小的结果。
变形4:构造回文子序列的最大长度
这个已经和回文排列没什么关系了,但它用的是同一套奇偶频率判断的思路。如果你把频率统计的思想掌握扎实,这类题会变得很容易。一个字符串的最长回文子序列长度等于所有字符的偶数频次之和,再加上(如果有奇数字符)1。
变形题的共同点在于:它们都依赖"回文串的字符频率特征"和"前半部分决定整个回文串"这两个核心观察。你不需要记住所有题的解法,只要把这两个观察刻进脑子里,遇到任何回文相关的构造题都能推导出来。
6.4 关于剪枝算法的一点延展:从这道题看"非结构化剪枝"的本质
最后,我想从这道题出发,稍微延展一下"剪枝"这个更大的话题,因为最近"剪枝算法"这个词被讨论得很多,而且很多时候它指代的并不是同一回事。
在这道题里,我们做的剪枝是搜索树剪枝,它的本质是:在做决策之前,先判断这条分支是否值得继续走。判断的依据通常是"这个分支已经不可能产生合法结果"或者"这个分支和已经走过的某个分支产生的结果是重复的"。搜索树剪枝不改变问题的解集,只是把注定无效的搜索路径提前切断,从而减少搜索空间。
另一种剪枝是神经网络剪枝,它指的是在深度学习模型训练完成后,删除模型中对输出影响较小的权重或神经连接,从而压缩模型体积、加速推理。这里的"剪枝"和题目无关,但"在保留核心能力的前提下砍掉冗余部分"这个思想是共通的。
还有一种是决策树剪枝,在机器学习中通过预剪枝和后剪枝来防止过拟合,本质上是砍掉那些对泛化能力贡献不大或甚至有负面影响的子树。
这三种剪枝虽然应用领域不同,但核心思想完全一致:识别哪些部分是"不值得保留/探索"的,然后果断地砍掉它们,避免在无意义的部分上消耗资源。
这道回文排列题就是一个绝佳的载体,让你能以最直观的方式理解剪枝的本质:全排列里存在大量完全相同的排列(重复剪枝)和大量不可能成为回文的排列(可行性剪枝),砍掉它们之后,剩下的搜索空间小到可以忽略不计。你在 LeetCode 上可以用这个题的剪枝思路迁移到很多其他回溯问题上,比如"全排列 II"的重复数字去重、"组合总和"的剪枝排序等,它们的原理都是一样的。
所以如果你准备面试,我会建议你把这道题当成回溯剪枝的标准练习题,认认真真把三种剪枝都写一遍、调试一遍,确保每一步都理解内在逻辑,而不是停留在"会背代码"的层面。等你把这道题吃透,以后再遇到什么"生成所有不含重复的排列""N 皇后""数独求解"之类的回溯题,都会有一种豁然开朗的感觉。