Module 9.2

Classic Practice Problems

Sharpen your C++ skills by solving classic algorithmic problems. Learn essential patterns like two pointers, sliding window, and prefix sums that appear in technical interviews and real-world applications.

45 min read
Intermediate
Hands-on Problems
What You'll Learn
  • Two pointer technique for arrays
  • Sliding window pattern
  • Prefix sum optimization
  • Sorting-based problem solving
  • Hash map frequency counting
Contents
01

Search Algorithms

Searching is the fundamental operation of finding a specific element in a collection. Understanding search algorithms is essential because they form the basis of countless applications, from database queries to finding files on your computer.

Linear Search

Linear search is the simplest search algorithm. It checks each element one by one until it finds the target or reaches the end. While not the fastest, it works on any collection - sorted or unsorted.

#include <vector>

int linearSearch(const std::vector<int>& arr, int target) {
    for (int i = 0; i < arr.size(); i++) {

Starting the Search: Linear search is like looking for a book on a shelf by checking each book from left to right. We pass the array by const reference (to avoid copying) and the target value we're looking for. The for loop will visit every element starting from index 0, incrementing i each time until we reach the end of the array.

        if (arr[i] == target) {
            return i;  // Found! Return the index
        }
    }
    return -1;  // Not found
}

Check and Return: For each element, we ask: "Is this what we're looking for?" If arr[i] equals our target, we've found it! We return the index i so the caller knows WHERE we found it. If we finish the entire loop without finding the target, we return -1 (a convention meaning "not found"). Time: O(n) - we might check every element. Space: O(1) - no extra memory needed.

Binary Search

Binary search is dramatically faster than linear search, but it requires the array to be sorted. Instead of checking every element, it repeatedly divides the search space in half, achieving O(log n) time complexity.

#include <vector>

int binarySearch(const std::vector<int>& arr, int target) {
    int left = 0;
    int right = arr.size() - 1;

Setting Up Search Boundaries: Binary search works like finding a word in a dictionary - you open to the middle and decide if your word comes before or after. We use two pointers: left starts at the beginning (index 0) and right starts at the end (last index). These boundaries define our current search space - the target MUST be between them if it exists.

    while (left <= right) {
        int mid = left + (right - left) / 2;  // Safe middle calculation
        
        if (arr[mid] == target) {
            return mid;  // Found!
        }

Finding the Middle: We continue searching while our search space is valid (left <= right). The middle index is calculated as left + (right - left) / 2 instead of (left + right) / 2 to prevent integer overflow when dealing with large arrays. If the middle element equals our target, we've found it and return its index immediately!

        if (arr[mid] < target) {
            left = mid + 1;   // Target is in right half
        } else {
            right = mid - 1;  // Target is in left half
        }
    }
    return -1;  // Not found
}

Halving the Search Space: Since the array is sorted, we can eliminate half the remaining elements with each comparison! If arr[mid] is less than target, our target must be to the RIGHT, so we move left pointer past mid. Otherwise, target is to the LEFT, so we move right pointer before mid. Each iteration cuts our search space in half. Time: O(log n) - halving means we need only ~20 steps for a million elements! Space: O(1).

Binary Search Variations

// Find first position where target could be inserted (lower bound)
int lowerBound(const std::vector<int>& arr, int target) {
    int left = 0, right = arr.size();
    
    while (left < right) {
        int mid = left + (right - left) / 2;
        if (arr[mid] < target) {
            left = mid + 1;
        } else {
            right = mid;
        }
    }
    return left;
}

Lower Bound - First Valid Position: Lower bound finds the FIRST position where we could insert the target while keeping the array sorted. If target exists, it returns its first occurrence. If not, it returns where it WOULD go. Notice right starts at arr.size() (not size-1) because the answer could be "insert at the end". This is extremely useful for range queries and finding the first element >= target.

// Find first position AFTER target (upper bound)
int upperBound(const std::vector<int>& arr, int target) {
    int left = 0, right = arr.size();
    
    while (left < right) {
        int mid = left + (right - left) / 2;
        if (arr[mid] <= target) {
            left = mid + 1;
        } else {
            right = mid;
        }
    }
    return left;
}

Upper Bound - First Greater Position: Upper bound finds the first position where element is GREATER than target. The only difference from lower bound is we use <= instead of < in the comparison. Together, lower_bound and upper_bound let you count occurrences: upperBound(x) - lowerBound(x) gives you how many times x appears! C++ STL provides these as std::lower_bound and std::upper_bound.

02

Sorting Algorithms

Sorting arranges elements in a specific order (usually ascending or descending). Understanding different sorting algorithms helps you choose the right one for your situation and teaches fundamental algorithm design techniques like divide-and-conquer.

Bubble Sort

Bubble sort is the simplest sorting algorithm. It repeatedly steps through the list, compares adjacent elements, and swaps them if they're in wrong order. Like bubbles rising to the surface, larger elements "bubble up" to their correct positions.

#include <vector>
#include <algorithm>

void bubbleSort(std::vector<int>& arr) {
    int n = arr.size();
    
    for (int i = 0; i < n - 1; i++) {

Outer Loop - Number of Passes: Bubble sort needs multiple passes through the array. After each complete pass, the largest unsorted element "bubbles up" to its final position at the end. So after pass 1, the largest element is in place. After pass 2, the second largest is in place, and so on. We need at most (n-1) passes because once (n-1) elements are sorted, the last one is automatically correct.

        bool swapped = false;
        
        for (int j = 0; j < n - i - 1; j++) {
            if (arr[j] > arr[j + 1]) {
                std::swap(arr[j], arr[j + 1]);
                swapped = true;
            }
        }

Inner Loop - Compare and Swap: The inner loop compares adjacent elements and swaps them if they're in wrong order. Notice the boundary is n - i - 1 because after i passes, the last i elements are already sorted and don't need checking. The swapped flag tracks if we made any swaps this pass - if not, the array is already sorted and we can stop early!

        // Optimization: if no swaps, array is sorted
        if (!swapped) {
            break;
        }
    }
}

Early Termination Optimization: If a complete pass makes no swaps, the array is already sorted! This optimization makes bubble sort O(n) for already-sorted arrays instead of O(n²). While bubble sort is not efficient for large datasets, it's educational and works well for small or nearly-sorted arrays. Time: O(n²) worst/average, O(n) best. Space: O(1) - sorts in place.

Merge Sort

Merge sort uses the "divide and conquer" strategy: split the array in half, recursively sort each half, then merge the sorted halves. It guarantees O(n log n) performance regardless of input and is stable (preserves order of equal elements).

#include <vector>

void merge(std::vector<int>& arr, int left, int mid, int right) {
    // Create temporary arrays for left and right halves
    std::vector<int> leftArr(arr.begin() + left, arr.begin() + mid + 1);
    std::vector<int> rightArr(arr.begin() + mid + 1, arr.begin() + right + 1);

Setting Up the Merge: The merge function combines two sorted halves into one sorted array. We're given: left (start of first half), mid (end of first half), and right (end of second half). First, we copy both halves into temporary vectors. This is necessary because we'll be overwriting the original positions as we merge.

    int i = 0, j = 0, k = left;
    
    // Compare and merge
    while (i < leftArr.size() && j < rightArr.size()) {
        if (leftArr[i] <= rightArr[j]) {
            arr[k++] = leftArr[i++];
        } else {
            arr[k++] = rightArr[j++];
        }
    }

The Merge Process: Now comes the clever part! Since both halves are already sorted, we just need to pick the smaller element from the front of each half. We use three pointers: i for leftArr, j for rightArr, and k for where to place in the original array. We compare leftArr[i] with rightArr[j], pick the smaller one, put it at arr[k], and advance the corresponding pointer. Using <= makes merge sort "stable" (equal elements keep their relative order).

    // Copy remaining elements (only one of these loops will execute)
    while (i < leftArr.size()) {
        arr[k++] = leftArr[i++];
    }
    while (j < rightArr.size()) {
        arr[k++] = rightArr[j++];
    }
}

Copy Remaining Elements: When one array is exhausted, the other might still have elements left. Since they're already sorted, we just copy them directly. Only ONE of these while loops will actually run (the one with remaining elements). For example, if we've used all of leftArr, we copy whatever's left in rightArr.

void mergeSort(std::vector<int>& arr, int left, int right) {
    if (left >= right) return;  // Base case: 0 or 1 element
    
    int mid = left + (right - left) / 2;
    
    mergeSort(arr, left, mid);       // Sort left half
    mergeSort(arr, mid + 1, right);  // Sort right half
    merge(arr, left, mid, right);    // Merge sorted halves
}

Divide and Conquer: This is the heart of merge sort! The base case handles arrays of 0 or 1 elements (already sorted). Otherwise, we find the middle, recursively sort the left half, recursively sort the right half, then merge them. The recursion naturally divides the problem into smaller subproblems. Time: O(n log n) always - we do log n levels of merging, each level processes n elements. Space: O(n) for temporary arrays.

Quick Sort

Quick sort also uses divide and conquer, but differently. It picks a "pivot" element and partitions the array so all smaller elements come before the pivot and all larger come after. Then it recursively sorts the partitions.

#include <vector>
#include <algorithm>

int partition(std::vector<int>& arr, int low, int high) {
    int pivot = arr[high];  // Choose last element as pivot
    int i = low - 1;        // Index of smaller element

Partition Setup: The partition function is the heart of quicksort. We choose the last element as our pivot (though other choices work too). The variable i tracks the boundary between elements smaller than pivot and elements larger than pivot. Everything from low to i (inclusive) will be smaller than pivot; everything from i+1 to j-1 will be larger.

    for (int j = low; j < high; j++) {
        if (arr[j] < pivot) {
            i++;
            std::swap(arr[i], arr[j]);
        }
    }

Partitioning Logic: We scan through the array with j. When we find an element smaller than pivot (arr[j] < pivot), we increment i (extending the "small elements" region) and swap arr[i] with arr[j]. This moves the small element into the correct region. Elements >= pivot stay in place initially because j keeps moving past them.

    std::swap(arr[i + 1], arr[high]);  // Place pivot in correct position
    return i + 1;  // Return pivot's position
}

Placing the Pivot: After the loop, elements 0..i are less than pivot, and elements i+1..high-1 are >= pivot. The pivot itself is still at high. We swap it to position i+1, which is its correct final sorted position! We return this position so quickSort knows where to split for the next recursive calls.

void quickSort(std::vector<int>& arr, int low, int high) {
    if (low < high) {
        int pivotIndex = partition(arr, low, high);
        
        quickSort(arr, low, pivotIndex - 1);   // Sort left of pivot
        quickSort(arr, pivotIndex + 1, high);  // Sort right of pivot
    }
}

Quick Sort Main Logic: If there's more than one element (low < high), we partition the array and get the pivot's final position. Then we recursively sort everything to the left of the pivot and everything to the right. The pivot is already in its correct spot, so we don't include it. Time: O(n log n) average, O(n²) worst. Space: O(log n) for recursion stack. Quick sort is often faster in practice than merge sort due to better cache performance.

Sorting Comparison

Algorithm Best Average Worst Space Stable?
Bubble Sort O(n) O(n²) O(n²) O(1) Yes
Merge Sort O(n log n) O(n log n) O(n log n) O(n) Yes
Quick Sort O(n log n) O(n log n) O(n²) O(log n) No
03

Recursion and Backtracking

Recursion is when a function calls itself to solve smaller instances of the same problem. Backtracking extends this by exploring all possibilities and "undoing" choices that don't lead to solutions. These techniques are essential for solving problems involving combinations, permutations, and tree/graph exploration.

Understanding Recursion

Every recursive solution has two parts: a base case (when to stop) and a recursive case (how to break down the problem). Let's start with classic examples.

#include <iostream>

// Factorial: n! = n * (n-1) * (n-2) * ... * 1
int factorial(int n) {
    // Base case: 0! = 1! = 1
    if (n <= 1) return 1;
    
    // Recursive case: n! = n * (n-1)!
    return n * factorial(n - 1);
}

Factorial - Classic Recursion: Factorial is the "Hello World" of recursion. To calculate 5!, we need 5 × 4!. To get 4!, we need 4 × 3!, and so on. The BASE CASE stops the recursion: when n <= 1, we return 1 (since 0! = 1! = 1). Without a base case, recursion would continue forever! Each call waits for the smaller call to complete, then multiplies. So factorial(5) → 5 × factorial(4) → 5 × 4 × factorial(3) → ... → 5 × 4 × 3 × 2 × 1 = 120.

// Fibonacci: F(n) = F(n-1) + F(n-2)
int fibonacci(int n) {
    // Base cases
    if (n <= 0) return 0;
    if (n == 1) return 1;
    
    // Recursive case
    return fibonacci(n - 1) + fibonacci(n - 2);
}
// Note: This is O(2^n) - very slow! We'll optimize with DP later.

Fibonacci - Two Recursive Calls: Fibonacci shows recursion with TWO recursive calls: F(n) = F(n-1) + F(n-2). The sequence goes 0, 1, 1, 2, 3, 5, 8, 13... Each number is the sum of the two before it. We need TWO base cases: F(0) = 0 and F(1) = 1. Warning: This naive implementation is O(2^n) because it recalculates the same values many times! We'll see how Dynamic Programming fixes this.

Backtracking

Backtracking is recursion with "undo". We explore a path, and if it doesn't lead to a solution, we backtrack (undo the last choice) and try another path.

#include <vector>
#include <algorithm>

