Split array of integers into subarrays with the biggest sum of difference between min and max

Question:

I’m trying to find the algorithm efficiently solving this problem:

Given an unsorted array of numbers, you need to divide it into several subarrays of length from a to b, so that the sum of differences between the minimum and maximum numbers in each of the subarrays is the greatest. The order of the numbers must be preserved.

Examples:

a = 3, b = 7
input: [5, 8, 4, 5, 1, 3, 5, 1, 3, 1]
answer: [[5, 8, 4], [5, 1, 3], [5, 1, 3, 1]] (diff sum is 12)

a = 3, b = 4
input: [1, 6, 2, 2, 5, 2, 8, 1, 5, 6]
answer: [[1, 6, 2], [2, 5, 2, 8], [1, 5, 6]] (diff sum is 16)

a = 4, b = 5
input: [5, 8, 4, 5, 1, 3, 5, 1, 3, 1, 2]
answer: splitting is impossible

The only solution I’ve come up with so far is trying all of the possible subarray combinations.

from collections import deque

def partition_array(numbers, min_len, max_len):
  max_diff_subarray = None

  queue = deque()

  for end in range(min_len - 1, max_len):
    if end < len(numbers):
      diff = max(numbers[0:end + 1]) - min(numbers[0:end + 1])
      queue.append(Subarray(previous=None, start=0, end=end, diff_sum=diff))

  while queue:
    subarray = queue.popleft()

    if subarray.end == len(numbers) - 1:
      if max_diff_subarray is None:
        max_diff_subarray = subarray
      elif max_diff_subarray.diff_sum < subarray.diff_sum:
        max_diff_subarray = subarray
      continue

    start = subarray.end + 1

    for end in range(start + min_len - 1, start + max_len):
      if end < len(numbers):
        diff = max(numbers[start:end + 1]) - min(numbers[start:end + 1])
        queue.append(Subarray(previous=subarray, start=start, end=end, diff_sum=subarray.diff_sum + diff))
      else:
        break

  return max_diff_subarray

class Subarray:
  def __init__(self, previous=None, start=0, end=0, diff_sum=0):
    self.previous = previous
    self.start = start
    self.end = end
    self.diff_sum = diff_sum

numbers = [5, 8, 4, 5, 1, 3, 5, 1, 3, 1]
a = 3
b = 7
result = partition_array(numbers, a, b)
print(result.diff_sum)

Are there any more time efficient solutions?

Asked By: Maxim

||

Answers:

First let’s solve a simpler problem. Let’s run through an array, and give mins and maxes for all windows of fixed size.

def window_mins_maxes (size, array):
    min_values = deque()
    min_positions = deque()
    max_values = deque()
    max_positions = deque()

    for i, value in enumerate(array):
        if size <= i:
            yield (i, min_values[0], max_values[0])
            if min_positions[0] <= i - size:
                min_values.popleft()
                min_positions.popleft()

            if max_positions[0] <= i - size:
                max_values.popleft()
                max_positions.popleft()

        while 0 < len(min_values) and value <= min_values[-1]:
            min_values.pop()
            min_positions.pop()
        min_values.append(value)
        min_positions.append(i)

        while 0 < len(max_values) and max_values[-1] <= value:
            max_values.pop()
            max_positions.pop()
        max_values.append(value)
        max_positions.append(i)

    yield (len(array), min_values[0], max_values[0])

This clearly takes memory O(size). What’s less obvious is that it takes time O(n) to process an array of length n. But we can see that with amortized analysis. To each element we’ll attribute the cost of checking the possible value that is smaller than it, the cost of some later element checking that it should be removed, and the cost of being added. That accounts for all operations (though this isn’t the order that they happen) and is a fixed amount of work per element.

Also note that the memory needed for this part of the solution fits within O(n).

So far I’d consider this a well-known dynamic programming problem. Now let’s make it more challenging.

We will tackle the partition problem as a traditional dynamic programming problem. We’ll build up an array best_weight of the best partition to that point, and prev_index of the start of the previous partition ending just before that point.

To build it up, we’ll use the above algorithm to take a previous partition and add one of min_len to it. If it is better than the previous, we’ll save its information in those arrays. We’ll then scan forward from that partition and do that up to max_len. Then we move on to the next possible start of a partition.

When we’re done we’ll find the answer from that code.

Here is what that looks like:

def partition_array(numbers, min_len, max_len):
    if max_len < min_len or len(numbers) < min_len:
        return (None, None)

    best_weight = [None for _ in numbers]
    prev_index = [None for _ in numbers]

    # Need an extra entry for off of the end of the array.
    best_weight.append(None)
    prev_index.append(None)

    best_weight[0] = 0

    for i, min_value, max_value in window_mins_maxes(min_len, numbers):
        window_start_weight = best_weight[i - min_len]
        if window_start_weight is not None:
            j = i
            while j - i < max_len - min_len and j < len(numbers):
                new_weight = window_start_weight + max_value - min_value
                if best_weight[j] is None or best_weight[j] < new_weight:
                    best_weight[j] = new_weight
                    prev_index[j] = i - min_len

                if numbers[j] < min_value:
                    min_value = numbers[j]
                if max_value < numbers[j]:
                    max_value = numbers[j]
                j += 1

            # And fill in the longest value.
            new_weight = window_start_weight + max_value - min_value
            if best_weight[j] is None or best_weight[j] < new_weight:
                best_weight[j] = new_weight
                prev_index[j] = i - min_len

    if best_weight[-1] is None:
        return (None, None)
    else:
        path = [len(numbers)]
        while prev_index[path[-1]] is not None:
            path.append(prev_index[path[-1]])
        path = list(reversed(path))
        partitioned = [numbers[path[i]:path[i+1]] for i in range(len(path)-1)]
        return (best_weight[-1], partitioned)

Note that we do O(1) work for each possible start and length. And so that is time O((max_len + 1 - min_len)*n). And the data structures we used are all bounded above by O(n) in size. Giving the overall efficiency that I promised in the comments.

Now let’s test it.

print(partition_array([5, 8, 4, 5, 1, 3, 5, 1, 3, 1], 3, 7))
print(partition_array([1, 6, 2, 2, 5, 2, 8, 1, 5, 6], 3, 4))
print(partition_array([5, 8, 4, 5, 1, 3, 5, 1, 3, 1, 2], 4, 5))

And the output is:

(12, [[5, 8, 4], [5, 1, 3], [5, 1, 3, 1]])
(16, [[1, 6, 2], [2, 5, 2, 8], [1, 5, 6]])
(None, None)
Answered By: btilly

Are there any more time efficient solutions?

Yes, there are.

The answer by btilly demonstrates that the algorithm presented in the code of the question which requires with array size exponential increasing time can be beaten by the dynamic programming approach with almost linear time dependency.

It is possible to do better than that? Is there still room for improvement?

Let’s start the journey with constructing a following test case where three different partitioning options deliver the same optimal result and the size of the list of integers allows reliable measurements of required computation time:

from time import perf_counter as T
from random import randint, seed 
seed(2); arr=[]
for _ in range(100000): arr.append(randint(1,10000000))
sT=T(); result=partition_array(arr, 49999, 50001); eT=T()
print(eT-sT)
print("btl", result[0], result[1] , end='')

and run it using the code provided by btilly.

On my machine I am getting as output:

0.10844088008161634
btl 19998737 [0, 49999, 100000]

The btl ( shortcut for btilly ) code needs to arrive at a solution on my Linux Mint 21.2 Xfce Thinkpad Notebook running Python 3.10.12 about 0.1 seconds and uses the window_mins_maxes() function for the most work and therefore optimized it using a deque data type from the collections module. The code outputs the best sum and one of the possible multiple solutions for the partitioning of the list of integers.

Below another version of the window_mins_maxes() I named winMinMaxLargeSize() to distinguish it from much simpler winMinMax() code which works very fast on small integer lists, but gets slow on large ones:

def winMinMax(size, array):
    for i  in range(size, len(array)+1):
        yield i, min(array[ i-size : i ]), max(array[ i -size : i])

