Algorithms

Meet in the Middle

Turning 2ⁿ brute force into 2^(n/2) — the square-root shortcut

Meet in the middle is an exponential-time technique that splits a search space of n binary choices into two halves, enumerates all 2^(n/2) combinations of each half separately, then stitches the two sides together using sorting plus binary search or a hash table. It converts the naive 2ⁿ brute force into O(2^(n/2)) time at the cost of O(2^(n/2)) space — a textbook space-for-time trade that makes subset-sum tractable for n ≈ 40 and that Diffie and Hellman used in 1977 to break Double DES's supposed 112-bit security down to roughly 2⁵⁷ work.

  • Time (sort + binary search)O(2^(n/2) · n)
  • Time (hashing)O(2^(n/2)) expected
  • SpaceO(2^(n/2))
  • Brute-force baselineO(2ⁿ)
  • Practical ceilingn ≈ 40–45
  • Introduced byHorowitz–Sahni (1974); Diffie–Hellman (1977)

Interactive visualization

Press play, or step through manually. The visualization is yours to drive — try it before reading on.

Open visualization fullscreen ↗

Watch the 60-second explainer

A condensed visual walkthrough — narrated, captioned, under a minute.

Why meet in the middle matters

Many combinatorial problems are trivially solvable by trying every possibility — but "every possibility" is 2ⁿ, and 2ⁿ grows so fast that even n = 45 is out of reach on a single machine (2⁴⁵ ≈ 3.5 × 10¹³ operations, minutes to hours). Meet in the middle exploits a structural gift: when the n decisions are independent binary choices, you can decide the first half and the second half separately, then merge the results. Each half has only 2^(n/2) outcomes. Since 2^(n/2) = √(2ⁿ), you replace an exponent with half an exponent — the square root of the original work.

Concretely: for n = 40, brute force is 2⁴⁰ ≈ 1.1 × 10¹² (a trillion, too slow). Meet in the middle is 2²⁰ ≈ 10⁶ per half — a million operations, essentially instant. That single doubling of tractable input size, from n ≈ 25 to n ≈ 50, is the difference between "unsolvable" and "solved in a millisecond" for an entire family of NP-hard-flavored problems: subset sum, k-sum, partition, minimum-cost knapsack with tiny item counts, and finding four values that XOR to zero.

How it works, step by step

Take the canonical problem: subset sum — given n integers and a target T, does some subset sum exactly to T? The naive approach enumerates all 2ⁿ subsets. Meet in the middle proceeds:

  1. Split. Partition the n numbers into two halves, L (first n/2 numbers) and R (last n/2). This split is arbitrary — any balanced partition works.
  2. Enumerate each half. Compute the multiset of all subset sums of L — there are 2^(n/2) of them. Do the same for R. Each list is generated in O(2^(n/2)) time by iterating over bitmasks or by recursion.
  3. Prepare one side. Sort the list of R-sums (or insert them into a hash set). Sorting costs O(2^(n/2) · log 2^(n/2)) = O(2^(n/2) · n).
  4. Combine. For each sum a in the L-list, look for the complement T − a in the R-list via binary search (or a hash probe). If found, the corresponding subset of L plus subset of R together sum to exactly T.

The genius is in step 4. Instead of pairing every L-subset with every R-subset (which would be 2^(n/2) × 2^(n/2) = 2ⁿ — back to brute force), you let the value do the matching. Each a has exactly one complement T − a, and binary search finds whether that complement exists in O(log 2^(n/2)) = O(n) time. The cross product never materializes.

The same skeleton adapts to many objectives. Want the subset sum closest to T without exceeding it? Sort R, and for each a use upper_bound(T − a) to find the largest feasible R-sum — the predecessor query a hash table cannot answer. Want to count subsets hitting T? Sort R and, for each a, count occurrences of T − a with a pair of binary searches.

Complexity: why √(2ⁿ)

Let n be the number of elements. Splitting gives two halves of n/2 elements, each yielding 2^(n/2) sub-results.

  • Enumeration: O(2^(n/2)) per half — 2·2^(n/2) = O(2^(n/2)) total.
  • Sorting one half: O(2^(n/2) · log(2^(n/2))) = O(2^(n/2) · (n/2)) = O(2^(n/2) · n).
  • Combining: 2^(n/2) binary searches, each O(n), so O(2^(n/2) · n).
  • Total time: O(2^(n/2) · n) with sorting, or O(2^(n/2)) expected with hashing.
  • Total space: O(2^(n/2)) — you must store at least one full half's results.

Because 2^(n/2) = (2ⁿ)^(1/2), the running time is the square root of the brute-force baseline (ignoring the polynomial n factor). This is the same "exponent-halving" you see in bidirectional graph search, baby-step giant-step for discrete logarithms, and the generalized birthday problem — all cousins of meet in the middle.

Meet in the middle vs brute force vs DP

