Sliding Window Pattern – Part 5: Variable-Size Windows

This is the biggest shift in the entire sliding window series.

Every problem in Parts 1–4 had a fixed window size. You were always told: look at every subarray of size k. The window slid one step at a time — add one element, remove one element — like clockwork. The challenge was in what you tracked inside the window, not in the window itself.

In this post, there is no k. The window size is not given. Instead, the window expands and contracts dynamically, and finding the right size is the core of the problem.


How Variable-Size Windows Work

The structure uses two pointers — a left boundary and a right boundary — both starting at 0.

The right pointer always moves forward, one step per iteration of the outer loop. Every time it moves, the window expands by including the new element.

The left pointer only moves forward when needed. After expanding, if the window violates some condition (sum too large, too many zeros, product exceeds a limit), we shrink from the left — advancing the left pointer and removing elements — until the condition is restored.

Here’s the template:

left = 0
for right = 0 to n-1:
    expand window by including nums[right]
    while (window violates condition):
        shrink window by excluding nums[left]
        left++
    evaluate window (update answer)

Why This Is Still O(n)

The nested while loop looks like it could make this O(n²), but it doesn’t. The key observation is that the left pointer never moves backward. Across the entire execution, left moves from 0 to at most n. The right pointer also moves from 0 to n. So the total number of pointer movements is at most 2n, which is O(n).

Each element is added to the window exactly once (when right passes it) and removed at most once (when left passes it). No element is processed more than twice.

Fixed vs. Variable: What Changes

Fixed-Size (Parts 1–4)Variable-Size (Part 5)
Window sizeGiven as kUnknown — the answer itself
Right pointerMoves one step per iterationSame
Left pointerAlways at right – k + 1Moves forward only when condition violated
Shrink triggerAutomatic (window exceeds k)Conditional (while loop)
Core question“What’s inside this window?”“What’s the best window size?”

The Reframing Challenge

Some variable-size problems are straightforward — the problem directly asks for “the shortest subarray with sum >= target” and you can see the two-pointer approach immediately.

But others disguise the sliding window. The problem might talk about removing elements from both ends, or flipping zeros, or counting valid subarrays. Before you can apply the template, you need to reframe the problem into “find a contiguous subarray that satisfies condition X.” This reframing step is often the hardest part.


Problem 12: Minimum Size Subarray Sum (LC 209)

Difficulty: Medium

Problem: Given an array of positive integers nums and a positive integer target, return the minimal length of a subarray whose sum is greater than or equal to target. If no such subarray exists, return 0.

Why variable-size: There’s no fixed k. Different regions of the array might need different window sizes to reach the target. The optimal answer could be 1 (a single large element) or the entire array.

Key Insight: Since all numbers are positive, adding an element always increases the sum and removing one always decreases it. This monotonic behavior is what makes the two-pointer approach correct — once the sum reaches the target, shrinking from the left can only make it smaller, so we don’t miss any valid windows.

Solution (C++)

cpp

class Solution {
public:
    int minSubArrayLen(int target, vector<int>& nums) {
        int startIdx = 0;
        int sumSoFar = 0;
        int minLen = INT_MAX;
        for (size_t i = 0; i < nums.size(); ++i) {
            sumSoFar += nums[i];
            while (sumSoFar >= target) {
                minLen = min(minLen, (int)i - startIdx + 1);
                sumSoFar -= nums[startIdx++];
            }
        }

        return minLen == INT_MAX ? 0 : minLen;
    }
};

How the Window Moves

The outer loop expands by adding nums[i] to sumSoFar. Once sumSoFar >= target, the while loop kicks in: record the current window length, subtract the leftmost element, and advance startIdx. This continues until the sum drops below the target.

At each position of the right pointer, we find the tightest valid window ending there. The overall minimum across all positions is the answer.

Notice minLen starts at INT_MAX as a sentinel. If it’s never updated, no valid subarray exists.

Time: O(n) Space: O(1)


Problem 13: Subarray Product Less Than K (LC 713)

Difficulty: Medium

Problem: Given an array of positive integers nums and an integer k, return the number of contiguous subarrays where the product of all elements is strictly less than k.

Why variable-size: The valid window size depends on the product of elements, which varies across the array. A subarray of length 5 might be valid in one region but not another.

Key Insight: The structure is identical to LC 209 — expand by multiplying, shrink by dividing — but with a clever counting twist.

The Counting Trick

At each position i, the window from idx to i is the longest subarray ending at i whose product is less than k. How many valid subarrays end at position i? Exactly i – idx + 1. These are the subarrays [idx..i], [idx+1..i], [idx+2..i], all the way down to [i..i]. Since the full window is valid and all numbers are positive (so shorter subarrays have smaller products), every sub-window is also valid.

By summing i – idx + 1 at each position, we count every valid subarray exactly once without double-counting.

Solution (C++)

cpp

class Solution {
public:
    int numSubarrayProductLessThanK(vector<int>& nums, int k) {
        if (k <= 1) {
            return 0;
        }
        int numArrs = 0;
        long long productSoFar = 1;
        int idx = 0;
        for (size_t i = 0; i < nums.size(); ++i) {
            productSoFar *= nums[i];
            while (productSoFar >= k) {
                productSoFar /= nums[idx++];
            }

            numArrs += i - idx + 1;
        }
        return numArrs;
    }
};

Comparing to LC 209

The mechanics are almost identical. The differences:

  • Arithmetic is multiply/divide instead of add/subtract
  • We shrink when the product is too large (>= k), keeping the window valid
  • Instead of tracking minimum length, we count subarrays using the i – idx + 1 formula
  • The edge case guard (k <= 1) handles the fact that all elements are at least 1, so no product can be less than 1

Time: O(n) Space: O(1)


Problem 14: Minimum Operations to Reduce X to Zero (LC 1658)

Difficulty: Medium

Problem: Given an integer array nums and an integer x, you can remove the leftmost or rightmost element and subtract its value from x. Return the minimum number of operations to reduce x to exactly 0, or -1 if impossible.

Why this is a sliding window problem (the reframing):

At first glance, this doesn’t look like sliding window at all. You’re removing from both ends, not looking at a contiguous subarray.

Here’s the trick: flip the problem. If you remove some elements from the left and right that sum to x, the remaining elements in the middle form a contiguous subarray. That middle subarray’s sum must equal totalSum – x.

Minimizing the number of removed elements is the same as maximizing the length of the middle subarray whose sum equals totalSum – x.

Now it’s a standard variable-size sliding window: find the longest subarray with a specific target sum.

Solution (C++)

cpp

class Solution {
public:
    int minOperations(vector<int>& nums, int x) {
        int target = accumulate(nums.begin(), nums.end(), 0) - x;
        int idx = 0;
        int sumSoFar = 0;
        int maxLen = -1;
        for (size_t i = 0; i < nums.size(); ++i) {
            sumSoFar += nums[i];
            while (idx <= i && sumSoFar > target) {
                sumSoFar -= nums[idx++];
            }
            if (sumSoFar == target) {
                maxLen = max(maxLen, (int)i - idx + 1);
            }
        }

        return maxLen == -1 ? -1 : nums.size() - maxLen;
    }
};

Why the Reframing Works

The original problem: pick elements from the left and right edges that sum to x, using the fewest picks. The reframed problem: find the longest contiguous middle section that sums to totalSum – x. They’re complements — whatever you don’t remove stays in the middle, and whatever stays in the middle you don’t remove.

The answer converts back at the end: nums.size() – maxLen gives the number of removed elements.

This is the most important lesson from this problem. The sliding window technique was always the right approach — the hard part was seeing past the original framing.

Time: O(n) Space: O(1)


Problem 15: Max Consecutive Ones III (LC 1004)

Difficulty: Medium

Problem: Given a binary array nums and an integer k, return the maximum number of consecutive 1’s in the array if you can flip at most k 0’s.

The reframing: Don’t think about flipping zeros. Think about it as: find the longest subarray that contains at most k zeros. Every zero in the window represents one flip. The window length (including both 1’s and flipped 0’s) is the answer.

This reframing turns a “manipulation” problem into a clean variable-size sliding window.

Solution (C++)

cpp

class Solution {
public:
    int longestOnes(vector<int>& nums, int k) {
        int longest = 0;
        int numFlipped = 0;
        int idx = 0;
        for (int i = 0; i < nums.size(); ++i) {
            numFlipped += nums[i] == 0 ? 1 : 0;
            while (idx <= i && numFlipped > k) {
                numFlipped -= nums[idx++] == 0 ? 1 : 0;
            }
            longest = max(longest, i - idx + 1);
        }
        return longest;
    }
};

How the Window Moves

As the right pointer advances, count zeros via numFlipped. When numFlipped exceeds k, shrink from the left: if the element being removed is a 0, decrement numFlipped. Keep shrinking until numFlipped is back to k or less.

At each step, i – idx + 1 is the window length — the number of consecutive 1’s achievable if we apply all k flips within this window. Track the maximum.

We never need to track 1’s explicitly. The window length minus the zero count gives us the 1’s count, but we don’t even need that — the window length itself is the answer.

Time: O(n) Space: O(1)


The Variable-Size Template Across All Four Problems

Every problem follows the same skeleton:

left = 0
state = initial value
for right = 0 to n-1:
    update state with nums[right]        // expand
    while (state violates condition):
        update state removing nums[left]  // shrink
        left++
    update answer with current window     // evaluate

What varies:

ProblemStateCondition to ShrinkAnswer
Min Size Sum (209)Running sumsum >= targetMinimum window length
Product < K (713)Running productproduct >= kCount of valid subarrays
Reduce X to Zero (1658)Running sumsum > targetMaximum window length (then convert)
Max Ones III (1004)Count of 0’szeros > kMaximum window length

Full Series Recap: Parts 1–5

Across 15 problems and 5 posts, we’ve covered the complete sliding window spectrum:

Part 1 (Basic Fixed Window): LC 346, 643, 1343 — Running sum, one-line evaluation changes. The mechanical foundation.

