☰
力扣经典150题36-40复盘:双指针、贪心与动态规划核心套路解析
2026/10/20 23:54:58 网站建设 项目流程

刷力扣的题36到题40这一档,我印象很深。倒不是说这几道题难度突然拉高,而是刷到这一带的时候,你会明显感觉到题目的套路开始收敛了:前面那些零散的数组操作、哈希表的用法,到这里都开始变成组合拳。你在面试里被追问的边界条件、复杂度分析、还有“能不能再优化一下”,基本就是在这个阶段被反复摩擦出来的。

这篇我按自己在经典150题里做题-复盘-二刷的真实顺序,把涉及双指针、区间合并、贪心、动态规划、原地数组操作的几类问题集中拆一遍。适用对象是两类人:一类是刚开始刷题、想找一条稳定主线的同学;另一类是刷过一遍但总觉得“会做却说不出为什么”的人。后者在面试里其实更吃亏,因为面试官问的从来不是答案,而是你的决策过程。

1. 这批题号背后的真实分布:先看懂它在考什么

我必须要先泼一盆冷水:所谓“经典150题”,在不同版本、不同平台的编排顺序并不是严格一致的,题36到题40具体对应哪几道题,可能有细微差异。但这反而给了我们一个更方便的复盘维度——按知识点类型来看这一档题目覆盖了什么。

从我拿到的编排来看,这一组题高度集中在四个高频专题上:

知识点专题典型题目场景面试出现率核心能力考察点
双指针三数之和、容器装水极高有序性利用、指针移动策略
区间合并合并重叠区间高排序预处理、边界重叠判定
贪心算法股票买卖、跳跃游戏极高局部最优与全局最优的证明直觉
动态规划(入门)最大子数组和、股票系列极高状态定义、状态转移、滚动优化
原地数组操作删除有序数组中的重复项高双指针覆盖、不另开数组的意识

你仔细看这张表会发现一个规律:这一档几乎全是“数组/序列类”问题。这是因为经典150题在编排时,前40题的主要任务是帮你建立线性结构的处理能力。所有的问题底层都是在一维数组上做文章,区别只在于你是用双指针、排序还是动态规划去解决。

打个比方,这个阶段就像健身房里的基础力量训练。卧推、深蹲、硬拉,动作看起来不花哨,但几乎后面所有的复合动作都从这里借力。题36到题40这批题目,就是算法面试里的卧推和深蹲。

另外还有一个容易被忽略的点:这一档题目的输入规模设计很有意思,大多数在10^4到10^5之间。这个规模暗示了很多信息。比如,如果你的暴力解法是O(n^2),在10^5的数据量下就是10^10次操作,再怎么优化常数都是超时的。而O(n log n)的排序思路、O(n)的双指针或贪心思路,就正好落在这个标准区间。所以你在写代码之前,看一眼数据量,其实就能大概判断面试官预期的时间复杂度。这个“看数据猜解法”的能力,是刷题刷到这个阶段必须开始培养的。

2. 排序预处理到底解决了什么:三数之和与合并区间的共同底牌

题36到题40里最让我觉得“通了”的一个瞬间,是我发现合并区间和三数之和,解题的第一步居然惊人地相似——都是先排序。这两道题表面上一个在求数组组合,一个在求区间合并,但排序这个动作,背后解决的是同一个问题:把无序的决策空间变成有序的线性扫描空间。

2.1 三数之和:为什么必须排序

三数之和要求你找出所有三元组,满足三个数相加等于0,且不能重复。你第一次看到这道题,最朴素的想法是三重循环,时间复杂度O(n^3),而且去重非常麻烦。排序之后,情况就变了。

排序之后,数组变为有序序列,三重循环的暴力解就被压缩成了“固定一个数 + 双指针扫剩余区间”。这种压缩的本质是:有序数组让你可以仅凭大小关系决定指针往哪边移动。

看代码:

