[路飞]前端算法——数据结构篇(二、队列): 常见的队列

127 阅读17分钟

队列是一种基本的数据结构,主要用于按照先进先出(FIFO)的原则存储和管理元素。根据队列的实现方式和特性,可以分为以下几种常见的队列:

  1. 普通队列(Array Queue): 普通队列是最基本的队列类型,通常使用数组(Array)来实现。普通队列支持在队尾添加元素(入队)和在队首删除元素(出队),并且遵循先进先出的原则。

  2. 循环队列(Circular Queue): 循环队列是对普通队列的一种优化,它通过循环利用数组空间来避免队列头部空间的浪费。循环队列可以在队尾入队和队首出队,并且在队尾和队首指针移动时通过取模操作来实现循环。

  3. 优先级队列(Priority Queue): 优先级队列是一种特殊的队列,它根据元素的优先级来确定出队顺序。具有更高优先级的元素会先出队,而具有相同优先级的元素则按照先进先出的原则出队。优先级队列通常使用堆(Heap)等数据结构来实现。

  4. 双端队列(Deque,Double-ended Queue): 双端队列是一种具有队列和栈的特性的数据结构,它允许在队头和队尾进行入队和出队操作。双端队列可以在一端进行入队操作,然后在另一端进行出队操作,也可以用作栈来实现先进后出(LIFO)的特性。

  5. 阻塞队列(Blocking Queue): 阻塞队列是在队列操作上增加了阻塞机制的一种队列类型。当队列为空时,尝试进行出队操作会被阻塞直到队列中有元素;当队列已满时,尝试进行入队操作会被阻塞直到队列有空间可用。阻塞队列通常用于多线程编程和并发控制。

这些是常见的几种队列类型,每种队列类型都有自己的特点和适用场景。选择合适的队列类型取决于具体的问题和需求,例如在性能要求高、优先级不同的任务管理中可以选择优先级队列,而在多线程环境中可以选择阻塞队列等。

一、普通队列

[路飞]前端算法——数据结构篇(二、队列): 初识队列

二、循环队列

[路飞]前端算法——数据结构篇(二、队列): 三种队列实现

简介

循环队列(Circular Queue),也称为环形队列或循环缓冲区,是一种特殊的队列数据结构,它通过循环利用数组空间来避免队列头部空间的浪费。循环队列具有固定大小,并且在队尾和队首指针移动时通过取模操作来实现循环。这使得循环队列在一定程度上具有环形的特性,可以有效地处理环形存储的数据。

循环队列主要有以下几个关键属性:

  1. 队列大小(Capacity):循环队列具有固定的队列大小,用于限制队列中可以存储的元素数量。

  2. 队首指针(Front):指向队列的第一个元素,即队列中最早进入的元素。

  3. 队尾指针(Rear):指向队列的最后一个元素的下一个位置,即下一个可以入队的位置。

  4. 循环指针:当队尾指针或队首指针达到队列边界时,通过取模操作使指针在队列中循环移动。

循环队列主要有两种常见的操作:入队(enqueue)和出队(dequeue)。在入队操作时,如果队列未满,则将元素插入到队尾,并将队尾指针后移;在出队操作时,如果队列非空,则将队首元素出队,并将队首指针后移。

实现

以下是一个使用数组实现的循环队列的示例代码:

class CircularQueue {
    constructor(capacity) {
        this.capacity = capacity;
        this.queue = new Array(capacity);
        this.front = 0; // 队首指针
        this.rear = 0;  // 队尾指针
        this.size = 0;  // 队列当前元素数量
    }

    enqueue(item) {
        if (this.isFull()) {
            throw new Error('Queue is full');
        }
        this.queue[this.rear] = item;
        this.rear = (this.rear + 1) % this.capacity; // 循环移动队尾指针
        this.size++;
    }

    dequeue() {
        if (this.isEmpty()) {
            throw new Error('Queue is empty');
        }
        const item = this.queue[this.front];
        this.front = (this.front + 1) % this.capacity; // 循环移动队首指针
        this.size--;
        return item;
    }