Part 2 (Conditional Fixed Window): LC 1176, 1052, 1456, 1100 — Same mechanics, but richer state: multi-branch scoring, dual counters, vowel sets, hash maps for uniqueness.

Part 3 (Monotonic Deque): LC 239 — Fixed window, but maintaining the max requires a new data structure. Introduction to the idea that the window’s internal state can be non-trivial.

Part 4 (Complex State): LC 567, 438, 480 — Fixed window with frequency maps and balanced BSTs. The template is unchanged; the data structure work per step gets heavier.

Part 5 (Variable-Size): LC 209, 713, 1658, 1004 — No fixed k. The window grows and shrinks based on conditions. Some problems require reframing before the sliding window is even visible.

The single most important skill across all these problems is decomposition: identify what the window tracks, when it should grow or shrink, and how to evaluate it. Once you answer those three questions, the code follows a predictable template — whether the window holds a single integer or two balanced BSTs, whether it’s fixed at size k or dynamically adapting to the data.


Thanks for following along with the full series! If you’re preparing for interviews, practice identifying these patterns before jumping into code — recognition is half the battle.

Sliding Window Pattern – Part 4: When the Window Gets Smarter

In Parts 1–3, every sliding window problem followed a single template: you were given a window size k, you maintained a running sum or count, and you slid one step at a time. The only variation was the evaluation logic — track the max, count matches, or maintain a monotonic deque. The window itself was always dumb: it just held a number.

Starting with this post, the pattern evolves. The state inside the window gets richer. Instead of a single integer (sum, count), the window now tracks a full frequency distribution or a sorted data structure. The window still slides the same way — add one element, remove one element — but the cost and complexity of each update step goes up. Comparing two frequency maps is different from comparing two integers. Maintaining two balanced BSTs is different from maintaining a running sum.

The window size is still fixed. The mechanics are still “add one, remove one.” What changes is what you’re carrying inside the window and what it costs to maintain it.


The Frequency Map Pattern

The first two problems both ask a version of the same question: does some substring of a longer string contain exactly the same characters (in any order) as a target string? In other words, is any window an anagram?

The brute-force approach would generate all permutations of the target string and check each one — factorial time, completely impractical. The key insight is that two strings are anagrams if and only if they have identical character frequencies. So instead of comparing character orderings, we compare frequency maps.

The window state is now an unordered_map of character counts. As the window slides, we decrement the count for the outgoing character and increment the count for the incoming character. At each position, we check if the window’s frequency map matches the target’s frequency map.

Since the input is limited to 26 lowercase English letters, each map has at most 26 entries. Comparing two maps is effectively O(1), so the overall time remains O(n).

One important implementation detail: when a character’s count drops to zero, erase it from the map entirely. The map equality operator checks all key-value pairs, so a key with value 0 would cause a false mismatch against a map that simply doesn’t contain that key.


Problem 9: Permutation in String (LC 567)

Difficulty: Medium

Problem: Given two strings s1 and s2, return true if s2 contains a permutation of s1.

Solution (C++)

cpp

class Solution {
public:
    bool checkInclusion(string s1, string s2) {
        if (s1.size() > s2.size()) {
            return false;
        }

        unordered_map<char, int> s1Freq;
        for (char c : s1) {
            ++s1Freq[c];
        }

        // fill the first window in a separate loop
        unordered_map<char, int> s2Freq;
        for (int i = 0; i < s1.size(); ++i) {
            ++s2Freq[s2[i]];
        }

        if (s1Freq == s2Freq) {
            return true;
        }

        for (int i = s1.size(); i < s2.size(); ++i) {
            // remove old char that is out of window
            char oow = s2[i-s1.size()];
            --s2Freq[oow];
            if (!s2Freq[oow]) {
                s2Freq.erase(oow);
            }

            // add new char that is part of the window now
            ++s2Freq[s2[i]];
            if (s1Freq == s2Freq) {
                return true;
            }
        }

        return false;
    }
};

How It Works

Build a frequency map for s1 once. Initialize a frequency map for the first window (first s1.size() characters of s2). Then slide: decrement the outgoing character, erase if zero, increment the incoming character, and compare maps. Return true on the first match.

The window size is fixed at s1.size(). The mechanics (add one, remove one) are the same as Part 1. What’s new is that the state is a map and the evaluation is a map comparison instead of a numeric comparison.

Time: O(n) where n = length of s2 Space: O(1) — maps have at most 26 entries


Problem 10: Find All Anagrams in a String (LC 438)

Difficulty: Medium

Problem: Given two strings s and p, return an array of all start indices of p’s anagrams in s.

Key Insight: This is LC 567 with one change: instead of returning true on the first match, collect all matching starting indices.

Solution (C++)

cpp

class Solution {
public:
    vector<int> findAnagrams(string s, string p) {
        unordered_map<char, int> sFreq;
        unordered_map<char, int> pFreq;
        for (size_t i = 0; i < p.size(); ++i) {
            ++sFreq[s[i]];
            ++pFreq[p[i]];
        }

        vector<int> result;
        if (sFreq == pFreq) {
            result.push_back(0);
        }

        for (size_t i = p.size(); i < s.size(); ++i) {
            if (!--sFreq[s[i-p.size()]]) {
                sFreq.erase(s[i-p.size()]);
            }
            ++sFreq[s[i]];
            if (sFreq == pFreq) {
                result.push_back(i-p.size()+1);
            }
        }

        return result;
    }
};

What Changed from LC 567?

Almost nothing. The sliding window logic is identical. The only difference is pushing the starting index (i – p.size() + 1) into a result vector instead of returning true, and continuing through the entire string instead of stopping at the first match.

This pair of problems is a good illustration of how recognizing the pattern pays off: once you solve one, the other takes under a minute.

Time: O(n) Space: O(1)


From Frequency Maps to Sorted Structures

The next problem takes the “complex window state” idea to its extreme. Instead of tracking character frequencies, we need to track the median of the window — which requires maintaining sorted order among all elements.

You might think a heap would work (the classic two-heap median trick), but standard heaps only support inserting and removing the top element. When the window slides, the outgoing element could be anywhere in the heap — not just the top. You can’t efficiently remove it.

The solution uses C++’s multiset, which is a balanced binary search tree. It gives O(log k) insertion, O(log k) deletion of any element by iterator, and ordered traversal. It acts like a heap that also supports arbitrary removal — exactly what we need for a sliding window that must maintain sorted order.


Problem 11: Sliding Window Median (LC 480)

Difficulty: Hard

Problem: Given an integer array nums and an integer k, return the median of each sliding window of size k.

The Two-Multiset Approach

Split the window into two halves using two multisets:

  • A “maxHeap” (lower half) — we read its largest element via prev(end())
  • A “minHeap” (upper half) — we read its smallest element via begin()

The maxHeap always has equal or one more element than the minHeap. This way:

  • Odd k: the median is the largest element in the maxHeap
  • Even k: the median is the average of the maxHeap’s largest and minHeap’s smallest

Insertion (updateHeap): Always insert into the maxHeap first, then move its largest element to the minHeap. If the minHeap becomes larger, move its smallest element back. This guarantees balance.

Removal (sliding the window): Find the outgoing element in whichever set contains it, erase it by iterator (not by value — erasing by value on a multiset removes all duplicates), then insert the new element through the normal updateHeap path.

Solution (C++)

cpp

class Solution {
public:
    void updateHeap(multiset<int> &maxHeap, multiset<int> &minHeap, int num) {
        maxHeap.insert(num);
        auto it = prev(maxHeap.end());
        int largest = *it;
        maxHeap.erase(it);
        minHeap.insert(largest);
        if (minHeap.size() > maxHeap.size()) {
            it = begin(minHeap);
            maxHeap.insert(*it);
            minHeap.erase(it);
        }
    }

    double findMedian(const multiset<int> &maxHeap, const multiset<int> &minHeap) {
        int rhs = *prev(maxHeap.end());
        if (maxHeap.size() > minHeap.size()) {
            return rhs;
        }

        int lhs = *minHeap.begin();
        return ((double)lhs + rhs) / 2;
    }

    vector<double> medianSlidingWindow(vector<int>& nums, int k) {
        multiset<int> maxHeap;
        multiset<int> minHeap;
        // fill out the first window
        for (int i = 0; i < k; ++i) {
            updateHeap(maxHeap, minHeap, nums[i]);
        }

        vector<double> medians = {findMedian(maxHeap, minHeap)};

        // starting from next window, update BST
        for (size_t i = k; i < nums.size(); ++i) {
            auto it = maxHeap.find(nums[i-k]);
            if (it != maxHeap.end()) {
                maxHeap.erase(it);
            } else {
                it = minHeap.find(nums[i-k]);
                minHeap.erase(it);
            }

            updateHeap(maxHeap, minHeap, nums[i]);
            medians.push_back(findMedian(maxHeap, minHeap));
        }

        return medians;
    }
};

Complexity

Each slide involves one find + erase (O(log k)), one updateHeap with potential rebalance (O(log k)), and one findMedian (O(1)). Total: O(n log k).

Compare this to the O(nk) brute force of sorting each window, or the O(nk) of using a regular heap with lazy deletion. The multiset approach is clean and efficient.

Time: O(n log k) Space: O(k)


Pattern Summary

All three problems still use a fixed-size window that slides one step at a time. What changed from Parts 1–3 is the weight of each step:

ProblemWindow StateUpdate CostEvaluation Cost
Parts 1–3 problemsInteger (sum/count)O(1)O(1)
Permutation / AnagramsFrequency mapO(1)O(1) with 26-char alphabet
Sliding Window MedianTwo balanced BSTsO(log k)O(1)

The sliding window template didn’t change. The data structure inside the window got heavier.

In Part 5, we tackle a different kind of shift: variable-size windows, where there is no fixed k and the window expands and contracts based on a condition.


Next up: Sliding Window Part 5 — Variable-Size Windows

Sliding Window Pattern – Part 3: The Monotonic Deque

In Part 1 and Part 2, every problem had a clean O(1) update per window slide — add an element, remove an element, done. That worked because we were tracking sums or counts, which are trivially updatable.

But what if you need the maximum of each window?

