Davide Spataro

Maximum Subarray

medium · Dynamic Programming · 26 min read
array dp divide-and-conquer kadane prefix-sum linear-time

Introduction

A sequence of integers — some positive, some negative — and a single objective: find the contiguous run with the largest sum. The puzzle is famous, asked in countless on-sites, and the obvious solutions are quadratic. The interesting ones are not, and the gap between “obvious” and “optimal” is the entire reason this problem keeps appearing.

The lesson is bigger than the algorithm. Most interview optimizations make a slow approach faster. The optimization here works the other way: you don’t speed up the brute force, you ask a different question whose answer happens to be linear. That move — re-stating the question so the answer falls out — is the heart of dynamic programming, and recognizing when it applies is what this chapter trains.

In dynamic programming, the optimization isn’t faster code — it’s a sharper question.

If you’re early in dynamic-programming prep, read the chapter linearly — each approach builds on the previous. If you can already write the optimal answer in your sleep, jump to the correctness section under Approach 03 and the follow-ups table; both contain something even seasoned candidates miss.

What the interviewer is actually testing

  1. Naming the obvious solution and not stopping there. A brute-force answer is correct but uninteresting. The signal is whether you spot that there’s a better way without being prompted.
  2. Reasoning about subproblem structure. The breakthrough comes from re-framing the question in terms of “best subarray ending here” rather than “best subarray overall.” That re-framing is the DP move; it shows up in countless other problems.
  3. Constant-space discipline. The linear-time answer fits in O(1)O(1) extra memory once you notice that yesterday’s subproblem solution is the only one tomorrow needs. Spotting that collapse is a senior signal.
  4. Edge cases and variations. All-negative inputs, the minimum-sum variant, the longest-positive-run variant — each is a one-line tweak. Knowing them shows you understand the move, not just memorized the code.

Problem statement

Figure · what the problem looks likeThe canonical input. Positive bars rise above the axis, negative bars dip below. The orange-tinted run from index 3 to 6 is the contiguous subarray with the largest sum: 4 + -1 + 2 + 1 = 6.
-2011-3243-142516-5748
Problem

Given an array AA of nn integers (positive, negative, or zero), find the contiguous subarray with at least one element whose sum is the largest possible. Return that sum.

Formally: find indices 0ij<n0 \le i \le j < n that maximize x=ijA[x]\sum_{x=i}^{j} A[x].

Example 1
Input A = [-2, 1, -3, 4, -1, 2, 1, -5, 4]
Output 6
The subarray A[3..6] = [4, -1, 2, 1] sums to 6. No other contiguous run reaches that total.
Example 2
Input A = [-3, -1, -4, -2]
Output -1
All elements are negative. The best 'subarray' is the largest single element on its own (here -1). The constraint that the subarray contain at least one element forces a non-trivial answer even when every sum is negative.
Example 3
Input A = [1, 2, 3]
Output 6
All positive. The optimal subarray is the whole array — every additional element only grows the sum.

Clarifying questions — and why each one matters

Clarification questions

Q Can the array contain negative numbers?
A Yes — the whole problem becomes trivial otherwise. If everything is non-negative, the answer is just the sum of the entire array; if everything is non-positive, the answer is the largest single element. Confirm the spec.
Q Can the subarray be empty?
A Standard formulation: no — the subarray must contain at least one element. If the spec allows an empty subarray, the answer for an all-negative input becomes 0, which changes a single line of code. Always ask.
Q Do we return the sum, or the indices, or the subarray itself?
A Default is just the sum. If the interviewer asks for the indices, track the start and end of the running subarray plus the start and end of the best-so-far. No asymptotic change — just two extra variables.
Q Are duplicates allowed?
A Yes, with no special handling. Duplicates don't affect the algorithm; the running sum simply incorporates them like any other value.
Q What's the upper bound on n, and on the range of A[i]?
A Typical: n ≤ 10⁵, |A[i]| ≤ 10⁴. That's well within an O(n) budget. If n is much larger, the linear sweep still wins; only the brute force struggles. If values are large, watch for overflow — use 64-bit accumulators if asked.
Q Do we need to handle a circular array (wrap-around)?
A That's a separate problem (LC 918). The wrap-around subarray case requires a second pass with a sign-flipped array — see follow-ups.

