队列是一种常见的线性表数据结构,它的典型特征是先进先出。即先入队列的先出队,后入队列的后出队。
队列有两个基本操作:入队(enqueue),即将一个数据放入到队列的尾部,出队(dequeque),即将一个数据从对头移除。
队列通常应用在资源有限的场景下,比如线程池、数据库连接池等。当线程池没有空闲线程时,新的任务请求线程资源时,将请求排队,等到有空闲线程时,取出排队的请求继续处理。
队列根据其中容纳的数据个数,可以分为无界队列(unbounded queue)和有界队列(bounded queue)。无界队列中可以存放任意多个数据,有界队列中可以放得数据个数是有限的。队列大小要设置的合理,队列太大导致等待的请求太多,队列太小会导致无法充分利用系统资源。
队列的数据结构可以用下图表示
队列可以用数组实现,也可以用链表实现。用数组实现的队列是顺序队列,用链表实现的队列是链式队列。
1.用Python列表实现队列
下面的代码是用Python的列表实现的队列。
class ArrayQueue:
"""
用Python列表实现,有限长度的队列,capacity是队列长度。
"""
def __init__(self, capacity: int):
self._items = []
self._capacity = capacity
self._head = 0
self._tail = 0
def enqueue(self, data):
if self._tail == self._capacity-1: # tail到达最后面了
if self._head == 0: # 没有空间了
return False
else: # 还有空间
for i in range(0, self._tail - self._head):
self._items[i] = self._items[i + self._head] # 数据搬移
self._tail = self._tail - self._head
self._head = 0
self._items.insert(self._tail, data) # 插入数据
self._tail += 1 # 移动tail指针
return True
def dequeue(self):
if self._head != self._tail: # 队列不为空
item = self._items[self._head]
self._head += 1
return item
else:
return None
def __repr__(self):
return str(self._items[self._head: self._tail])
if __name__ == '__main__':
aq = ArrayQueue(5)
aq.enqueue("1")
aq.enqueue("2")
aq.enqueue("3")
aq.dequeue()
aq.enqueue("4")
aq.enqueue("5")
print(aq)
aq.dequeue()
print(aq)
aq.dequeue()
print(aq)
aq.enqueue("6")
aq.enqueue("7")
aq.enqueue("8")
aq.enqueue("9")
print(aq)
2.深入理解队列的内部原理
下面我们看看队列的入队和出队的工作原理。
对队列的操作,需要两个指针,一个head指向队头,一个tail指向队尾。当入队的时候,tail指针往后移动一位,当出队时,head指针往后移动一位。随着不断地入队和出队,head指针和tail指针持续后移。对于有界队列,当tail移动到最右边,即使队列中还有空闲空间,此时也不能入队了。当队列tail到达最右边后,此时可以触发一次数据的搬移,将head和tail之间的数据都往左搬移到0~tail-head之间,再重置head和tail指针的指向。
下面代码描述了tail到达队尾时,入队操作前,进行数据搬移的过程可以参考上面一节入队的部分代码。
3. 用链表实现队列
用链表实现队列,也需要维护两个指针,一个是head指针指,一个是tail指针。head指针指向队列的队头元素,tail指针指向队列的队尾元素。
对于入队操作,需要先将新结点接到队尾(tail.next=new_node),再将队尾指针(tail=new_node)指向新的结点。
对于出队操作,返回队头结点的数据(head.data),再将队头指针往后移动一位(head=head.next)。
对于入队和出队操作,要考虑到队为空的情况。
下面是代码实现:
class Node:
"""链表的Node结点"""
def __init__(self, data: int, next_node=None):
"""
:param data: 存储的数据
:param next_node: 下一个Node节点的地址
"""
self.data = data
self.next = next_node
class LinkedListQueue:
"""
https://www.geeksforgeeks.org/queue-linked-list-implementation/
"""
def __init__(self):
self._head = self._tail = None # 队头指针,队尾指针初始化为None
def enqueue(self, value):
new_node = Node(value)
if self._tail: # 队不是空
self._tail.next = new_node # 队尾元素的next指向新结点
else: # 队是空
self._head = new_node # 队头指向新结点
self._tail = new_node # 移动尾指针
def dequeue(self):
if self._head: # 队不为空
value = self._head.data
self._head = self._head.next
if not self._head:
self._tail = None
return value
def __repr__(self):
values = []
p = self._head
if not p:
return "->"
while p:
values.append(p.data)
p = p.next
return "->".join(value for value in values)
if __name__ == '__main__':
aq = LinkedListQueue()
aq.enqueue("1")
aq.enqueue("2")
aq.enqueue("3")
aq.dequeue()
aq.enqueue("4")
aq.enqueue("5")
print(aq)
aq.dequeue()
print(aq)
aq.dequeue()
print(aq)
aq.enqueue("6")
aq.enqueue("7")
aq.enqueue("8")
aq.enqueue("9")
print(aq)
4. 循环队列
用数组来实现队列的时候,在 tail==n 时,会有数据搬移操作,这样入队操作性能就会受到影响。那有没有办法能够避免数据搬移呢?循环队列应用而生。
循环队列长成下面的这个样子。
图中这个队列的大小为 8,当前 head=4,tail=7。如果这时,依次将在 a,b 入队,循环队列中的元素就变成了下面的样子:
可见,tail指针是在环上移动。当tail到达size-1的下标时,tail指针移动到下标为0的位置。通过这样的方法,我们成功避免了数据搬移操作。
在用数组实现的非循环队列中,队满的判断条件是 tail==capacity-1 并且head=0, 队空的判断条件时head=tail。那针对循环队列,如何判断队空和队满呢?
下面是一张队满的图。
可以看到队满的时候,(tail+1)%size=head。而队空的判断条件,肯容易想到是head = tail = None。
class CircularQueue:
def __init__(self, capacity):
self._capacity = capacity + 1
self._items = [None] * self._capacity # 用None占位
self._head = 0
self._tail = 0
def enqueue(self, item: str) -> bool:
if (self._tail + 1) % self._capacity == self._head:
return False
self._items[self._tail] = item
self._tail = (self._tail + 1) % self._capacity
return True
def dequeue(self) -> Optional[str]:
if self._head != self._tail:
item = self._items[self._head]
self._head = (self._head + 1) % self._capacity
return item
def __repr__(self) -> str:
if self._tail >= self._head:
return " ".join(item for item in self._items[self._head: self._tail])
else:
return " ".join(item for item in chain(self._items[self._head:], self._items[:self._tail]))
if __name__ == '__main__':
aq = CircularQueue1(5)
aq.enqueue("1")
aq.enqueue("2")
aq.enqueue("3")
p = aq.dequeue()
print(aq)
aq.enqueue("4")
aq.enqueue("5")
print(aq)
aq.dequeue()
print(aq)
aq.dequeue()
print(aq)
aq.enqueue("6")
aq.enqueue("7")
aq.enqueue("8")
aq.enqueue("9")
print(aq)
5.Python中的queue模块
平时的业务开发不大可能从零实现一个队列,而是使用编程语言中线程的队列库来解决实际问题。
Python提供了现成的模块queue,这个模块提供了三种常用的队列,分别是:
- 先进先出(FIFO)队列Queue
- 后进先出(LIFO)队列LifoQueue
- 优先级队列PriorityQueue
另外,collections模块提供了双端队列deque。可以在队列两端进行入队和出队操作。
下面,我们简单看一下这四种队列的用法。
5.1 先进先出(FIFO)队列Queue
import queue
q=queue.Queue(5)
q.put(1)
q.put(2)
q.put(3)
q.put(4)
q.put(5)
# q.put(6) # 将会阻塞住
print(q.maxsize)
print(q.get())
print(q.get())
print(q.get())
print(q.get())
print(q.get())
# print(q.get()) # 将会阻塞线程
这里的put方法和get方法,是阻塞操作,简单来说,如果队列已经满了,那么插入数据的操作就会被阻塞,直到队列中有空闲位置后再插入数据,然后再返回。在队列为空的时候,从队头取数据会被阻塞,因为此时还没有数据可取,直到队列中有了数据才能返回。
常见的阻塞队列的应用场景是“生产者 - 消费者模型”。阻塞队列可以有效地协调生产和消费的速度。当“生产者”生产数据的速度过快,“消费者”来不及消费时,存储数据的队列很快就会满了。这个时候,生产者就阻塞等待,直到“消费者”消费了数据,“生产者”才会被唤醒继续“生产”。
在一个脚本中,模拟生产者-消费者模型,需要用到两个线程,一个是生产者线程,一个是消费者线程。一个简单的利用阻塞队列实现生产者-消费者模型的代码如下:
import queue
import random
import threading
import time
class Producer(threading.Thread):
def __init__(self, q):
super(Producer, self).__init__()
self.q = q
def run(self) -> None: # 定义线程要运行的函数,线程被cpu调度后自动执行
while True:
item = random.randint(1, 10)
self.q.put(item) # 入队,队列满时阻塞
print("Produce " + str(item) + ": " + str(self.q.qsize()) + " items in queue")
time.sleep(random.randint(1, 2))
class Consumer(threading.Thread):
def __init__(self, q):
super(Consumer, self).__init__()
self.q = q
def run(self) -> None: # 定义线程要运行的函数,线程被cpu调度后自动执行
while True:
item = self.q.get() # 出队,队列为空时阻塞
print("Consume " + str(item) + ": " + str(self.q.qsize()) + " items in queue")
time.sleep(random.randint(1, 2))
if __name__ == '__main__':
q = queue.Queue(4)
c = Consumer(q)
p = Producer(q)
p2 = Producer(q)
c.start()
p.start() # 启动线程
p2.start() # 启动线程
这段代码中,生产者线程时刻监控队列情况,队列没有满时,使用put方法在随机的时间间隔往队列中放入数据,消费者线程也时刻监控队列情况,队列不为空时,使用get方法从队列中取数据。从上面程序运行的结果看,队列满后生产者停止生产,等待消费者消费之后再生产。
Queue类还提供了两个非阻塞的操作方法,分别是put_nowait和get_nowait。这两个方法不会阻塞线程的执行,当队列已经满了,在调用put_nowait进行入队操作时会立即抛出queue.Full异常,而不是等待队列中有空闲。当队列为空时,在调用get_nowait进行出队操作时会立即抛出_queue.Empty异常,而不是等待有数据入队。
import queue
q=queue.Queue(5)
q.put_nowait(1)
q.put_nowait(2)
q.put_nowait(3)
q.put_nowait(4)
q.put_nowait(5)
# q.put_nowait(6) # 抛出queue.Full异常
print(q.maxsize)
print(q.get_nowait())
print(q.get_nowait())
print(q.get_nowait())
print(q.get_nowait())
print(q.get_nowait())
print(q.get_nowait()) # 抛出_queue.Empty异常
Queue类除了上面基础的入队和出队操作外,更多的方法可以参考:https://docs.python.org/3.8/library/asyncio-queue.html?highlight=queue#queue
其中有两个比较难理解的方法task_done和join。
- task_done()
意味着之前入队的一个任务已经完成。由队列的消费者线程调用。每一个get()调用得到一个任务,接下来的task_done()调用告诉队列该任务已经处理完毕。
如果当前一个join()正在阻塞,它将在队列中的所有任务都处理完时恢复执行(即每一个由put()调用入队的任务都有一个对应的task_done()调用)。
- join()
阻塞调用线程,直到队列中的所有任务被处理掉。
只要有数据被加入队列,未完成的任务数就会增加。当消费者线程调用task_done()(意味着有消费者取得任务并完成任务),未完成的任务数就会减少。当未完成的任务数降到0,join()解除阻塞。
5.2. Python中的优先级队列PriorityQueue
优先队列是根据优先级判定谁先出来,如果优先级一样,则按数据的ASCII码输出。
优先级队列的一个常见应用场景是,电商网站在进行抢购时,用户访问下单提交量很大,服务器后端收到订单后,并不是直接进行扣库存处理,而是将请求放到队列,异步消费处理,用普通队列是FIFO的。但是如果需求是用户会员级别高的,可以优先抢购到商品,这个时候使用优先队列代替普通队列FIFO队列,就能满足我们的需求。
Python中queue模块中,有一个优先级队列的类PriorityQueue。
q = queue.PriorityQueue()
q.put((10, 'b'))
q.put((30, 'a'))
q.put((13, 'k'))
q.put((13, 'f'))
q.put((-3.5, 'c'))
q.put((-7.5, 'h'))
print(q.get())
print(q.get())
print(q.get())
print(q.get())
print(q.get())
print(q.get())
p = queue.PriorityQueue()
p.put(19)
p.put(1)
p.put(13)
p.put(5)
p.put(10)
print(p.get())
print(p.get())
print(p.get())
print(p.get())
print(p.get())
在将数据入优先级队列时,put方法的参数可以使单个数值和字符,也可以采用(priority_number, data)元组结构,元组第一个值代表优先级,值越小优先级越高,元组第二个值表示真正的数据。在出队列时,数值越小,或者priority_number越小的优先出队列。因此上面的代码出队列的顺序是:
(-7.5, 'h')
(-3.5, 'c')
(10, 'b')
(13, 'f')
(13, 'k')
(30, 'a')
1
5
10
13
19
与Queue一样,优先级队列的put和get方法也是阻塞方法,但是也提供非阻塞方法put_nowait和get_nowait。
利用优先级队列出队的顺序,可以利用优先级队列求出数据流中的第k大元素。题目来自leetcode703:https://leetcode-cn.com/problems/kth-largest-element-in-a-stream/
思路是维护一个长度为k的优先级队列,队首就是第k大元素。
具体算法如下,我增加了比较多的注释,方便理解:
import queue
class KthLargest:
def __init__(self, k: int, nums: List[int]):
self.q = queue.PriorityQueue()
self.k = k
for num in nums: # 初始时将所有元素依次插入优先级队列
self.q.put(num)
while self.q.qsize() > k: # 保持队列长度是k
self.q.get()
def add(self, val: int) -> int:
if self.q.qsize() == self.k: # 队列长度已经是k
head = max(self.q.get(), val) # 获取队首和待add元素之间的大值
self.q.put(head)
else:
self.q.put(val) # 队列长度不是k直接加入队列
kth = self.q.get() # 获取队首,也就是第k大元素
self.q.put(kth) # 还要放回去保持队列长度为k
return kth # 返回队首的元素,也就是第k大元素
利用优先级队列,通过上面的算法,能够始终保持队列中保持前k大元素。对于需要求topk的场景,非常好用。
5.3. Python中的后进先出队列LifoQueue
后进先出队列其实是与栈的效果类似。参考下面的代码,后入队的先出队。
q = queue.LifoQueue()
q.put(1)
q.put(2)
q.put(3)
q.put(4)
print(q.get())
print(q.get())
print(q.get())
print(q.get())