LeetCode热题100刷到第六篇了。如果你是一路按顺序刷过来的,应该已经摸到了一点规律:前面几十题是“数据结构基本功”,到了中间开始进入“算法思维分水岭”。这一篇我挑了四道题:最长回文子串、目标和、爱吃香蕉的狒狒、杨辉三角。这几题在热题100里分布在不同章节,但它们有一个共同点——“思路对了就非常简单,思路不对能卡你一晚上”。尤其是最长回文子串和目标和,几乎每个大厂面试题库里都有它们的身影,值得单独拿出来拆一拆。
这篇不是按题号顺序做的题解,而是按“思维模型”组织起来的。我尽量把每道题的暴力解、优化解、最优解全部串一遍,告诉你每种做法在什么场景下够用、什么时候必须换思路,最后再聊聊怎么从“看懂题解”变成“自己会写”。不管你是刚开始刷热题100的中段,还是准备面试前快速过一轮经典题,这篇都值得花二十分钟慢慢看。
1. 这一篇选了哪四道题?以及为什么这么选
1.1 选题背后的逻辑
热题100刷到中段,很多人会进入一个尴尬期:简单题能独立做出来,中等题开始要看题解,难题干脆直接背答案。这个阶段最需要的不是更多题目,而是把已经会做的题“榨干”,从每道题里提炼出可以迁移的思维模型。
这四道题分布在热题100的不同分区,恰好代表了四种典型题感:
- 最长回文子串是字符串处理里最经典的“中心扩展 + 动态规划 + 马拉车”三合一题,刷透它,后面遇到子序列、子数组类问题会顺手很多。
- 目标和从 DFS 一步步推到 01 背包,是理解“暴力搜索为什么慢、动态规划为什么快”的最佳教材。
- 爱吃香蕉的狒狒是二分答案的模板题,这个套路考得比二分查找本身还频繁。
- 杨辉三角看着简单,但它是理解“状态转移”的最小单元,也是很多组合数问题的地基。
我在刷题群里见过太多人卡在“题目刷了不少,但换个问法就不会了”的困境。这个问题的根源往往不是刷题量不够,而是只记答案、不记思路。所以这一篇我故意选了这四道思路链完整的题,每道题都从最朴素的解法开始推,让你看清楚一个解法的“为什么”长在哪个环节。
1.2 四道题在面试中的定位
| 题目 | 难度 | 核心考点 | 面试出现频率 | 建议掌握程度 |
|---|---|---|---|---|
| 最长回文子串 | 中等 | 中心扩展 / 动态规划 / Manacher | 极高 | 中心扩展必须手写,Manacher理解原理 |
| 目标和 | 中等 | DFS / 记忆化 / 01背包 | 高 | 背包解法要能讲清推导 |
| 爱吃香蕉的狒狒 | 中等 | 二分答案 | 高 | 模板写法必须熟练 |
| 杨辉三角 | 简单 | 基础动态规划 | 中 | 空间优化要会 |
注意一个细节:热题100里这几道题虽然难度标签是“简单/中等”,但实际面试里它们常常作为“引子题”出现。面试官问完基础版本后会立刻加一个变体,比如问完杨辉三角接着问“只返回第k行怎么办”,问完目标和接着问“如果数组里有负数怎么办”。这四道题刷到位,等于连变体一起准备了。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 第5题 最长回文子串:从暴力到马拉车,一条完整思路链
2.1 先写最朴素的解法,建立正确性基准
最长回文子串这题我最早刷的时候犯过所有该犯的错。第一反应是暴力枚举所有子串,判断每个子串是不是回文。这个思路的代码大概长这样:
cpp复制// 暴力枚举:O(n^3),仅供建立直觉,实际不可行
bool isPalindrome(const string& s, int l, int r) {
while (l < r) {
if (s[l] != s[r]) return false;
l++, r--;
}
return true;
}
string bruteForce(string s) {
int n = s.size(), maxLen = 1, start = 0;
for (int i = 0; i < n; i++)
for (int j = i; j < n; j++)
if (isPalindrome(s, i, j) && j - i + 1 > maxLen) {
maxLen = j - i + 1;
start = i;
}
return s.substr(start, maxLen);
}
暴力枚举子串是 O(n^2),每个子串判断回文是 O(n),总复杂度 O(n^3)。n 稍微大一点比如 1000,就接近十亿次操作,必炸。
但暴力解不是废的。它提供了一个“正确性基准”——不管你后面用什么优化算法,结果都应该和暴力解一致。我调试中心扩展代码的时候,就是靠暴力解做对拍器找出边界 bug 的。这个习惯建议养成,题做多了你就知道,很多时候不是思路错,是边界写错了,而对拍是找边界最快的方式。
2.2 中心扩展:回到回文串的本质去思考
暴力枚举慢在哪里?它把“枚举子串”和“判断回文”分成了两步。但实际上,回文串的定义就是“从中心向两边对称”。与其枚举所有子串再判断,不如枚举所有中心,然后从中心向两边扩展,扩展到的位置天然就是回文。
回文串有两种中心:长度为奇数的回文中心是一个字符,长度为偶数的回文中心是两个字符之间的缝隙。所以循环 i 从 0 到 n-1,需要同时检查这两种情况。
cpp复制class Solution {
public:
string longestPalindrome(string s) {
int n = s.size();
if (n < 2) return s;
int maxLen = 1, start = 0;
for (int i = 0; i < n; i++) {
int len1 = expand(s, i, i); // 奇数长度
int len2 = expand(s, i, i + 1); // 偶数长度
int len = max(len1, len2);
if (len > maxLen) {
maxLen = len;
start = i - (len - 1) / 2;
}
}
return s.substr(start, maxLen);
}
private:
int expand(const string& s, int l, int r) {
while (l >= 0 && r < s.size() && s[l] == s[r]) {
l--;
r++;
}
return r - l - 1; // 注意此时 l 和 r 已经越界一位
}
};
这里最关键的是 expand 函数返回值的理解:while 循环结束时,s[l] 和 s[r] 已经不相等了(或者已经越界),所以回文的长度不是 r - l + 1 而是 r - l - 1。以及 start = i - (len - 1) / 2 这条公式,奇数长度和偶数长度都适用,可以自己推一遍。
中心扩展的时间复杂度是 O(n^2),空间 O(1)。在面试中这个解法完全够用,而且思路非常直观,面试官能一眼看穿你的逻辑。我个人的建议是:面试时先写中心扩展,把过程讲清楚,比强行写马拉车更安全。马拉车虽然更优,但边界细节多,一旦写错反而减分。
2.3 进阶:马拉车算法到底在优化什么
马拉车(Manacher)算法的核心是复用已计算的回文半径。它维护一个数组 p[i],表示以 i 为中心的最长回文半径(包含中心本身),同时维护一个当前覆盖到最右位置的回文区间 [mx - id, mx]。当计算新的 p[i] 时,如果能找到 i 关于 id 的对称点 j,那么 p[i] 至少等于 min(p[j], mx - i),在此基础上继续扩展。
核心代码如下:
cpp复制string longestPalindrome_Manacher(string s) {
// 插入分隔符,统一奇偶长度
string t = "#";
for (char c : s) {
t += c;
t += '#';
}
int n = t.size();
vector<int> p(n, 0);
int id = 0, mx = 0;
for (int i = 0; i < n; i++) {
p[i] = i < mx ? min(p[2 * id - i], mx - i) : 1;
while (i - p[i] >= 0 && i + p[i] < n && t[i - p[i]] == t[i + p[i]])
p[i]++;
if (i + p[i] > mx) {
mx = i + p[i];
id = i;
}
}
int maxLen = 0, center = 0;
for (int i = 0; i < n; i++) {
if (p[i] > maxLen) {
maxLen = p[i];
center = i;
}
}
// t中#的回文半径对应原串偶数长度回文
int start = (center - maxLen) / 2;
return s.substr(start, maxLen - 1);
}
注意,马拉车里 p[i] 的定义和边界处理非常容易出错。核心是 2 * id - i 得到 i 关于 id 的对称点,这个对称点的回文信息可以直接复用,所以马拉车能做到总体 O(n)。
我在实际刷题中的心得是:马拉车这个算法,明确说,工作中几乎遇不到,面试也很少要求你写最优解。但它的价值在于训练“对称性思考”——很多字符串问题都会用到“对称位置”这个思想。如果你时间充裕,建议把代码默写三遍,不是为了背题,而是为了把“以空间换时间、利用已计算信息”这个思维模型刻进脑子里。
2.4 这类题的面试取舍
这里想多说一句。其实最长回文子串还有动态规划解法,思路是 dp[i][j] 表示 s[i..j] 是否为回文,递推关系是 dp[i][j] = dp[i+1][j-1] && s[i]==s[j],复杂度也是 O(n^2)。这个解法在理解中心扩展之后再看一遍会非常轻松,它对“区间 DP”类问题是很好的铺垫。
面试时如果被问到这题,我建议的顺序是:先讲清“回文 = 对称”这个本质,然后给中心扩展,如果有余力再提马拉车是 O(n) 并能说清核心思想。这样整条思路链是完整的,面试官能看出你不是背题,而是真的理解。
3. 第494题 目标和:从暴力DFS到背包DP的经典推导
3.1 题目的本质是选择问题
目标和这题,我第一次看到题目的时候人有点懵:要往数组每个数字前面加 + 或 -,使得最终表达式的结果等于 target,求方案数。比如 nums = [1,1,1,1,1],target = 3,答案是 5。
这题包装成一个表达式的样子,但剥开外壳,本质上是:每个数字有两种选择,要么加正号,要么加负号,问有多少种选择方案能让总和等于 target。这就是一个典型的“组合选择”问题,第一反应自然是 DFS 枚举所有选择。
cpp复制// DFS暴力,O(2^n),n <= 20 勉强能跑
class Solution {
public:
int findTargetSumWays(vector<int>& nums, int target) {
int res = 0;
dfs(nums, 0, 0, target, res);
return res;
}
void dfs(vector<int>& nums, int idx, int cur, int target, int& res) {
if (idx == nums.size()) {
if (cur == target) res++;
return;
}
dfs(nums, idx + 1, cur + nums[idx], target, res);
dfs(nums, idx + 1, cur - nums[idx], target, res);
}
};
当 n 超过 20,2^n 就完全不可行。LeetCode 这道题的 n 最大到 20,暴力能做,但这个复杂度在面试里说出“我用 DFS”会被追问怎么优化。
3.2 记忆化搜索:给 DFS 加缓存
DFS 为什么慢?因为大量子问题被重复计算。假设数组是 [1,2,1,2,1,2,...],在某个位置 idx、当前和为 cur 的状态,可能通过不同的前期选择路径到达,这些路径后续的搜索过程完全一样。那就可以用 memo 缓存起来。
cpp复制class Solution {
public:
int findTargetSumWays(vector<int>& nums, int target) {
unordered_map<string, int> memo;
return dfs(nums, 0, 0, target, memo);
}
int dfs(vector<int>& nums, int idx, int cur, int target,
unordered_map<string, int>& memo) {
if (idx == nums.size()) return cur == target ? 1 : 0;
string key = to_string(idx) + "," + to_string(cur);
if (memo.count(key)) return memo[key];
int res = dfs(nums, idx + 1, cur + nums[idx], target, memo)
+ dfs(nums, idx + 1, cur - nums[idx], target, memo);
return memo[key] = res;
}
};
注意这里 cur 的范围可能包含负数,所以用哈希表做记忆化比二维数组省空间。这个解法的复杂度取决于状态数,最坏 O(n * sum),比 O(2^n) 好了很多。
我实际刷题中第一次理解记忆化搜索,就是通过这题。它的意义不只是会写这个函数,而是要意识到一个重要的思维转变:搜索树中很多分支是重叠的,缓存中间结果就是把指数级剪成多项式级。这个思想后面处理更多复杂 DP 题的时候会不断用到。
3.3 转换成 01 背包:数学推导才是关键
记忆化搜索能过,但面试官可能还会追问:“能不能用 DP 做?”这时候就需要做一步关键的数学变换。
设所有添加正号的数字之和为 pos,所有添加负号的数字之和为 neg,数组总和为 sum。那么:
- pos + neg = sum
- pos - neg = target
两式相减得:2 * neg = sum - target,所以 neg = (sum - target) / 2。
于是问题变成:从数组里选若干个数,让它们的和恰好为 neg,有多少种选法。这就是标准的 01 背包计数问题。
关键条件是:sum - target 必须是偶数且非负,否则方案数为 0。
cpp复制class Solution {
public:
int findTargetSumWays(vector<int>& nums, int target) {
int sum = 0;
for (int num : nums) sum += num;
int diff = sum - target;
if (diff < 0 || diff % 2 != 0) return 0;
int neg = diff / 2;
vector<int> dp(neg + 1, 0);
dp[0] = 1; // 不选任何数,和为0,一种方案
for (int num : nums) {
for (int j = neg; j >= num; j--) {
dp[j] += dp[j - num];
}
}
return dp[neg];
}
};
这里必须强调一点:内层循环一定要从后往前遍历。因为 01 背包每个物品只能用一次,如果从前往后,dp[j] 会累加到自己这一轮已经更新过的 dp[j-num],相当于同一个数字被用了多次,那就变成完全背包了。这是背包问题最容易写错的细节,没有之一。
数组中有零的情况也值得注意。如果 nums 里有 0,那么 dp 递推时 dp[j] += dp[j-0] 相当于 dp[j] *= 2,零本身不影响和的大小,但会成倍增加方案数。你如果直接用上面的代码,递推对 0 的处理会自然正确,省去特判。不过有兴趣的话可以手动算一个例子验证一下。
3.4 这一题的“题眼”总结
目标和这道题从 DFS 到记忆化到背包,本质上是三道题。如果你刷这题的时候直接跳到最优解,虽然代码很短,但你失去了最宝贵的训练机会。我强烈建议:第一次做这道题,把 DFS 写出来,然后加 memo,最后再推背包。这个过程就是动态规划思维的完整训练,做完这一题,你对“状态设计”“状态转移”“状态压缩”的理解会上升一个台阶。
面试时如果被问这题而且想展示深度,可以从 DFS 讲起,主动说“但这样是 2 的 n 次方,我们可以发现它是背包模型”,然后退出数学公式。这比直接甩 dp 代码要加分得多。
4. 第875题 爱吃香蕉的狒狒:二分答案的模板级套路
4.1 为什么这题要用二分
爱吃香蕉的狒狒长这样:有一堆香蕉 piles,每堆有若干根,狒狒每小时最多吃一堆,如果选的吃速 k 大于当前堆的数量,也只需要一小时;问在 h 小时内吃完所有香蕉的最小速度 k。
这题最容易掉进去的坑是第一反应是模拟:从 k=1 开始试,每次计算总耗时,直到满足 h。这个思路正确但太慢,因为 k 的可能范围很大,线性扫描到大数就废了。
关键观察是:总耗时和 k 之间存在单调性。k 越大,总耗时越小或不变;k 越小,总耗时越大。这是一个单峰(其实单调递减)函数。只要一个问题的可行解存在单调性,就可以用二分答案来找边界。
4.2 二分答案的标准写法
二分答案的难点从来不是二分本身,而是两个东西:边界怎么定、check 函数怎么写。
先定边界:k 的最小值是 1(不可能为 0,0 没法吃),最大值是 max(piles)(因为每小时最多一堆,k 大于最大值没有意义,耗时已经是最小)。
check 函数要求“给定 k,计算吃完所有香蕉需要的总小时数”。注意每小时最多吃一堆,所以对每堆香蕉 piles[i],需要的时间是 piles[i] / k 向上取整,数学表达是 (piles[i] + k - 1) / k。
cpp复制class Solution {
public:
int minEatingSpeed(vector<int>& piles, int h) {
int left = 1, right = *max_element(piles.begin(), piles.end());
while (left < right) {
int mid = left + (right - left) / 2;
if (check(piles, h, mid)) {
right = mid; // 够快,尝试更小速度
} else {
left = mid + 1; // 不够快,必须提高速度
}
}
return left;
}
private:
bool check(vector<int>& piles, int h, int k) {
long long hours = 0;
for (int p : piles) {
hours += (p + k - 1) / k;
if (hours > h) return false; // 提前退出,防止溢出/加速
}
return hours <= h;
}
};
这里是标准模板:left = mid 还是 left = mid + 1,取决于你是在找“满足条件的左边界”还是“右边界”。本题找最小 k,所以用 right = mid 收缩右边界,left = mid + 1 收缩左边界。这个模板建议背下来,二分答案的题 90% 都是这个框架。
check 函数里的提前退出写得特别值钱。因为 hours 可能会非常大,用 long long 累加能防溢出,但更妙的是 if (hours > h) return false; 这一行——一旦超过目标时间,立刻返回,不用继续算,省时间,还能顺便避免大数问题。
4.3 怎么识别“二分答案”类题目
这题是二分答案的典型代表,而二分答案类是 LeetCode 上被反复考察的模型。常见的识别词有以下几组:
- 最小化最大值:把数组分成 k 份,每份和的最大值最小(如分割数组求最大子数组和)
- 最大化最小值:在 m 次跳跃内到达终点,每段距离的最小值最大(如小球的磁力)
- 速度/能力可行性:给定速度 k 判断在限制内能否完成(如本题、运输问题)
我刷过的二分答案题目里,90% 的难点不是二分本身,而是“从题意抽象出单调性”。只要你看到一个题能写出 f(k),并且 f(k) 随 k 单调变化,那么不管它包装成香蕉、包裹、还是气球,方法都一模一样。
这里分享一个我踩过的坑:check 函数里如果用 while 循环模拟每个 pile 的逐根吃的过程,会超时。正确做法是直接用除法计算耗时,而不是循环减。这个细节虽然简单,但实际写代码的时候容易因为“想验证一下”而手滑写成循环。
5. 杨辉三角:动态规划的最小单元
5.1 一道简单的题,一个重要的模型
杨辉三角在 LeetCode 上难度是“简单”,但这道题拿到手不应该只是“写出来了就行”。它其实是动态规划里最朴素的状态转移:第 i 行的第 j 个数,等于上一行的第 j-1 个数加上上一行的第 j 个数。
cpp复制class Solution {
public:
vector<vector<int>> generate(int numRows) {
vector<vector<int>> res(numRows);
for (int i = 0; i < numRows; i++) {
res[i].resize(i + 1);
res[i][0] = res[i][i] = 1; // 首尾都是1
for (int j = 1; j < i; j++) {
res[i][j] = res[i - 1][j - 1] + res[i - 1][j];
}
}
return res;
}
};
第一次写这道题的人,最容易漏的是 res[i][0] = res[i][i] = 1 这行的边界初始化。因为 j 从 1 到 i-1 只处理了中间元素,首尾不初始化就默认是 0,结果会错得很离谱。
5.2 空间优化:只保留上一行
LeetCode 还有个变体叫“杨辉三角 II”(第119题),只要求返回第 rowIndex 行,顺序从 0 开始。如果直接把 generate 里所有行都存下来,空间是 O(n^2)。但既然只需要一行,完全可以只维护一维数组,然后从后往前更新。
cpp复制class Solution {
public:
vector<int> getRow(int rowIndex) {
vector<int> row(rowIndex + 1, 0);
row[0] = 1;
for (int i = 1; i <= rowIndex; i++) {
// 必须从后往前更新,否则会覆盖还没用到的值
for (int j = i; j >= 1; j--) {
row[j] += row[j - 1];
}
}
return row;
}
};
注意看,这个从后往前的遍历方式和 01 背包的空间优化是同构的。如果把第 i 轮循环看成“使用前 i 个数”,那从后往前更新保证了每一个状态只被更新一次。很多 DP 题的空间优化都是这一段逻辑。所以杨辉三角 II 虽然简单,但它是理解“滚动数组”思想的最小训练场。
5.3 题目之外的思考:组合数的联系
杨辉三角的第 n 行第 k 个数,其实就是组合数 C(n, k)。这一点如果你知道,会在很多组合计数问题上占便宜。比如某道题问你从网格左上角走到右下角有多少种走法,本质上就是组合数;再比如涉及二项式展开的题,也会往杨辉三角上靠。
我在面试中遇到过一个问题:一个数组里任意两个数两两组合求和,怎么高效枚举?其实不需要杨辉三角,但理解 C(n,2) 的组合意义会非常有用。杨辉三角作为这个思维的地基,值得花半小时把它和组合数的关系好好推导一遍,而不是只满足于 AC。
6. 刷题复盘:如何把这四道题真正变成自己的
6.1 一个实操过、有效的刷题流程
说了这么多题解,最后想聊聊刷题方法论。很多人刷热题100,习惯是“看题→想十分钟→没思路→看题解→标记已刷”,但这样刷完一遍基本等于没刷。我个人的流程是:
- 必须动手写暴力解。哪怕知道会超时,也先写一个朴素版本。它的价值是逼你理解题意,并且给后续优化提供对照基准。
- 不要看题解,先看提示。LeetCode 题目下面的 Hint 是有用的,提示通常按难度递进,给一点提示再自己思考。
- AC 之后再去看题解区的高票答案。对比别人的解法和自己的解法,找出“为什么人家能想到这一步”。
- 关掉代码,重新默写。这道题三个月后你还能写出来吗?不能的话,说明你当时只是记住了代码,没有理解思想。
这套流程看起来很折腾,但它是从“刷了100题”到“吃透100题”的关键差异。我见过太多人刷了三遍热题100第三遍还是从第一题开始看题解,因为每次都是“眼睛会了,手没会”。
6.2 建议给每道题建一个“思路档案”
不要只建错题本,建议给每道题建一个更薄的档案,只记录三件事:
- 拿到题的第一步反应是什么(暴力?贪心?DP?)
- 卡住的关键点在哪里
- 这道题的题眼是什么(比如二分的单调性、背包的容量定义)
拿这篇的四道题举例:
| 题目 | 第一步反应 | 卡住我的点 | 题眼 |
|---|---|---|---|
| 最长回文子串 | 暴力枚举 | 奇偶长度处理 | 回文的本质是对称 |
| 目标和 | DFS | 怎么从DFS想到背包 | 数学变换 pos - neg = target |
| 爱吃香蕉的狒狒 | 线性枚举k | check函数的向上取整 | k的单调性 |
| 杨辉三角 | 直接模拟 | 空间优化方向 | 状态压缩从后往前 |
这个档案不用写很多字,每个题几行就够了。但当你刷到第100题再回看这些记录,你会发现自己的思维模型在慢慢建立。这类比就像玩 RPG 游戏,你是在收集“算法卡片”,而不是单纯刷怪升级。
6.3 周赛对刷题状态的检验价值
顺便提一下,如果最近刷题刷到麻木、觉得自己进步慢,我非常推荐去打一场周赛。周赛的限时压力和平时的随意刷题完全是两个量级。你以为自己会了的最长回文子串,在周赛第三题变形出现时,可能依然会在 30 分钟内翻车。这很正常,翻车才能照出薄弱点。
我在周赛里最高频翻车的地方,就是像目标和这种“背包模型识别题”。平时做热题100,题目分类已经帮你分好了,你知道这题是背包;但周赛里没人告诉你这题是背包,你需要自己在 20 分钟内识别出来。所以如果目标是面试实战,刷题库之外一定要留一些时间做无分类限时训练。
6.4 热题100之后怎么继续进阶
四道题聊完,再往后的方向说一下。热题100覆盖了绝大多数面试题模型,刷完一遍后,下一步不是你想象的“再刷第二遍”,而是去做专题和乱序混合训练。比如:
- 把二维动态规划单独列出来,刷 20 道二维 DP 题
- 把单调栈单独列出来,刷 15 道
- 每周打一次周赛,无分类训练识别能力
这里给出一个简单的判据:如果看到一道新题,你能在 15 分钟内给出正确的复杂度和大致思路,并且 30 分钟内把代码写出来跑通,那你的刷题节奏就是健康的。如果超过这个时间,不是智商问题,而是做的题还不够,或者做完没复盘。
我个人在实际操作中的体会是:热题100的价值不在于它的100这个数字,而在于它把最常考的思路模型浓缩到了一个可控的范围里。这个系列刷到中段,最忌讳的事就是“题越刷越多,思路越来越薄”。所以这一篇里四道题,每一道我都刻意把“暴力解到最优解”的链条写完整了。你别嫌弃朴素解法代码长,能把一条思路链闭环走完,比背十个最优解都有用。最后再分享一个小技巧:每次刷完一类题,试着用一句话总结这类题的共同特征,写在自己的笔记里。比如二分答案类的共同特征就是“求某个边界值且该值满足单调性”——写出来的总结才是真正内化的知识。