You can’t just subtract the outgoing element from “the max” — the max might not change, or it might have been the element you just removed. A naive approach would scan the entire window for the new max each time, giving O(nk). For large inputs, that’s too slow.

This is where the monotonic deque comes in — arguably the most elegant data structure trick in the sliding window toolkit.


Problem 8: Sliding Window Maximum (LC 239)

Difficulty: Hard

Problem: Given an array nums and a sliding window of size k, return the maximum value in each window position as it slides from left to right.

Example

nums = [1, 3, -1, -3, 5, 3, 6, 7], k = 3

Window position                Max
-----------------             -----
[1  3  -1] -3  5  3  6  7      3
 1 [3  -1  -3] 5  3  6  7      3
 1  3 [-1  -3  5] 3  6  7      5
 1  3  -1 [-3  5  3] 6  7      5
 1  3  -1  -3 [5  3  6] 7      6
 1  3  -1  -3  5 [3  6  7]     7

Output: [3, 3, 5, 5, 6, 7]

Why a Regular Queue Doesn’t Work

In earlier problems, we used a queue to maintain the window (LC 346). But a queue only gives O(1) access to the front — there’s no efficient way to find the maximum among all elements in the queue.

You might think of using a priority_queue (max-heap), but the problem is removal: when the window slides, you need to remove the oldest element, and heaps don’t support efficient deletion of arbitrary elements. You’d need lazy deletion, which adds complexity.


The Monotonic Deque Idea

A deque (double-ended queue) lets you push and pop from both the front and the back in O(1). The trick is to maintain the deque in decreasing order — the front is always the current maximum.

Here’s the core insight: you don’t need to store every element in the window. If a newer element is larger than older elements in the deque, those older elements can never be the maximum for any future window. They’re permanently shadowed. So we remove them.

The Two Rules

When inserting a new element:

  • While the deque is not empty AND the back of the deque is smaller than the new element → pop the back.
  • Then push the new element to the back.

This ensures the deque stays in decreasing order. The front is always the largest.

When the window slides (removing the oldest element):

  • Check if the front of the deque equals the element leaving the window.
  • If so, pop the front.
  • If not, do nothing — that element was already removed by the insertion rule.

This is why the deque doesn’t store all window elements. Smaller elements that appeared before a larger one get purged during insertion and never need explicit removal.


Solution (C++)

cpp

class Solution {
public:
    vector<int> maxSlidingWindow(vector<int>& nums, int k) {
        deque<int> maxNums;
        for (size_t i = 0; i < k; ++i) {
            while (!maxNums.empty() && maxNums.back() < nums[i]) {
                maxNums.pop_back();
            }
            maxNums.push_back(nums[i]);
        }

        vector<int> result = {maxNums.front()};

        for (size_t i = k; i < nums.size(); ++i) {
            if (maxNums.front() == nums[i-k]) {
                maxNums.pop_front();
            }

            while (!maxNums.empty() && maxNums.back() < nums[i]) {
                maxNums.pop_back();
            }
            maxNums.push_back(nums[i]);
            result.push_back(maxNums.front());
        }

        return result;
    }
};

Step-by-Step Trace

Let’s trace through nums = [1, 3, -1, -3, 5, 3, 6, 7] k = 3

Phase 1: Build the initial window [0..2]

inums[i]ActionDeque State
01Push 1[1]
133 > 1, pop 1. Push 3[3]
2-1-1 < 3, just push[3, -1]

First result: front() = 3

Phase 2: Slide the window

iLeavingnums[i]Remove front?Insert logicDequeResult
3nums[0]=1-31 ≠ 3, no-3 < -1, push[3, -1, -3]3
4nums[1]=353 == 3, pop front5 > all, clear then push[5]5
5nums[2]=-13-1 ≠ 5, no3 < 5, push[5, 3]5
6nums[3]=-36-3 ≠ 5, no6 > 3, pop; 6 > 5, pop; push[6]6
7nums[4]=575 ≠ 6, no7 > 6, pop; push[7]7

Final output: [3, 3, 5, 5, 6, 7]


Why O(n)?

It might look like the inner while loop makes this O(nk), but each element is pushed into the deque at most once and popped at most once. Across all iterations, the total number of push and pop operations is bounded by 2n. So the amortized time per element is O(1), and the overall complexity is O(n).

Time: O(n)
Space: O(k) — the deque holds at most k elements


A Note on the Value-Based Approach

The solution above stores values in the deque, not indices. This works correctly because the removal check (maxNums.front() == nums[i-k]) only pops the front when the outgoing value matches. If there are duplicate values in the window, this is safe — we only remove one occurrence, and the deque’s decreasing order guarantees correctness.

An alternative approach stores indices in the deque instead of values. This makes the out-of-window check explicit (if deque.front() <= i – k, pop) and avoids any ambiguity with duplicates. Both approaches are O(n), but the index-based version is sometimes considered more robust.


Series Recap

Across all 8 problems, we’ve seen the sliding window pattern in three levels:

Level 1: Basic Fixed Window (Part 1)

Problems: LC 346, 643, 1343

The window slides, you maintain a sum, and the evaluation is a simple comparison. The template is mechanical — once you recognize “fixed-size subarray,” the code almost writes itself.

Level 2: Conditional Fixed Window (Part 2)

Problems: LC 1176, 1052, 1456, 1100

Same window mechanics, but the state you track gets more interesting: multiple counters, character sets, hash maps. The skill here is decomposing the problem to identify what the window should track vs. what should be computed globally.

Level 3: Monotonic Deque (Part 3)

Problem: LC 239

The window concept is the same, but maintaining the max efficiently requires a new data structure. The monotonic deque keeps candidates in sorted order and prunes elements that can never be the answer.


When to Use Sliding Window

Ask yourself these questions:

  1. Does the problem involve a contiguous subarray or substring?
  2. Is the window size fixed (or bounded)?
  3. Can you incrementally update the window state as elements enter and leave?

If all three are yes, sliding window is almost certainly the right approach. Start with the basic template, figure out what state to track, and you’re most of the way there.


Thanks for following along! If you found this series helpful, check out my other posts on data structures and algorithms for interview prep.

Sliding Window Pattern – Part 2: Variations with Conditions

In Part 1, we covered the core fixed-size sliding window pattern: maintain a running sum, slide one element at a time, evaluate each window in O(1). The three problems we solved were almost identical in structure.

Now things get more interesting. The four problems in this post still use a fixed-size window, but each one adds a twist to what you track or how you evaluate the window. The underlying mechanics remain the same — the challenge is in decomposing the problem to recognize where the sliding window fits.


Problem 4: Diet Plan Performance (LC 1176)

Difficulty: Easy

Problem: A dieter consumes calories[i] on day i. For every consecutive sequence of k days, if total calories T > upper, gain 1 point; if T < lower, lose 1 point; otherwise, no change. Return total points.

Key Insight: This is the exact same window-sum pattern from Part 1, but the evaluation step has three branches instead of one. Instead of tracking a max or counting matches, you accumulate a score based on whether the window sum falls above, below, or between two thresholds.

Solution (C++)

cpp

class Solution {
public:
    int dietPlanPerformance(vector<int>& calories, int k, int lower, int upper) {
        int calConsumed = accumulate(begin(calories), begin(calories) + k, 0);
        int points = calConsumed > upper ? 1 : calConsumed < lower ? -1 : 0;
        for (size_t i = k; i < calories.size(); ++i) {
            calConsumed -= calories[i-k];
            calConsumed += calories[i];
            points += calConsumed > upper ? 1 : calConsumed < lower ? -1 : 0;
        }

        return points;
    }
};

What’s Different?

Only the scoring logic changed. The ternary expression calConsumed > upper ? 1 : calConsumed < lower ? -1 : 0 replaces the simple comparison from LC 1343. The window slide mechanics (-= calories[i-k], += calories[i]) are identical.

Time: O(n)
Space: O(1)


Problem 5: Grumpy Bookstore Owner (LC 1052)

Difficulty: Medium

Problem: A bookstore owner has customers[i] customers arriving at minute i. The owner is grumpy during minute i if grumpy[i] == 1 (customers unsatisfied), and not grumpy if grumpy[i] == 0 (customers satisfied). The owner can use a secret technique to suppress grumpiness for minutes consecutive minutes — but only once. Maximize total satisfied customers.

Key Insight: This is where problem decomposition matters. Break it into two independent pieces:

  1. Always-satisfied customers: Sum of customers[i] where grumpy[i] == 0 — these are satisfied no matter what.
  2. Bonus from the technique: A sliding window of size minutes over the “grumpy” minutes. Within this window, unsatisfied customers become satisfied. Maximize this bonus.

The answer is always_satisfied + max_bonus. The sliding window only tracks the unsatisfied customers (where grumpy[i] == 1) within the current window.

Solution (C++)

cpp

class Solution {
public:
    int maxSatisfied(vector<int>& customers, vector<int>& grumpy, int minutes) {
        int numComplained = 0;
        int numSatisfied = 0;
        for (size_t i = 0; i < minutes; ++i) {
            numComplained += grumpy[i] == 1 ? customers[i] : 0;
            numSatisfied += grumpy[i] == 0 ? customers[i] : 0;
        }

        int maxComplained = numComplained;
        for (size_t i = minutes; i < customers.size(); ++i) {
            numSatisfied += grumpy[i] == 0 ? customers[i] : 0;
            numComplained -= grumpy[i-minutes] == 1 ? customers[i-minutes] : 0;
            numComplained += grumpy[i] == 1 ? customers[i] : 0;
            maxComplained = max(maxComplained, numComplained);
        }

        return numSatisfied + maxComplained;
    }
};

Walkthrough

For customers = [1,0,1,2,1,1,7,5] grumpy = [0,1,0,1,0,1,0,1] minutes = 3

  • numSatisfied accumulates customers at non-grumpy minutes across the entire array (not just the window).
  • numComplained is the sliding window tracking unsatisfied customers that the technique could save.
  • maxComplained tracks the best window — the optimal placement of the technique.

