每天一个数据结构(js 版本) - Stack (栈)

816 阅读11分钟

每天一个数据结构 - Stack (栈)

[TOC]

一、什么是 栈 ?

栈又名(堆栈), 是一种运算受限的线性表。 - 百度

上面的这个说法来源于百度百科, 简洁明了的一针见xue 。那什么是线性表为什么会运算受限呢?

  • 线性表(Linear List) :
    • n 个具有相同特性的数据元素的有序序列。
    • 线性表主要的表示方式 : 顺序表示链式表示
    • 特征 :
      • 集合必须存在唯一的一个 "第一元素"
      • 集合必须存在唯一的一个 "最后元素"
      • 除第一个元素外剩余元素均有唯一前驱
      • 除最后一个元素外剩余元素均有唯一后继
      • 均匀性 : 可以存储任意类型数据, 但同一线性表中的各元素数据类型与长度必须一致
      • 有序性 : 各数据元素在线性表中的位置只取决于他们的序号, 数据元素之前的相对位置是线性的
      • 链表存储结构分为 : 链式存储结构顺序存储结构
    • 用到线性表存储方式的数据结构通常是 : Stack(栈)Queue(队列)链表字符串
  • 运算受限 :
    • 运算受限是相对于不受限来说的, 实际上线性表就是一个运算不受限的数据结构, 换句话说就是首尾均可以进行操作, 并且更加灵活。但说栈结构是一个运算受限的线性表, 大意就是说 首先栈结构符合线性表的定义, 其次在运算操作的时候不能像线性表那样灵活, 栈结构由于其本身的定义(LIFO -> last in first out【后进先出 / 先进后出】)所以只能从首尾的一端进行操作, 而栈结构的操作区间就是栈顶

贴一个线性表示意图帮助理解 :

线性表.png

  • 其中图中的 "第一元素" 中的 "前驱" 概念是相对于 "2" 来说的, 意为 "2" 的前驱是 "1"。"3" 的前驱是 "2" ... "5" 的前驱是 "4"。
    图中 "最后元素" 中的 "后驱概念是相对于 "4" 来说的, 意为 "4" 的后驱是 "5"、"3" 的后驱是 "4" ... "1" 的后驱是 "2"。
  • 拿 "3" 来说其 直接前驱 是 "2", 其 前驱元素 为 "1" 和 "2"。其 直接后驱 是 "4", 其 后驱元素 是 "4"和"5"。
  • 图中直接也可体现同一出线性表的数据类型必须一致、长度一致、并且首尾均可操作的灵活性等特点, 简单的可以理解为, 用一根线儿顺序的将数据串联起来, 无论在两端的两头的哪一边均可取用操作。
  • 不论在集合中存储的方式是哪一种但是只要遵循上述原则的都是线性表(见下图)。
    101.png
  • 左边一眼看去绝对符合线性表的定义, 实际上这种将数据依次存储在连续的整块物理空间中, 这种数据数据结构称为 顺序存储结构
  • 右边一眼看去貌似不符合线性表的定义, 但实际上貌似乱序表面有 "一根线(在链表中的直接体现就是各个元素通过"指针"来指向另一个元素)" 将数据元素串联起来, 也即将数据分散存储在物理空间中, 通过一根线保存着他们之间的逻辑关系, 这种存储结构被称为链式存储结构

聊了半天线性表, 理解了 栈, 是一种运算受限的线性表 这个概念之后对下面理解与体验栈这个数据结构 有很大的帮助(继续说 Stack) :

栈的特点 :

  • 是一种运算受限的线性表。
  • 遵循 LIFO 原则(后进先出 / 先进后出)。
  • 内部存储结构遵循线性表中的顺序存储结构。
  • 栈中常说的栈顶是指 : 元素的活动区间也即栈的顶部, 相对的也就有栈底意为栈的底部
  • 我们将元素从栈顶放进栈中的这个过程称之为 : 进栈入栈压栈
    将元素从栈顶移出栈中的这个过程称之为 : 出栈退栈

所以说栈首先是一个线性表, 其次是栈的操作区间只能是栈顶就导致其运算受限。
尽管如此栈结构依然是处理某些场景下的问题的最好的数据结构, 想一想 : 一摞书(先放的在最底下, 后放的在最上面, 取书时是从上向下取)
手机 App 界面(从首页到详情页, 首页放到了栈底, 后点开的详情页被放到了栈顶, 点退出是先将栈顶的详情页移出然后才能看到首页)
浏览器的执行栈(方法执行形成私有方法的执行上下文, 如果没有其他变量引用则进栈执行完就释放(出栈), 反之则往栈底去放, 周而复始迎来下一个方法的私有执行上下文执行 ...)等。

-> 栈底放置的都是 "旧" 的元素, 栈顶放置的都是 "新" 的元素。

图解 :

101.png

二、手写一个 栈 !

提供的方法 :

  • push【从栈顶添加】
  • pop【从栈顶移除】
  • peek【返回一个栈顶元素】
  • size【返回栈的长度】
  • isEmpty【判空】
  • clear【清空栈结构】

