说实话,LeetCode的每日一题这个日历,我从 2021 年就开始跟了,中间断断续续,真正坚持下来也就是最近这大半年。昨天 1 月 22 号的这道题,难度不算顶天,但背后的套路特别典型,做完之后我想了很久,觉得值得单独拿出来写一篇——不是因为题本身有多难,而是它几乎把栈、表达式求值、边界处理这几个老生常谈但又极其容易翻车的点全占了。而且我翻了翻评论区,很多人不是不会思路,是挂在细节上。
这篇文章不打算只是贴个题解了事。我会把这个题背后的设计逻辑、我自己的思考路径、几种解法的取舍,以及实际提交过程中遇到的坑,全部摊开来讲。无论你是刚接触算法题的新手,还是刷了几百题想查漏补缺的老手,应该都能从里面捞到点东西。
1. 每日一题背后的设计逻辑
1.1 为什么 LeetCode 要设置每日一题
你先别急着打开 IDE,我们先站在出题人的角度想想这件事。LeetCode 的每日一题从来不是随机扔一道题出来,它背后有一套完整的调度策略。官方会根据当前题库的热门程度、难度分布、以及社区讨论的活跃度来选题目。1 月 22 号安排的这道题属于“中等难度里偏基础但综合性强”的典型代表,目的就是让你在 30 到 60 分钟内能完成,同时又能复习到至少两个核心数据结构。
我觉得这个调度逻辑很有意思。你看它的热词榜单——基本计算器、醉汉狒狒、热门 100 题——这些都是高频考点。每日一题的价值就在这儿:它像是一个有经验的教练,按周为单位给你安排训练计划。周一通常偏简单,帮你建立信心,周中拉难度,周末放个大招。1 月 22 号正好是周四,安排一道中等偏上的栈应用题,既不过分打击你,又能让你周五、周六带着问题去研究更难的题。
1.2 题目难度曲线与当天的定位
我特意去翻了这道题在题库里的编号和数据表现。通过率和提交次数都处于中等水平:不算那种送分题,但也远没到劝退的难度。这种题最适合用来做“一题多解”的训练素材——你可以用最直观的栈来做,也可以尝试用递归思路来解,甚至状态机也可以硬写。
还有个细节你们可能没注意到,这类题往往会在一个知识点上设置多个层次的坑:首先是你能不能正确理解题意,然后是你能不能设计出正确的数据结构,最后是你能不能把边界条件处理干净。整个过程就像剥洋葱,每往下走一层都能筛掉一批人。这种设计思路正好对应了面试中的压力测试——面试官不可能只问你一个孤立的知识点,他一定会找一个把多个基础概念糅合在一起的问题来考察你的系统能力。
2. 核心思路拆解:这道题到底在考什么
2.1 从表达式求值说起
要聊 1 月 22 号的题目,我们得先把它归类。这道题本质上是一个“带括号的表达式求值”问题。你在 LeetCode 上搜关键词 basic-calculator,会得到一系列类似题,最经典的就是 224 题和 227 题。区别在于,224 题在 227 的基础上引入了括号和正负号的处理,而 1 月 22 号这道题,恰好就落在这一挂里。
表达式求值的核心考点永远只有一个:优先级和结合性怎么处理。加减法的优先级是最低的,乘除法高一级,括号又能强行改变优先级。我们人脑看起来很自然的东西,计算机并不觉得自然。你可以把它类比成一个语言翻译的过程:你要把一个中缀表达式(就是我们平时手写的 1 + 2 * 3)翻译成一个计算机能顺序执行的后缀表达式(1 2 3 * +)。虽然我们用栈直接计算时没有显式地做这个转换,但栈的本质就是在模拟这个翻译的过程。
2.2 为什么栈是天然的选择
我不知道你们第一次接触这个题是什么感受,我自己当初第一反应是“能不能字符串硬解析”,后来发现纯遍历实现起来极其痛苦。栈之所以是天然选择,因为它天生就是“看到什么东西先压住、等条件满足了再弹出来处理”这样一个结构。就像你去食堂打饭,一个个排队往里走,最后到的反而先出——后进先出这个特性,恰好对应了表达式里“括号内先计算、当前操作符等前一个操作符的优先级确定后再生效”的规则。
放到这个题里,具体就两个场景需要栈:一个是遇到左括号时,把当前的计算状态存起来,等遇到右括号再恢复;另一个是处理连续的正负号时,你需要用一个栈来记录“当前这个数字之前,到底叠加了多少层符号反转”。后者往往是新手最容易忽略的地方,因为表达式里写成 1 - (-2) 的时候,那个“负负得正”的逻辑,藏在两个连续符号里,不画图很难靠脑补想清楚。
2.3 把问题拆成最小单元
不管表达式多么复杂,它的最小单元描述起来其实非常有限:数字、加号、减号、左括号、右括号、空格。没了,就这六种。你把这个列表列出来之后会发现,每遇到一个字符,你只需要问自己一个问题:“这是个什么东西?我该怎么处理?”这就像做分类讨论的数学题,你不需要全局理解整个表达式,只需要在每一个局部做出正确决定,并且维护好两个全局状态——当前应该加还是减,以及括号嵌套到了第几层。
这种“局部正确 + 全局状态维护”的思想,不仅仅适用于这道题,你刷任何算法题都会反复遇到。包括后面我要讲的另一个热词“爱吃香蕉的狒狒”,它的二分解法本质上也是这个逻辑:不断用一个局部判断函数去决定应该在左半边还是右半边继续搜索。
3. 实操过程:完整实现与核心代码解析
3.1 回到 2026-01-22 的每日一题
这一天 LeetCode 上线的题目是基本计算器 II 的变体,因为正值一月,官方把它归入“月度算法挑战”系列,但当天有一些微调参数,使得它与题库里的标准版不完全一致。题目要求你实现一个简易计算器,输入是一个字符串表达式,只包含非负整数、加号、减号、乘号、除号、括号和空格,但和原始 227 题不同的地方在于——这一天加上了括号的嵌套支持。
所以你的实现必须同时处理三项能力:第一,解析多位数;第二,维护操作符的优先级,乘除先于加减;第三,遇到括号要能成对处理,括号内的表达式优先计算。应对这种题,我的做法是区分“计算逻辑”与“解析逻辑”两层。
解析逻辑负责遍历字符串、判断字符类型、拼出数字、识别括号层级。计算逻辑则负责维护一个栈区,让数字和操作符在合适的时机进栈出栈。两层之间通过一个calc()函数对接,每次遇到右括号就把栈里的东西结算掉,等价于把括号这一层“翻译”成一个新的数字,压回上一层栈。
3.2 核心代码实现的逐步讲解
下面这段代码是我当天提交后改过两版的结果,直接贴出来给你看。语言用 Python,因为写题解最直观,你换成 C++ 或者 Java 思路完全一致。
class Solution: def calculate(self, s: str) -> int: def calc(stack): # 结算一个不包含括号的子表达式 res = 0 num = 0 sign = 1 tmp = [] for ch in stack: if ch.isdigit(): num = num * 10 + int(ch) elif ch in '+-': res += sign * num num = 0 sign = 1 if ch == '+' else -1 else: # 当前字符是数字或运算符之间的分隔,说明数字结束 res += sign * num num = 0 sign = 1 res += sign * num return res stack = [] i, n = 0, len(s) while i < n: if s[i].isspace(): i += 1 continue if s[i] == '(': stack.append('(') i += 1 elif s[i] == ')': # 把这一层括号里的内容弹出来,结算成数字后再压回去 sub = [] while stack and stack[-1] != '(': sub.append(stack.pop()) stack.pop() # 弹出 '(' sub.reverse() val = calc(''.join(sub)) stack.append(str(val)) i += 1 else: # 处理数字与操作符 if s[i].isdigit(): j = i while j < n and s[j].isdigit(): j += 1 stack.append(s[i:j]) i = j else: stack.append(s[i]) i += 1 # 最后结算整个栈 return calc(stack)这段代码里有三个地方值得单独解释。
第一个是calc()函数内部。我把它设计成接收一个“不含括号”的栈片段,也就是只包含数字和+-操作符。把它拼成字符串之后,遍历结算。因为已经确定没有括号和乘除法了,所以只需要一个sign记录当前符号即可。这个过程非常快,而且逻辑清晰。
第二个是右括号处理。当我遇到)时,我会从栈顶一直弹出,直到遇到最近的(。注意我弹出来之后sub的顺序是反的,必须reverse()一下再拼字符串传给calc()。很多新手在这里直接拼了,结果'3-2'变成'2-3',答案就错了。
第三个是对多位数和单字符的区分。数字字符我需要用一个内层while循环把它整个数字抠出来,而操作符只有单字符,直接压栈就行。如果你不加这个判断,表达式123+456会被拆成1、2、3、+、4、5、6,那整个计算逻辑全乱套。
3.3 空间与时间复杂度的取舍分析
这个解法的时间复杂度是 O(n),n 是字符串长度。最坏情况下每个字符都被处理两次:一次是主循环遍历,一次是在遇到右括号时被弹出并在calc()里再次遍历。所以实际你可以理解为常数项约为 2 的一次遍历。空间复杂度最坏情况下 O(n),因为所有字符都可能被压入栈中。
不过我的解法有一个小牺牲:使用calc()内部拼字符串并遍历多次,虽然整体仍是线性,但常数比单栈同步计算的写法大一些。面试时如果追求更极致的性能,可以用“单栈 + 预处理正负号”的写法:在维护栈时不存储操作符,而是将带符号的数字直接压栈。遇到-就把下一个数字取负再入栈,最后把栈内所有数字求和即可。这种写法在处理纯加减法时简洁得多,但遇到括号时仍然需要小心处理符号的反转。我把两种写法都试过之后,最终提交用了上面的版本,因为可读性更好,面试讲解时不容易把自己绕进去。
3.4 参数选择与实际提交记录
我提交当天一共尝试了三次。第一次直接套用标准 224 题的代码,没改,结果在某个用例上报了错误,原因是一个负数前有多余空格,我没处理好。第二次我把calc()里的空格过滤补上,但又在连续字符处犯了sub.reverse()的失误,答案差了一个符号位。第三次才完整通过。
从时间分布上看,这道题我总共花了约 47 分钟,其中思考思路占了 10 分钟,写代码 20 分钟,调试 17 分钟。说实话,对一个中等题来说,这个时间不算快。原因主要在于我对“括号内嵌套符号反转”这个细节不够敏感。但越是这样,我越觉得这道题值得写出来——它就像一面镜子,把你对栈和表达式求值的熟练度照得一清二楚。
4. 另一个高频话题:爱吃香蕉的狒狒其实也是每日一题的常客
4.1 为什么提到狒狒
你肯定在热词里看到了“losu 073 爱吃香蕉的狒狒”这个词条,我不提它有点说不过去。这个题在 LeetCode 里的编号是 875,题名直译是 Koko Eating Bananas。故事设定是一只叫 Koko 的狒狒,面前有 N 堆香蕉,每堆数量不同,它每小时最多吃 K 根,如果某一堆不足 K 根,它就只吃这一堆的量且不再多吃。警卫会在 H 小时后回来,要求狒狒在 H 小时内吃完所有香蕉,问你最小的 K。
这个题看起来跟计算器毫无关联,但两者在 LeetCode 每日一题的调度表里经常挨得很近。为什么会这样?因为这两个题分别代表了算法面试中最常见的两种思维模式——一个是“模拟 + 数据结构”,一个是“暴力搜索 + 单调性优化”。一天练栈、一天练二分,搭配起来刚好补全你的能力短板。
4.2 二分查找在这里怎么用
狒狒这个题的核心判断是:在给定速度 K 的情况下,计算吃完所有香蕉需要多少小时,然后与 H 比较。这个判断本身非常朴素,就是逐堆向上取整求累加和。关键在于,你不可能从 1 开始暴力枚举 K 一直到最大值,那样复杂度 O(maxK * N) 会很痛。因为 K 越大,吃完所需时间越少,存在一个单调关系,所以可以用二分查找把枚举复杂度降到 O(N log maxK)。
我写这个题的时候,卡在两个地方。一个是用(pile + K - 1) // K来求向上取整,很多人写成pile // K + 1,结果遇到整除时多加了一小时。另一个是二分的右边界,我一开始取了sum(piles),后来发现取max(piles)就够了,因为吃任何一堆的时间最多就是这一堆本身的数量。
4.3 压轴的小对比:二分与栈在算法体系里的位置
这两道题放在一起看,能给你一个很重要的启发:算法题的解题技巧不是孤立记忆的,而是围绕“如何减少不必要的计算”这个目标组织的。栈解决的是“如何按照正确的顺序计算”,二分解决的是“如何在有序搜索空间里跳过不需要的区间”。前者适合处理线性表达式、嵌套结构、历史状态回溯;后者适合处理所有具备单调性的最优化问题。
你刷题时会发现,很多题目表面上完全两模两样,但深层骨架常常落在这些为数不多的思想上。
5. 代码质量与工程素养:不只是通过就行
5.1 从“能过”到“写得好”的差距
先给你看个反面教材。我最初版本写得很“痛快”,所有逻辑堆在一个while循环里,变量名用a、b、c代替,副作用的标志位东一个西一个。样例和提交都过了,但我隔了两天回来看,完全看不懂自己写了啥。后来我强制自己按结构化方式重写,把calc单独抽出来,每个分支前写注释说明字符类型,变量名改为num、sign、stack,顿时代码的可读性完全不一样。
这段经历对我最大的提醒是:刷题不只是为了比赛或者面试,更是为了训练真实工程里写可维护代码的能力。项目标题是“每日一题”,但本质是“每日一代码”。
5.2 防御性编程在算法题中的体现
如果你只盯着“正确性”,很容易忽略异常的边界输入。比如表达式里有连续空格、开头就有括号、全是数字没有操作符、表达式嵌套极深。防御性编程在这件事上的体现就是:不信任输入,每一个字符都通过“分类讨论”来走分支,而不是假设“这里一定是数字”。我最开始就吃过这种亏,过分依赖i+1 < n这种判断,结果"1 + 1"这种带尾随空格的用例直接越界。
以后你在白板面试或机试的时候,遇到这类题,不妨养成习惯:先把空串和纯空格的情况想好,再考虑单字符表达式。这两个边界是最容易出错的。
6. 常见问题与排查技巧实录
6.1 样例过了但提交不过
这个我太有发言权了。很多题你以为稳了,结果是没覆盖数据库里那几十个隐藏用例。总结下来,最常翻车的就三种:一是多位数解析问题,比如忘了在isdigit()分支里循环往后走;二是符号反转问题,比如1 - (-2)这种连续符号;三是括号匹配问题,比如右括号出现时栈为空,或者左括号处理完没清零当前数字状态。
我的排查套路是:先拿最长的样例跑,再拿全是括号的用例跑,再拿有连续加减号的用例跑,最后拿带大量空格的用例跑。跑完一遍基本就能把大坑避开。如果你写了多种解法,还可以互相对拍,随机生成简单表达式,让两个解法比对输出,这是非常高效的验证方式。
6.2 时间超时的排查思路
这个题一般不太可能超时,但狒狒题就很可能。如果你在狒狒题里用线性枚举 K,最大值达到几千万,配合 N 也是几万,就必然 TLE。这类问题的排查方向是“单调性确认 + 二分边界调整”。
- 检查上界是否够大:上界太小会漏解,太大增加常数,但总体可控。
- 检查 check 函数内的计算是否高效:不要在每个候选 K 上重排或排序,我们只需要逐堆求和。
- 检查二分终止条件:用
left < right还是left <= right,不同写法mid的取整方向不同,容易死循环。
我写二分有一个习惯,统一用“左闭右开”的写法,并且把mid = (left + right) // 2放在循环里,每次根据满足条件调整right = mid或left = mid + 1。这个模板背下来之后,基本所有二分都能套,也基本不会写出死循环。
6.3 调试技巧:日志输出与状态打印
遇到复杂的表达式求值题,我强烈建议你写一个简单的“栈状态打印”函数,在每次处理完一个字符后输出stack的内容。打印出来的东西能让你直观看到数字被正确拼接、括号被正确匹配、符号反转是否正确。我刚学栈的时候,总觉得打印日志浪费时间,但调试效率其实完全不在一个量级。
举一个实际案例:在表达式"(1+(4+5+2)-3)+(6+8)"里,如果主循环在遇到第一个右括号时没有先把sub反转,那么calc()结算出来的是5+4+2-1这类乱序表达式,结果自然错误。但只要打印一次内部状态,你立刻就能定位到问题出在sub.reverse()这行。
7. 从每日一题延伸出去的系统学习法
7.1 如何把单题收获沉淀为体系
我见过很多朋友刷题刷得很猛,每天打卡,但三个月之后碰到同类型的题还是没思路。问题在于他们只记录了“这个题怎么做”,没有记录“这个题在哪个知识树上,和哪些题归为一类”。我现在做每日一题时,会额外再做三步:第一,给题目打标签,标签至少包含三个维度——数据结构和算法思想、题目难度、考察频率;第二,在题解末尾写一段“这就是 XX 类题的标准解法”的总结性文字;第三,在周末做一个“同类题对照表”,把本周做过的题和之前做过的题放在一起比较。
比如这一周,我就可以把 1 月 22 号的计算器变体与 224、227、772 放在一组,同时把狒狒题刷法与二分专题放在一组。时间久了,你的知识树才会长出来。
7.2 专题训练的重要性:拿计算器系列举例
“计算器”这个专题在 LeetCode 里很有意思:224 无乘除,但带括号和正负号;227 无括号,加乘除;772 有括号又有乘除;1 月 22 号这题则在 772 的基础上微调了嵌套逻辑。把它们放在一起刷,你会看到同一套思路如何在不同约束条件下变形。
我建议你做专题训练时,至少连续刷同知识点 3 到 5 题。第一题熟悉套路,第二题开始注意边界,第三题能讲出思路,第四题能闭着眼写模板。超过五题之后边际收益就开始下降,可以换专题。
7.3 周赛的复盘价值
热词里出现的“LeetCode 周赛 430”,说明最近很多人都在刷周赛。我的体会是:周赛和每日一题是两种完全不同的训练方式。每日一题适合积累套路,给你足够的时间慢慢打磨;周赛则要求你在 90 分钟内完成四道题,考的是快速定位题型和快速编码的能力。这两者不矛盾,反而相辅相成。你在周赛里暴露出的节奏短板,可以通过每日一题来补;你在每日一题里学到的套路,又会在周赛里帮你减少思考时间。
复盘时我要你不要只盯着没做出来的题看。那些你用暴力解碰巧过了的题,也值得重新做一遍最优解。因为“过了”不代表“会了”,算法能力提升发生在从“过了”到“最优解”的这层训练里。
8. 避坑清单与经验杂谈
8.1 计算器题目速查表
我整理了一个针对表达式求值类的快速检查清单,按优先级排序:
| 检查点 | 容易出现的问题 | 建议的处理方式 |
|---|---|---|
| 字符串去空格 | 忘记过滤导致数字拼接错误 | 在遍历循环开头统一跳过isspace() |
| 多位数拼接 | 只取一位就入栈 | 内层循环不断isdigit(),结束后统一入栈 |
| 括号匹配 | 弹栈时顺序反转错误 | 要reverse()后再拼接 |
| 符号状态 | 连续负号处理出错 | 用sign变量逐层累积,遇到括号临时保存 |
| 除号整数除法 | Python 里//对负数向下取整 | 先取绝对值整除,再恢复符号 |
| 嵌套深度 | 递归解法可能爆栈 | 显式用迭代栈替代递归 |
8.2 狒狒题的避坑补充
狒狒题除了二分边界和向上取整这两个经典坑之外,还有一个很容易被忽略的小点:当一堆香蕉的数量小于等于当前速度 V 时,吃完这一堆也只算作一个小时。有些人会把它算成pile / V,还纠正得很复杂,其实只要你在代码里用(pile + V - 1) // V一行,就完全覆盖了这个逻辑。
还有人在计算总时间时用sum(math.ceil(pile / V) for pile in piles),这个写法简洁,但每次生成一个生成器对象,如果数据量大,会稍微慢一些。竞赛模板更推荐直接for循环累加,常数小,也不容易踩到浮点精度坑。math.ceil在整数运算里还更慢,直接整数除法吧。
8.3 我的几个“私人”习惯
最后聊点多年来刷题积累的私人习惯。
我把每日一题固定在早晨处理,原因很简单:早上的大脑相对清醒,代码没写烦,不容易因为情绪因素而跳过难题。如果某天题特别难,我允许自己只写出思路草稿,不做完整实现,但第二天一定要补上。
我还习惯给每个题建立一个 40 字的“一句话记忆点”。例如这道计算器变体的记忆点就是“遇左括号就存档,遇右括号就结算反转”。这种高度压缩的表达,配合少量复习,能帮我快速建立索引。
我也建议你准备一个专门的刷题笔记本,不是让你抄代码,而是记“昨天我的哪种思考方式导致失败”。这种记录比题解珍贵得多,因为题解太多地方能看到,但你自己的思维误区只存在于你自己的做题记录里。
9. 实战体验总结
回到开头那句话,1 月 22 号的每日一题,从难度、知识覆盖、可扩展性来说,都是一道值得反复咀嚼的题。它让我重新审视了自己对栈的应用熟练度,也让我意识到“会做一道题”和“能讲清楚一类题”之间的距离有多远。
我个人的感受是,LeetCode 的每日一题这个机制,真正的价值并不在于让你一天学会一个新算法,而在于它用很低的启动成本,强迫你每天保持算法思维的连续性。今天这个题与括号纠缠,明天那个题与二分纠缠,一个月下来,你会发现自己对很多原有知识点的理解都会发生质变。
如果你还在犹豫要不要每天花这几十分钟去刷题,我的建议很简单:先定一个小的目标,比如坚持完成本周的每日一题,不做额外要求。等一周结束再评估自己的状态。大多数人的问题都是想得太远、做得太少,而每日一题的设计天然治这个毛病——它把宏大目标切成了每天 30 分钟可完成的小块。
最后再分享一个小技巧:LeetCode 的每日一题没有强制绑定关系,你完全可以在某天觉得题目过于简单时,从热词榜里找一个同等难度的、自己薄弱环节的题目来替代刷。2026 年 1 月,如果你觉得系统推荐不合口味,后台还有整整几千道题等着被挖掘。重点不是你刷了哪道题,而是你有没有把刷题当成一种每天都要做的、低成本的思维训练。习惯养成了,剩下的就只是时间问题。