The key subtlety: numSatisfied is computed globally while the window slides, not just within the window. The first loop handles indices [0, minutes), and the second loop handles the rest. Both loops contribute to numSatisfied.

Time: O(n)
Space: O(1)


Problem 6: Maximum Number of Vowels in a Substring (LC 1456)

Difficulty: Medium

Problem: Given a string s and integer k, return the maximum number of vowel letters in any substring of length k.

Key Insight: Instead of a numeric sum, the window tracks a count of vowels. When a character enters the window, check if it’s a vowel and increment. When a character leaves, check and decrement. Same pattern, different “currency.”

Solution (C++)

cpp

class Solution {
public:
    int maxVowels(string s, int k) {
        unordered_set<char> vowels = {'a', 'e', 'i', 'o', 'u'};
        int numVowels = 0;
        for (size_t i = 0; i < k; ++i) {
            numVowels += vowels.find(s[i]) != end(vowels) ? 1 : 0;
        }

        int result = numVowels;
        for (size_t i = k; i < s.size(); ++i) {
            numVowels -= vowels.find(s[i-k]) != end(vowels) ? 1 : 0;
            numVowels += vowels.find(s[i]) != end(vowels) ? 1 : 0;
            result = max(result, numVowels);
        }

        return result;
    }
};

Pattern Recognition

Compare this to LC 643 (Maximum Average Subarray):

LC 643LC 1456
Window stateSum of numbersCount of vowels
Add elementsum += nums[i]count += isVowel(s[i])
Remove elementsum -= nums[i-k]count -= isVowel(s[i-k])
Evaluatemax(maxSum, sum)max(result, count)

The structure is identical. Only the definition of “what to add/remove” changes.

Time: O(n)
Space: O(1)


Problem 7: Find K-Length Substrings With No Repeated Characters (LC 1100)

Difficulty: Medium

Problem: Given a string s and integer k, return the number of substrings of length k with no repeated characters.

Key Insight: This is a sliding window problem, but the “window state” is now a hash map tracking character positions. When a duplicate is found, we can jump the start of the window forward to skip past the previous occurrence.

This problem bridges fixed-size and variable-size sliding windows. The window target is still k, but duplicates can shrink the valid region, so we track a startIdx that may jump forward.

Solution (C++)

cpp

class Solution {
public:
    int numKLenSubstrNoRepeats(string s, int k) {
        // key: letter
        // value: index of the observed letter
        unordered_map<char, int> observed;

        int startIdx = 0;
        int result = 0;
        for (size_t i = 0; i < s.size(); ++i) {
            auto it = observed.find(s[i]);
            // letter is found
            if (it != end(observed)) {
                startIdx = max(startIdx, it->second + 1);
                // update start index to current one to avoid repeat char
                it->second = i;
            } else {
                // letter is not found. update map with letter and index
                observed.insert(it, make_pair(s[i], i));
            }

            if (i - startIdx + 1 == k) {
                result += 1;
                ++startIdx;
            }
        }
        return result;
    }
};

How It Differs

Unlike the previous problems where we always subtract the [i-k] element, here the window start (startIdx) can jump forward when a repeat is detected. The map stores each character’s most recent index, so when we encounter a duplicate, we know exactly where to move startIdx.

Once the window reaches size k with no repeats (i – startIdx + 1 == k), we count it and advance startIdx by 1 to look for the next valid window.

Time: O(n)
Space: O(min(n, 26)) — at most 26 lowercase letters in the map


The Evolving Pattern

Across these four problems, the sliding window template stayed consistent, but the state and evaluation grew more complex:

ProblemWindow StateEvaluation
Diet Plan (1176)SumThree-way comparison
Grumpy Owner (1052)Two counters (satisfied + complained)Sum of global + max window
Max Vowels (1456)Vowel countTrack maximum
K-Length No Repeats (1100)Hash map of positionsCount valid windows

The takeaway: when you see a fixed-size subarray/substring problem, start with the basic template. Then ask yourself: what do I need to track in the window, and how do I evaluate it?

In Part 3, we’ll tackle the hardest problem in this series — Sliding Window Maximum — where the challenge isn’t the window itself, but efficiently maintaining the maximum within it.


Next up: Sliding Window Part 3 — The Monotonic Deque

Sliding Window Pattern – Part 1: The Core Pattern and Basic Problems

If you’re preparing for coding interviews or just want to sharpen your algorithm skills, the sliding window technique is one of the most important patterns to master. It shows up everywhere — from easy warm-ups to hard interview favorites — and once you see the pattern, you’ll recognize it instantly.

In this 3-part series, I’ll walk you through 8 LeetCode problems that all use sliding window, progressing from the simplest form to more advanced variations. By the end, you’ll have a clear mental model for when and how to apply this technique.


What Is the Sliding Window Pattern?

Imagine you have an array and you need to look at every contiguous subarray of size k. The brute-force approach recalculates everything from scratch for each subarray — that’s O(nk). But notice: when the window slides one position to the right, only one element enters and one element leaves. Everything in the middle stays the same.

The sliding window technique exploits this overlap. Instead of recomputing, you update the result by adding the new element and removing the old one. This brings the time complexity down to O(n).

The general template looks like this:

  1. Initialize the window with the k elements
  2. Slide the window one step at a time: add nums[i], remove nums[i-k]
  3. Update your answer at each step

Let’s see this in action with three problems.


Problem 1: Moving Average from Data Stream (LC 346)

Difficulty: Easy

Problem: Given a stream of integers and a window size, calculate the moving average of all integers in the sliding window.

Key Insight: This is the purest form of sliding window. You maintain a queue of the last size numbers. Each time a new number arrives, push it in. If the queue exceeds the window size, pop the oldest number. Track the running sum so you can return the average in O(1).

Solution (C++)

cpp

class MovingAverage {
    int sum;
    queue<int> numbers;
    int size;
public:
    MovingAverage(int size) : sum(0), size(size) {

    }

    double next(int val) {
        sum += val;
        numbers.push(val);
        if (numbers.size() > size) {
            sum -= numbers.front();
            numbers.pop();
        }
        return (double)sum / numbers.size();
    }
};

Why It Works

  • sum tracks the running total so we never re-sum the entire window.
  • The queue naturally maintains insertion order — front() is always the oldest element.
  • When the queue grows beyond size, we subtract the front element from sum and pop it.

Time: O(1) per next() call
Space: O(k) where k = window size


Problem 2: Maximum Average Subarray I (LC 643)

Difficulty: Easy

Problem: Given an integer array nums and an integer k, find the contiguous subarray of length k with the maximum average value.

Key Insight: Same sliding window, but now instead of a stream, you have a fixed array. Compute the sum of the first k elements, then slide: add the new element, subtract the one that fell off. Track the maximum sum seen so far, and divide by k at the end.

Brute Force vs. Sliding Window

  • Brute force: For each starting index, sum up k elements → O(nk)
  • Sliding window: Maintain a running sum, update in O(1) per step → O(n)

Solution (C++)

cpp

class Solution {
public:
    double findMaxAverage(vector<int>& nums, int k) {
        int sumSoFar = 0;
        for (int i = 0; i < k; ++i) {
            sumSoFar += nums[i];
        }
        int maxSum = sumSoFar;
        for (int i = k; i < nums.size(); ++i) {
            sumSoFar += nums[i];
            sumSoFar -= nums[i-k];
            maxSum = max(maxSum, sumSoFar);
        }

        return (double)maxSum / k;
    }
};

Walkthrough

For nums = [1, 12, -5, -6, 50, 3] and k = 4

WindowElementsSum
[0..3]1, 12, -5, -62
[1..4]12, -5, -6, 5051
[2..5]-5, -6, 50, 342

Maximum sum is 51 → average = 51 / 4 = 12.75

Notice the two-phase structure: first loop initializes the window sum, second loop slides it. This is the canonical fixed-size sliding window template.

Time: O(n)
Space: O(1)


Problem 3: Number of Sub-arrays of Size K with Average ≥ Threshold (LC 1343)

Difficulty: Medium

Problem: Given an array arr, integers k and threshold, return the number of sub-arrays of size k whose average is greater than or equal to threshold.

Key Insight: Identical sliding window — the only change is what you do at each window position. Instead of tracking the max sum, you count how many windows meet the condition sum / k >= threshold.

Solution (C++)

cpp

class Solution {
public:
    int numOfSubarrays(vector<int>& arr, int k, int threshold) {
        int sum = accumulate(begin(arr), begin(arr) + k, 0);
        int result = sum / k >= threshold ? 1 : 0;

        for (int i = k ; i < arr.size(); ++i) {
            sum -= arr[i-k];
            sum += arr[i];
            result += sum / k >= threshold ? 1 : 0;
        }

        return result;
    }
};

What Changed?

Compared to the previous problem, only line that evaluates the window changed. Instead of maxSum = max(…), we have result += (condition) ? 1 : 0. The window mechanics are identical.

This is the beauty of the sliding window pattern: the window management code is the same every time. Only the evaluation logic changes based on what the problem asks.

Time: O(n)
Space: O(1)


The Pattern So Far

All three problems share this structure:

1. Compute initial window (first k elements)
2. Evaluate initial window
3. For i = k to n-1:
     a. Add nums[i] to window state
     b. Remove nums[i-k] from window state
     c. Evaluate current window
4. Return result

The only thing that varies is:

  • What state you track (sum, count, etc.)
  • How you evaluate (return average, track max, count matches)

In Part 2, we’ll see how this same pattern handles more interesting conditions — counting vowels, dealing with grumpy bookstore owners, and checking for unique characters.

Priority Queues & Binary Heaps Explained: How They Work Under the Hood

Imagine a hospital emergency room. Patients don’t get seen in the order they arrived — they get seen based on the severity of their condition. A patient with a heart attack jumps ahead of someone with a sprained ankle, regardless of who checked in first. That’s exactly the problem a priority queue solves in software.

In this post, we’ll cover what priority queues are, how the binary heap makes them efficient, and how the core operations — insertion, extraction, and heapify — actually work.


What Is a Priority Queue?

