数据结构与算法学习系列之 栈

190 阅读9分钟

栈的定义

栈是一种遵从 后进先出(LIFO)原则的有序集合。新添加或待删除的元素都保存在栈的同一端,称作栈顶,另一端就叫栈底。在栈里,新元素都靠近栈顶,旧元素都接近栈底。如图:

基于数组的栈

我们使用 ES6 的类来创建一个基于数组的栈:

class Stack {
    constructor() {
    this.items = []
  }
}

接下来,为栈声明一些方法:

push(element):添加一个或多个新元素到栈顶

pop():移除栈顶的元素,同时返回被移除的元素

peek():查看栈顶的元素,不对栈做任何修改

isEmpty():判断栈是否为空,如果栈里没有任何元素就返回 true,否则返回 false

clear():清空栈,即移除栈里的所有元素

size(): 返回栈里的元素个数,该方法和数组的 length 属性类似

print(): 打印栈元素,将栈中的元素以字符串的形式打印出来

下面,我们逐一实现栈的方法:

push 向栈添加元素

push 方法负责往栈里添加新元素,该方法只能添加元素到栈顶

// 往栈里压入一个元素,因为使用数组实现栈,往栈里压入元素就是往数组的末尾添加元素
push(element) {
    this.items.push(element);
}

pop 从栈顶移除元素

pop 方法用来移除栈顶的元素,也就是移除的是最后添加进去的元素

// 移除栈顶元素,因为使用数组实现栈,移除栈顶元素就是移除数组的最后一个元素
pop() {
    return this.items.pop();
}

peek 查看栈顶元素

peek() {
    return this.items[this.items.length - 1]
}

isEmpty 检查栈是否为空

isEmpty 方法检查栈是否为空,如果栈为空就返回 true,否则就放回 false

isEmpty() {
    return this.items.length === 0;
}

clear 清空栈

clear 方法用来移除栈里所有的元素,把栈清空

clear() {
    this.items = []
}

size 返回栈里的元素个数

size() {
    return this.items.length
}

print 打印栈元素

print() {
    return this.items.toString();
}

完整代码

class Stack {
    constructor() {
    this.items = [];
  }

  push(element) {
    this.items.push(element);
  }

  pop() {
    return this.items.pop();
  }

  peek() {
    return this.items[this.items.length - 1];
  }

  isEmpty() {
    return this.items.length === 0;
  }

  clear() {
    this.items = [];
  }

  size() {
    return this.items.length;
  }

  print() {
    return this.items.toString()
  }
}

基于对象的栈

除了可以使用数组来实现栈,也可以使用JavaScript对象来存储所有的栈元素,保证它们的顺序并且遵循 LIFO 原则。在使用对象实现是栈中,使用 count 变量来记录栈元素的个数,并且使用 count 变量作为键将栈元素存储在 items 对象中。

class Stack {
    constructor() {
    // 栈的个数
    this.count = 0;
    // 存储栈元素
    this.items = {};
  }
}

push 向栈插入元素

我们将 count 作为键,插入的元素作为值存储在对象中

push(element) {
    this.items[this.count] = element;
  this.count++;
}

pop 从栈顶移除元素

pop() {
  // 栈是否为空,为空则返回 undefined
    if (this.isEmpty()) {
    return undefined;
  }
  // count 属性减 1,此时的值即为栈顶元素在对象中的位置
  this.count--;
  // 根据 count 取出栈顶元素
  const result = this.items[this.count];
  // 删除栈顶元素
  delete this.items[this.count];
  // 将栈顶元素返回
  return result;
}

peek 查看栈顶元素

peek() {
    if (this.isEmpty()) {
    return undefined;
  }
  // count 属性 减 1 后的值即为 栈顶元素的健值
  return this.items[this.count - 1];
}

isEmpty 检查栈是否为空

isEmpty() {
  // count 变量表示栈的元素个数
    return this.count === 0;
}

clear 清空栈

清空栈,只需将 items变量复原为空对象,count 变量复原为 0 即可

clear() {
  // 将对象置为空对象
    this.items = {};
  // 将 count 变量置为 0
  this.count = 0;
}

size 返回栈里的元素个数

size() {
    return this.count;
}

print 打印栈元素