void generatePermutations(std::vector<int>& nums, int start,
                          std::vector<std::vector<int>>& result) {
    // Base case: we've placed all elements
    if (start == nums.size()) {
        result.push_back(nums);
        return;
    }

Permutations - Base Case: To generate all permutations of [1,2,3], we fix one element at a time. start indicates which position we're currently deciding. When start equals the array size, we've made all decisions and have a complete permutation to save. This is our base case - a valid solution has been found!

    // Try each element at position 'start'
    for (int i = start; i < nums.size(); i++) {
        std::swap(nums[start], nums[i]);        // Choose
        generatePermutations(nums, start + 1, result);  // Explore
        std::swap(nums[start], nums[i]);        // Unchoose (backtrack)
    }
}

The Backtracking Pattern - Choose, Explore, Unchoose: This is the classic backtracking template! For position start, we try placing each remaining element (indices start to end). The pattern is: (1) CHOOSE: swap to place element i at position start. (2) EXPLORE: recursively fill remaining positions. (3) UNCHOOSE: swap back to restore original state (BACKTRACK). This last step is crucial - it "undoes" our choice so we can try the next option!

void generateSubsets(const std::vector<int>& nums, int index,
                     std::vector<int>& current,
                     std::vector<std::vector<int>>& result) {
    // Every partial state is a valid subset
    result.push_back(current);
    
    for (int i = index; i < nums.size(); i++) {
        current.push_back(nums[i]);              // Include element
        generateSubsets(nums, i + 1, current, result);
        current.pop_back();                       // Exclude (backtrack)
    }
}

Subsets - Include or Exclude: Generating subsets uses the same backtracking pattern. For each element, we can include it or not. We add the current state (which could be empty, partial, or complete) to results. Then we try including each remaining element: push it to current, explore further, then pop it (backtrack). For [1,2,3], this generates: [], [1], [1,2], [1,2,3], [1,3], [2], [2,3], [3] - all 2³ = 8 subsets!

bool isSafe(std::vector<std::string>& board, int row, int col, int n) {
    // Check column above
    for (int i = 0; i < row; i++)
        if (board[i][col] == 'Q') return false;
    
    // Check upper-left diagonal
    for (int i = row - 1, j = col - 1; i >= 0 && j >= 0; i--, j--)
        if (board[i][j] == 'Q') return false;
    
    // Check upper-right diagonal
    for (int i = row - 1, j = col + 1; i >= 0 && j < n; i--, j++)
        if (board[i][j] == 'Q') return false;
    
    return true;
}

N-Queens - Checking Valid Positions: The N-Queens problem places N queens on an N×N chessboard so no two attack each other. Queens attack horizontally, vertically, and diagonally. Since we place one queen per row (moving down), we only check upward for conflicts: the column above, upper-left diagonal, and upper-right diagonal. If any direction has a queen, this position isn't safe.

void solveNQueens(std::vector<std::string>& board, int row, int n,
                  std::vector<std::vector<std::string>>& solutions) {
    if (row == n) {
        solutions.push_back(board);
        return;
    }
    
    for (int col = 0; col < n; col++) {
        if (isSafe(board, row, col, n)) {
            board[row][col] = 'Q';                    // Place queen
            solveNQueens(board, row + 1, n, solutions);
            board[row][col] = '.';                    // Remove queen (backtrack)
        }
    }
}

N-Queens Backtracking: We place queens row by row. If we've placed queens in all N rows, we found a solution! For each row, we try placing a queen in each column. If the position is safe, we place the queen ('Q'), recurse to the next row, then BACKTRACK by removing the queen ('.'). This explores ALL valid placements. For N=4, there are 2 solutions; for N=8, there are 92!

04

Dynamic Programming Basics

Dynamic Programming (DP) optimizes recursive solutions by storing results of subproblems to avoid redundant calculations. If a problem has overlapping subproblems and optimal substructure, DP can often reduce exponential time complexity to polynomial.

Memoization (Top-Down DP)

Memoization adds a "memory" to recursion. Before computing a value, check if it's already cached. If yes, return the cached result. If not, compute it, cache it, then return it.

#include <vector>
#include <unordered_map>

std::unordered_map<int, long long> memo;

long long fibMemo(int n) {
    if (n <= 1) return n;
    
    // Check if already computed
    if (memo.count(n)) return memo[n];
    
    // Compute and store
    memo[n] = fibMemo(n - 1) + fibMemo(n - 2);
    return memo[n];
}

Memoized Fibonacci - From O(2^n) to O(n): Remember our naive recursive Fibonacci was O(2^n)? With memoization, we cache each F(n) value after computing it. When we need F(n) again, we look it up in O(1) instead of recomputing. Since there are only n unique subproblems and each is computed once, we achieve O(n) time! The hash map memo stores previously computed values.

Tabulation (Bottom-Up DP)

Tabulation builds solutions iteratively, starting from the smallest subproblems. Instead of recursion, we use loops to fill a table. This avoids recursion overhead and is often more efficient.

long long fibTabulation(int n) {
    if (n <= 1) return n;
    
    std::vector<long long> dp(n + 1);
    dp[0] = 0;
    dp[1] = 1;
    
    for (int i = 2; i <= n; i++) {
        dp[i] = dp[i - 1] + dp[i - 2];
    }
    
    return dp[n];
}

Bottom-Up Fibonacci: Instead of starting at F(n) and recursing down, we start at F(0), F(1) and build UP! We create a table (vector) to store results. We know F(0)=0 and F(1)=1. For each subsequent i, we compute F(i) = F(i-1) + F(i-2) using values already in the table. No recursion needed! Time: O(n), Space: O(n) for the array.

// Space-optimized: we only need the last 2 values!
long long fibOptimized(int n) {
    if (n <= 1) return n;
    
    long long prev2 = 0, prev1 = 1;
    
    for (int i = 2; i <= n; i++) {
        long long current = prev1 + prev2;
        prev2 = prev1;
        prev1 = current;
    }
    
    return prev1;
}

Space Optimization - O(1) Space: We only ever use the last two values to compute the next one. So instead of storing all n+1 values, we just keep prev2 (F(i-2)) and prev1 (F(i-1)). After computing the current value, we "shift": prev2 becomes the old prev1, and prev1 becomes current. Time: O(n), Space: O(1). This pattern applies to many DP problems!

Classic DP Problems

Problem 1: Climbing Stairs

// How many distinct ways to climb n stairs, taking 1 or 2 steps at a time?
int climbStairs(int n) {
    if (n <= 2) return n;
    
    int prev2 = 1;  // Ways to reach step 1
    int prev1 = 2;  // Ways to reach step 2
    
    for (int i = 3; i <= n; i++) {
        int current = prev1 + prev2;  // Come from step i-1 or i-2
        prev2 = prev1;
        prev1 = current;
    }
    
    return prev1;
}

Climbing Stairs - Hidden Fibonacci: To reach step n, you can come from step n-1 (take 1 step) OR from step n-2 (take 2 steps). So ways(n) = ways(n-1) + ways(n-2). Sound familiar? It's Fibonacci! Base cases: 1 way to reach step 1, 2 ways to reach step 2 (1+1 or 2). We use the same space-optimized approach. For n=4: 1→1→1→1, 1→1→2, 1→2→1, 2→1→1, 2→2 = 5 ways.

Problem 2: Coin Change

#include <vector>
#include <algorithm>
#include <climits>

int coinChange(std::vector<int>& coins, int amount) {
    // dp[i] = minimum coins needed to make amount i
    std::vector<int> dp(amount + 1, INT_MAX);
    dp[0] = 0;  // 0 coins needed for amount 0

Coin Change - Problem Setup: Given coin denominations (e.g., [1, 2, 5]) and an amount, find the MINIMUM number of coins needed. We create a DP array where dp[i] represents the minimum coins to make amount i. Initialize with INT_MAX (infinity) meaning "impossible". The base case dp[0] = 0 because we need 0 coins to make amount 0.

    for (int i = 1; i <= amount; i++) {
        for (int coin : coins) {
            if (coin <= i && dp[i - coin] != INT_MAX) {
                dp[i] = std::min(dp[i], dp[i - coin] + 1);
            }
        }
    }
    
    return dp[amount] == INT_MAX ? -1 : dp[amount];
}

Building the Solution: For each amount from 1 to target, we try each coin. If we can use this coin (coin <= i) and the remaining amount (i - coin) is achievable (not INT_MAX), then dp[i] = min(dp[i], dp[i-coin] + 1). The "+1" counts the current coin. Example: coins=[1,2,5], amount=11 → dp[11]=3 (5+5+1). If we can't make the amount, return -1. Time: O(amount × coins), Space: O(amount).

Problem 3: Longest Increasing Subsequence

int lengthOfLIS(std::vector<int>& nums) {
    if (nums.empty()) return 0;
    
    int n = nums.size();
    // dp[i] = length of longest increasing subsequence ending at index i
    std::vector<int> dp(n, 1);  // Each element is a subsequence of length 1

LIS - Problem Setup: Find the length of the longest strictly increasing subsequence. For [10,9,2,5,3,7,101,18], the answer is 4 ([2,3,7,101] or [2,5,7,101]). Note: subsequence doesn't have to be contiguous! dp[i] represents the LIS length ending at index i. Initialize all to 1 because every single element is a valid subsequence of length 1.

    int maxLen = 1;
    
    for (int i = 1; i < n; i++) {
        for (int j = 0; j < i; j++) {
            if (nums[j] < nums[i]) {
                dp[i] = std::max(dp[i], dp[j] + 1);
            }
        }
        maxLen = std::max(maxLen, dp[i]);
    }
    
    return maxLen;
}

Building the LIS: For each position i, we check all previous positions j. If nums[j] < nums[i] (we can extend the subsequence), then dp[i] = max(dp[i], dp[j] + 1). We're asking: "what's the longest subsequence ending at j that I can extend?" We track the maximum across all positions. Time: O(n²). Note: There's an O(n log n) solution using binary search, but this O(n²) version is easier to understand!

Problem 4: 0/1 Knapsack

int knapsack(std::vector<int>& weights, std::vector<int>& values, int capacity) {
    int n = weights.size();
    // dp[i][w] = max value using first i items with capacity w
    std::vector<std::vector<int>> dp(n + 1, std::vector<int>(capacity + 1, 0));

0/1 Knapsack - The Classic DP Problem: You have a knapsack with limited capacity. Each item has a weight and value. Pick items to maximize value without exceeding capacity. Each item can only be used once (0/1). We use a 2D DP table: dp[i][w] = maximum value achievable using the first i items with capacity w. Initialize with zeros (0 items or 0 capacity = 0 value).

    for (int i = 1; i <= n; i++) {
        for (int w = 0; w <= capacity; w++) {
            // Option 1: Don't take item i
            dp[i][w] = dp[i - 1][w];
            
            // Option 2: Take item i (if it fits)
            if (weights[i - 1] <= w) {
                dp[i][w] = std::max(dp[i][w], 
                    dp[i - 1][w - weights[i - 1]] + values[i - 1]);
            }
        }
    }
    
    return dp[n][capacity];
}

The Decision at Each Step: For each item i and capacity w, we have two choices: (1) DON'T take item i → value is same as using i-1 items with same capacity. (2) TAKE item i (if it fits) → value = value with remaining capacity + item's value. We pick the maximum. The final answer is dp[n][capacity]. Time: O(n × capacity), Space: O(n × capacity). This can be optimized to O(capacity) space by processing in reverse order!

05

Two Pointers Technique

The two pointers technique uses two indices to traverse an array, typically from opposite ends or at different speeds. This pattern transforms O(n^2) brute force solutions into elegant O(n) algorithms. It is fundamental for problems involving pairs, subarrays, and sorted data manipulation.

Understanding Two Pointers

Imagine you have a sorted list of numbers and need to find two that sum to a target. The naive approach checks every possible pair - that is O(n^2). But with two pointers starting at opposite ends, you can find the answer in a single pass through the array.

Algorithm Pattern

Two Pointers

The two pointers technique maintains two indices that traverse a data structure, often from opposite ends moving toward each other, or at different speeds (fast/slow pointers). The key insight is that pointer movement decisions are based on comparisons, avoiding redundant checks.

When to use: Sorted arrays, finding pairs with a sum, palindrome checking, removing duplicates in-place, merging sorted arrays, and linked list cycle detection.

Problem 1: Two Sum in Sorted Array

#include <vector>
#include <iostream>

std::pair<int, int> twoSumSorted(const std::vector<int>& nums, int target) {
    int left = 0;
    int right = nums.size() - 1;

Setup: We include the vector library and create a function that returns a pair of indices. The function takes a sorted array and a target sum as input. We initialize two pointers: left starts at the beginning (index 0) and right starts at the end of the array. This is the classic two-pointer technique for sorted arrays.

    while (left < right) {
        int sum = nums[left] + nums[right];

Main Loop: We continue the loop as long as left pointer is less than right pointer. Inside the loop, we calculate the sum of elements at both pointer positions. This sum will help us decide which pointer to move next. If pointers ever meet or cross, we've checked all possible pairs.

        if (sum == target) {
            return {left, right};  // Found the pair
        } else if (sum < target) {
            left++;   // Need larger sum, move left pointer right
        } else {
            right--;  // Need smaller sum, move right pointer left
        }
    }

Decision Logic: If sum equals target, we found our answer and return both indices as a pair. If sum is too small, we move the left pointer right to get a larger number (array is sorted!). If sum is too large, we move the right pointer left to get a smaller number. This works because moving left pointer right increases the sum, and moving right pointer left decreases it.

    return {-1, -1};  // No pair found
}

