程序猿打野基本功之数据结构:队列

99 阅读9分钟

温故知新,程序猿打野基本功之数据结构~

开局一张图,其余全靠编😄。此篇的核心话题即是:队列(Queue)。 image-20220302094536267.png

基础

定义

队列是一种线性表,特殊的是只允许在头部添加删除元素,尾部添加元素

没有元素的队列称为空队。

特点

  • 先进先出(FIFO,First In First Out)。即先进入队列的元素优先出队。
  • 它是一种受限的数据结构,只允许头部删除,尾部添加。

方法

enqueue:在队列尾部添加一个元素。(后来的排列在队伍尾部)

dequeue:在队列首部删除一个元素。(通过安检,离开队伍)

head:返回头部元素,注意只是查看,不是删除。(瞅一眼,谁在第一位)

size:返回队列的大小。(看一眼队伍长度,多少人排队)

clear:清空队列。(打烊了,都散了吧)

isEmpty:判断队列是否为空。(瞅一眼,是不是有人排队)

重点方法是**enqueuedequeue**,为了加深理解,可以参照下方两图:

存储

image-20220227170911060.png

image-20220227170948954.png

以下为简述基于数组实现的顺序队列的存储结构。

对于队列的存储,如前面方法中的图解所示,有两个指针(这里记做头部head和尾部tail)初始时都指向索引为0的起始位置。image-20220227220801260.png

  • 当一个元素从尾部入队时,tail对应的索引值会增加一。譬如,向队列中推入字母A,尾指针tail则会在0的基础上加一,指向索引为1的位置。

image-20220227220944360.png 当一个元素从首部出队时,head对应的索引值会增加一。譬如,队列中弹出字母A,首指针head则会在0的基础上加一,指向索引为1的位置。

image-20220227221320999.png

实现

队列的实现有两种方式:基于数组与基于链表。

基于数组

鉴于数组一旦被定义就会被分配一片连续的存储空间,因此相比于链表,数组在性能上有天然的部分优势。

顺序队列

我们可以像基于数组实现栈那样轻松的实现队列。

class MyQueue {
    constructor() {
        this.queue = [];
    }
    //enqueue 入队
    enqueue = function (item) {
        this.queue.push(item);
    }
    //dequeue 出队
    dequeue = function () {
        return this.queue.shift();
    }
    head = function () {
        return this.queue[0]
    }
    size = function () {
        return this.queue.length;
    }
    clear = function () {
        this.queue = [];
    }
    isEmpty = function () {
        return this.queue.length === 0;
    }
}
exports.Queue = MyQueue;

上述代码实现的队列,我们称之为顺序队列,是队列最简单的形式。

通常情况下,使用基于数组的队列时,都会默认给一个初始长度。

对于顺序队列这种简单结构,我们设想一下这样一个情景:

当rear到达数组长度,也就是队列存储之外的位置时,此时队列已经无法容纳更多的元素入队了。这个时候空间都充满了元素了么?

其实并没有。因为队列是一种受限的数据结构,只能首部出队尾部入队,基于前文对于队列存储结构的了解,我们不难发现,head和tail两个标记都是只增不减,所以,最终两个指针都会到数组最后一个元素之外的索引位置,虽然此时队列是空的,但是已经无法再向这个队列添加任何元素了。

当队列无法再将元素入队时,称之为“上溢”。上溢分为两类,一类是上方所描述的场景,队列为空却无法添加元素,称之为伪上溢;另外一种是队列空间真的满了,无法再添加元素,称之为真上溢。与上溢相对应的,下溢是指当队列为空时执行了出队操作,此时无元素存在,自然不能执行出队操作。

对于伪上溢这种场景如何解决?第一种方案,简单粗暴--->队列中的所有元素均向低地址区移动,但是浪费性能。第二种方案就是接下来要说的循环队列。

循环队列

当顺序队列出现假上溢时,其实队列内还有空间,我们可以不用把标记指向队列外的地方,只需要把这个标记重新指向开始处就可以解决。想象一下这个数组首尾相接,成为一个圈。存储结构还是之前提到的,在同一个数组上。假设当前存储情况如下图所示:

image-20220227224117123.png 在向队列加入G、H时,则会如下图所示:

image-20220227224401724.png 通常,在对 head 或者 tail 加‘ 1’ 时,为了方便,可直接对数组长度取余,得到我们需要的索引值。由于顺序队列存在“假上溢”的问题,所以在实际使用过程中大部分都是使用循环队列来实现的。

但是呢,循环队列中会出现这样一种情况:当队列没有元素时,head 等于 tail,而当队列满了时,head 也等于 tail。为了区分这两种状态,一般在循环队列中规定队列的长度只能为数组总长度减 1,即有一个位置不放元素。因此,当 head 等于 tail 时,说明队列为空队,而当 head 等于(tail+1)%length 时(length 为数组长度),说明队满。

