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)!
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.
Invariant & State
| 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! |
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$!
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;
}
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!