解决这个算法难题的想法

4

我曾经遇到过类似的问题,但至今仍没有好的解决方法。这个问题是这样的:

给你一个长度为n(n<=1000)的正整数数组和一个正整数k(k<=n),k表示需要将该数组分割成k个连续子数组。你需要输出最小的m,其中m=max{s[1],...,s[k]},s[i]表示第i个子数组的和。数组中的所有整数都在1到100之间。例如:

Input:                           Output:
5  3  >> n = 5 k = 3             3
2 1 1 2 3

将数组分成2+1 | 1+2 | 3可以最小化m。

我的暴力想法是让第一个子数组在位置i结束(对于所有可能的i),然后尝试以最佳方式将其余的数组分成k-1个子数组。然而,这是指数级的解决方案,永远不会奏效。

因此,我正在寻找好的解决方法。如果您有,请告诉我。

感谢您的帮助。


回溯法会帮助你到达目的地。 - Noldorin
这是背包问题的一个变体吗?http://en.wikipedia.org/wiki/Knapsack_problem - BlueMonkMN
@BlueMonkMN,我认为这个问题更容易解决。请看我的答案,我没有使用动态规划。但是,我不完全确定它是否有效或更快。 - toto2
这是二分查找。如果子数组不必连续,那么它会很困难。 - Thomas Ahle
6个回答

5
您可以使用动态规划来解决这个问题,但实际上您可以使用贪心算法和二分查找答案来解决。该算法的复杂度为 O(n log d),其中d是输出答案。(一个上限是数组中所有元素的总和)。(或者在输出位的大小上O( n d ))。
其思想是在你的m上进行二分查找 - 然后贪心地沿着数组向前移动,将当前元素添加到分区中,除非将当前元素添加会推出当前的 - 在这种情况下,您开始一个新的分区。如果使用的分区数量少于或等于您给定的输入k,则当前的m是成功的(因此调整上限)。否则,您使用了太多的分区,并提高了m的下限。
一些伪代码:
// binary search
binary_search ( array, N, k ) {
    lower = max( array ), upper = sum( array )

    while lower < upper {
        mid = ( lower + upper ) / 2

        // if the greedy is good
        if partitions( array, mid ) <= k
           upper = mid
        else
           lower = mid
    }
 }

 partitions( array, m ) {
    count = 0
    running_sum = 0

    for x in array {
       if running_sum + x > m
          running_sum = 0
          count++
       running_sum += x
    }
    if running_sum > 0
       count++
    return count
 }

这个概念应该更容易理解。另外请注意,由于分区函数的单调性,如果您确定输出的d不太大,实际上可以跳过二分查找并进行线性搜索:

 for i = 0 to infinity
    if partitions( array, i ) <= k
       return i

哇,这是一个非常容易实现但对我来说很难想到的解决方案。感谢您的帮助。 - Mike Plott
说实话,对于某些问题,我发现自己更加自信使用动态规划,因为相较于证明贪心算法正确性而言,我觉得它更容易证明最优解。 - Larry
不错 Larry,这很棒。 - FUD

3
动态规划。创建一个数组。
int best[k+1][n+1];

其中,best[i][j]代表将数组前j个元素划分为i个子数组所能获得的最佳结果。而best[1][j]仅仅表示前j个数组元素的总和。在已知第i行的情况下,可以按照以下方式计算第i+1行:

for(j = i+1; j <= n; ++j){
    temp = min(best[i][i], arraysum[i+1 .. j]);
    for(h = i+1; h < j; ++h){
        if (min(best[i][h], arraysum[h+1 .. j]) < temp){
            temp = min(best[i][h], arraysum[h+1 .. j]);
        }
    }
    best[i+1][j] = temp;
}

best[m][n] 将包含解决方案。该算法的时间复杂度为O(n^2*k),可能有更好的解决方案。

编辑:采用ChingPing、toto2、Coffee on Mars和rds的想法组合(按照当前页面上出现的顺序)。

A = ceiling(sum/k)。这是最小值的下界。通过任何一种提到的方法创建一个很好的分区,移动边框,直到找不到任何仍然可以减少最大子和的简单移动为止,以找到一个良好的最小上界B(如果它比下界大得多,你会发现通过移动边框可以轻易地得到改进)。 现在继续使用ChingPing的算法,知道上界值来减少可能的分支数量。这最后一阶段的时间复杂度为O((B-A)*n),找到未知的B,但我认为比O(n^2)更好。


1
我认为这个方法会行 :D 只是一个建议,因为每个元素的值都有100的限制...我们可以预先计算出j=0至n的arraysum[0...j]的值..然后array[i...j]==arraysum[0...j]-arraysum[0...i].. 这将使时间复杂度降到O(n*k)。 - FUD
1
是的,我也会将累积和存储在数组中,所以 arraysum[a .. b] 将变为 cum[b] - cum[a-1],但这并不意味着它是 O(n*k),二次行为中的 n 来自于我们必须检查 j-i 可能的位置来找到最佳的子数组 best[i+1][j]。当然,可以通过简化来削减一些常数因子。 - Daniel Fischer
抱歉..你是对的..还有一件事..你认为应该这样写吗:best[i+1][j] = min( best[i+1][j],temp ) - FUD
不,我认为不应该,best[i+1][j]只在找到temp中的最小值后设定一次(实际上,我们可以消除temp,这可能更清晰)。 - Daniel Fischer
@ChingPing 啊,我之前的思路有误,完全误解了。当然你是对的,我们需要用 min - Daniel Fischer

