DSA DSA In Depth
0%
design meet-in-the-middlesubset-sumsearch

Meet-in-the-Middle: Halving the Exponent from O(2^N) to O(2^(N/2) log(2^(N/2)))

7 min read Module 2.3: Binary Search on Monotonic Spaces & Divide-and-Conquer Updated Oct 23, 2026
📄 Cheat Sheet
Meet in the Middle Algorithm — Bidirectional Exponential Search
Gaurav Sen Meet in the Middle Algorithm — Bidirectional Exponential Search Watch on YouTube ↗

Demonstrates how splitting an N=40 exponential search space into two N/2=20 halves turns an impossible 10^12-operation brute force into a 10^6-element two-pointer sweep.

The Exponential Wall at $N = 40$ and Horowitz-Sahni (1974)

Consider the exact or closest Subset Sum problem: given $N$ signed 64-bit integers $A = [a_0, a_1, \dots, a_{N-1}]$ and a target sum $T$ (where $|a_i|, |T| \le 10^{18}$), find a subset $S \subseteq \{0, \dots, N-1\}$ whose sum $\sum_{i \in S} a_i$ equals $T$ (or is the largest sum $\le T$). Why do standard techniques fail when $N = 40$?

  • Pseudo-polynomial DP ($O(N \cdot T)$): Fails catastrophically when $T = 10^{18}$ (requires exabytes of RAM).
  • Exhaustive Brute Force ($O(2^N)$): At $N = 40$, $2^{40} = 1{,}099{,}511{,}627{,}776 \approx 1.1 \times 10^{12}$ states—requiring minutes of CPU time.

In 1974, Ellis Horowitz and Sartaj Sahni introduced the Meet-in-the-Middle paradigm: partition the input array $A$ into two disjoint halves of size $n_1 = \lfloor N/2 \rfloor$ and $n_2 = \lceil N/2 \rceil$:

$$A_L = A[0 .. n_1 - 1], \qquad A_R = A[n_1 .. N - 1]$$

Any subset $S \subseteq A$ uniquely decomposes into $S_L \subseteq A_L$ and $S_R \subseteq A_R$ such that $\text{sum}(S) = \text{sum}(S_L) + \text{sum}(S_R) = T$. Thus, enumerating all $2^{n_1}$ subset sums of $A_L$ into a list $L$ and all $2^{n_2}$ subset sums of $A_R$ into a list $R$ reduces Subset Sum to the classic 2-SUM problem: find $x \in L$ and $y \in R$ such that $x + y = T$ (or $x + y \le T$ is maximized)!

Half-Open [lo, hi) Monotonic Predicate Decision Boundary Search
O(log N) · Zero Off-By-One

Evaluate predicate P(x) over a monotonic boolean space [F, F, ..., F, T, T, ..., T]. Maintain the loop invariant P(lo−1) == False and P(hi) == True until lo == hi.

Step 1 / 11
Custom Input:
Timeline

Invariant & State

Initial Sorted Monotonic Domain: [0, 8) | Threshold P(x): x ≥ 47 (Boundary index = 5)
📊 Live Magnitude & Pointer Stage — Half-Open [lo, hi) Monotonic Predicate Decision Boundary Search Active Pivot Match/Sorted
P=False 10 [0] P=False 18 [1] P=False 24 [2] P=False 31 [3] P=False 39 [4] P=True 47 [5] P=True 55 [6] P=True 68 [7]
[0]10P=False
[1]18P=False
[2]24P=False
[3]31P=False
[4]39P=False
[5]47P=True
[6]55P=True
[7]68P=True
Interval [lo, hi)
[0, 8)
Predicate P(x)
x ≥ 47
Monotonic Split
F×5 then T×3
Max Probes ⌈log₂N⌉
4
[Step 1] Initialized half-open interval [lo=0, hi=8) over sorted array [10, 18, 24, 31, 39, 47, 55, 68] for predicate P(x): x ≥ 47.
Algorithm / Variant ($N = 40$) Time Complexity Auxiliary Space Operations at $N = 40$
Naive Recursive / Bitmask Brute Force $\Theta(2^N)$ $O(1)$ $\approx 1.10 \times 10^{12}$ ops (TLE)
Meet-in-the-Middle + Sort + Binary Search $\Theta\!\left(N \cdot 2^{N/2}\right)$ $\Theta(2^{N/2})$ $\approx 2.1 \times 10^7$ ops (~25 ms)
Meet-in-the-Middle + Incremental Merge + 2-Pointer $\Theta\!\left(2^{N/2}\right)$ $\Theta(2^{N/2})$ $\approx 2.1 \times 10^6$ ops (~3 ms!)
Schroeppel-Shamir (1981) 4-Way Split + Heap $\Theta\!\left(N \cdot 2^{N/2}\right)$ $\Theta\!\left(2^{N/4}\right)$ Space drops to $2^{10} = 1024$ elements!
Shaving the Extra $\log(2^{N/2}) = N/2$ Factor via Incremental Merge

