图解数据结构js篇-栈结构(Stack)

1,487 阅读7分钟

栈(Stack)又名堆栈,它是一种运算受限的 线性表。限定仅在表尾进行插入和删除操作的线性表。这一端被称为 栈顶,相对地,把另一端称为 栈底。向一个栈插入新元素又称作进栈入栈压栈 ,它是把新元素放到栈顶元素的上面,使之成为新的栈顶元素;从一个栈删除元素又称作 出栈退栈,它是把栈顶元素删除掉,使其相邻的元素成为新的栈顶元素。

栈是一种具有 「先入后出」 特点的抽象数据结构,可使用数组或链表实现。使用数组实现的栈也成为 顺序栈,使用链表实现的栈成为 链栈

「先入后出」 可以用手枪弹夹比喻,弹夹中最先放入的子弹最后才会打出去,而最后放入的子弹第一枪就打出去了。

压栈 方法名一般叫做 push出栈 方法名一般叫做 pop

具体使用什么方法名完全取决于你自己,但是为了规范,我们都会统一使用 pushpop

我们先来画图看看入栈和出栈的过程

stack.push(1); // 元素 1 入栈
stack.push(2); // 元素 2 入栈
stack.pop();   // 出栈 -> 元素 2
stack.pop();   // 出栈 -> 元素 1

image.png

没什么问题,这看起来非常好理解。所谓先入后出就像你上班坐公交车一样,先上车的乘客司机总会让你往里走走,因为里面 “还有位置”,但是下车时总是后上车的乘客先下车,因为离车门近,反而先上车的乘客因为在里面的“位置上坐着”,离车门远,总是后下车。

顺序栈和链栈的优缺点

上面有提到过,栈可使用数组或链表实现。使用数组实现的栈也称为 循序栈,使用链表实现的栈称为 链栈

但是这两种栈的优缺点是什么,我们应该如何选择呢?首先我们需要知道数组和链表的优缺点

对数组和链表不了解的请看:

图解数据结构js篇-数组结构(Array)

图解数据结构js篇-链表结构(Linked-list)

数组的优缺点:

优点: 数组拥有非常高效的随机访问能力,只要给出下标,就可以用常量时间找到对应元素。

缺点: 数组的劣势体现在插入和删除元素方面。由于数组元素连续紧密地存储在内存中,插入、删除元素都会导致大量元素被迫移动或者重新开辟内存扩容,影响效率。

总结: 数组所适合的是读操作多、写操作少的场景!

链表的优缺点:

优点:插入和删除速度快,保留原有的逻辑顺序,在插入或者删除一个元素的时候,只需要改变指针指向即可。没有空间限制,存储元素无上限,只与内存空间大小有关.

缺点:查找速度比较慢,因为在查找时,需要循环遍历链表。

总结:链表适合的是读操作少、写操作多的场景!

栈的特点:

  • 元素数量不固定
  • 对线性表尾部有频繁的插入和删除操作(push和pop)

无论从那个方面看,使用链表来实现栈都是一个更好的选择,但是如果我们能避开数组的缺点来使用栈的话,数组的顺序存储也带来不少好处。

js数组也已经包含了符合栈的 push 和 pop 方法,所以js中也经常使用数组来当做栈使用,这样就不用自己实现了。但是因为v8数组的扩容收缩机制,性能可能不是十分完美

对v8数组扩容收缩机制不了解的同学可以看看我之前的文章:图解数据结构js篇-数组结构(Array)深入V8 - js数组的内存是如何分配的

顺序栈

顺序栈使用数组来实现,将数组的第一项作为栈低,然后维护一个指针来表示栈顶。由于数组的缺点我们应该尽量使用固定的栈大小,也就是限制栈用元素的数量。下图是一个大小为5的栈。

image.png

当栈顶指针为 -1 时表示栈中没有元素,即 空栈

我们来看一下简单的模拟实现

// stack.js
export class Stack {
    constructor(length) {
        this._stack = Array(length);
        this._maxLength = length;
        this._stackTop = -1;
    }