No Solution Found: If the loop ends without finding a valid pair, we return {-1, -1}. This is a common convention to signal "not found" when returning indices. The caller should check for this special value. This solution runs in O(n) time since each element is visited at most once, and uses O(1) extra space.

Problem 2: Valid Palindrome

#include <string>
#include <cctype>

bool isPalindrome(const std::string& s) {
    int left = 0;
    int right = s.size() - 1;

Setup: We include string for string operations and cctype for character functions like isalnum and tolower. The function returns true if the string is a valid palindrome, false otherwise. We use two pointers: left starts at index 0 and right starts at the last character. A palindrome reads the same forwards and backwards (like "racecar" or "A man a plan a canal Panama").

    while (left < right) {
        // Skip non-alphanumeric from left
        while (left < right && !std::isalnum(s[left])) {
            left++;
        }
        // Skip non-alphanumeric from right
        while (left < right && !std::isalnum(s[right])) {
            right--;
        }

Skip Invalid Characters: The outer while loop runs as long as left hasn't crossed right. The first inner while skips any non-alphanumeric characters (spaces, punctuation) from the left side. The second inner while does the same from the right side. std::isalnum() returns true only for letters (a-z, A-Z) and digits (0-9). This way, "A man, a plan" is treated as "AmanaplanacanalPanama".

        // Compare characters (case-insensitive)
        if (std::tolower(s[left]) != std::tolower(s[right])) {
            return false;
        }
        
        left++;
        right--;
    }
    
    return true;
}

Compare and Move: We use std::tolower() to convert both characters to lowercase before comparing. This makes the comparison case-insensitive, so 'A' equals 'a'. If characters don't match, we immediately return false - it's not a palindrome. If they match, we move both pointers inward (left++ and right--) and continue checking. If the loop completes without finding mismatches, we return true - it IS a palindrome! Time: O(n) | Space: O(1)

Problem 3: Remove Duplicates from Sorted Array

#include <vector>

int removeDuplicates(std::vector<int>& nums) {
    if (nums.empty()) return 0;
    
    int writePos = 1;  // Slow pointer - where to write next unique

Setup: This function removes duplicate elements from a sorted array in-place (without using extra memory). First, we handle the edge case: if the array is empty, return 0 (no elements). We use the "two-pointer" technique with a slow pointer called writePos. writePos starts at 1 because the first element (index 0) is always unique. This pointer marks where we should write the next unique element we find.

    for (int readPos = 1; readPos < nums.size(); readPos++) {
        // If current differs from previous unique, it's a new unique value
        if (nums[readPos] != nums[writePos - 1]) {
            nums[writePos] = nums[readPos];
            writePos++;
        }
    }

Main Loop: The fast pointer readPos scans through every element starting from index 1. For each element, we compare it with the last unique value we found (at writePos - 1). If they're different, we've found a new unique element! We copy it to the writePos position. Then we increment writePos to prepare for the next unique element. If they're the same (duplicate), we simply skip it and move to the next element.

    return writePos;  // Number of unique elements
}

// Example usage:
// Input:  [1, 1, 2, 2, 3]
// Output: 3, array becomes [1, 2, 3, _, _]

Return Result: We return writePos which tells us how many unique elements exist. After the function runs, the first writePos elements contain all unique values in order. The remaining elements in the array are still there but can be ignored. For example: [1,1,2,2,3] becomes [1,2,3,2,3] and we return 3 (first 3 elements are unique). Time: O(n) - one pass through array. Space: O(1) - no extra memory used.

Problem 4: Container With Most Water

#include <vector>
#include <algorithm>

int maxArea(const std::vector<int>& height) {
    int left = 0;
    int right = height.size() - 1;
    int maxWater = 0;

Problem Setup: We need to find two vertical lines that form a container holding maximum water. Think of an array like [1,8,6,2,5,4,8,3,7] where each number is the height of a wall. We use two pointers starting at both ends - left at the beginning and right at the end. This gives us the maximum possible width initially. The variable maxWater will store our best answer found so far.

    while (left < right) {
        // Water = width * minimum height
        int width = right - left;
        int h = std::min(height[left], height[right]);
        maxWater = std::max(maxWater, width * h);

Calculate Water Area: Inside the while loop, we calculate how much water the current container can hold. The width is the distance between the two pointers (right - left). The height is the SHORTER of the two walls because water will spill over the shorter one - that's why we use std::min(). The area is simply width × height. We compare this with our current best using std::max() and update if we found a bigger container.

        // Move the pointer with smaller height (greedy choice)
        if (height[left] < height[right]) {
            left++;
        } else {
            right--;
        }
    }
    
    return maxWater;
}

Greedy Decision - Move Shorter Wall: This is the clever part! We always move the pointer pointing to the shorter wall inward. Why? Because the water level is limited by the shorter wall. If we move the taller wall inward, we only reduce the width without any chance of increasing height - guaranteed worse result. But if we move the shorter wall, we might find a taller wall that could increase our water capacity enough to compensate for the reduced width. Time: O(n) - single pass. Space: O(1) - only three variables.

Practice: Two Pointers

Given:

std::string s = "hello";

Task: Reverse the string in-place using two pointers. Expected result: "olleh"

Show Solution
#include <string>
#include <algorithm>

void reverseString(std::string& s) {
    int left = 0;
    int right = s.size() - 1;
    
    while (left < right) {
        std::swap(s[left], s[right]);
        left++;
        right--;
    }
}

// Test
std::string s = "hello";
reverseString(s);
// s is now "olleh"

Given:

std::vector<int> nums = {-1, 0, 1, 2, -1, -4};

Task: Find all unique triplets that sum to zero. Expected: [[-1,-1,2], [-1,0,1]]

Show Solution
#include <vector>
#include <algorithm>

std::vector<std::vector<int>> threeSum(std::vector<int>& nums) {
    std::vector<std::vector<int>> result;
    std::sort(nums.begin(), nums.end());
    
    for (int i = 0; i < nums.size(); i++) {
        // Skip duplicates for first element
        if (i > 0 && nums[i] == nums[i-1]) continue;
        
        int target = -nums[i];
        int left = i + 1;
        int right = nums.size() - 1;
        
        while (left < right) {
            int sum = nums[left] + nums[right];
            if (sum == target) {
                result.push_back({nums[i], nums[left], nums[right]});
                // Skip duplicates
                while (left < right && nums[left] == nums[left+1]) left++;
                while (left < right && nums[right] == nums[right-1]) right--;
                left++;
                right--;
            } else if (sum < target) {
                left++;
            } else {
                right--;
            }
        }
    }
    
    return result;
}

Given:

std::vector<int> height = {0, 1, 0, 2, 1, 0, 1, 3, 2, 1, 2, 1};

Task: Calculate how much rain water can be trapped. Expected: 6 units

Show Solution
#include <vector>
#include <algorithm>

int trap(const std::vector<int>& height) {
    if (height.empty()) return 0;
    
    int left = 0, right = height.size() - 1;
    int leftMax = 0, rightMax = 0;
    int water = 0;
    
    while (left < right) {
        if (height[left] < height[right]) {
            if (height[left] >= leftMax) {
                leftMax = height[left];
            } else {
                water += leftMax - height[left];
            }
            left++;
        } else {
            if (height[right] >= rightMax) {
                rightMax = height[right];
            } else {
                water += rightMax - height[right];
            }
            right--;
        }
    }
    
    return water;
}

// The key insight: water at position i depends on
// min(max_left, max_right) - height[i]
// Two pointers track these maximums efficiently
06

Sliding Window Pattern

The sliding window pattern maintains a dynamic window over a sequence to efficiently compute aggregate values like sums, counts, or max/min. Instead of recalculating from scratch, you add new elements and remove old ones as the window slides, achieving O(n) complexity.

Understanding Sliding Windows

The Window Concept

Picture a window sliding across an array. Instead of recalculating everything for each position, update window state by adding new element and removing old one. Incremental approach = efficiency!

Algorithm Pattern

Sliding Window

The sliding window technique maintains a range [left, right] over contiguous elements. Fixed-size windows slide by one position each step. Variable-size windows expand when valid and shrink when invalid, always maintaining an optimal condition.

Two types: Fixed window (size k stays constant) and variable window (size changes based on constraints like sum, character count, or validity conditions).

Problem 1: Maximum Sum Subarray of Size K

#include <vector>
#include <algorithm>

int maxSumSubarray(const std::vector<int>& nums, int k) {
    if (nums.size() < k) return -1;

Problem Setup: We want to find the maximum sum of any k consecutive elements in an array. For example, with [2,1,5,1,3,2] and k=3, we check all possible "windows" of 3 elements. First we include the headers and handle an edge case - if the array has fewer than k elements, we can't form a valid window so we return -1.

    // Calculate sum of first window
    int windowSum = 0;
    for (int i = 0; i < k; i++) {
        windowSum += nums[i];
    }
    
    int maxSum = windowSum;

Build First Window: Before we start sliding, we need to calculate the sum of our first window (first k elements). This loop adds up elements from index 0 to k-1. For nums=[2,1,5,1,3,2] with k=3, this gives us 2+1+5=8. We store this as our initial maxSum because so far it's the only window we've seen.

    // Slide the window: add new element, remove old element
    for (int i = k; i < nums.size(); i++) {
        windowSum += nums[i];      // Add incoming element
        windowSum -= nums[i - k];  // Remove outgoing element
        maxSum = std::max(maxSum, windowSum);
    }
    
    return maxSum;
}

Slide the Window: Instead of recalculating the entire sum for each position (which would take O(k) time each), we "slide" by adding one new element and removing one old element - this takes only O(1) time! For each position, we add nums[i] (the new element entering the window) and subtract nums[i-k] (the element leaving). We keep track of the maximum sum seen. Example: window [2,1,5] → add 1, remove 2 → [1,5,1]. Time: O(n) - single pass. Space: O(1) - constant extra space.

Problem 2: Longest Substring Without Repeating Characters

#include <string>
#include <unordered_map>
#include <algorithm>

int lengthOfLongestSubstring(const std::string& s) {
    std::unordered_map<char, int> charIndex;  // Last seen index of each char
    int maxLen = 0;
    int left = 0;

Problem Setup: We need to find the longest substring where no character repeats. For "abcabcbb", the answer is 3 ("abc"). We use a hash map charIndex to remember where we last saw each character. Variable maxLen stores our best answer so far, and left marks the start of our current window. The window will grow and shrink as we find unique and duplicate characters.

    for (int right = 0; right < s.size(); right++) {
        char c = s[right];
        
        // If char was seen and is within current window, shrink from left
        if (charIndex.count(c) && charIndex[c] >= left) {
            left = charIndex[c] + 1;  // Move left past the duplicate
        }

Handle Duplicates: We expand our window by moving right through the string. For each character, we check two things: (1) Have we seen this character before? (2) Is that previous occurrence inside our current window (charIndex[c] >= left)? If both are true, we found a duplicate! We fix this by moving left to one position after the previous occurrence of this character. This "shrinks" our window to remove the duplicate.

        charIndex[c] = right;  // Update last seen position
        maxLen = std::max(maxLen, right - left + 1);
    }
    
    return maxLen;
}

Update and Track Maximum: After handling any duplicate, we update the character's position in our map to its current index. Then we calculate the current window size using right - left + 1 (the +1 is because both ends are inclusive). We keep track of the maximum we've seen. When the loop finishes, maxLen contains our answer. Time: O(n) - we visit each character once. Space: O(min(m,n)) - m is charset size.

Problem 3: Minimum Window Substring

#include <string>
#include <unordered_map>
#include <climits>

std::string minWindow(const std::string& s, const std::string& t) {
    if (s.empty() || t.empty()) return "";
    
    // Count required characters
    std::unordered_map<char, int> required;
    for (char c : t) required[c]++;
    
    int requiredCount = required.size();  // Distinct chars needed
    int formedCount = 0;                   // Distinct chars satisfied

Problem Setup - Count What We Need: This is a harder problem! Given string s="ADOBECODEBANC" and t="ABC", we need to find the smallest window in s that contains all characters from t (answer: "BANC"). First, we count how many of each character we need from t using the required map. Then we track two counts: requiredCount is how many distinct characters we need, and formedCount is how many we've satisfied so far.

    std::unordered_map<char, int> window;
    int left = 0;
    int minLen = INT_MAX;
    int minStart = 0;

Window Variables: The window map tracks character counts in our current window. left is the start of our window. We use minLen = INT_MAX as a placeholder for "infinity" - any valid window will be smaller. minStart remembers where our best answer starts so we can extract it later.

    for (int right = 0; right < s.size(); right++) {
        char c = s[right];
        window[c]++;
        
        // Check if this char's requirement is now satisfied
        if (required.count(c) && window[c] == required[c]) {
            formedCount++;
        }

Expand Window: We expand by moving right and adding characters to our window. For each character, we increment its count in window. Then we check: is this character needed (in required)? And did we just reach exactly the required count? If yes, we've satisfied one more character requirement, so increment formedCount.

        // Contract window while valid
        while (formedCount == requiredCount) {
            // Update minimum
            if (right - left + 1 < minLen) {
                minLen = right - left + 1;
                minStart = left;
            }
            
            // Remove leftmost character
            char leftChar = s[left];
            window[leftChar]--;
            if (required.count(leftChar) && window[leftChar] < required[leftChar]) {
                formedCount--;
            }
            left++;
        }
    }

Contract While Valid: Once formedCount == requiredCount, we have a valid window! Now we try to shrink it from the left to find the minimum. First, we check if this window is smaller than our best and save it. Then we remove the leftmost character - if this drops its count below what's required, we decrement formedCount (window becomes invalid) and stop shrinking.

    return minLen == INT_MAX ? "" : s.substr(minStart, minLen);
}

