javascript:递归与栈(Stack):解决表达式求值

338 阅读6分钟

1.栈基础知识

1.栈支持两种基本操作,出队和入队

  • 出队,栈顶指针向下移动一位,在逻辑层面,认为元素已出队
  • 入队,把数据添加进来,栈顶指针向上移动一位 2.特点:先入后出(FILO)

2.栈适合解决什么问题

栈可以处理具有完全包含关系的问题

3.经典的栈实现方法

// 栈类
class Stack {
  constructor(n = 100) {
    this.data = new Array(n); // 一片连续的存储空间
    this.top = -1; // 栈顶指针,栈为空,指向-1
  }
  // 入队
  push(x) {
    // 指针向上移动一位
    this.top += 1;
    // x放入指针指向的位置
    this.data[this.top] = x;
    return;
  }
  // 出队
  pop() {
    // 栈为空,不操作
    if (this.empty()) return;
    // 栈顶指针向下移动一位,逻辑上认为已出队
    this.top -= 1;
    return;
  }
  // 判空
  empty() {
    return this.top === -1;
  }
  // 栈中元素数量
  size() {
    return this.top + 1;
  }
  output() {
    for (let i = this.top; i >= 0; i--) {
      console.log(this.data[i]);
    }
  }
}

function main() {
  let arr = new Stack(5);
  arr.push(1);
  arr.push(2);
  arr.push(4);
  arr.output();
  arr.pop();
  arr.output();
  console.log(arr.size());
}

main();

4.栈的典型应用场景

1.场景一:操作系统中的线程栈
2.场景二:表达式求值
给运算符人为设定数值上的优先级,加减为1,乘除为2,括号内的加100

// s:待计算的表达式;l:开始坐标;r:结束坐标
function calc(s, l, r) {
  // op:最低优先级操作符的位置
  // pri:最低优先级操作符的优先级
  // cur_pri:当前操作符优先级
  // temp:因为括号额外增加的优先级
  let op = -1,
    pri = 10000 - 1,
    cur_pri,
    temp = 0;
  // 第一步:找到优先级最低的操作符
  for (let i = l; i <= r; i++) {
    cur_pri = 10000;
    switch (s[i]) {
      case "+":
      case "-": cur_pri = 1 + temp; break;
      case "*":
      case "/": cur_pri = 2 + temp; break;
      // 进了一层括号
      case "(": temp += 100; break;
      // 出了一层括号
      case ")": temp -= 100; break;
    }
    // 当前优先级小于等于先前记录的优先级,就更新一下
    if (cur_pri <= pri) {
      pri = cur_pri;
      op = i;
    }
  }
  if (op === -1) {
    // 证明当前表达式是没有操作符的纯数字,需要转换成数字
    let num = 0;
    for (let i = l; i <= r; i++) {
      if (s[i] < "0" || s[i] > "9") continue;
      num = num * 10 + (s[i].charCodeAt() - "0".charCodeAt());
    }
    return num;
  }
  // 第二步:递归的计算,以优先级最低的操作符为分隔,两侧的表达式,
  let a = calc(s, l, op - 1);
  let b = calc(s, op + 1, r);
  // 计算表达式结果
  switch (s[op]) {
    case "+":  return a + b;
    case "-": return a - b;
    case "*": return a * b;
    case "/": return a / b;
  }
}

function main() {
  let s = "(3+5)*7";
  console.log(calc(s, 0, s.length - 1));
}
main();

5.经典面试题-栈的基本操作

面试题 03.04. 化栈为队

利⽤两个栈来实现,⼀个输⼊栈、⼀个输出栈。
输⼊栈⽤于读⼊数据。当需要输出元素时,若输出栈为空,则将输⼊栈的所有元素 推送到输出栈,然后取栈顶元素;若输出栈非空,则输出栈顶即可。
输出栈的作⽤是对已经被反转的序列进⾏⼆次反转。
两个栈命名s1s2,从s2中入队,s1中出队,出队时s1要是为空,就把s2中的元素全部放入s1中,此时我们会发现s1可以实现s2数据的逆序,正常出队即可,这时两个栈对外的表现形式就是队列
s2入队〇〇〇|①②③
s1出队①②③|〇〇〇
s1出队②③|〇〇〇
s2入队②③|④⑤⑥

var MyQueue = function() {
    // s1出队,s2入队
    this.s1 = [];
    this.s2 = [];
};

MyQueue.prototype.push = function(x) {
    this.s2.push(x);
    return;
};

// s1为空时,把s2中的元素全部放入s1中
MyQueue.prototype.transfer = function() {
    if(this.s1.length !== 0) return;
    while(this.s2.length !== 0){
        this.s1.push(this.s2.pop());
    }
    return;
};

