1. Stack Fundamentals & Balanced Parentheses
A stack is a Last-In-First-Out (LIFO) structure: you can only push
onto the top and pop from the top, in O(1) time each. std::stack in
<stack> is an adaptor — by default it wraps a std::deque
and exposes only the operations a stack needs: push, pop,
top and empty.
#include <stack>
#include <string>
#include <unordered_map>
using namespace std;
// Returns true if every bracket in s is closed in the right order.
// O(n) time, O(n) space in the worst case (all opening brackets).
bool isBalanced(const string& s) {
stack<char> open;
unordered_map<char, char> match = {{')', '('}, {']', '['}, {'}', '{'}};
for (char c : s) {
if (c == '(' || c == '[' || c == '{') {
open.push(c);
} else if (match.count(c)) {
if (open.empty() || open.top() != match[c]) return false;
open.pop();
}
}
return open.empty(); // nothing left unclosed
}
The LIFO order is exactly what makes a stack the right tool here: the most recently
opened bracket must be the next one closed, which is precisely what top()
gives you access to. Any problem with a "most recently seen, still unresolved" item
is a strong signal that a stack belongs in your solution.
Every recursive function call in Week 8 pushes a new frame onto the program's call stack and pops it on return — the same LIFO discipline you're implementing explicitly here. Any recursive algorithm can, in principle, be rewritten iteratively with an explicit std::stack standing in for those call frames.
2. The Monotonic Stack Pattern
A monotonic stack keeps its elements in strictly increasing or decreasing order at all times, by popping off elements that would violate that order before pushing a new one. The classic use is the next greater element problem: for each element, find the first element to its right that's strictly larger.
#include <vector>
#include <stack>
using namespace std;
// For each index i, result[i] is the next element to the right that's greater
// than nums[i], or -1 if none exists. O(n) time, O(n) space.
vector<int> nextGreaterElement(const vector<int>& nums) {
int n = nums.size();
vector<int> result(n, -1);
stack<int> indices; // stores INDICES whose next-greater is still unknown,
// with nums[indices] kept in decreasing order top-to-bottom
for (int i = 0; i < n; i++) {
// nums[i] is bigger than everything smaller still waiting on the stack --
// pop and resolve each of those
while (!indices.empty() && nums[indices.top()] < nums[i]) {
result[indices.top()] = nums[i];
indices.pop();
}
indices.push(i);
}
return result; // whatever's left on the stack has no next greater element
}
The complexity argument is worth stating precisely: the inner while loop
looks like it could make this O(n²), but every index is pushed exactly once and
popped at most once across the entire run, so total work across all iterations is
O(n), not O(n) per iteration — this is called amortized analysis, and
it's the reasoning behind why the monotonic stack pattern is O(n) despite the nested
loop shape.
"Next greater/smaller element," "daily temperatures," "largest rectangle in a histogram," and "trapping rain water" all reduce to a monotonic stack once you frame them as "for each element, what's the nearest element to the left/right satisfying some comparison?" Store indices, not values, whenever you need to know how far apart two elements are.
3. Queue Fundamentals
A queue is First-In-First-Out (FIFO): elements are added at the back
and removed from the front, both in O(1). std::queue in
<queue> is another adaptor over std::deque by default,
exposing push, pop, front and back.
#include <queue>
using namespace std;
int main() {
queue<int> q;
q.push(1); // back: [1]
q.push(2); // back: [1, 2]
q.push(3); // back: [1, 2, 3]
int f = q.front(); // f == 1 -- the first element pushed
q.pop(); // removes 1, queue is now [2, 3]
while (!q.empty()) {
// process q.front(), then q.pop()
q.pop();
}
}
FIFO order is exactly what you want when processing elements in the order they were discovered rather than the order you most recently touched them — which is precisely why breadth-first search on a graph or tree, covered starting Week 9's level-order traversal and continuing into Week 12's graph BFS, always uses a queue rather than a stack: it guarantees you finish exploring everything at the current "distance" before moving farther out.
"Do I want to process the most recently added item next, or the earliest added item next?" Most-recent points to a stack (DFS, undo history, bracket matching); earliest points to a queue (BFS, task scheduling, print queues). Getting this choice wrong doesn't crash your code — it just silently explores or processes things in the wrong order.
4. std::deque & Sliding Window Maximum
std::deque ("double-ended queue," <deque>) supports O(1)
push and pop at both ends — push_front, push_back,
pop_front, pop_back — which is more flexibility than either
stack or queue exposes on their own. That flexibility is
exactly what the sliding window maximum problem needs: find the
maximum of every size-k contiguous window as it slides across an array.
#include <vector>
#include <deque>
using namespace std;
// Returns the maximum of every contiguous window of size k.
// O(n) time, O(k) space -- each index is pushed and popped from the
// deque at most once, the same amortized argument as the monotonic stack.
vector<int> maxSlidingWindow(const vector<int>& nums, int k) {
deque<int> dq; // stores INDICES, nums[dq] kept in decreasing order front-to-back
vector<int> result;
for (int i = 0; i < (int)nums.size(); i++) {
// drop indices that fell out of the window on the left
if (!dq.empty() && dq.front() <= i - k) {
dq.pop_front();
}
// maintain decreasing order -- anything smaller than nums[i] can
// never be the max again while nums[i] is still in the window
while (!dq.empty() && nums[dq.back()] < nums[i]) {
dq.pop_back();
}
dq.push_back(i);
if (i >= k - 1) {
result.push_back(nums[dq.front()]); // front is always the current max
}
}
return result;
}
This is the monotonic stack idea applied at both ends at once: the front of the deque
always holds the index of the current window's maximum, and it gets evicted from the
front only once it slides outside the window, and from the back whenever a larger
value arrives to make it irrelevant. Compare this to the brute-force approach of
scanning all k elements for every window position, which costs
O(n × k) — the deque version does the same job in O(n) total.
A priority_queue (covered in Week 11) always gives you the true maximum of everything you've inserted, with O(log n) insertion. The monotonic deque here is cheaper — O(1) amortized per element — precisely because it only needs to answer "what's the max of the current window," and it exploits the fact that old, smaller elements become provably useless and can be discarded rather than kept around.
5. Hands-on Exercise
Solve "Next Greater Element II" and "Sliding Window Maximum" together
Combine this week's monotonic stack and monotonic deque patterns to handle a circular array and a windowed maximum in the same program.
Requirements:
- Implement
vector<int> nextGreaterCircular(const vector<int>& nums)where the array is treated as circular — an element may find its next-greater by wrapping around to the start. Achieve this by conceptually iterating the array twice (indexi % n) while only recording results on the first pass. - State in a comment why the circular version is still O(n) time and O(n) space despite the "two passes."
- Implement
maxSlidingWindowexactly as shown in Section 4, then also implement a brute-forcemaxSlidingWindowBruteForcethat recomputes the max of each window from scratch. - Write a randomized test that generates an array of 2,000 random integers and a random
kbetween 1 and the array's length, and asserts both sliding-window-maximum implementations agree on every window. - Time both sliding-window-maximum implementations on an array of 200,000 elements with
<chrono>and print the results.
For the circular version, push and pop using i % n as the actual index but loop i from 0 to 2 * n - 1; only write into result[i % n] when i < n. Each index still enters and leaves the stack at most twice total, so the amortized O(n) bound still holds.
6. Knowledge Check
Four quick questions. Expand each to check your answer.
Q1
Why is a stack the right structure for validating balanced parentheses, rather than, say, just counting opens and closes?
Why is a stack the right structure for validating balanced parentheses, rather than, say, just counting opens and closes?
Counting only checks that the total number of opens equals the total number of closes, but it can't detect wrong ordering or mismatched bracket types, like "([)]", which has equal counts but isn't balanced. A stack tracks exactly which bracket is "currently open and most recently unmatched," so each closing bracket can be checked against the correct corresponding opener via top().
Q2
The next-greater-element algorithm has a while loop nested inside a for loop. Why is its overall time complexity still O(n) and not O(n²)?
The next-greater-element algorithm has a while loop nested inside a for loop. Why is its overall time complexity still O(n) and not O(n²)?
Each index is pushed onto the stack exactly once (in the for loop) and can be popped at most once (in the while loop) over the entire execution, so the total number of push and pop operations across all iterations is bounded by 2n. This amortized-analysis argument shows the real cost is O(n) total, even though any single iteration's while loop could, in isolation, run many times.
Q3
Why does breadth-first search use a queue instead of a stack?
Why does breadth-first search use a queue instead of a stack?
BFS needs to fully process every node at the current distance from the start before moving on to nodes farther away, which requires processing nodes in the exact order they were discovered — FIFO order, which is what a queue guarantees. A stack would instead dive depth-first into whichever node was most recently discovered, which is DFS's behavior, not BFS's.
Q4
In the sliding window maximum algorithm, why is it safe to permanently discard an element from the back of the deque as soon as a larger element arrives?
In the sliding window maximum algorithm, why is it safe to permanently discard an element from the back of the deque as soon as a larger element arrives?
If a new element is larger than an existing element near the back of the deque, and the new element entered the window later, then the new element will still be inside the window for at least as long as the older, smaller one — meaning the older element can never again be the maximum of any window it still belongs to while the larger one is also present. Since it can never win, keeping it around only wastes space and comparisons, so discarding it is safe.