Using Meet in the Middle Pattern
1. Applying Meet in the Middle Technique
The Meet in the Middle pattern is a divide-and-conquer algorithmic technique used to optimize problems that can be split into two independent parts. It is particularly effective for problems where a brute-force approach would be too slow, but the problem can be decomposed into two subproblems that are solved separately and then combined.
| Step | Description | Implementation |
|---|---|---|
| 1. Split the problem | Divide the input into two halves or independent subproblems. | For example, in a subset sum problem, split the array into two halves. |
| 2. Solve each half independently | Compute all possible solutions or intermediate results for each half. | For subset sum, compute all possible sums from the first half and store in a set. |
| 3. Combine results | Use the intermediate results to find a solution that satisfies the problem constraints. | For subset sum, check if any element from the second half's sums can combine with an element from the first half to reach the target. |
Example: Subset Sum Problem
Given an array of integers, determine if there exists a subset that sums to zero.
function canPartition(nums) {
const total = nums.reduce((a, b) => a + b, 0);
if (total % 2 !== 0) return false;
const target = total / 2;
// Split into two halves
const leftHalf = nums.slice(0, nums.length / 2);
const rightHalf = nums.slice(nums.length / 2);
// Compute all possible sums for left half
const leftSums = new Set();
const n = leftHalf.length;
for (let i = 0; i < (1 << n); i++) {
let sum = 0;
for (let j = 0; j < n; j++) {
if ((i & (1 << j)) !== 0) {
sum += leftHalf[j];
}
}
leftSums.add(sum);
}
// For each sum in right half, check if complement exists in left sums
const m = rightHalf.length;
for (let i = 0; i < (1 << m); i++) {
let sum = 0;
for (let j = 0; j < m; j++) {
if ((i & (1 << j)) !== 0) {
sum += rightHalf[j];
}
}
if (leftSums.has(target - sum)) {
return true;
}
}
return false;
}
// Example usage:
const nums = [1, -1, 2, -2];
console.log(canPartition(nums)); // true
Key Characteristics
- Effective for problems with exponential time complexity.
- Reduces the search space by splitting the problem into two manageable parts.
- Requires additional space to store intermediate results (e.g., a hash set).
2. Solving Subset Sum
The Meet-in-the-Middle (MITM) algorithm pattern is particularly useful for solving the Subset Sum problem efficiently when the input size is large. Instead of checking all 2n subsets, MITM splits the array into two halves and computes all possible subset sums for each half.
| Property | Description | Notes |
|---|---|---|
| Input | Array of integers nums, target sum target |
Array length is even for simplicity; can be adapted for odd lengths |
| Time Complexity | O(2n/2 × log(2n/2)) | Reduces from O(2n) to O(2n/2 × n) |
| Space Complexity | O(2n/2) | Storing subset sums for both halves |
Example: Subset Sum with MITM
function subsetSumMITM(nums, target) {
const n = nums.length;
const half = Math.floor(n / 2);
// Split into two halves
const left = nums.slice(0, half);
const right = nums.slice(half);
// Generate all subset sums for left half
const leftSums = new Set();
for (let mask = 0; mask < (1 << left.length); mask++) {
let sum = 0;
for (let i = 0; i < left.length; i++) {
if ((mask & (1 << i)) !== 0) sum += left[i];
}
leftSums.add(sum);
}
// For each subset sum in right half, check if complement exists
for (let mask = 0; mask < (1 << right.length); mask++) {
let sum = 0;
for (let i = 0; i < right.length; i++) {
if ((mask & (1 << i)) !== 0) sum += right[i];
}
if (leftSums.has(target - sum)) return true;
}
return false;
}
// Example usage:
const nums = [1, 3, 5, 7, 9, 11];
const target = 16;
console.log(subsetSumMITM(nums, target)); // true (e.g., 1 + 3 + 5 + 7 = 16)
n is large.
3. Finding Closest Sum
Given an array of integers and a target sum, find two elements whose sum is closest to the target.
| Property | Description | Example |
|---|---|---|
| Input | Array of integers and target sum | [1, 3, 5, 7, 9], target = 10 |
| Output | Pair of elements with sum closest to target | [3, 7] |
Example: Finding Closest Sum
function findClosestSum(arr, target) {
let left = 0;
let right = arr.length - 1;
let closestSum = arr[left] + arr[right];
let minDiff = Math.abs(target - closestSum);
while (left < right) {
const currentSum = arr[left] + arr[right];
const diff = Math.abs(target - currentSum);
if (diff < minDiff) {
minDiff = diff;
closestSum = currentSum;
}
if (currentSum < target) {
left++;
} else {
right--;
}
}
return closestSum;
}
const arr = [1, 3, 5, 7, 9];
const target = 10;
console.log(findClosestSum(arr, target)); // Output: 10
4. Implementing Bidirectional Search
Bidirectional search is a graph traversal algorithm that runs two simultaneous searches: one from the source node and another from the target node, meeting in the middle. This approach can significantly reduce the search space compared to unidirectional search.
| Aspect | Description | Notes |
|---|---|---|
| Search Direction | Runs two searches: forward from source, backward from target | Reduces search space by half in many cases |
| Data Structures | Two queues/stacks, two visited sets | Forward and backward search states maintained separately |
| Meeting Point | Searches meet at a common node | Path is reconstructed from both directions |
Example: Bidirectional BFS Implementation
function bidirectionalBFS(graph, start, target) {
let forwardQueue = [start];
let backwardQueue = [target];
let forwardVisited = new Set([start]);
let backwardVisited = new Set([target]);
let forwardParent = new Map();
let backwardParent = new Map();
while (forwardQueue.length && backwardQueue.length) {
// Expand forward search
let current = forwardQueue.shift();
if (backwardVisited.has(current)) {
return reconstructPath(forwardParent, backwardParent, current);
}
for (let neighbor of graph[current]) {
if (!forwardVisited.has(neighbor)) {
forwardVisited.add(neighbor);
forwardParent.set(neighbor, current);
forwardQueue.push(neighbor);
}
}
// Expand backward search
current = backwardQueue.shift();
if (forwardVisited.has(current)) {
return reconstructPath(forwardParent, backwardParent, current);
}
for (let neighbor of graph[current]) {
if (!backwardVisited.has(neighbor)) {
backwardVisited.add(neighbor);
backwardParent.set(neighbor, current);
backwardQueue.push(neighbor);
}
}
}
return null; // No path found
}
function reconstructPath(forwardParent, backwardParent, meetingPoint) {
let path = [];
let current = meetingPoint;
while (current !== null) {
path.unshift(current);
current = forwardParent.get(current);
}
current = backwardParent.get(meetingPoint);
while (current !== null) {
path.push(current);
current = backwardParent.get(current);
}
return path;
}
5. Solving 4Sum Problem
The Meet-in-the-Middle (MITM) pattern is particularly useful for problems where we can split the input into two parts and reduce the time complexity by precomputing one half.
For the 4Sum problem, we are given an array of integers and need to find all unique quadruplets that sum to a target value.
Using the MITM approach:
- Split the array into two halves.
- Precompute all possible sums of pairs from the first half and store them in a hash map with their indices.
- For each pair from the second half, compute the complement (target - sum) and look it up in the hash map.
- Combine valid pairs to form quadruplets and ensure uniqueness.
Example: 4Sum using MITM
function fourSum(nums, target) {
const n = nums.length;
if (n < 4) return [];
// Split into two halves
const firstHalf = nums.slice(0, n / 2);
const secondHalf = nums.slice(n / 2);
// Precompute sums from first half
const sumMap = new Map();
for (let i = 0; i < firstHalf.length; i++) {
for (let j = i + 1; j < firstHalf.length; j++) {
const sum = firstHalf[i] + firstHalf[j];
if (!sumMap.has(sum)) {
sumMap.set(sum, []);
}
sumMap.get(sum).push([i, j]);
}
}
const result = new Set();
for (let i = 0; i < secondHalf.length; i++) {
for (let j = i + 1; j < secondHalf.length; j++) {
const complement = target - (secondHalf[i] + secondHalf[j]);
if (sumMap.has(complement)) {
const pairs = sumMap.get(complement);
for (const [a, b] of pairs) {
const quad = [
firstHalf[a], firstHalf[b],
secondHalf[i], secondHalf[j]
].sort((x, y) => x - y);
result.add(JSON.stringify(quad));
}
}
}
}
return Array.from(result).map(JSON.parse);
}
// Example usage:
const nums = [1, 0, -1, 0, -2, 2];
const target = 0;
console.log(fourSum(nums, target)); // [[-2, -1, 1, 2], [-2, 0, 0, 2], [-1, 0, 0, 1]]
| Step | Description | Time Complexity | Space Complexity |
|---|---|---|---|
| Splitting | Divide array into two halves of size n/2 | O(n) | O(n) |
| Precompute Sums | Compute all pair sums from first half and store in hash map | O(n²) | O(n²) |
| Lookup Complements | For each pair in second half, look up complement in hash map | O(n²) | O(1) |
| Combine Quadruplets | Generate unique quadruplets from valid pairs | O(n²) | O(n) |
MITM Pattern for 4Sum
- Split array into two halves.
- Precompute all pair sums from the first half and store in a hash map.
- For each pair in the second half, find complement sums in the hash map.
- Combine valid pairs to form unique quadruplets.
6. Finding Minimum XOR
The "Finding Minimum XOR" problem involves finding two numbers in an array such that their XOR value is minimized. This pattern is often solved using a Trie data structure to efficiently search for the closest number that would minimize the XOR.
| Property | Description | Notes |
|---|---|---|
| XOR Operation | XOR of two numbers is minimized when the numbers are as close as possible in binary representation. | Minimizing XOR is equivalent to minimizing the difference between two numbers in binary form. |
| Trie Implementation | Each bit of a number is inserted into a Trie, starting from the most significant bit. | For each number in the array, we traverse the Trie to find the closest number that minimizes XOR. |
Example: Finding Minimum XOR Using Trie
class TrieNode {
constructor() {
this.children = {};
this.value = null;
}
}
class Trie {
constructor() {
this.root = new TrieNode();
}
insert(num) {
let node = this.root;
for (let i = 31; i >= 0; i--) {
const bit = (num & (1 << i)) !== 0 ? 1 : 0;
if (!node.children[bit]) {
node.children[bit] = new TrieNode();
}
node = node.children[bit];
}
node.value = num;
}
findMinXor(num) {
let node = this.root;
let xorValue = 0;
for (let i = 31; i >= 0; i--) {
const bit = (num & (1 << i)) !== 0 ? 1 : 0;
const toggledBit = bit === 1 ? 0 : 1;
if (node.children[toggledBit]) {
xorValue |= (1 << i);
node = node.children[toggledBit];
} else {
node = node.children[bit];
}
}
return xorValue;
}
}
function minXOR(arr) {
const trie = new Trie();
let minXor = Infinity;
for (let num of arr) {
if (trie.root.children[0] || trie.root.children[1]) {
const xor = trie.findMinXor(num);
minXor = Math.min(minXor, xor);
}
trie.insert(num);
}
return minXor;
}
// Example usage:
const arr = [9, 5, 3];
console.log(minXOR(arr)); // Output: 6 (from 9 XOR 5)
7. Solving Knapsack Variants
| Variant | Description | Solution Approach | Time Complexity |
|---|---|---|---|
| 0/1 Knapsack | Each item can be used at most once. Maximize value within weight capacity. | Dynamic Programming (2D array) | O(nW) |
| Unbounded Knapsack | Each item can be used unlimited times. Maximize value within weight capacity. | Dynamic Programming (1D array) | O(nW) |
| Bounded Knapsack | Each item has a limited count. Maximize value within weight capacity. | Dynamic Programming with item counts | O(nW) |
| Subset Sum | Determine if a subset of items sums to a target value. | Dynamic Programming (1D array) | O(nS) |
| Partition Problem | Partition array into two subsets with equal sum. | Subset Sum variant | O(nS) |
Example: 0/1 Knapsack
const weights = [2, 1, 3, 2];
const values = [3, 2, 4, 2];
const capacity = 4;
function knapsack(weights, values, capacity) {
const n = weights.length;
const dp = Array(n + 1).fill().map(() => Array(capacity + 1).fill(0));
for (let i = 1; i <= n; i++) {
for (let w = 0; w <= capacity; w++) {
dp[i][w] = dp[i - 1][w];
if (weights[i - 1] <= w) {
dp[i][w] = Math.max(dp[i][w], dp[i - 1][w - weights[i - 1]] + values[i - 1]);
}
}
}
return dp[n][capacity];
}
console.log(knapsack(weights, values, capacity)); // Output: 7
Example: Subset Sum
const nums = [1, 2, 3, 4];
const target = 5;
function canPartition(nums, target) {
const dp = Array(target + 1).fill(false);
dp[0] = true;
for (const num of nums) {
for (let i = target; i >= num; i--) {
dp[i] = dp[i] || dp[i - num];
}
}
return dp[target];
}
console.log(canPartition(nums, target)); // Output: true (2 + 3 = 5)
8. Implementing Hash-Based Merging
Hash-based merging is a technique used to efficiently combine data structures or solve problems by leveraging hash tables for fast lookups and merges. It's particularly useful in scenarios where you need to find common elements or merge sorted arrays efficiently.
| Operation | Description | Time Complexity | Space Complexity |
|---|---|---|---|
| Hash Table Construction | Build a hash table from one dataset for O(1) lookups. | O(n) | O(n) |
| Merging with Lookup | Iterate through the second dataset and check for matches in the hash table. | O(m) | O(1) |
Example: Finding Common Elements in Two Arrays
const arr1 = [1, 2, 3, 4, 5];
const arr2 = [3, 4, 5, 6, 7];
// Step 1: Build hash table from first array
const hashSet = new Set(arr1);
// Step 2: Find common elements by checking second array
const common = arr2.filter(item => hashSet.has(item));
console.log(common); // [3, 4, 5]
Example: Merging Sorted Arrays Using Hash Map
const arr1 = [1, 3, 5, 7];
const arr2 = [2, 4, 6, 8];
// Create a map to store elements from first array
const map = new Map();
for (let i = 0; i < arr1.length; i++) {
map.set(arr1[i], true);
}
// Merge by checking second array elements
const merged = [...arr1, ...arr2].filter(item => map.has(item) || !map.has(item));
console.log(merged); // [1, 3, 5, 7, 2, 4, 6, 8]
9. Solving Partition Equal Subset
The Partition Equal Subset Sum problem asks whether a given array of positive integers can be partitioned into two subsets with equal sums.
This problem is a variant of the subset sum problem and can be solved efficiently using the Meet-in-the-Middle technique to reduce time complexity.
| Problem | Description | Solution Approach |
|---|---|---|
| Input | Array of positive integers | Partition into two subsets with equal sum |
| Output | Boolean indicating if partition is possible | Meet-in-the-Middle + Subset Sum |
Example: Partition Equal Subset Sum
function canPartition(nums) {
const totalSum = nums.reduce((sum, num) => sum + num, 0);
if (totalSum % 2 !== 0) return false;
const target = totalSum / 2;
// Generate all subset sums for first half
const leftSums = new Set();
const n = nums.length;
for (let mask = 0; mask < (1 << Math.floor(n / 2)); mask++) {
let sum = 0;
for (let i = 0; i < Math.floor(n / 2); i++) {
if ((mask & (1 << i)) !== 0) sum += nums[i];
}
leftSums.add(sum);
}
// For second half, check if complement exists in left sums
for (let mask = 0; mask < (1 << Math.ceil(n / 2)); mask++) {
let sum = 0;
for (let i = Math.floor(n / 2); i < n; i++) {
if ((mask & (1 << (i - Math.floor(n / 2)))) !== 0) sum += nums[i];
}
if (leftSums.has(target - sum)) return true;
}
return false;
}
// Example usage:
console.log(canPartition([1, 5, 11, 5])); // true
console.log(canPartition([1, 2, 3, 5])); // false
| Approach | Time Complexity | Space Complexity | Description |
|---|---|---|---|
| Meet-in-the-Middle | O(2^(n/2)) |
O(2^(n/2)) |
Split array into two halves, compute subset sums for first half and check if complement exists in second half |
O(2^n) to O(2^(n/2)), making it feasible for larger inputs.
10. Optimizing Time Complexity
| Operation | Time Complexity (Before) | Time Complexity (After) | Notes |
|---|---|---|---|
| Brute Force Search | O(2^n) |
O(n * 2^(n/2)) |
Meet-in-the-middle reduces exponential search space by splitting problem into two halves |
| Subset Sum | O(2^n) |
O(n * 2^(n/2)) |
Generate all subsets for left half, right half, then combine using two pointers |
| Shortest Path in Graph | O(V^2) with Floyd-Warshall |
O(V * 2^(V/2)) |
Split nodes into two groups and compute paths between them |
Example: Subset Sum Optimization
function subsetSumOptimized(arr, target) {
const n = arr.length;
const leftHalf = [];
const rightHalf = [];
// Split array into two halves
for (let i = 0; i < n / 2; i++) {
leftHalf.push(arr[i]);
}
for (let i = n / 2; i < n; i++) {
rightHalf.push(arr[i]);
}
// Generate all subset sums for left half
const leftSums = new Set();
for (let i = 0; i < (1 << leftHalf.length); i++) {
let sum = 0;
for (let j = 0; j < leftHalf.length; j++) {
if ((i & (1 << j)) !== 0) {
sum += leftHalf[j];
}
}
leftSums.add(sum);
}
// For each subset sum in right half, check if complement exists
for (let i = 0; i < (1 << rightHalf.length); i++) {
let sum = 0;
for (let j = 0; j < rightHalf.length; j++) {
if ((i & (1 << j)) !== 0) {
sum += rightHalf[j];
}
}
if (leftSums.has(target - sum)) {
return true;
}
}
return false;
}
O(2^n) to O(n * 2^(n/2)), which is significantly better for large inputs.