print() {
  // 栈为空,返回空字符串
    if (this.isEmpty) {
    return '';
  }

  let objString = `${this.items[0]}`;
  // 迭代 栈的键,一直到栈顶,将栈元素拼接成字符串
  for (let i = 1; i <this.count; i++) {
    objString = `${objString},${this.items[i]}`;
  }

  return objString;
}

完整代码

class Stack {
    constructor() {
    // 栈的个数
    this.count = 0;
    // 存储栈元素
    this.items = {};
  }

  push(element) {
    this.items[this.count] = element;
    this.count++;
  }

  pop() {
    // 栈是否为空,为空则返回 undefined
    if (this.isEmpty()) {
        return undefined;
    }

    // count 属性减 1,此时的值即为栈顶元素在对象中的位置
    this.count--;
    // 根据 count 取出栈顶元素
    const result = this.items[this.count];
    // 删除栈顶元素
    delete this.items[this.count];
    // 将栈顶元素返回
    return result;
  }

  peek() {
    if (this.isEmpty()) {
        return undefined;
    }
    // count 属性 减 1 后的值即为栈顶元素的健值
    return this.items[this.count - 1];
  }

  isEmpty() {
    // count 变量表示栈的元素个数
    return this.count === 0;
  }

  clear() {
    // 将对象置为空对象
    this.items = {};
    // 将 count 变量置为 0
    this.count = 0;
  }

  size() {
    return this.count;
  }

  print() {
    // 栈为空,返回空字符串
    if (this.isEmpty()) {
        return '';
    }

    let objString = `${this.items[0]}`;
    // 迭代 栈的键,一直到栈顶,将栈元素拼接成字符串
    for (let i = 0; i < this.count; i++) {
        objString = `${objString}, ${this.items[i]}`;
    }

    return objString;
  }
}

栈的应用

进制转换

在现实生活中,我们主要使用十进制,但在计算科学中,计算机里的所有内容都是用二进制数字表示的 (0 和 1)。要与计算机节流,就必须把十进制转换成二进制。

要把十进制转换成二进制,我们可以将十进制数除以 2,并对商取整,直到商为 0 为止。举个例子,把十进制的数 20 转换成二进制的数字,其过程如下:

思路分析:

将十进制数除以 2,当除法的结果 (商) 不为 0 时,我们会获得一个余数,并将余数放到栈里

然后让结果 (商) 继续除以 2,并将余数入栈,直到结果 (商) 为 0

将栈中的元素出栈,连接成字符串,就是转换后的二进制数,如上图,转换后的二进制位 10100

代码实现

const decimalToBinary = (decNumber) => {
  const stack = new Stack();
  let number = decNumber;
  let remainder;
  let binaryNumber = '';

  while (number > 0) {
    // 获取余数
    remainder = Math.floor(number % 2);
    // 将余数入栈
    stack.push(remainder);
    // 将结果 (商) 除以 2
    number = Math.floor(number / 2);
  }

  while (!stack.isEmpty()) {
    // 栈元素出栈,拼接成字符串
    binaryNumber += stack.pop().toString();
  }

  return binaryNumber
}

console.log(decimalToBinary(20))  // 10100
console.log(decimalToBinary(10))  // 1010
console.log(decimalToBinary(1000))  // 1111101000
console.log(decimalToBinary(255))  // 11111111

下面,我们将十进制转二进制的算法进行改造,使之能把十进制转换成基数为 2~36 的任意进制。除了把十进制数除以 2 转成 二进制数,还可以传入其他任意进制的基数为参数,然后转换成相应的进制数。

const baseConverter = (decNumber, base) => {
    const stack = new Stack();
  const digits = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ';
  let number = decNumber;
  let remainder;
  let baseString = '';

  if (!(base >=2 && base <=36)) {
    return '';
  }

  while(number > 0) {
    remainder = Math.floor(number % base);
    stack.push(remainder);
    number = Math.floor(number / base);
  }

  while(!stack.isEmpty()) {
    baseString += digits[stack.pop()];
  }

  return baseString;
}

合法括号

下面的字符串中包含小括号,编写一个函数判断字符串中的括号是否合法,所谓合法,就是括号成对出现