    push(data){
        // 判断是否栈满
        if(this._stackTop === this._maxLength-1){
            return false;
        }

        this._stack[++this._stackTop] = data
        return true
    }

    pop(){
        // 判断是否栈空
        if(this.isEmpty()){
            return null;
        }

        // 可以不必清空出栈的元素,栈顶指针移动即可。下次入栈会自动覆盖
        return this._stack[this._stackTop--];
    }
    
    isEmpty(){
        return this._stackTop === -1;
    }

    toString(){
        return `Stack(${this._stack.slice(0, this.stackTop+1).toString()})`
    }
}

拓展-共享栈

我们先来看一个场景

image.png

现在有两个容量为5的栈,其中 栈1 已经满栈,栈2 只有一个元素。现在想要向 栈1 中 push 新的元素2,由于 栈1 已经满栈会导致入栈操作失败,但是 栈2 却还有很多空位没有元素使用。

这就很气人,内存空间得不到很好的利用。所以人们就想出来一种方法,那就是让两个栈共享一个内存空间,从而让空间得到更好的利用。这种栈也称为 共享栈

共享栈是将数组两两端分别作为两个栈的栈低,然后分别使用两个指针指向两个栈的栈顶

image.png

共享栈一般在算法中用得比较少,但是对于操作系统或者硬件底层很常见,因为他们的资源非常有限,需要共享栈来优化资源的利用率

链栈

使用链表实现栈有一个问题: 使用链表的头指针还是尾指针作为栈顶?

因为栈操作都是从栈顶操作,栈底就不需要考虑指针了,现在我们只需要在头指针和尾指针中选择一个作为栈顶即可。

选择尾指针:

image.png

如果选择尾指针作为栈顶,那么当我们入栈时相当于尾插,只需要将 rear.next = node; rear=node 即可。借助尾指针我们可以很方便的在链表尾部添加节点。

但是我们出栈时需要执行尾删操作,对于单向链表来说,尾删需要把尾指针指向前一个元素,而获取前一个元素的唯一方法就是从头指针开始遍历,因为单向链表无法逆向查找。但是遍历会带来较大的时间消耗。

选择头指针:

image.png

入栈相当于头插,只需要 node.next = head; head = node;,出栈相当于头删,只需要 head = head.next;。可以看出单向链表的头插和头删都是非常快速的。

相比之下,我们可以看出使用头指针作为栈顶更好。所以我们选择使用头指针作为栈顶,借助头指针我们可以很快很方便的完成头插和头删操作。

// stack.js
import { LinkedListNode, LinkedList } from './linkedList.js'
// 链表实现的栈
export class LinkedStack {
    constructor(){
        this._linkedList = new LinkedList();
    }
    // 入栈
    push(data){
        return this._linkedList.insertInHead(data);
    }

    // 出栈
    pop(){
        return this._linkedList.remove(0);
    }

    isEmpty() {
        return this._linkedList.isEmpty();
    }

    top() {
        return this._linkedList.get(0);
    }

    toString(){
        let reslut = []
        let cur = this._linkedList.head;
        while (cur = cur.next){
            reslut.push(cur.toString())
        }
        return `Stack(${reslut.toString()})`;
    }
}

栈的实现很简单,如果对链表的操作和原理不熟悉,可以看我的另一篇文章图解数据结构js篇-链表结构(Linked-list)

常用的实现方法

如果你的项目对数据结构没有特别高的要求,一般我们会借助js数组已有的一些Api方法来编写一个栈。但是其逻辑还是数组。

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

    push(value) {
        return this._arr.push(value);
    }

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

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

    top() {
        return this._arr[this._arr.length - 1];
    }

    toString() {
        return `Stack(${this._arr.toString()})`
    }
}

leetcode 实战

弄明白了栈的实现和原理,我们还要知道什么时候改使用栈,如何用栈结构来解决问题。

这里举例两 leetcode 上几个典型的题目

