DSA Sum of Subarray Minimums
The sum of subarray minimums adds the smallest value of every contiguous subarray. Each element is counted as the minimum of one family of subarrays, and the family size comes from its boundaries.
The problem
A subarray is a contiguous run of elements. The sum of subarray minimums adds the smallest value of every subarray of a list. A list of n values holds n times n plus 1, divided by 2, subarrays.
Every subarray contributes exactly one minimum. The cases below are the ones this article solves.
The cases used in this article
cases = [
([3, 1, 2, 4], 17),
([2, 2], 6),
([2, 2, 1], 9),
([5, 4, 3, 2, 1], 35),
([1], 1),
]
for nums, expected in cases:
print("nums", nums, "sum of minimums:", expected)The second case is the trap in miniature. Its three subarrays are [2], [2] and [2, 2], and the shared pair must be counted once, not twice.
Intuition: list every subarray and take its smallest
The first idea writes out every subarray and adds its minimum. On a four-value list the full list is short enough to print. Every row below is one term of the sum.
Every subarray of [3, 1, 2, 4] with its minimum
nums = [3, 1, 2, 4]
total = 0
for i in range(len(nums)):
for j in range(i, len(nums)):
sub = nums[i:j + 1]
total += min(sub)
print(sub, "minimum:", min(sub))
print("total:", total)Ten rows give 17. The idea is sound and hopeless at scale. The row count grows with the square of the length, and each row costs a scan of its own.
Brute force: extend and track the running minimum
The brute force keeps the enumeration but drops the repeated scanning. Each start position extends one step at a time and carries the smallest value seen on the way. A counter records the subarrays rated.
The enumeration with a running minimum
def brute_mins(nums):
total = 0
subs = 0
for i in range(len(nums)):
low = nums[i]
for j in range(i, len(nums)):
low = min(low, nums[j])
total += low
subs += 1
return total, subs
for nums in [[3, 1, 2, 4], [2, 2], [2, 2, 1], [5, 4, 3, 2, 1], [1]]:
total, subs = brute_mins(nums)
print("nums", nums, "total:", total, "subarrays rated:", subs)The totals agree with the cases. The rated count is the full subarray count every time. Nothing about a clever minimum tracker changes how many subarrays exist.
The optimal solution: count each element's family
Each element is the minimum of one family of subarrays, and the family size is its left choices times its right choices, where the boundaries are the nearest smaller value on the left and the nearest smaller-or-equal value on the right. That one sentence is the whole method.
A left choice is a start position the subarray may use without crossing the left boundary. A right choice is an end position with the same freedom. The element contributes its value times the family size.
The strictness sits on one side only. The left scan stops at a strictly smaller value, and the right scan stops at a smaller-or-equal value. Equal values then divide their shared subarrays between them instead of double counting. A monotonic stack, a stack whose values keep one order, finds both boundaries for all elements in one pass each.
The stack solution on every case
def sum_mins(nums):
n = len(nums)
left = [0] * n
stack = []
for i in range(n):
while stack and nums[stack[-1]] >= nums[i]:
stack.pop()
previous = stack[-1] if stack else -1
left[i] = i - previous
stack.append(i)
right = [0] * n
stack = []
for i in range(n - 1, -1, -1):
while stack and nums[stack[-1]] > nums[i]:
stack.pop()
following = stack[-1] if stack else n
right[i] = following - i
stack.append(i)
total = 0
for i in range(n):
total += nums[i] * left[i] * right[i]
return total
cases = [
([3, 1, 2, 4], 17),
([2, 2], 6),
([2, 2, 1], 9),
([5, 4, 3, 2, 1], 35),
([1], 1),
]
for nums, expected in cases:
result = sum_mins(nums)
print("nums", nums, "->", result, "expected:", expected, "match:", result == expected)All five cases pass. The duplicate case gives 6, so the shared subarray [2, 2] is counted exactly once across the two families.
Walkthrough: the boundary table for two lists
The table prints one row per element. The left and right columns are the choice counts from the two boundary scans. The contribution column multiplies value, left and right.
Boundaries and contributions, printed
def table(nums):
n = len(nums)
total = 0
print("value | left | right | contribution")
for i in range(n):
left = 1
j = i - 1
while j >= 0 and nums[j] >= nums[i]:
left += 1
j -= 1
right = 1
j = i + 1
while j < n and nums[j] > nums[i]:
right += 1
j += 1
part = nums[i] * left * right
total += part
print(nums[i], "|", left, "|", right, "|", part)
print("total:", total)
print("first list [3, 1, 2, 4]")
table([3, 1, 2, 4])
print("second list [2, 2, 1]")
table([2, 2, 1])The value 1 in the first list leads six subarrays, so it contributes 6 of the 17. In the second list the two equal values take one and two left choices. Their families stay disjoint, and the total still matches the brute force.
Complexity
The brute force rates every subarray, so its time is O(n^2) and its extra space is O(1). The stack passes push and pop each index at most once per pass, so the optimal time is O(n) and its extra space is O(n).
The counts below run a counted stack pass on falling lists, the worst shape for boundary scans. The subarray column is the brute force's rating count at the same sizes.
Counting subarrays against stack operations
def counted_mins(nums):
n = len(nums)
operations = 0
left = [0] * n
stack = []
for i in range(n):
while stack and nums[stack[-1]] >= nums[i]:
stack.pop()
operations += 1
previous = stack[-1] if stack else -1
left[i] = i - previous
stack.append(i)
operations += 1
right = [0] * n
stack = []
for i in range(n - 1, -1, -1):
while stack and nums[stack[-1]] > nums[i]:
stack.pop()
operations += 1
following = stack[-1] if stack else n
right[i] = following - i
stack.append(i)
operations += 1
return operations
for n in [5, 50, 500]:
nums = list(range(n, 0, -1))
subarrays = n * (n + 1) // 2
print("values:", n, "- subarrays rated:", subarrays, " stack operations:", counted_mins(nums))At 500 values the brute force rates 125,250 subarrays. The stack spends at most four operations per value across both passes. The family idea trades a square for a line.
Edge cases and pitfalls
An empty list has no subarrays and sums to zero. A single value sums to itself. Negative values need no special handling, because boundaries compare values, not sizes.
Edge cases, run
def sum_mins(nums):
n = len(nums)
left = [0] * n
stack = []
for i in range(n):
while stack and nums[stack[-1]] >= nums[i]:
stack.pop()
previous = stack[-1] if stack else -1
left[i] = i - previous
stack.append(i)
right = [0] * n
stack = []
for i in range(n - 1, -1, -1):
while stack and nums[stack[-1]] > nums[i]:
stack.pop()
following = stack[-1] if stack else n
right[i] = following - i
stack.append(i)
total = 0
for i in range(n):
total += nums[i] * left[i] * right[i]
return total
print("empty list:", sum_mins([]))
print("single value:", sum_mins([7]))
print("all equal:", sum_mins([2, 2, 2]))
print("negatives:", sum_mins([-1, -2]))The pitfall is strictness on both sides. When both scans stop at equal values, a shared subarray belongs to no family and vanishes from the sum.
The pitfall: both sides strict
def sum_mins_both_strict(nums):
total = 0
n = len(nums)
for i in range(n):
left = 1
j = i - 1
while j >= 0 and nums[j] > nums[i]:
left += 1
j -= 1
right = 1
j = i + 1
while j < n and nums[j] > nums[i]:
right += 1
j += 1
total += nums[i] * left * right
return total
print("both sides strict on [2, 2]:", sum_mins_both_strict([2, 2]))
print("correct total on [2, 2]: 6")Exercise
In [2, 2], why do the two equal values get different left counts?