Brute forceMeet in the middlePseudo-poly DP
TimeO(2ⁿ)O(2^(n/2) · n)O(n · T)
SpaceO(n)O(2^(n/2))O(T) or O(n · T)
Good whenn ≤ 25n ≤ 40–45, any valuesT small (integer)
Handles huge / real values?YesYesNo (needs integer T)
Handles large n?NoNo (2^(n/2) blows up)Yes (if T is bounded)
Extra memory costNoneHigh (stores a half)Proportional to T

The decision rule is clean. If the target/weights are small integers, use the pseudo-polynomial DP — it is simpler and faster. If n is small but the values are astronomically large (up to 10¹⁸) or real-valued, DP's table is infeasible and meet in the middle wins. If n is large (say ≥ 60), neither exact method fits in memory and you fall back to approximation or specialized lattice methods.

Implementation in Python: subset sum

from bisect import bisect_left, insort

def subset_sum_exists(nums, target):
    """Return True if some subset of nums sums exactly to target.
    Runs in O(2^(n/2) * n) time and O(2^(n/2)) space."""
    n = len(nums)
    mid = n // 2
    left, right = nums[:mid], nums[mid:]

    def all_subset_sums(arr):
        sums = [0]
        for x in arr:
            sums += [s + x for s in sums]   # doubles the list each step
        return sums

    left_sums = all_subset_sums(left)        # 2^(mid) values
    right_sums = sorted(all_subset_sums(right))  # 2^(n-mid) values, sorted

    for a in left_sums:
        need = target - a
        i = bisect_left(right_sums, need)
        if i < len(right_sums) and right_sums[i] == need:
            return True
    return False


def closest_subset_sum(nums, target):
    """Largest achievable subset sum that does not exceed target."""
    n = len(nums)
    mid = n // 2
    left_sums = _sums(nums[:mid])
    right_sums = sorted(_sums(nums[mid:]))
    best = float('-inf')
    for a in left_sums:
        room = target - a
        i = bisect_left(right_sums, room + 1) - 1  # largest R-sum <= room
        if i >= 0:
            best = max(best, a + right_sums[i])
    return best

def _sums(arr):
    sums = [0]
    for x in arr:
        sums += [s + x for s in sums]
    return sums

The all_subset_sums helper doubles its output on each iteration, so after processing all n/2 elements it holds exactly 2^(n/2) sums. Sorting the right half once, then binary-searching it 2^(n/2) times, is what keeps the combine step off the 2ⁿ cross product. For the pure existence test you can swap the sorted array + bisect for a Python set and get O(2^(n/2)) expected time — but you lose the predecessor query that closest_subset_sum depends on.

A short history: subset sum and Double DES

The first published meet-in-the-middle algorithm is due to Ellis Horowitz and Sartaj Sahni in 1974, "Computing Partitions with Applications to the Knapsack Problem." They solved subset sum and 0/1 knapsack in O(2^(n/2)) time and space — a landmark because it showed that a problem believed to require 2ⁿ effort could be square-rooted with clever bookkeeping.

Three years later, in 1977, Whitfield Diffie and Martin Hellman turned the same idea into a cryptanalytic weapon. Their target was Double DES: encrypting a block twice under two independent 56-bit keys, C = E(k₂, E(k₁, P)), which naively looks like 112-bit security (2¹¹² keys). Diffie and Hellman observed that the intermediate value M = E(k₁, P) = D(k₂, C) is the "middle" both sides can compute. Encrypt a known plaintext under all 2⁵⁶ values of k₁ and store the results; decrypt the known ciphertext under all 2⁵⁶ values of k₂; any collision in the middle reveals a candidate (k₁, k₂). The attack costs about 2⁵⁷ encryptions and 2⁵⁶ storage instead of 2¹¹². This is precisely why the world standardized on Triple DES (three DES operations, effective ~112-bit security even under meet in the middle) rather than Double DES. The same principle attacks other cascade ciphers and drives collision-finding across cryptography.

Common misconceptions and pitfalls

  • "It makes exponential problems polynomial." No — 2^(n/2) is still exponential. Meet in the middle roughly doubles the feasible input size; it does not defeat NP-hardness. For n = 80 it needs 2⁴⁰ ≈ 10¹² entries, which no longer fits in memory.
  • Forgetting the memory wall. The technique lives or dies on whether 2^(n/2) results fit in RAM. At n = 50 that is 2²⁵ ≈ 33 million entries (fine); at n = 60 it is 2³⁰ ≈ 10⁹ (gigabytes, borderline); at n = 66 it is 2³³ ≈ 8 × 10⁹ (usually too much). Always compute the storage before committing.
  • Materializing the cross product. The whole point is to match by value, never pairing every L-subset with every R-subset. If your combine loop is nested over both lists, you have reintroduced O(2ⁿ) and gained nothing.
  • Unbalanced split. Splitting 1/3 vs 2/3 gives 2^(n/3) + 2^(2n/3) — dominated by the larger half. Always split as evenly as possible to minimize the max of the two halves.
  • Hashing when you need order. A hash set answers "does exactly T − a exist?" but not "what is the largest R-sum ≤ T − a?" For closest-sum, count-in-range, or bounded-knapsack variants you need a sorted array and binary search (or a balanced BST).
  • Integer overflow. Subset sums of large inputs can exceed 32-bit and even 64-bit ranges; use wide integers and be deliberate about the target's type.