sdf(ds(ew(we)rw)rwqq)qwewe          合法
(sd(qwqw)sd(sd))                                                    合法
()()sd()(sd()fw))(                                          不合法

思路分析:

括号存在嵌套关系,也存在并列关系,如果使用数组存储这些括号,然后再一对一对的将括号抵消掉,这似乎是一个可行的办法,可是如何判断一个左括号对应的是哪个右括号呢?在使用数组的角度上思考这个问题,就会陷入一种无从下手的绝望之中。

我们换个思路,在栈的角度上思考这个问题,解题的步骤就非常简单。我们可以使用 for 循环遍历字符串中的每个字符,对每个字符做如下的操作:

  1. 遇到左括号,就把左括号压入栈中
  2. 遇到右括号,判断栈是否为空,为空则说明没有左括号与之对应,缺少左括号,字符串括号不合法;如果栈不为空,则把栈顶元素移除,这对括号就抵消掉了

当 for 循环结束之后,如果栈是空的,就说明所有的左右括号都抵消掉了,如果栈里还有元素,则说明缺少右括号,字符串括号不合法

实现代码:

下面,我们使用 栈 解题

const isLeaglBrackets = (str) => {
    const stack = new Stack();
  for (let i = 0; i < str.length; i++) {
    const item = str[i];
    if (item === '(') {
      // 将 左括号 压入 栈
        stack.push(item);
    } else if (item === ')') {
      // 如果为空,就说明没有左括号与右括号抵消
        if (stack.isEmpty()) {
        return false;
      } else {
        // 将左括号弹出栈
        stack.pop();
      }
    }
  }
  // 栈是否为空,为空则说明字符串中所有的左右括号都抵消掉了,那么字符串中的括号是合法的,否则是不合法的
  return stack.size() === 0;
}

console.log(isLeaglBrackets("()()))"));  // false
console.log(isLeaglBrackets("sdf(ds(ew(we)rw)rwqq)qwewe"));  // true
console.log(isLeaglBrackets("()()sd()(sd()fw))("));  // false

计算逆波兰表达式

逆波兰表达式,也叫后缀表达式,它将复杂表达式转换为可以依靠简单的操作得到计算结果的表达式,例如我们平时写 a + b,这是中缀表达式,写成后缀表达式就是: ab+。又比如中缀表达式 (a+b)*c 写成后缀表达式为:ab+c*

现在我们实现一个算法,来计算逆波兰表达式

["4",   "13",   "5",    "/",    "+"]    等价于(4   +   (13 /   5)) =   6
["10",  "6",    "9",    "3",    "+",    "-11",  "*",    "/",    "*",    "17",   "+",    "5",    "+"]    等价于((10 *   
(6  /   ((9 +   3)  *   -11)))  +   17) +   5

思路分析:

['4', '13', '5', '/', '+'] 是一个数组,我们在数组的角度来思考这个问题,遇到 ' / ' 时,把 13 和 5 从数组里取出来计算,然后把 13 和 5 删除并不计算结果放入到数组中 4 的后面。天哪,太复杂了,太笨拙了,我已经无法继续思考了😱

如果是使用栈来解决这个问题,一切就那么的简单而自然,使用 for 循环遍历数组,对每一个元素做如下操作:

  1. 如果元素不是 + - * / 中的某一个,就压入栈中
  2. 如果元素是 + - * / 中的某一个,则从栈里连续弹出两个元素,并对这两个元素进行计算,将计算结果压入栈中

for 循环结束之后,栈里只有一个元素,这个元素就是整个表达式的计算结果

实现代码

const cacl_inverse_polish_expression = (exp) => {
    const stack = new Stack();
  for (let i = 0; i < exp.length; i++) {
    const item = exp[i]
    if (['+', '-', '*', '/'].indexOf(item) >= 0) {
      // 从栈顶弹出两个元素
        const value_1 = stack.pop();
      const value_2 = stack.pop();
      // 拼成表达式
      const expStr = value_2 + item + value_1;
      // 计算结果并取整
      const result = parseInt(eval(expStr));
      // 将计算结果压入栈
      stack.push(result.toString());
    } else {
        stack.push(item);
    }
  }
  // 表达式如果是正确的,最终栈里只有一个元素,且就是表达式计算的结果
  return stack.pop();
}

const   exp_1   = ["4", "13", "5", "/", "+"];
const   exp_2   = ["10", "6", "9", "3", "+", "-11", "*", "/", "*", "17", "+", "5", "+"];
console.log(cacl_inverse_polish_expression(exp_1)); // 输出结果:6
console.log(cacl_inverse_polish_expression(exp_2)); // 输出结果:22

中缀表达式转后缀表达式

中缀表达式是人们常用的算术表示方法,例如 3 + 48 + 4 * 6 等都是中缀表达式,操作符以中缀的形式处于操作数中间。

后缀表达式也称作逆波兰表达式,通常是将运算符写在操作数之后,如将中缀表达式 3 + 4 写成后缀表达式就是 34+

下面,我们实现一个算法,将中缀表达式转换成后缀表达式

输⼊:["12","+",   "3"]
输出:["12","3","+"]

输⼊:["(","1","+","(","4","+","5","+","3",")","-","3",")","+","(","9","+","8",")"]
输出:[    '1',    '4',    '5',    '+',    '3',    '+',    '+',    '3',    '-',    '9',    '8',    '+',    '+' ]

输⼊:['(',    '1',    '+',    '(',    '4',    '+',    '5',    '+',    '3', ')',   '/',    '4',    '-',    '3',    ')',    '+',    '(',    '6',    '+',    '8',    ')',    '*',    '3']
输出:['1',    '4',    '5',    '+',    '3',    '+',    '4',    '/',    '+',    '3',    '-',    '6',    '8',    '+',    '3',    '*',    '+']

思路分析:

定义一个数组 postfixList ,用于存储后缀表达式,遍历中缀表达式,对每一个遍历到的元素做如下处理:

如果是数字,直接放入 postfixList 中

  1. 遇到左括号则入栈
  2. 遇到右括号,把栈顶元素弹出并放入到 postfixList 中,直到遇到左括号,最后弹出左括号
  3. 遇到运算符,把栈顶的运算符弹出,直到栈顶的运算符优先级低于当前运算符,把弹出的运算符加入到 postfixList 中,当前的运算符入栈
  4. for 循环结束后,栈里可能还有元素,都弹出放入到 postfixList 中

实现代码

// 定义运算符的优先级
const priorityMap = {
    "+": 1,
  "-": 1,
  "*": 2,
  "/": 2
}

const infixExpToPostfixExp = (exp) => {
    const stack =  new Stack();
  const postfixList = [];

  for (let i = 0; i < exp.length; i++) {
    const item = exp[i];

    if (!isNaN(item)) {
      // 如果是数字,直接放入到 postfixList 中
        postfixList.push(item);
    } else if (item === "(") {
      // 遇到左括号入栈
        stack.push(item);
    } else if (item === ")") {
      // 遇到右括号,把栈顶元素弹出,直到遇到左括号
        while(stack.peek() !== "(") {
        postfixList.push(stack.pop());
      }
      // 左括号出栈
      stack.pop();
    } else {
      // 遇到运算符,把栈顶的运算符弹出,直到栈顶的运算符优先级低于当前运算符
        while(!stack.isEmpty() 
            && ['+', '-', '*', '/'].indexOf(stack.peek()) >=0 
            && priorityMap[stack.peek() >= priorityMap[item]]) {
        // 把弹出的运算符加入到 postfixList 中
        postfixList.push(stack.pop());
      }
      // 当前运算符入栈
      stack.push(item);
    }
  }
  // for 循环结束后,栈里可能还有元素,都弹出放入到 postfixList 中
  while(!stack.isEmpty()) {
    postfixList.push(stack.pop())
  }

  return postfixList
} 

//  12+3
console.log(infixExpToPostfixExp(["12","+", "3"]))
//  2-3+2
console.log(infixExpToPostfixExp(["2","-", "3", "+", "2"]))
//  (1+(4+5+3)-3)+(9+8)
var exp = ["(","1","+","(","4","+","5","+","3",")","-","3",")","+","(","9","+","8",")"];
console.log(infixExpToPostfixExp(exp))
//  (1+(4+5+3)/4-3)+(6+8)*3
var exp = ['(', '1', '+', '(', '4', '+', '5', '+', '3', ')', '/', '4', '-', '3', ')', '+', '(', '6', '+', '8', ')', '*', '3']
console.log(infixExpToPostfixExp(exp))
console.log(infixExpToPostfixExp(["12","+", "3","*", "5"]))
console.log(infixExpToPostfixExp(["12","*", "3","+", "5"]))

参考资料:

书籍:《JavaScript数据结构与算法》