If you generate all $2^{N/2}$ subset sums in arbitrary bitmask order and then call std::sort, sorting takes $O(2^{N/2} \log(2^{N/2})) = O(N \cdot 2^{N/2})$ time. Instead, build the sorted subset-sum array $L$ incrementally one element at a time:

Suppose after processing $k$ items, $L[0 .. 2^k - 1]$ is already sorted. Adding item $a_k$ produces $2^k$ new sums $L[0]+a_k, \dots, L[2^k-1]+a_k$, which are already in sorted order! Merging these two sorted halves of size $2^k$ takes $O(2^{k+1})$ linear time. Summing over all $k = 0 \dots N/2 - 1$ gives a geometric series:

$$\sum_{k=0}^{N/2 - 1} O(2^{k+1}) = O(2 + 4 + 8 + \dots + 2^{N/2}) = \Theta\!\left(2^{N/2}\right)$$

Once both $L$ and $R$ are generated in sorted order in $\Theta(2^{N/2})$ time, a single linear two-pointer sweep ($i$ ascending in $L$, $j$ descending in $R$) finds the optimal pair in $\Theta(2^{N/2})$ time—completely eliminating the factor of $N/2 = 20$!

Meet-in-the-Middle: Splitting N = 40 into Two Sorted Halves of Size 2^20 = 1,048,576 Left Half A_L [0 .. 19] (20 items) Right Half A_R [20 .. 39] (20 items) Sorted L (2^20 = 1,048,576 sums) i = 0 ───────────► (Ascending) Sorted R (2^20 = 1,048,576 sums) (Descending) ◄─────────── j = |R|-1 Two-Pointer Combine: If L[i] + R[j] ≤ T, record & ++i; else --j → O(2^{N/2})
flowchart LR A["Array A of Size N <= 46"] --> B["Split into A_L (N/2) & A_R (N - N/2)"] B --> C["Generate Sorted Subset Sums L via Incremental Merge in O(2^{N/2})"] B --> D["Generate Sorted Subset Sums R via Incremental Merge in O(2^{N/2})"] C --> E["Two-Pointer Sweep (i in L up, j in R down) Total Time: O(2^{N/2})"] D --> E
Hardware Reality: Why $2^{20}$ Int64s Fly in L3 Cache vs. Hash Table Disasters

A common beginner mistake in Meet-in-the-Middle is inserting the $2^{20} = 1{,}048{,}576$ left subset sums into an std::unordered_set<int64_t> and probing it $2^{20}$ times. An std::unordered_set of $10^6$ nodes allocates $10^6$ separate heap objects (~32–48 MB of pointer-chasing nodes) and incurs millions of L3 cache/TLB misses. By contrast, a flat std::vector<int64_t> of size $2^{20}$ occupies exactly $8\text{ MB}$ of contiguous RAM—fitting entirely inside modern CPU L3 cache ($16\text{–}64\text{ MB}$)—and both the incremental merge and two-pointer sweep access memory purely sequentially at hardware prefetcher speed!

Production C++20 Implementation: $O(2^{N/2})$ Subset Sum via Incremental Merge & Two Pointers

