From a5736a4db7f84ecbf199473ce4ecc884d768b256 Mon Sep 17 00:00:00 2001 From: yasinshaw Date: Sun, 8 Mar 2026 21:32:02 +0800 Subject: [PATCH] =?UTF-8?q?docs:=20improve=20solution=20explanations=20for?= =?UTF-8?q?=20=E6=9C=80=E9=95=BF=E5=9B=9E=E6=96=87=E5=AD=90=E4=B8=B2=20and?= =?UTF-8?q?=20=E6=8B=AC=E5=8F=B7=E7=94=9F=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加思路推导部分,从暴力解法分析优化过程 - 增加详细的算法流程和Q&A形式的解释 - 添加执行过程演示和常见错误分析 - 完善边界条件和复杂度分析 - 保持原有的代码实现和进阶问题 --- 16-LeetCode Hot 100/括号生成.md | 534 ++++++++++++++++++++++++--- 16-LeetCode Hot 100/最长回文子串.md | 545 +++++++++++++++++++++++----- 2 files changed, 939 insertions(+), 140 deletions(-) diff --git a/16-LeetCode Hot 100/括号生成.md b/16-LeetCode Hot 100/括号生成.md index 6b96822..e5c2f69 100644 --- a/16-LeetCode Hot 100/括号生成.md +++ b/16-LeetCode Hot 100/括号生成.md @@ -22,54 +22,353 @@ - `1 <= n <= 8` +## 思路推导 + +### 暴力解法分析 + +**第一步:最直观的思路 - 生成所有可能的组合** + +```python +def generateParenthesis_brute(n): + # 生成所有长度为 2n 的括号组合 + all_combinations = [] + generate_all("", 2*n, all_combinations) + + # 过滤出有效的组合 + valid_combinations = [] + for combo in all_combinations: + if is_valid(combo): + valid_combinations.append(combo) + + return valid_combinations + +def generate_all(current, max_len, result): + if len(current) == max_len: + result.append(current) + return + + generate_all(current + "(", max_len, result) + generate_all(current + ")", max_len, result) + +def is_valid(s): + balance = 0 + for char in s: + if char == "(": + balance += 1 + else: + balance -= 1 + if balance < 0: + return False + return balance == 0 +``` + +**时间复杂度分析:** +- 生成所有组合:每个位置有 2 种选择,共 2^(2n) 个组合 +- 判断每个组合是否有效:O(n) +- **总时间复杂度:O(n × 2^(2n))** + +**问题:** +- 生成了大量无效组合 +- 对于 n = 8,2^(16) = 65536 个组合,但有效的只有卡特兰数 C(8) = 1430 个 +- 效率极低,大部分计算都浪费了 + +### 优化思考 - 如何避免生成无效组合? + +**核心观察:** +1. **暴力法的瓶颈**:生成了大量无效组合,最后才过滤 +2. **优化方向**:能否在生成过程中就避免无效组合? + +**关键问题:什么是无效组合?** + +无效组合的特征: +1. 右括号数量超过左括号数量:")()(" +2. 最终左右括号数量不相等:"((()" + +**思路:在生成过程中实时检查** + +与其生成所有组合再过滤,不如: +1. 在添加每个括号时就检查是否合法 +2. 如果不合法,直接跳过这个分支(剪枝) +3. 只生成有可能合法的组合 + +```python +def generateParenthesis_optimized(n): + result = [] + + def backtrack(current, open_count, close_count): + # 终止条件 + if len(current) == 2 * n: + result.append(current) + return + + # 关键剪枝条件 + # 1. 左括号数量不能超过 n + if open_count < n: + backtrack(current + "(", open_count + 1, close_count) + + # 2. 右括号数量不能超过左括号数量 + if close_count < open_count: + backtrack(current + ")", open_count, close_count + 1) + + backtrack("", 0, 0) + return result +``` + +### 为什么这样思考? + +**1. 剪枝思想(Pruning)** +``` +暴力法:生成所有 2^(2n) 个组合,然后过滤 + ↓ +优化:在生成过程中就剪掉不可能的分支 + ↓ +效果:只生成卡特兰数 C(n) 个有效组合 +``` + +**2. 有效括号的本质** +``` +有效括号的两个充要条件: +1. 任何时候,左括号数量 >= 右括号数量 +2. 最终,左括号数量 = 右括号数量 = n + +这意味着: +- 可以添加左括号的条件:open < n +- 可以添加右括号的条件:close < open +``` + +**3. 递归树视角** +``` +n = 2 时的递归树: + +"" (open=0, close=0) +├── "(" (open=1, close=0) +│ ├── "((" (open=2, close=0) +│ │ └── "(()" (open=2, close=1) +│ │ └── "(())" (open=2, close=2) ✓ +│ └── "()" (open=1, close=1) +│ └── "()(" (open=2, close=1) +│ └── "()()" (open=2, close=2) ✓ +└── ")" (open=0, close=0) + ✗ 剪枝:close 不能 > open + +只生成了 2 个有效组合,而不是 2^4 = 16 个! +``` + ## 解题思路 ### 方法一:回溯法(推荐) -**核心思想:**使用回溯法生成所有可能的括号组合。在生成过程中,始终保持括号的有序性: -1. 左括号数量不能超过 n -2. 右括号数量不能超过左括号数量 +**核心思想:**使用回溯法生成所有可能的括号组合。在生成过程中,始终保持括号的有序性。 -**算法步骤:** -1. 初始化结果数组 `result` 和当前字符串 `current` -2. 定义回溯函数 `backtrack(open, close)`: - - `open`:已使用的左括号数量 - - `close`:已使用的右括号数量 -3. 终止条件:`len(current) == 2 * n`,将 `current` 加入 `result` -4. 选择条件: - - 如果 `open < n`,可以添加左括号 - - 如果 `close < open`,可以添加右括号 -5. 递归调用后撤销选择(回溯) +### 详细算法流程 -**为什么这样做?** -- 通过限制 `close < open`,保证任何时候右括号数量不超过左括号数量 -- 通过限制 `open < n`,保证左括号数量不超过 n -- 这样生成的所有组合都是有效的 +**步骤1:理解有效括号的生成规则** -### 方法二:DFS 深度优先搜索 +```python +# 规则1:左括号数量不能超过 n +if open < n: + 可以添加 '(' -**核心思想:**与回溯法类似,但使用更纯粹的 DFS 思想。将问题看作在二叉树中搜索。 +# 规则2:右括号数量不能超过左括号数量 +if close < open: + 可以添加 ')' +``` -**算法步骤:** -1. 构建一个递归树,每个节点代表一个状态 -2. 从根节点开始,每次可以选择添加左括号或右括号 -3. 剪枝:不符合条件的分支直接跳过 -4. 到达叶子节点(长度为 2n)时,记录结果 +**Q: 为什么右括号数量不能超过左括号数量?** -### 方法三:动态规划 +A: 因为在任何前缀中,如果右括号多于左括号,就不可能通过后续添加括号使其变成有效括号。 -**核心思想:**利用卡特兰数(Catalan Number)的性质。n 对括号的有效组合数等于第 n 个卡特兰数。 +举例: +``` +")" → 不可能变有效,因为第一个字符就是右括号 +"())(" → 中间的 "()" 后面是 ")",右括号已经多余了 +``` + +**步骤2:设计回溯函数** + +```python +def backtrack(current, open, close): + # 终止条件:生成了足够的括号 + if len(current) == 2 * n: + result.append(current) + return + + # 选择1:添加左括号(如果可以) + if open < n: + backtrack(current + "(", open + 1, close) + + # 选择2:添加右括号(如果可以) + if close < open: + backtrack(current + ")", open, close + 1) +``` + +**Q: 为什么不需要显式地撤销选择(回溯)?** + +A: 因为我们使用的是字符串拼接 `current + "("`,这会创建新的字符串,不会修改原来的 `current`。每次递归调用都是独立的,不需要手动撤销。 + +如果要使用列表优化性能: +```python +def backtrack(current, open, close): + if len(current) == 2 * n: + result.append("".join(current)) + return + + if open < n: + current.append("(") # 做选择 + backtrack(current, open + 1, close) + current.pop() # 撤销选择 + + if close < open: + current.append(")") # 做选择 + backtrack(current, open, close + 1) + current.pop() # 撤销选择 +``` + +**步骤3:初始化并启动回溯** + +```python +result = [] +backtrack("", 0, 0) +return result +``` + +**Q: 为什么初始状态是 open=0, close=0?** + +A: 因为我们从空字符串开始,还没有添加任何括号。 + +### 关键细节说明 + +**细节1:为什么终止条件是 `len(current) == 2 * n`?** + +```python +# 错误理解:应该是 len(current) == n +# 错误原因:n 是括号的对数,每对有 2 个括号 + +# 正确理解:总长度是 2n +# 例如 n=3,最终字符串长度是 6:"((()))" +``` + +**细节2:为什么两个 if 是独立的,而不是 if-else?** + +```python +# 错误写法:if-else +if open < n: + backtrack(current + "(", open + 1, close) +else: + backtrack(current + ")", open, close + 1) + +# 问题:这样会导致每步只能添加一种括号 +# 正确写法:两个独立的 if +if open < n: + backtrack(current + "(", open + 1, close) +if close < open: + backtrack(current + ")", open, close + 1) +``` + +**为什么?** +- 因为在满足两个条件的情况下,我们可以选择添加左括号或右括号 +- 这是两种不同的选择,需要分别探索 + +**细节3:为什么判断条件是 `close < open` 而不是 `close <= open`?** + +```python +# 错误写法 +if close <= open: # ❌ + backtrack(current + ")", open, close + 1) + +# 问题:当 close == open 时,不能添加右括号 +# 例如:current="()", open=1, close=1 +# 如果再添加 ")",变成 "()()",这是错误的 + +# 正确写法 +if close < open: # ✓ + backtrack(current + ")", open, close + 1) +``` + +### 边界条件分析 + +**边界1:n = 1** +``` +输入:n = 1 +输出:["()"] +过程: + "" → "(" → "()" +终止:len("()") = 2 = 2*1 +``` + +**边界2:n = 8(最大值)** +``` +输入:n = 8 +输出:1430 个组合(卡特兰数 C(8)) +注意:虽然约束是 n <= 8,但算法可以处理更大的 n +``` + +**边界3:某个时刻右括号用完了** +``` +状态:current="(()(", open=3, close=1 +分析: + - 不能添加 ")":因为 close(1) < open(3),可以添加 + - 添加后:current="(()()", open=3, close=2 + - 继续添加 ")":current="(()())", open=3, close=3 ✓ +``` + +### 复杂度分析(详细版) + +**时间复杂度:** +``` +- 理论最坏情况:每个节点有 2 个分支,深度为 2n → O(2^(2n)) +- 实际情况:由于剪枝,只有卡特兰数 C(n) 个有效节点 +- 卡特兰数公式:C(n) = (2n)! / ((n+1)! × n!) +- 渐近复杂度:C(n) ≈ 4^n / (n^(3/2) × √π) +- **时间复杂度:O(4^n / √n)** + +为什么是 4^n 而不是 2^(2n)? +- 数学上 2^(2n) = 4^n +- 但由于剪枝,实际是 O(4^n / √n),比 O(4^n) 小得多 +``` + +**空间复杂度:** +``` +- 递归栈深度:最多 2n 层(每添加一个括号递归一次)- O(n) +- 存储结果:O(C(n))(卡特兰数)- 通常不计入空间复杂度 +- **空间复杂度:O(n)**(不计结果存储) +``` + +### 方法二:动态规划 + +**核心思想:**利用卡特兰数的递推关系。 **递推公式:** -- `dp[n]` 表示 n 对括号的所有有效组合 -- `dp[n] = "(" + dp[i] + ")" + dp[n-1-i]`,其中 `i` 从 0 到 n-1 +``` +dp[n] = "(" + dp[i] + ")" + dp[n-1-i] -**算法步骤:** -1. 初始化 `dp[0] = [""]` -2. 对于 `i` 从 1 到 n: - - 对于 `j` 从 0 到 i-1: - - 将 `dp[j]` 的每个组合加上一对括号,再拼接 `dp[i-1-j]` 的每个组合 -3. 返回 `dp[n]` +其中 i 从 0 到 n-1 + +解释: +- dp[n] 表示 n 对括号的所有有效组合 +- 每个组合可以看作:一对括号包裹着 i 对括号,后面跟着 n-1-i 对括号 +``` + +**Q: 为什么这样递推?** + +A: 任何有效的 n 对括号组合,都可以分解为: +1. 第一个左括号 +2. 一个与之匹配的右括号 +3. 中间有 i 对括号 +4. 后面有 n-1-i 对括号 + +举例: +``` +"(()())" 可以分解为: + ( + ()() + ) + ↑ ↑ ↑ + | | 匹配的右括号 + | 中间的 i=2 对括号 + 第一个左括号 + +所以:"(())()" = "(" + "()" + ")" + "()" + i=1, n-1-i=1 +``` ## 代码实现 @@ -137,6 +436,8 @@ func main() { } ``` +### Go 实现(动态规划) + ```go func generateParenthesisDP(n int) []string { if n == 0 { @@ -161,23 +462,162 @@ func generateParenthesisDP(n int) []string { } ``` -- **时间复杂度:** O(4^n / √n) - - 在回溯树中,每个节点最多有 2 个分支 - - 树的高度为 2n - - 但是由于剪枝,实际复杂度约为卡特兰数 C(n) - - 卡特兰数约为 O(4^n / (n^(3/2) * √π)) +## 执行过程演示 -- **空间复杂度:** O(n) - - 递归栈深度最大为 2n - - 存储结果的空间不算在内(这是必须的) +以 `n = 3` 为例: -### 动态规划 +``` +初始状态: current="", open=0, close=0 -- **时间复杂度:** O(4^n / √n) - - 与回溯法类似,需要生成所有有效组合 +第1层递归: + current="" → 可以添加 "(" (open=0 < 3) + current="" → 不能添加 ")" (close=0 不 < open=0) -- **空间复杂度:** O(4^n / √n) - - 需要存储中间结果和最终结果 + 路径:backtrack("(", 1, 0) + +第2层递归: + current="(" → 可以添加 "(" (open=1 < 3) + current="(" → 可以添加 ")" (close=0 < open=1) + + 路径1:backtrack("((", 2, 0) + 路径2:backtrack("()", 1, 1) + +第3层递归(路径1): + current="((" → 可以添加 "(" (open=2 < 3) + current="((" → 不能添加 ")" (close=0 < open=2) ✓ + + 路径1.1:backtrack("(((", 3, 0) + 路径1.2:backtrack("(()", 2, 1) + +第4层递归(路径1.1): + current="(((" → 不能添加 "(" (open=3 不 < 3) + current="(((" → 不能添加 ")" (close=0 < open=3) ✓ + + 路径1.1.1:backtrack("((()", 3, 1) + +第5层递归(路径1.1.1): + current="((()" → 不能添加 "(" (open=3 不 < 3) + current="((()" → 不能添加 ")" (close=1 < open=3) ✓ + + 路径1.1.1.1:backtrack("((())", 3, 2) + +第6层递归(路径1.1.1.1): + current="((())" → 不能添加 "(" (open=3 不 < 3) + current="((())" → 不能添加 ")" (close=2 < open=3) ✓ + + 路径1.1.1.1.1:backtrack("((()))", 3, 3) + +第7层递归(路径1.1.1.1.1): + current="((()))" → len=6=2*3,终止! + 添加到结果:["((()))"] + +...(继续其他路径) + +最终结果: +["((()))","(()())","(())()","()(())","()()()"] +``` + +## 常见错误 + +### 错误1:忘记剪枝条件 + +❌ **错误写法:** +```go +func generateParenthesisWrong(n int) []string { + result := []string{} + + var backtrack func(current string, length int) + backtrack = func(current string, length int) { + if length == 2*n { + result = append(result, current) + return + } + + // 没有剪枝条件! + backtrack(current+"(", length+1) + backtrack(current+")", length+1) + } + + backtrack("", 0) + return result +} +``` + +✅ **正确写法:** +```go +func generateParenthesis(n int) []string { + result := []string{} + current := []byte{} + + var backtrack func(open, close int) + backtrack = func(open, close int) { + if len(current) == 2*n { + result = append(result, string(current)) + return + } + + if open < n { // 剪枝条件1 + current = append(current, '(') + backtrack(open+1, close) + current = current[:len(current)-1] + } + + if close < open { // 剪枝条件2 + current = append(current, ')') + backtrack(open, close+1) + current = current[:len(current)-1] + } + } + + backtrack(0, 0) + return result +} +``` + +**原因:**没有剪枝会生成大量无效组合,时间复杂度爆炸。 + +### 错误2:使用 if-else 而不是独立的 if + +❌ **错误写法:** +```go +if open < n { + backtrack(open+1, close) +} else if close < open { + backtrack(open, close+1) +} +``` + +✅ **正确写法:** +```go +if open < n { + backtrack(open+1, close) +} +if close < open { + backtrack(open, close+1) +} +``` + +**原因:**两个条件可能同时满足,需要都尝试。 + +### 错误3:终止条件错误 + +❌ **错误写法:** +```go +if open == n && close == n { // 错误 + result = append(result, string(current)) + return +} +``` + +✅ **正确写法:** +```go +if len(current) == 2*n { // 正确 + result = append(result, string(current)) + return +} +``` + +**原因:**虽然 `open==n && close==n` 等价于 `len(current)==2*n`,但前者更冗余。 ## 进阶问题 diff --git a/16-LeetCode Hot 100/最长回文子串.md b/16-LeetCode Hot 100/最长回文子串.md index e47c699..3767dab 100644 --- a/16-LeetCode Hot 100/最长回文子串.md +++ b/16-LeetCode Hot 100/最长回文子串.md @@ -24,39 +24,335 @@ - `1 <= s.length <= 1000` - `s` 仅由数字和英文字母组成 +## 思路推导 + +### 暴力解法分析 + +**第一步:最直观的思路 - 枚举所有子串** + +```python +def longestPalindrome_brute(s): + n = len(s) + max_len = 1 + start = 0 + + # 枚举所有可能的子串 + for i in range(n): + for j in range(i+1, n): + # 检查 s[i:j+1] 是否是回文 + if isPalindrome(s, i, j) and (j - i + 1) > max_len: + start = i + max_len = j - i + 1 + + return s[start:start+max_len] + +def isPalindrome(s, left, right): + while left < right: + if s[left] != s[right]: + return False + left += 1 + right -= 1 + return True +``` + +**时间复杂度分析:** +- 枚举所有子串:O(n²)(有 n(n+1)/2 个子串) +- 判断每个子串是否回文:O(n) +- **总时间复杂度:O(n³)** + +**问题:** +- 当 n = 1000 时,n³ = 10⁹ 次操作,会超时 +- 存在大量重复判断,效率极低 + +### 优化思考 - 如何降低复杂度? + +**核心观察:** +1. **暴力法的瓶颈**:每次判断回文都要重新遍历子串 +2. **优化方向**:能否利用已计算的信息,避免重复判断? + +**思路一:动态规划(空间换时间)** + +关键问题:小问题的解能否帮助解决大问题? + +``` +如果 s[i:j] 是回文,那么: +- s[i+1:j-1] 也必须是回文 +- s[i] == s[j] +``` + +这就是**最优子结构**! + +**思路二:中心扩展法(逆向思维)** + +与其判断每个子串是否回文,不如: +1. 枚举每个可能的"中心" +2. 从中心向两边扩展 +3. 扩展到不能扩展为止 + +为什么这样可行? +- 回文串是关于中心对称的 +- 回文串的中心可能是: + - 一个字符(奇数长度):如 "aba",中心是 'b' + - 两个字符之间(偶数长度):如 "abba",中心在两个 'b' 之间 + +**思路三:Manacher 算法(极致优化)** + +中心扩展法的问题:同一个位置可能被访问多次 + +Manacher 的核心思想: +- 利用回文串的对称性 +- 如果已经知道一个长回文串,可以快速计算出它内部对称位置的回文半径 +- **时间复杂度降到 O(n)** + +### 为什么这样思考? + +**1. 降维思想** +``` +暴力法:枚举起点 + 枚举终点 + 判断回文 + ↓ +优化:枚举中心 + 向两边扩展 + +从二维枚举(起点,终点) 降到 一维枚举(中心) +``` + +**2. 对称性利用** +``` +回文串的定义:正读和反读相同 + +这意味着: +- 如果 s[left:right+1] 是回文 +- 那么 s[left] == s[right] +- 且 s[left+1:right] 也是回文 +``` + +**3. 中心扩展的优势** +``` +暴力法:每个子串都要单独判断 +中心扩展:一次扩展可以同时判断多个子串 + +例如 "babad": +以 'b'(index=0) 为中心扩展: + - "b" ✓ + - "aba" ✓ + - "bab" 不可能(越界) +``` + ## 解题思路 -### 方法一:动态规划(推荐) +### 方法一:中心扩展法(推荐) + +**核心思想:**回文串关于中心对称。从每个字符(或两个字符之间)向两边扩展,寻找最长的回文串。 + +### 详细算法流程 + +**步骤1:遍历所有可能的中心点** + +```python +for i in range(len(s)): + # 奇数长度:以 s[i] 为中心 + len1 = expand(s, i, i) + + # 偶数长度:以 s[i] 和 s[i+1] 之间为中心 + len2 = expand(s, i, i+1) + + # 取较长的 + max_len = max(max_len, len1, len2) +``` + +**Q: 为什么要考虑奇数和偶数两种情况?** + +A: 因为回文串长度的奇偶性不同,中心位置也不同: +- 奇数长度 "aba":中心是单个字符 'b' +- 偶数长度 "abba":中心在两个字符之间 + +**步骤2:从中心向两边扩展** + +```python +def expand(s, left, right): + # 当不越界且字符相等时,继续扩展 + while left >= 0 and right < len(s) and s[left] == s[right]: + left -= 1 + right += 1 + + # 返回回文串长度 + # 注意:循环结束时,left 和 right 已经不满足条件 + # 所以回文串是 s[left+1:right],长度为 right - left - 1 + return right - left - 1 +``` + +**Q: 为什么返回 right - left - 1?** + +A: 因为循环结束时: +- left 和 right 指向的位置要么越界,要么字符不等 +- 实际的回文串是 s[left+1:right] +- 长度 = right - (left+1) = right - left - 1 + +举例: +``` +s = "babad", 中心 i=1('a') +初始: left=1, right=1 +第1次: left=0('b'), right=2('b'), 相等 ✓ +第2次: left=-1(越界), right=3('a') +终止 +返回: right - left - 1 = 3 - (-1) - 1 = 3 +回文串: s[0:3] = "bab" ✓ +``` + +**步骤3:更新最大长度和起始位置** + +```python +if current_len > max_len: + max_len = current_len + # 计算起始位置 + start = i - (current_len - 1) // 2 +``` + +**Q: 起始位置为什么是 i - (current_len - 1) // 2?** + +A: 需要根据奇偶性分别计算: +- 奇数长度:中心在字符上,起始 = i - len//2 +- 偶数长度:中心在两个字符间,起始 = i - len//2 + 1 + +统一公式:`start = i - (len-1)//2` + +举例: +``` +奇数:i=1, len=3 ("bab"的中心) + start = 1 - (3-1)//2 = 1 - 1 = 0 ✓ + +偶数:i=1, len=2 ("bb"的中心在两个字符间) + start = 1 - (2-1)//2 = 1 - 0 = 1 ✓ +``` + +### 关键细节说明 + +**细节1:为什么需要两次扩展(奇数和偶数)?** + +```python +# 错误做法:只考虑奇数长度 +for i in range(len(s)): + max_len = max(max_len, expand(s, i, i)) + +# 对于 "abba",会漏掉 "bb" 和 "abba" +``` + +```python +# 正确做法:奇偶都要考虑 +for i in range(len(s)): + len1 = expand(s, i, i) # 奇数长度 + len2 = expand(s, i, i+1) # 偶数长度 + max_len = max(max_len, len1, len2) +``` + +**细节2:为什么边界条件是 left >= 0 而不是 left > 0?** + +```python +# 错误写法 +while left > 0 and right < len(s)-1 and s[left] == s[right]: + left -= 1 + right += 1 + +# 问题:会漏掉边界上的回文串 +# 例如 "aba" 的中心在 index=1 +# 如果 left > 0,就无法扩展到 index=0 +``` + +```python +# 正确写法 +while left >= 0 and right < len(s) and s[left] == s[right]: + left -= 1 + right += 1 +``` + +**细节3:如何处理单个字符的情况?** + +```python +def longestPalindrome(s): + if len(s) < 2: + return s # 直接返回,长度为 0 或 1 都是回文 + # ... 后续逻辑 +``` + +### 边界条件分析 + +**边界1:空字符串或单字符** +``` +输入: s = "" +输出: "" +原因: 空字符串也是回文串 + +输入: s = "a" +输出: "a" +原因: 单字符本身就是回文串 +``` + +**边界2:全部相同字符** +``` +输入: s = "aaaa" +输出: "aaaa" +处理: 每个位置扩展都能得到长度 4 +``` + +**边界3:无回文串(除单字符外)** +``` +输入: s = "abc" +输出: "a" 或 "b" 或 "c" +原因: 任意多字符子串都不是回文,返回任意单字符即可 +``` + +**边界4:多个最长回文串** +``` +输入: s = "babad" +输出: "bab" 或 "aba" +原因: 题目允许返回任意一个最长回文串 +``` + +### 复杂度分析(详细版) + +**时间复杂度:** +``` +- 外层循环:遍历 n 个字符 - O(n) +- 内层扩展:每个中心最多扩展 O(n) 次 +- 总计:O(n) × O(n) = O(n²) + +为什么不是 O(n³)? +- 暴力法:枚举起点 O(n) × 枚举终点 O(n) × 判断回文 O(n) = O(n³) +- 中心扩展:枚举中心 O(n) × 扩展 O(n) = O(n²) +- 关键:扩展是"一次判断,逐层扩展",而不是对每个子串重新判断 +``` + +**空间复杂度:** +``` +- 只使用了常数级别的变量:O(1) +- 不需要额外的数组或哈希表 +- 递归调用栈:O(1)(虽然看起来像递归,但深度最多 O(n)) +``` + +### 方法二:动态规划 **核心思想:**使用二维数组 `dp[i][j]` 表示 `s[i:j+1]` 是否为回文串。 **状态转移方程:** -- `dp[i][j] = (s[i] == s[j]) && (j - i < 2 || dp[i+1][j-1])` -- 如果 `s[i] == s[j]` 且 `dp[i+1][j-1]` 为真(或子串长度小于3),则 `dp[i][j]` 为真 +``` +dp[i][j] = (s[i] == s[j]) && (j - i < 2 || dp[i+1][j-1]) -**算法步骤:** -1. 初始化 `dp` 数组,所有单个字符都是回文串 -2. 按长度递增的顺序遍历所有子串 -3. 更新最长回文子串的起始位置和长度 +解释: +1. s[i] == s[j]:首尾字符必须相等 +2. j - i < 2:子串长度小于 3(如 "a", "aa")必然是回文 +3. dp[i+1][j-1]:去掉首尾后的子串也必须是回文 +``` -### 方法二:中心扩展法(最优) +**Q: 为什么按长度递增的顺序遍历?** -**核心思想:**回文串关于中心对称。从每个字符(或两个字符之间)向两边扩展,寻找最长的回文串。 +A: 因为计算 `dp[i][j]` 需要 `dp[i+1][j-1]`,而 `dp[i+1][j-1]` 对应更短的子串。 -**算法步骤:** -1. 遍历每个字符作为中心点 -2. 从中心点向两边扩展,直到不再是回文串 -3. 记录最长的回文串 -4. 需要考虑奇数长度和偶数长度两种情况 - -### 方法三:Manacher 算法(最优) - -**核心思想:**利用回文串的对称性,避免重复计算。时间复杂度 O(n)。 - -**算法步骤:** -1. 在字符串中插入特殊字符(如 `#`),统一处理奇偶长度 -2. 使用数组 `P` 记录以每个字符为中心的最长回文半径 -3. 利用对称性快速计算回文半径 +```python +# 按长度从 2 到 n 遍历 +for length in range(2, n + 1): + for i in range(n - length + 1): + j = i + length - 1 + # 计算 dp[i][j] +``` ## 代码实现 @@ -134,6 +430,8 @@ func main() { } ``` +### Go 实现(动态规划) + ```go func longestPalindromeDP(s string) string { if len(s) < 2 { @@ -174,89 +472,150 @@ func longestPalindromeDP(s string) string { } ``` +## 执行过程演示 + +以 `s = "babad"` 为例: + +``` +初始状态: start=0, maxLen=1 + +i=0, 字符='b': + 奇数扩展: expand(s, 0, 0) + left=0, right=0: 'b'=='b' ✓ + left=-1, right=1: 越界,停止 + 返回: 1 - (-1) - 1 = 1 + + 偶数扩展: expand(s, 0, 1) + left=0, right=1: 'b'!='a' ✗ + 返回: 1 - 0 - 1 = 0 + + maxLen = max(1, 1, 0) = 1 + +i=1, 字符='a': + 奇数扩展: expand(s, 1, 1) + left=1, right=1: 'a'=='a' ✓ + left=0, right=2: 'b'=='b' ✓ + left=-1, right=3: 越界,停止 + 返回: 3 - (-1) - 1 = 3 + + 偶数扩展: expand(s, 1, 2) + left=1, right=2: 'a'!='b' ✗ + 返回: 2 - 1 - 1 = 0 + + 更新: maxLen=3, start=1-(3-1)/2=0 + 当前最长: s[0:3]="bab" + +i=2, 字符='b': + 奇数扩展: expand(s, 2, 2) + left=2, right=2: 'b'=='b' ✓ + left=1, right=3: 'a'=='a' ✓ + left=0, right=4: 'b'!='d' ✗ + 返回: 4 - 0 - 1 = 3 + + 偶数扩展: expand(s, 2, 3) + left=2, right=3: 'b'!='a' ✗ + 返回: 3 - 2 - 1 = 0 + + maxLen = max(3, 3, 0) = 3 (保持不变) + +i=3, 字符='a': + 奇数扩展: expand(s, 3, 3) + left=3, right=3: 'a'=='a' ✓ + left=2, right=4: 'b'!='d' ✗ + 返回: 4 - 2 - 1 = 1 + + 偶数扩展: expand(s, 3, 4) + left=3, right=4: 'a'!='d' ✗ + 返回: 4 - 3 - 1 = 0 + + maxLen = max(3, 1, 0) = 3 (保持不变) + +i=4, 字符='d': + 奇数扩展: expand(s, 4, 4) + left=4, right=4: 'd'=='d' ✓ + left=3, right=5: 越界,停止 + 返回: 5 - 3 - 1 = 1 + + 偶数扩展: expand(s, 4, 5) + right=5 越界,直接返回 0 + + maxLen = max(3, 1, 0) = 3 (保持不变) + +最终结果: s[0:3] = "bab" +``` + +## 常见错误 + +### 错误1:只考虑奇数长度回文 + +❌ **错误写法:** ```go -func longestPalindromeManacher(s string) string { - if len(s) < 2 { - return s - } - - // 预处理:插入特殊字符 - t := "#" - for i := 0; i < len(s); i++ { - t += string(s[i]) + "#" - } - - n := len(t) - p := make([]int, n) - center, right := 0, 0 - maxCenter, maxLen := 0, 0 - - for i := 0; i < n; i++ { - if i < right { - mirror := 2*center - i - p[i] = min(right-i, p[mirror]) - } - - // 尝试扩展 - for i+p[i]+1 < n && i-p[i]-1 >= 0 && t[i+p[i]+1] == t[i-p[i]-1] { - p[i]++ - } - - // 更新中心和右边界 - if i+p[i] > right { - center = i - right = i + p[i] - } - - // 更新最大回文串 - if p[i] > maxLen { - maxLen = p[i] - maxCenter = i - } - } - - // 计算原字符串中的起始位置 - start := (maxCenter - maxLen) / 2 - return s[start : start+maxLen] -} - -func min(a, b int) int { - if a < b { - return a - } - return b +func longestPalindromeWrong(s string) string { + for i := 0; i < len(s); i++ { + len := expand(s, i, i) // 只考虑奇数 + // ... + } } ``` -## 复杂度分析 +✅ **正确写法:** +```go +func longestPalindrome(s string) string { + for i := 0; i < len(s); i++ { + len1 := expand(s, i, i) // 奇数 + len2 := expand(s, i, i+1) // 偶数 + maxLen = max(maxLen, len1, len2) + } +} +``` -### 中心扩展法 +**原因:**会漏掉偶数长度的回文串,如 "abba"。 -- **时间复杂度:** O(n²) - - 外层循环遍历 n 个字符 - - 内层扩展最多 O(n) 次 - - 总时间复杂度:O(n²) +### 错误2:边界条件错误 -- **空间复杂度:** O(1) - - 只使用了常数级别的额外空间 +❌ **错误写法:** +```go +func expand(s string, left, right int) int { + for left > 0 && right < len(s)-1 && s[left] == s[right] { + left-- + right++ + } + return right - left - 1 +} +``` -### 动态规划 +✅ **正确写法:** +```go +func expand(s string, left, right int) int { + for left >= 0 && right < len(s) && s[left] == s[right] { + left-- + right++ + } + return right - left - 1 +} +``` -- **时间复杂度:** O(n²) - - 需要填充 n×n 的 dp 数组 - - 但由于剪枝,实际复杂度约为 O(n²/2) +**原因:**边界字符也是回文串的一部分,需要判断。 -- **空间复杂度:** O(n²) - - 需要存储 n×n 的 dp 数组 +### 错误3:起始位置计算错误 -### Manacher 算法 +❌ **错误写法:** +```go +if currentLen > maxLen { + maxLen = currentLen + start = i - currentLen/2 // 错误! +} +``` -- **时间复杂度:** O(n) - - 只需遍历字符串一次 - - 利用对称性避免重复计算 +✅ **正确写法:** +```go +if currentLen > maxLen { + maxLen = currentLen + start = i - (currentLen-1)/2 // 正确 +} +``` -- **空间复杂度:** O(n) - - 需要存储处理后的字符串和半径数组 +**原因:**需要考虑奇偶性,统一公式是 `i - (len-1)/2`。 ## 进阶问题