def three_sum(nums): nums.sort() n = len(nums) res = [] for i in range(n - 2): if i > 0 and nums[i] == nums[i - 1]: continue left, right = i + 1, n - 1 while left < right: total = nums[i] + nums[left] + nums[right] if total == 0: res.append([nums[i], nums[left], nums[right]]) while left < right and nums[left] == nums[left + 1]: left += 1 while left < right and nums[right] == nums[right - 1]: right -= 1 left += 1 right -= 1 elif total < 0: left += 1 else: right -= 1 return res

这里有几个细节值得单独拿出来说。

去重的逻辑是这道题最容易写错的地方。很多人写去重时,会把判断放在total == 0的分支外面,导致漏掉有效答案或者产生重复项。正确的做法是:在固定第一个数nums[i]时,就跳过所有已经在当前循环用过的值。这样,即使后面双指针部分遇到重复元素,也不会产生重复三元组。

还有一个经验:total < 0时我们移动left,理由是数组已经升序,nums[left]是当前区间最小的元素,它加上固定值和右指针之后仍然小于0,说明左指针这个值在任何组合下都太小了,直接淘汰。同理,total > 0时移动right。这就是双指针能够把复杂度从O(n^3)降到O(n^2)的根本原因——每一轮循环,至少有一个指针在向最终答案收敛。

2.2 合并区间:排序把“乱麻”捋成了“线头”

合并区间的题长这样:给你一堆形如[start, end]的区间,把有重叠的区间合并起来。暴力做法是两两比较区间是否重叠,时间复杂度O(n^2),而且合并完还要处理连锁反应。

排序之后的思路就清晰了:按区间起点排序,然后顺序扫描。如果当前区间的起点小于等于上一个合并区间的终点,说明有重叠,更新终点;否则,上一个区间已经独立成片,可以输出。

def merge_intervals(intervals): intervals.sort(key=lambda x: x[0]) res = [] for start, end in intervals: if res and start <= res[-1][1]: res[-1][1] = max(res[-1][1], end) else: res.append([start, end]) return res

这段代码总共只有几行,但有个很核心的判断逻辑:start <= res[-1][1]是重叠的条件。这里用等号是有意为之的,[1, 3]和[3, 5]在题目的定义里应该被合并成[1, 5]。很多人在这个点上会纠结,我的建议是直接看题目定义。一般题目里如果明确说“重叠”包含首尾相接,那等号就是必须的;如果面试官没有特别说明,按包含等号处理,并且说一句“我把相邻区间也视为重叠”,这是更稳的做法,因为面试官大概率是默认合并相邻区间的。

排序之后用res[-1][1] = max(res[-1][1], end)这个更新操作也值得琢磨。它保证了当前合并区间的终点能兼容后续可能出现的、起点更早但终点更晚的区间。比如[1, 6]之后来了[2, 5],终点不需要变;如果来了[2, 8],终点要更新为8。

2.3 排序思路的适用范围

把这两道题放在一起看,你会发现一个通用的解题模板:当问题涉及子集、区间、配对关系时,先排序,把离散的对应关系变成连续的大小关系。排序的时间复杂度O(n log n),在绝大多数面试题里都是可以接受的预处理代价,但它换来的算法简化往往是阶别上的——从O(n^2)暴力解降到O(n)或O(n log n)。

当然,排序也不是万能钥匙。如果题目要求保持原始相对顺序(比如后面要配合动态规划做状态转移),排序就会破坏数据间的隐含关系。这点在面试时要主动提出来,让面试官知道你不是只会套模板,而是真的在思考排序的代价和收益。

3. 买卖股票与跳跃游戏:贪心解法的正确性到底从哪来

这一组题里最让我觉得“不讲道理”的,就是贪心类问题。每次用贪心解完,总有一种“怎么这么简单”的不真实感。但面试官问一句“你凭什么觉得这样局部最优就能推出全局最优”,很多人就卡住了。所以我这部分重点讲讲贪心选择性质的证明思路,而不只是给代码。

3.1 买卖股票的最佳时机:差分思想的妙用

买卖股票的最佳时机,题目描述很简短:给定一个数组prices,其中prices[i]表示第 i 天的股价,你只能选择某一天买入,之后某一天卖出,求最大利润。很多人直觉就是找最小值再找最大值,但没注意到“卖必须在买之后”。