#include <algorithm>
#include <cstddef>
#include <cstdint>
#include <limits>
#include <span>
#include <vector>

// Generates all 2^k subset sums of items in strictly sorted order in O(2^k) time
// without calling std::sort, using incremental std::inplace_merge!
std::vector<std::int64_t> generate_sorted_subset_sums(std::span<const std::int64_t> items) {
    const std::size_t k = items.size();
    std::vector<std::int64_t> sums;
    sums.reserve(std::size_t{1} << k);
    sums.push_back(0);

    for (const std::int64_t x : items) {
        const std::size_t curr_sz = sums.size();
        for (std::size_t i = 0; i < curr_sz; ++i) {
            sums.push_back(sums[i] + x);
        }
        // Merge the two sorted halves [0, curr_sz) and [curr_sz, 2*curr_sz) in linear time
        if (x >= 0) {
            std::inplace_merge(sums.begin(),
                               sums.begin() + static_cast<std::ptrdiff_t>(curr_sz),
                               sums.end());
        } else {
            // Even for negative x, [curr_sz, 2*curr_sz) is still sorted ascending!
            std::inplace_merge(sums.begin(),
                               sums.begin() + static_cast<std::ptrdiff_t>(curr_sz),
                               sums.end());
        }
    }
    return sums;
}

// Finds the maximum subset sum <= target in O(2^(N/2)) time and space
std::int64_t max_subset_sum_at_most(const std::vector<std::int64_t>& nums,
                                    std::int64_t target) {
    const std::size_t n = nums.size();
    const std::size_t mid = n / 2;

    std::span<const std::int64_t> left_span(nums.data(), mid);
    std::span<const std::int64_t> right_span(nums.data() + mid, n - mid);

    const std::vector<std::int64_t> L = generate_sorted_subset_sums(left_span);
    const std::vector<std::int64_t> R = generate_sorted_subset_sums(right_span);

    std::int64_t best = std::numeric_limits<std::int64_t>::min();
    std::size_t i = 0;
    std::ptrdiff_t j = static_cast<std::ptrdiff_t>(R.size()) - 1;

    // Linear O(2^(N/2)) two-pointer sweep
    while (i < L.size() && j >= 0) {
        const std::int64_t cur = L[i] + R[static_cast<std::size_t>(j)];
        if (cur <= target) {
            if (cur > best) best = cur;
            if (best == target) return best; // Exact match early exit
            ++i;
        } else {
            --j;
        }
    }
    return best;
}
Asymmetric Splits When $N$ Is Odd or When Applying 0/1 Knapsack Dominance Pruning

If $N = 41$, splitting into $20$ and $21$ gives $|L| = 1.05 \times 10^6$ and $|R| = 2.10 \times 10^6$. If you use binary search (lookup each element of one list in the other), always sort the larger list and binary-search from the smaller list, or use the symmetric two-pointer sweep above. Furthermore, in 0/1 Knapsack Meet-in-the-Middle (pairs $(w_i, v_i)$ sorted by weight $w_i$), before running two pointers, filter the right list $R$ in $O(|R|)$ to remove dominated states (any state with higher weight $w_b \ge w_a$ but smaller or equal value $v_b \le v_a$) so that remaining states are strictly increasing in both weight and value!

Check yourself Question 1 of 4
Concept Check Core Principle of Horowitz-Sahni Meet-in-the-Middle recall

You are given N = 40 integers with arbitrary 64-bit weights (up to 10^{16}) and need to determine whether any subset sums to exact target T.

Key Takeaways

Meet-in-the-Middle cuts the exponent of exhaustive search in half by decomposing an N-item space into two independent N/2-item halves.
Building subset sums via incremental linear merging takes O(2 + 4 + ... + 2^(N/2)) = O(2^(N/2)) time, shaving off the O(N) sorting factor.
Combining two sorted lists of size 2^(N/2) with opposite-direction two pointers takes linear O(2^(N/2)) time and sequential L1/L3 cache scans.
For 0/1 Knapsack with large weights, removing weight-value dominated states makes the sorted list monotonic in both coordinates, enabling two-pointer or binary search queries.