每天一个数据结构(js 版本) - Queue (队列) 与 Deque (双端队列)

1,264 阅读10分钟

每天一个数据结构 - Queue (队列) 与 Deque (双端队列)

[TOC]

一、Queue 队列

1. Queue 的特点

  • 栈(Stack) 一样, 队列也是一种操作受限的线性表【什么是线性表? 什么是操作受限? 什么是栈? 在笔者上一篇文章有详细论述】。
  • 进行删除操作的端称为: 队头、进行添加操作的端称为 队尾
  • 当一个元素被添加进队列当中的这个过程叫 入队, 反之则叫 出队
  • 队列关于操作受限的这个层面来说是因为队列本身是遵循 FIFO(先进先出/后进后出) 的原则, 这也就限制了队列在添加或删除元素的时候只能首尾两端分别进行。 图解 :

2. 形象的理解 Queue

  • 流着水的水管: 水流从一端流入, 从另一端留出。这个过程一定是先进入水管里面的那一撮水最先在另一端留出, 这实际上就是队列了。
  • 为了买卧铺票少遭点罪: 熬夜排队买车票: 一定是井然有序先到排队的人先买到票, 一定不是后到排队的人先买到票, 这个排队买票的队伍加上整体的行为也是队列。

3. 手写一个 Queue

手写 Queue 之前首先要明确的点 :

  • 涉及实现的方法 :
    • clear(): 清空队列
    • size(): 获取当前队列长度
    • isEmpty(): 判空
    • peek(): 返回队头元素
    • enqueue(val): 队尾 -> 添加元素
    • dequeue(): 队头 => 移除元素
  • 具体实现需要有 首、尾 两个 "指针", 来跟踪队头与队尾的变化情况,以保证对队列的操作都是正确的。
  • Queue 的代码实现如下 :
void (() => {
    /**
     * @author FruitJ
     * @version 1.0
     * @see https://github.com/FruitJ
     * @description Queue 类(基于对象)
     * @constructor 初始化 Queue 类的实例
     */
    class Queue {

        #queue; // 队列
        #endIndex; // 尾部 "指针" (追踪队尾的状态)
        #startendIndex; // 首部 "指针" (追踪队头的状态)
        constructor() {
            this.#queue = {};
            this.#endIndex = 0;
            this.#startendIndex = 0;
        }

        /**
         * @description 向 queue 的尾部追加一个元素
         * @param {any} arg - 接收一个任意类型参数 
         * @returns {number} this.#endIndex++ - 返回追加完后, 队列的长度 
         */
        enqueue(arg) {
            this.#queue[this.#endIndex] = arg;
            this.#endIndex++;
            return this.#endIndex;
        }

        /**
         * @description 从 queue 首部移除一个元素
         * @returns { any } res - 返回一个从 queue 首部移除的元素
         */
        dequeue() {
            if (this.isEmpty()) return;
            let res = this.#queue[this.#startendIndex];
            delete this.#queue[this.#startendIndex];
            this.#startendIndex++;
            return res;
        }

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

        /**
         * @returns {any} this.#queue[this.#endIndex] - 返回一个 queue 首部的元素
         */
        peek() {
            return this.#queue[this.#startendIndex];
        }

        /**
         * @description 获取当前 queue 长度
         * @returns {number} this.#endIndex - this.#startendIndex - 返回一个表示当前队列的长度的 number 类型值
         */
        size() {
            return this.#endIndex - this.#startendIndex; 
        }

        /**
         * @description 清空当前队列结构
         */
        clear() {
            this.#queue = {};
            this.#endIndex = 0;
            this.#startendIndex = 0;
        }        
    }

    // 创建队列实例
    let queue = new Queue();

    // 测试部分
    queue.enqueue(1);
    queue.enqueue(2);
    console.log(queue.enqueue(3)); // 3
    console.log(queue.size()); // 3
    console.log(queue.isEmpty()); // false
    console.log(queue.dequeue()); // 1
    console.log(queue.peek()); // 2
    queue.clear();
    console.log(queue.isEmpty()); // true
    console.log(queue.peek()); // undefined
})();

代码比较简单就不做赘述了(😁需要赘述一下 : 明确 startIndex、endIndex, 因为存在删除元素的情况所以 size 方法计算队列长度是 endIndex - startIndex)。

4. 使用 Queue 解决实际问题

  • 解决一个经典的游戏问题 - (击鼓传花)【实际上就是小时候咱们玩的: 老师在前面敲击数数, 数到几就停, 在同学们之间传递的物品被传递到了谁那谁就表演节目那个游戏是一样的, 只不过在这里是被传递到的那位同学出局而已, 因为最终要计算出一个胜利者
  • 代码实现思路就是:
    • 将人员信息录入进队列中
    • 不断迭代走游戏规则但队列长度保证最终为 1.
    • 迭代条件的设定就是最终留下一人
    • 在迭代的过程中通过循环队列的机制来进行人员的移动并最终移除队头的人员(被淘汰的人员), 这样队列长度在每轮循环后都会减 1, 从而得出最终结果。
  • 具体代码实现如下 :
