45.1 deque: Double-Ended Queue with O(1) Appends
The collections.deque (pronounced “deck”) is a double-ended queue that provides O(1) time complexity for appending and popping from both ends. This makes it fundamentally different from a standard Python list, which provides O(1) appends/pops from the right end but O(n) operations from the left end because all other elements must be shifted. This performance characteristic is the primary reason for choosing a deque over a list.
Under the hood, a deque is implemented as a doubly-linked list of fixed-length blocks. This hybrid structure avoids the memory overhead of a traditional linked list while still providing the constant-time performance for end operations. When an element is appended to one end and the current block is full, a new block is allocated and linked. This avoids the need to copy the entire data structure, which is what happens during a list resize, though the list amortized cost for appends is still O(1).
Initialization and Basic Operations
A deque can be initialized as empty or from any iterable. The optional maxlen argument creates a bounded deque, which is a powerful feature for working with a fixed-size history of items.
from collections import deque
# Basic initialization
d = deque(['a', 'b', 'c']) # deque(['a', 'b', 'c'])
empty_d = deque() # deque([])
d_from_range = deque(range(3), maxlen=5) # deque([0, 1, 2], maxlen=5)
# Appending and popping
d.append('d') # Right end: deque(['a', 'b', 'c', 'd'])
d.appendleft('z') # Left end: deque(['z', 'a', 'b', 'c', 'd'])
right_item = d.pop() # Returns 'd', d is now ['z', 'a', 'b', 'c']
left_item = d.popleft() # Returns 'z', d is now ['a', 'b', 'c']
The maxlen Parameter and Automatic Pruning
The maxlen parameter is a key feature that enables elegant solutions for tracking recent activity. When a bounded deque is full, adding a new element to one end automatically removes an element from the opposite end. This behavior is intrinsic and thread-safe.
# Creating a bounded deque to track the last 3 operations
history = deque(maxlen=3)
history.append('read')
history.append('write')
history.append('execute')
print(history) # deque(['read', 'write', 'execute'], maxlen=3)
# Adding a new operation automatically evicts the oldest one
history.append('delete')
print(history) # deque(['write', 'execute', 'delete'], maxlen=3)
# The 'read' operation has been automatically removed
Performance Characteristics and Comparison to List
The performance difference for left-end operations is stark, especially as the data structure grows. This is the most critical factor in deciding between a deque and a list.
import time
from collections import deque
num_elements = 1_000_000
# Time appending to the left of a list
lst = []
start = time.perf_counter()
for i in range(num_elements):
lst.insert(0, i) # O(n) operation per iteration
list_time = time.perf_counter() - start
# Time appending to the left of a deque
dq = deque()
start = time.perf_counter()
for i in range(num_elements):
dq.appendleft(i) # O(1) operation per iteration
deque_time = time.perf_counter() - start
print(f"List.insert(0): {list_time:.4f} seconds")
print(f"deque.appendleft(): {deque_time:.4f} seconds")
# Typical output: List takes seconds, deque takes milliseconds.
Indexing and Middle Operations: A Key Pitfall
While deque provides excellent performance for end operations, it is important to understand its weakness: accessing or modifying elements in the middle is O(n). This is because the underlying structure requires a linear traversal to find the element. For these operations, a list (with O(1) indexing) is superior.
d = deque(['a', 'b', 'c', 'd', 'e'])
# Indexing is supported but is O(n)
print(d[2]) # 'c' - This requires traversing from one end.
# Inserting in the middle is also O(n)
d.insert(2, 'X') # deque(['a', 'b', 'X', 'c', 'd', 'e'])
# This operation is inefficient for large deques.
# Rotating is highly efficient (O(k mod n))
d.rotate(2) # deque(['d', 'e', 'a', 'b', 'X', 'c'])
d.rotate(-1) # deque(['e', 'a', 'b', 'X', 'c', 'd'])
Thread-Safety and Common Use Cases
The deque is designed to be thread-safe for appends and pops from opposite ends. This makes it an excellent choice for simple producer-consumer queues without the need for explicit locking, though for more complex multi-threading scenarios, queue.Queue is often preferred.
Common use cases include:
- Implementing queues and stacks: Perfect for breadth-first search (BFS) algorithms or managing a LIFO stack.
- Sliding window computations: The
maxlenfeature is ideal for calculating running averages or processing a fixed number of recent data points. - Maintaining a history: Keeping a log of the most recent commands, messages, or transactions.
# Simple producer-consumer with a deque (thread-safe for opposite-end operations)
import threading
import time
def producer(queue, items):
for item in items:
queue.append(item) # Producer appends to the right
time.sleep(0.1)
def consumer(queue):
while True:
try:
item = queue.popleft() # Consumer pops from the left
print(f"Consumed: {item}")
except IndexError: # deque is empty
time.sleep(0.05)
task_queue = deque()
producer_thread = threading.Thread(target=producer, args=(task_queue, ['task1', 'task2', 'task3']))
consumer_thread = threading.Thread(target=consumer, args=(task_queue,))
producer_thread.start()
consumer_thread.start()
producer_thread.join()