    isEmpty() {
        return this.size === 0;
    }

    isFull() {
        return this.size === this.capacity;
    }

    peek() {
        if (this.isEmpty()) {
            throw new Error('Queue is empty');
        }
        return this.queue[this.front];
    }

    print() {
        let current = this.front;
        let result = '';
        for (let i = 0; i < this.size; i++) {
            result += this.queue[current] + ' ';
            current = (current + 1) % this.capacity; // 循环移动指针
        }
        console.log(result);
    }
}

// 示例用法
const queue = new CircularQueue(5);
queue.enqueue('A');
queue.enqueue('B');
queue.enqueue('C');
queue.print(); // 输出:A B C
queue.dequeue();
queue.print(); // 输出:B C
queue.enqueue('D');
queue.enqueue('E');
queue.enqueue('F'); // 抛出异常:Queue is full

在这个示例中,我们实现了一个循环队列,通过数组存储队列元素,并通过取模操作实现循环移动队尾和队首指针。循环队列可以高效地利用数组空间,避免了普通队列因头部空间浪费而导致的空间利用率低下的问题。

使用场景

以下是一些常见的循环队列应用场景:

  1. 环形缓冲区: 循环队列常用于实现环形缓冲区,例如在音频处理、视频处理、数据传输等领域。环形缓冲区可以持续接收数据,并且在缓冲区满时循环覆盖最旧的数据,从而实现数据的连续流动和处理。

  2. 任务调度: 在操作系统中,循环队列可以用于实现任务调度的就绪队列(Ready Queue)。任务根据优先级入队,然后通过循环队列的方式进行调度执行,从而实现任务的优先级调度和资源分配。

  3. 循环缓存: 循环队列可以用作循环缓存,例如在网络数据传输中,循环缓存可以存储最近一段时间的数据包,然后按照先进先出的原则进行处理,保证数据的及时传输和处理。

  4. 事件循环: 在前端开发中,循环队列常用于实现事件循环(Event Loop),例如浏览器中的事件处理机制。事件按照发生顺序入队,然后通过循环队列的方式进行事件处理和响应。

  5. 轮询任务: 循环队列可以用于实现轮询任务,例如定时任务的轮询执行。任务按照时间顺序入队,然后通过循环队列定时执行,从而实现定时任务的调度和执行。

  6. 循环缓冲区的应用: 在多媒体领域,循环队列经常被用作音频、视频的缓冲区,这样可以连续播放音视频数据,不需要一次性加载全部数据,节省了内存和处理资源。

  7. 消息队列: 循环队列也可以用作消息队列的实现,例如在分布式系统中,消息队列可以用于不同节点之间的通信和数据传输。

三、优先队列

[路飞]前端算法——数据结构篇(三、树): 大顶堆与小顶堆

简介

优先队列(Priority Queue)是一种特殊的队列数据结构,它不同于普通队列的先进先出(FIFO)原则,而是根据元素的优先级确定出队顺序。优先队列中具有更高优先级的元素会先出队,而具有相同优先级的元素则按照先进先出的原则出队。

优先队列可以用于许多场景,例如任务调度、事件处理、最小堆、最大堆等。实现优先队列的方式有多种,常见的有以下几种:

  1. 使用堆实现: 最常见的实现方式是使用堆(Heap)数据结构来实现优先队列。在堆中,根据堆的性质(最小堆或最大堆),可以使得优先级高的元素在堆顶,从而实现优先队列的特性。

  2. 使用平衡二叉搜索树实现: 也可以使用平衡二叉搜索树(如红黑树)来实现优先队列。通过维护树结构的特性,可以使得具有较高优先级的元素在树的根部,从而实现优先队列的出队顺序。

  3. 使用数组和排序实现: 另一种简单的实现方式是使用数组存储元素,并在入队时根据优先级进行排序。这种方式的缺点是插入和删除元素的效率较低,适用于元素数量较少的情况。

实现

下面是一个使用最小堆(Min Heap)实现的优先队列的示例代码:

class PriorityQueue {
    constructor() {
        this.heap = [];
    }