代码使用 ES6 的 class 来写的, 并且使用了 # 修饰符来初始化实例不能被外界直接访问到的私有属性, # 的具体用法 -> class#私有方法和私有属性 - 阮一峰 如果你还不太熟悉 ES6 class 的用法, 请运行并自动编写实例来研究一下, 研究完毕请继续观看后面内容 :
一段 class 代码(弄懂它)再继续看 :

// 测试用例
class C {
    // 静态属性与方法
    static name = "XXY";
    static sayName() { console.log(C.name); };
    // 实例私有属性与方法
    #name;
    #sayHello;
    constructor(name, age) {
        // 实例私有属性(外界不可直接访问)
        this.#name = name;
        this.#sayHello = function() { console.log("Hello"); }
        // 实例私有属性(外界可直接访问)
        this.age = age;
    }

    // 原型属性与方法
    get hobby() { return "打代码"; } // 原型属性

    work() { // 原型方法
       console.log("work ...");
    }
    // setter 与 getter
    set name(val) {
        this.#name = val;            
    }
    get name() { return this.#name; }
}

此代码段没有编写测试代码, 读者可自行编写研究, 值得一提的是 : 使用 # 修饰的实例属性在外界是不能被访问到, 但是如果你编写过测试代码后你会发现你依然可以在 class 外部访问到 name 属性, 这其实是上面代码为这个"外界不可直接访问的实例的私有属性" 提供了 settergetter(就是代码中的 get name 与 set name 函数), 所以才可以在外界间接的访问, 如果不想让 class 外部访问某个使用 # 修饰的实例的属性则不提供 settergetter 即可。
如果你的浏览器或者 node 环境 不支持 # 自行升级版本即可(新的提案 - 兼容性应该不会好到哪里去, 但不过 js 第一次有了自己真正的实例私有属性, 是外界访问不到【除非提供了 setter 与 getter】的只能在 class 内部使用它😀【仔细研究你会发现新的东西 ...】)。

栈结构 - 代码(基于数组)

void (() => {
    /**
     * @author FruitJ
     * @version 1.0
     * @see https://github.com/FruitJ
     * @description Stack 类(基于数组)
     * @constructor 初始化 Stack 类的实例
     */
    class Stack { // 栈类
        #stack;
        constructor() { // 初始化
            this.#stack = [];
        }

        /**
         * @description 向 stack 结构的栈顶添加元素
         * @param  {...any} args - 接收任意类型的参数
         * @returns {number} length - 返回添加元素完毕后的 stack 结构的长度
         */
        push(...args) {
            this.#stack.push(...args);
            return this.size();
        }

        /**
         * @description 删除一个栈顶元素并返回
         * @returns {Array} this.stack.pop() - 返回 stack 结构中被删除的栈顶元素
         */
        pop() {
            return this.#stack.pop();
        }

        /**
         * @description 获取当前 stack 结构的长度
         * @returns {number} length - 返回添加元素完毕后的 stack 结构的长度
         */
        size() {
            return this.#stack.length;
        }

        /**
         * @description 判断当前 stack 结构是否为空
         * @returns {boolean} this.size() === 0 - 返回一个布尔值(true 代表不为空; false 代表为空)
         */
        isEmpty() {
            return this.size() === 0;
        }

        /**
         * @description 获取当前栈结构中的栈顶元素
         * @returns {any} this.stack[this.size() - 1] - 返回 stack 结构的栈顶元素
         */
        peek() {
            return this.#stack[this.size() - 1];
        }

        /**
         * @description 清空栈结构
         */
        clear() {
            this.#stack = [];
        }
    }

    let stack = new Stack();
    console.log(stack.push(1)); // 1
    console.log(stack.push(2)); // 2
    console.log(stack.push(3)); // 3
    console.log(stack); // Stack { }
    console.log(stack.isEmpty()); // false
    console.log(stack.pop()); // 3
    console.log(stack.size()); // 2
    console.log(stack.peek()); // 2
})();

栈结构 - 代码(基于对象)

void (function () {

    /**
     * @author FruitJ
     * @version 1.0
     * @see https://github.com/FruitJ
     * @description Stack 类(基于对象)
     * @constructor 初始化 Stack 类的实例
     */
    class Stack {
        #stack;
        #index;
        constructor() { // 初始化
            this.#stack = {};
            this.#index = 0;
        }

        /**
         * @description 向 stack 结构的栈顶添加元素
         * @param  {any} arg - 接收任意类型的参数
         * @returns {number} length - 返回添加元素完毕后的 stack 结构的长度
         */
        push(arg) {
            this.#stack[this.#index] = arg;
            this.#index++;
            return this.#index;
        }

        /**
         * @description 删除一个栈顶元素并返回
         * @returns {Array} this.stack.pop() - 返回 stack 结构中被删除的栈顶元素
         */
        pop() {
            if (this.isEmpty()) return;

            let temp = this.#stack[this.#index - 1];
            delete this.#stack[this.#index - 1];
            this.#index--;
            return temp;
        }

        /**
         * @description 获取当前 stack 结构的长度
         * @returns {number} length - 返回添加元素完毕后的 stack 结构的长度
         */
        size() {
            return this.#index;
        }

        /**
         * @description 判断当前 stack 结构是否为空
         * @returns {boolean} this.size() === 0 - 返回一个布尔值(true 代表不为空; false 代表为空)
         */
        isEmpty() {
            return this.size() === 0;
        }

        /**
         * @description 获取当前栈结构中的栈顶元素
         * @returns {any} this.#stack[this] - 返回 stack 结构的栈顶元素
         */
        peek() {
            return this.#stack[this.#index - 1];
        }

        /**
         * @description 清空栈结构
         */
        clear() {
            this.#stack = {};
            this.#index = 0;
        }
    }

    // 获取 Stack 类的实例 
    let stack = new Stack();
    console.log(stack.push("1")); // 1
    console.log(stack.push("2")); // 2
    console.log(stack.push("3")); // 3
    console.log(stack.pop()); // 3
    console.log(stack.isEmpty()); // false
    console.log(stack.size()); // 2
    console.dir(stack); // Stack {}
    console.log(stack.peek()); // 2
    
    stack.clear();
    console.log(stack.isEmpty()); // true
    console.log(stack.size()); // 0
})();
  • 阅读代码你会发现我们自己手动实现的栈结构整体上与栈的定义是一致的。
  • 有一个瑕疵点就是栈结构要求我们栈中的每个元素都必须是同一数据类型的, 但是 js 本身是弱类型语言, 所以这一点暂时还无法实现, ts 虽然可以定义数据类型但也只是在编写阶段起作用,没有改变 js 这个本质, 但是换个角度想一想, js 本身是追求灵活, 浏览器本身也追求高效和简洁, 这个小疙瘩也就微微释然了。
  • 此代码仅是简单的入个门体验一下栈结构的韵味 ...

三、用栈结构解决实际问题

例如 : 将十进制转化为 2 ~ 36 的任意一个进制数 这个其实使用 Number.prototype.toString 方法一行代码就可以搞定 :

console.log((10).toString(2)); // "1010"
console.log((10).toString(4)); // "22"
console.log((10).toString(8)); // "12"
console.log((10).toString(16)); // "a"
console.log((10).toString(36)); // "a"   

但是使用栈结构我们也可以简单的实现下 :
实现思路就是 : 不断的取余, 将余数放到我们刚写的栈结构中, 然后再将栈中的元素一项项取出, 因为栈结构是遵循 LIFO 原则, 所以不用刻意逆序, 按顺序取出来后本身就是逆序的, 然后用一个字符串拼接即可 :

<!DOCTYPE html>
<html>

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>1. 十进制转换为任意进制</title>
</head>

<body>