基于链表

基于链表的实现待后续链表部分实现。

单链队列

单链队列与单链表没有什么不同,只是用了两个指针,并且在初始化时让两个指针相等(此时,可认为创建了一个空的单链队列)。入队则修改尾指针的指向,出队则修改首指针指向。

注意⚠️:比较特殊的是当队列中最后一个元素被删后,队尾指针也丢失了。因此需要对队尾指针重新赋值(指向头结点)。

应用场景与案例

一般情况下,会将队列作为缓冲器或者解耦使用。譬如,电商app中的秒杀活动、发布订阅模式、http请求中的socket、消息队列、约瑟夫环等。

消息队列

队列最常见的场景就是消息队列。当不需要立即获得结果,但是并发量又需要进行控制的时候,差不多就是需要使用消息队列的时候,主要解决了应用耦合、异步处理、流量削锋等问题。

  • 应用耦合:多应用间通过消息队列对同一消息进行处理,避免调用接口失败导致整个过程失败;
  • 异步处理:多应用对消息队列中同一消息进行处理,应用间并发处理消息,相比串行处理,减少处理时间;
  • 限流削峰:广泛应用于秒杀或抢购活动中,避免流量过大导致应用系统挂掉的情况;
  • 消息驱动的系统:系统分为消息队列、消息生产者、消息消费者,生产者负责产生消息,消费者(可能有多个)负责对消息进行处理;

洪泛攻击

在前端中常提到的一个问题是从http输入到页面展示发生了什么这个问题中,一般会延伸的考虑到三次握手四次挥手,此时深层次的原因就是涉及到安全的问题之一:洪泛攻击。

分析

服务器资源的分配时在第二次握手的时候分配的,客户端资源的分配是在第三次握手时候分配的。所以服务器比较容易受到SYN洪泛攻击。SYN攻击就是客户端在短时间内杜撰大量不存在的IP地址,并向服务器不断发送SYN包,服务器在接收到请求后返回请求确认包,因为这个IP地址是不存在的,所以导致服务器等不到请求确认而不断重发直到超时,这会导致SYN包长时间占用半连接队列,导致正常的SYN包因为半连接队列满而丢弃,表现为网络阻塞甚至系统瘫痪。这是一种典型的DoS/DDoS攻击。洪泛攻击的本质就是不断的压榨队列的富裕空间,导致服务障碍。

检测

如果在服务器上看到大量半连接状态的请求,尤其是源IP地址随机,基本可以确定是SYN攻击。 Linux/Unix使用netstats命令来检测SYN攻击。 netstats -n -p TCP | grep SYN_RECV

处理

  • 缩短超时时间(SYN timeout)
  • 增大半连接队列
  • 过滤网关防护
  • SYN cookie技术。

约瑟夫环

问题描述:

17世纪的法国数学家加斯帕在《数目的游戏问题》中讲了这样一个故事:15个教徒和15 个非教徒在深海上遇险,必须将一半的人投入海中,其余的人才能幸免于难,于是想了一个办法:30个人围成一圆圈,从第一个人开始依次报数,每数到第九个人就将他扔入大海,如此循环进行直到仅余15个人为止。问怎样排法,才能使每次投入大海的都是非教徒。

进一步简化:N个人围成一圈,从第一个开始报数,第M个将被杀掉,最后剩下一个,其余人都将被杀掉。

再次简化这个问题为一个纯数学表示如下:

有一个数组长度为100的数组a,其内存放0-99。每隔两个数删除一个数字,到末尾时循环至开头继续执行,求最后一个被删除的数字。

如果使用数组可能就比较麻烦,要考虑如何到数组末尾之后如何再从头开始,而且考虑被删除的元素的位置。但是使用队列,这个问题就简单了多了:

  • 将这一百个数字放入到队列中,进行while循环,循环的终止条件是队列长度为一。
  • 从队列头部删除一个元素,index+1
  • 如果index % 3 == 0,说明当前读取到的这个元素是需要删除的元素;不等于零,意味着此时不需要删除,将其添加到队列的尾部即可。
  • 执行上述操作,直到剩余一个。

代码如下:

const Queue = require('./queue')
function del_ring(arr_list) {
​
    // 把数组里的元素都放入到队列中
    let queue = new Queue.Queue();
    for (let i = 0; i < arr_list.length; i++) {
        queue.enqueue(arr_list[i]);
    }
​
    let index = 0;
    while (queue.size() != 1) {
        // 弹出一个元素,判断是否需要删除
        const item = queue.dequeue();
        index += 1;
        // 每隔两个就要删除掉一个,那么不是被删除的元素就放回到队列尾部
        if (index % 3 != 0) {
            queue.enqueue(item);
        }
    }
    return queue.head();
};
​
​
let arr_list = [];
for (let i = 0; i < 100; i++) {
    arr_list.push(i);
}
​
​
console.log(del_ring(arr_list));