    enqueue(item, priority) {
        const node = { item, priority };
        this.heap.push(node);
        this.heapifyUp();
    }

    dequeue() {
        if (this.isEmpty()) {
            return null;
        }

        const min = this.heap[0];
        const last = this.heap.pop();

        if (this.heap.length > 0) {
            this.heap[0] = last;
            this.heapifyDown();
        }

        return min.item;
    }

    heapifyUp() {
        let currentIndex = this.heap.length - 1;

        while (currentIndex > 0) {
            const parentIndex = Math.floor((currentIndex - 1) / 2);
            if (this.heap[currentIndex].priority < this.heap[parentIndex].priority) {
                [this.heap[currentIndex], this.heap[parentIndex]] = [this.heap[parentIndex], this.heap[currentIndex]];
                currentIndex = parentIndex;
            } else {
                break;
            }
        }
    }

    heapifyDown() {
        let currentIndex = 0;

        while (true) {
            let leftChildIndex = currentIndex * 2 + 1;
            let rightChildIndex = currentIndex * 2 + 2;
            let smallestIndex = currentIndex;

            if (leftChildIndex < this.heap.length && this.heap[leftChildIndex].priority < this.heap[smallestIndex].priority) {
                smallestIndex = leftChildIndex;
            }

            if (rightChildIndex < this.heap.length && this.heap[rightChildIndex].priority < this.heap[smallestIndex].priority) {
                smallestIndex = rightChildIndex;
            }

            if (smallestIndex !== currentIndex) {
                [this.heap[currentIndex], this.heap[smallestIndex]] = [this.heap[smallestIndex], this.heap[currentIndex]];
                currentIndex = smallestIndex;
            } else {
                break;
            }
        }
    }

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

// 示例用法
const priorityQueue = new PriorityQueue();
priorityQueue.enqueue('Task 1', 3);
priorityQueue.enqueue('Task 2', 1);
priorityQueue.enqueue('Task 3', 2);

console.log(priorityQueue.dequeue()); // 输出:Task 2(优先级最高)
console.log(priorityQueue.dequeue()); // 输出:Task 3
console.log(priorityQueue.dequeue()); // 输出:Task 1
console.log(priorityQueue.dequeue()); // 输出:null(队列为空)

在这个示例中,我们使用最小堆实现了优先队列,其中每个节点包含了元素和优先级信息。入队操作会将元素插入堆中并进行堆化操作,出队操作会删除堆顶元素并重新调整堆以维持堆的性质。这种优先队列的实现方式保证了高优先级元素先出队的特性。

使用场景

以下是一些常见的优先队列应用场景:

  1. 任务调度: 在操作系统中,优先队列常用于任务调度。任务可以根据优先级插入优先队列,然后调度器根据任务的优先级顺序执行,确保高优先级任务优先处理。

  2. 调度器: 在计算机系统中,调度器可以使用优先队列来调度进程或线程。高优先级的进程或线程可以更快地获取CPU资源,从而提高系统的响应速度和性能。

  3. 算法和数据结构: 在算法和数据结构中,优先队列常用于解决一些特定问题,如Dijkstra算法中的最短路径搜索、Prim算法中的最小生成树问题等。

  4. 事件处理: 在事件驱动的系统中,优先队列可以用于处理事件。事件按照优先级插入队列,然后按照优先级顺序进行处理,确保高优先级事件优先响应。

  5. 调度任务: 在分布式系统或并行计算中,优先队列可以用于调度任务。任务可以根据优先级插入队列,然后调度器按照优先级顺序分配资源执行任务。

  6. 缓存淘汰策略: 在缓存系统中,优先队列可以用于缓存淘汰策略,如LRU(Least Recently Used)策略。最近访问的数据优先保留在缓存中,而长时间未被访问的数据可以被淘汰。