A mental framework — re-frame the question

The brute-force question is “what’s the largest sum across all contiguous subarrays?”. There are O(n2)O(n^2) subarrays, so any approach that enumerates them is at least quadratic.

The DP move is to re-frame: “for each ending position jj, what’s the largest sum of any subarray that ends exactly at jj?”. There are only nn ending positions, and — crucially — the answer for ending position jj has a cheap relationship to the answer for ending position j1j-1:

best ending at j  =  max(A[j],  A[j]+best ending at j1)\text{best ending at } j \;=\; \max\bigl(A[j],\; A[j] + \text{best ending at } j-1\bigr)

Either we extend the previous run by adding A[j]A[j], or the previous run wasn’t worth keeping and we start a new one at A[j]A[j]. The decision at each cell is binary, and the global answer is just the maximum over all the per-position values. That’s the entire algorithm.

This re-framing — replacing a “best across all XX” question with a “best ending at each XX” recurrence — is one of the most-reused moves in dynamic programming. It works whenever extending a partial solution is a constant-time operation given the previous value.

Discussion

Approach 01

Brute force — every subarray, prefix-sum accelerated

Enumerate every contiguous subarray, sum each via prefix sums, track the maximum. Quadratic, correct, your starting line.

Time O(n²) Space O(n)

The interview narration

Say this out loud:

“The straightforward solution enumerates every pair (i,j)(i, j) with iji \le j and sums A[i..j]A[i..j]. There are O(n2)O(n^2) such pairs, and a prefix-sum array makes each sum O(1)O(1) — so this runs in O(n2)O(n^2) time, O(n)O(n) space. I’ll write it as a baseline and then look for something faster.”

How it works, step by step

A naive triple loop computes each subarray sum from scratch in O(n)O(n) — that gives O(n3)O(n^3) overall, which is wasteful since the same prefixes are recomputed over and over. Instead:

  1. Build a prefix-sum array prefix where prefix[i] = A[0] + A[1] + ... + A[i]. One linear pass; O(n)O(n) time and space.
  2. Enumerate every starting index i and every ending index j ≥ i. For each pair, compute sum = prefix[j] − (i > 0 ? prefix[i - 1] : 0). That’s the sum of A[i..j] in O(1)O(1).
  3. Track the maximum sum seen across all pairs.

The loop visits exactly (n2)+n=O(n2)\binom{n}{2} + n = O(n^2) pairs, doing O(1)O(1) arithmetic at each. Total: O(n2)O(n^2) time, O(n)O(n) extra space.

The code

brute-force.cpp
#include <climits>
#include <vector>
int maxSubArray(const std::vector<int>& A) {
const int n = static_cast<int>(A.size());
if (n == 0) return 0;
std::vector<int> prefix(n);
prefix[0] = A[0];
for (int i = 1; i < n; ++i) {
prefix[i] = prefix[i - 1] + A[i];
}
int best = INT_MIN;
for (int i = 0; i < n; ++i) {
for (int j = i; j < n; ++j) {
const int sum = prefix[j] - (i > 0 ? prefix[i - 1] : 0);
if (sum > best) best = sum;
}
}
return best;
}

Prefix sums make each subarray sum constant-time, so the double loop is honest O(n²). The starting `INT_MIN` ensures the first comparison wins for any input, including all-negative arrays.

Why it’s rejected

For n=105n = 10^5, this is 101010^{10} operations — about a minute of wall-clock time. Production code would never ship this, and no interviewer accepts it as the final answer. But it establishes the problem shape and serves as a reference implementation to verify the optimized versions against.