2
我有一种效率较低的分支定界算法(请不要对我评价过低)。
首先,将数组中所有元素相加并除以 k,这将给出您答案的最佳情况下限即平均值 A。同时,我们将为任何分支 GO(全局最优解)保留一个当前已见到的最佳解。假设我们在某个数组元素后放置一个分割器(逻辑分割单元),我们需要放置 k-1 个分割器。现在,我们将通过以下贪婪方法放置分区:
遍历数组元素并将它们相加,直到看到下一个位置将超过 A,此时创建两个分支:一个在此位置放置分割器,另一个在下一个位置放置分割器。递归地执行此操作,并设置 GO = min(GO,分支的答案)。如果在任何分支的任何点上,分区大于 GO 或位置数小于要放置的剩余分区,则终止运行。最终答案应为 GO。
编辑: 如 Daniel 所建议的,我们可以稍微修改分割器放置策略,使其在达到元素总和为 A 或剩余位置少于分割器的情况下停止放置。

我认为这个一般情况下会表现得很好。一个增强的方法是在每个可能的最优点重新计算。我们从 A = ceiling(sum/k) 开始。在第一个 running_sum[i] 超过 A 的点,计算 B = ceiling((sum-running_sum[i-1])/(k-1))。如果 B >= running_sum[i],则不需要分支并且可以更新 A = running_sum[i]。类似地,在后续交叉点,如果剩余的平均值大于通过交叉获得的平均值,则无需分支。 - Daniel Fischer
1
谢谢大家的慷慨点赞,但我的解决方案并不适用于所有情况...考虑将1 1 1 9作为数组,并进行3个分区,我的解决方案永远无法得出答案...我不介意取消点赞.. :) - FUD
1
好的,在这种极端情况下,A = max{ max array, ceiling(sum/k) },不要运行到只剩下少量元素的子数组。那么我仍然认为它非常好。 - Daniel Fischer

1

这只是一个想法的草图...我不确定它是否有效,但它非常简单(而且可能也很快)。

首先将分隔符均匀分布(实际上开始的方式并不重要)。

对每个子数组求和。
找到和最大的子数组。
查看右侧和左侧相邻的子数组,并在左侧的子数组比右侧的子数组和低(反之亦然)时将分隔符向左移动一位。
针对当前具有最大和的子数组重新执行此操作。

你会遇到一些情况,其中你将保持在同样的两个位置之间来回弹跳,这可能意味着你已经找到了解决方案。

编辑:请参见@rds的评论。您需要更加努力地思考弹跳解决方案和结束条件。


1
这不是正确的。反例:[1; 40; 50; 1; 2; 40],其中k=3。从(1;40)(50;1)(2;40)开始。中间最大。左边更低。走到(1;40;90)(1)(2;40)。回到(1;40)(50;1)(2;40)。结束。然而还有(1;40)(50)(1;2;40)。 - rds
@rds 谢谢。这就是为什么我用了“可能”这个词,意思是你很可能已经找到了解决方案。我会添加一个编辑。 - toto2

0

如果您的数组有随机数,您可以希望每个子数组都有n/k是一个好的起点。

从那里开始

  1. 通过计算总和来评估此候选解决方案
  2. 存储此候选解决方案。例如:
    • 每个子数组索引的数组
    • 在子数组上对应的最大值
  3. 减小最大子数组的大小:创建两个新的候选项:一个以索引+1开头的子数组;一个以索引-1结尾的子数组
  4. 评估新的候选项。
    • 如果它们的最大值更高,则丢弃
    • 如果它们的最大值更低,则迭代2,除非已经评估了此候选项,在这种情况下,它就是解决方案。

0

我的想法,可惜不起作用:

  1. 将数组分成N个子数组
  2. 找到两个连续的子数组,它们的和最小
  3. 合并步骤2中找到的子数组,形成一个新的连续子数组
  4. 如果子数组的总数大于k,则从步骤2开始迭代,否则结束。

不幸的是,这并不总是有效的。例如,这将产生2 | 1 1 | 2 3作为第一步,并以2 1 1 | 2 | 32 | 1 1 2 | 3结束。然后,您需要检查是否可以通过移动边界来减小最大值。这将为移动边界提供一个良好的起点,而且在大多数情况下,您很快就会找到最佳解,但我不确定是否存在一些情况,您会陷入局部但不是全局最小值的情况。 - Daniel Fischer
是的,似乎当两个接近的小值太少时,问题总是会导致局部最小值,因为算法会将它们合并在一起而不是更接近的值。 - Coffee on Mars

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接