def winMinMaxLargeSize(size, L):
    """ Let's optimize min/max calculation by re-using already got values what starts to 
    make sense probably only at larger sizes (for example greater than 7).  
    This function moves a "Window" with width equal 'size' over the list 'L' calculating 
    the min/max values and remembering their list "L' indices. The indices are used then
    to skip recalculating of min/max values in case the value "leaving" the "Window" has
    no impact on the min/max values because in the range between min and max. The
    variable names storing these indices are 'indxMin' and 'indxMax' """

    # Lets calculate the min/max values for the first "Window" at the beginning of 'L' to 
    # have a reference start values for "moving" of the "Window" over the list  'L'  : 
    prevMin, prevMax = min(L[0 : size ]), max(L[0: size])
    lenL=len(L)
    indxCounterMin=0; indxCounterMax=0
    for i in range(size)[::-1] :
        if indxCounterMin == 0 and L[i] == prevMin :
              indxMin = i
              indxCounterMin+= 1
        if indxCounterMax == 0 and L[i] == prevMax :
              indxMax = i
              indxCounterMax+= 1
        if indxCounterMax and indxCounterMin :  # index positions of min/max are found
            break                               #   so we may exit the search loop . 
    #print(f"i={size=} , {indxMin=}, {indxMax=}, {prevMin=}, {prevMax=}")
    yield size, prevMin, prevMax

    for i  in range(size+1, lenL+1):
        if ( i - size - 1 ) == indxMin:
            prevMin=min(L[ i - size : i ])
            for indx in range( i - size, i )[::-1]:
                if L[indx] == prevMin :
                    indxMin = indx
                    break
        elif ( i  - size  - 1 ) == indxMax:
            prevMax=max(L[ i - size : i ])
            for indx in list(range(i - size , i ))[::-1]:
                if L[indx] == prevMax :
                    indxMax = indx
                    break
        else:   
            if  L[ i  - 1 ] <= prevMin :
                indxMin = i  -  1  
                if      L[ i  - 1 ] < prevMin :
                    prevMin = L[ i  - 1 ]
            elif    L[ i  - 1 ] >= prevMax :
                indxMax = i  - 1
                if      L[ i  - 1 ] > prevMax :
                    prevMax = L[ i  - 1 ]
             
        #print(f"  >  {i=:2d}, {indxMin=}, {indxMax=}, {prevMin=}, {prevMax=}")
        yield i, prevMin, prevMax

Replacing the function used in the "btl" code with the function above gives following output:

0.05169699003454298
btl-opt 19998737 [0, 49999, 100000]

Wow! With this "btl-opt" optimization of the code it was possible to cut the time down to a half and arrive at 0.05 seconds.

Is this now already the limit of what could be achieved, or is there still room for improvement?

Let’s leave the dynamic programming approach by side and use another one which targets the solution by "brute force" and checks all of the possible cases of partitioning the list. As a side-effect it spits out multiple solutions for the partitioning if there are any:

def allArrangementsOfIntegerItemsInLsummingUpTOtargetSum(L, targetSum, n=None, m=None):
    if n is None:       n  = 1
    if m is None:   m = targetSum
    lenL = len(L)
    # print(f"{targetSum=}, {L=}, {n=}, {m=}")

    Stack           = []
    
    # Initialize the Stack with the starting point for each element in L
    for i in range(lenL):
        currentSum  =   L[ i ]
        path        = [   L[ i ]   ]
        start       = 0         # Start from 0 allows revisiting of all items
        Stack.append(   (currentSum, path, start )   )  

    while Stack:
        currentSum, path, start = Stack.pop()
        # Check if the current path meets the criteria
        if currentSum == targetSum and n <= len(path) <= m:
            yield path
        if currentSum > targetSum or len(path) > m:
            continue  
        # ^ - NEXT please: stop exploring this path as it's not valid or complete

        # Continue to build the path by adding elements from L, starting from 0 index
        for i in range(len(L)):  # Change start to 0 if you want to include all permutations
            newSum = currentSum + L[ i  ]
            newPath = path + [ L[ i  ]  ]
            Stack.append((newSum, newPath, 0))  # Start from 0 allows every possibility
# def allArrangementsOfIntegerItemsInLsummingUpTOtargetSum

splitsGenerator = allArrangementsOfIntegerItemsInLsummingUpTOtargetSum

def arrSplit(Arr, m, n) :
    targetSum=len(Arr)
    L=list(range( m, n+1) )
    bestSum = None
    for split in splitsGenerator( L, targetSum )  :
        #print("###", split)
        startIndx   = 0
        endIndx = 0
        currSum = 0
        for indx in split :
            endIndx += indx
            currSum = currSum + max(Arr[startIndx : endIndx ]) - min(Arr[ startIndx : endIndx  ])
            startIndx=startIndx + endIndx

        if bestSum is None:
            bestSum = currSum
            bestSplit   = [ split ]
            continue

        if bestSum <= currSum :
            if bestSum == currSum :
                bestSplit.append(split)
            else: 
                bestSum = currSum
                bestSplit = [ split ]


    return bestSum, bestSplit