MyQueue.prototype.pop = function() {
    this.transfer();
    return this.s1.pop();
};

MyQueue.prototype.peek = function() {
    this.transfer();
    return this.s1[this.s1.length - 1];
};

// s1、s2都为空时,才空
MyQueue.prototype.empty = function() {
    return this.s1.length === 0 && this.s2.length === 0;
};

682. 棒球比赛

// 依照题意编写代码即可
var calPoints = function(ops) {
    let s = [];
    for(let i = 0; i < ops.length; i++){
        if(ops[i] === '+'){
            // 先弹出前一次得分,并记录下来
            let a = s.pop();
            // 拿到前两次得分
            let b = s[s.length - 1];
            // 把前一次得分再放回栈中
            s.push(a);
            // 最后放入前两次得分的总和
            s.push(a + b);
        }else if(ops[i] === 'D'){
            s.push(2 * s[s.length - 1]);
        }else if(ops[i] === 'C'){
            s.pop();
        }else{
            s.push(Number(ops[i]));
        }
    }
    let sum = 0;
    while(s.length !== 0){
        sum += s.pop();
    }
    return sum;
};

844. 比较含退格的字符串

// 把原字符串按题意处理得到新字符串
var transform = function(arr, S){
    for(let i = 0; i< S.length; i++){
        if(S[i] == '#'){
            arr.pop();
        }else{
            arr.push(S[i]);
        }
    }
    return arr.join('');
}
var backspaceCompare = function(s, t) {
    let sarr = [];
    let tarr = [];
    return transform(sarr,s) === transform(tarr,t);
};

946. 验证栈序列

只需关注出栈序列即可,要出栈的元素,要么是栈顶元素,要么是未来可能入栈的元素,所以,出栈序列中当前出栈元素是否能被满足,就判断元素在不在栈顶,不在,就继续从入栈序列,入栈元素即可。

var validateStackSequences = function(pushed, popped) {
    let s = [];
    // 判断出栈元素能否被满足
    // i:出栈序列;j:入栈序列
    for(let i = 0, j = 0;i < popped.length; i++){
        // 当入栈序列还有元素并且 栈为空或者栈顶元素不等于当前要出栈的元素时,持续性入栈
        while(j < pushed.length && (s.length === 0 || s[s.length - 1] !== popped[i])){
            s.push(pushed[j]);
            j += 1;
        }
        // 全部入栈后栈顶元素还不等于当前要出栈的元素,没法满足
        if(s[s.length - 1] !== popped[i]) return false;
        // 当前出栈元素满足,出栈
        s.pop();
    }
    return true;
}; 

6.经典面试题-栈结构扩展

20. 有效的括号

var isValid = function(s) {
    let stack = [];
    for(let i = 0; i < s.length; i++){
        switch(s[i]){
            // 如果是左括号之间入栈
            case '(':
            case '[':
            case '{': stack.push(s[i]);break;
            // 如果是右括号,判断是否能和栈顶元素匹配,同时栈不能为空,匹配成功则出栈
            case ')': if(stack.length === 0 || stack[stack.length - 1] !== '(') return false; stack.pop();break;
            case ']': if(stack.length === 0 || stack[stack.length - 1] !== '[') return false; stack.pop();break;
            case '}': if(stack.length === 0 || stack[stack.length - 1] !== '{') return false; stack.pop();break;
        }
    }
    // 最后栈为空,才是合法的序列
    return stack.length === 0;
};

1021. 删除最外层的括号

如何判断某一部分是原语,即独立的括号部分呢 设定遇到(加一,遇到)减一,统计()的数量,记录()的差值,当差值为0时,则代表这⼀串括号序列是独立的,可以被单独分解出来。

var removeOuterParentheses = function(s) {
    let ret = '';
    // pre:当前独立括号的起始位置
    // cnt:左右括号差值
    for(let  i = 0, pre = 0, cnt = 0; i < s.length; i++){
        if(s[i] === '(') cnt += 1;
        else cnt -= 1;
        if(cnt !== 0) continue;
        // 差值为0证明从pre开始到当前位置i是一段独立的字符串
        // 截取中间部分,左闭右开
        ret += s.substring(pre + 1, i);
        // 下一段起始位置是当前位置的下一位
        pre = i + 1;
    }
    return ret;
};

1249. 移除无效的括号

可以被匹配的括号都是有效的,⽽其他的括号都需要被删除。
先从前向后遍历,跳过多余的右括号,再从后向前遍历,跳过多余的左括号。