    <script src="../utils/utils.js"></script>
    <script>
        // 十进制转换为任意进制
        void(function (proto) {
            /**
             * @description 十进制转换为任意进制数
             * @param  {number} arg - 接收一个 number 类型的 [2 ,36] 区间的数值表示要转换到的进制位数
             * @this {Number} this - Number 包装类的实例
             * @returns {string} anyRadixStr - 返回进制转换后的结果
             */
            function toAnyRadix(base) {
                if(base < 2 || base > 36) return ''; // 处理不符合 2 ~ 36 的进制位
                let num = +this, // Number -> number
                    stack = new dSModule.Stack(), // 获取 Stack 实例
                    anyRadixStr = '', // 结果字符串
                    structure = '0123456789abcdefghijklmnopqrstuvwxyz'; // 各级进制位可取的进制的值
                while (num > 0) { // 不断取余扔进 Stack 实例中
                    let remainder = num % base;
                    stack.push(remainder);
                    num = Math.floor(num / base);
                }
                if (stack.isEmpty()) return; // 处理为空情况
                while (!stack.isEmpty()) { // 拼接结果
                    anyRadixStr += structure[stack.pop()];
                }
                return anyRadixStr;
            }
            proto.toAnyRadix = toAnyRadix;
        })(Number.prototype);
        console.log((10).toAnyRadix(2)); // "1010"
        console.log((10).toAnyRadix(3)); // "101"
        console.log((10).toAnyRadix(8)); // "12"
        console.log((10).toAnyRadix(10)); // "10"
        console.log((10).toAnyRadix(16)); // "a"
        console.log((10).toAnyRadix(17)); // "a"
        console.log((10).toAnyRadix(36)); // "a"

    </script>
</body>

</html>

此方法中有着诸多细节, 需要你略微的了解进制之间的那些事儿, 但有关进制转换这块笔者有篇文章可以助你脱困 进制转换 - 知乎

其实这个直接基于数组就能实现但是这段代码只是为了体验一下 Stack, 如果真涉及到进制转换的 直接 Number.prototype.toString 就好了呀 ...

这就是今天的数据结构, 本来打算只写 Stack 的没想到遇到了一些其他的定义也顺便查查写一写, 其实线性表的相关内容应该剥离出来的, 但笔者太懒了😂。

如有谬误还请各路大神路过点化一下子, 谢啦 !!!

四、参考连接 :