如何将解决方案转化为分而治之(找到具有最大、最小值的子数组)

How to translate a solution into divide-and-conquer (finding a sub array with the largest, smallest value)

我正在努力提高分而治之的算法,并以下面的算法为例。给定一个数组 _in 和一些长度 l 它找到子数组 _in[_min_start,_min_start+l] 的起点,使得该子数组中的最低值是可能的最高值。我想出了一个 none 分而治之的解决方案,我想知道如何将其转化为将数组分成更小的部分(分而治之)的解决方案。

def main(_in, l):
    _min_start = 0
    min_trough = None

    for i in range(len(_in)+1-l):
        if min_trough is None:
            min_trough = min(_in[i:i+l])
        
        if min(_in[i:i+l]) > min_trough:
            _min_start = i
            min_trough = min(_in[i:i+l])
    
    return _min_start, _in[_min_start:_min_start+l]

例如对于数组 [5, 1, -1, 2, 5, -4, 3, 9, 8, -2, 0, 6] 和长度为 3 的子数组,它将 return 从位置 6 开始(导致数组 [3,9,8])。

三个 O(n) 解决方案和一个基准

请注意,我将 _in 和 l 重命名为更清晰的名称 A 和 k。

解决方案 1:分而治之

将数组分成两半。递归求解左半边和右半边。尚未考虑的子数组穿过中间,即它们是左侧部分的后缀加上右侧部分的前缀。计算左半部分的 k-1 后缀最小值和右半部分的 k-1 前缀最小值。这允许您在 O(1) 时间内计算每个长度为 k 的中间交叉子数组的最小值。整个数组的最佳子数组是左最佳、右最佳和交叉最佳中的最佳。

我相信运行时间是 O(n)。正如埃利斯指出的那样,在递归中,子数组可以变得小于 k。这种情况需要 O(1) 时间 return 相当于 “这里没有任何 k 长度的子数组”。所以时间是:

T(n) = { 2 * T(n/2) + O(k)    if n >= k
       { O(1)                 otherwise

对于任何 0 <= k <= n 我们有 k=nc 其中 0 <= c <= 1。那么调用次数是 Θ(n1-c) 并且每个调用自己的工作需要 Θ(nc) 时间,总共 Θ(n) 时间。

发布了一个question关于复杂性的信息。

Python 实施:

def solve_divide_and_conquer(A, k):
    def solve(start, stop):
        if stop - start < k:
            return -inf,
        mid = (start + stop) // 2
        left = solve(start, mid)
        right = solve(mid, stop)
        i0 = mid - k + 1
        prefixes = accumulate(A[mid:mid+k-1], min)
        if i0 < 0:
            prefixes = [*prefixes][-i0:]
            i0 = 0
        suffixes = list(accumulate(A[i0:mid][::-1], min))[::-1]
        crossing = max(zip(map(min, suffixes, prefixes), count(i0)))
        return max(left, right, crossing)
    return solve(0, len(A))[1]

解决方案 2:k 块

正如@benrg评论的那样,上面的分而治之是不必要的复杂。我们可以简单地处理长度为 k 的块。计算第一个块的后缀最小值和第二个块的前缀最小值。这允许在 O(1) 时间内找到这两个块中每个 k 长度子数组的最小值。对第二个和第三个块,第三个和第四个块等执行相同的操作。时间也是 O(n)。

Python 实施:

def solve_blocks(A, k):
    return max(max(zip(map(min, prefixes, suffixes), count(mid-k)))
               for mid in range(k, len(A)+1, k)
               for prefixes in [accumulate(A[mid:mid+k], min, initial=inf)]
               for suffixes in [list(accumulate(A[mid-k:mid][::-1], min, initial=inf))[::-1]]
           )[1]

解决方案 3:Monoqueue

不是分而治之,而是我想出的第一个(并且知道是 O(n))。

滑动 window,用 window 中严格递增数组值的双端队列(已排序)索引表示 window。滑动 window 以包含新值时 A[i]:

  1. 如果滑动使它脱离 window,则从双端队列中删除第一个索引。
  2. 删除数组值大于A[i]的索引。 (它们再也不能是 window 中的最小值了。)
  3. 包括新索引 i
  4. 仍在双端队列中的第一个索引是当前window的最小值的索引。用它来更新整体结果。

Python 实施:

from collections import deque

A = [5, 1, -1, 2, 5, -4, 3, 9, 8, -2, 0, 6]
k = 3

I = deque()
for i in range(len(A)):
    if I and I[0] == i - k:
        I.popleft()
    while I and A[I[-1]] >= A[i]:
        I.pop()
    I.append(i)
    curr_min = A[I[0]]
    if i == k-1 or i > k-1 and curr_min > max_min:
        result = i - k + 1
        max_min = curr_min

print(result)

基准

从 0 到 9999 的 4000 个数字,k=2000:

 80.4 ms   81.4 ms   81.8 ms  solve_brute_force
 80.2 ms   80.5 ms   80.7 ms  solve_original
  2.4 ms    2.4 ms    2.4 ms  solve_monoqueue
  2.4 ms    2.4 ms    2.4 ms  solve_divide_and_conquer
  1.3 ms    1.4 ms    1.4 ms  solve_blocks

基准代码(Try it online!):

from timeit import repeat
from random import choices
from itertools import accumulate
from math import inf
from itertools import count
from collections import deque

def solve_monoqueue(A, k):
    I = deque()
    for i in range(len(A)):
        if I and I[0] == i - k:
            I.popleft()
        while I and A[I[-1]] >= A[i]:
            I.pop()
        I.append(i)
        curr_min = A[I[0]]
        if i == k-1 or i > k-1 and curr_min > max_min:
            result = i - k + 1
            max_min = curr_min
    return result

def solve_divide_and_conquer(A, k):
    def solve(start, stop):
        if stop - start < k:
            return -inf,
        mid = (start + stop) // 2
        left = solve(start, mid)
        right = solve(mid, stop)
        i0 = mid - k + 1
        prefixes = accumulate(A[mid:mid+k-1], min)
        if i0 < 0:
            prefixes = [*prefixes][-i0:]
            i0 = 0
        suffixes = list(accumulate(A[i0:mid][::-1], min))[::-1]
        crossing = max(zip(map(min, suffixes, prefixes), count(i0)))
        return max(left, right, crossing)
    return solve(0, len(A))[1]

def solve_blocks(A, k):
    return max(max(zip(map(min, prefixes, suffixes), count(mid-k)))
               for mid in range(k, len(A)+1, k)
               for prefixes in [accumulate(A[mid:mid+k], min, initial=inf)]
               for suffixes in [list(accumulate(A[mid-k:mid][::-1], min, initial=inf))[::-1]]
           )[1]
        
def solve_brute_force(A, k):
    return max(range(len(A)+1-k),
               key=lambda start: min(A[start : start+k]))

def solve_original(_in, l):
    _min_start = 0
    min_trough = None

    for i in range(len(_in)+1-l):
        if min_trough is None:
            min_trough = min(_in[i:i+l])
        
        if min(_in[i:i+l]) > min_trough:
            _min_start = i
            min_trough = min(_in[i:i+l])
    
    return _min_start  # , _in[_min_start:_min_start+l]

solutions = [
    solve_brute_force,
    solve_original,
    solve_monoqueue,
    solve_divide_and_conquer,
    solve_blocks,
]

for _ in range(3):
    A = choices(range(10000), k=4000)
    k = 2000

    # Check correctness
    expect = None
    for solution in solutions:
        index = solution(A.copy(), k)
        assert 0 <= index and index + k-1 < len(A)
        min_there = min(A[index : index+k])
        if expect is None:
            expect = min_there
            print(expect)
        else:
            print(min_there == expect, solution.__name__)
    print()

    # Speed
    for solution in solutions:
        copy = A.copy()
        ts = sorted(repeat(lambda: solution(copy, k), number=1))[:3]
        print(*('%5.1f ms ' % (t * 1e3) for t in ts), solution.__name__)
    print()