<!DOCTYPE html>
<html>

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>击鼓传花</title>
</head>

<body>

    <script src="../utils/utils.js"></script>
    <script>
        // 击鼓传花
        function fn(items, num) {

            // 创建队列实例
            let queue = new dSModule.Queue();
            // 存储被淘汰的人员
            let dieOutArr = [];
            // 将人员信息录入至队列中
            items.forEach(item => {
                queue.enqueue(item);
            });
            // 击鼓传花
            while (queue.size() > 1) {
                for (let i = 0; i < num; i++) {
                    queue.enqueue(queue.dequeue()); // 从队尾添加同时从队首移除(循环队列), 移动 num - 1次【为了下面移除队首元素, 所以这里只移动重排了 num - 1 次】
                }
                dieOutArr.push(queue.dequeue()); // 移除队首元素
            }

            return {
                victory: queue.peek(), // 返回游戏胜利者
                defeat: dieOutArr, // 返回游戏设失败者集合
            };
        }
        console.log(fn(["John", "Jack", "Alice", "Blank", "Tom"], 7)); // {victory: "John", defeat: Array(4)}
    </script>
</body>

</html>

实际上还是和上一回 Stack 的案例一样, 在 js 中我们完全可以使用数组的一些方法来而不借助于队列完成这个游戏, 譬如说这个击鼓传花, 循环队列部分就可以换成数组的 shift 与 push 方法同样可以。
所以说此处只是借用 击鼓传花 这个游戏来体验以下队列的 FIFO 原则, 以及亲手验证咱们自己实现的 Queue。

二、Deque 双端队列

1. Deque 的特点

  • 双端队列是一种具有队列和栈的性质的数据结构, 可以同时在双端队列的两边进行 删除 / 添加 操作【白话概述: 当你拿到一个双端队列的时候你就可以为所欲为了, 拿他到栈用、当队列用都可以, 但是就双端队列而言如果仅仅是拿他做栈做队列那就有显得些麻烦😂】。
  • 双端队列是限定插入和删除操作在表的两端进行的线性表。

2. 形象的理解 Deque

  • 首先前面举得关于 Stack 的与 Queue 的生活中常见的实例在双端队列这都适用。
  • 还有一个场景比较适合解释双端队列: 你和你爱人去电影院买票, 如果赶上十一一定会看到这些场景:
    • 前面刚排队买完票的老哥突然走了没多远又回到队伍首部去询问售票员一些信息。
    • 队伍后面有对儿情侣刚在队伍尾部排上队发现前面队伍太长于是放弃观看直接离开了。
    • 好不容易排到你和你爱人前面仅剩一个人了就要轮到你俩了, 突然前面那位老哥他远方表哥来插队 ...
    • 这在电影院中发生的一切的一切, 都说明这体现了一个双端队列的工作流程(桥段太多, 哈哈哈哈嗝 ...)。 图解 :

3. 手写一个 Deque

手写 Queue 之前首先要明确的点 :

  • 涉及实现的方法 :
    • clear(): 清空队列
    • size(): 获取当前队列长度
    • isEmpty(): 判空
    • peekStart(): 返回队头元素
    • peekEnd(): 返回队尾元素
    • addFront(val): 队头 -> 添加元素
    • addBack(val): 队尾 -> 添加元素
    • rmFront(): 队头 => 移除元素
    • rmBack(): 队尾 => 移除元素
  • 具体实现需要有 首、尾 两个 "指针", 来跟踪队头与队尾的变化情况,以保证对队列的操作都是正确的。
  • 代码实现如下 :
// 双端队列
/**
 * @author FruitJ
 * @version v1.0
 * @see https://github.com/FruitJ
 * @description Deque 类(基于对象)
 * @constructor 初始化 Deque 类的实例
 */
class Deque {

    #startIndex; // 首项索引
    #endIndex; // 尾项索引
    #deque; // // "双端队列实例"
    constructor() { // 初始化 "双端队列实例"
        this.#startIndex = 0;
        this.#endIndex = 0;
        this.#deque = {};
    }