Return Result: If minLen is still INT_MAX, we never found a valid window, so return empty string. Otherwise, extract the minimum window using substr(minStart, minLen). For s="ADOBECODEBANC" and t="ABC", the answer is "BANC" - the smallest window containing A, B, and C. Time: O(n+m) - visit each char at most twice. Space: O(m) - for the hash maps.

Problem 4: Maximum of All Subarrays of Size K

#include <vector>
#include <deque>

std::vector<int> maxSlidingWindow(const std::vector<int>& nums, int k) {
    std::vector<int> result;
    std::deque<int> dq;  // Stores indices of potential maximums

Problem Setup - Using a Deque: We need to find the maximum element in every window of size k. For [1,3,-1,-3,5,3,6,7] with k=3, answer is [3,3,5,5,6,7]. A naive approach would scan each window for max (O(n*k)). Instead, we use a deque (double-ended queue) that can add/remove from both ends. The key insight: we store indices of "candidates" that could potentially be the maximum in some future window.

    for (int i = 0; i < nums.size(); i++) {
        // Remove indices outside the current window
        if (!dq.empty() && dq.front() <= i - k) {
            dq.pop_front();
        }

Remove Outdated Elements: As we process each element, first we check if the element at the front of the deque has "expired" - meaning its index is no longer within our current window of size k. If the front index is <= i-k, it's too old (outside our window), so we remove it with pop_front(). This keeps our deque containing only indices from the current window.

        // Remove indices of smaller elements (they can never be max)
        while (!dq.empty() && nums[dq.back()] < nums[i]) {
            dq.pop_back();
        }
        
        dq.push_back(i);

Maintain Decreasing Order: Here's the clever part! We remove all indices from the back whose values are smaller than the current element. Why? Because if a smaller element came before a larger element, the smaller one can NEVER be the maximum for any window that includes the larger element. After this cleanup, we add the current index. The deque now maintains a decreasing order of values!

        // Window is complete, record the maximum
        if (i >= k - 1) {
            result.push_back(nums[dq.front()]);
        }
    }
    
    return result;
}

Record the Maximum: Once we've processed k elements (i >= k-1), we have a complete window. Since our deque maintains decreasing order, the front always holds the index of the maximum element in the current window! We simply add nums[dq.front()] to our result. This technique is called "Monotonic Deque" - the deque maintains a specific order (here, decreasing values). Time: O(n) - each element enters/exits deque once. Space: O(k) - deque holds at most k elements.

Practice: Sliding Window

Given:

std::vector<int> arr = {1, 3, 2, 6, -1, 4, 1, 8, 2};
int k = 5;

Task: Find the average of each contiguous subarray of size k.

Show Solution
#include <vector>

std::vector<double> findAverages(const std::vector<int>& arr, int k) {
    std::vector<double> result;
    double windowSum = 0;
    
    for (int i = 0; i < arr.size(); i++) {
        windowSum += arr[i];
        
        if (i >= k - 1) {
            result.push_back(windowSum / k);
            windowSum -= arr[i - k + 1];
        }
    }
    
    return result;
}

// Result: [2.2, 2.8, 2.4, 3.6, 2.8]

Given:

std::vector<int> nums = {1, 2, 1, 0, 1, 1, 0};
int k = 4;

Task: Find the longest subarray where sum is at most k. Expected: 5

Show Solution
#include <vector>
#include <algorithm>

int longestSubarrayWithSumAtMostK(const std::vector<int>& nums, int k) {
    int left = 0;
    int windowSum = 0;
    int maxLen = 0;
    
    for (int right = 0; right < nums.size(); right++) {
        windowSum += nums[right];
        
        // Shrink window if sum exceeds k
        while (windowSum > k && left <= right) {
            windowSum -= nums[left];
            left++;
        }
        
        maxLen = std::max(maxLen, right - left + 1);
    }
    
    return maxLen;
}

// Longest subarray with sum <= 4 is [2,1,0,1,1] or [1,0,1,1,0] = length 5

Given:

std::string s = "barfoothefoobarman";
std::vector<std::string> words = {"foo", "bar"};

Task: Find starting indices of substrings that are concatenation of each word exactly once. Expected: [0, 9]

Show Solution
#include <string>
#include <vector>
#include <unordered_map>

std::vector<int> findSubstring(const std::string& s, 
                                const std::vector<std::string>& words) {
    std::vector<int> result;
    if (s.empty() || words.empty()) return result;
    
    int wordLen = words[0].size();
    int wordCount = words.size();
    int totalLen = wordLen * wordCount;
    
    std::unordered_map<std::string, int> wordFreq;
    for (const auto& w : words) wordFreq[w]++;
    
    // Try each starting position within first word length
    for (int i = 0; i < wordLen; i++) {
        std::unordered_map<std::string, int> seen;
        int matched = 0;
        int left = i;
        
        for (int j = i; j + wordLen <= s.size(); j += wordLen) {
            std::string word = s.substr(j, wordLen);
            
            if (wordFreq.count(word)) {
                seen[word]++;
                matched++;
                
                while (seen[word] > wordFreq[word]) {
                    std::string leftWord = s.substr(left, wordLen);
                    seen[leftWord]--;
                    matched--;
                    left += wordLen;
                }
                
                if (matched == wordCount) {
                    result.push_back(left);
                }
            } else {
                seen.clear();
                matched = 0;
                left = j + wordLen;
            }
        }
    }
    
    return result;
}
07

Prefix Sum Arrays

Prefix sums precompute cumulative totals to answer range queries in constant time. After O(n) preprocessing, any subarray sum becomes a simple subtraction. This technique is essential for problems involving cumulative data, range queries, and optimization.

Building Prefix Sum Arrays

The Core Idea

prefix[i] stores sum of elements 0 to i-1. Sum from L to R = prefix[R+1] - prefix[L]. Transforms O(n) per query into O(1) after O(n) preprocessing!

Data Structure

Prefix Sum Array

A prefix sum array (also called cumulative sum) stores running totals where prefix[i] = sum of elements from arr[0] to arr[i-1]. The sum of any range [L, R] equals prefix[R+1] - prefix[L], computed in O(1) time.

Formula: sum(L, R) = prefix[R+1] - prefix[L], where prefix[0] = 0 and prefix[i] = prefix[i-1] + arr[i-1] for i > 0.

Building and Using Prefix Sums

#include <vector>

class PrefixSum {
private:
    std::vector<long long> prefix;

Setting Up the Class: We include the <vector> header to use dynamic arrays. The class is named PrefixSum because it will store cumulative sums. Inside the private section, we declare prefix as a vector of long long. We use long long instead of int because sums can grow very large and overflow regular integers. This vector will hold our running totals - each position stores the sum of all elements before it.

public:
    PrefixSum(const std::vector<int>& arr) {
        int n = arr.size();
        prefix.resize(n + 1, 0);

Constructor Setup: The constructor takes the original array as input (passed by reference to avoid copying). We store the array's size in n for convenience. Notice we resize prefix to n + 1, one larger than the original array! This extra slot at the beginning (index 0) will always be 0, representing "the sum of zero elements." All slots are initialized to 0 using the second argument to resize().

        // Build prefix sum array
        for (int i = 0; i < n; i++) {
            prefix[i + 1] = prefix[i] + arr[i];
        }
    }

Building Running Totals: This loop is the heart of prefix sums - it builds our cumulative totals. Each prefix[i + 1] equals the previous prefix sum plus the current array element. For example, if arr = [1, 2, 3, 4, 5], we get prefix = [0, 1, 3, 6, 10, 15]. Notice how prefix[3] = 6 is the sum of elements at indices 0, 1, 2 (which is 1+2+3). This one-time O(n) work lets us answer any range sum query instantly!

    // Get sum of elements in range [left, right] (inclusive)
    long long rangeSum(int left, int right) {
        return prefix[right + 1] - prefix[left];
    }

Range Sum Query: This is where the magic happens - getting any range sum in O(1) time! The formula prefix[right + 1] - prefix[left] works because of how prefix sums are structured. prefix[right + 1] contains the sum from index 0 to right (inclusive). prefix[left] contains the sum from index 0 to left - 1 (everything before our range). Subtracting removes the part we don't want, leaving exactly the sum from left to right!

    // Get sum of first k elements
    long long sumFirst(int k) {
        return prefix[k];
    }
};

Sum of First K Elements: This helper function is even simpler - just return prefix[k] directly! Remember, prefix[k] stores the sum of elements from index 0 to k-1, which is exactly k elements. For example, sumFirst(3) returns prefix[3], the sum of elements at indices 0, 1, and 2. This is a common query in many problems: "What's the total of the first k items?" Both query methods run in O(1) constant time, no matter how big the array is!

// Example usage:
// arr = [1, 2, 3, 4, 5]
// prefix = [0, 1, 3, 6, 10, 15]
// rangeSum(1, 3) = prefix[4] - prefix[1] = 10 - 1 = 9 (which is 2+3+4)

Walkthrough Example: Let's trace through with arr = [1, 2, 3, 4, 5] to see it in action. The prefix array becomes [0, 1, 3, 6, 10, 15] where each value is a running total. To find the sum from index 1 to 3 (elements 2, 3, 4), we compute rangeSum(1, 3). This returns prefix[4] - prefix[1] = 10 - 1 = 9, which is indeed 2+3+4! Without prefix sums, we'd need to loop through and add 2+3+4 every single time we query.

Problem 1: Subarray Sum Equals K

#include <vector>
#include <unordered_map>

int subarraySum(const std::vector<int>& nums, int k) {
    std::unordered_map<long long, int> prefixCount;
    prefixCount[0] = 1;  // Empty prefix has sum 0

Initial Setup: We need two headers: <vector> for the array and <unordered_map> for fast lookups. The prefixCount map tracks how many times we've seen each prefix sum value. We initialize prefixCount[0] = 1 which is crucial - it represents "before the array starts." Why is this important? If the running sum itself equals k, we need to count that subarray starting from index 0! Without this initialization, we'd miss subarrays that start at the very beginning of the array.

    long long currentSum = 0;
    int count = 0;
    
    for (int num : nums) {
        currentSum += num;

Building Running Sum: We use currentSum to track our running total as we scan through the array. The count variable will store our final answer - the number of subarrays that sum to k. We use a range-based for loop to visit each number in the array. On each iteration, we add the current number to our running sum. Think of currentSum as answering: "What's the sum of all elements from index 0 to here?"

        // If (currentSum - k) was seen before, those positions
        // mark the start of subarrays summing to k
        if (prefixCount.count(currentSum - k)) {
            count += prefixCount[currentSum - k];
        }

The Key Insight: This is the clever part! We check if (currentSum - k) exists in our map. Why? If the prefix sum at some earlier index was (currentSum - k), then the subarray from there to here sums to exactly k! For example, if currentSum=10 and k=7, we look for prefix sums of 3. If we saw sum=3 twice before, that's 2 valid subarrays. This transforms a nested loop problem (O(n²)) into a single-pass solution (O(n))! We add the count of matching prefix sums because each one represents a valid subarray ending at our current position.

        prefixCount[currentSum]++;
    }
    
    return count;
}

Record and Return: After checking, we record the current prefix sum by incrementing its count in the map. This is crucial: we record AFTER checking to avoid counting subarrays that end where they start (length 0). The order matters - check first, then record. This ensures we only find subarrays that end at the current index. After processing all elements, count holds the total number of subarrays summing to k. Time complexity is O(n) with O(n) space for the hash map - much better than the O(n²) brute force!

// Example: nums = [1, 1, 1], k = 2
// prefix sums: 0, 1, 2, 3
// At sum=2: need sum=0, found 1 time
// At sum=3: need sum=1, found 1 time
// Result: 2 subarrays ([1,1] and [1,1])

Walkthrough Example: Let's trace through nums = [1, 1, 1] with k = 2. We start with prefixCount = {0: 1}. As we scan: sum becomes 1, then 2, then 3. When sum=2, we need (2-2)=0. We saw sum=0 once, so count=1 (subarray [1,1] from index 0-1). When sum=3, we need (3-2)=1. We saw sum=1 once, so count=2 (subarray [1,1] from index 1-2). The two valid subarrays are [1,1] at indices 0-1 and [1,1] at indices 1-2. Both sum to 2!

Problem 2: Product of Array Except Self

#include <vector>

std::vector<int> productExceptSelf(const std::vector<int>& nums) {
    int n = nums.size();
    std::vector<int> result(n, 1);

Function Setup: The goal is to return an array where each element is the product of ALL other elements except itself. We take the input array by reference (const) to avoid copying and ensure we don't modify it. We store the size in n and create result initialized with all 1s. Why 1s? Because multiplying by 1 doesn't change anything - it's the "identity" for multiplication. We'll use result cleverly to store intermediate values, saving extra memory!

    // First pass: compute prefix products (left to right)
    int prefix = 1;
    for (int i = 0; i < n; i++) {
        result[i] = prefix;
        prefix *= nums[i];
    }

Left-to-Right Pass: We scan from left to right, building "prefix products" (products of all elements to the left). prefix starts at 1 (empty product). For each position, we first store prefix in result, THEN multiply. After this pass with nums = [1, 2, 3, 4], we get result = [1, 1, 2, 6]. Notice: result[2] = 2 is the product of everything LEFT of index 2 (which is 1×2=2). We're halfway there - each position now holds the product of all elements before it!

    // Second pass: multiply by suffix products (right to left)
    int suffix = 1;
    for (int i = n - 1; i >= 0; i--) {
        result[i] *= suffix;
        suffix *= nums[i];
    }

Right-to-Left Pass: Now we scan backwards, building "suffix products" (products of all elements to the right). suffix starts at 1. For each position, we MULTIPLY result[i] by suffix, then update suffix. The magic: result[i] already holds the left product, so multiplying by the right product gives us the full answer! After this pass: result = [24, 12, 8, 6]. For index 1: left product (1) × right product (12) = 12. This two-pass technique avoids using division - important when the array might contain zeros!

    return result;
}