一个非常经典的转化是:把相邻两天的价格差算出来,得到一个新数组。在这个差分数组上,求最大利润就变成了求最大连续子数组和。这个转化很漂亮,把“选择买入卖出时机”的问题变成了“选择一段连续上涨区间”的问题。

def max_profit_one_transaction(prices): min_price = float('inf') max_profit = 0 for price in prices: min_price = min(min_price, price) max_profit = max(max_profit, price - min_price) return max_profit

这个解法里,min_price维护的是当前天之前(包括当天)的最低价格。它之所以正确,是因为最优卖出点一定是在某个最低买入点之后出现的。你不需要提前知道未来是什么样,只需要顺着数组往后扫,不断用“今天的价格减去之前最低价”去更新答案。

这道题我当年犯过的错误是:把min_price初始值设成prices[0],然后从下标1开始遍历。这在大多数测试用例下没问题,但如果你把空数组传进去,就会直接报错。更保险的写法是像上面这样初始化成正无穷,遍历完整数组。这种从空值角度考虑问题的习惯,是面试中很少见但很加分的点。

3.2 跳跃游戏:可达范围的持续扩张

跳跃游戏的描述是:数组nums中每个元素表示你在该位置可以跳跃的最大长度,判断你是否能够到达最后一个下标。这道题典型的贪心解法是维护一个最远可达位置max_reach,遍历每个位置时,如果当前位置已经超过max_reach,说明卡住了;否则更新max_reach = max(max_reach, i + nums[i])。

这段逻辑的证明思路是:只要某个位置可达,那么它前面的所有位置都可达。因此我们不需要关心具体的跳跃路径,只需要关心可达范围的最远边界。这个范围是单调扩张的,一旦扩张到数组末尾,就说明必然存在一条路径。

我觉得理解这道题最好的方式,是把它想象成一个“移动的雷达扫描范围”。你在当前位置,雷达能探测到未来一段距离内的所有位置。你每往前走一步,如果这个位置在雷达范围内,就能把这个位置能探测到的范围叠加进去,雷达的覆盖距离会变大。你只需要关心雷达有没有覆盖到终点。

def can_jump(nums): max_reach = 0 n = len(nums) for i in range(n): if i > max_reach: return False max_reach = max(max_reach, i + nums[i]) if max_reach >= n - 1: return True return True

这里要注意一个细节:当max_reach >= n - 1时,理论上可以直接返回 True,因为已经能到终点。但如果你不想提前退出,继续循环也是对的。提前返回的好处是省时间,坏处是代码有多个出口,面试时容易在边界条件下把自己绕晕。我的习惯是只在i > max_reach时返回 False,其他情况走完循环再返回 True。这样保证代码的出口只有两个,逻辑更清晰。

3.3 贪心不是蒙答案:如何向面试官解释正确性

很多人把贪心理解成“凭感觉猜解法”,这是不对的。贪心能成立,需要满足两个性质:贪心选择性质和最优子结构性质。面试时你可以用这个框架来组织回答。

贪心选择性质的意思是:每一步的最优选择,最终一定包含在全局最优解里。跳跃游戏里,每一步把max_reach扩张到最大,这个选择不会让后续的可达范围变小,因此它不会阻碍你到达终点。买卖股票里,每一步记录最低买入点,这个局部信息只会让利润不下降,因此它不会错过最优卖出时机。

最优子结构性质的意思是:一个问题的最优解包含子问题的最优解。跳跃游戏里,如果你能到达位置 i,那么以位置 i 为起点能不能到达终点,与原问题结构相同。股票问题里,在第 i 天卖出的最优利润,一定基于前 i-1 天的最优买入时机。

面试时不需要背这两个名词,但要用这个思路去解释你的解法。一个比较顺的口头表达是:“我维护的这个量是单调变化的,且它不会丢失任何可能的最优候选,所以贪心成立。我可以简单用反证法说明——如果存在一个更好的解不用这个局部最优值,那在某个位置上必然后续可达范围更小,会产生矛盾。”