Where it shows up

  • Subset sum & partition. The classic use: decide if a target is reachable, or find the closest reachable sum, for n up to ~40. Competitive programming problems with n ≤ 40 and 64-bit weights are a dead giveaway for meet in the middle.
  • 4-SUM and k-SUM. Finding four numbers summing to T: enumerate all pairwise sums from two halves in O(N²), sort, and match — an O(N² log N) algorithm that is a two-list meet in the middle over pairs.
  • Cryptanalysis. Double encryption, multi-key cascades, and hash collision search all use the encrypt-forward / decrypt-backward meeting pattern; it is the reason effective key length of a double cipher is only slightly more than a single one.
  • Discrete logarithm — baby-step giant-step. Shanks's algorithm solves gˣ = h in O(√N) time and O(√N) space by tabulating "baby steps" and matching against "giant steps" — meet in the middle over a cyclic group.
  • Minimum-cost path / bounded search. When a search tree has depth d and branching b, meeting from both ends turns O(b^d) into O(b^(d/2)) — the bidirectional-search sibling of this idea.

Frequently asked questions

What is the time complexity of meet in the middle?

For a search space with 2^n candidates, meet in the middle splits the n choices into two halves and enumerates each half in 2^(n/2). Combining the halves costs an extra logarithmic or constant factor per element, so the total time is O(2^(n/2) · n) with sort-and-binary-search, or O(2^(n/2)) expected with a hash table. That is roughly the square root of the brute-force 2^n — for n = 40 it turns 10^12 operations into about 10^6, a million-fold speedup. The space is O(2^(n/2)) because you must store one half.

How does meet in the middle solve subset sum?

Split the n numbers into two halves A and B of size n/2. Enumerate every subset sum of A (2^(n/2) values) and every subset sum of B (another 2^(n/2) values). Sort B's sums. For each sum a in A, binary-search B for the value target − a; if it exists, the two subsets together hit the target. This runs in O(2^(n/2) · n) time and O(2^(n/2)) space, making n ≈ 40 tractable where the naive 2^n brute force (n ≈ 25) is not. The 1974 Horowitz–Sahni algorithm is the canonical example.

Why is meet in the middle a space-time tradeoff?

Pure brute force uses O(1) or O(n) memory but O(2^n) time. Meet in the middle spends O(2^(n/2)) extra memory to store all results of one half, and in exchange it drops the running time to O(2^(n/2)). You are buying a square-root reduction in time by paying a square-root-of-the-total amount of memory. It is only worthwhile when 2^(n/2) results actually fit in RAM — for n = 60 that is over a billion entries, which is where the technique hits its practical ceiling.

What is the double-DES meet-in-the-middle attack?

Double DES encrypts as C = E(k2, E(k1, P)) with two independent 56-bit keys, seemingly giving 112-bit security. The meet-in-the-middle attack, published by Diffie and Hellman in 1977, encrypts a known plaintext under all 2^56 possible k1 values and decrypts the ciphertext under all 2^56 possible k2 values, then finds a match in the middle. It recovers the key pair in about 2^57 operations and 2^56 storage instead of 2^112 — which is why real systems use Triple DES, not Double DES.

When should I use meet in the middle instead of dynamic programming?

Use meet in the middle when the state space is exponential in n but n is small (typically n ≤ 40–45) and the values are large or real-valued, so a pseudo-polynomial DP table keyed on the sum is infeasible. Subset sum with target up to 10^18 is the textbook case: an O(n · target) DP would need 10^18 cells, but meet in the middle needs only O(2^(n/2)) ≈ 2^20 ≈ 10^6. When the target is small and integer, plain DP is simpler and faster; when n is large, neither works and you need approximation.

Can meet in the middle be sped up with hashing instead of sorting?

Yes. Instead of sorting one half and binary-searching, you can insert all 2^(n/2) sums of one half into a hash set or hash map, then probe it for each sum of the other half. This drops the per-query cost from O(log(2^(n/2))) = O(n) to O(1) expected, giving O(2^(n/2)) expected total time. Sorting is preferred when you need to answer range or nearest queries — such as 'is any subset sum within the interval [L, R]?' — because binary search over a sorted array supports predecessor/successor lookups that a plain hash table does not.

What is the difference between meet in the middle and bidirectional search?

They share the same insight — search from both ends and match in the middle — but apply it differently. Bidirectional search runs BFS from the start and goal simultaneously on a graph, halving the effective depth from O(b^d) to O(b^(d/2)) frontier nodes. Meet in the middle splits a set of independent binary choices, enumerating 2^(n/2) combinations per half and joining them by value. One meets on a shared graph vertex; the other meets on a shared numeric key. Both turn an exponent into half an exponent.