var minRemoveToMakeValid = function(s) {
    let t = '';
    // cnt左右括号差值
    // 先正向遍历,去掉多余的右括号
    for(let i = 0, cnt = 0; i < s.length; i++){
        if(s[i] !== ')' ) {
            // (加一
            cnt += (s[i] === '(');
            t += s[i];
        }else{
            // 差值为0,说明当前)是非法的,跳过
            if(cnt === 0) continue;
            cnt -= 1;
            t += ')';
        }
    }
    let ans = '';
    // 得到的新字符串,再逆向遍历,去掉多余的左括号
    for(let i = t.length - 1, cnt = 0; i >= 0; i--){
        if(t[i] !== '(' ) {
            cnt += (t[i] === ')');
            ans = t[i] +ans;
        }else{
            // 差值为0,说明当前(是非法的,跳过
            if(cnt === 0) continue;
            cnt -= 1;
            ans = '(' +ans;
        }
    }
    return ans;
};

145. 二叉树的后序遍历

用迭代算法完成:模拟系统栈
技巧是使⽤两个栈,⼀个数据栈,存储相关节点地址,⼀个状态栈。将“遍历左⼦树”,“遍历右⼦树”和“输出根节点”三个步骤分别⽤状态码表⽰,枚举状态转移过程,使⽤有限 状态⾃动机(FSM, Finite State Machine)的模型来模拟递归过程。


var postorderTraversal = function(root) {
    if(root === null) return [];
    // s1:数据栈,存储节点地址
    // s2:状态栈
    //     0:数据栈中压入栈顶节点的左⼦树
    //     1:数据栈中压入栈顶节点的右⼦树
    //     2:输出栈顶节点

    // ret:结果数组
    let s1 = [], s2 = [], ret = [];
    s1.push(root);
    s2.push(0);
    while(s1.length !== 0){
        // 记录s2的栈顶状态执行不同操作,任何操作执行完都弹栈,所以在这里之间先执行
        let status = s2.pop();
        switch(status){
            case 0:{
                // 把栈顶元素的状态码由0改为1
                s2.push(1);
                if(s1[s1.length - 1].left !== null){
                    // s1压入左子树,s2压入左子树的状态码
                    s1.push(s1[s1.length - 1].left);
                    s2.push(0);
                }
            }break;
            case 1:{
                // 右子树同理
                s2.push(2);
                if(s1[s1.length - 1].right !== null){
                    s1.push(s1[s1.length - 1].right);
                    s2.push(0);
                }
            }break;
            case 2:{
                // 输出并弹栈
                ret.push(s1.pop().val) ;
            }break;
        }
    }
    return ret;
};

331. 验证二叉树的前序序列化

每次碰到数字、#、#的节点(即叶⼦结点),就可以回溯,抽象成#,如果经过这种不断缩减的操作,把树上的全部节点都拆光(即只剩⼀个#),能拆光的序列就是合法序列。

var isValidSerialization = function(preorder) {
    // 字符串转数组,目的去掉','
    let order = preorder.split(',');
    let s = []
    for(let i = 0; i < order.length; i++){
        // 遍历过程中进行缩减
        s.push(order[i]);
        // 是叶子节点时,缩减
        while(s.length >= 3 && s[s.length - 1] === '#' && s[s.length - 2] === '#' && s[s.length - 3] !== '#'){
            s[s.length - 3] = '#'
            s.pop();
            s.pop();
        }
    }  
    // 只剩一个#,正确
    return s.length === 1 && s[0] ==='#';
};

227. 基本计算器 II

使⽤操作数栈和操作符栈辅助计算,当操作符栈遇到更低优先级的操作符时,需要将之前更⾼级别的操作符对应的结果计算出来。
即依次把每个操作数和操作符压入栈中,当新压入的操作符优先级小于等于栈顶元素操作符时,就得把前面操作符的结果先算出来。
对于有括号的情况,左括号相当于提⾼了内部全部运算符的优先级,当遇到右括号 的时候需要将匹配的括号间的内容全部计算出来。
可以通过加⼀个特殊操作符的处理技巧,来额外处理结尾的数字。