  7. 系统调度: 在系统调度中,例如网络调度器或磁盘调度器,可以使用优先队列来调度任务或请求。高优先级的请求或任务可以优先获取资源或服务。

四、双端队列

简介

双端队列(Deque,全称Double-ended Queue)是一种具有队列和栈特性的数据结构,它支持在队头和队尾进行入队和出队操作。双端队列可以灵活地在两端进行操作,因此具有较高的灵活性和实用性。双端队列既可以作为队列使用(先进先出,FIFO),也可以作为栈使用(后进先出,LIFO),具有广泛的应用场景。

双端队列的常见操作包括:

  • 入队操作:可以在队头或队尾插入元素。
  • 出队操作:可以在队头或队尾删除元素。
  • 查看队头和队尾元素:获取队头和队尾元素的值但不删除。
  • 判断队列是否为空:判断双端队列中是否包含元素。
  • 获取队列大小:获取双端队列中元素的数量。

实现

以下是一个使用数组实现的双端队列的示例代码:

class Deque {
    constructor() {
        this.queue = [];
    }

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

    getSize() {
        return this.queue.length;
    }

    enqueueFront(item) {
        this.queue.unshift(item); // 在队头插入元素
    }

    enqueueRear(item) {
        this.queue.push(item); // 在队尾插入元素
    }

    dequeueFront() {
        if (this.isEmpty()) {
            throw new Error('Deque is empty');
        }
        return this.queue.shift(); // 在队头删除元素
    }

    dequeueRear() {
        if (this.isEmpty()) {
            throw new Error('Deque is empty');
        }
        return this.queue.pop(); // 在队尾删除元素
    }

    peekFront() {
        if (this.isEmpty()) {
            throw new Error('Deque is empty');
        }
        return this.queue[0]; // 获取队头元素
    }

    peekRear() {
        if (this.isEmpty()) {
            throw new Error('Deque is empty');
        }
        return this.queue[this.queue.length - 1]; // 获取队尾元素
    }

    print() {
        console.log(this.queue.join(' ')); // 打印双端队列元素
    }
}

// 示例用法
const deque = new Deque();
deque.enqueueFront('A');
deque.enqueueRear('B');
deque.enqueueFront('C');
deque.print(); // 输出:C A B
console.log(deque.peekFront()); // 输出:C
console.log(deque.peekRear()); // 输出:B
deque.dequeueFront();
deque.print(); // 输出:A B
deque.dequeueRear();
deque.print(); // 输出:A

在这个示例中,我们实现了一个双端队列,使用数组存储队列元素,并实现了双端队列的常用操作。通过双端队列,可以在队头和队尾灵活地插入和删除元素,使得数据结构更加灵活和适用于不同的应用场景。

应用场景

以下是一些常见的双端队列应用场景:

  1. 任务调度: 在多线程或并发编程中,双端队列可以用于任务调度。任务可以按照优先级插入队列的头部或尾部,然后由调度器从队列头部或尾部取出任务执行,实现任务的优先级调度。

  2. 缓存: 双端队列常用于实现缓存机制,例如LRU(Least Recently Used)缓存策略。最近访问的元素可以插入队列头部或尾部,而最久未使用的元素则在队列的另一端。当缓存空间不足时,可以从队列尾部删除元素。

  3. 窗口滑动问题: 在算法和数据结构中,双端队列常用于解决窗口滑动问题,如滑动窗口最大值。通过维护一个递减的双端队列,可以快速获取滑动窗口的最大值。

  4. 数据流处理: 在数据流处理中,双端队列可以用于实现滚动窗口、移动平均值等功能。数据流的新元素可以插入队列尾部,旧元素则从队列头部移除,从而实现对数据流的实时处理。

  5. 双端队列优先级队列: 双端队列也可以用作优先级队列的实现。根据任务的优先级将任务插入队列的头部或尾部,然后按照优先级从队列头部或尾部取出任务执行。

