2

I need to create an algorithm that takes an array A as an argument and finds a subarray of A whose sum of elements multiplied by the smallest element in that subarray yields the greatest value. Values in A are positive and we cannot change the order in that array.

I was told that its possible to do in O(nlogn). I was thinking about some sort of Divide and Conquer algorithm to obey brute force approach.

Any Ideas?

greybeard
  • 2,249
  • 8
  • 30
  • 66
  • Sorry, I've edited my question. The greatest value should be sum of elements from subsequence of array multiplied by the smallest element from that subsequence. – Piotrek Rzepka Jun 24 '23 at 16:53

3 Answers3

5

We can have divide and conquer in O(n log n) by evaluating:

f(whole_section) =
  max(
    f'(whole_section),
    f(left_half),
    f(right_half)
  )

Where f' evaluates the maximum value for a section that includes at least one element fron the left side and one from the right (or at least the single middle element in case of an odd-numbered whole section).

To understand how f' can be calculated in O(n) consider the following example. Say g below is the smaller of g and h.

abcdefghijklmn
      ^^

Expand the interval in both directions until the next element smaller than g is encountered.

abcdefghijklmn
    ^    ^

Say j is now the next smallest (e is smaller than j so we end the current interval at f on the left).

Our sum and multiplier can be updated in O(1) each time we move a pointer. Calculate for g * sum and expand the interval again, this time with j as the smallest. Note that multiple j values can be passed by the pointers in either direction.

abcdefghijklmn
    ^      ^

This time l is the next smaller after j and e is even smaller. Calculate for j * sum and keep going.

Since each element during the interval expansion is visited at most once, f' can be evaluated in O(n) and since we halve the number of elements with each recursive call to f, f can be evaluated in O(n log n).

גלעד ברקן
  • 23,602
  • 3
  • 25
  • 61
  • 1
    Might be a little simpler to *always* consider the single "middle" element (left of middle if even length). Either it's part of the subarray (expand like you describe) or it isn't (recursively solve the subarray *before* that element and the subarray *after* it). – Kelly Bundy Jun 24 '23 at 20:52
  • @KellyBundy not sure I follow, could you please explain? The procedure I propose enforces both left and right elements must be present in `f'`. – גלעד ברקן Jun 24 '23 at 21:14
  • It's almost the same, I just suspect the implementation is slightly simpler when starting with only *one* element. See my answer now. – Kelly Bundy Jun 24 '23 at 21:54
4

You can do this in O(n) time. Here's a couple of different ways:

Algorithm 1

Observe that the best array with A[i] as the smallest element always includes all the contiguous greater or equal elements on its left and right.

Make an array L[i] and do a forward pass over A[i] while maintaining a stack of every element seen so far that is less than all the elements seen on its right. You can maintain this stack while going through A in linear time, and the top element always lets you fill in L[i] with the index of the first element in A[i]'s subarray. (search "monotonic queue" for a lot of information about this trick)

Similarly you can do a linear time backward pass that lets you fill in R[i] with the index of the last element in every A[i]'s subarray.

Then you can precompute the prefix sums of A, which allow to get the sum of any subarray in constant time.

Finally, multiply every A[i] by the sum from A[L[i]] to A[R[i]] and remember the best result.

Algorithm 2

The second algorithm also depends on the fact that the best array with A[i] as the smallest element always includes all the greater or equal elements on its left and right.

Because of this fact, if you visit the elements of A[i] in decreasing order, then you can always use A[i] as the smallest element, and join it to the contiguous already visited subarrays on its immediate left and right. If we keep track of the sums of these contiguous subarrays, then we can do this traversal in linear time and remember the best result.

Of course, it seems that you have to sort A first, which we cannot do in linear time... but strictly decreasing order isn't the only order that works. If you do a forward pass on A, while maintaining a stack of every element seen so far that is less than all the elements seen on its right (that monotonic queue again), then we can visit the elements of A in the order that they are removed from this stack, and that will still work.

Using that monotonic queue order avoids the original sort and lets us do the whole thing in linear time.

Here's a python implementation of algorithm 2 that returns the best product:

def solve(A):
    best = 0
    indexes=[]
    # lsums[i] = sum from indexes[i]-1 to indexes[i-1]+1
    lsums=[]
    for i in range(len(A)):
        rsum=0
        while len(indexes)>0 and A[i]<=A[indexes[-1]]:
            small = A[indexes.pop()]
            rsum += lsums.pop() + small
            val = rsum*small 
            best = max(val,best)
        indexes.append(i)
        lsums.append(rsum)
    # visit all the items left on the stack
    rsum=0
    while len(indexes)>0:
        small = A[indexes.pop()]
        rsum += lsums.pop() + small
        val = rsum*small 
        best = max(val,best)
    return best
Matt Timmermans
  • 53,709
  • 3
  • 46
  • 87
3

A Python implementation of גלעד ברקן's algorithm, except I split by a middle element and expand a little differently (one by one include the larger of the next value on the left and the next value on the right).

The subarray either includes that middle element (so I expand outwards from there) or it doesn't (so it's on the left or the right of that element, and I solve both those cases recursively).

The function returns the subarray value and start/stop indices.

def f(A):
    n = len(A)
    if n == 0:
        return 0, None, None
    mid = n // 2
    sum_ = min_ = A[mid]
    maxval = sum_ * min_, mid, mid+1
    i = mid - 1
    j = mid + 1
    while i >= 0 or j < n:
        if i < 0 or j < n and A[j] > A[i]:
            a = A[j]
            j += 1
        else:
            a = A[i]
            i -= 1
        sum_ += a
        min_ = min(min_, a)
        val = sum_ * min_, i+1, j
        maxval = max(maxval, val)
    return max(maxval, f(A[:mid]), f(A[mid+1:]))

Code for testing with ten arrays of 200 elements:

import random

def naive(A):
    return max(
        sum(subarray) * min(subarray)
        for stop in range(len(A) + 1)
        for start in range(stop)
        for subarray in [A[start:stop]]
    )

n = 200
for _ in range(10):
    A = random.choices(range(1, 1000), k=n)
    expect = naive(A)
    result, start, stop = f(A)
    print(result == expect, expect, result, stop - start)

Sample output showing correctness, expected value, value computed with f and the subarray length (Attempt This Online!):

True 3218670 3218670 6
True 2726960 2726960 5
True 4929120 4929120 101
True 3806745 3806745 33
True 3946340 3946340 6
True 3092978 3092978 4
True 3768700 3768700 31
True 2784148 2784148 6
True 4260596 4260596 27
True 3176926 3176926 5
Kelly Bundy
  • 23,480
  • 7
  • 29
  • 65