Loading learning content...
The best way to internalize binary search on answer space is through deep practice with canonical problems. This page provides exhaustive coverage of the two most important examples in this category:
These problems appear constantly in technical interviews at top companies, competitive programming contests, and as building blocks for more complex optimization challenges. Master these, and you'll recognize their patterns everywhere.
By the end of this page, you will have: (1) complete, production-ready implementations of both classic problems, (2) step-by-step traces showing exactly how binary search finds the answer, (3) analysis of edge cases and how to handle them, and (4) common variations and extensions you might encounter.
Problem Statement (LeetCode 1011):
A conveyor belt has packages that must be shipped from one port to another within
Ddays.The
i-th package on the conveyor belt has a weight ofweights[i]. Each day, we load the ship with packages on the conveyor belt (in the given order). We may not load more weight than the maximum weight capacity of the ship.Return the least weight capacity of the ship that will result in all the packages on the conveyor belt being shipped within D days.
Key constraints:
weights = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], D = 515Ship capacity of 15:
Completed in exactly 5 days. Capacity 14 would require 6 days (try it!), so 15 is minimum.
weights = [3, 3, 3, 3, 3, 100, 3, 3, 3, 3], D = 5100The ship must have capacity ≥ 100 to carry the heavy package at all. With capacity 100: Days distribute around the heavy package. No smaller capacity is possible regardless of D.
weights = [1, 2, 3, 4, 5], D = 55With 5 days and 5 packages, ship one package per day. Minimum capacity = max(weights) = 5. This is always the lower bound.
This is a minimax problem: minimize the (maximum) ship capacity. The capacity determines the worst-case load per trip, and we want the smallest capacity that still meets the deadline.
Let's build the solution methodically, applying everything we've learned:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173
from typing import List def shipWithinDays(weights: List[int], days: int) -> int: """ LeetCode 1011: Capacity To Ship Packages Within D Days Find the minimum ship capacity to ship all packages within 'days' days. Packages must be shipped in order (contiguous groups). Approach: Binary search on answer space (minimax pattern) - Answer space: [max(weights), sum(weights)] - Feasibility: can we ship with given capacity in ≤ days? Time Complexity: O(n * log(sum - max)) where n = len(weights) Space Complexity: O(1) extra space Args: weights: List of package weights (must ship in this order) days: Maximum number of days allowed Returns: Minimum ship capacity needed """ def can_ship_in_days(capacity: int) -> bool: """ Feasibility predicate: Can we ship all packages in ≤ 'days' days with the given ship capacity? Strategy: Greedy loading - Each day, load packages consecutively until capacity is reached - Start a new day when adding next package would exceed capacity - Count days needed; return True if ≤ allowed days Time: O(n) - single pass through packages """ days_needed = 1 # Start with day 1 current_load = 0 # Weight loaded today for weight in weights: # Can we fit this package in today's ship? if current_load + weight <= capacity: current_load += weight else: # Start a new day days_needed += 1 current_load = weight # Early termination: already exceeded allowed days if days_needed > days: return False return days_needed <= days # ========================================== # STEP 1: Define the search space bounds # ========================================== # Lower bound: Must fit the heaviest single package # If max(weights) = 10, ship capacity must be at least 10 lo = max(weights) # Upper bound: Ship everything in one day # If sum(weights) = 55, capacity 55 definitely works (worst case) hi = sum(weights) # ========================================== # STEP 2: Binary search for minimum capacity # ========================================== # Minimax pattern: find smallest feasible capacity answer = hi # Default to upper bound (always feasible) while lo <= hi: mid = lo + (hi - lo) // 2 # Avoid integer overflow if can_ship_in_days(mid): # This capacity works! Record and try smaller answer = mid hi = mid - 1 # SEARCH LEFT: looking for minimum else: # Too small; need larger capacity lo = mid + 1 return answer # ==========================================# TEST CASES AND VERIFICATION# ========================================== def test_ship_within_days(): """Comprehensive test suite""" # Example 1: Standard case assert shipWithinDays([1,2,3,4,5,6,7,8,9,10], 5) == 15 # Example 2: Heavy single package assert shipWithinDays([3,3,3,3,3,100,3,3,3,3], 5) == 100 # Example 3: One package per day assert shipWithinDays([1,2,3,4,5], 5) == 5 # Edge case: Single package assert shipWithinDays([10], 1) == 10 # Edge case: All same weight assert shipWithinDays([5,5,5,5,5], 3) == 10 # [5,5], [5,5], [5] # Edge case: One day (ship everything) assert shipWithinDays([1,2,3,4,5], 1) == 15 # Edge case: Many days (one package each) assert shipWithinDays([1,2,3,4,5], 10) == 5 # Just need max(weights) print("All tests passed! ✓") # Run teststest_ship_within_days() # ==========================================# DETAILED TRACE# ==========================================def trace_ship_within_days(weights, days): """Trace the binary search execution step by step""" def can_ship(capacity): days_needed = 1 current_load = 0 daily_loads = [] for weight in weights: if current_load + weight <= capacity: current_load += weight else: daily_loads.append(current_load) days_needed += 1 current_load = weight daily_loads.append(current_load) # Last day's load return days_needed <= days, days_needed, daily_loads lo, hi = max(weights), sum(weights) print(f"Tracing: weights={weights}, days={days}") print(f"Search space: [{lo}, {hi}]") print("-" * 60) iteration = 0 while lo <= hi: mid = (lo + hi) // 2 feasible, days_used, loads = can_ship(mid) iteration += 1 status = "✓ FEASIBLE" if feasible else "✗ INFEASIBLE" print(f"Iter {iteration}: capacity={mid}, days_used={days_used}, {status}") print(f" Daily loads: {loads}") if feasible: print(f" → Try smaller: hi = {mid - 1}") hi = mid - 1 else: print(f" → Need larger: lo = {mid + 1}") lo = mid + 1 print() print(f"Answer: {hi + 1}") return hi + 1 # Trace exampletrace_ship_within_days([1,2,3,4,5,6,7,8,9,10], 5)Trace output for [1,2,3,4,5,6,7,8,9,10] with D=5:
Search space: [10, 55]
Iter 1: capacity=32, days_used=2, ✓ FEASIBLE
Daily loads: [32, 23]
→ Try smaller: hi = 31
Iter 2: capacity=20, days_used=4, ✓ FEASIBLE
Daily loads: [15, 18, 15, 7]
→ Try smaller: hi = 19
Iter 3: capacity=14, days_used=6, ✗ INFEASIBLE
Daily loads: [10, 14, 14, 8, 9, 10]
→ Need larger: lo = 15
Iter 4: capacity=17, days_used=4, ✓ FEASIBLE
Daily loads: [15, 17, 15, 8]
→ Try smaller: hi = 16
Iter 5: capacity=15, days_used=5, ✓ FEASIBLE
Daily loads: [15, 13, 8, 9, 10]
→ Try smaller: hi = 14
lo > hi, Answer: 15
Notice how binary search efficiently narrows down from [10, 55] to find exactly 15 in just 5 iterations. Brute force would check 46 values; binary search checks log₂(46) ≈ 6. This efficiency becomes dramatic for larger ranges.
Problem Statement (LeetCode 410):
Given an integer array
numsand an integerk, splitnumsintoknon-empty subarrays such that the largest sum of any subarray is minimized.Return the minimized largest sum of the split.
A subarray is a contiguous part of the array.
Key constraints:
nums = [7, 2, 5, 10, 8], k = 218Possible splits:
The split [7,2,5] | [10,8] gives maximum sum 18, which is minimal.
nums = [1, 2, 3, 4, 5], k = 55Each element is its own subarray: [1], [2], [3], [4], [5]. Maximum sum = max(1, 2, 3, 4, 5) = 5.
nums = [1, 2, 3, 4, 5], k = 115Only one subarray possible: the entire array. Sum = 1 + 2 + 3 + 4 + 5 = 15.
Split Array is essentially the same problem structure as Capacity to Ship! Replace 'packages' with 'elements', 'ship capacity' with 'max subarray sum', and 'days' with 'k subarrays'. The solution approach is identical.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175
from typing import List def splitArray(nums: List[int], k: int) -> int: """ LeetCode 410: Split Array Largest Sum Find the minimum possible value of the largest sum after splitting nums into k contiguous subarrays. Approach: Binary search on answer space (minimax pattern) - Answer space: [max(nums), sum(nums)] - Feasibility: can we split into ≤ k subarrays with each sum ≤ limit? Time Complexity: O(n * log(sum - max)) where n = len(nums) Space Complexity: O(1) extra space Args: nums: Array of integers to split k: Number of subarrays to create Returns: Minimum possible maximum subarray sum """ def can_split_with_max_sum(max_sum: int) -> bool: """ Feasibility predicate: Can we split into ≤ k subarrays where each subarray has sum ≤ max_sum? Strategy: Greedy partitioning - Extend current subarray while sum ≤ max_sum - Start new subarray when adding more would exceed max_sum - Count subarrays; return True if ≤ k Why "at most k" instead of "exactly k"? - If we can split into fewer subarrays, we can always split further to get exactly k (splitting a subarray only decreases the maximum, which is still ≤ max_sum) Time: O(n) - single pass through elements """ subarrays_needed = 1 # Start with first subarray current_sum = 0 # Current subarray sum for num in nums: # Can we extend current subarray? if current_sum + num <= max_sum: current_sum += num else: # Start a new subarray subarrays_needed += 1 current_sum = num # Early termination if subarrays_needed > k: return False return subarrays_needed <= k # ========================================== # STEP 1: Define the search space bounds # ========================================== # Lower bound: At minimum, each subarray must hold max element lo = max(nums) # Upper bound: Everything in one subarray hi = sum(nums) # ========================================== # STEP 2: Binary search for minimum max_sum # ========================================== answer = hi while lo <= hi: mid = lo + (hi - lo) // 2 if can_split_with_max_sum(mid): # This max_sum works! Try smaller answer = mid hi = mid - 1 else: # Too restrictive; allow larger sums lo = mid + 1 return answer # ==========================================# ALTERNATIVE: Dynamic Programming Solution# ========================================== def splitArrayDP(nums: List[int], k: int) -> int: """ Alternative DP solution for comparison. dp[i][j] = minimum largest sum when splitting nums[0:i] into j subarrays Time: O(n² * k) - much slower than binary search! Space: O(n * k) Note: This is shown for educational comparison. Binary search approach is strictly better for this problem. """ n = len(nums) # Precompute prefix sums for O(1) range sum queries prefix = [0] * (n + 1) for i in range(n): prefix[i + 1] = prefix[i] + nums[i] def range_sum(i, j): """Sum of nums[i:j+1]""" return prefix[j + 1] - prefix[i] # dp[i][j] = min largest sum for nums[0:i] split into j parts INF = float('inf') dp = [[INF] * (k + 1) for _ in range(n + 1)] dp[0][0] = 0 # Empty array, 0 parts for i in range(1, n + 1): for j in range(1, min(i, k) + 1): # Try all possible positions for last split for m in range(j - 1, i): # nums[0:m] split into j-1 parts, nums[m:i] is the last part last_part_sum = range_sum(m, i - 1) dp[i][j] = min(dp[i][j], max(dp[m][j-1], last_part_sum)) return dp[n][k] # ==========================================# TEST CASES# ========================================== def test_split_array(): """Comprehensive test suite with both solutions""" test_cases = [ ([7, 2, 5, 10, 8], 2, 18), ([1, 2, 3, 4, 5], 2, 9), ([1, 4, 4], 3, 4), ([1, 2, 3, 4, 5], 5, 5), ([1, 2, 3, 4, 5], 1, 15), ([10], 1, 10), ([2, 3, 1, 2, 4, 3], 5, 4), ] for nums, k, expected in test_cases: result_bs = splitArray(nums, k) result_dp = splitArrayDP(nums, k) assert result_bs == expected, f"BS failed: {nums}, k={k}, got {result_bs}, expected {expected}" assert result_dp == expected, f"DP failed: {nums}, k={k}, got {result_dp}, expected {expected}" print("All tests passed! ✓") # Complexity comparison import time large_input = list(range(1, 1001)) # [1, 2, ..., 1000] start = time.time() splitArray(large_input, 10) bs_time = time.time() - start start = time.time() # DP would be too slow: O(1000² * 10) = 10 million operations # splitArrayDP(large_input, 10) # Uncomment to see slowness print(f"Binary search time for n=1000, k=10: {bs_time:.4f}s") print("Binary search is dramatically faster than DP!") test_split_array()Why Binary Search Beats DP:
| Approach | Time Complexity | For n=1000, k=10 |
|---|---|---|
| Binary Search | O(n log(sum)) | ~10,000 operations |
| Dynamic Programming | O(n² × k) | ~10,000,000 operations |
Binary search is 1000x faster for this input size. For larger inputs, the gap grows even more. This is why recognizing the binary search pattern is so valuable.
While DP is a valid approach and tests your understanding of subproblem decomposition, binary search is the optimal solution. In interviews, mention that DP is possible but binary search is more efficient. This shows depth of understanding.
The greedy feasibility checker is deceptively simple. Let's prove why it's correct and explore its nuances:
The Greedy Strategy:
for each item:
if item fits in current group:
add to current group
else:
start new group with this item
Why is greedy optimal for counting minimum groups?
Claim: Greedy produces the minimum number of groups for a given limit.
Proof by contradiction:
Therefore, greedy cannot produce more groups than optimal.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364
def greedy_with_proof(items: list[int], limit: int): """ Greedy grouping with explanation of why it's optimal. Key insight: Greedy never 'wastes' capacity by starting a group too early. It always packs as much as possible into each group. """ groups = [] current_group = [] current_sum = 0 for item in items: if current_sum + item <= limit: # Item fits: extend current group current_group.append(item) current_sum += item else: # Item doesn't fit: finalize current group, start new if current_group: # Save non-empty group groups.append((current_group.copy(), current_sum)) current_group = [item] current_sum = item # Don't forget the last group if current_group: groups.append((current_group.copy(), current_sum)) return groups def demonstrate_greedy_optimality(): """Show greedy is optimal through examples""" items = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] limit = 15 groups = greedy_with_proof(items, limit) print(f"Items: {items}") print(f"Limit: {limit}") print(f"Greedy produced {len(groups)} groups:") for i, (group, total) in enumerate(groups, 1): slack = limit - total print(f" Group {i}: {group} → sum={total}, slack={slack}") print() print("Why greedy is optimal:") print("- Each group is packed as full as possible") print("- Moving any item to previous group would exceed limit") print("- Therefore, no way to use fewer groups") demonstrate_greedy_optimality() # Output:# Items: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]# Limit: 15# Greedy produced 5 groups:# Group 1: [1, 2, 3, 4, 5] → sum=15, slack=0# Group 2: [6, 7] → sum=13, slack=2# Group 3: [8] → sum=8, slack=7# Group 4: [9] → sum=9, slack=6# Group 5: [10] → sum=10, slack=5Important subtlety: "At most k" vs "Exactly k"
Our feasibility check returns True if we can split into at most k groups. But the problem asks for exactly k groups. Why is this okay?
Answer: If we can split into j < k groups with max sum M, we can always split further to get exactly k groups:
So if 'at most k groups' is achievable with limit M, so is 'exactly k groups'. The feasibility check is correct as written.
Greedy works for contiguous partitioning with a limit constraint. It does NOT work for: (1) non-contiguous selection, (2) minimizing total cost across groups, (3) problems where group order matters beyond just feasibility. Always verify greedy is appropriate for your specific problem.
Let's systematically address edge cases that cause bugs in interviews:
| Edge Case | Example | Correct Behavior |
|---|---|---|
| Single element | [42], k=1 | Answer = 42 |
| All same elements | [5,5,5,5], k=2 | Answer = 10 (greedy: [5,5], [5,5]) |
| k = length | [1,2,3], k=3 | Answer = max = 3 |
| k = 1 | [1,2,3,4,5], k=1 | Answer = sum = 15 |
| One huge element | [1,1,100,1,1], k=3 | Answer ≥ 100 (must include 100) |
| Elements equal limit | [10,10,10], limit=10 | Each element is own group |
| Large numbers | [10^9, 10^9, 10^9], k=2 | Watch for integer overflow |
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374
def comprehensive_edge_case_tests(): """Test all edge cases to ensure robustness""" test_cases = [ # (nums, k, expected_answer, description) ([42], 1, 42, "Single element"), ([5, 5, 5, 5], 2, 10, "All same - even split"), ([5, 5, 5, 5, 5], 2, 15, "All same - uneven split"), ([1, 2, 3], 3, 3, "k equals length"), ([1, 2, 3, 4, 5], 1, 15, "k equals 1"), ([1, 1, 100, 1, 1], 3, 100, "One huge element"), ([1, 1, 1, 1, 100], 2, 100, "Huge element at end"), ([100, 1, 1, 1, 1], 2, 100, "Huge element at start"), ([1], 1, 1, "Minimal input"), ([1, 1, 1, 1, 1], 5, 1, "All ones, each separate"), ([10, 10, 10], 3, 10, "Elements equal minimum non-trivial limit"), ] for nums, k, expected, description in test_cases: result = splitArray(nums, k) status = "✓" if result == expected else "✗" print(f"{status} {description}: splitArray({nums}, {k}) = {result} (expected {expected})") assert result == expected, f"Failed: {description}" print("\nAll edge cases passed!") # Common bugs to avoid:def buggy_feasibility_v1(nums, limit): """BUG: Forgetting to count the last group""" groups = 0 # Should start at 1 current = 0 for num in nums: if current + num <= limit: current += num else: groups += 1 current = num return groups # BUG: missed adding 1 for final group! def buggy_feasibility_v2(nums, limit): """BUG: Off-by-one in comparison""" groups = 1 current = 0 for num in nums: if current + num < limit: # BUG: should be <= current += num else: groups += 1 current = num return groups def correct_feasibility(nums, limit): """CORRECT implementation""" groups = 1 # Start with 1 current = 0 for num in nums: if current + num <= limit: # Correct: <= current += num else: groups += 1 current = num return groups # No +1 needed because we started at 1 # Demonstrate the bugsnums = [1, 2, 3, 4, 5]limit = 9 print(f"Buggy v1 (forgot last group): {buggy_feasibility_v1(nums, limit)}") # Wrong!print(f"Buggy v2 (< instead of <=): {buggy_feasibility_v2(nums, limit)}") # Wrong! print(f"Correct: {correct_feasibility(nums, limit)}") # Correct!< or <=? Think carefully: if current + num equals the limit exactly, does it fit? (Yes, so use <=).These classic problems have many variations. Recognizing them helps you apply the same pattern to new situations:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384
def minEatingSpeed(piles: list[int], h: int) -> int: """ LeetCode 875: Koko Eating Bananas Koko loves bananas. There are n piles. She can eat at speed k bananas/hour. Each hour, she chooses a pile and eats k bananas (or finishes the pile). What's the minimum k to finish all bananas in h hours? This is essentially "Capacity to Ship" with: - "capacity" = eating speed k - "days" = hours h - "packages" = piles Key difference: A pile takes ceil(pile_size / k) hours to eat, even if pile_size < k (she doesn't move between piles within an hour). """ import math def can_finish(speed: int) -> bool: """Can Koko finish all piles in h hours eating at given speed?""" hours_needed = 0 for pile in piles: hours_needed += math.ceil(pile / speed) if hours_needed > h: return False return hours_needed <= h # Bounds lo = 1 # Minimum possible speed hi = max(piles) # At max(piles), each pile takes 1 hour # Binary search (minimax) answer = hi while lo <= hi: mid = (lo + hi) // 2 if can_finish(mid): answer = mid hi = mid - 1 else: lo = mid + 1 return answer # The pattern is the same! Only the feasibility check differs.print(minEatingSpeed([3, 6, 7, 11], 8)) # Output: 4print(minEatingSpeed([30, 11, 23, 4, 20], 5)) # Output: 30 def cuttingRibbons(ribbons: list[int], k: int) -> int: """ LeetCode 1891: Cutting Ribbons Cut ribbons to get k pieces of equal length. Maximize that length. A ribbon can be cut into multiple pieces or not used at all. This is a MAXIMIN problem: maximize the minimum piece length. """ def can_get_k_pieces(length: int) -> bool: """Can we cut ribbons to get at least k pieces of given length?""" pieces = 0 for ribbon in ribbons: pieces += ribbon // length # How many pieces from this ribbon return pieces >= k # Bounds lo = 1 hi = max(ribbons) # Can't get piece longer than longest ribbon # Binary search (MAXIMIN - search right when feasible) answer = 0 # Default: might not be possible while lo <= hi: mid = (lo + hi) // 2 if can_get_k_pieces(mid): answer = mid lo = mid + 1 # Maximin: try larger else: hi = mid - 1 return answer print(cuttingRibbons([9, 7, 5], 3)) # Output: 5 (cut 9→5, keep 7→5, keep 5→5)print(cuttingRibbons([7, 5, 9], 4)) # Output: 4All these problems share the same structure: (1) bounded answer space, (2) monotonic feasibility, (3) greedy or O(n) feasibility check. Once you see this, you'll recognize binary search on answer in disguised problems instantly.
When you encounter these problems in interviews, structure your response clearly:
What interviewers are looking for:
Follow-up questions to prepare for:
Pattern recognition + clear explanation + clean code + complexity analysis = strong interview performance. Practice these problems until the pattern becomes second nature.
We've completed an exhaustive study of the two most important binary search on answer problems. Let's consolidate:
Module Complete!
You've now mastered binary search on answer space—one of the most powerful and frequently tested algorithmic patterns. You understand:
This knowledge will serve you well in technical interviews, competitive programming, and real-world optimization problems. The pattern appears everywhere once you know to look for it.
Congratulations! You've completed the Binary Search on Answer Space module. You can now recognize, formulate, and solve this important class of optimization problems. Practice with the variations mentioned, and this pattern will become second nature.