    /**
     * @name addFront
     * @description 向双端队列的首部添加元素
     * @param {any} val - 一个任意类型参数
     * @returns {number} this.size() - 返回当前双端队列的长度
     */
    addFront(val) {
        if (this.isEmpty()) { // 当前 deque 中无元素, 在前后添加都无所谓
            this.addBack(val); // 向后添加
            return this.size();
        }
        this.#endIndex--;
        this.#deque[this.#endIndex] = val;
        return this.size();
    }
    /**
     * @name addBack
     * @description 向队列首部添加一个元素
     * @param {any} val - 接收做个任意类型值 
     * @returns {number} this.size() - 返回添加元素后的队列长度
     */
    addBack(val) {
        this.#deque[this.#startIndex] = val;
        this.#startIndex++;
        return this.size();
    }
    /**
      * @name rmFront
      * @description 向队列首部移除一个元素
      * @returns {any} temp - 返回被移除的首部元素
      */
    rmFront() {
        if (this.isEmpty()) return;
        let temp = this.#deque[this.#endIndex];
        delete this.#deque[this.#endIndex];
        this.#endIndex++;
        return temp;
    }
    /**
      * @name rmBack
      * @description 向队列尾部移除一个元素
      * @returns {any} temp - 返回被移除的尾部元素
      */
    rmBack() {
        if (this.isEmpty()) return;
        let temp = this.#deque[this.#startIndex - 1];
        delete this.#deque[this.#startIndex - 1];
        this.#startIndex--;
        return temp;
    }

    /**
     * @name peekStart
     * @description 返回一个队头元素
     * @returns {any} this.#deque[this.#endIndex] - 返回一个队头元素
     */
    peekStart() {
        return this.#deque[this.#endIndex];
    }

    /**
     * @name peekEnd
     * @description 返回一个队头元素
     * @returns {any} this.#deque[this.#startIndex - 1] - 返回一个队尾元素
     */
    peekEnd() {
        return this.#deque[this.#startIndex - 1];
    }

    /**
     * @description 获取当前 queue 长度
     * @returns {number} this.#startIndex - this.#endIndex - 返回一个表示当前队列的长度的 number 类型值
     */
    size() {
        return this.#startIndex - this.#endIndex;
    }
    /**
     * @description 判断 deque 结构是否为空
     * @returns {boolean} this.size() === 0 - 返回一个布尔值, false 代表不为空 
     */
    isEmpty() {
        return this.size() === 0;
    }
    /**
     * @description 清空当前队列结构
     */
    clear() {
        this.#startIndex = 0;
        this.#endIndex = 0;
        this.#deque = {};
    }
}
// 创建实例
let deque = new Deque();
console.log(deque.addFront(1)); // 1
console.log(deque.size()); // 1
console.log(deque.addBack(2)); // 2
console.log(deque.isEmpty()); // false
console.log(deque.addFront(-1)); // 3
console.log(deque.peekStart()); // -1
console.log(deque.rmFront()); // -1
console.log(deque.rmBack()); // 2
console.log(deque.peekEnd()); // 1
console.log(deque.isEmpty()); // false
deque.clear();
console.log(deque.isEmpty()); // true

代码比较简单也就不赘述了(与前面的 Queue 大体相似) : 唯一要说的就是在 addFront、rmFront、rmBack 的时候要注意考虑多种情况以及索引问题, 本代码的设计思路就是 addFront 的时候, 如果此时 Deque 中没有元素则直接向后添加, 如果已经有元素, 直接 -- 取负数作为 索引 / key。

4. 使用 Deque 解决实际问题

  • 检测回文字符串 : 检测类似 moom 这种两边相同的这种字符串。
  • 实现思路 :
    • 将字符串按照一项项存储到 deque 实例中。
    • 设置一个终止标记 isEqual 用来终止循环, 标记含义为判断两数是否相等。
    • 通过 while 循环不断取出首项与尾项进行对比, 如遇到不相等时直接更新标记为 false 跳出循环。
  • 具体代码实现如下 :
<!DOCTYPE html>
<html>

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>回文字符串</title>
</head>

<body>

    <script src="../utils/utils.js"></script>
    <script>
        function fn(str) {

            // 格式化字符串
            str = str.toLowerCase().split(" ").join("");
            // 获取(Deque)双端队列实例
            let deque = new dSModule.Deque(),
                isEqual = true, // 终止标记
                front = "", // 首项
                back = ""; // 尾项
            [].forEach.call(str, (item, index) => { // 将字符串存储进 deque 中
                deque.addBack(item);
            })
            while(deque.size() > 1 && isEqual) { // deque 大于 1 才有必要启动循环且两数不相等时直接终止循环
                front = deque.rmFront(); // 取首项
                back = deque.rmBack(); // 取尾项
                if(front !== back) isEqual = false; // 比对
            }
            return isEqual;
        }

        console.log(fn("moom")); // true
        console.log(fn("Jj")); // true
        console.log(fn("J")); // true
        console.log(fn("level")); // true
        console.log(fn("my name is apple elppa si eman ym")); // true
        
        console.log(fn("moon")); // false
        console.log(fn("as")); // false
    </script>
</body>

</html>
  • 这个与 Stack 与 Queue 一样通过数组(pop 与 shift)就能实现, 但是笔者只是想借助此案例体验一下 Deque 这个数据结构的特点。
  • 代码块中出现 utils.js 字样的地方实际上是笔者将每次贴出的每种数据结构仍在了一个文件里了 用的时候直接引进来直接用。
  • 本文有不足指出或是谬误地方还请指出, 谢谢 !

三、参考链接