What it gets right

  • Correct on every sign mix. Initializing best to INT_MIN and comparing actual sums (never absolute values) means all-negative, all-positive, and mixed inputs all return the right answer with no special-casing.
  • The prefix-sum technique itself is reusable. It’s the right move for any range-sum-related problem; coding it once cleanly pays off in Subarray Sum Equals K, Range Sum Query, and the rest of the prefix-sum family.
Approach 02

Divide and conquer — the cross-boundary trick

Recursively find the best subarray in each half, then handle the case that spans the midpoint with a linear scan. T(n) = 2T(n/2) + O(n), so O(n log n).

Time O(n log n) Space O(log n) divide-and-conquerrecursion

The insight

Split the array at the midpoint. The best contiguous subarray of the whole array must be one of three things:

  1. Entirely within the left half — a recursive subproblem.
  2. Entirely within the right half — a recursive subproblem.
  3. Crossing the midpoint — the only case that actually needs new work.

The cross-boundary case is the one that makes this approach interesting. To compute it, scan from the midpoint leftward, accumulating the running sum and recording the maximum (this gives the best subarray ending exactly at the midpoint). Then scan from mid + 1 rightward with the same trick (best subarray starting at mid + 1). Sum the two. Linear time per level.

Figure · three candidates after a splitDivide the array at the midpoint (vertical line). The best subarray of the whole array is one of three: entirely in the left half (recursively), entirely in the right half (recursively), or crossing the midpoint. The cross-boundary case is computed in linear time; the other two recurse.
split-2011-3243-142516-5748left = 4right = 3cross = 6 ✓

The recurrence is the classic “balanced split with linear combine” pattern: T(n)=2T(n/2)+O(n)=O(nlogn)T(n) = 2T(n/2) + O(n) = O(n \log n).

How it works, step by step

For a subrange [lo, hi]:

  1. Base case lo == hi: return A[lo] itself (a single element is its own best subarray).
  2. Pick the midpoint mid = lo + (hi - lo) / 2. The (hi - lo) / 2 form avoids overflow when lo + hi would exceed INT_MAX for huge ranges.
  3. Recurse left: leftBest = solve(lo, mid).
  4. Recurse right: rightBest = solve(mid + 1, hi).
  5. Cross-boundary scan:
    • Walk from mid down to lo, accumulating the running sum, tracking its maximum. Call that leftSum.
    • Walk from mid + 1 up to hi, accumulating, tracking the maximum. Call that rightSum.
    • The best cross-boundary subarray sums to leftSum + rightSum.
  6. Combine: return max(leftBest, rightBest, crossBest).

The leftSum and rightSum scans must each include the midpoint cell on their side (so they capture subarrays that genuinely span the boundary). Initializing them to INT_MIN and walking inward solves both edge cases (a single negative cell on one side) without special-casing.

The code

divide-and-conquer.cpp
#include <algorithm>
#include <climits>
#include <vector>
namespace {
int crossSum(const std::vector<int>& A, int lo, int mid, int hi) {
int leftBest = INT_MIN;
int sum = 0;
for (int i = mid; i >= lo; --i) {
sum += A[i];
if (sum > leftBest) leftBest = sum;
}
int rightBest = INT_MIN;
sum = 0;
for (int i = mid + 1; i <= hi; ++i) {
sum += A[i];
if (sum > rightBest) rightBest = sum;
}
return leftBest + rightBest;
}
int solve(const std::vector<int>& A, int lo, int hi) {
if (lo == hi) return A[lo];
const int mid = lo + (hi - lo) / 2;
const int leftBest = solve(A, lo, mid);
const int rightBest = solve(A, mid + 1, hi);
const int crossBest = crossSum(A, lo, mid, hi);
return std::max({leftBest, rightBest, crossBest});
}
} // namespace
int maxSubArray(const std::vector<int>& A) {
if (A.empty()) return 0;
return solve(A, 0, static_cast<int>(A.size()) - 1);
}

Two recursive calls plus a linear cross-boundary scan. The std::max({a, b, c}) initializer-list overload handles the three-way comparison cleanly.