难度:一个简单一个中等(为什么没有困难的?因为困难的我也搞不定)。

以下题目都是面试出现记录很高的题

20. 有效的括号

给定一个只包括 '(',')','{','}','[',']' 的字符串 s ,判断字符串是否有效。

有效字符串需满足:

  • 左括号必须用相同类型的右括号闭合。
  • 左括号必须以正确的顺序闭合。
示例 1:
输入:s = "()"
输出:true 

示例 2:
输入:s = "()[]{}"
输出:true
示例 3:

输入:s = "(]"
输出:false
示例 4:

输入:s = "([)]"
输出:false
示例 5:

输入:s = "{[]}"
输出:true

提示:

1 <= s.length <= 104, s 仅由括号 '()[]{}' 组成

符号成对匹配和后缀表达式之类的题型可以首先考虑栈,例如本题,每当遇到 ({[ 就入栈,遇到 )}] 就入栈顶出栈的元素,如果成对则继续,不成对则返回失败;

/**
 * @param {string} s
 * @return {boolean}
 */
var isValid = function(s) {
    // js中未提供Stack结构,需要自己将上面的Stack实现复制过来。篇幅原因,这里就不复制了
    let stack = new Stack()

    for(let char of s){
        // 左括号入栈
        if('({['.includes(char)){
            stack.push(char)
        }else {
            // 出栈左括号与右括号匹配,如果不匹配或者为空则说明括号无效
            switch (stack.pop()){
                case '(':
                    if(char !== ')') return false;
                    break;
                case '{':
                    if(char !== '}') return false;
                    break;
                case '[':
                    if(char !== ']') return false;
                    break;
                // 为空
                default: return false;
            }
        }
    }

    // 如果栈为空,则说明所有括号都匹配,返回true,否则返回false
    return !stack.pop()
};

143. 字符串解码

给定一个经过编码的字符串,返回它解码后的字符串。

编码规则为: k[encoded_string],表示其中方括号内部的 encoded_string 正好重复 k 次。注意 k 保证为正整数。

你可以认为输入字符串总是有效的;输入字符串中没有额外的空格,且输入的方括号总是符合格式要求的。

此外,你可以认为原始数据不包含数字,所有的数字只表示重复的次数 k ,例如不会出现像 3a 或 2[4] 的输入。

示例 1:

输入:s = "3[a]2[bc]"
输出:"aaabcbc"
示例 2:

输入:s = "3[a2[c]]"
输出:"accaccacc"
示例 3:

输入:s = "2[abc]3[cd]ef"
输出:"abcabccdcdcdef"
示例 4:

输入:s = "abc3[cd]xyz"
输出:"abccdcdcdxyz"
  1. 本题核心思路是在栈里面每次存储两个信息, (左括号前的字符串, 左括号前的数字), 比如3[a2[c]], 当遇到第一个左括号的时候,压入栈中的是("", 3), 然后遍历括号里面的字符串a2[c], 遇到第二个左括号,压入栈中的是("a", 2),然后继续遍历后面,遇到右括号则出栈,得到新字符串 str = t[0] + str.repeat(t[1]) 也就是 acc,然后遇到第二个右括号继续出栈,得到 accaccacc
  2. 凡是遇到左括号就进行压栈处理,遇到右括号就弹出栈,栈中记录的元素很重要。
function decodeString(s){
    let stack = new Stack()
    //记录字符
    let str = '';
    // 记录数字
    let num = '';


    for(let char of s){
        // 遇到[,入栈
        if(char === '['){
            stack.push([str, num]);
            // 重置
            str = ''
            num = ''
        }
        else if(char === ']'){
            // 遇到右括号出栈,然后拼接结果
            let t = stack.pop();
            str = t[0] + str.repeat(+t[1]);
        }
        else if(char >= '0' && char <= '9'){
            // 遇到数字,记录
            num += char;
        }else {
            // 遇到字符,记录
            str += char;
        }
    }

    return str;
}