// 判断运算符优先级
var level = function(op){
    switch(op){
        case '@':return -1;
        case '+':
        case '-':return 1;
        case '*':
        case '/':return 2;
    }
    return 0;
}
// 根据运算符计算结果
var calc = function(a, op, b){
    switch(op){
        case '+':return a + b;
        case '-':return a - b;
        case '*':return a * b;
        case '/':return Math.floor(a / b);
    }
    return 0;
}
var calculate = function(s) {
    let num = [];// 操作数栈
    let ops = [];// 运算符栈
    /* 技巧:设定@运算符优先级小于所有运算符。因为计算过程是碰到一个优先级比较低的运算符时,
     会把前面结果都算出来。又因为@运算符优先级最低,所以会把表达式全部计算,是一个收尾操作*/
    s += '@';
    // n:当前数字
    for(let i = 0, n = 0; i < s.length; i++){
        if(s[i] === ' ') continue;
        if(s[i] >= '0' && s[i] <= '9'){
            // 数字字符转数字
            n = n * 10 + (s[i].charCodeAt() - '0'.charCodeAt());
            continue;
        }
        // 当前元素是运算符,把之前的数字放入操作数栈,并重置n
        num.push(n);
        n = 0;
        // 栈不为空并且当前运算符优先级小于等于栈顶运算符的话
        while(ops.length !== 0 && level(s[i]) <= level(ops[ops.length - 1])){
            // 拿出操作数栈中的前两位
            let b = num.pop();
            let a = num.pop();
            // 把运算结果放入操作数栈中
            num.push(calc(a ,ops[ops.length - 1], b));
            // 栈顶操作符运算完成,弹栈
            ops.pop();
        }
        // 当前操作符入栈
        ops.push(s[i]); 
    }
    
    return num[0];
};

636. 函数的独占时间

任务开始时进栈,上⼀个 任务暂停执⾏;任务完成时出栈,恢复上⼀个任务的执⾏

var exclusivetime = function(n, logs) {
    // 结果数组,初始化为0
    let ans = new Array(n).fill(0);
    // 运行函数的编号
    let vID = [];
    // pre上个时间点
    for(let i = 0, pre = 0; i < logs.length; i++){
        // 把函数,状态,时间戳拆分出来
        let log = logs[i].split(':');
        let id = Number(log[0]);
        let status = log[1];
        let time_stamp = Number(log[2]);
        // 函数开始
        if(status === 'start'){
            // 不为空,证明有上一个函数在执行
            if(vID.length !== 0){
                // start是累加给上一个函数
                ans[vID[vID.length - 1]] += time_stamp - pre;
            }
            pre = time_stamp;
            vID.push(id);
        }else{
            // end是累加给当前函数,id和栈顶元素一致,所以两者都可以写,换成栈顶元素写法可以简化代码,为了好理解,这里就不简化了
            // ans[id] += time_stamp - pre + 1;
            ans[vID[vID.length - 1]] += time_stamp - pre + 1;
            pre = time_stamp + 1;
            vID.pop();
        }
    }
    return ans;
};

1124. 表现良好的最长时间段

把表现“良好”记为+1,把表现“不好”记为-1,将原序列转化为正负1的序列,原问题转化为求转化后序列的最⻓⼀段连续⼦序列,使得⼦序列的和⼤于0。
在这⾥引⼊“前缀和”的技巧。前缀和数组的第n项,是原数组前n项的和。
记原数组为a,那么前缀和prefix[n] = Σa[i](i从1到n)。这样就可以将“区间和”转化 为“前缀和”之差来计算。前缀和可以视情况补⼀个前导0,表⽰前0项之和,即不取任何元素的情况。
在本题中,数组中的元素只有-1和1,因此前缀和 的变化⼀定是连续的。我 们记录下前缀和中,每⼀个前缀和第⼀次出现的位置,它对应的位置⼀定是从该前 缀和出发的最优解。
我们以f(n)表⽰以n结尾的序列的最⼤⻓度,pos(n)表⽰前缀和n第⼀次出现的位置。那么有f(n) = f(n-1) + pos(n) - pos(n-1)

// 关键:记录每个值第一次出现时的能达到的最大长度以及位置
var longestWPI = function(hours) {
    // 前缀和中每个值第一次出现的位置
    let ind = new Map();
    // 前缀和中每个值的第一次出现时最大长度
    let f = new Map();
    // 前导0第一次出现的位置是-1
    ind.set(0, -1);
    // 前导0是第一个前缀和元素,前面不会比0小的元素,最大长度是0
    f.set(0, 0)
    // cnt前缀和
    let cnt = 0, ans = 0;
    for(let i = 0; i < hours.length; i++){
        if(hours[i] > 8) cnt += 1;
        else cnt -= 1;
        // cnt第一次出现
        if(!ind.has(cnt)){
            // 记录cnt第一次出现的位置
            ind.set(cnt, i);
            // 前面没有出现比cnt小1的值,最大长度赋值为0
            // 因为前缀和的变化⼀定是连续的,所以每个负数第一次出现时前面不会有比它小的值,最大长度是0
            if(!ind.has(cnt - 1)) f.set(cnt, 0);
            // 出现过,求第一次出现能达到的最大长度
            else f.set(cnt, f.get(cnt - 1) + (i - ind.get(cnt - 1)));
        }
        if(!ind.has(cnt - 1)) continue;
        // 前面出现比cnt小1的值,求最大长度
        ans = Math.max(ans, f.get(cnt - 1) + (i - ind.get(cnt - 1)));
    }
    return ans;
};