Return Result: We return the completed result array where each result[i] = product of all other elements. Time complexity is O(n) - we make exactly two passes through the array. Space complexity is O(1) extra (not counting the output) - we only use two integer variables! This is more efficient than the naive O(n²) approach of computing each product separately. No division is needed, so this solution works even when the array contains zeros.

// Example: nums = [1, 2, 3, 4]
// After prefix pass: result = [1, 1, 2, 6]
// After suffix pass: result = [24, 12, 8, 6]
// Each result[i] = product of all elements except nums[i]

Complete Walkthrough: With nums = [1, 2, 3, 4], let's verify the final answers. result[0] = 24: product of 2×3×4 = 24 (everything except index 0). ✓ result[1] = 12: product of 1×3×4 = 12 (everything except index 1). ✓ result[2] = 8: product of 1×2×4 = 8 (everything except index 2). ✓ result[3] = 6: product of 1×2×3 = 6 (everything except index 3). ✓

Problem 3: Contiguous Array (Equal 0s and 1s)

#include <vector>
#include <unordered_map>
#include <algorithm>

int findMaxLength(const std::vector<int>& nums) {
    std::unordered_map<int, int> sumIndex;
    sumIndex[0] = -1;  // Sum 0 at imaginary index -1

Setup and Key Insight: We need to find the longest subarray with equal numbers of 0s and 1s. We include <algorithm> for the std::max function we'll use later. The sumIndex map stores the FIRST index where each running sum occurred. sumIndex[0] = -1 is crucial - it means "a sum of 0 exists before the array starts." This handles cases where a valid subarray starts at index 0 (like [0, 1] which has equal 0s and 1s).

    int maxLen = 0;
    int sum = 0;
    
    for (int i = 0; i < nums.size(); i++) {
        // Treat 0 as -1, so equal 0s and 1s means sum = 0
        sum += (nums[i] == 1) ? 1 : -1;

The Transformation Trick: Here's the clever idea - we treat each 0 as -1 and each 1 as +1. Why? Because if we have equal counts of 0s and 1s, the sum will be zero! (e.g., two 0s (-2) and two 1s (+2) = 0) maxLen will store our answer - the longest valid subarray found so far. sum is our running total using the transformed values (-1 for 0, +1 for 1). This transformation converts "count equal elements" into "find zero-sum subarray" - a prefix sum problem!

        if (sumIndex.count(sum)) {
            // Same sum seen before means equal 0s and 1s in between
            maxLen = std::max(maxLen, i - sumIndex[sum]);
        } else {
            sumIndex[sum] = i;  // Record first occurrence
        }

Finding Valid Subarrays: If we've seen this sum before, the subarray between those positions has sum 0! Why? If sum[i] == sum[j], then elements from j+1 to i must sum to 0 (they "cancel out"). A sum of 0 means equal 0s and 1s! We calculate the length as i - sumIndex[sum]. We only record the FIRST occurrence of each sum because we want the LONGEST subarray. If a sum appears at index 2 and again at index 8, the subarray from 3 to 8 has length 6.

    }
    
    return maxLen;
}

Return the Result: After scanning the entire array, maxLen holds the length of the longest valid subarray. Time complexity is O(n) - we visit each element exactly once. Space complexity is O(n) in the worst case for the hash map (when all running sums are unique). This is much better than the O(n²) brute force that checks every possible subarray! The key insight: transforming 0→-1 lets us use prefix sums to find "balanced" subarrays.

// Example: nums = [0, 1, 0]
// Transform: [-1, 1, -1]
// Running sums: -1, 0, -1
// Sum -1 repeats at index 0 and 2, so subarray [1,2] has length 2
// Sum 0 at index 1 means [0,1] has equal 0s and 1s, length 2

Detailed Walkthrough: Let's trace through nums = [0, 1, 0] step by step. We transform values: 0→-1, 1→+1, 0→-1. Running sums become: -1 (after idx 0), 0 (after idx 1), -1 (after idx 2). At index 1, sum=0 matches our initial sumIndex[0]=-1, so length = 1-(-1) = 2. This is [0,1]. At index 2, sum=-1 matches sumIndex[-1]=0, so length = 2-0 = 2. This is [1,0] at indices 1-2. Both subarrays have length 2, with equal counts of 0s and 1s. Answer: 2.

Problem 4: Range Sum Query 2D

#include <vector>

class NumMatrix {
private:
    std::vector<std::vector<int>> prefix;

Class Definition: This class extends prefix sums from 1D arrays to 2D matrices (grids). We include <vector> because we'll use a 2D vector to store our prefix sums. The private member prefix is a "vector of vectors" - essentially a 2D grid. Each cell prefix[i][j] will store the sum of all elements in the rectangle from (0,0) to (i-1,j-1). This preprocessing lets us answer "What's the sum of this rectangle?" in O(1) time!

public:
    NumMatrix(const std::vector<std::vector<int>>& matrix) {
        if (matrix.empty()) return;
        int m = matrix.size(), n = matrix[0].size();
        prefix.assign(m + 1, std::vector<int>(n + 1, 0));

Constructor Setup: The constructor takes the original matrix and builds our prefix sum grid. First, we check if the matrix is empty to avoid errors. Then we get dimensions: m rows and n columns. Just like 1D prefix sums, we add padding! Our prefix grid is (m+1) × (n+1) in size. The extra row and column of zeros at the top and left make our formulas simpler. assign creates the 2D vector with all zeros - this handles our padding automatically.

        // Build 2D prefix sum
        for (int i = 1; i <= m; i++) {
            for (int j = 1; j <= n; j++) {
                prefix[i][j] = matrix[i-1][j-1]
                             + prefix[i-1][j]
                             + prefix[i][j-1]
                             - prefix[i-1][j-1];
            }
        }
    }

Building with Inclusion-Exclusion: This nested loop fills in our 2D prefix sums using a clever formula. For each cell, we add: (1) the current matrix value, (2) the sum above, (3) the sum to the left. But wait - we've now counted the top-left region twice! So we subtract prefix[i-1][j-1]. Think of it like Venn diagrams: when you add two overlapping circles, you subtract the overlap once. After this O(m×n) preprocessing, every prefix[i][j] holds the sum of the rectangle from (0,0) to (i-1,j-1).

    // Sum of region from (row1,col1) to (row2,col2)
    int sumRegion(int row1, int col1, int row2, int col2) {
        return prefix[row2+1][col2+1]
             - prefix[row1][col2+1]
             - prefix[row2+1][col1]
             + prefix[row1][col1];
    }
};

Query in O(1) Time: This is where our preprocessing pays off - instant rectangle sums! We use inclusion-exclusion in reverse: start with the full rectangle to bottom-right corner. Then subtract the region ABOVE our target rectangle (prefix[row1][col2+1]). Then subtract the region to the LEFT of our target (prefix[row2+1][col1]). But now we've subtracted the top-left corner twice, so we add it back (prefix[row1][col1]).

// Inclusion-exclusion principle:
// Sum(r1,c1,r2,c2) = Total - Top - Left + TopLeft
//   where TopLeft was subtracted twice

Visual Understanding: Imagine a grid where you want the sum of a small rectangle in the middle. prefix[row2+1][col2+1] gives you everything from (0,0) to (row2,col2) - that's too much! Subtracting prefix[row1][col2+1] removes all rows above your target rectangle. Subtracting prefix[row2+1][col1] removes all columns to the left of your target. Adding prefix[row1][col1] fixes the double-subtraction of the top-left corner. Done in O(1)!

Prefix Sum Variations: Beyond simple sums, you can compute prefix XOR (for range XOR queries), prefix products, prefix min/max (with sparse tables), and 2D/3D prefix sums for multi-dimensional range queries.

Practice: Prefix Sum

Given:

std::vector<int> nums = {1, 2, 3, 4};

Task: Return running sum where result[i] = sum(nums[0]...nums[i]). Expected: [1, 3, 6, 10]

Show Solution
#include <vector>

std::vector<int> runningSum(std::vector<int>& nums) {
    for (int i = 1; i < nums.size(); i++) {
        nums[i] += nums[i - 1];
    }
    return nums;
}

// In-place: each element becomes sum of itself and all previous
// [1, 2, 3, 4] -> [1, 3, 6, 10]

Given:

std::vector<int> nums = {4, 5, 0, -2, -3, 1};
int k = 5;

Task: Count subarrays with sum divisible by k. Expected: 7

Show Solution
#include <vector>
#include <unordered_map>

int subarraysDivByK(const std::vector<int>& nums, int k) {
    std::unordered_map<int, int> remainderCount;
    remainderCount[0] = 1;  // Empty prefix
    
    int prefixSum = 0;
    int count = 0;
    
    for (int num : nums) {
        prefixSum += num;
        
        // Handle negative remainders
        int remainder = ((prefixSum % k) + k) % k;
        
        // Same remainder means difference is divisible by k
        if (remainderCount.count(remainder)) {
            count += remainderCount[remainder];
        }
        remainderCount[remainder]++;
    }
    
    return count;
}

// Key insight: if prefix[i] % k == prefix[j] % k,
// then (prefix[j] - prefix[i]) % k == 0

Given:

std::vector<std::vector<int>> matrix = {
    {1, 2, -1, -4, -20},
    {-8, -3, 4, 2, 1},
    {3, 8, 10, 1, 3},
    {-4, -1, 1, 7, -6}
};

Task: Find the maximum sum of any rectangular submatrix. Expected: 29

Show Solution
#include <vector>
#include <algorithm>
#include <climits>

// Kadane's algorithm for 1D max subarray sum
int kadane(const std::vector<int>& arr) {
    int maxSum = INT_MIN, currentSum = 0;
    for (int x : arr) {
        currentSum = std::max(x, currentSum + x);
        maxSum = std::max(maxSum, currentSum);
    }
    return maxSum;
}

int maxSumRectangle(const std::vector<std::vector<int>>& matrix) {
    int m = matrix.size(), n = matrix[0].size();
    int maxSum = INT_MIN;
    
    // Fix left column
    for (int left = 0; left < n; left++) {
        std::vector<int> temp(m, 0);
        
        // Expand right column
        for (int right = left; right < n; right++) {
            // Add current column to temp
            for (int i = 0; i < m; i++) {
                temp[i] += matrix[i][right];
            }
            
            // Find max 1D subarray in temp
            maxSum = std::max(maxSum, kadane(temp));
        }
    }
    
    return maxSum;
}

// O(n^2 * m) - fix columns, use Kadane on rows
08

Sorting-Based Problems

Sorting often simplifies complex problems by organizing data predictably. Many problems that seem difficult become straightforward once the data is sorted - finding pairs, detecting duplicates, or merging intervals all benefit from sorted order.

Why Sorting Helps

The O(n log n) Investment: Sorting costs O(n log n) time, but it's often the best investment you can make! Once data is sorted, magical things happen: equal elements cluster together (making duplicates easy to find), ranges become contiguous (simplifying interval problems), and binary search becomes possible (enabling O(log n) lookups). Many O(n²) brute-force solutions become elegant O(n log n) algorithms just by sorting first!

Sort-First Strategy: Many problems become trivial after sorting. Duplicates cluster together, smallest/largest elements move to ends, and relationships between adjacent elements reveal patterns. The O(n log n) sorting cost is often negligible compared to the algorithmic benefits. Best for: finding duplicates, merging intervals, scheduling problems, finding closest pairs, median finding, and greedy algorithms.

Problem 1: Merge Intervals

Given a collection of intervals like [[1,3],[2,6],[8,10]], merge all overlapping intervals into one. For example, [1,3] and [2,6] overlap because 2 is within [1,3], so they merge into [1,6].

#include <vector>
#include <algorithm>