When divide-and-conquer beats Kadane

Almost never, for this specific problem. Kadane’s O(n)O(n) wins on every input, in both time and space. The reason to know divide-and-conquer is that the cross-boundary trick generalizes: the same template solves the closest-pair-of-points problem, the maximum-product subarray problem, and several others where Kadane’s “extend or restart” framing doesn’t apply directly.

Complexity

  • Time: O(nlogn)O(n \log n). The recurrence T(n)=2T(n/2)+O(n)T(n) = 2T(n/2) + O(n) resolves to this by the master theorem.
  • Space: O(logn)O(\log n) for the recursion stack. No auxiliary data structures.
Approach 03

Kadane's algorithm — one variable, one decision per cell Recommended

Walk the array once. At each cell, decide whether to extend the running subarray or restart from the current cell. Track the maximum encountered.

Time O(n) Space O(1) dplinear-timeconstant-spacerecommended

The insight, stated cleanly

Instead of asking “what’s the best subarray of the whole array?”, ask the question per-position: “what’s the best subarray that ends exactly at index jj?”. There are only nn such positions, and the answer for one has a one-line relationship to the answer for the previous:

  • The best subarray ending at jj is either just A[j]A[j] (start fresh) or A[j]A[j] added to the best subarray ending at j1j-1 (extend the previous run).
  • We pick whichever of the two is larger.

The global answer is the maximum across all positions. Walk the array once, update a running endingHere, update a running best. Two integers, one pass.

Live · Kadane's sweepOne pass left to right. At each cell the algorithm decides whether to extend the running subarray or to restart from the current cell. The cyan run is the subarray currently ending at this index; the orange run is the best subarray seen so far.
Input:
-2011-3243-142516-5748
Step 0 of 9endingHere = -2best = -2init

Initial: endingHere = best = A[0] = -2.

Three transitions worth naming

The widget above runs the full sweep. Three moments are particularly instructive on the canonical input [-2, 1, -3, 4, -1, 2, 1, -5, 4]. Pause on each — together they show every behavior the algorithm exhibits.

The first restart. At step 3 the running sum is 2-2 (it’s been struggling through the negative early values). When A[3]=4A[3] = 4 arrives, the algorithm faces a choice: extend 2+4=2-2 + 4 = 2, or restart at 44 alone. Restart wins. The window collapses to a single cell; from here on, the running run starts fresh.

-21-34-121-54
step 3 (i=3)endingHere=4, best=4Restart: A[3] = 4 alone is better than (-2) + 4 = 2. The running window resets to start at index 3.

The peak. By step 6 the algorithm has extended through [4, -1, 2, 1] and endingHere reaches 6 — the largest value seen so far. best updates accordingly. This is the moment the answer for the whole input is determined; everything after this either ties or fails to beat it.

-21-34-121-54
step 6 (i=6)endingHere=6, best=6best = 6, achieved on [4, -1, 2, 1]. Every subarray ending at index 6 with this run beats every previous one.

Extend vs. restart, the close call. At step 7 a 5-5 arrives. Extend would give 6+(5)=16 + (-5) = 1; restart would give 5-5 alone. Extend still wins, but only barely. The running sum drops sharply but stays positive — the algorithm is ready to capitalize if a positive value follows. (It does, at step 8, but not enough to dethrone the existing best.)

-21-34-121-54
step 7 (i=7)endingHere=1, best=6Extend: 6 + (-5) = 1 beats restarting at -5. The running sum survives the dip but doesn't break the record.

The code

kadane.cpp
#include <algorithm>
#include <vector>
int maxSubArray(const std::vector<int>& A) {
if (A.empty()) return 0;
int best = A[0];
int endingHere = A[0];
for (std::size_t i = 1; i < A.size(); ++i) {
endingHere = std::max(A[i], endingHere + A[i]);
best = std::max(best, endingHere);
}
return best;
}