A priority queue is a specialized version of a regular queue. Instead of dequeuing elements in the order they arrived (FIFO), it always dequeues the element with the highest priority first.

Priority can be defined as:

  • Max-priority queue — the largest value is always at the front
  • Min-priority queue — the smallest value is always at the front

Real-World Use Cases

Use CasePriority Rule
OS job schedulerHigher priority tasks run first
Dijkstra’s shortest pathAlways process the closest unvisited node
Hospital triage systemMost critical patients seen first
Event simulationProcess earliest-timestamp events first

Priority queues are most commonly implemented using a binary heap stored in an array — which gives O(log n) for both insertion and extraction, much better than the O(n) you’d get with a sorted array or linked list.


What Is a Binary Heap?

A binary heap is a complete binary tree that satisfies the heap property:

  • Max-heap: Every node’s value is greater than or equal to its children’s values. The maximum is always at the root.
  • Min-heap: Every node’s value is less than or equal to its children’s values. The minimum is always at the root.

“Complete binary tree” means all levels are fully filled except possibly the last, which fills from left to right. This shape is what makes the array representation work so cleanly.

Max-heap example:

        50
       /  \
      31   40
     / \  /
    14 10 23

Every parent is larger than its children. The root (50) is always the maximum.

Time Complexity

OperationComplexity
Push (insert)O(log n)
Pop (extract top)O(log n)
Peek (read top)O(1)
Heapify (build from array)O(n)

Storing a Heap in an Array

The elegance of a binary heap is that you don’t need pointers or a node struct — the entire tree fits neatly into a flat array. The parent-child relationships are computed mathematically from the index.

0-based indexing (most common in code):

          A[0]
       /      \
     A[1]      A[2]
     / \        / \
   A[3] A[4] A[5] A[6]
RelationshipFormula
Parent of i(i - 1) / 2
Left child of i2 * i + 1
Right child of i2 * i + 2

So for the max-heap [50, 31, 40, 14, 10, 23]:

  • Node at index 2 (value 40): parent is index (2-1)/2 = 0 (value 50) ✓
  • Node at index 2 (value 40): left child is index 2*2+1 = 5 (value 23) ✓

No pointers needed — just arithmetic.


Operation 1: Push (Insertion)

Inserting into a heap always follows the same two-step pattern:

  1. Add the new element at the end of the array (to keep the tree complete)
  2. Bubble up — swap with the parent repeatedly until the heap property is restored

Step-by-Step Example (Max-heap)

Starting heap: [50, 31, 23, 14, 10] — inserting 40

Step 1: Append 40 to the end
  Array: [50, 31, 23, 14, 10, 40]
  40 is at index 5

Step 2: Bubble up — compare with parent
  Parent of index 5 = (5-1)/2 = index 2 → value 23
  40 > 23 → swap!
  Array: [50, 31, 40, 14, 10, 23]

Step 3: Continue bubbling up from index 2
  Parent of index 2 = (2-1)/2 = index 0 → value 50
  40 < 50 → heap property satisfied, stop ✓

Final: [50, 31, 40, 14, 10, 23]

As a tree:

        50
       /  \
      31   40      ← 40 bubbled up from the bottom
     / \  /
    14 10 23

Complexity: O(log n) — in the worst case, the new element bubbles all the way from the bottom to the root, traversing the height of the tree.


Operation 2: Pop (Extract Top)

Popping always removes the root (the max in a max-heap, min in a min-heap). But you can’t just delete the root — the tree would fall apart. The trick is a three-step process:

  1. Swap the root with the last element in the array
  2. Remove the last element (which is now the old root — this is your return value)
  3. Bubble down from the root — swap with the larger child until the heap property is restored

Step-by-Step Example (Max-heap)

Starting heap: [50, 31, 40, 14, 10, 23] — extracting the max (50)

Step 1: Swap root (50) with last element (23)
  Array: [23, 31, 40, 14, 10, 50]

Step 2: Remove last element (50) — this is returned
  Array: [23, 31, 40, 14, 10]

Step 3: Bubble down from root (index 0, value 23)
  Children: left = index 1 (31), right = index 2 (40)
  Largest child is 40 at index 2
  23 < 40 → swap!
  Array: [40, 31, 23, 14, 10]

Step 4: Continue from index 2 (value 23)
  Children: left = index 5, right = index 6 → both out of bounds
  No children — stop ✓

Final: [40, 31, 23, 14, 10]

As a tree:

        40          ← new max at root
       /  \
      31   23
     / \
    14  10

Complexity: O(log n) — the swapped element bubbles down at most the height of the tree.


Operation 3: Heapify (Build a Heap from an Array)

What if you have an unsorted array and want to turn it into a heap? You could push each element one by one — but that costs O(n log n). The heapify (or buildHeap) algorithm does it in O(n).

The key insight: leaf nodes (the bottom half of the array) are already valid heaps by themselves — they have no children to violate the heap property. So we only need to bubble down from the non-leaf nodes, working backwards from the last parent to the root.

python

def heapify(arr, i, n):
    left  = 2 * i + 1
    right = 2 * i + 2
    largest = i  # assume current node is largest

    # check if left child exists and is greater
    if left < n and arr[left] > arr[largest]:
        largest = left

    # check if right child exists and is greater
    if right < n and arr[right] > arr[largest]:
        largest = right

    # if current node is not the largest, swap and recurse
    if largest != i:
        arr[i], arr[largest] = arr[largest], arr[i]
        heapify(arr, largest, n)


def buildMaxHeap(arr, n):
    # start from the last non-leaf node, work up to root
    i = n // 2
    while i >= 0:
        heapify(arr, i, n)
        i -= 1

n // 2 gives you the index of the last parent node. Everything after that index is a leaf. By calling heapify on each parent from bottom to top, we guarantee every subtree satisfies the heap property by the time we process its parent.

Complexity: O(n) — counterintuitively, this is faster than pushing elements one by one (O(n log n)), because most of the work happens near the bottom of the tree where the subtrees are small.


Min-heap vs. Max-heap: Which to Use?

Max-heapMin-heap
Root holdsLargest valueSmallest value
Use when you needQuickly access the maximumQuickly access the minimum
ExampleFind the k largest elementsDijkstra’s algorithm
In Pythonheapq (negate values for max)heapq (default)
In C++priority_queue<int> (default)priority_queue<int, vector<int>, greater<int>>
In JavaPriorityQueue (default min)PriorityQueue (default)

Using Priority Queues in Practice

You almost never implement a binary heap from scratch. Every major language has one built in:

Python:

python

import heapq

# Min-heap (default)
heap = []
heapq.heappush(heap, 10)
heapq.heappush(heap, 5)
heapq.heappush(heap, 20)
print(heapq.heappop(heap))  # 5 — smallest first

# Max-heap: negate values
heapq.heappush(heap, -10)
print(-heapq.heappop(heap))  # 10 — largest first

C++:

cpp

#include <queue>

// Max-heap (default)
priority_queue<int> maxHeap;
maxHeap.push(10);
maxHeap.push(5);
maxHeap.push(20);
cout << maxHeap.top();  // 20
maxHeap.pop();

// Min-heap
priority_queue<int, vector<int>, greater<int>> minHeap;
minHeap.push(10);
cout << minHeap.top();  // smallest value

Summary

Priority queues are essential for any problem where you need to repeatedly access the “most important” element:

  • A priority queue dequeues by priority, not by arrival order
  • The binary heap is the standard implementation — a complete binary tree stored in a flat array
  • Parent-child relationships are computed with simple index math: left = 2i+1, right = 2i+2, parent = (i-1)/2
  • Push appends to the end and bubbles up: O(log n)
  • Pop swaps root with last element, removes it, then bubbles down: O(log n)
  • BuildHeap converts an unsorted array to a heap in O(n) by bubbling down from the last parent to the root
  • Peek at the top element is always O(1)

Once you understand the heap, algorithms like Dijkstra’s shortest path and Kruskal’s MST — both of which rely on a priority queue — become much clearer.

Tree Data Structures Explained: Binary Trees, BSTs & Tree Traversals

Arrays and hash tables are great for storing flat collections of data. But what about data that’s naturally hierarchical — like a company org chart, a file system, or the rules in a decision-making process? That’s where trees come in.

Trees are one of the most widely used data structures in computer science, and a solid understanding of them will help you with everything from database internals to coding interviews. In this post, we’ll cover what trees are, the most important types, and how to traverse them in four different ways.


What Is a Tree?

A tree is a hierarchical data structure made up of nodes connected by edges. Unlike graphs (which can have cycles and arbitrary connections), trees have a strict parent-child structure with no cycles.

Here’s the essential vocabulary you’ll need:

TermDefinition
RootThe topmost node — every tree has exactly one
Parent / ChildA node connected above (parent) or below (child) another
SiblingsNodes that share the same parent
Leaf NodeA node with no children
HeightLength of the longest path from the root to any leaf
DepthDistance from the root to a given node

A key distinction: trees are a special case of graphs — specifically, a connected, acyclic, directed graph. Every tree is a graph, but not every graph is a tree.


Binary Tree

A binary tree is a tree where each node has at most two children — called the left child and the right child.

        A
       / \
      B   C
     / \   \
    D   E   F

There are several subtypes worth knowing:

TypeRule
Full Binary TreeEvery node has exactly 0 or 2 children
Complete Binary TreeAll levels filled except possibly the last, which fills left to right
Perfect Binary TreeAll internal nodes have 2 children; all leaves at the same level
Skewed Binary TreeEvery node has only one child (degenerates into a linked list)

Complexity

A plain binary tree has no ordering rules, so searching for a value means checking every node in the worst case.

OperationAverageWorst
SearchO(n)O(n)
InsertO(n)O(n)
DeleteO(n)O(n)

This is where the Binary Search Tree improves things significantly.


Binary Search Tree (BST)

A Binary Search Tree is a binary tree with one extra rule: for every node, all values in its left subtree are less than or equal to it, and all values in its right subtree are greater.

        50
       /  \
      30   70
     / \  / \
    20 40 60 80

This ordering property is powerful — it lets you eliminate half the tree at every step, just like binary search on a sorted array.

