题目
给你一个字符串表达式 s ,请你实现一个基本计算器来计算并返回它的值,整数除法仅保留整数部分。
示例 1:
输入: s = "3+2*2"
输出: 7
示例 2:
输入: s = " 3/2 "
输出: 1
示例 3:
输入: s = " 3+5 / 2 "
输出: 5
提示:
- 1 <= s.length <= 3 *
- s 由整数和算符 ('+', '-', '*', '/') 组成,中间由一些空格隔开
- s 表示一个 有效表达式
- 表达式中的所有整数都是非负整数,且在范围 [0, - 1] 内
- 题目数据保证答案是一个 32-bit 整数
数据结构课题中有介绍通过堆栈的方式来实现一个简单计算器,其原理是遍历字符串表达式s,分别将数字和运算符放到不同的堆栈,例如将数值、运算符分别放到numStack、calcStack两个堆栈列表中,但在存放运算符时,需要判断当前运算符sign和堆栈中最新的运算符preSign的优先级,如果sign优先级低于或等于preSign,需要先将preSign从推展中取出并执行运算,重复以上操作,直到sign优先级高于preSign的优先级,才可将sign存放到堆栈中。
以s = 3 + 5 / 2为例,对其遍历,假设[*,/]优先级为5, [+, -]优先级为3,当前位置的符号为target:
- target为3,放入numStack,s变为
+ 5 / 2, numStack为[3], calcStack为[]; - target为
+, 放入calcStack,s变为5 / 2, numStack为[3], calcStck为[+]; - target为5, 放入numStack,s变为
/ 2, numStack为[3, 5], calcStack为[+]; - target为
/,优先级大于+,放入calcStack, s变为2, numStack为[3, 5], calcSack为[+,/]; - target为2, 放入numStack,s变为空字符串,numStack为[3, 5, 2],calcStack为[
+,/]; - 循环提取calcStack,运算符号记为tag;
- 从numStack提取最新两个数right、left;
- 执行运算left tag right, 将结果存入numStack;
- 执行步骤6
经过以上的步骤最终numStack只会存储一个值,其结果即为运算表达式s的运算结果。
基本版
/**
* 基本计算器
* 使用数据结构中的堆栈,分别存储数值和计算符号, 需要考虑计算符号的优先级
* @param {*} s s 由整数和算符 ('+', '-', '*', '/') 组成,中间由一些空格隔开
*/
function calculate(s) {
const calcStack = [], numStack = []
// 运算符优先级配置
const weights = {
'*': 5,
'/': 5,
'+': 3,
'-': 3
}
// 错误处理函数
function error() {
throw new Error(`传入的计算表达式${s}有误.`)
}
// 执行计算,从calcStack取出运算符tag,从numStack取出两个数值right、left
function executeCalc() {
// numStack存在最上面的是右运算数
const right =numStack.pop(),
tag = calcStack.pop(),
left = numStack.pop()
if (isNaN(left) || isNaN(right)) {
error()
}
const result = Math.floor(eval(`(${left})${tag}(${right})`))
return result
}
let subStr = s.trim()
while (subStr) {
let match = subStr.match(/^\d+/g)
if (match && match.length) { // 如果为数值,将其存储到numStack
const num = Number(match[0])
numStack.push(num)
subStr = subStr.replace(num + '', '').trim()
} else if ((match = subStr.match(/^[\/*+-]/g)) && match.length) { // 如果为运算符
const tag = match[0]
// 如果堆栈中的最新符号优先级大于等于当前运算符tag,立即执行计算
while (calcStack.length && weights[calcStack[calcStack.length - 1]] >= weights[tag]) {
numStack.push(executeCalc())
}
// 将当前运算符存储到calcStack
calcStack.push(tag)
subStr = subStr.replace(tag, '').trim()
}
}
// 遍历运算符堆栈,并执行计算,对应步骤6-9
while (calcStack.length) {
numStack.push(executeCalc())
}
// 容错判断,最后的numStack只能有几个结果
if (numStack.length !== 1) {
error()
}
return numStack[0]
};
以上的代码的执行过程和上面列出的步骤一致,需要说明的是,当遍历完表达式字符串,calcStack中存储的运算符肯定是最新放入的优先级大于堆栈中已有的运算符,相当于从右向左优先级递减,所以直接用pop取最新的计算结果是正确的。
进阶版
假如现在题目要求变为: s 由数字、'+'、'-'、'('、')'、和 ' ' 组成
示例 4:
输入: s = "(1+(4+5+2)-3)+(6+8)"
输出: 23
示例 5:
输入: - (3 + (4 + 5))
输出: -12
对比题目新的要求和老版本之间的区别:
- 首个符号不一定是数值,可能直接是一个运算符,例如
示例5 - 表达式中多了左右括号
(、),需要考虑处理逻辑
对于第一个问题,考虑在每次遍历的时候对表达式字符串先做预处理,例如将- (3 + 2)处理为0 - (3 + 2),这样就可以当着完整表达式了,但需要主要的是,因为我们会将已处理的符号从表达式中移除,如果当前是一个符号,那要识别出是否要做预处理。
可以考虑定义一个变量numOrTag来记录当前遍历应该处理数字还是运算符,0表示数值处理,1表示符号处理。
let subStr = s.trim(), numOrTag = 0 // 0表示进行数值判断,1表示进行符号判断
在遍历时先做预处理,如果当前遍历需要处理数值,并且表达式以-开头,那么在字符串头部附加数值0。
if (!numOrTag && subStr.indexOf('-') === 0) {
subStr = '0' + subStr
}
接着考虑第二个问题,当遇到左括号(,此时左括号右部分的计算优先级肯定高于左边,例如 3 * (2 + 5),左括号右边的+运算优先级肯定高于*,所有可以将(当着优先最高的符号,直接存到calcStack堆栈中。添加如下代码:
} else if ((match = subStr.match(/^\(/g)) && match.length) {
calcStack.push(match[0])
}
当遇到右括号),那么需要将堆栈中左括号(之后的运算符立即运算出结果,并且取出堆栈中的左括号。
} else if ((match = subStr.match(/^\)/g))) {
// 当遇到右括号,从堆栈拿值计算,直到遇到左括号,并且将左括号从堆栈中移除
executeCalcWithEndTag('(')
calcStack.pop()
}
其中executeCalcWithEndTag函数会循环遍历calcStack执行计算,直到遇到左括号(。
function executeCalcWithEndTag(endTag) {
while (calcStack.length && calcStack[calcStack.length - 1] !== endTag) {
executeCalc()
}
}
完整的代码如下:
/**
* 基本计算器, 支持括号表达式 '('、')'
* 使用数据结构中的堆栈,分别存储数值和计算符号, 需要考虑计算符号的优先级
* @param {*} s s 由整数和算符 ('+', '-', '*', '/') 组成,中间由一些空格隔开
*/
function calculate(s) {
const calcStack = [], numStack = []
const config = {
weights: {
'*': 5,
'/': 5,
'+': 3,
'-': 3,
'(': 1 // 其他运算符优先级都高于'(', 保证左括号之后的计算优先执行
},
func: {
'*': function multi (left, right) {
return left * right
},
'/': function div (left, right) {
return left / right
},
'+': function add (left, right) {
return left + right
},
'-': function reduce (left, right) {
return left - right
}
}
}
function error() {
throw new Error(`传入的计算表达式${s}有误.`)
}
function executeCalc() {
// numStack存在最上面的是右运算数
const numLen = numStack.length, calcLen = calcStack.length,
right = numStack.pop(),
tag = calcStack.pop(),
left = numStack.pop()
if (!calcLen || numLen < 2) {
return
}
if (isNaN(left) || isNaN(right) || !config.func[tag]) {
error()
}
const result = Math.floor(config.func[tag](left, right))
numStack.push(result)
}
function executeCalcWithEndTag(endTag) {
while (calcStack.length && calcStack[calcStack.length - 1] !== endTag) {
executeCalc()
}
}
let subStr = s.trim(), numOrTag = 0 // 0表示进行数值判断,1表示进行符号判断
while (subStr) {
if (!numOrTag && subStr.indexOf('-') === 0) {
subStr = '0' + subStr
}
let match = subStr.match(/^-?\d+/g)
if (!numOrTag && match && match.length) {
numStack.push(Number(match[0]))
numOrTag = 1
} else if ((match = subStr.match(/^\(/g)) && match.length) {
calcStack.push(match[0])
} else if ((match = subStr.match(/^\)/g))) {
// 当遇到右括号,从堆栈拿值计算,直到遇到左括号,并且将左括号从堆栈中移除
executeCalcWithEndTag('(')
calcStack.pop()
} else if (numOrTag && (match = subStr.match(/^[\/*+-]/g)) && match.length) {
const tag = match[0]
if (calcStack.length && config.weights[calcStack[calcStack.length - 1]] >= config.weights[tag]) {
executeCalc()
}
calcStack.push(tag)
numOrTag = 0
}
if (match && match.length) {
subStr = subStr.replace(match[0], '').trim()
}
}
while (calcStack.length) {
executeCalc()
}
if (numStack.length !== 1) {
error()
}
return numStack[0]
};
扩展版
假如现在要支持%运算符, 例如计算1 * 2 + 3 * (5 % 2)的结果,那我们只能重新调整原代码,在while遍历中增加%判断,并修改config下的weights、func配置。那能不能在不修改源代码的前提下,支持新的运算符计算?例如定义如下签名的calculate函数,通过options选项来支持扩展。
calculate(s: string, options?: CalcOptions): number
要实现可扩展的计算器,我们先分析代码的执行过程,主要流程如下:
- 配置优先级、运算函数, 定义weights、funcs配置。
- 遍历前处理,可定义为beforeTraverse事件
- 开始遍历
- 符号预处理,可定义为beforeSignHandle事件
- 获取符号信息, 可定义getSign函数获取,返回Num、Operation、LeftBracket等符号标示
- 处理符号,根据获取的符号(如Num、Operation)做相应的处理,可定义为NumHandler、OperationHandler等等
- 符号后置处理,可定义afterSignHandle事件
- 结束遍历
- 遍历后处理,可定义afterTraverse事件
可通过如下的流程图表示:
把环节划分清楚之后,可把每个节点定义为可扩展的hook,支持通过options参数来扩展计算器。CalcOptions的定义如下:
type CalcOptions = {
weights?: { [key: string]: number }
funcs?: { [key: string]: (left: any, right: any) => number },
// singKeys?: { [key: string]: string | number },
genSign?: (params: { str: string, preSign: string, error?: (msg?: any) => void }) => { sign: SignType, target: string | number },
signHandlers?: { [signKey: string | number]: (params: HandlerParams) => void },
beforeTraverse?: (str: string) => void,
beforeSignHandle?: (params?: SignHookParams) => void,
afterSignHandle?: (params?: SignHookParams) => void,
afterTraverse?: (str: string) => void,
error?: (msg?: any) => void
}
CalcOptions下的选项我们可以提供一套基础的配置,weights和funcs和上一版代码中的weights、funcs是一样的,我们可以将其定义到baseWeights、baseFuncs两个对象中。
beforeTraverse为预留的hook,可自定义扩展,对表达式s作预处理。
beforeSignHandle会对当前字符串先做处理,如上一个字符preSign不为数字,并且s中包含符号-,那么需要在前面加上0。
// 进阶版
if (!numOrTag && subStr.indexOf('-') === 0) {
subStr = '0' + subStr
}
// beforeSignHandle定义
beforeSignHandle: (params?: SignHookParams) => {
console.log('hook beforeSignHandle:' + JSON.stringify(params))
const { cb, preSign, str } = params
if (preSign !== 'Num' && str.indexOf('-') === 0) {
cb && cb(('0' + str).trim())
}
}
getSign函数解析当前字符,并将其转换{ sign: string, target: string}格式,sign表示LeftBracket、RightBracket等符号,target记录原始符号。
genSign: (params: { str: string, preSign: string, error?: (msg?: any) => void }): { sign: SignType, target: string | number } => {
let { str, preSign, error } = params
let match = str.match(/^-?\d+/g)
if (preSign !== 'Num' && match && match.length) {
return { sign: 'Num', target: Number(match[0]) }
} else if ((match = str.match(/^\(/g)) && match.length) {
return { sign: 'LeftBracket', target: match[0] }
} else if ((match = str.match(/^\)/g))) {
return { sign: 'RightBracket', target: match[0] }
} else if (preSign !== 'Operation' && (match = str.match(/^[\/*+-]/g)) && match.length) {
return { sign: 'Operation', target: match[0] }
}
error && error(`传入的计算表达式${str}有误.`)
},
SignHandle环节最复杂,需要对每个符号Sign做对应的处理,可以考虑为每个符号定义处理函数,如NumHandler、OperationHandler、LeftBracketHandler、RightBracketHandler。 如果是OperationHandler,对比calcStack最新符号和前当前符号(originalTarget)的优先级,如堆栈calcStack符号为['*'], 当前符号为+,那么会先执行*,再将当前*放入堆栈中。
signHandlers: {
// 将数值存入到numStack堆栈中
NumHandler: (params: HandlerParams) => {
const { originalTarget, cb } = params
numStack.push(originalTarget)
cb && cb(false)
},
// 对比当calcStack最新符号和前符号(originalTarget)的优先级
// 如果大于等于当前符号优先级,先把堆栈中最新符号计算了
// cb的定义为(needCalc?: boolean, endTag?: string) => void
OperationHandler: (params: HandlerParams) => {
const { originalTarget, cb } = params
if (calcStack.length && baseWeights[calcStack[calcStack.length - 1]] >= baseWeights[originalTarget]) {
cb && cb(true)
}
calcStack.push(originalTarget)
},
// 如果是左括号,直接先放入calcStack中
LeftBracketHandler: (params: HandlerParams) => {
const { originalTarget, cb } = params
calcStack.push(originalTarget)
cb && cb(false)
},
// 如何是右括号,调用cb(true, '('),表示执行堆栈中的计算,知道遇到左括号为止
RightBracketHandler: (params: HandlerParams) => {
const { originalTarget, cb } = params
cb && cb(true, '(')
calcStack.pop()
}
}
cb回调格式为(needCalc?: boolean, endTag?: string) => void ,其中needCalc表示是否需要对calcStack中的符号立即计算,当endTag有值(如左括号(),表示计算堆栈直到遇见左括号(。
到目前我们只是对单个环节做了介绍,要把所有的环节串联起来,可定义createCalculator函数,具体代码如下:
function createCalculator(s: string, options?: CalcOptions) {
const {
beforeTraverse,
beforeSignHandle,
afterSignHandle,
afterTraverse,
genSign,
error } = options
let subStr = s.trim(), preSign = ''
beforeTraverse && beforeTraverse(subStr)
while (subStr) {
beforeSignHandle({
str: subStr,
preSign: preSign,
cb: (newStr: string) => {
subStr = newStr
}
})
const { sign, target } = genSign({ str: subStr, preSign: preSign, error })
console.log('hook genSign:' + JSON.stringify({ sign, target }))
const signHandlers = options.signHandlers
const currrentHandler = signHandlers[sign + 'Handler']
currrentHandler && currrentHandler({
preSign: preSign,
currentSign: sign,
originalTarget: target,
cb: (needCalc?: boolean, endTag?: string) => {
if (needCalc) {
if (!endTag) {
executeCalc({ error: error })
} else {
while (calcStack.length && calcStack[calcStack.length - 1] !== endTag) {
executeCalc()
}
}
}
}
})
afterSignHandle && afterSignHandle({
str: subStr,
currentSign: sign,
originalTarget: target,
preSign: preSign,
cb: (newStr: string) => {
subStr = newStr
}
})
preSign = sign
}
afterTraverse && afterTraverse(subStr)
return numStack[0]
}
createCalculator是内部函数,对外暴露的计算函数定义为calculate,包含s、options两个参数,函数体先定义配置对象finalOptions,其weights、funcs使用我们预先定义的baseWeights、baseFuncs,baseSignConfig定义了sign相关的处理函数,而baseEvents定义了和hooks相关的事件,如beforeSignHandle、afterSignHandle。
function calculate(s: string, options?: CalcOptions) {
options = options || {}
const finalOptions: CalcOptions = {
weights: baseWeights,
funcs: baseFuncs,
...baseSignConfig,
...baseEvents
}
if (options.weights) {
finalOptions.weights = Object.assign(finalOptions.weights, options.weights)
}
if (options.funcs) {
finalOptions.funcs = Object.assign(finalOptions.funcs, options.funcs)
}
if (options.signHandlers) {
finalOptions.signHandlers = Object.assign(finalOptions.signHandlers, options.signHandlers)
}
for (const key in options) {
if (key !== 'weights' && key !== 'funcs' && key !== 'signHandlers') {
finalOptions[key] = options[key]
}
}
return createCalculator(s, finalOptions)
}
紧接着会判断用户传入的options参数,如果有配置weights、funcs,会基于原有的baseWeights、baseFuncs做额外的扩展,如options包含有%配置,那么finalOptions中的weights处理%,还包括+、-、*、/配置。
有了calculate函数,那现在我们可以考虑如何支持表达式1 * 2 + 3 * (5 % 2)的计算,仅仅需要扩展funcs和genSign两项即可,具体代码如下:
const result = calculate('1 * 2 + 3 * (5 % 2)', {
funcs: Object.assign(baseFuncs, { '%': (left, right) => {
return left % right
} }),
genSign: params => {
const { str, preSign, error } = params
params.error && (delete params.error)
const baseResult = baseSignConfig.genSign(params)
if (baseResult) {
return baseResult
}
console.log('hook % genSign:' + JSON.stringify(baseResult))
let match = str.match(/^[%]/g)
if (preSign !== 'Operation' && match && match.length) {
return { sign: 'Operation', target: match[0] }
}
error && error(`传入的计算表达式${str}有误.`)
},
error: (msg?: any) => {
throw new Error(msg)
}
})
在genSign中先调用baseSignConfig.genSign函数,看是否为+、-、*、/,如果不是,再通过str.match(/^[%]/g)判断是否为%,是则调用在funcs中扩展的%运算函数。
完成的代码可在github查看:github.com/heavis/algo…
写在最后
如果大家有其他问题可直接留言,一起探讨!最近我会持续更新Vue源码介绍、前端算法系列,感兴趣的可以持续关注。