4. 原地操作与滑动窗口:数组题里的手工活

这一组题里还有一类非常考验代码功底的题目——原地删除重复项。这类题不要求你另开一个数组,而是让你直接修改输入数组,并返回处理后的长度。业务上这种需求很常见,因为内存空间有限,或者你有传递数组引用的约束。

4.1 有序数组去重:快慢指针的经典写法

有序数组中的重复项,因为顺序已经排好,重复元素一定是连续出现的。我们可以用快慢指针:慢指针slow指向下一个不重复元素要放置的位置,快指针fast负责遍历数组。边界条件设置好后,代码非常像一个手工作业:把不重复的“货”一件件搬到前面对应的“货架”上。

def remove_duplicates_sorted(nums): if not nums: return 0 slow = 1 for fast in range(1, len(nums)): if nums[fast] != nums[slow - 1]: nums[slow] = nums[fast] slow += 1 return slow

这个写法的精髓是:nums[slow - 1]是上一个保留的元素,而nums[fast]是当前扫描的位置。只要当前元素不等于上一个保留元素,说明出现了新值,就把它覆盖到slow的位置。代码里没有显式的删除操作,因为覆盖在数组中就是删除。

这种“延迟写入”的技术在这类题里非常关键。你不需要预先考虑哪些元素要保留,只需要通过比较判断是否覆盖。面试官给你这个题的时候,心里其实也在看你有没有“一根筋不修改原数组”的洁癖。很多应届生会绕远路——创建一个新列表,最后再复制回去。虽然结果是对的,但一方面空间复杂度不符合题目预期,另一方面面试官会觉得你没有真正理解原地操作的优势。

4.2 滑动窗口为什么经典

题36到题40之间如果出现了滑动窗口相关题目,那它考察的其实是另一套思维:用一个可变大小的窗口,在数组上移动,通过调整窗口的左右边界来维护一个动态区间。这类题目在解决“连续子数组满足某个条件”时几乎是无脑好用的。

与双指针需要数组有序不同,滑动窗口通常不依赖数组有序。它依赖的是单调性——随着窗口扩大,某个指标单调变化(比如和增加、长度变长、窗口内的不重复字符数增加)。正是因为这种单调性,你才可能用移动右边界加收缩左边界的策略。

一个典型套路是:右指针不断右移,扩大窗口,直到窗口内条件不满足,然后左指针右移,缩小窗口,直到条件重新满足。循环的这个过程,其实是在枚举所有可能的窗口,但不重复扫描。时间复杂度是O(n),因为每个指针最多移动n次。

在面试时,我习惯把滑动窗口的代码结构固定下来:

left = 0 window_state = initial_state for right, val in enumerate(iterable): window_state = update_state(window_state, val) while window_state doesn't satisfy condition: window_state = remove_state(window_state, iterable[left]) left += 1 # 此时窗口[left, right]是满足条件的状态 result = update_result(result, right - left + 1)

这个模板切换不同题目时,只需要改三处:状态是什么、右移时怎么更新状态、左移时怎么减少状态。掌握了这个模板,你几乎可以把所有连续子数组类问题都套进去。

4.3 边界条件的体系化检查清单

原地数组操作和滑动窗口的共同难点,是数组边界。我建议你在写完代码后,心里默念三组边界测试用例:空数组、单元素数组、全相同元素数组。

空数组测试的是你是否有显式的防御性判断,还是直接访问下标导致崩溃。单元素数组测试的是你的循环起点和指针初始位置是否合理。比如remove_duplicates_sorted里slow初始化为1,单元素数组根本不会进入循环,直接返回1,这是安全的。但如果你把slow初始化为0,就会发生覆盖自己的尴尬操作。全相同元素数组测试的是你的覆盖逻辑是否在无法遇见新值时仍然保持正确。这三组用例能帮你过滤掉90%的边界错误。

5. 我的复盘清单:这几题在面试现场最容易翻车的三个地方

