ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

栈与二分答案实战:从基本计算器到算法模板,吃透LeetCode核心思维

栈与二分答案实战:从基本计算器到算法模板,吃透LeetCode核心思维 我们直接进入正题。这一期总结我打算围绕最近刷题和打周赛时反复遇到的一些典型题目展开重点聊聊三类东西一类是“基本计算器”这种看似简单但坑极多的硬核模拟题一类是“爱吃香蕉的狒狒”这类二分答案的经典套路最后一类是从热门100题和周赛里提炼出来的、平时容易忽略的思维盲区。这些知识点不是孤立存在的它们在面试和实际工程里都反复出现值得花时间吃透。先说清楚这篇文章适合谁。如果你刚开始刷题那可以从第二部分的栈应用和第三部分的二分模板抄起直接背思路、记代码如果你已经刷了不少题那重点看第一部分的知识图谱梳理和第四部分的避坑指南这些是我自己踩过坑之后总结出来的常规题解里基本不会写这么细。1. 内容整体设计与思路拆解1.1 为什么要按“知识点维度”做总结而不是按题目做总结很多人在刷题时有一个习惯把题号记下来一道一道过过完就忘。我最早也是这么干的结果刷了三百多题之后发现遇到变形题还是没思路。后来我换了个方式——按“知识点”而不是“题号”来组织刷题笔记效果立刻不一样了。按题号刷你记得的是“我做过第224题”但你未必记得“当表达式里有括号和加减乘除时可以用两个栈去处理运算符优先级”。按知识点刷你记住的是“栈可以用来处理括号匹配和运算优先级”那么遇到“基本计算器 II”、遇到“逆波兰表达式求值”、甚至遇到“字符串解码”这类题你都能快速定位到同一套底层方法。这一期的知识点总结我选择的就是这种组织方式。从热词里挑几个最高频的题目出来把它们归并到几个大的知识点模块下面——栈与表达式处理、二分答案、贪心思维、边界条件处理——然后每个模块集中讲透。这样一篇总结下来你收获的不是几道题的答案而是一套可以迁移的解题框架。1.2 这一期选了什么题目作为载体我列一下这一期的核心题目清单后面所有内容都围绕它们展开基本计算器LeetCode 224和基本计算器 IILeetCode 227爱吃香蕉的狒狒LeetCode 875LeetCode 热门 100 题中具有代表意义的几道题涉及前缀和、滑动窗口和单调栈最近周赛里出现的一个典型思维误区题这些题目本身不难难的是把它们的共性提炼出来。比如基本计算器系列它们的核心考点根本不是“计算”而是“怎么用栈把人类习惯的中缀表达式处理成计算机能顺序执行的结构”。二分类的题核心考点不是“你会不会写二分”而是“你能不能把题目里那个‘最小速度’、‘最小容量’之类的变量提炼成一个单调函数”。这才是做题的人和一等的做题人之间的差别。2. 核心细节解析与实操要点2.1 基本计算器系列栈的三种用法一次性讲清基本计算器系列可以说是栈这个数据结构最经典的出场场景了。“基本计算器”224题要求你实现一个支持括号、加减法的计算器“基本计算器 II”227题则去掉了括号加入了乘除法。这两道题放在一起刷效果是叠加的——224让你理解括号怎么消解227让你理解优先级怎么处理。先说224题的整体思路。我们维护两个东西一个结果变量一个当前数字前面的符号。遍历字符串遇到数字就累积遇到加号或减号就把之前累积的数字按符号加到结果里然后更新符号遇到左括号就把当前结果和符号压栈进入括号内的独立计算遇到右括号就弹出之前的符号和结果把括号内的结果合并回去。class Solution: def calculate(self, s: str) - int: stack [] num 0 sign 1 result 0 for ch in s: if ch.isdigit(): num num * 10 int(ch) elif ch in -: result sign * num num 0 sign 1 if ch else -1 elif ch (: stack.append(result) stack.append(sign) result 0 sign 1 elif ch ): result sign * num num 0 result * stack.pop() result stack.pop() result sign * num return result这里最关键的细节是当遇到右括号时为什么要先把 num 置零因为括号内部可能以一个数字结尾也可能以一个右括号结尾它的值已经累计到 result 里了。先把当前数字结算掉就不会出现“括号外还残留括号内最后一个数字”的 bug。227题则换了一种处理方式。因为乘除法的优先级高于加减法我们不能简单左右累计。我的做法是用栈保存每个加减法“分段”的值。遇到加减号把当前数字带着符号压栈遇到乘除号从栈顶取出上一个数与当前数字做运算后再压回去。最后把栈里所有数字加起来。class Solution: def calculate(self, s: str) - int: num 0 sign stack [] for i, ch in enumerate(s): if ch.isdigit(): num num * 10 int(ch) if ch in -*/ or i len(s) - 1: if ch ! or i len(s) - 1: if sign : stack.append(num) elif sign -: stack.append(-num) elif sign *: stack.append(stack.pop() * num) elif sign /: stack.append(int(stack.pop() / num)) sign ch num 0 return sum(stack)提示Python 里负数整除和 int() 截断与 Java/C 的向零取整逻辑有差异227题如果用例出现负数除法直接用 int(a / b) 会安全一些因为 int() 是向零取整而 // 是向下取整。2.2 爱吃香蕉的狒狒二分答案的标准模板“爱吃香蕉的狒狒”这道题875题给了我很大的启发不是因为题目本身多难而是它把“二分答案”这个抽象概念讲得无比具体博士每小时最多能吃掉多少根香蕉不知道我们在 [1, max(piles)] 这个区间里猜一个速度看能不能在 h 小时内吃完。这个“猜”的过程就是二分。我们定义一个检查函数输入是速度 k输出是吃完所有香蕉所需的总时间。总时间的计算方式是对每堆香蕉做向上取整的除法。然后二分查找最小的 k让总时间不大于 h。这个套路能套用到很多题上爱吃香蕉的狒狒、分割数组的最大值、制作 m 束花所需的最少天数、每个小孩最多能分到多少糖果。它们的共同特征是——题目问的是某个“最小可行值”或“最大可行值”而这个值对应的可行性能用一个单调函数判断。写二分答案的代码时我有几个习惯这些习惯帮我避免了不少边界错误def minEatingSpeed(piles, h): def feasible(k): hours 0 for p in piles: hours (p k - 1) // k return hours h left, right 1, max(piles) while left right: mid left (right - left) // 2 if feasible(mid): right mid else: left mid 1 return left注意三个细节。第一右边界初始化为 max(piles)因为如果一小时只吃一根而最大堆有 11 根那这个堆就要吃 11 小时这显然不是最优但合法所以我们从 1 猜到最大值就够了不需要猜到一个更大的数。第二向上取整的公式(p k - 1) // k不要写错写成p // k会漏掉余数。第三判断条件用hours h等于的情况是允许的因为题目说“在 h 小时内返回”等于 h 完全没问题。2.3 从热榜题里提炼出的通用解题模板这期我还专门把热门 100 题里几个高频知识点拉出来总结了一下。热榜题不算难但它们是最经典的母题把每个母题背后的模板吃透遇到子题才能从容应对。比如前缀和这个知识点核心模板只有两行代码prefix [0] * (n 1) for i in range(n): prefix[i 1] prefix[i] nums[i]有了前缀和任意子数组 [i, j] 的和可以直接用 prefix[j 1] - prefix[i] 算出来时间复杂度 O(1)。很多看似复杂的题比如“和为 K 的子数组”本质上就是“两数之和”套上一层前缀和的外衣。这种模板的价值在于你不需要每次从零推导直接套用即可。滑动窗口是另一个高频母题。它的核心思想是维护左右两个指针右指针不断右移扩展窗口当窗口不满足约束时左指针右移收缩窗口。这个模板适用于“最长不重复子串”“最小覆盖子串”“长度最小的子数组”这一大批题。有几个经常出错的地方我单独说窗口用什么数据结构记录当前状态、什么时候更新答案、什么时候收缩窗口。这三件事的先后顺序错了整个算法就错了。3. 实操过程与核心环节实现3.1 手把手指南用“基本计算器”训练状态机思维我建议你刷“基本计算器”的时候别急着写代码先在纸上画一个状态机。这个题的状态变量有三个当前符号、当前累积的数字、已经结算的结果。外加一个栈用来保存进入括号“之前”的状态。整个遍历过程你可以想象成一个人在阅读一串数学表达式。遇到数字就继续读遇到运算符就结算一下遇到左括号就说“先暂停把当前结果记在小本本上”遇到右括号就说“小本本拿出来把这段的结果合并进去”。这种理解方式非常贴近实际解析器的工作方式你用这种思路写出来的代码对空格、对连续括号的容忍度都很高。实操时有一个常见问题数字可能有多位。比如 “(1(452)-3)(68)” 里的 45、28 这种数字是连续字符。处理方法是每次遇到数字字符时用num num * 10 int(ch)累积而不是只取当前字符。这个细节很容易在面试时紧张写漏建议背下来。3.2 二分答案完整演练从读题到 AC 的四个步骤我拿“爱吃香蕉的狒狒”完整走一遍自己的解题流程你照着这个流程练习二分答案类题目基本上都能搞定。第一步识别题型。题目问的是“最小速度”而这个速度有一个明确的上界和下界——至少为 1至多为最大堆的香蕉数。再看约束条件能不能在 h 小时内完成是速度的单调函数——速度越快总时间越短。这就是典型的二分答案结构。第二步确定二分的对象和边界。对象是速度 k左边界是 1右边界是 max(piles)。注意有些题目右边界可以直接取一个很大的数比如 10 的 9 次方但你必须判断一下当 k 取这个值时函数是不是一定可行。对这道题来说取 max(piles) 足够因为每堆香蕉最多吃 max(piles) 小时总体一定能在 len(piles) 小时内完成而 h 至少是 len(piles)。第三步写检查函数。检查函数是二分答案的灵魂它决定了你猜的答案“对还是不对”。这里注意向上取整。Python 里向上取整不要用 math.ceil(p / k)因为浮点数有精度问题直接用整数运算就好了。第四步二分收缩。我用左闭右开的模板也就是 while left right 循环可行的时候把右边界缩到 mid不可行的时候把左边界缩到 mid 1。这个模板返回的 left 就是最小可行值。注意别写成left mid或right mid - 1那会导致死循环或错解。注意二分答案题最坑的地方是检查函数写错了还不知道因为样例可能恰好通过。为了让检查函数更可靠我通常在写完 feasible 函数后先手动跑两个样例一个特别大的数一个特别小的数看边界是否合理。3.3 如何在实战中快速分析题目复杂度很多读者问我拿到一道题怎么判断该用二分还是贪心还是动态规划我的经验是看问题的“决策域”也就是你需要尝试的答案的范围是否连续、是否有单调性。二分答案的题决策域通常是一个连续的整数区间而且可行/不可行之间存在一次性翻转——小于某个值全部不可行大于等于某个值全部可行。一旦你发现了这个翻转点这道题就是二分答案。贪心的题决策域上往往存在局部最优策略你不需要回溯只要每一步都选当前看起来最好的最终就是全局最优。比如区间调度问题按结束时间排序、每次选最早结束的就是经典贪心。动态规划的题决策域通常是“前 i 个元素”的某种状态而且状态之间存在递推关系。它能解决的题型更多但复杂度也更高。拿到一道题先问自己三个问题候选答案是连续的还是离散的可行性和答案之间是否是单调关系是否存在局部最优策略三个问题问完90% 的题目的思路方向基本就确定了。4. 常见问题与排查技巧实录4.1 基本计算器最容易踩的五个坑我在刷基本计算器系列的评论区里看到了无数人踩坑我自己也踩过这里盘点一下最典型的五个坑。第一个坑是忽略多位数字。如果输入是 “2147483647”而你只读了一位数字那显然算不对。解决办法是累积读取数字这一点前面已经强调过。第二个坑是括号前没有运算符的情况。比如输入 “(1)” 或者 “-(2)”这时候左括号前可能没有明确的加减号或者是负号被当作括号的一部分了。许多人在这里出错其实 224 题的解法已经兼容了这种情况因为遇到左括号只会压栈不会对括号前的符号做错误处理。第三个坑是空字符串和纯空格。有些用例会故意给你一堆空格比如 “1 1 ”。如果你的代码没有处理空格字符就会把这个字符当作未知字符跳过——但如果你没跳过而是把它当作结束标志可能导致最后的数字没被结算。第四个坑是乘除法中负数的处理。227 题原题保证了除法向零取整也就是用 int()。如果你写熟练了 C 或 Java切到 Python 时容易习惯性写a // b但那么做负数时会出错。这个我前面说过一定要用 int(a / b)。第五个坑是栈的弹出顺序。224 题遇到右括号时你弹出的是“符号”再是“当前结果”。压栈时是先压结果再压符号所以弹栈时顺序是反的。写反了等于符号和结果对调整个括号的计算就错了。4.2 二分答案的边界问题速查表二分答案题目最常见的报错无非三种死循环、答案差 1、超时。我整理了一张速查表你刷这类题的时候可以直接对照自查。症状可能原因解决方法死循环更新左边界时用了left mid且mid等于left改用left mid 1或改成左右闭区间模板答案比预期小 1检查函数用了而不是排除了刚好相等的情况确认题目要求是否允许等于按需调整答案比预期大 1右边界初始值不对或收缩时right mid - 1把可行解排除了用左闭右开模板右边界缩到 mid超时检查函数时间复杂度太高确认检查函数是 O(n) 而不是 O(n log n)答案始终是初始右边界检查函数在任何 mid 下都返回 false检查操作对象可能是右边界取得太小或检查逻辑错误这里面最关键的一个心得是不要随便在二分模板里“混搭”。左闭右闭和左闭右开是两套自洽的模板混着用一定会出问题。我个人的习惯是全部用左闭右开也就是while left rightmid left (right - left) // 2if feasible(mid): right mid else: left mid 1。这个模板简单粗暴适用范围最广。4.3 真实的周赛复盘一道题如何打开思路最近一场周赛里有一道题题目是典型的“范围查询 前缀极值”的套路但很多人一上来就想着用线段树或树状数组结果写得很复杂还超时了。实际上题目限制在 O(n) 范围内因为一次查询可以用一个前缀最大值数组来解决。这个复盘给我的启发是先想最简单的数据结构再想高级的。很多人一看到“区间查询”就条件反射式地想线段树但实际上前缀和、前缀最大值、滑动窗口、差分数组这些线性结构已经能解决相当一部分问题。高级数据结构不是不能用而是只有当数据范围逼着你用时才用。比如 n 到 10 的 5 次方O(n^2) 会超但 O(n log n) 可行这时候才能考虑树状数组如果 n 只有 10 的 3 次方O(n^2) 的暴力法过样例绰绰有余稳妥和简洁才是优先考虑的因素。另一个复盘心得是把题目“翻译”成你自己熟悉的问题。那道周赛题翻译成人话是“在数组里找满足某些条件的连续子段的极值”翻译完立刻就能想到滑动窗口的模板。这种翻译能力是刷题的核心能力因为面试官不会直接告诉你考察什么知识点你需要自己把它识别出来。6. 关于刷题节奏与备战策略的几点个人经验最后聊一个很多人关心但很少被认真对待的问题——怎么安排刷题节奏。我发现“LeetCode热门100题”几乎成了大家默认的入门清单这个清单确实含金量高因为题都很经典覆盖了绝大多数考点。但直接一头扎进去盲刷效果未必好。我的建议是分三轮来刷。第一轮按“算法标签”刷比如集中刷一周的栈和队列题再集中刷一周的二分类题目。这个阶段不追求数量追求把每个知识点的核心模板吃透。第二轮打乱标签刷从热门100题里随机抽题训练自己识别题型的敏锐度。第三轮开始参加周赛用比赛检验自己的临场反应和知识迁移能力。我自己坚持了大概三个月周赛的分数从三题全挂到稳定三题靠的就是这种循序渐进的节奏。另外还有一点就是复习的优先级。很多人刷题只做新题不做旧题导致知识的遗忘率特别高。我对自己的要求是每做完一个新知识点模块就回头把之前的旧题重新口述一遍思路。如果思路能清晰讲出来说明真的理解了如果卡壳了就再看一遍题解记到错题本里。这个方式比每天开新题要累但效果是我刷过的题基本不会再忘面试时遇到变形题也能快速认出底层结构。最后分享一个小技巧我个人在刷“基本计算器”这类带括号的表达式题时有一个习惯——先在纸上写出表达式的后缀形式再对照着写栈的代码。你可能会觉得这不是多此一举吗但实际上把中缀转成后缀的过程本质上就是你对“运算符优先级”和“括号消解”这两个核心机制的理解过程。经历过这个转换的人写起代码来思路会清晰得多遇到边界情况也不容易慌。说到二分答案就一句话——前十分钟别急着写代码先判断可行性是不是单调的。如果可行性和你猜的答案之间存在翻转点那这题基本都是二分答案如果根本没有单调性那二分散了也白散。判断“单调性”的这个习惯是我刷了几十道二分题之后才真正内化下来的今天就提前分享给你。
返回列表