Maximum Subarray
1. Problem statement
Aziz et al. (2018, p. 250) mentions this problem but the explanations are so terse as to render them useless.
Given an array of integers, find the maximum subarray (Bentley, 2000, p. 77).
Input: [-2, 1, -3, 4, -1, 2, 1, -5, 4] ^ ^ | | | `------. | | `-----------. | | | v v Output: 6 (subarray from index 3 to 6)
2. Insights?
While simple to state, this problem can be approached in many ways. See the various solutions below.
3. Solution
We use a common type SubSum
(to mean "subarray sum") across our different
implementations to capture the answer.
3.1. Brute force (cubic)
Time complexity is \(O(n^3)\) (aka cubic algorithm), because we have 2 for
loops, both that go over the entire range of input, which by themselves make it
\(O(n^2)\). But then in the inner for
loop we find the sum (sum(xs[i:j+1])
) by
looping over the range, itself a O(n)
algorithm.
3.2. Brute force (quadratic)
Instead of computing subsum
from scratch on every inner loop iteration, we can
just build it up within the outer for
loop.
Time complexity is \(O(n^2)\), because we use a nested for
loop over all
elements.
3.3. Brute force (quadratic, alternative)
Here we compute cumulative sums, such that csums[i] == sum(xs[0:i])
.
xs = [1, 2, 3] # csums == [1, 3, 6] csums = [ sum([1]), sum([1, 2]), sum([1, 2, 3]) ]
Then if we want to compute sum(xs[i:j])
, we can just use the csums
values.
Going with the example above, if we want to compute the sum of xs[1, 2]
(where
i == 0
and j == 2
), we can do csum[j] - csum[i - 1]
or csum[2] -
csum[0]
.
This algorithm is still quadratic, because we still use a nested for
loop for
the main computation.
3.4. Divide-and-conquer (\(O(n \log{n})\))
Bentley (2000, p. 80) says that this approach is actually rather complicated, and that there's no harm in skipping it in favor of the optimal algorithm (Kadane's algorithm). We include it here for completeness, because although it's not the fastest algorithm, it does a good job of illustrating the principles of "divide-and-conquer".
Figure 1: Division of array into subarrays a
and b
.
The maximum subarrays of a
and b
are each individually somewhere within a
and b
.
Figure 2: Maximum subarrays \(M_a\) and \(M_b\) found in a
and b
, respectively.
You may think that we can choose the greater of \(M_a\) and \(M_b\) and be done with
it. Alas, there is a flaw in this thinking: there could be a subarray that uses
parts of both a
and b
, whose maximum is greater than either \(M_a\) or \(M_b\).
Figure 3: Maximum subarray \(M_c\) which crosses over into a
and b
.
In summary, there are two possibilities:
- \(M_a\) or \(M_b\) is the maximum subarray by itself, or
- a separate subarray, \(M_c\), which is composed of parts of subarrays
a
andb
, is the maximum subarray.
Below are two examples of both cases:
Max subarray is M_a = 6 (larger than M_b = 3). |-------a------| |--------b---------| [-1, 1, 2, 3, -1, -2, -1, 1, 1, 1, -1] |-M_a-| |-M_b-| Max subarray is M_c = 7 (larger than M_a = 6). |-------a------| |-------b------| [-1, 1, 2, 3, -1, -1, 1, 1, 1, -1] |-M_a-| |-M_b-| |---------M_c----------| Max subarray is M_c = 15, but this time its boundaries don't line up with the boundaries of M_a = 11 or M_b = 5. |----------a---------| |--------b---------| [-1, 1, 0, 7, -9, 7, 1, 7, -10, 3, 1, 1, -1] |-M_a-| |-M_c-| |-M_b-|
Note that \(M_c\) may include \(M_a\) or \(M_b\), but whether this happens depends on the input.
Notice that in order to compute M_c
, we still have to do (essentially) a for
loop through the elements in the current array, but with the condition that it
must include the middle element at index mid
. We do this by computing the
M_c_left_sum
and M_c_right_sum
bits with two separate for
loops.
The time complexity is \(O(n \log{n})\). This is because we do division into
halves repeatedly (this gives us the \(O(\log(n))\)), but for each of those levels
we do a linear computation of M_c
, which takes \(O(n)\) time.
The for
loops needed to compute M_c
is a bit annoying — it seems odd that,
even though we are able to divide up the problem into 1-element subarrays (as
the base case of the recursion), we still need to do some sort of traversal,
essentially looking over the same elements we've looked at previously when we
computed M_a
and M_b
. As the next section shows, we can indeed avoid looping
to compute M_c
.
3.5. Divide-and-conquer (linear)
The key aspect of this variation (Daescu & Malik, 2018) of
divide-and-conquer is that instead of only returning the maximum subarray sum
from the recursive calls, we return additional information. Then we use this
additional information to compute M_c
without having to look at the array
elements directly (again).
The additional pieces are the sum of all elements total_sum
, the maximum
subarray max_L
which must include the leftmost element, and the maxmum
subarray max_R
which must include the rightmost element. The total_sum
just
helps us compute max_L
and max_R
.
We need max_L
and max_R
because we need those bits to compute M_c
. These
bits are basically M_c_left_sum
and M_c_right_sum
, respectively, from
dac()
in the previous section.
Figure 4: max_R
(yellow) and max_L
(blue) of subarrays a
and b
, respectively.
For computing max_L
of the current array, there are two possibilities. Either
we can use max_L
from the left half directly, or we must include the entire
left half plus the max_L
from the right half. And this is why we need the
total_sum
value — we need it to get the total sum of the left half.
Figure 5: Computing max_L
of the current array has two possibilities.
By symmetry, the same thing could be said of computing max_R
. The code below
is a bit long, but it's mainly because we also keep track of all the indices
involved.
3.6. Optimal (Kadane's algorithm, linear)
The divide and conquer solution is admittedly a bit long-winded. Kadane's algorithm is much simpler, although it may not appear obvious at first sight.
Kadane's algorithm gives each element in the subarray two options:
- join the current subarray under consideration (growing it), or
- start a new subarray to take over as the "current" subarray, starting with this element.
The "current subarray" starts off with the first element in the array, and then every time we hit the second case, our current subarray changes — we stop looking at the current subarray and just start looking at a new subarray.
Now let's consider the possibilities for deciding whether the current element falls into the first case (grow the current subarray sum) or the second case (start a new subarray).
x is positive |
x is zero |
x is negative |
|
---|---|---|---|
subarray_sum is positive |
grow subarray | grow subarray | grow subarray |
subarray_sum is zero |
new subarray | new subarray | new subarray |
subarray_sum is negative |
new subarray | new subarray | new subarary |
The key to understanding this table is to focus on the subarray_sum
and the
boolean check subarray_sum + x > x
. If the subarray_sum
is positive, then we
always fall into the first case, no matter what the value of x
is, because
\(n + x > x\) for all positive \(n\) (where \(n\) is the subarray_sum
). If
subarray_sum
is zero, then the boolean check is always false because
subarray_sum + x > x
reduces to x > x
, which is clearly false. Lastly, if
subarray_sum
is negative, then similarly the boolean check is always fales,
because (rearranging the terms) \(x + n\) is always smaller than just \(x\) if \(n\)
is a negative number.
The result, then, is that if the subarray_sum
reaches 0 or becomes negative,
we will use the very next iteration of the loop to start tracking a new
subarray, starting at that iteration's x
as the initial value (the else
block in the code above). This makes sense because if a subarray is zero or
negative, it is not going to help make any other subarray larger. We're
done looking at that subarray, and it's safe to stop considering it.
If the subarray_sum
is positive, then we always want to grow the subarray.
This is obvious for positive and negative elements under consideration in the
loop, but it is also true for negative elements! This may seem unintuitive, but
it makes sense. If the negative element is small, then our subarray_sum
is
still positive anyway, and any additional positive elements (in subsequent
iterations) will help us grow our (existing) subarray_sum
. In a way, we still
want to "hold on" to our subarray_sum
"investment" to see if it can go up some
more. If the negative element is very large, enough to bring our subarray_sum
down to 0 or make it go negative, then in the next iteration we'll start a new
subarray_sum
anyway according to the chart in Table 1. So in this case the
negative element is a good indicator for telling us to start a new
subarray_sum
.
In summary, Kadane's algorithm helps us demarcate the beginnings and endings of all relevant subarrays. Then finding the maximum subarray is just a matter of remembering the "best subarray sum" seen so far.
Kadane's algorithm is a good example of dynamic programming, where we build up the solution from a smaller version of the problem to solve the bigger problem. It basically boils down the problem to checking whether we want to keep the current number to grow into the current subarray, or treat it as the start of a new subarray. As we now know which subarrays to look at, it's easy to calculate their sums and to record the maximum among them.
3.7. Variant using a running sum
Aziz et al. (2018, p. 251) propose using cumulative sums to do the check. The version shown here uses the same idea, but also tracks beginning and ending indices.
Instead of only tracking the maximum subarray, this version instead tracks the minimum. The idea is that whenever we hit a new low, we'll mark off the next element as the start of a new subarray which we need to measure. Tracking the current subarray sum is just a matter of taking the difference between the current running sum, and the previous minimum sum we've observed (because again, the minimum is the starting point of a new subarray).
The downside is that this version cannot handle negative numbers. That is, it
will return 0
as the maximum sum even for the input [-1]
. To get around this
deficiency, when we encounter an array with all negative numbers or zeroes, we
do a separate search for the highest single integer found, and return that
instead.
This also has linear running time, but is a bit more complicated because we have to build up cumulative sums, which is unnecessary in Kadane's algorithm.
4. Tests
4.1. Basic tests
4.2. Property-based tests
5. Export
from __future__ import annotations import itertools from typing import List, NamedTuple, Optional boilerplate solution