DSA Split Array Largest Sum
Splitting an array for the smallest possible largest part sum is a search over candidate answers. A greedy feasibility check tests each candidate limit in one pass over the array.
The problem
Split Array Largest Sum asks for a split of an array into at most k parts that makes the largest part sum as small as possible. The parts are contiguous and keep the original order. Every part is non-empty.
Making one part smaller forces another part to grow. The best split balances that trade-off. The cases below are the ones this article solves.
The cases used in this article
cases = [
([7, 2, 5, 10, 8], 1, 32),
([7, 2, 5, 10, 8], 2, 18),
([7, 2, 5, 10, 8], 3, 14),
([10, 2, 2, 4, 5, 2], 3, 10),
([4, 2, 4, 5, 1], 2, 10),
]
for nums, k, expected in cases:
print("nums", nums, "k:", k, "smallest largest sum:", expected)The first row allows one part, so the whole total of 32 is the answer. More parts can only push the largest sum down, never up.
Intuition: try the cuts for two parts
The first idea for two parts tries every single cut. Each cut gives two part sums, and the larger one is that cut's score. The best cut has the lowest score.
Every cut for two parts, scored
nums = [7, 2, 5, 10, 8]
best = None
for cut in range(1, len(nums)):
left = sum(nums[:cut])
right = sum(nums[cut:])
score = max(left, right)
print("cut after index", cut - 1, "-> parts", nums[:cut], "and", nums[cut:], "largest:", score)
if best is None or score < best:
best = score
print("best largest sum for k = 2:", best)Four cuts settle the two-part question. The trap is the growth. Three parts choose two cut positions, four parts choose three, and the combinations multiply fast.
Brute force: rate every split
The brute force tries every way to place the cuts. A recursive walk fixes one part at a time and rates each complete split by its largest part sum. A counter records the complete splits rated.
Enumerating all splits for k parts
def brute_split(nums, k):
tried = 0
def best_from(start, parts_left):
nonlocal tried
if parts_left == 1:
tried += 1
return sum(nums[start:])
best = None
running = 0
for end in range(start, len(nums) - parts_left + 1):
running += nums[end]
rest = best_from(end + 1, parts_left - 1)
candidate = max(running, rest)
if best is None or candidate < best:
best = candidate
return best
answer = best_from(0, k)
return answer, tried
for nums, k in [([7, 2, 5, 10, 8], 2), ([7, 2, 5, 10, 8], 3), ([1, 2, 3, 4, 5, 6], 3), ([1, 2, 3, 4, 5, 6, 7, 8, 9], 4)]:
answer, tried = brute_split(nums, k)
print("nums", nums, "k:", k, "answer:", answer, "splits rated:", tried)Nine values with four parts already rate 56 complete splits. Each split also costs a scan of its parts. The count grows combinatorially, and long arrays make it hopeless.
The optimal solution: binary search over candidate limits
A feasibility check asks whether a candidate limit allows at most k parts, and binary searching the smallest limit the check accepts gives the answer. That one sentence is the whole method.
The check is greedy. It fills each part as far as the limit permits, then starts a new part. Filling greedily never uses more parts than any other split under the same limit.
Feasibility is monotone. A larger limit never needs more parts, so the yes answers form one unbroken run above the true answer. A monotone question can be binary searched.
The search on every case
def parts_needed(nums, limit):
parts = 1
running = 0
for value in nums:
if running + value > limit:
parts += 1
running = value
else:
running += value
return parts
def split_array(nums, k):
lo = max(nums)
hi = sum(nums)
while lo < hi:
mid = (lo + hi) // 2
if parts_needed(nums, mid) <= k:
hi = mid
else:
lo = mid + 1
return lo
cases = [
([7, 2, 5, 10, 8], 1, 32),
([7, 2, 5, 10, 8], 2, 18),
([7, 2, 5, 10, 8], 3, 14),
([10, 2, 2, 4, 5, 2], 3, 10),
([4, 2, 4, 5, 1], 2, 10),
]
for nums, k, expected in cases:
result = split_array(nums, k)
print("nums", nums, "k:", k, "->", result, "expected:", expected, "match:", result == expected)All five cases pass. The search range starts at the largest single value and ends at the total. No limit outside that range can be the answer.
Walkthrough: the search on [7, 2, 5, 10, 8] with k = 2
The range opens at the largest value, 10, and the total, 32. Each probe runs the greedy check once and prints the part count. The search keeps the feasible half and discards the other.
Each probe and its verdict
def parts_needed(nums, limit):
parts = 1
running = 0
for value in nums:
if running + value > limit:
parts += 1
running = value
else:
running += value
return parts
def split_traced(nums, k):
lo = max(nums)
hi = sum(nums)
while lo < hi:
mid = (lo + hi) // 2
parts = parts_needed(nums, mid)
if parts <= k:
print("limit", mid, "needs", parts, "parts -> feasible, search lower")
hi = mid
else:
print("limit", mid, "needs", parts, "parts -> too many, search higher")
lo = mid + 1
return lo
print("answer:", split_traced([7, 2, 5, 10, 8], 2))The limit 21 fits two parts with room to spare, so the search looks lower. The limit 15 forces three parts, so the search looks higher. Four probes settle on 18, the split [7, 2, 5] and [10, 8].
Complexity
The brute force rates every combination of cut positions, and each rating scans the array, so its time grows combinatorially in n and k. The search runs one greedy pass per probe, and the probes halve a numeric range, so its time is O(n log S) for a total sum S.
Both methods store only counters and running sums besides the input, so their extra space is O(1) apart from the brute force's recursion depth. The counts below compare splits rated against greedy passes run.
Counting splits rated against greedy passes
from math import comb
def parts_needed(nums, limit):
parts = 1
running = 0
for value in nums:
if running + value > limit:
parts += 1
running = value
else:
running += value
return parts
def search_passes(nums, k):
lo = max(nums)
hi = sum(nums)
passes = 0
while lo < hi:
mid = (lo + hi) // 2
passes += 1
if parts_needed(nums, mid) <= k:
hi = mid
else:
lo = mid + 1
return passes
for n in [5, 10, 20]:
nums = list(range(1, n + 1))
k = n // 2
passes = search_passes(nums, k)
print("values:", n, "k:", k, "- splits the brute force rates:", comb(n - 1, k - 1), " greedy passes:", passes, " values visited:", passes * n)At 20 values the brute force rates 92,378 splits. The search runs 7 passes and visits 140 values. The trade is a clever question for an enumeration.
Edge cases and pitfalls
One part forces the whole total. As many parts as values forces the largest single value. Both extremes fall out of the search range endpoints with no special code.
Edge cases, run
def parts_needed(nums, limit):
parts = 1
running = 0
for value in nums:
if running + value > limit:
parts += 1
running = value
else:
running += value
return parts
def split_array(nums, k):
lo = max(nums)
hi = sum(nums)
while lo < hi:
mid = (lo + hi) // 2
if parts_needed(nums, mid) <= k:
hi = mid
else:
lo = mid + 1
return lo
def greedy_parts(nums, limit):
parts = []
current = []
running = 0
for value in nums:
if running + value > limit and current:
parts.append(current)
current = []
running = 0
current.append(value)
running += value
parts.append(current)
return parts
nums = [7, 2, 5, 10, 8]
print("k = 1:", split_array(nums, 1))
print("k = n:", split_array(nums, 5))
print("single value:", split_array([6], 1))
print("all equal, k = 2:", split_array([4, 4, 4, 4], 2))
print("greedy parts at limit 9:", greedy_parts(nums, 9))The last line shows the classic pitfall. A limit below the largest value lets the greedy walk place 10 alone in a part. That part sums to 10 and breaks the very limit being tested, yet the part count of four looks like an ordinary answer. Starting the search at the largest value rules the trap out.
Exercise
A limit of 20 needs two parts and a limit of 15 needs four parts. What does the search try next for k = 2?