最后这部分,我按照自己参加某公司二面、某互联网大厂在线笔试时被反复追问过的点,整理了一份避坑清单。它不是标准答案,但确实是实战中高频出现的坑。

5.1 复杂度估算的误区

很多人在回答“时间复杂度是多少”时,只回答“O(n)”或者“O(n^2)”,但面试官的追问通常是:“你知道 n 是什么吗?你的排序复杂度算进去没有?”

三数之和这道题,排序是O(n log n),双指针主体是O(n^2),所以整体是O(n^2),因为n^2增长更快。但如果面试官问的是“为什么排序不影响复杂度”,你要能说出来:因为n^2项支配了n log n项。这种看似简单的分析,在现场紧张状态下很容易卡壳。我的经验是,每道题做完都把这个计算过程完整写一遍,反复几次就形成肌肉记忆了。

还有一点,面试官如果问“你还能优化吗”,不要急着说“不能”。你可以先从空间复杂度入手,比如把辅助数组优化成滚动变量;再考虑剪枝的条件;最后再谈“如果有额外约束,比如数据范围很小,还可以用计数排序或桶排序”。这一套组合话术,比直接回答“不能”要好得多。

5.2 面试现场容易说错的概念

有一个高频翻车点,是混淆“贪心”和“动态规划”。简单来说,贪心是每步只选当前最优,且不回头;动态规划是要把子问题的解都算出来,然后组合。股票买卖问题里,如果允许无限次交易,贪心就能解;如果只允许一次交易,你要用动态规划思想维护最低点;如果限制了交易次数,就需要更复杂的动态规划。做题时先判断清楚约束条件,再决定用哪种策略,这个判断本身就构成了面试官考察你的重点。

我在面试某平台的时候,就因为没看清“只能交易一次”这个条件,直接按“无限次交易”的贪心解了,最后被面试官一步步追问才意识到错了。那次之后,我的习惯是看完题目先圈出约束词——比如“最多”“恰好”“连续”“不重叠”——再开始思考解法。这个习惯帮我避免了很多不必要的返工。

5.3 表述顺序比答案更值钱

面试时如果你直接给出代码,哪怕完全正确,也未必能拿满分。比较推荐的表述节奏是:先说是自己做过类似题,让面试官知道你不是从零开始;然后用一句话解释核心思路,比如“这道题我先排序,再用双指针;排序的目的是让重复元素相邻,便于去重”;再讲复杂度;最后,在代码写完后,主动说一个你自己曾经踩过的坑。

比如三数之和那道题,你就可以顺势说:“这里我特别注意了一下左指针跳过重复元素的逻辑,第一次写的时候漏了这个判断,结果输出了一个重复三元组。”这句话的价值在于,它向面试官传递了两个信息:你真的上手写过而且写过不止一遍;你能主动暴露自己的思考过程。在面试这种信息不对称的场景里,这就是最有效的信任积累。

好的代码题完成状态,不应该只是“跑通了”,而是“我已经把可能的边界条件、复杂度、错误点都准备了一遍”。这才是面试官最想看到的候选状态。

写在最后

题目编号是题36到题40也好,是题41到题45也好,并不重要。重要的是你在刷到这个阶段时,有没有开始形成一套稳定的解题策略:看到数组题先问是否有序,看到最值先想贪心还是动态规划,看到连续子数组先想滑动窗口。我在刷完这批题后,明显感觉到后续刷题的速度变快了,因为很多难题无非是这几个基础套路的叠加。

我个人的一个小建议是,每刷完一组题,别急着推进度,先做一次“错题归因”。把每道题的翻车点写在一张表格里,总共不要超过20个字。等到你积累了三四组,再回看这张表,会发现自己常犯的错误高度集中。比如我就总是在边界条件上翻车,后来每次写循环前都会先问自己:“如果数组是空的怎么办?如果只有一个元素怎么办?”这一个小小的习惯,让我后续面试手撕代码的通过率高了不少。

希望这组题的复盘心得能帮到你。刷题这条路没有捷径,但每次踩坑之后的梳理,都会让你下一题走得更稳。共勉。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询