算法:基本计算器

335 阅读6分钟

题目

给你一个字符串表达式 s ,请你实现一个基本计算器来计算并返回它的值,整数除法仅保留整数部分。

示例 1:

输入: s = "3+2*2"
输出: 7

示例 2:

输入: s = " 3/2 "
输出: 1

示例 3:

输入: s = " 3+5 / 2 "
输出: 5

提示:

  • 1 <= s.length <= 3 * 10510^5
  • s 由整数和算符 ('+', '-', '*', '/') 组成,中间由一些空格隔开
  • s 表示一个 有效表达式
  • 表达式中的所有整数都是非负整数,且在范围 [0, 2312^{31} - 1] 内
  • 题目数据保证答案是一个 32-bit 整数

数据结构课题中有介绍通过堆栈的方式来实现一个简单计算器,其原理是遍历字符串表达式s,分别将数字和运算符放到不同的堆栈,例如将数值、运算符分别放到numStack、calcStack两个堆栈列表中,但在存放运算符时,需要判断当前运算符sign和堆栈中最新的运算符preSign的优先级,如果sign优先级低于或等于preSign,需要先将preSign从推展中取出并执行运算,重复以上操作,直到sign优先级高于preSign的优先级,才可将sign存放到堆栈中。

以s = 3 + 5 / 2为例,对其遍历,假设[*,/]优先级为5, [+, -]优先级为3,当前位置的符号为target:

  1. target为3,放入numStack,s变为+ 5 / 2, numStack为[3], calcStack为[];
  2. target为+, 放入calcStack,s变为5 / 2, numStack为[3], calcStck为[+];
  3. target为5, 放入numStack,s变为/ 2, numStack为[3, 5], calcStack为[+];
  4. target为/,优先级大于+,放入calcStack, s变为2, numStack为[3, 5], calcSack为[+,/];
  5. target为2, 放入numStack,s变为空字符串,numStack为[3, 5, 2],calcStack为[+,/];
  6. 循环提取calcStack,运算符号记为tag;
  7. 从numStack提取最新两个数right、left;
  8. 执行运算left tag right, 将结果存入numStack;
  9. 执行步骤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

要实现可扩展的计算器,我们先分析代码的执行过程,主要流程如下:

  1. 配置优先级、运算函数, 定义weights、funcs配置。
  2. 遍历前处理,可定义为beforeTraverse事件
  3. 开始遍历
  4. 符号预处理,可定义为beforeSignHandle事件
  5. 获取符号信息, 可定义getSign函数获取,返回Num、Operation、LeftBracket等符号标示
  6. 处理符号,根据获取的符号(如Num、Operation)做相应的处理,可定义为NumHandler、OperationHandler等等
  7. 符号后置处理,可定义afterSignHandle事件
  8. 结束遍历
  9. 遍历后处理,可定义afterTraverse事件 可通过如下的流程图表示: 计算器流程.jpg 把环节划分清楚之后,可把每个节点定义为可扩展的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源码介绍、前端算法系列,感兴趣的可以持续关注