在算法面试中,表达式求值是一个经久不衰的经典问题。LeetCode 224题「基本计算器」要求我们手动实现一个支持加减运算、括号和空格的简易计算器,这不仅是栈数据结构的绝佳应用场景,更是考察开发者逻辑思维与细节处理能力的试金石。本文将带你从零开始,深入剖析这道题的核心思想与实现技巧,无论你是使用TypeScript、Python、JavaScript还是C++,都能从中获得启发。
一、问题拆解:挑战与核心难点
题目要求计算一个仅包含数字、'+'、'-'、'('、')'和空格的字符串表达式。禁止使用eval()等内置函数,意味着我们必须从底层手动解析和计算。这看似简单,实则暗藏玄机。
- 括号的优先级:括号会强制改变运算顺序,必须先计算括号内的子表达式。
- 多位数的解析:字符流中的数字需要正确拼接,如“123”不能拆成1、2、3。
- 符号的处理:正负号不仅影响紧随其后的数字,在括号嵌套时,其影响范围更为复杂。
- 空格的干扰:需要优雅地跳过,不影响核心逻辑。
解决这些难点的关键在于一个数据结构——栈(Stack)。栈的“后进先出”特性,完美契合了括号嵌套时“保存现场,稍后处理”的需求。
二、核心思路:栈的妙用与状态管理
我们采用一次遍历的策略,在遍历过程中动态维护计算状态,并用栈来处理括号带来的上下文切换。整个算法的灵魂在于三个核心变量和一个栈:
- result:记录当前层级(当前括号内)的累计计算结果。
- num:用于临时拼接当前正在读取的多位数字。
- sign:记录下一个待处理数字的符号(+1 或 -1)。
- stack:栈,用于在遇到左括号时,保存括号外的
result和sign。
核心思想:将表达式视为一个由数字和运算符组成的序列,遇到左括号就“入栈”保存当前环境,遇到右括号就“出栈”恢复环境并合并结果。这样,我们就能在单次遍历中,递归地处理任意深度的嵌套括号。
[AFFILIATE_SLOT_1]三、代码实现与初始化
下面我们以TypeScript为例,展示完整的代码框架。这段代码清晰体现了上述思路,在Python、Go、C++或JavaScript中实现时,逻辑完全一致,只是语法略有不同。
function calculate(s: string): number {
const stack: number[] = [];
let num = 0;
let sign = '+'; // 当前数字的符号(+/-)
let result = 0; // 当前层级(括号内/外)的计算结果
for (let i = 0; i < s.length; i++) {
const c = s[i];
// 1. 解析多位数(比如"123" -> 123)
if (c >= '0' && c<= '9') {
num = num * 10 + (c.charCodeAt(0) - '0'.charCodeAt(0));
}
// 2. 处理左括号:保存当前状态(结果、符号)到栈,重置状态计算括号内的值
if (c === '(') {
stack.push(result); // 保存括号外的结果
stack.push(sign === '+' ? 1 : -1); // 保存括号前的符号(用1/-1代替+/-更方便)
// 重置状态,开始计算括号内的子表达式
result = 0;
sign = '+';
}
// 3. 处理运算符(+/-)或右括号:结算当前数字
if ((c === '+' || c === '-') || i === s.length - 1 || c === ')') {
// 根据当前符号,把数字加到结果中
result += sign === '+' ? num : -num;
num = 0; // 重置临时数字
// 更新符号(仅当是+/-时)
if (c === '+' || c === '-') {
sign = c;
}
// 4. 处理右括号:弹出栈中保存的状态,合并结果
if (c === ')') {
const prevSign = stack.pop()!; // 括号前的符号(1/-1)
const prevResult = stack.pop()!; // 括号外的结果
result = prevResult + prevSign * result; // 合并括号内和括号外的结果
}
}
// 空格直接跳过,无需处理
}
return result;
}
代码开头,我们初始化了关键变量。这里有一个精妙的设计:sign初始化为1,代表正号。这是因为表达式开头的数字如果没有显式符号,默认为正数。使用1和-1代替字符'+'和'-',能极大简化后续的乘法运算逻辑。
const stack: number[] = []; // 栈:保存括号外的计算状态(结果+符号)
let num = 0; // 临时变量:拼接多位数(如"123",先算1,再12,最后123)
let sign = '+'; // 符号变量:记录当前数字的符号(默认+,因为第一个数字隐含正号)
let result = 0; // 结果变量:记录当前层级(括号内/外)的计算结果
四、逐字符处理:五大场景的逻辑拆解
接下来,我们进入最核心的循环,逐个字符处理字符串。根据字符类型,可分为五大场景:
1. 数字字符:拼接多位数
当字符是'0'到'9'时,我们将其转换为数值并拼接到num变量上。这是处理多位数的关键。
if (c >= '0' && c <= '9') {
num = num * 10 + (c.charCodeAt(0) - '0'.charCodeAt(0));
}
⚠️ 注意:这里利用ASCII码差值进行转换,是高效且通用的做法。在Python中可以直接用int(c),在JavaScript中可以用parseInt(c)。
2. 左括号:保存上下文,进入新环境
遇到'('意味着要开始计算一个新的子表达式。此时,必须将当前的result和sign保存起来(压栈),然后重置它们,为计算括号内的内容做准备。
if (c === '(') {
stack.push(result); // 保存括号外的结果
stack.push(sign === '+' ? 1 : -1); // 保存括号前的符号(用1/-1代替+/-)
// 重置状态,开始计算括号内的子表达式
result = 0;
sign = '+';
}
✅ 为什么先压入result再压入sign? 这是为了出栈时顺序正确。栈是后进先出,所以恢复时先弹出的是sign,再是result。
3. 运算符与右括号:结算数字,更新状态
这是最核心也最容易出错的逻辑块。当遇到'+'、'-'、')'或字符串末尾时,意味着一个完整的数字num已经解析完毕,需要根据当前的sign将其结算到result中。
if ((c === '+' || c === '-') || i === s.length - 1 || c === ')') {
// 步骤1:根据当前符号,把数字加到结果中
result += sign === '+' ? num : -num;
num = 0; // 重置临时数字,准备解析下一个数
// 步骤2:更新符号(仅当当前字符是+/-时)
if (c === '+' || c === '-') {
sign = c;
}
// 步骤3:处理右括号,合并括号内外结果
if (c === ')') {
const prevSign = stack.pop()!; // 弹出括号前的符号(1/-1)
const prevResult = stack.pop()!; // 弹出括号外的结果
result = prevResult + prevSign * result; // 合并结果
}
}
逻辑详解:
1. 结算数字:result += sign * num。这是算法的核心计算步骤,将当前数字连同其符号累加到结果中。
2. 重置数字:将num归零,准备读取下一个数字。
3. 处理右括号:如果当前字符是')',则需要结束当前括号内的计算。从栈中弹出括号前的符号和结果,合并计算:result = stack.pop() + stack.pop() * result。
4. 更新符号:如果当前字符是'+'或'-',则更新sign为对应的1或-1,用于下一个数字。
五、复杂度分析与优化思考
该算法的时间复杂度为O(n),其中n是字符串长度,因为我们只进行了一次线性扫描。空间复杂度在最坏情况下(如全是嵌套括号)为O(n),用于栈的存储,这已是此类问题的最优解。
- 优化点:可以预先用
s.replace(/\s/g, '')(在JS/TS中)移除所有空格,避免循环中的空字符判断,但会引入一次额外遍历。 - 扩展思考:此解法是解决LeetCode 227题(支持乘除)的基础。对于乘除,需要引入更复杂的“当前操作数”概念,并注意乘除比加减有更高的优先级。
六、总结与举一反三
LeetCode 224「基本计算器」是一道锻炼栈应用的典范题目。通过本题,我们掌握了:
- 栈的核心作用:保存和恢复上下文,完美处理嵌套结构(括号)。
- 状态机思想:用有限变量(result, num, sign)维护当前计算状态,流式处理输入。
- 编码技巧:用±1代替符号字符、ASCII码转换、循环内结算数字等。
无论你主要使用哪种编程语言,理解这个算法的本质都比记忆代码更重要。下次遇到表达式求值、括号匹配、甚至解析类问题,不妨先想想:栈能不能帮我保存“之前的世界”? 这或许就是你打开解题之门的钥匙。
浙公网安备 33010602011771号