std::vector<std::vector<int>> merge(std::vector<std::vector<int>>& intervals) {
    if (intervals.empty()) return {};

Setting Up the Function: We include <vector> to store our intervals (each interval is a pair of numbers like [start, end]) and <algorithm> for the sorting function. Our function takes a reference to a vector of intervals - using a reference (&) means we work with the original data instead of making a copy, which saves memory. The first thing we check is if the input is empty - if someone gives us no intervals, we simply return an empty result. This is called an "edge case" - a special situation we handle right away to avoid errors later.

    // Sort by start time
    std::sort(intervals.begin(), intervals.end());

The Key Insight - Sort First: This single line is the magic that makes the entire problem easy! When we sort intervals, they automatically get arranged by their start times (the first number). For example, [[8,10],[1,3],[2,6]] becomes [[1,3],[2,6],[8,10]]. Why does this help? Because after sorting, any intervals that could possibly overlap will be sitting right next to each other! Without sorting, we'd have to compare every interval with every other interval (very slow). With sorting, we just look at neighbors (very fast).

    std::vector<std::vector<int>> merged;
    merged.push_back(intervals[0]);

Starting Our Result Collection: We create a new vector called "merged" to store our final answer. Think of it as an empty basket where we'll put our combined intervals. We immediately add the first interval (intervals[0]) to our basket. Why? Because we need something to compare against! As we go through the remaining intervals, we'll check if each one overlaps with the last interval in our basket. The first interval becomes our starting point.

    for (int i = 1; i < intervals.size(); i++) {
        auto& last = merged.back();

Walking Through Each Interval: We start our loop at i=1 (not 0) because we already added the first interval to our result. For each interval, we get a reference to the last interval in our merged result using merged.back(). The "auto&" means "figure out the type automatically and give me a reference, not a copy." Using a reference is important here because if we need to extend this interval (when there's an overlap), we want to modify it directly in our result vector.

        if (intervals[i][0] <= last[1]) {
            // Overlaps - extend the end
            last[1] = std::max(last[1], intervals[i][1]);
        }

Detecting and Handling Overlaps: Here's the overlap detection: intervals[i][0] is the start of current interval, last[1] is the end of the previous interval. If the current starts before (or exactly when) the previous ends, they overlap! Example: if last is [1,6] and current is [4,8], then 4 ≤ 6, so they overlap. We merge them by extending the end: take the maximum of both end times. So [1,6] and [4,8] become [1,8]. We use max() because the current interval might end earlier than the last one (like [4,5] would be completely inside [1,6]).

        else {
            // No overlap - start new interval
            merged.push_back(intervals[i]);
        }
    }

No Overlap - Keep Separate: If the current interval's start is greater than the last interval's end, there's a gap between them - no overlap! Example: if last is [1,3] and current is [8,10], then 8 > 3, so they don't touch. In this case, we simply add the current interval as a new entry in our merged result. It will become the new "last" interval for future comparisons. This is why sorting was so important - we only need to compare with the immediately previous interval!

    return merged;
}

Returning the Merged Intervals: After processing all intervals, our "merged" vector contains the final answer with all overlapping intervals combined. The time complexity is O(n log n) because of sorting, plus O(n) for the single pass through intervals - so overall O(n log n). The space complexity is O(n) for storing the result. This is very efficient compared to the naive O(n²) approach of comparing every pair!

// Example: [[1,3],[2,6],[8,10],[15,18]]
// Sorted (already sorted by start)
// Merge [1,3] and [2,6] -> [1,6]
// [8,10] no overlap, keep separate
// [15,18] no overlap, keep separate
// Result: [[1,6],[8,10],[15,18]]

Step-by-Step Example: Let's trace through: Start with [1,3] in result. Next, [2,6] - does 2 ≤ 3? Yes! Overlap! Merge to [1, max(3,6)] = [1,6]. Next, [8,10] - does 8 ≤ 6? No! Add as separate. Next, [15,18] - does 15 ≤ 10? No! Add as separate. Final answer: [[1,6],[8,10],[15,18]]. Notice how we reduced 4 intervals to 3 by merging the overlapping ones. This algorithm is commonly used in calendar applications for scheduling!

Problem 2: Meeting Rooms II

Imagine you're managing a conference center. Given a list of meeting times, figure out the minimum number of rooms you need so that no two overlapping meetings share the same room.

#include <vector>
#include <algorithm>
#include <queue>

int minMeetingRooms(std::vector<std::vector<int>>& intervals) {
    if (intervals.empty()) return 0;

Setting Up - Why We Need a Priority Queue: We include three headers: <vector> for storing intervals, <algorithm> for sorting, and <queue> for the priority queue (also called a "heap"). The priority queue is key to this solution - it will help us track which rooms become free and when. Think of it like a smart list that always tells us which room will finish its meeting first. If there are no meetings, we need zero rooms - that's our edge case.

    // Sort by start time
    std::sort(intervals.begin(), intervals.end());

Processing Meetings in Order: We sort meetings by their start time so we can process them chronologically - just like how meetings would actually occur during a day! If meeting A starts at 9am and meeting B starts at 10am, we want to assign room(s) for A first. This way, when we get to B, we already know if any room from earlier meetings might be free. Without sorting, we'd have no logical order to work with.

    // Min-heap of end times (earliest ending meeting at top)
    std::priority_queue<int, std::vector<int>, std::greater<int>> rooms;

The Secret Weapon - Min-Heap: This line creates a special data structure called a min-heap. The "std::greater<int>" part makes it so the smallest number is always on top (normally C++ heaps put the largest on top). Each number in this heap represents when a room becomes free (the end time of its current meeting). When we need a room, we check the top - if that room will be free by the time our new meeting starts, we can reuse it!

    rooms.push(intervals[0][1]);  // First meeting's end time

Booking the First Room: We start by giving the first meeting a room. We push its end time (intervals[0][1]) into our heap. Think of this as saying "Room 1 is occupied until time X." For example, if the first meeting is [0, 30] (from time 0 to time 30), we push 30. Now our heap knows "there's one room in use, and it becomes free at time 30." The heap size (currently 1) represents rooms currently in use.

    for (int i = 1; i < intervals.size(); i++) {
        // If current meeting starts after earliest ending meeting
        if (intervals[i][0] >= rooms.top()) {
            rooms.pop();  // Reuse that room
        }

Can We Reuse a Room?: For each new meeting, we check: does this meeting start after (or exactly when) the earliest-ending room becomes free? rooms.top() gives us the earliest end time. If our meeting starts at time 15 and rooms.top() is 10, then yes - that room is free by the time we need it! We "pop" (remove) that end time from the heap because that room is no longer occupied by its old meeting - it's about to get a new one.

        rooms.push(intervals[i][1]);  // Add current meeting's end time
    }

Booking the Room: Whether we reused a room or not, we push the current meeting's end time into the heap. If we popped (reused a room), the heap size stays the same - we removed one old meeting and added one new meeting. If we didn't pop (no room was free), the heap size grows by 1 - we needed a brand new room! This elegantly tracks how many rooms are in use at any point.

    return rooms.size();  // Number of concurrent meetings
}

The Answer is the Heap Size: After processing all meetings, the heap size tells us the maximum number of rooms that were ever needed simultaneously. This is exactly our answer! The heap grew whenever we couldn't reuse a room (meetings overlapped) and stayed the same when we could reuse. Time complexity is O(n log n) for sorting plus O(n log n) for n heap operations, giving O(n log n) overall. Space is O(n) for the heap.

// Example: [[0,30],[5,10],[15,20]]
// After sorting: [[0,30],[5,10],[15,20]]
// t=0: room1=[0,30], heap={30}
// t=5: [5,10] starts before 30 ends, need room2, heap={10,30}
// t=15: [15,20] starts after 10, reuse room2, heap={20,30}
// Result: 2 rooms needed

Walking Through the Example: Meeting [0,30] gets Room 1 (heap: {30}). Meeting [5,10] arrives - can we reuse? Check heap.top() = 30. Does 5 ≥ 30? No! Room 1 is still busy. We need Room 2 (heap: {10, 30}). Meeting [15,20] arrives - heap.top() = 10. Does 15 ≥ 10? Yes! Room 2 is free. Pop 10, push 20 (heap: {20, 30}). Final heap size = 2, so we need 2 rooms. Notice the first meeting [0,30] runs the whole time - it's blocking Room 1 throughout!

Problem 3: Non-Overlapping Intervals

Given a collection of intervals, find the minimum number of intervals you need to remove so that the remaining intervals don't overlap. This is like scheduling - how do you fit the most events without conflicts?

#include <vector>
#include <algorithm>

int eraseOverlapIntervals(std::vector<std::vector<int>>& intervals) {
    if (intervals.size() <= 1) return 0;

Understanding the Problem: We need to find the minimum number of intervals to remove. Think of it like this: you have a calendar full of events, some overlapping, and you want to cancel as few as possible while eliminating all conflicts. Our function returns a count (integer) of how many need to be removed. If we have 0 or 1 interval, there can't be any overlap, so we return 0 (remove nothing). This edge case saves us from accessing invalid array indices later.

    // Sort by END time (greedy: finish early to leave room for more)
    std::sort(intervals.begin(), intervals.end(), 
              [](const auto& a, const auto& b) {
                  return a[1] < b[1];
              });

The Greedy Trick - Sort by END Time!: This is the key insight that makes this problem solvable! Unlike previous problems where we sorted by start time, here we sort by END time (a[1] < b[1]). Why? Because if we keep intervals that finish earliest, we leave maximum room for future intervals! Imagine two events: one from 9am-5pm and another from 9am-11am. Keeping the shorter one (ends at 11am) leaves more day for other events. This is called a "greedy" approach - make the locally optimal choice at each step.

    int count = 0;
    int prevEnd = intervals[0][1];

Setting Up Our Counters: We need two things: "count" to track how many intervals we remove, and "prevEnd" to remember when the last kept interval ends. We start count at 0 (haven't removed anything yet) and initialize prevEnd with the end time of the first interval (intervals[0][1]). Since we sorted by end time, the first interval ends earliest - it's the best choice to keep, so we "keep" it by recording its end time.

    for (int i = 1; i < intervals.size(); i++) {
        if (intervals[i][0] < prevEnd) {
            // Overlaps with previous - remove this one
            count++;
        }

Detecting Overlaps: For each interval starting from the second one, we check: does it start before the previous interval ends? If intervals[i][0] (current start) < prevEnd, they overlap! Example: if prevEnd is 5 and current starts at 3, they overlap (3 < 5). When there's an overlap, which do we remove? The current one! Why? Because we sorted by end time, the current one ends later than the previous. Removing the later-ending one leaves more room. We increment count but DON'T update prevEnd.

        else {
            // No overlap - update end time
            prevEnd = intervals[i][1];
        }
    }

No Overlap - Keep This Interval: If the current interval starts at or after prevEnd (current.start ≥ prevEnd), there's no overlap - we can keep both! Example: if prevEnd is 5 and current starts at 7, no conflict (7 ≥ 5). We keep this interval by updating prevEnd to the current interval's end time. Now future intervals will be compared against this new endpoint. Notice we don't increment count - we're not removing anything.

    return count;
}

The Answer - Minimum Removals: After scanning all intervals, "count" holds exactly how many we needed to remove to eliminate all overlaps. The greedy approach guarantees this is the minimum! Time complexity is O(n log n) for sorting plus O(n) for the scan = O(n log n) overall. Space is O(1) extra (we only use a couple variables). This is also known as the "Activity Selection Problem" in algorithms.

// Greedy insight: Keep the interval that ends earliest
// This maximizes space for future intervals

Why This Greedy Strategy Works: Imagine you're booking a room for events. If you have two conflicting events - one ending at 2pm and one at 5pm - which do you keep? The one ending at 2pm! From 2pm onwards, you can fit more events. By always keeping the earlier-ending interval when there's a conflict, you mathematically maximize how many intervals can coexist. This greedy choice at each step leads to the globally optimal solution - a beautiful example of greedy algorithms!

Problem 4: Largest Number from Array

Given a list of non-negative integers, arrange them to form the largest possible number. For example, given [3, 30, 34, 5, 9], the largest number you can form is "9534330". This isn't as simple as sorting largest to smallest!

#include <vector>
#include <string>
#include <algorithm>

std::string largestNumber(std::vector<int>& nums) {

Understanding the Challenge: We need three headers: <vector> for our input array, <string> because we'll work with strings (easier than manipulating large numbers), and <algorithm> for custom sorting. The function returns a string because the result could be HUGE - imagine arranging [999999999, 999999998, ...]. Such a number wouldn't fit in any integer type! By working with strings, we avoid overflow issues entirely.

    std::vector<std::string> strs;
    for (int n : nums) {
        strs.push_back(std::to_string(n));
    }

Converting to Strings: First, we convert every integer to its string form using std::to_string(). So [3, 30, 34, 5, 9] becomes ["3", "30", "34", "5", "9"]. Why strings? Because we need to compare combinations like "330" vs "303" to decide if "3" or "30" should come first. With integers, such comparisons would be complicated. With strings, we can simply concatenate (join) and compare. The "for (int n : nums)" is a range-based for loop - it visits each number in nums one by one.

    // Custom comparator: compare concatenation results
    std::sort(strs.begin(), strs.end(), 
              [](const std::string& a, const std::string& b) {
                  return a + b > b + a;  // Choose order giving larger number
              });

The Clever Comparison Trick: This is the heart of the solution! We use a custom comparator that asks: "If I put 'a' before 'b', is that better than putting 'b' before 'a'?" We check by concatenating: a+b vs b+a. Example: for "3" and "30", we compare "330" vs "303". Since "330" > "303", we want "3" to come before "30". The comparator returns true when a should come first. String comparison works perfectly here because comparing "330" > "303" character by character gives the right answer!

    // Handle edge case: all zeros
    if (strs[0] == "0") return "0";

Edge Case - All Zeros: What if the input is [0, 0, 0]? After sorting, we'd get ["0", "0", "0"]. If we just concatenate, we get "000" - but that's not right! The number zero should just be "0". So we check: after sorting, if the first (largest) element is "0", then ALL elements must be "0" (because 0 can only beat other 0s in our comparison). In this case, we return just "0". This prevents outputs like "0000".

    std::string result;
    for (const auto& s : strs) {
        result += s;
    }

Assembling the Final Answer: Now that our strings are sorted in the optimal order, we simply concatenate them all together. We start with an empty "result" string and keep appending each string from our sorted vector using the += operator. The "const auto&" means we're reading each string without copying it (const = won't modify, auto = figure out the type, & = reference). After this loop, "result" contains our answer!

    return result;
}

Returning the Largest Number: We return the concatenated result string. The time complexity is O(n log n) for sorting, where each comparison takes O(k) time (k = average string length), so overall O(n * k * log n). Space is O(n * k) for storing the string copies. This problem beautifully demonstrates how custom comparators can solve problems that seem impossible with standard sorting!

// Example: [3, 30, 34, 5, 9]
// Compare: "330" vs "303" -> "3" before "30"
// Compare: "534" vs "345" -> "5" before "34"
// Sorted: ["9", "5", "34", "3", "30"]
// Result: "9534330"

Walking Through the Example: Let's trace [3, 30, 34, 5, 9]. When comparing "3" vs "30": "330" > "303" ✓ so "3" comes first. When comparing "5" vs "34": "534" > "345" ✓ so "5" comes first. Comparing "9" vs everything: "9X" > "X9" for any single digit, so "9" is first overall. Final order: ["9", "5", "34", "3", "30"]. Concatenate: "9" + "5" + "34" + "3" + "30" = "9534330". Notice how 9 comes first (largest single digit), then 5, then 34 beats 3 because "343" > "334"!

Key Takeaway - Custom Comparators: Many sorting problems require defining your own comparison logic. In C++, use lambda functions with std::sort like: [](const auto& a, const auto& b) { return /* your comparison */; }. The comparator returns true if the first argument should come before the second. This is incredibly powerful - you can sort by any criteria you need, whether it's end time, string concatenation results, or any custom logic!

Practice: Sorting Problems

Given:

std::string s = "anagram";
std::string t = "nagaram";

Task: Check if t is an anagram of s. Expected: true

Show Solution
#include <string>
#include <algorithm>

bool isAnagram(std::string s, std::string t) {
    if (s.size() != t.size()) return false;
    
    std::sort(s.begin(), s.end());
    std::sort(t.begin(), t.end());
    
    return s == t;
}

// Alternative O(n) solution using frequency array:
bool isAnagramFast(const std::string& s, const std::string& t) {
    if (s.size() != t.size()) return false;
    
    int count[26] = {0};
    for (char c : s) count[c - 'a']++;
    for (char c : t) count[c - 'a']--;
    
    for (int i = 0; i < 26; i++) {
        if (count[i] != 0) return false;
    }
    return true;
}

Given:

std::vector<std::vector<int>> intervals = {{1,3},{6,9}};
std::vector<int> newInterval = {2,5};

Task: Insert and merge if necessary. Expected: [[1,5],[6,9]]

Show Solution
#include <vector>
#include <algorithm>

std::vector<std::vector<int>> insert(
    std::vector<std::vector<int>>& intervals, 
    std::vector<int>& newInterval) {
    
    std::vector<std::vector<int>> result;
    int i = 0;
    int n = intervals.size();
    
    // Add all intervals that end before new interval starts
    while (i < n && intervals[i][1] < newInterval[0]) {
        result.push_back(intervals[i++]);
    }
    
    // Merge overlapping intervals
    while (i < n && intervals[i][0] <= newInterval[1]) {
        newInterval[0] = std::min(newInterval[0], intervals[i][0]);
        newInterval[1] = std::max(newInterval[1], intervals[i][1]);
        i++;
    }
    result.push_back(newInterval);
    
    // Add remaining intervals
    while (i < n) {
        result.push_back(intervals[i++]);
    }
    
    return result;
}

Given: Each employee has a list of non-overlapping work intervals.

// Employee 1: [[1,2],[5,6]]
// Employee 2: [[1,3]]
// Employee 3: [[4,10]]

Task: Find all common free time intervals. Expected: [[3,4]]

Show Solution
#include <vector>
#include <algorithm>

std::vector<std::vector<int>> employeeFreeTime(
    std::vector<std::vector<std::vector<int>>>& schedule) {
    
    // Flatten all intervals
    std::vector<std::vector<int>> all;
    for (auto& emp : schedule) {
        for (auto& interval : emp) {
            all.push_back(interval);
        }
    }
    
    // Sort by start time
    std::sort(all.begin(), all.end());
    
    std::vector<std::vector<int>> result;
    int prevEnd = all[0][1];
    
    for (int i = 1; i < all.size(); i++) {
        if (all[i][0] > prevEnd) {
            // Gap found - this is free time
            result.push_back({prevEnd, all[i][0]});
        }
        prevEnd = std::max(prevEnd, all[i][1]);
    }
    
    return result;
}

// Merge all intervals, gaps between merged intervals = free time
09

Frequency Counting with Hash Maps

Hash maps enable O(1) lookup and storage of element frequencies, making them perfect for counting problems, anagram detection, and finding unique elements. Using std::unordered_map transforms many O(n^2) solutions into efficient O(n) algorithms.

The Power of Hash Maps

What is a Hash Map? Think of a hash map like a magical dictionary where you can instantly look up any word. In C++, we use std::unordered_map to store pairs of information: a "key" (what you're looking for) and a "value" (the data attached to that key). For counting problems, the key is typically the item you want to count, and the value is how many times you've seen it. The "magic" is that finding, adding, or removing items takes the same amount of time regardless of how much data you have - this is called O(1) or "constant time" performance.

Common Operations: The most useful hash map operations are: map[key]++ to count occurrences (adds 1 to the value, creates it if it doesn't exist), map.count(key) to check if a key exists (returns 1 if yes, 0 if no), and map.find(key) to get an iterator to the element. You can loop through all key-value pairs using a range-based for loop: for (auto& [key, value] : map). These operations form the foundation of virtually every frequency counting problem you'll encounter.

Problem 1: Two Sum (Original)

#include <vector>
#include <unordered_map>

Including the Right Tools: We need two header files here. The <vector> header gives us access to dynamic arrays that can grow and shrink as needed - our input will be a vector of integers. The <unordered_map> header provides the hash map data structure we'll use for lightning-fast lookups. Think of includes like importing tools into your workshop - without the right tools, you can't build anything. Always include only what you need to keep your code clean and compilation fast.

std::vector<int> twoSum(const std::vector<int>& nums, int target) {
    std::unordered_map<int, int> numIndex;  // value -> index

Function Setup and Hash Map Declaration: Our function takes a vector of numbers (nums) and a target sum we're trying to reach. It returns a vector containing the two indices whose values add up to the target. We use const and & to pass by reference without copying (efficient!) while promising not to modify the input. The numIndex hash map will store each number as a key and its position (index) as the value. This lets us instantly answer: "Have I seen this number before, and where was it?"

    for (int i = 0; i < nums.size(); i++) {
        int complement = target - nums[i];

Loop and Complement Calculation: We walk through each number in the array one by one using a standard for loop. The variable i is our current position (0, 1, 2, ...). Here's the clever trick: instead of checking every possible pair, we calculate what number we NEED. If target is 9 and current number is 2, we need 7. That's our "complement." The formula complement = target - nums[i] tells us exactly what partner we're looking for. If that partner exists somewhere we've already looked, we've found our answer! This transforms a slow O(n²) double-loop into a fast O(n) single pass.

        // Check if complement was seen before
        if (numIndex.count(complement)) {
            return {numIndex[complement], i};
        }

The Magic Lookup: This is where the hash map shines! numIndex.count(complement) instantly checks if we've seen the complement before - no looping required, just O(1) constant time. If count returns 1 (true), we found it! We immediately return both indices: numIndex[complement] gives us where the complement was stored, and i is our current position. The curly braces {...} create a vector on the fly with these two values. Notice we check BEFORE adding the current number - this prevents using the same element twice (important edge case!).

        numIndex[nums[i]] = i;
    }
    
    return {};  // No solution found
}

Store and Continue: If we didn't find our complement, we store the current number and its index in our map: numIndex[nums[i]] = i. This makes it available for future iterations to find. The key is the actual number value, the value is its index position. For example, if nums[0] = 2, we store {2: 0}. If the loop completes without finding a pair, we return an empty vector {} to indicate no solution exists. This "look before you store" pattern is fundamental - it ensures we build up our knowledge base as we go.

// Example: nums = [2, 7, 11, 15], target = 9
// i=0: need 7, not found, store {2->0}
// i=1: need 2, found at index 0!
// Result: [0, 1]

Walkthrough Example: Let's trace through step by step. We want two numbers that add to 9. At i=0, nums[0]=2, so we need 9-2=7. Is 7 in our map? No (map is empty). Store {2: 0}. At i=1, nums[1]=7, so we need 9-7=2. Is 2 in our map? Yes! It's at index 0. Return [0, 1]. We found that nums[0] + nums[1] = 2 + 7 = 9, which equals our target. Notice how we only needed to look at 2 elements instead of checking all possible pairs. That's the power of hash maps!

Problem 2: Group Anagrams

#include <vector>
#include <string>
#include <unordered_map>
#include <algorithm>

Required Headers: This problem needs more tools. <vector> for dynamic arrays, <string> for text handling, <unordered_map> for our hash map. The new one is <algorithm> which gives us the std::sort function - we'll use it to sort characters in a string. Anagrams are words with the same letters rearranged: "eat", "tea", and "ate" are all anagrams of each other. Our strategy: if we sort each word's letters, all anagrams become identical! "eat" → "aet", "tea" → "aet", "ate" → "aet".

std::vector<std::vector<std::string>> groupAnagrams(
    std::vector<std::string>& strs) {
    
    std::unordered_map<std::string, std::vector<std::string>> groups;

Complex Return Type and Map Setup: The return type looks scary but let's break it down: vector<vector<string>> means "a list of lists of strings" - each inner list contains words that are anagrams of each other. Our hash map groups maps a sorted string (the "key") to a vector of original strings (all the anagrams with that sorted form). For example, the key "aet" would map to the vector ["eat", "tea", "ate"]. This map structure automatically groups all anagrams together as we process them!

    for (const std::string& s : strs) {
        // Create sorted key (canonical form)
        std::string key = s;
        std::sort(key.begin(), key.end());

Creating the Canonical Key: We loop through each string in the input. For each string, we create a copy called key (we don't want to modify the original!). Then std::sort(key.begin(), key.end()) sorts the characters alphabetically. The word "tea" becomes "aet" after sorting. This sorted version is called the "canonical form" - a standard representation that all anagrams share. It's like giving everyone in a family the same last name: different first names (original words), same identifier (sorted form).

        groups[key].push_back(s);
    }

Adding to Groups: This single line does a lot! groups[key] accesses (or creates) the vector for this sorted key. Then .push_back(s) adds the original string to that vector. If the key doesn't exist yet, C++ automatically creates an empty vector for it. This is super convenient! So when we process "eat" (key="aet"), "tea" (key="aet"), and "tan" (key="ant"), we get: {"aet": ["eat", "tea"], "ant": ["tan"]}. Each anagram naturally joins its family group without any extra logic needed.

    // Extract grouped anagrams
    std::vector<std::vector<std::string>> result;
    for (auto& [key, anagrams] : groups) {
        result.push_back(std::move(anagrams));
    }
    
    return result;
}

Extracting Results: Now we need to convert our map into the required return format (just the grouped vectors, not the keys). The syntax auto& [key, anagrams] is called "structured binding" - it unpacks each map entry into its key and value parts automatically. We use std::move(anagrams) to transfer ownership of the vector instead of copying it - this is a performance optimization. The result is a vector of vectors, where each inner vector contains words that are anagrams of each other.

// Example: ["eat","tea","tan","ate","nat","bat"]
// Keys: "aet", "aet", "ant", "aet", "ant", "abt"
// Groups: {"aet": [eat,tea,ate], "ant": [tan,nat], "abt": [bat]}

Complete Example Trace: Let's walk through: "eat" sorts to "aet", "tea" also becomes "aet", "tan" becomes "ant", "ate" → "aet", "nat" → "ant", "bat" → "abt". The map builds up: {"aet": ["eat"]}, then {"aet": ["eat", "tea"]}, then {"aet": ["eat", "tea"], "ant": ["tan"]}, and so on. Final groups: [["eat", "tea", "ate"], ["tan", "nat"], ["bat"]] - three groups of anagrams! Time complexity is O(n × k × log k) where n is the number of strings and k is the maximum string length (sorting each string takes k log k).

Problem 3: First Unique Character

#include <string>
#include <unordered_map>

Simple Includes: This problem only needs strings and our trusty hash map. The goal is to find the first character in a string that appears exactly once. For example, in "leetcode", the letter 'l' appears once and is at index 0, while 'e' appears three times so it doesn't count. We'll use a classic two-pass approach: first count all characters, then find the first with count = 1. This is one of the cleanest examples of frequency counting in action.

int firstUniqChar(const std::string& s) {
    std::unordered_map<char, int> freq;

Function and Frequency Map: Our function takes a string and returns an integer (the index of the first unique character, or -1 if none exists). The freq map will count how many times each character appears. The key is a char, the value is an int count. For "hello", we'll build: {'h': 1, 'e': 1, 'l': 2, 'o': 1}. This map becomes our "character census" - we know exactly how popular each letter is.

    // First pass: count frequencies
    for (char c : s) {
        freq[c]++;
    }

Pass 1 - Count Everything: This loop walks through every character in the string. The range-based for loop for (char c : s) gives us each character one at a time. The expression freq[c]++ is the counting idiom: it accesses the count for character c and increments it by 1. If c wasn't in the map, C++ creates it with value 0, then adds 1. After this loop, our map contains the complete frequency table for all characters. This is O(n) time - we look at each character exactly once.

    // Second pass: find first with frequency 1
    for (int i = 0; i < s.size(); i++) {
        if (freq[s[i]] == 1) {
            return i;
        }
    }

Pass 2 - Find the First Unique: Now we walk through the string again, this time by index (we need to return the position, not just the character). For each character s[i], we check freq[s[i]] == 1 - does this character appear exactly once? The FIRST character we find with frequency 1 is our answer, so we immediately return its index i. We must traverse in original order to find the FIRST unique character, not just ANY unique character.

    return -1;  // No unique character
}

No Unique Found: If we finish the second loop without finding any character with frequency 1, every character repeats at least twice. We return -1 as a signal that no unique character exists. This is a common convention in C++ for "not found" results. For example, "aabb" would return -1 since both 'a' and 'b' appear twice. The total time complexity is O(n) + O(n) = O(n), and space is O(1) if we consider the alphabet size fixed (only 26 lowercase letters at most).

// Example: "leetcode"
// freq: {l:1, e:3, t:1, c:1, o:1, d:1}
// First pass finds 'l' at index 0 has freq 1
// Result: 0

Walkthrough: In "leetcode": l appears 1 time, e appears 3 times (at positions 1, 2, 6), t once, c once, o once, d once. In the second pass, we check index 0 (character 'l') - its frequency is 1, so we return 0 immediately. Even though t, c, o, and d are also unique, 'l' comes first so it wins. This two-pass pattern (count first, then query) is extremely common in frequency problems.

Problem 4: Top K Frequent Elements

#include <vector>
#include <unordered_map>
#include <queue>

Headers for Counting + Selection: This problem combines frequency counting with selecting the "top k" most frequent items. We need vectors, our hash map, and a new tool: <queue>. The queue header gives us priority_queue - a special container that always keeps its "best" element at the top. We'll use it as a "min-heap" to efficiently track the k most frequent elements without sorting everything. Think of it like keeping track of the top 10 scores in a game - you only need to remember 10 values, not all scores ever.

std::vector<int> topKFrequent(const std::vector<int>& nums, int k) {
    // Count frequencies
    std::unordered_map<int, int> freq;
    for (int n : nums) {
        freq[n]++;
    }

Phase 1 - Count Frequencies: Just like before, we first count how many times each number appears. The freq map stores number → count. For input [1,1,1,2,2,3], we get: {1: 3, 2: 2, 3: 1}. The number 1 appears 3 times, 2 appears twice, 3 appears once. This O(n) pass gives us complete frequency information. Now the question becomes: given these frequencies, how do we efficiently find the k numbers with the highest counts?

    // Min-heap of (frequency, element)
    // Keep only top k elements
    auto cmp = [](const std::pair<int,int>& a, const std::pair<int,int>& b) {
        return a.first > b.first;  // Min-heap by frequency
    };

Custom Comparator for Min-Heap: A priority_queue in C++ is a max-heap by default (largest on top). We want a min-heap (smallest on top) so we can easily remove the least frequent element. The cmp is a lambda function (inline function) that compares two pairs. Each pair is (frequency, number). By returning a.first > b.first, we flip the default comparison - now smaller frequencies bubble to the top. Why min-heap? We'll keep exactly k elements. When a new element is better than our worst (the min), we swap them. The min-heap makes finding the worst element instant!

    std::priority_queue<std::pair<int,int>, 
                        std::vector<std::pair<int,int>>, 
                        decltype(cmp)> pq(cmp);

Creating the Priority Queue: This line looks complex but breaks down simply. We're creating a priority_queue that stores pairs (frequency, number). The three template arguments are: (1) what we store (pairs), (2) underlying container (vector of pairs), (3) our custom comparator type. decltype(cmp) gets the type of our lambda, and we pass cmp to the constructor. Now pq is a min-heap where the pair with the smallest frequency is always accessible at the top via pq.top().

    for (auto& [num, count] : freq) {
        pq.push({count, num});
        if (pq.size() > k) {
            pq.pop();  // Remove least frequent
        }
    }

Phase 2 - Fill the Heap: We iterate through all unique numbers and their counts. For each, we push (count, num) onto our heap. Here's the clever part: if the heap grows beyond k elements, we immediately pop the smallest (least frequent). This keeps exactly k elements. After processing all numbers, the heap contains the k most frequent because we always discarded the weakest candidate. This is O(n log k) - better than sorting everything O(n log n) when k is small!

    // Extract results
    std::vector<int> result;
    while (!pq.empty()) {
        result.push_back(pq.top().second);
        pq.pop();
    }
    
    return result;
}

Phase 3 - Extract Results: Now we empty the heap to build our result. pq.top().second gets the number (not the frequency) from the top pair. We pop each element after reading it. The result vector will contain all k most frequent numbers. Note: the order might be from least to most frequent (since it's a min-heap), but the problem often doesn't require a specific order. Total complexity: O(n) for counting + O(n log k) for heap operations = O(n log k). Space: O(n) for the frequency map.

// Example: nums = [1,1,1,2,2,3], k = 2
// freq: {1:3, 2:2, 3:1}
// Heap keeps top 2: {2:2, 1:3}
// Result: [2, 1] (or [1, 2], order may vary)

Example Walkthrough: We want the 2 most frequent numbers from [1,1,1,2,2,3]. Frequencies: 1 appears 3 times, 2 appears 2 times, 3 appears 1 time. Processing: push (3,1), heap size 1 ≤ 2, keep. Push (2,2), size 2 ≤ 2, keep. Push (1,3), size 3 > 2, pop smallest (1,3). Heap now has (2,2) and (3,1) - the two most frequent! Number 3 (frequency 1) was kicked out. Result contains 2 and 1 - the numbers that appeared most often in the original array.

Problem 5: Longest Consecutive Sequence

#include <vector>
#include <unordered_set>
#include <algorithm>

Headers - Introducing unordered_set: This problem uses unordered_set instead of unordered_map. A set is like a map that only has keys, no values - perfect for checking if numbers exist. We want to find the longest sequence of consecutive integers. For [100, 4, 200, 1, 3, 2], the sequence 1-2-3-4 has length 4. The naive approach would be to sort the array O(n log n), but we can do better with a hash set achieving O(n)! The algorithm header gives us std::max for comparing lengths.

int longestConsecutive(const std::vector<int>& nums) {
    std::unordered_set<int> numSet(nums.begin(), nums.end());
    int maxLen = 0;

Building the Set: The constructor numSet(nums.begin(), nums.end()) creates a set containing all numbers from the input vector in one line. Using a set automatically removes duplicates and gives us O(1) lookup. For [100, 4, 200, 1, 3, 2], the set contains {1, 2, 3, 4, 100, 200}. maxLen tracks the longest consecutive sequence we've found so far. The key insight: we need to check "does number X exist?" many times - hash set makes this instant.

    for (int num : numSet) {
        // Only start counting if num is the start of a sequence
        // (i.e., num-1 is not in the set)
        if (!numSet.count(num - 1)) {

The Brilliant Optimization: Here's the key trick that makes this O(n). We only START counting from numbers that are the BEGINNING of a sequence. How do we know if a number starts a sequence? If num - 1 is NOT in the set, then nothing comes before this number - it's a starting point! For example, with {1, 2, 3, 4, 100, 200}: 1 is a start (0 not in set), but 2, 3, 4 are not starts (their predecessors exist). Without this check, we'd count the sequence 1-2-3-4 four times. With it, we count it once starting from 1.

            int currentNum = num;
            int currentLen = 1;
            
            // Count consecutive numbers
            while (numSet.count(currentNum + 1)) {
                currentNum++;
                currentLen++;
            }

Counting the Sequence: Once we've identified a sequence start, we count how long it goes. currentNum tracks where we are, currentLen counts how many consecutive numbers we've seen. The while loop keeps going as long as the next number exists: "Is currentNum + 1 in the set?" If yes, move forward and increment the length. For starting at 1: check 2 (exists, len=2), check 3 (exists, len=3), check 4 (exists, len=4), check 5 (doesn't exist, stop). Each number is visited at most once in these inner loops across all iterations - this is the key to O(n) performance.

            maxLen = std::max(maxLen, currentLen);
        }
    }
    
    return maxLen;
}

Track the Maximum: After counting each sequence that starts at a valid beginning point, we update maxLen if this sequence is longer than our previous best. std::max(a, b) returns the larger of two values - a concise way to update a running maximum. After processing all numbers, maxLen holds the length of the longest consecutive sequence. Time complexity is O(n) despite the nested loop because each element is processed at most twice (once in outer loop, once in while loop).

// Example: nums = [100, 4, 200, 1, 3, 2]
// Set: {100, 4, 200, 1, 3, 2}
// 1 is start of sequence (0 not present): 1->2->3->4, length 4
// 100 is start: just 100, length 1
// 200 is start: just 200, length 1
// Result: 4

Complete Example: Let's trace the algorithm. Set = {100, 4, 200, 1, 3, 2}. Check each number: is it a sequence start? 100: is 99 in set? No → start! Count: 100. Is 101 there? No. Length = 1. 4: is 3 in set? Yes → skip (not a start). 200: is 199 in set? No → start! Count: 200. Is 201 there? No. Length = 1. 1: is 0 in set? No → start! Count: 1→2→3→4. Is 5 there? No. Length = 4. Maximum length found = 4. The sequence [1, 2, 3, 4] is the longest consecutive sequence, even though the numbers were scattered in the original input.

When to Use Hash Maps vs Arrays: For small, known ranges (like ASCII characters 0-127), use arrays - they're faster and cache-friendly. For large or sparse ranges (like arbitrary integers), or when key types aren't integers, use unordered_map. If you need ordered iteration (smallest to largest keys), use map instead - it's tree-based with O(log n) operations. The right data structure choice can make your code both cleaner and faster!

Practice: Frequency Counting

Given:

std::vector<int> nums = {1, 2, 3, 1};

Task: Return true if any value appears at least twice. Expected: true

Show Solution
#include <vector>
#include <unordered_set>

bool containsDuplicate(const std::vector<int>& nums) {
    std::unordered_set<int> seen;
    
    for (int n : nums) {
        if (seen.count(n)) {
            return true;  // Already seen this number
        }
        seen.insert(n);
    }
    
    return false;
}

// Alternative one-liner:
bool containsDuplicateAlt(const std::vector<int>& nums) {
    return std::unordered_set<int>(nums.begin(), nums.end()).size() 
           < nums.size();
}

Given:

std::string s = "cbaebabacd";
std::string p = "abc";

Task: Find all start indices of p's anagrams in s. Expected: [0, 6]

Show Solution
#include <string>
#include <vector>

std::vector<int> findAnagrams(const std::string& s, const std::string& p) {
    std::vector<int> result;
    if (s.size() < p.size()) return result;
    
    int pCount[26] = {0}, sCount[26] = {0};
    
    // Initialize frequency arrays
    for (char c : p) pCount[c - 'a']++;
    
    for (int i = 0; i < s.size(); i++) {
        // Add current character
        sCount[s[i] - 'a']++;
        
        // Remove character outside window
        if (i >= p.size()) {
            sCount[s[i - p.size()] - 'a']--;
        }
        
        // Compare frequency arrays
        if (i >= p.size() - 1) {
            bool match = true;
            for (int j = 0; j < 26; j++) {
                if (pCount[j] != sCount[j]) {
                    match = false;
                    break;
                }
            }
            if (match) result.push_back(i - p.size() + 1);
        }
    }
    
    return result;
}

Given:

std::string s = "ADOBECODEBANC";
std::string t = "ABC";

Task: Find minimum window in s containing all chars of t. Expected: "BANC"

Show Solution
#include <string>
#include <unordered_map>
#include <climits>

std::string minWindow(const std::string& s, const std::string& t) {
    std::unordered_map<char, int> need, have;
    for (char c : t) need[c]++;
    
    int required = need.size();
    int formed = 0;
    int left = 0;
    int minLen = INT_MAX, minStart = 0;
    
    for (int right = 0; right < s.size(); right++) {
        char c = s[right];
        have[c]++;
        
        if (need.count(c) && have[c] == need[c]) {
            formed++;
        }
        
        while (formed == required) {
            if (right - left + 1 < minLen) {
                minLen = right - left + 1;
                minStart = left;
            }
            
            char leftChar = s[left];
            have[leftChar]--;
            if (need.count(leftChar) && have[leftChar] < need[leftChar]) {
                formed--;
            }
            left++;
        }
    }
    
    return minLen == INT_MAX ? "" : s.substr(minStart, minLen);
}

// Combines sliding window with frequency counting

Key Takeaways

Two Pointers Power

Use two pointers from opposite ends on sorted arrays to find pairs, reduce O(n^2) to O(n)

Sliding Window Efficiency

Maintain a window that slides across data to compute running aggregates in linear time

Prefix Sum Magic

Precompute cumulative sums once, then answer any range query in O(1) time

Sort First Strategy

Sorting often reveals structure - consider sorting as a preprocessing step

Hash Map Counting

Use unordered_map to count frequencies and enable O(1) lookups for element presence

Pattern Recognition

Recognize which pattern fits the problem - the right technique makes solutions elegant

Knowledge Check

Test your understanding of algorithmic problem-solving patterns:

Question 1 of 6

When is the two pointers technique most effective?

Question 2 of 6

What is the time complexity of finding a subarray sum using prefix sums after preprocessing?

Question 3 of 6

In a sliding window of fixed size k, what happens when the window slides right by one position?

Question 4 of 6

Which C++ container is best for frequency counting with O(1) average lookup?

Question 5 of 6

To find if two numbers in a sorted array sum to a target, how should two pointers move?

Question 6 of 6

Why is sorting often useful as a preprocessing step?

Answer all questions to check your score