targetSum = 10000
from time import perf_counter as T
from random import randint, seed 
seed(2); arr=[]
for _ in range(100000): arr.append(randint(1,10000000))
sT=T(); result=arrSplit(arr, 49999, 50001); eT=T()
print(eT-sT)
print("oOo", result[0], result[1], end='')

The code outputs:

0.012507450999692082
oOo 19998737 [[50001, 49999], [50000, 50000], [49999, 50001]]

and beats the already very fast code we have started the journey with being ten times faster with 0.01 seconds.


Can this be still improved by a further order of magnitude? I am quite sure it can by more than that, so there is still plenty of room for improvement and for next answers with much faster code to continue the journey.

May the power of oOo be with you!

Answered By: Claudio

Just an alternative for btilly’s window_mins_maxes, also O(len(array)) time and O(size) space:

from itertools import accumulate

def window_mins_maxes(size, array):
    leftmins = None
    for stop in range(size, len(array) + 1):
        if not leftmins:
            left = array[stop-size : stop][::-1]
            right = array[stop-1 : stop+size]
            leftmins = list(accumulate(left, min))
            leftmaxs = list(accumulate(left, max))
            rightmins = accumulate(right, min)
            rightmaxs = accumulate(right, max)
        yield (
            stop,
            min(leftmins.pop(), next(rightmins)),
            max(leftmaxs.pop(), next(rightmaxs))
        )

The idea: If you want the minimum of some array range, split the range into a left part and a right part, and take the minimum of the left’s minimum and the right’s minimum.

For example for 10 elements and window size 4:

 0 1 2 3 4 5 6 7 8 9

 L L L L
       R

   L L L
       R R

     L L
       R R R

       L
       R R R R

         L L L L
               R

           ...

The Ls mark the left part and the Rs mark the right part of the window. The right part is growing, so its minima are just its cumulative minima. The left part is shrinking, so its minima are it’s reversed cumulative suffix minima.

Whenever I don’t have left-part minima, I compute a chunk of them and reset the right part, as shown in the last step above.

I made L and R overlap so they’re never empty, not even when size=1.

Attempt This Online!

Answered By: no comment

While trying to find an algorithm for fast splitting an n -items long array of integers into sub-arrays of length between and including a and b with the biggest sum of differences between min and max values in each of the sub-arrays it is important to become aware of the fact that the complexity of the algorithm required to solve this problem highly depends on the parameters a and b which impact on the overall complexity is a way higher than the size of the array itself.

What is the time complexity of this problem expressed in terms of n, a and b and why is it important to be aware of it?

Becoming aware of it is important in order to see that as long as no algorithm allowing full linear time complexity becomes known here it will be necessary to use different algorithms to achieve good performance over various special ranges of possible values of a and b at given array size n.

If you have tried to use the algorithm provided by Claudio in the other answer (which claims to be ten times faster than the algorithm provided by btilly) with another values for a and b as these ones chosen by Claudio, you had already experienced its really bad performance compared to the dynamic programming approach in most of the other cases.

This is the point where knowing about the right formula for the time complexity of the problem helps to provide code which always runs faster.

Below the hopefully correct formula used in provided code for deciding about the choice of the appropriate algorithm to ensure best performance in all of the possible cases:

O( n * (b-a+1)**(n//a -1) ) ( ‘ ** ‘-> exponentiation ‘//’-> integer division )

In the code itself the (b-a+1)**(n//a) part of the formula is called complexityFactor and the threshold value used to choose one of the two provided algorithms for calculating the result is called complexityFactorThreshold


def allArrangementsOfIntegerItemsInLsummingUpTOtargetSum(L, targetSum, n=None, m=None):
    if n is None:       n  = 1
    if m is None:   m = targetSum
    lenL = len(L)
    # print(f"{targetSum=}, {L=}, {n=}, {m=}")
    Stack           = []
    # Initialize the Stack with the starting point for each element in L
    for i in range(lenL):
        currentSum  =   L[ i ]
        path        = [   L[ i ]   ]
        start       = 0         # Start from 0 allows revisiting of all items
        Stack.append(   (currentSum, path, start )   )  

    while Stack:
        currentSum, path, start = Stack.pop()
        # Check if the current path meets the criteria
        if currentSum == targetSum and n <= len(path) <= m:
            yield path
        if currentSum > targetSum or len(path) > m:
            continue  
        # ^ - NEXT please: stop exploring this path as it's not valid or complete

        # Continue to build the path by adding elements from L, starting from 0 index
        for i in range(len(L)):  # Change start to 0 if you want to include all permutations
            newSum = currentSum + L[ i  ]
            newPath = path + [ L[ i  ]  ]
            Stack.append((newSum, newPath, 0))  # Start from 0 allows every possibility
# def allArrangementsOfIntegerItemsInLsummingUpTOtargetSum
splitsGenerator = allArrangementsOfIntegerItemsInLsummingUpTOtargetSum

def lowComplexitySplit(Arr, m, n) :
    targetSum=len(Arr)
    L=list(range( m, n+1) )
    bestSum = None
    for split in splitsGenerator( L, targetSum )  :
        #print("###", split)
        startIndx   = 0
        endIndx = 0
        currSum = 0
        for indx in split :
            endIndx += indx
            currSum = currSum + max(Arr[startIndx : endIndx ]) - min(Arr[ startIndx : endIndx  ])
            startIndx=startIndx + endIndx
        if bestSum is None:
            bestSum = currSum
            bestSplit   = [ split ]
            continue
        if bestSum <= currSum :
            if bestSum == currSum :
                bestSplit.append(split)
            else: 
                bestSum = currSum
                bestSplit = [ split ]
    return bestSum, bestSplit

def winMinMaxLargeSize(size, L):
    """                                 oOosys                               2024-04-23_21:30
    This method of getting the min/max values within the over the list 'L' moved 
    chunk/interval/partition/section "Window" is saving the indices/positions of these 
        values in the list 'L'  for later use. This allows skipping of recalculations of min/max
        in cases where the integer value "leaving" the "Window" has no impact on the 
            min/max values because it fits into the range between min and max. 
    The variables storing the indices/positions are 'indxMin' and 'indxMax' and represent 
    the LAST position of the found min/max value if non-unique.  
    As Python suprisingly does not support simultaneous calculation of min/max and the
    last position of obtained values some "trickery" is used to avoid some of the multiple 
        scans over the analysed "Window" in the process of getting both the min/max 
        values and their last indices/positions in the for them scanned  list. 
    """
    # Lets calculate the min/max values for the first "Window" at the beginning of 'L' to 
    # get initial/start values for "moving" of the "Window" over the list  'L'  : 
    prevMax, indxMax    = max(  zip(L[ 0 : size ],          range( size  )))
    #prevMin, indxMin   = min(  zip(L[ 0 : size ], reversed(range( size )))); indxMin=size - indxMin - 1
    prevMin=min(L[ 0 : size ]); indxMin = size  - list(reversed(L[ 0 : size  ])).index( prevMin ) - 1 # probably FASTER than in the line above
    lenL=len(L)
    #print(f"i={size=} , {indxMin=}, {indxMax=}, {prevMin=}, {prevMax=}")
    yield size, prevMin, prevMax

    for i  in range(size+1, lenL+1):
        if ( i - size - 1 ) == indxMin:
            prevMin=min(L[ i - size : i ]); indxMin = i  - list(reversed(L[ i - size : i ])).index( prevMin ) - 1 # appears to be the FASTEST ONE (see another approaches below)
            # ---
            #prevMin, indxMin   = min(  zip(L[ i - size : i ],  range( size - 1, -1, -1 ))); indxMin = i - indxMin  - 1
            # ---
            #prevMin, indxMin   = min(  zip(L[ i - size : i ], reversed(    range( size )))); indxMin = i - indxMin  - 1
            # ---
            #prevMin=min(L[ i - size : i ])
            #for indx in range( i - size, i )[::-1]:
            #   if L[indx] == prevMin :
            #       indxMin = indx
            #       break
            # ---
        elif ( i  - size  - 1 ) == indxMax:
            prevMax, indxMax    = max(  zip(L[ i - size : i ],              range( size  ))); indxMax = i  - size + indxMax
            #prevMax=max(L[ i - size : i ])
            #for indx in list(range(i - size , i ))[::-1]:
            #   if L[indx] == prevMax :
            #       indxMax = indx
            #       break
        else:  # the value which has "left" the "Window" was a neutral one and it is possible to update values without re-calculating min/max over the "Window" width
            if  L[ i  - 1 ] <= prevMin :    # let's handle the case of necessary update of indxMin and eventually necessary update also of prevMin in one if block:
                indxMin = i  -  1  
                if      L[ i  - 1 ] < prevMin :
                    prevMin = L[ i  - 1 ]
            elif    L[ i  - 1 ] >= prevMax :    # this is the case where the "incoming" value impacts indxMax and eventually the prevMax value 
                indxMax = i  - 1
                if      L[ i  - 1 ] > prevMax :
                    prevMax = L[ i  - 1 ]
             
        #print(f"  >  {i=:2d}, {indxMin=}, {indxMax=}, {prevMin=}, {prevMax=}")
        yield i, prevMin, prevMax


def highComplexitySplit(listOfInts, minSplitWidth, maxSplitWidth):
    if maxSplitWidth < minSplitWidth or len(listOfInts) < minSplitWidth:
        return (None, None)

    best_weight = [None for _ in listOfInts]
    prev_index = [None for _ in listOfInts]

    # Need an extra entry for off of the end of the array.
    best_weight.append(None)
    prev_index.append(None)

    best_weight[0] = 0

    for i, min_value, max_value in winMinMaxLargeSize(minSplitWidth, listOfInts):

        #print(f"  {i=:4d}, {min_value=:8d}, {max_value=:8d}", end=" >>> ")

        window_start_weight = best_weight[i - minSplitWidth]
        if window_start_weight  is not None:
            j = i
            while j - i < maxSplitWidth - minSplitWidth and j < len(listOfInts):
                new_weight = window_start_weight + max_value - min_value
                if best_weight[j] is None or best_weight[j] < new_weight:
                    best_weight[j] = new_weight
                    prev_index[j] = i - minSplitWidth

                if listOfInts[j] < min_value:
                    min_value = listOfInts[j]
                if max_value < listOfInts[j]:
                    max_value = listOfInts[j]
                j += 1

            # And fill in the longest value.
            new_weight = window_start_weight + max_value - min_value
            if best_weight[j] is None or best_weight[j] < new_weight:
                best_weight[j] = new_weight
                prev_index[j] = i - minSplitWidth

    if best_weight[-1] is None:
        return (None, None)
    else:
        path = [len(listOfInts)]
        while prev_index[path[-1]] is not None:
            path.append(prev_index[path[-1]])
        #path = list(reversed(path))
        path = list(path)[::-1]
        #partitioned = [listOfInts[path[i]:path[i+1]] for i in range(len(path)-1)]
        #return (best_weight[-1], partitioned)
        return (best_weight[-1], path )

def complexityFactorSplit(listOfInts, minSplitWidth, maxSplitWidth) :

    n=len(listOfInts)
    complexityFactor=( maxSplitWidth  - minSplitWidth + 1 )**( n//minSplitWidth - 1) 
    complexityFactorThreshold = 7

    if complexityFactor  <= complexityFactorThreshold :
        return lowComplexitySplit( listOfInts, minSplitWidth, maxSplitWidth )
    else :
        return highComplexitySplit( listOfInts, minSplitWidth, maxSplitWidth )
# ---
#n=100000; a=49970; b=50030
#n=100000; a=50000; b=50000
#n=100000; a=49995; b=50005

from time import perf_counter as T
from random import randint, seed 
seed(2); arr=[]; n=100000 
for _ in range(n): arr.append(randint(1,n**2))

print(f"{n=}")
for step in range(0, 11, 1):
    a=50000 - step
    b=50000 + step
    sT1=T(); lowComplexitySplit(arr, a, b); eT1=T()
    sT2=T(); highComplexitySplit(arr, a, b); eT2=T()
    print(f"a/b:{a}/{b}  lowC: {eT1-sT1:.3f},highC: {eT2-sT2:.3f}  low/high:{100*(eT1-sT1)/(eT2-sT2): 5.0f}% , cF={( b  - a + 1 )**( n//a - 1):3d} , btilly O(): O( n *{b+1-a: 4d} )")

for step in range(15, 60, 5):
    a=50000 - step
    b=50000 + step
    sT1=T(); lowComplexitySplit(arr, a, b); eT1=T()
    sT2=T(); highComplexitySplit(arr, a, b); eT2=T()
    print(f"a/b:{a}/{b}  lowC: {eT1-sT1:.3f},highC: {eT2-sT2:.3f}  low/high:{100*(eT1-sT1)/(eT2-sT2): 5.0f}% , cF={( b  - a + 1 )**( n//a - 1):3d} , btilly O(): O( n *{b+1-a: 4d} )")

which outputs:

n=100000
a/b:50000/50000  lowC: 0.005,highC: 0.050  low/high:   11% , cF=  1 , btilly O(): O( n *   1 )
a/b:49999/50001  lowC: 0.023,highC: 0.048  low/high:   47% , cF=  3 , btilly O(): O( n *   3 )
a/b:49998/50002  lowC: 0.031,highC: 0.051  low/high:   60% , cF=  5 , btilly O(): O( n *   5 )
a/b:49997/50003  lowC: 0.042,highC: 0.048  low/high:   88% , cF=  7 , btilly O(): O( n *   7 )
a/b:49996/50004  lowC: 0.055,highC: 0.048  low/high:  115% , cF=  9 , btilly O(): O( n *   9 )
a/b:49995/50005  lowC: 0.065,highC: 0.046  low/high:  141% , cF= 11 , btilly O(): O( n *  11 )
a/b:49994/50006  lowC: 0.076,highC: 0.046  low/high:  166% , cF= 13 , btilly O(): O( n *  13 )
a/b:49993/50007  lowC: 0.090,highC: 0.046  low/high:  195% , cF= 15 , btilly O(): O( n *  15 )
a/b:49992/50008  lowC: 0.100,highC: 0.047  low/high:  212% , cF= 17 , btilly O(): O( n *  17 )
a/b:49991/50009  lowC: 0.108,highC: 0.046  low/high:  233% , cF= 19 , btilly O(): O( n *  19 )
a/b:49990/50010  lowC: 0.120,highC: 0.047  low/high:  254% , cF= 21 , btilly O(): O( n *  21 )
a/b:49985/50015  lowC: 0.175,highC: 0.046  low/high:  378% , cF= 31 , btilly O(): O( n *  31 )
a/b:49980/50020  lowC: 0.238,highC: 0.047  low/high:  510% , cF= 41 , btilly O(): O( n *  41 )
a/b:49975/50025  lowC: 0.297,highC: 0.048  low/high:  620% , cF= 51 , btilly O(): O( n *  51 )
a/b:49970/50030  lowC: 0.367,highC: 0.048  low/high:  769% , cF= 61 , btilly O(): O( n *  61 )
a/b:49965/50035  lowC: 0.445,highC: 0.048  low/high:  935% , cF= 71 , btilly O(): O( n *  71 )
a/b:49960/50040  lowC: 0.531,highC: 0.048  low/high: 1100% , cF= 81 , btilly O(): O( n *  81 )
a/b:49955/50045  lowC: 0.625,highC: 0.048  low/high: 1300% , cF= 91 , btilly O(): O( n *  91 )
a/b:49950/50050  lowC: 0.730,highC: 0.048  low/high: 1512% , cF=101 , btilly O(): O( n * 101 )
a/b:49945/50055  lowC: 0.841,highC: 0.049  low/high: 1722% , cF=111 , btilly O(): O( n * 111 )

The output shows that the threshold value for cF (complexity Factor) of 7 will in the case of the example allow a right decision about the algorithm to use while running the complexityFactorSplit() function which then delivers fast results for both, high and low complexity sets of values for a and b.

Notice in the output that the by btilly provided formula for the O() complexity suggests growing complexity, but the code running time (highC) does not increase what proves that this formula is incorrect for both: describing the behavior of the code and describing the complexity of the problem itself.

Answered By: Claudio

There are up to now ( 2024-04-24 ) following main algorithmic approaches formulated as Python scripts in the question and all the other answers:

  • brute force approach which fails to handle arrays of length beyond 35 ( in OP question )
  • a dynamic programming approach able to handle large arrays ( first given answer by btilly )
  • a dynamic programming btilly code based approach with improved core function for finding the maxima and minima in all "windows" with width a sliding over the array of numbers running twice as fast as the original version
  • a brute force approach generating in first step a splitting which runs on some cases of a and b values ten times faster then the fastest code in the already mentioned approaches
  • a mixed approach achieving overall faster results by switching to the faster algorithm running two to ten times faster than the first by btilly posted code.

Is it possible to provide an algorithm which runs faster than all of the already provided ones without any tricks with making the code as such running faster?

This answer shows that it is possible covering a much wider range of cases as the up to now fastest algorithm suitable for a small range of b and a values for given n being the size of the array with numbers.

Compared to the first posted fast dynamic programming based algorithm the below provided code runs 60 times faster and could probably not be improved as an algorithm (except there is some relationship valid because of the specifics of the weight function being the difference of max-min values within the chunk/partition/sub-list), so speeding it up will need faster running code implementing the algorithm.

Below the code showing the basic idea behind the algorithm rendering all the previous considerations about complexity of the problem obsolete.

Using this basic idea will make it with some further work on it possible to provide code covering all possible cases by hierarchical building upon this core "brick like" unit. It will then handle all of possible parameter sets for n, a, b and not only these which satisfy the condition n//a == 2 ( where // means integer division ) leading to better performance than the dynamic programming based approach and capable to provide multiple solutions for splittings if there are any.

def oneSplitPoint(Arr,a,b):
    lenArr=len(Arr)
    assert lenArr//a == 2
    minL, maxL  =min(Arr[:a]),  max(Arr[:a])
    minR, maxR  =min(Arr[-a:]), max(Arr[-a:])
    dL  =[ maxL  -minL ]
    dR  =[ maxR - minR ]
    if a == b and a+b == n:
        return ( dL[0]+dR[0], f"[ {a} ]" )
    minSplitWidth = a ; maxSplitWidth = b
    for indxL in range( minSplitWidth, maxSplitWidth ):
        if Arr[indxL] > maxL : maxL = Arr[indxL]
        if Arr[indxL] < minL :   minL = Arr[indxL]
        dL = dL + [ maxL  -minL ]
        indxR = lenArr  - indxL - 1
        if Arr[indxR] > maxR : maxR = Arr[indxR]
        if Arr[indxR] < minR :   minR = Arr[indxR]
        dR = dR + [ maxR  -minR ]
    dR.reverse()
    sumLR = []
    for indx in range(maxSplitWidth - minSplitWidth + 1) :
        sumLR = sumLR + [ dL[indx] + dR[indx] ]
    maxSumLR = max( sumLR )
    splitIndices = []; lastIndex = 0
    while True:
        try: 
            lastIndex = sumLR.index(  maxSumLR , lastIndex  ) + 1
            splitIndices = splitIndices + [ minSplitWidth + lastIndex - 2 ]
        except ValueError:
            break
    return maxSumLR, splitIndices

Below the output from running the above code against the improved dynamic programming algorithm approach showing cutting down of the time to 3% of the reference code. And because the reference code runs twice as fast as the first one posted it means an improvement of being over 60 times faster.

n=12000
a/b:6000/6000  lowC: 0.000,highC: 0.005  low/high:   11% , cF=  0 , btilly O(): O( n *   1 )
a/b:5800/6200  lowC: 0.002,highC: 0.040  low/high:    4% , cF=  1 , btilly O(): O( n * 401 )
a/b:5600/6400  lowC: 0.004,highC: 0.137  low/high:    3% , cF=  4 , btilly O(): O( n * 801 )
a/b:5400/6600  lowC: 0.009,highC: 0.301  low/high:    3% , cF=  9 , btilly O(): O( n * 1201 )
a/b:5200/6800  lowC: 0.017,highC: 0.530  low/high:    3% , cF= 16 , btilly O(): O( n * 1601 )
a/b:5000/7000  lowC: 0.025,highC: 0.828  low/high:    3% , cF= 25 , btilly O(): O( n * 2001 )
a/b:4800/7200  lowC: 0.039,highC: 1.188  low/high:    3% , cF= 36 , btilly O(): O( n * 2401 )

I have adjusted the complexity factor to a formula which mirrors the running time and it seems that the time complexity of this approach is rough estimated proportional to the square of (b-a+1) i.e. O(n(b-a+1)^2) .

Answered By: Claudio
Categories: questions Tags: ,
Answers are sorted by their score. The answer accepted by the question owner as the best is marked with
at the top-right corner.