  6. 实现队列和栈: 双端队列本身就是同时具有队列和栈的特性,可以根据具体需求灵活地选择使用队头或队尾进行入队和出队操作,实现不同的数据结构功能。

五、阻塞队列

简介

阻塞队列(Blocking Queue)是一种特殊的队列数据结构,它在队列操作上增加了阻塞(Blocking)机制。当阻塞队列为空时,尝试进行出队操作的线程会被阻塞,直到队列中有元素可用;当阻塞队列已满时,尝试进行入队操作的线程会被阻塞,直到队列有空间可用。

阻塞队列通常用于多线程编程和并发控制,它可以有效地解决生产者-消费者模型中的同步和互斥问题,提供了一种线程安全的队列操作方式。阻塞队列可以有多种实现方式,常见的包括使用锁(Lock)和条件变量(Condition Variable)、使用信号量(Semaphore)等。

阻塞队列的常见操作包括:

  • 入队操作:如果队列已满,则入队操作会阻塞,直到队列有空间可用。
  • 出队操作:如果队列为空,则出队操作会阻塞,直到队列有元素可出队。
  • 阻塞超时:有些阻塞队列实现支持设置阻塞超时时间,在超时时间内未能进行入队或出队操作,则返回特定值或抛出异常。

实现

以下是一个简单的使用 Java 的阻塞队列实现的示例代码:

import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;

public class BlockingQueueExample {
    public static void main(String[] args) {
        BlockingQueue<Integer> queue = new ArrayBlockingQueue<>(5); // 创建容量为 5 的阻塞队列

        // 生产者线程
        Thread producer = new Thread(() -> {
            try {
                for (int i = 0; i < 10; i++) {
                    Thread.sleep(1000); // 模拟生产者生产数据的耗时
                    queue.put(i); // 阻塞入队操作,如果队列已满则阻塞
                    System.out.println("Produced: " + i);
                }
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        });

        // 消费者线程
        Thread consumer = new Thread(() -> {
            try {
                for (int i = 0; i < 10; i++) {
                    Thread.sleep(1500); // 模拟消费者消费数据的耗时
                    int value = queue.take(); // 阻塞出队操作,如果队列为空则阻塞
                    System.out.println("Consumed: " + value);
                }
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        });

        producer.start();
        consumer.start();
    }
}

在这个示例中,我们使用了 Java 中的 ArrayBlockingQueue 来实现阻塞队列。生产者线程不断地向队列中添加数据,如果队列已满则会阻塞;消费者线程不断地从队列中取出数据,如果队列为空则会阻塞。这种阻塞队列的机制可以有效地控制生产者和消费者之间的同步和互斥,避免了线程间的竞争和资源浪费。

应用场景

以下是一些常见的阻塞队列应用场景:

  1. 生产者-消费者模型: 阻塞队列常用于实现生产者-消费者模型。生产者线程向队列中生产数据,消费者线程从队列中消费数据。由于阻塞队列的阻塞特性,可以有效地控制生产者和消费者的速度,避免资源竞争和数据不一致问题。

  2. 线程池任务队列: 在线程池中,阻塞队列常用于存储待执行的任务。线程池的工作线程可以从阻塞队列中获取任务并执行,当队列为空时工作线程会阻塞,直到有新的任务可用。

  3. 并发控制: 在并发编程中,阻塞队列可以用于实现并发控制机制,如信号量(Semaphore)等。线程可以通过阻塞队列来等待资源的释放或信号的到达。

  4. 异步消息传递: 在异步消息传递系统中,阻塞队列可以用于消息的传递和调度。消息生产者将消息放入阻塞队列,消息消费者从队列中获取消息并进行处理。

  5. 日志处理: 在日志处理系统中,阻塞队列可以用于存储日志消息。日志生产者将日志消息放入队列,日志消费者从队列中获取日志消息并进行处理,可以实现异步日志处理。

  6. 任务调度器: 任务调度器可以使用阻塞队列来管理和调度任务。任务按照优先级或调度策略放入阻塞队列,调度器从队列中获取任务并执行,确保任务的顺序和调度。

  7. 网络通信: 在网络通信中,阻塞队列可以用于实现异步通信模型。网络请求的处理可以通过阻塞队列进行异步处理,提高系统的吞吐量和响应速度。

总体而言,阻塞队列在多线程编程、并发控制和异步处理等领域都有广泛的应用,能够有效地解决线程同步、资源竞争和任务调度等问题,提高系统的稳定性和性能。