Complexity

OperationAverageWorst
SearchO(log n)O(n)
InsertO(log n)O(n)
DeleteO(log n)O(n)

The catch is the worst case. If you insert values in sorted order (e.g., 1, 2, 3, 4, 5), the BST degenerates into a straight line — effectively a linked list — and you lose the O(log n) advantage. This is why balanced BSTs (like AVL trees or Red-Black trees) exist: they automatically restructure themselves to stay balanced and keep worst-case operations at O(log n).


N-ary Tree

While binary trees limit each node to two children, an N-ary tree allows any number of children per node. Instead of dedicated left and right pointers, each node holds an array of children.

cpp

struct NaryNode {
    int value;
    vector<NaryNode*> children;
};

File systems are a classic N-ary tree: a folder can contain any number of files or subfolders. The same traversal logic applies — you just iterate over the children array instead of two fixed pointers.


Tree Traversal

To search, insert, or delete in a tree, you need to visit the nodes in some order. There are four standard traversal methods, each visiting nodes in a different sequence.

All of them use the same TreeNode struct:

cpp

struct TreeNode {
    int value;
    TreeNode *left;
    TreeNode *right;
};

We’ll use this tree for all four examples:

        A
       / \
      B   C
     / \   \
    D   E   F

1. In-Order Traversal (Left → Root → Right)

Visit the left subtree first, then the current node, then the right subtree. On a BST, this always produces values in sorted ascending order — which makes it extremely useful.

Output on example tree: D, B, E, A, C, F

cpp

void inorder(TreeNode *node) {
    if (node == nullptr) return;

    inorder(node->left);       // 1. go left
    print(node->value);        // 2. visit current
    inorder(node->right);      // 3. go right
}

Use case: Printing all values of a BST in sorted order.


2. Pre-Order Traversal (Root → Left → Right)

Visit the current node first, then recurse left, then recurse right. The root is always the first node visited.

Output on example tree: A, B, D, E, C, F

cpp

void preorder(TreeNode *node) {
    if (node == nullptr) return;

    print(node->value);        // 1. visit current
    preorder(node->left);      // 2. go left
    preorder(node->right);     // 3. go right
}

Use case: Copying or serializing a tree — since the root is printed before its subtrees, you can reconstruct the tree from a pre-order sequence.


3. Post-Order Traversal (Left → Right → Root)

Recurse left, recurse right, then visit the current node last. The root is always the last node visited.

Output on example tree: D, E, B, F, C, A

cpp

void postorder(TreeNode *node) {
    if (node == nullptr) return;

    postorder(node->left);     // 1. go left
    postorder(node->right);    // 2. go right
    print(node->value);        // 3. visit current
}

Use case: Deleting a tree (delete children before parent) or evaluating expression trees (compute sub-expressions before the operator).


4. Level-Order Traversal (Level by Level)

Unlike the three traversals above — which all use recursion and go deep first — level-order visits nodes one level at a time, left to right. This is essentially BFS applied to a tree, and it uses a queue instead of recursion.

Output on example tree: A, B, C, D, E, F

cpp

void levelorder(TreeNode *node) {
    if (node == nullptr) return;

    queue<TreeNode*> q;
    q.push(node);

    while (!q.empty()) {
        int size = q.size();  // number of nodes at this level

        for (int i = 0; i < size; ++i) {
            TreeNode *curr = q.front();
            q.pop();
            cout << curr->value << " ";

            if (curr->left  != nullptr) q.push(curr->left);
            if (curr->right != nullptr) q.push(curr->right);
        }
        cout << endl;  // newline after each level
    }
}

Use case: Finding the shortest path in a tree, printing trees level by level, or anything where proximity to the root matters.


Traversal Comparison at a Glance

Using the same tree for all four:

        A
       / \
      B   C
     / \   \
    D   E   F
TraversalOrderOutputKey Use Case
In-orderLeft → Root → RightD, B, E, A, C, FSorted output from BST
Pre-orderRoot → Left → RightA, B, D, E, C, FTree serialization / copying
Post-orderLeft → Right → RootD, E, B, F, C, ADeletion, expression evaluation
Level-orderLevel by levelA, B, C, D, E, FShortest path, BFS-style problems

A simple memory trick: the name tells you when the current node is visited relative to its children.

  • Pre-order → current node before children
  • In-order → current node between children
  • Post-order → current node after children

Summary

Trees are essential for modeling hierarchical data, and understanding them opens the door to a huge range of algorithms and system designs:

  • A tree is a hierarchical, acyclic structure with a single root
  • A binary tree limits each node to two children; a BST adds an ordering rule for O(log n) average search
  • N-ary trees generalize to any number of children, stored as an array
  • In-order, pre-order, and post-order traversals use recursion and differ only in when the current node is visited
  • Level-order traversal uses a queue and visits nodes level by level

Mastering these four traversal patterns also sets you up for more advanced topics like heap data structures, segment trees, and tries — all of which build on these same fundamentals.


This post is part of a series on core data structures and algorithms. Hash Tables Explained Graph Data Structures: A Beginner’s Guide BFS & DFS: Graph Traversal Algorithms BFS & DFS: Graph Traversal Algorithms

Hash Tables Explained: How Hashmaps Work Under the Hood

If you’ve ever used a dictionary in Python, an object in JavaScript, or an unordered_map in C++, you’ve already used a hash table. It’s one of the most important data structures in programming — and once you understand how it works internally, a lot of things in software development start to click.

In this post, we’ll cover everything from the basics of key-value pairs to hash functions, collision handling, and the trade-offs behind different implementations.


What Is a Hash Table?

A hash table (also called a hashmap) is a data structure that stores key-value pairs and allows you to look them up extremely fast — on average in O(1) time, regardless of how many items are stored.

The three core operations are:

OperationWhat it doesAverage Time
get(key)Returns the value associated with the keyO(1)
set(key, value)Stores the key-value pairO(1)
delete(key)Removes the key-value pairO(1)

One important note: if you set the same key twice with different values, the latest value always wins — the old one gets overwritten. Space complexity is O(n), where n is the number of stored pairs.


Key-Value Pairs

Every entry in a hash table has two parts:

  • Key — used to identify the entry (like a word in a dictionary)
  • Value — the data associated with that key (like the definition)

A phone book is a classic example: the key is a person’s name, and the value is their phone number. You look up the name (key) to get the number (value). Hash tables make this lookup nearly instant, no matter how large the phone book is.


How Is a Hash Table Implemented Internally?

Under the hood, a hash table needs a data structure to actually store the pairs. There are three options:

Array (Best Choice)

Arrays support random access by index in O(1) time. If we can convert any key into a valid array index, we get O(1) get and set for free. This is the standard implementation and what we’ll focus on.

Binary Search Tree (2nd Choice)

A BST organized by key gives O(log n) get/set/delete. This is slower than an array, but it has one advantage: keys are kept in sorted order. Use this when you need to iterate over keys in a sorted sequence.

Linked List (Avoid)

Get, set, and delete all cost O(n). There’s rarely a reason to implement a hashmap this way — the array-based approach already covers everything a linked list would offer, but faster.


The Hash Function

Here’s the core challenge: array indices are integers, but keys can be anything — strings, floats, objects. How do you convert "Alice" into a valid array index?

That’s the job of the hash function.

index = H(key)

A hash function takes any key and deterministically returns a number. For example:

H("Alice") = 5
→ store ("Alice", "1-201-123-4567") at array index 5

The Modulus Problem

In practice, standard library hash functions return very large numbers — far bigger than your array. To fit the result into the array, you apply a modulus:

index = H(k) mod m

Where k is the key and m is the size of the array. This guarantees the index always falls within bounds. For example, if H(k) = 1,482,910 and m = 10, then index = 0.

This works great — with one catch.


Hash Collisions

When two different keys hash to the same array index, that’s a collision.

H(k1) = 4,  H(k2) = 8,  array size m = 4
4 % 4 = 0
8 % 4 = 0  ← same index!

k1 and k2 are completely different keys, but they land in the same slot. We can’t just overwrite one with the other — we need to keep both. There are two standard ways to handle this.


Option 1: Chaining

In chaining, each array slot holds a linked list instead of a single value. When two keys collide at the same index, they’re both stored in the list at that slot.

Index 0: [ (key0, val0) ] → [ (key1, val1) ] → [ (key2, val2) ]
Index 1: [ empty ]
Index 2: [ (key3, val3) ] → [ (key4, val4) ]
Index 3: [ empty ]
Index 4: [ empty ]
Index 5: [ (key5, val5) ]

When you do a get, you go to the index and walk the linked list until you find the matching key.

The downside: In the absolute worst case, every single key hashes to the same index — turning the whole table into one long linked list with O(n) lookup. This is rare in practice, but it can happen with a bad hash function or a poorly sized array.


Option 2: Probing (Open Addressing)

Instead of linking collisions in a chain, probing keeps everything inside the array itself. When a collision occurs, you probe (scan) for the next available slot.

  • Linear probing: check index+1, index+2, index+3, …
  • Quadratic probing: check index+1², index+2², index+3², …

Probing avoids the overhead of linked list nodes and can be more cache-friendly, but it requires careful handling of deletions and can degrade if the array gets too full.


Preventing Collisions: Practical Improvements

Collisions can never be completely eliminated, but you can significantly reduce them with three strategies:

1. Use a prime number for array size. Since we use H(k) % m, the choice of m matters. Prime numbers distribute remainders more evenly and reduce the chance of clustering. For example, size 11 or 97 is better than size 10 or 100.

2. Resize when the table gets full. Most real-world implementations track the load factor — the ratio of stored entries to array size. When it exceeds a threshold (commonly 60–75%), the table is resized to a larger array and all entries are rehashed. This keeps collision rates low as the table grows.

3. Choose a good hash function. A good hash function spreads keys evenly across the array. Language standard libraries (Python’s hash(), Java’s hashCode(), C++’s std::hash) provide well-tested implementations — you almost never need to write one yourself.


The Good News

You don’t need to implement any of this from scratch. Every major programming language ships with a hash table in its standard library:

LanguageHash Table Type
Pythondict
JavaScriptObject / Map
JavaHashMap
C++unordered_map
Gomap

Understanding the internals helps you reason about performance — for instance, knowing that worst-case lookup is O(n) (not O(1)) helps you write better code under adversarial inputs or heavy load.


Summary

Hash tables are one of the most useful data structures you’ll encounter, and now you know why they work so well:

  • Key-value pairs let you store and retrieve data by meaningful identifiers
  • Array-based storage with a hash function gives average O(1) access
  • Modulus maps any hash value to a valid array index
  • Collisions are unavoidable, but handled via chaining or probing
  • Prime-sized arrays, load factor resizing, and good hash functions keep collision rates low in practice

Once you’re comfortable with hash tables, you’ll start seeing them everywhere — they’re the backbone of caches, databases, compilers, and countless interview problems.


Want to keep learning? Check out the rest of this series: Graph Data Structures: A Beginner’s Guide BFS & DFS: Graph Traversal Algorithms Explained Dijkstra, MST & Union Find

Dijkstra’s Algorithm, Minimum Spanning Trees & Union Find Explained

In Part 1 we covered what graphs are and how to represent them. In Part 2 we explored BFS and DFS — the two ways to traverse a graph. Now it’s time for the advanced algorithms that solve real engineering problems: finding the shortest weighted path, connecting a network at minimum cost, and efficiently grouping nodes.

This is Part 3 of a 3-part series. Start from Part 1 if you’re new to graphs.


Dijkstra’s Algorithm — Shortest Path in a Weighted Graph

The Problem

BFS finds the shortest path in an unweighted graph (fewest edges). But what if edges have costs — distances, latency, price? BFS treats a 1km road the same as a 1000km road. We need something smarter.

Dijkstra’s algorithm computes the shortest distance from one source vertex to every other vertex in a weighted graph. It’s the algorithm inside your GPS.

Important constraint: Dijkstra only works correctly with non-negative edge weights. If your graph has negative weights, use Bellman-Ford instead.

The Core Idea: Greedy Relaxation

Dijkstra always processes the vertex with the smallest known distance next. When it processes a vertex, it checks whether going through that vertex offers a shorter route to any of its neighbors. Updating a neighbor’s distance is called relaxation.

Think of it like this: you’re planning a road trip and you always pick the nearest unvisited city next. From there, you check if you can reach other cities more cheaply by routing through it.

Step-by-Step Example

Graph (directed, weighted):
A → B : 5
A → C : 1
A → D : 4
A → E : 10
C → D : 2
D → E : 5
B → E : 6

Starting from A, with all distances initialized to ∞:

Initial:  dist = {A:0, B:∞, C:∞, D:∞, E:∞}
Heap:     [(0, A)]
Given this is min-heap, it always processes the node with the minimum distance value

Process A (dist 0):
  Relax A→B: dist[B] = 0+5 = 5  ✓
  Relax A→C: dist[C] = 0+1 = 1  ✓
  Relax A→D: dist[D] = 0+4 = 4  ✓
  Relax A→E: dist[E] = 0+10 = 10 ✓
  Pop A
  Heap: [(1,C), (4,D), (5,B), (10,E)]

Process C (dist 1):
  Relax C→D: dist[D] = min(4, 1+2) = 3  ← update!
  Pop C
  Heap: [(3,D), (4,D-old), (5,B), (10,E)]

Process D (dist 3):
  Relax D→E: dist[E] = min(10, 3+5) = 8  ← update!
  Pop D (dist 3)
  Heap: [(4,D-old-skip), (5,B), (8,E)]

Process D (dist 4, D-old-skip):
  Nothing to update

Process B (dist 5):
  Relax B→E: dist[E] = min(8, 5+6) = 8  ← no change

Process E (dist 8): Done ✓

Result: A→B=5, A→C=1, A→D=3 (via C), A→E=8 (via C→D)

Dijkstra’s in C++

cpp

void dijkstra(unordered_map<int, vector<pair<int,int>>> &graph,
              vector<int> &dist) {
    // Min-heap stores (current_dist, node)
    priority_queue<pair<int,int>,
                   vector<pair<int,int>>,
                   greater<pair<int,int>>> pq;

    pq.push({0, source});

    while (!pq.empty()) {
        auto [currDist, u] = pq.top();
        pq.pop();

        // Stale entry in heap — skip
        if (currDist > dist[u]) continue;

        for (auto &[v, weight] : graph[u]) {
            if (dist[u] + weight < dist[v]) {
                dist[v] = dist[u] + weight;
                pq.push({dist[v], v});  // relax
            }
        }
    }
}

// Setup in main():
vector<int> dist(n, INT_MAX);
dist[source] = 0;
dijkstra(graph, dist);

Time complexity: O((V + E) log V) with a binary min-heap Key data structure: Priority queue (min-heap)


Minimum Spanning Tree (MST)

The Problem

You’re building a network — fiber cables between offices, or roads between cities. You need to connect all nodes while minimizing total cost. But you don’t want redundant connections (cycles waste money).

This is exactly the Minimum Spanning Tree problem.

A spanning tree of an undirected connected graph:

  • Includes every vertex
  • Has exactly V−1 edges
  • Contains no cycles

The MST is the spanning tree with the smallest possible total edge weight.

Quick Example

Graph:   (A) --1-- (B)
          |    \    |
          4     5   3
          |      \  |
         (C) --2-- (D)

All possible spanning trees use 3 edges (4 nodes − 1). The MST picks:

  • A–B (weight 1)
  • C–D (weight 2)
  • B–D (weight 3)
  • Total: 6 — cheaper than any other combination

Edges A–C (4) and A–D (5) are excluded because cheaper alternatives connect those components.

Two Algorithms to Find the MST

There are two classic algorithms, each with a different strategy:


Kruskal’s Algorithm — Edge-Centric

Strategy: Sort all edges by weight. Add them one by one, skipping any that would create a cycle. Stop when you have V−1 edges.

How it detects cycles: Union-Find (we’ll cover this next).

cpp

void kruskal(UnionFind &uf, vector<Edge> &edges, vector<Edge> &mst) {
    // Sort edges by weight (ascending)
    sort(edges.begin(), edges.end(),
         [](const Edge &a, const Edge &b) {
             return a.weight < b.weight;
         });

    for (auto &e : edges) {
        // Only add if the two endpoints are in different components
        if (uf.find(e.src) != uf.find(e.dst)) {
            uf.merge(e.src, e.dst);
            mst.push_back(e);
        }
        if (mst.size() == numNodes - 1) break; // MST complete
    }
}

Time complexity: O(E log E) — dominated by sorting


Prim’s Algorithm — Vertex-Centric

Strategy: Start from any vertex. Greedily add the cheapest edge that connects a vertex already in the MST to one outside it. Repeat until all vertices are included.

It works similarly to Dijkstra’s — a min-heap tracks the cheapest way to add each unvisited vertex.

cpp

void prim(unordered_map<int, vector<pair<int,int>>> &g) {
    vector<bool> inMST(numNodes + 1, false);
    vector<int> dist(numNodes + 1, INT_MAX);

    priority_queue<pair<int,int>,
                   vector<pair<int,int>>,
                   greater<pair<int,int>>> pq;

    dist[0] = 0;
    pq.push({0, 0});
    int mstCost = 0;

    while (!pq.empty()) {
        auto [currDist, node] = pq.top();
        pq.pop();

        if (inMST[node]) continue;
        inMST[node] = true;
        mstCost += currDist;

        for (auto &[neighbor, weight] : g[node]) {
            if (!inMST[neighbor] && weight < dist[neighbor]) {
                dist[neighbor] = weight;
                pq.push({dist[neighbor], neighbor});
            }
        }
    }
}

Time complexity: O(E log V)


Kruskal’s vs. Prim’s

Kruskal’sPrim’s
ApproachEdge-centricVertex-centric
Data structureUnion-FindMin-heap
Best forSparse graphsDense graphs
ComplexityO(E log E)O(E log V)

Both produce a valid MST — the choice is about which fits your graph’s structure better.


Union Find — The Power Tool Behind Kruskal’s

The Problem

When building the MST with Kruskal’s, we need to answer one question repeatedly and fast: do these two nodes already belong to the same connected component? If yes, adding the edge between them would create a cycle — skip it.

Union Find (also called Disjoint Set Union, or DSU) is a data structure designed exactly for this.

Three Operations

  • Make-Set(x): Initialize x as its own group (it’s its own root)
  • Find(x): Return the root/representative of x’s group
  • Union(x, y): Merge the groups containing x and y

Two Key Optimizations

Without optimizations, Find can be O(n) in the worst case (a long chain). Two techniques fix this:

1. Union by Size: When merging two trees, always attach the smaller tree under the larger one. This keeps the tree height at O(log n).

2. Path Compression: When calling Find(x), make every node on the path point directly to the root. Future finds on those nodes become nearly instant.

Together, these bring Find and Union to amortized nearly O(1) — effectively constant time for all practical purposes.

Union Find in C++

cpp

class UnionFind {
    vector<int> parents, sizes;
public:
    UnionFind(int n) : parents(n), sizes(n, 1) {
        // Each node starts as its own parent
        iota(parents.begin(), parents.end(), 0);
    }

    int find(int node) {
        if (parents[node] == node) return node;
        // Path compression: point directly to root
        return parents[node] = find(parents[node]);
    }

    void merge(int a, int b) {
        a = find(a);
        b = find(b);
        if (a == b) return; // Already same component

        // Union by size: smaller tree goes under larger
        if (sizes[a] < sizes[b]) swap(a, b);
        parents[b] = a;
        sizes[a] += sizes[b];
    }

    bool connected(int a, int b) {
        return find(a) == find(b);
    }
};

Where Union Find Shows Up

  • Kruskal’s MST — detect cycles as edges are added
  • Connected components — group nodes dynamically as edges arrive
  • Network connectivity — check if two servers can communicate in real time
  • Social grouping — merge friend groups, detect clusters
  • Pixel clustering — image segmentation by connecting similar pixels

Putting It All Together