Two variables, one pass. The std::max calls are the entire decision logic; everything else is bookkeeping.

A few details worth naming aloud:

  • endingHere = std::max(A[i], endingHere + A[i]) is the recurrence in one line. The choice between restart and extend hides inside std::max.
  • best = std::max(best, endingHere) must run after endingHere is updated — otherwise the current cell’s contribution is missed.
  • Initialization to A[0] (not 0) is what makes all-negative inputs work. If we initialized best to 0, an all-negative input would return 0 — which is wrong for the standard formulation requiring a non-empty subarray.

Correctness, precisely

Invariant. After processing index ii:

  • endingHere equals the largest sum of any contiguous subarray that ends at index ii.
  • best equals the largest sum of any contiguous subarray that ends at any index in [0,i][0, i].

Proof sketch. Induction on ii.

Base: i=0i = 0. The only subarray ending at index 0 is {A[0]}\{A[0]\} itself; its sum is A[0]A[0]. Both endingHere and best are initialized to A[0]A[0]. ✓

Inductive step: Assume the invariant holds at index i1i - 1. At index ii, any subarray ending at ii either starts at ii (sum = A[i]A[i]) or starts before ii (sum = A[i]+A[i] + some subarray ending at i1i - 1). The latter is maximized when we extend the best subarray ending at i1i - 1 — which is endingHere. So max(A[i],A[i]+endingHere)\max(A[i], A[i] + \text{endingHere}) is the new endingHere. The new best is max(old best,new endingHere)\max(\text{old best}, \text{new endingHere}), which equals the largest sum across subarrays ending at any index in [0,i][0, i]. ✓

Therefore best at the end of the loop is the largest sum of any contiguous subarray of AA.

Edge cases

  • All positive: every step extends; endingHere grows monotonically; best equals the full sum at the end.
  • All negative: every step restarts; endingHere equals A[i] at each step; best tracks the largest single element. ✓
  • All zero: endingHere and best both stay at 0 throughout. ✓
  • Single element: the loop body never runs; best = A[0]. ✓
  • Mixed with one massive positive surrounded by negatives: the positive dominates; best records it on its single cell.

Complexity

  • Time: O(n)O(n). One pass, two std::max calls per cell.
  • Space: O(1)O(1). Two integers, no auxiliary storage.

Approach comparison

ApproachTimeSpaceWhen to reach for it
Brute force (prefix-sum)O(n2)O(n^2)O(n)O(n)Baseline in narration; never in production.
Divide-and-conquerO(nlogn)O(n \log n)O(logn)O(\log n)When the interviewer wants D&C; teaches the cross-boundary trick.
Kadane’s algorithmO(n)O(n)O(1)O(1)Default. What you ship.

Default answer: Kadane. State the recurrence — “best ending here is either A[i] alone or A[i] plus best ending at i−1” — and the algorithm writes itself.

Follow-ups to anticipate

The interviewer asks…What to do
”Return the indices of the optimal subarray, not just the sum.”Track two more variables: windowLo (start of the current running subarray) and bestLo/bestHi. Update windowLo to i on a restart, and on every best update, copy windowLo and i into bestLo/bestHi. Same O(n)O(n).
”Find the minimum sum subarray instead.”Flip every max to min and initialize accordingly. The mechanics don’t change.
”Find the longest contiguous subarray where every element is positive.”Different question, same template. length = A[i] > 0 ? length + 1 : 0; track the maximum length seen.
”Circular array — the subarray can wrap around the end.”LC 918. Two passes: standard Kadane gives the best non-wrapping answer; then run Kadane on -A[i] to find the minimum subarray, and total - min is the best wrapping answer. Take the max of the two (with a guard for all-negative inputs).
”Maximum product subarray.”LC 152. Track both maxEnding and minEnding at every step. A negative number multiplied by the current minEnding can produce the next maxEnding — the worst running product becomes the best when a negative arrives. The Kadane recurrence becomes a swap-and-update of two values.
”Subarray sum equals K.”LC 560. Different problem — uses a hash map of prefix sums. Worth naming as the “prefix-sum + hash” cousin.
”What if numbers are streaming and we want a running answer?”Kadane is already streaming-friendly. Each new value triggers one update; the running best is the current answer. No reset needed.
”Optimal subarray length must be at least k.”Sliding-window adaptation. Maintain a window of size at least k via a min-prefix-sum of prefix[i - k].

