Queue and Deque
Key Points
- •A queue is a First-In-First-Out (FIFO) line where you add at the back and remove from the front in O(1) time.
- •A deque (double-ended queue) lets you push and pop from both the front and the back in O(1) time.
- •Breadth-First Search (BFS) on unweighted graphs uses a queue and runs in O(n + m) time where n is vertices and m is edges.
- •A monotonic deque solves the sliding window minimum/maximum problem in O(n) by maintaining a decreasing or increasing sequence of candidates.
- •0-1 BFS uses a deque to compute shortest paths in graphs with edge weights 0 or 1 in O(n + m) time.
- •Circular-array implementations of queues/deques use modular arithmetic to wrap indices around the buffer.
- •std::queue and std::deque in C++ are reliable, but understanding custom implementations helps debug and optimize.
- •Common pitfalls include off-by-one errors in circular indices, forgetting to evict out-of-window indices in sliding windows, and misusing pus/back in 0-1 BFS.
Prerequisites
- →Arrays and indexing — Circular arrays implement queues/deques using modular arithmetic on indices.
- →Big-O notation — To analyze amortized O(1) operations and linear-time algorithms like BFS and sliding windows.
- →Graphs and adjacency lists — BFS and 0-1 BFS traverse graphs stored as adjacency lists.
- →Modular arithmetic — Index wrap-around in circular buffers uses modulo operations.
- →Queues in C++ STL — Practical use leverages std::queue and std::deque interfaces.
- →Proof by invariant — Monotonic deque correctness relies on maintaining a sorted-invariant of elements.
Detailed Explanation
Tap terms for definitions01Overview
Queues and deques are fundamental data structures for ordered processing. A queue is like a real-world line: the first person to enter is the first to be served (FIFO). Operations are limited to two ends: you enqueue (push) at the back and dequeue (pop) from the front, each in constant time. Deques generalize this idea by allowing insertion and deletion at both ends, also in constant time. These structures are widely used in algorithms that process items in layers or over sliding windows. For example, BFS uses a queue to explore neighbors level by level, ensuring shortest paths in unweighted graphs. A monotonic deque cleverly maintains candidate elements to compute sliding window minimums or maximums in linear time, a powerful technique in competitive programming. Similarly, 0-1 BFS uses a deque to efficiently compute shortest paths when edges have weights 0 or 1, achieving linear time similar to BFS but handling weighted edges correctly. Implementations are commonly built on circular arrays (using modular arithmetic to wrap indices) or linked lists (using pointers), with trade-offs in memory locality and dynamic growth. In C++, std::queue and std::deque provide robust, optimized implementations, but understanding the underlying mechanics helps you reason about performance, avoid bugs, and implement algorithmic patterns effectively.
02Intuition & Analogies
Imagine a movie theater line: people join at the back and are served from the front—this is a queue. You can’t skip the line or insert in the middle, which keeps things fair and simple. A deque is like a double-doors hallway where people can enter or exit from either door: sometimes you need to add to the front (priority arrivals) or remove from the back (cancel last), and a deque handles both ends efficiently. For BFS, picture ripples spreading in a pond after you drop a pebble. The closest points get wet first (distance 0), then their neighbors (distance 1), and so on; a queue ensures you process points in exactly that ripple order. For sliding window maximum, think of looking through a car’s side window at passing buildings. As the car moves one step, the leftmost building leaves your view while a new one appears on the right. You only care about the tallest building currently in view. A monotonic deque acts like a curated list of candidate buildings, kicking out any shorter building behind a taller newcomer, because they can never be the tallest while that taller one remains in view. For 0-1 BFS, picture you’re walking a city where some streets are moving walkways (cost 0) and others are normal sidewalks (cost 1). If you find a moving walkway, you want to take it immediately—so you place that next location at the very front of your plan (deque’s front). If it’s a sidewalk, you place it at the back and will get to it after free opportunities. This front/back choice is exactly how a deque implements optimal choices in linear time.
03Formal Definition
04When to Use
Use a queue when you need to process items in the exact order they arrive, especially for level-order or breadth-wise exploration: BFS in unweighted graphs, topological-like layer processing, multi-source spreading phenomena, or simulating pipelines. Use a deque when you need efficient access at both ends: implementing undo/redo buffers, maintaining a moving window of data (e.g., stream processing), or two-ended simulations. A monotonic deque is ideal for sliding window minimum/maximum problems in arrays or streams, achieving O(n) over all windows compared to O(nk) naive methods; this shows up in stock price analysis, sensor signal smoothing, and problems like maximizing subarray sums under window constraints. Use 0-1 BFS for shortest paths when edges have weights 0 or 1; it’s faster and simpler than Dijkstra for this special case and is common in grid problems where moving straight may cost 0 and turning costs 1, or in problems with free teleports (0 edges) plus normal moves (1 edges). Choose circular arrays for implementing queues/deques when you want cache-friendly performance and predictable memory layout; choose linked lists when frequent mid-structure splicing is needed (though for standard queue/deque operations, arrays are usually faster).
⚠️Common Mistakes
- Off-by-one and wrap-around bugs in circular arrays: forgetting to apply modulo after incrementing/decrementing indices, or confusing head/tail semantics. Always compute new indices as (index ± 1 + C) mod C and keep an explicit size to detect full vs empty.
- Forgetting to evict out-of-window indices in sliding window problems, causing stale maxima/minima. Before reading the current window result, pop from the front while dq.front() <= i - k.
- Maintaining the wrong monotonicity direction: for maximums use nonincreasing values in the deque; for minimums use nondecreasing. Also, use indices not values to quickly check if an element has left the window.
- Misusing 0-1 BFS pushes: pushing weight-0 relaxations to the back breaks correctness and can degrade performance. The correct rule is push_front for weight 0, push_back for weight 1, and only relax when a shorter distance is found.
- Re-enqueuing already visited nodes in BFS without checking visited state leads to O(m) enqueues per node and timeouts. Mark visited (or set distance) at enqueue time, not at dequeue time, to avoid duplicates.
- Confusing std::queue and std::deque: std::queue is an adapter with no iterators and no push_front/pop_back; std::deque supports both ends and random access but has different iterator invalidation rules than std::vector.
- Unnecessary copying of large elements in queues/deques. Prefer storing indices or pointers/references to reduce overhead.
- Assuming all operations are O(1) worst-case even with dynamic resizing. Resizes are O(n), but amortized O(1); avoid triggering many resizes by reserving capacity when possible.
Key Formulas
BFS Complexity
Explanation: Breadth-First Search processes each vertex and edge a constant number of times. Total time is linear in the number of vertices n and edges m.
Sliding Windows Count
Explanation: For an array of length n and window size k, the number of distinct windows you can slide over is n − k + 1. This bounds how many outputs your algorithm must produce.
Monotonic Deque Work Bound
Explanation: Each index is pushed to the deque once and popped at most once across the entire process. Therefore, total deque operations are linear in n.
Circular Index Update
Explanation: When using a circular array of capacity C, moving to the next or previous position wraps around using modulo arithmetic to stay within [0, C).
0-1 Shortest Path Recurrence
Explanation: The shortest distance to v is the minimum over all incoming edges of the distance to the predecessor plus the edge weight. With weights 0 or 1, a deque can realize this efficiently.
0-1 BFS Complexity
Explanation: Each edge causes at most one successful relaxation and each vertex enters the deque at most once per improvement. This yields linear time and space in graph size.
Linear Space
Explanation: Both BFS and sliding window algorithms store a constant amount of data per vertex/element, resulting in total memory proportional to input size.
Monotonic Invariant (Max)
Explanation: In a deque storing indices for window maximums, the corresponding values are nonincreasing from front to back. This ensures the front is always the current maximum.
Complexity Analysis
Code Examples
1 #include <iostream> 2 #include <vector> 3 #include <stdexcept> 4 5 class CircularDeque { 6 private: 7 std::vector<int> buf; // storage buffer 8 int head; // index of the first element 9 int len; // number of elements currently stored 10 11 void resize_if_needed() { 12 if (len < (int)buf.size()) return; // not full 13 int oldC = (int)buf.size(); 14 int newC = oldC * 2; 15 std::vector<int> nb(newC); 16 // Copy elements in logical order to new buffer starting at 0 17 for (int i = 0; i < len; ++i) { 18 nb[i] = buf[(head + i) % oldC]; 19 } 20 buf.swap(nb); 21 head = 0; // realign to start 22 } 23 24 public: 25 CircularDeque(int capacity = 8) : buf(std::max(2, capacity)), head(0), len(0) {} 26 27 bool empty() const { return len == 0; } 28 int size() const { return len; } 29 int capacity() const { return (int)buf.size(); } 30 31 // Accessors 32 int front() const { 33 if (empty()) throw std::runtime_error("front on empty deque"); 34 return buf[head]; 35 36 } 37 int back() const { 38 if (empty()) throw std::runtime_error("back on empty deque"); 39 int C = (int)buf.size(); 40 int tailIndex = (head + len - 1 + C) % C; 41 return buf[tailIndex]; 42 } 43 44 // Push operations 45 void push_front(int x) { 46 resize_if_needed(); 47 int C = (int)buf.size(); 48 head = (head - 1 + C) % C; // move head backward with wrap-around 49 buf[head] = x; 50 ++len; 51 } 52 void push_back(int x) { 53 resize_if_needed(); 54 int C = (int)buf.size(); 55 int tail = (head + len) % C; // position after the last element 56 buf[tail] = x; 57 ++len; 58 } 59 60 // Pop operations 61 void pop_front() { 62 if (empty()) throw std::runtime_error("pop_front on empty deque"); 63 int C = (int)buf.size(); 64 head = (head + 1) % C; 65 --len; 66 } 67 void pop_back() { 68 if (empty()) throw std::runtime_error("pop_back on empty deque"); 69 --len; // logical removal from the back 70 } 71 }; 72 73 int main() { 74 CircularDeque dq(4); 75 76 // Use as a deque 77 dq.push_back(10); 78 dq.push_front(5); 79 dq.push_back(20); 80 std::cout << "Front: " << dq.front() << ", Back: " << dq.back() << "\n"; // 5, 20 81 82 dq.pop_front(); // remove 5 83 std::cout << "Front after pop_front: " << dq.front() << "\n"; // 10 84 85 dq.push_front(7); 86 dq.push_back(30); // may trigger resize 87 88 // Use as a queue: only push_back and pop_front 89 dq.push_back(40); 90 std::cout << "Queue-like front: " << dq.front() << "\n"; // 10 91 dq.pop_front(); 92 std::cout << "Next front: " << dq.front() << "\n"; // 20 93 94 std::cout << "Size: " << dq.size() << ", Capacity: " << dq.capacity() << "\n"; 95 return 0; 96 } 97
This program implements a circular-array deque with O(1) amortized push/pop at both ends. It keeps a head index and a length; the tail is derived as (head + len) % C. When full, it doubles capacity and realigns data contiguously. The main function demonstrates both deque operations and how a queue is just a restricted deque using push_back and pop_front.
1 #include <iostream> 2 #include <vector> 3 #include <queue> 4 #include <limits> 5 6 // BFS to compute shortest distances (in edges) from a source in an unweighted graph 7 std::vector<int> bfs_shortest_distances(const std::vector<std::vector<int>>& adj, int s) { 8 int n = (int)adj.size(); 9 const int INF = std::numeric_limits<int>::max(); 10 std::vector<int> dist(n, INF); 11 std::queue<int> q; 12 13 dist[s] = 0; // mark discovered at enqueue time 14 q.push(s); 15 16 while (!q.empty()) { 17 int u = q.front(); q.pop(); 18 for (int v : adj[u]) { 19 if (dist[v] == INF) { 20 dist[v] = dist[u] + 1; 21 q.push(v); 22 } 23 } 24 } 25 return dist; 26 } 27 28 int main() { 29 // Example graph (undirected) 30 int n = 6; 31 std::vector<std::vector<int>> adj(n); 32 auto add_edge = [&](int u, int v){ adj[u].push_back(v); adj[v].push_back(u); }; 33 add_edge(0, 1); 34 add_edge(0, 2); 35 add_edge(1, 3); 36 add_edge(2, 3); 37 add_edge(3, 4); 38 add_edge(4, 5); 39 40 int source = 0; 41 std::vector<int> dist = bfs_shortest_distances(adj, source); 42 43 std::cout << "Distances from node " << source << ":\n"; 44 for (int i = 0; i < n; ++i) { 45 if (dist[i] == std::numeric_limits<int>::max()) std::cout << "INF "; 46 else std::cout << dist[i] << ' '; 47 } 48 std::cout << '\n'; 49 return 0; 50 } 51
This code computes shortest-path distances (number of edges) from a source in an unweighted graph using std::queue. Marking dist[v] at enqueue time prevents multiple enqueues of the same vertex and guarantees O(n + m) time.
1 #include <iostream> 2 #include <vector> 3 #include <deque> 4 5 // Returns maximums for each window of size k 6 std::vector<int> sliding_window_max(const std::vector<int>& a, int k) { 7 std::deque<int> dq; // stores indices, values are nonincreasing 8 std::vector<int> ans; 9 for (int i = 0; i < (int)a.size(); ++i) { 10 // 1) Evict indices that fall out of the window [i-k+1, i] 11 while (!dq.empty() && dq.front() <= i - k) dq.pop_front(); 12 // 2) Maintain decreasing deque: remove smaller values from the back 13 while (!dq.empty() && a[dq.back()] <= a[i]) dq.pop_back(); 14 // 3) Add current index 15 dq.push_back(i); 16 // 4) Record answer once the first window is formed 17 if (i >= k - 1) ans.push_back(a[dq.front()]); 18 } 19 return ans; 20 } 21 22 int main() { 23 std::vector<int> a = {1, 3, -1, -3, 5, 3, 6, 7}; 24 int k = 3; 25 std::vector<int> res = sliding_window_max(a, k); 26 27 std::cout << "Window maximums: "; 28 for (int x : res) std::cout << x << ' '; 29 std::cout << '\n'; 30 return 0; 31 } 32
The deque stores indices of elements in nonincreasing value order. Each index is pushed once and popped at most once, yielding O(n) total operations. The front of the deque is always the maximum in the current window, and indices that fall out of the window are evicted from the front.
1 #include <iostream> 2 #include <vector> 3 #include <deque> 4 #include <limits> 5 #include <utility> 6 7 // Graph with edges of weight 0 or 1 8 using Edge = std::pair<int,int>; // (to, weight) 9 10 std::vector<int> zero_one_bfs(const std::vector<std::vector<Edge>>& adj, int s) { 11 int n = (int)adj.size(); 12 const int INF = std::numeric_limits<int>::max() / 4; 13 std::vector<int> dist(n, INF); 14 std::deque<int> dq; 15 16 dist[s] = 0; 17 dq.push_front(s); 18 19 while (!dq.empty()) { 20 int u = dq.front(); dq.pop_front(); 21 for (auto [v, w] : adj[u]) { 22 int nd = dist[u] + w; // w is 0 or 1 23 if (nd < dist[v]) { 24 dist[v] = nd; 25 if (w == 0) dq.push_front(v); // free move: process sooner 26 else dq.push_back(v); // cost-1 move: process later 27 } 28 } 29 } 30 return dist; 31 } 32 33 int main() { 34 int n = 5; 35 std::vector<std::vector<Edge>> adj(n); 36 auto add = [&](int u, int v, int w){ adj[u].push_back({v, w}); }; 37 // Example directed graph with 0/1 weights 38 add(0, 1, 0); add(0, 2, 1); 39 add(1, 2, 0); add(1, 3, 1); 40 add(2, 3, 0); add(3, 4, 1); 41 add(1, 4, 1); 42 43 int s = 0; 44 std::vector<int> d = zero_one_bfs(adj, s); 45 46 std::cout << "Distances from " << s << ":\n"; 47 for (int i = 0; i < n; ++i) { 48 if (d[i] >= std::numeric_limits<int>::max()/8) std::cout << "INF "; 49 else std::cout << d[i] << ' '; 50 } 51 std::cout << '\n'; 52 return 0; 53 } 54
0-1 BFS keeps a deque of vertices ordered by current tentative distance: relaxing a 0-weight edge inserts the neighbor at the front, while a 1-weight edge inserts at the back. This mirrors Dijkstra’s priority behavior without a heap and achieves O(n + m) time for 0/1 weights.