Here’s a quick decision guide for picking the right algorithm:

ProblemAlgorithmWhy
Shortest path, unweightedBFSSimplest, O(V+E)
Shortest path, weighted (non-negative)DijkstraGreedy + min-heap
Shortest path, negative weightsBellman-FordHandles negatives
Minimum cost to connect all nodesKruskal or PrimMST algorithms
Are two nodes in the same component?Union FindNear O(1) per query
Valid ordering of dependenciesTopological Sort (DFS)Works on DAGs

Series Wrap-Up

Over these three posts, you’ve gone from zero to covering the most important graph algorithms used in real software systems:

  1. Part 1 — Graphs, terminology, adjacency list vs. matrix
  2. Part 2 — BFS (shortest unweighted path), DFS (exploration + cycle detection), Topological Sort
  3. Part 3 — Dijkstra (weighted shortest path), MST with Kruskal & Prim, Union Find

The best way to solidify this knowledge is to practice. Try these problems on LeetCode or Codeforces:

  • BFS/DFS: Number of Islands (LC 200), Clone Graph (LC 133)
  • Topological Sort: Course Schedule (LC 207), Course Schedule II (LC 210)
  • Dijkstra: Network Delay Time (LC 743), Path with Minimum Effort (LC 1631)
  • Union Find: Number of Connected Components (LC 323), Accounts Merge (LC 721)

Good luck, and happy graph traversing!

BFS vs DFS Explained: Graph Traversal Algorithms with Code Examples

Every graph algorithm starts with the same fundamental question: how do we visit every node? Answering that question efficiently is the job of the two core traversal algorithms — Breadth-First Search (BFS) and Depth-First Search (DFS).

By the end of this post you’ll understand how both work, when to use each, and how DFS extends naturally into topological sort — one of the most useful tools in any developer’s toolkit.

This is Part 2 of a 3-part series. Start with Part 1 if you haven’t yet.


The Node Status Model

Before writing any traversal code, it helps to think of nodes as having three possible states. Both BFS and DFS track these:

StatusMeaning
UNDISCOVEREDHaven’t seen this node yet
DISCOVEREDFound it, but haven’t finished processing its neighbors
PROCESSEDDone — all neighbors explored

cpp

enum Status { UNDISCOVERED, DISCOVERED, PROCESSED };

This three-state model is more useful than a simple visited boolean because it lets you detect cycles: if you reach a node that’s DISCOVERED but not yet PROCESSED, you’ve found a back edge (and therefore a cycle in a directed graph).


Breadth-First Search (BFS)

The Core Idea

BFS explores a graph level by level. Starting from a source node, it visits all immediate neighbors first, then their neighbors, and so on — like a ripple spreading across water.

BFS is your go-to when you need the shortest path in an unweighted graph. The first time BFS reaches a node, it’s guaranteed to have taken the minimum number of edges to get there.

Step-by-Step Walkthrough

Consider this graph (undirected):
Node 1 has neighbor 2, 3
Node 2 has neighbor 1, 4
Node 3 has neighbor 1, 4
Node 4 has neighbor 2, 3
Node 5 has neighbor 4

    1
   / \
  2   3
  |   |
  4---+
  |
  5

Starting from node 1:

Step 1: Enqueue 1. Mark 1 DISCOVERED.
  Queue: [1]

Step 2: Dequeue 1. Enqueue neighbors 2, 3. Mark them DISCOVERED. Mark 1 PROCESSED.
  Queue: [2, 3]

Step 3: Dequeue 2. Enqueue neighbor 4. Mark 4 DISCOVERED. Mark 2 PROCESSED.
  Queue: [3, 4]

Step 4: Dequeue 3. Neighbor 4 is already DISCOVERED — skip. Mark 3 PROCESSED.
  Queue: [4]

Step 5: Dequeue 4. Enqueue 5. Mark 5 DISCOVERED. Mark 4 PROCESSED.
  Queue: [5]

Step 6: Dequeue 5. No new neighbors. Mark 5 PROCESSED. Done ✓
  Queue: []

Visit order: 1 → 2 → 3 → 4 → 5

BFS in C++

cpp

void bfs(unordered_map<int, vector<int>> &graph) {
    queue<int> q;
    vector<Status> status(graph.size(), UNDISCOVERED);

    q.push(0);
    status[0] = DISCOVERED;

    while (!q.empty()) {
        int u = q.front();
        q.pop();

        for (int v : graph[u]) {
            if (status[v] == UNDISCOVERED) {
                q.push(v);
                status[v] = DISCOVERED;
            } else if (status[v] == DISCOVERED) {
                // In a directed graph: cycle detected
                // In an undirected graph: this is just the parent node
            }
        }
        status[u] = PROCESSED;
    }
}

Data structure: Queue (FIFO — first discovered, first explored) Time complexity: O(V + E) — each vertex and edge is touched once Space complexity: O(V) — the queue holds at most V nodes


Depth-First Search (DFS)

The Core Idea

DFS goes as deep as possible along one path before backtracking. Rather than spreading out like BFS, it dives straight down one branch, hits a dead end, unwinds, and tries the next branch.

DFS is naturally implemented with recursion (which uses the call stack implicitly). It’s the foundation for cycle detection, topological sort, and finding connected components.

Step-by-Step Walkthrough

Same graph as before, starting from node 1:

    1
   / \
  2   3
  |   |
  4---+
  |
  5

Call dfs(1): Mark 1 DISCOVERED. Visit neighbor 2.
  Call dfs(2): Mark 2 DISCOVERED. Visit neighbor 4.
    Call dfs(4): Mark 4 DISCOVERED. Visit neighbor 5.
      Call dfs(5): Mark 5 DISCOVERED. No new neighbors.
      Mark 5 PROCESSED. ← first to finish
    Mark 4 PROCESSED.
  Mark 2 PROCESSED.
  Back at node 1 — visit neighbor 3.
  Call dfs(3): Mark 3 DISCOVERED. Neighbor 4 already PROCESSED — skip.
  Mark 3 PROCESSED.
Mark 1 PROCESSED. Done ✓

Process order: 5 → 4 → 2 → 3 → 1  (nodes finish deepest-first)

DFS in C++

cpp

void dfs(const unordered_map<int, vector<int>> &graph,
         int curr,
         vector<Status> &status) {

    status[curr] = DISCOVERED;

    for (int neighbor : graph[curr]) {
        if (status[neighbor] == UNDISCOVERED) {
            dfs(graph, neighbor, status);
        } else if (status[neighbor] == DISCOVERED) {
            // Directed graph  → back edge, cycle detected
            // Undirected graph → this is the parent node
        }
    }
    status[curr] = PROCESSED;
}

Data structure: Call stack (recursion) or explicit stack Time complexity: O(V + E) Space complexity: O(V) for the recursion stack


BFS vs. DFS: When to Use Which

BFSDFS
Core structureQueueStack / Recursion
Shortest path (unweighted)YesNo
Cycle detectionYesYes
Topological sortNoYes
Memory (wide graphs)HigherLower
Memory (deep graphs)LowerHigher (stack depth)
Time complexityO(V + E)O(V + E)

Rule of thumb:

  • Need shortest path in an unweighted graph? → BFS
  • Need to explore all possibilities, detect cycles, or sort dependencies? → DFS

Topological Sort (DFS Extension)

Topological sort orders the vertices of a Directed Acyclic Graph (DAG) so that every edge points from left to right — meaning, every dependency comes before the thing that depends on it.

Classic example: course prerequisites

                 
A ──► B ──► D ──► C
│           ▲     ▲
└───────────┘     ┘
└─────────────────┘
Edges: A→B, A→C, A→D, B→D, D→C

Suppose you need to take these courses, with prerequisites:

  • A is required before B, C, and D
  • A and B are required before D
  • A, B, and D are required before C

The correct order to take them: A → B → D → C

If there’s a cycle in the graph (course X requires Y, and Y requires X), no valid order exists — topological sort is impossible.

The Algorithm

Topological sort is just DFS with one extra line: push each node onto a stack when it finishes (after all its successors are processed). Pop the stack at the end and that’s your order.

cpp

void dfs(const unordered_map<int, vector<int>> &graph,
         int curr,
         vector<Status> &status,
         stack<int> &result) {

    status[curr] = DISCOVERED;

    for (int neighbor : graph.at(curr)) {
        if (status[neighbor] == UNDISCOVERED) {
            dfs(graph, neighbor, status, result);
        } else if (status[neighbor] == DISCOVERED) {
            // Cycle detected — topological sort impossible!
            cout << "Cycle found. No topological order exists." << endl;
            exit(1);
        }
    }

    status[curr] = PROCESSED;
    result.push(curr);  // ← the only change from plain DFS
}

// In main():
stack<int> topSort;
dfs(graph, 0, status, topSort);

while (!topSort.empty()) {
    cout << topSort.top() << " ";
    topSort.pop();
}

Why the stack gives the right order: A node is pushed only after all of its successors have been pushed. So when you pop, successors always come out before the things they depend on — wait, the opposite: predecessors come out first. That’s exactly what we want.


Common Pitfalls

Disconnected graphs — if your graph has multiple components, a single BFS/DFS from one starting node won’t visit all vertices. Wrap your traversal in a loop that checks all nodes:

cpp

for (int i = 0; i < n; i++) {
    if (status[i] == UNDISCOVERED) {
        dfs(graph, i, status);
    }
}

Stack overflow in DFS — deep recursive DFS on a graph with 10,000+ nodes can blow the call stack. For very deep graphs, convert to an iterative implementation using an explicit stack.


Summary

  • BFS uses a queue, explores level by level, and finds shortest paths in unweighted graphs
  • DFS uses recursion (or a stack), dives deep first, and is the basis for cycle detection and topological sort
  • Both run in O(V + E) time with an adjacency list
  • Topological sort = DFS + push to a result stack on node completion; only works on DAGs

In Part 3, we’ll tackle the advanced algorithms: Dijkstra’s shortest path for weighted graphs, Kruskal’s and Prim’s Minimum Spanning Tree algorithms, and the Union Find data structure that powers them.