Meet in the Middle

Split the input in half, enumerate each half separately, and combine. Turns into — the difference between and .

The pattern

1. Split the n items into halves A and B.
2. Enumerate all 2^(n/2) subsets of A, storing their values.
3. Enumerate all 2^(n/2) subsets of B; for each, find the matching partner in A.

The combine step is a sort + binary search, a hash map, or a two-pointer scan.

Worked example: subset sum

“Is there a subset summing to exactly ?” with .

bool subsetSum(vector<long long>& a, long long S) {
    int n = a.size(), h = n / 2;
    vector<long long> A, B;
    for (int m = 0; m < (1 << h); m++) {
        long long s = 0;
        for (int i = 0; i < h; i++) if (m >> i & 1) s += a[i];
        A.push_back(s);
    }
    for (int m = 0; m < (1 << (n - h)); m++) {
        long long s = 0;
        for (int i = 0; i < n - h; i++) if (m >> i & 1) s += a[h + i];
        B.push_back(s);
    }
    sort(B.begin(), B.end());
    for (long long x : A)
        if (binary_search(B.begin(), B.end(), S - x)) return true;
    return false;
}

time and memory. For that is per half — instant.

The variants of the combine step

GoalCombine
Exact sum hash set, or sort + binary search
Closest to sort , lower_bound and check both neighbours
Sum , maximisesort , keep a prefix maximum, binary search
Count the wayssort , equal_range
Sum in a rangetwo lower_bound calls
Maximise a value under a weight limitsort by weight, prefix-max the value, binary search
Both halves constrainedsort both, two pointers

The prefix-maximum trick

For “maximise the value with total weight ”:

sort(B.begin(), B.end());                              // by weight
for (int i = 1; i < (int)B.size(); i++)
    B[i].value = max(B[i].value, B[i-1].value);        // prefix max
// then for each a in A: binary search the largest weight <= W - a.weight

Without the prefix maximum you would need the maximum over a range; with it, the binary search alone suffices.

Where it applies

ProblemNote
Subset sum / partition40-45the canonical case
Knapsack with huge weights40when the DP’s is too large
Count subsets with a given XOR40hash map on the XOR
4-SUMpair sums from each half,
Shortest path with edgesbidirectional BFS
Discrete log (baby-step giant-step) from each side
Solve , the same splitting
Puzzle solving (15-puzzle, Rubik’s)bidirectional BFS
Count paths of length in a graphmeet at the midpoint
Equation enumerate each side

Bidirectional search — the graph version

Search forward from the start and backward from the goal, stopping when the frontiers meet:

// BFS from both ends, always expanding the smaller frontier
while (!fwd.empty() && !bwd.empty()) {
    if (fwd.size() <= bwd.size()) expand(fwd, visitedF, visitedB, ans);
    else                          expand(bwd, visitedB, visitedF, ans);
}

Expanding the smaller frontier keeps the balance. This is what makes the 15-puzzle and similar state-space searches tractable.

Memory — the real constraint

entries at 8 bytes:

Entries per halfMemory
300.3 MB
408 MB
4434 MB
50268 MB — too much

Mitigations: store only one half and stream the other; use an unequal split (a smaller stored half); or apply Schroeppel-Shamir, which solves subset sum in time but only memory by splitting into four parts and merging with priority queues.

Choosing the split

An even split minimises . But if one side’s enumeration is cheaper or its combine step is heavier, shifting the split can help. When memory is the binding constraint, store the smaller half.

Search simultaneously from the start and from the goal, stopping when the frontiers meet. With branching factor and distance , a one-sided BFS explores states, while bidirectional explores . That’s the difference between impossible and instant for puzzles.

Requirements

  • The goal state is known explicitly (not just a predicate).
  • Moves are reversible, or you can generate predecessors.
  • States are hashable (store visited sets in hash maps).

Algorithm (unweighted)

  1. BFS frontiers , with distance maps , .
  2. Always expand the smaller frontier by one full layer.
  3. When a newly generated state exists in the other side’s map, the answer is (minimise over the whole layer).
int bidirectional(State s, State t) {
    if (s == t) return 0;
    unordered_map<State, int> ds{{s, 0}}, dt{{t, 0}};
    vector<State> fs{s}, ft{t};
    while (!fs.empty() && !ft.empty()) {
        bool fromS = fs.size() <= ft.size();
        auto& F = fromS ? fs : ft;
        auto& D = fromS ? ds : dt;
        auto& O = fromS ? dt : ds;
        vector<State> nxt;
        int best = INT_MAX;
        for (auto& u : F) for (auto& v : neighbours(u)) {   // predecessors if expanding from t in a directed space
            if (D.count(v)) continue;
            D[v] = D[u] + 1;
            if (O.count(v)) best = min(best, D[v] + O[v]);
            nxt.push_back(v);
        }
        if (best != INT_MAX) return best;
        F.swap(nxt);
    }
    return -1;
}

Relation to meet in the middle

Meet in the Middle is the same idea for non-graph enumeration: split the choices into two halves, enumerate each, and combine via sorting or hashing.

Weighted version

Run Dijkstra from both sides alternately. Stop when , where is the best meeting distance found so far.

Pitfalls

  • Stopping at the first meeting vertex without finishing the layer can give a non-shortest answer in edge cases. Finish the layer.
  • Memory: both hash maps hold states.
  • Typical uses: 15-puzzle variants, Rubik’s-cube-like puzzles, word ladders, permutation sorting by reversals with small .