Pattern recognition — DP on running state

The move this problem teaches — re-frame a “best across all XX” question as a recurrence over per-position best values — is a recurring shape in dynamic programming:

  • House Robber. “Best loot ending at house ii, given you can’t rob adjacent houses.” One-line recurrence: f(i)=max(f(i1),f(i2)+nums[i])f(i) = \max(f(i-1), f(i-2) + \text{nums}[i]).
  • Best Time to Buy/Sell Stock. Track the running minimum price; the best profit ending at day ii is prices[i] − runningMin. Same shape.
  • Longest Increasing Subsequence. “Length of the longest LIS ending at ii, considering all earlier positions jj with A[j]<A[i]A[j] < A[i].” Same per-position re-framing; the inner scan over earlier positions makes it O(n2)O(n^2) (or O(nlogn)O(n \log n) with a binary-searched tails array).
  • Maximum Sum Increasing Subsequence. Hybrid of LIS and this problem; the recurrence considers all valid predecessors.
  • Edit Distance. “Best alignment ending at the suffix pair (i,j)(i, j).” Two-dimensional but the same per-position re-framing.

The reflex: when a problem asks for the best across O(nk)O(n^k) structures, ask whether you can re-frame it as the best ending at each of nn positions. If yes, the algorithm is at most O(n)O(n) per position — and the resulting algorithm is almost always exactly O(n)O(n) overall.

What to say out loud — the delivery script

Ten beats:

  1. “Let me restate: given an array of integers, find the contiguous subarray with the largest sum.”
  2. Ask: “can A contain negatives? is the subarray required to be non-empty? sum or indices?”
  3. “Brute force enumerates every (i,j)(i, j) pair and sums each subarray — O(n3)O(n^3) naïvely, O(n2)O(n^2) with prefix sums. Let me name that and look for something better.”
  4. “There’s a divide-and-conquer angle that gets us to O(nlogn)O(n \log n) — but the linear-time answer is what most interviewers expect.”
  5. “Re-frame the question: instead of ‘best subarray of the whole array,’ ask ‘best subarray ending at each index.’ That’s nn subproblems, each with a one-line recurrence.”
  6. “At index ii: either start fresh at A[i]A[i], or extend the best subarray ending at i1i - 1. Pick whichever is larger.”
  7. Write the code — five lines plus the loop. endingHere = max(A[i], endingHere + A[i]). best = max(best, endingHere). Initialize both to A[0]A[0] — that’s what makes all-negative inputs work.”
  8. Trace one or two key transitions. Pick a cell where extending wins narrowly, or where restarting reverses the trend.
  9. “Complexity: O(n)O(n) time, O(1)O(1) space. The recurrence only ever needs the previous cell’s value, so the auxiliary array collapses to a single variable.”
  10. “Variations: minimum subarray flips both max calls to min. Circular subarray needs two passes. Returning indices needs two extra trackers. The core recurrence is the same.”

Summary

The largest-sum subarray problem is the rite of passage for dynamic programming. The interview signal isn’t producing the linear-time answer — it’s articulating the re-framing that makes it possible, and recognizing that the per-position recurrence collapses to a single variable.

The wider lesson is the shape of the optimization. Many interview problems ask for the best across an exponential or quadratic space of structures; many of those problems collapse to a linear-time recurrence the moment you ask the question per position rather than across all structures. House Robber, Best Time to Buy/Sell Stock, the longest-positive-run variant, the minimum-sum variant — they all yield to the same move. Internalize it here; the rest of the DP family becomes substantially less intimidating.