将一个数组分割成P个平衡子数组的算法

28

我有一个长度为N的大数组,比如说:

2 4 6 7 6 3 3 3 4 3 4 4 4 3 3 1

我需要将这个数组分成P个子数组(在此例中,P=4是比较合适的),使得每个子数组元素的和尽可能接近sigma,其中:

sigma=(sum of all elements in original array)/P
在这个示例中,sigma=15
为了清晰起见,一种可能的结果是:
2 4 6    7 6 3 3   3 4 3 4    4 4 3 3 1
(sums: 12,19,14,15)

我编写了一个非常朴素的算法,基于我手动进行除法时所采用的方式,但我不知道如何强制执行这样一个条件:一个和为(14,14,14,14,19)的除法结果比一个和为(15,14,16,14,16)的结果更差。

提前感谢您。


1
为什么一个结果比另一个更差?如果你能在脑海中澄清这一点,你就可以用代码写出来。它只是简单地想要最小化差异的总和(与理想结果15相比)吗?有许多方法可以实现这一点。例如,差异的总和(如上所述),差异平方的总和(更严重地惩罚离理想值更远的答案),甚至像标准偏差这样的东西。 - dty
2
你可能想要将平方误差和作为一个不良度量:你可以将每个总和与sigma的差平方,然后将它们相加。(14,14,14,14,19)的不良度为20,而(15,14,16,14,16)的不良度为4。当然,你可以尝试调整指数。 - user824425
是的,你说得对,很抱歉我没有表达更清楚,并感谢你的及时回答。我认为最小化差值之和或平方差值都可以起作用。有什么已知的方法可以在“即兴”情况下执行吗?我实际拥有的数组包含约五十万个数字,因此我不认为首先考虑所有可能的组合,然后选择最平衡的一个选项是可行的。 - Renoa
抱歉,我不知道如何回答这个问题!这是一个完全不同的问题!(因此,最好作为单独的问题提出。)我不知道有没有简单的方法可以做到这一点,除非使用某种形式的“暴力破解” - 你可能需要并行化。 - dty
这听起来非常类似于0-1背包问题,我会从那里开始寻找。但是那个问题没有已知的有效解决方案,所以我担心这个问题也没有。也许可以去cs.stackexchange.com问问? - vonbrand
1
什么是子数组?是连续的子数组还是更像是子序列? - Chao Xu
10个回答

6

首先,让我们通过指定输入、输出和每个可能解决方案的度量来形式化您的优化问题(我希望这符合您的利益):

给定一个由正整数组成的数组A和一个正整数P,将数组A分成P个不重叠的子数组,使得每个子数组的和与子数组的理论和(sum(A)/P)之间的差异最小。
输入:一个由正整数组成的数组A和一个正整数P。 输出:一个由P个非负整数组成的数组SA,表示A的每个子数组的长度,使得这些子数组的长度之和等于A的长度。 度量:对于每个sa ∈ {sa | sa = (A_i, …, A_i+SA_j) for i = (Σ SA_j), j从0到P-1},abs(sum(sa)-sum(A)/P)最小。
输入和输出定义了有效解集。度量定义了比较多个有效解的度量方式。由于我们正在寻找与完美解之间差异最小的解(最小化问题),因此度量也应该是最小的。
有了这些信息,实现测量函数就非常容易了(这里使用Python):
def measure(a, sa):
    sigma = sum(a)/len(sa)
    diff = 0
    i = 0
    for j in xrange(0, len(sa)):
        diff += abs(sum(a[i:i+sa[j]])-sigma)
        i += sa[j]
    return diff

print measure([2,4,6,7,6,3,3,3,4,3,4,4,4,3,3,1], [3,4,4,5]) # prints 8

现在,找到一个最优解有点困难。
我们可以使用回溯算法来寻找有效的解,并使用measure函数对其进行评分。我们基本上尝试所有可能的非负整数数字组合,这些数字之和等于length(A),以表示所有可能的有效解。虽然这确保不会错过有效的解决方案,但它基本上是一种蛮力方法,好处是我们可以省略一些无法比我们已有的最佳解更好的分支。例如,在上面的示例中,如果我们已经有一个measure≤38的解,则不需要测试[9,…](measure > 38)的解。
按照维基百科的伪代码模式,我们的bt函数如下:
def bt(c):
    global P, optimum, optimum_diff
    if reject(P,c):
        return
    if accept(P,c):
        print "%r with %d" % (c, measure(P,c))
        if measure(P,c) < optimum_diff:
            optimum = c
            optimum_diff = measure(P,c)
        return
    s = first(P,c)
    while s is not None:
        bt(list(s))
        s = next(P,s)

全局变量Poptimumoptimum_diff代表问题实例,保存APsigma的值,以及最优解及其度量。
class MinimalSumOfSubArraySumsProblem:
    def __init__(self, a, p):
        self.a = a
        self.p = p
        self.sigma = sum(a)/p

接下来,我们会指定rejectaccept函数,它们非常直观:

def reject(P,c):
    return optimum_diff < measure(P,c)
def accept(P,c):
    return None not in c

这只是拒绝那些度量已经超过我们最优解的候选者。而我们接受任何有效的解决方案。
由于c现在可以包含None值,因此measure函数也略有改变。
def measure(P, c):
    diff = 0
    i = 0
    for j in xrange(0, P.p):
        if c[j] is None:
            break;
        diff += abs(sum(P.a[i:i+c[j]])-P.sigma)
        i += c[j]
    return diff

剩下的两个函数firstnext稍微复杂一些:
def first(P,c):
    t = 0
    is_complete = True
    for i in xrange(0, len(c)):
        if c[i] is None:
            if i+1 < len(c):
                c[i] = 0
            else:
                c[i] = len(P.a) - t
            is_complete = False
            break;
        else:
            t += c[i]
    if is_complete:
        return None
    return c

def next(P,s):
    t = 0
    for i in xrange(0, len(s)):
        t += s[i]
        if i+1 >= len(s) or s[i+1] is None:
            if t+1 > len(P.a):
                return None
            else:
                s[i] += 1
            return s

基本上,first 将列表中下一个 None 值替换为 0(如果它不是列表中的最后一个值),或者将余数替换为有效解决方案(这里有一点优化),如果它是列表中的最后一个值,则返回 None(如果列表中没有 None 值)。next 简单地将最右边的整数加一,或者如果增量超过总限制,则返回 None

现在你只需要创建一个问题实例,初始化全局变量,并调用根节点的 bt 函数:

P = MinimalSumOfSubArraySumsProblem([2,4,6,7,6,3,3,3,4,3,4,4,4,3,3,1], 4)
optimum = None
optimum_diff = float("inf")
bt([None]*P.p)

4

@Gumbo的回答非常清晰易懂,但是当A数组长度大于400且P大于8时,它会消耗大量时间。这是因为该算法有点类似于暴力搜索,虽然有一定的优势。

实际上,一种非常快速的解决方案是使用动态规划

给定一个由正整数组成的数组A和一个正整数P,将数组A分成P个不重叠的子数组,使得每个子数组的和与子数组的完美和(即A数组元素和除以P)之间的差异最小。

度量:,其中是子数组的元素总和,是P个子数组的平均值。

这可以确保和的平衡,因为它使用了标准差的定义。

假设数组A有N个元素;Q(i,j)表示将A数组的最后i个元素分成j个子数组时的最小度量值。D(i,j)表示当数组B由A数组的第i~j个元素组成时的(sum(B)-sum(A)/P)^20≤i≤j<N)。

问题的最小度量是计算Q(N,P)。我们发现:

Q(N,P)=MIN{Q(N-1,P-1)+D(0,0); Q(N-2,P-1)+D(0,1); ...; Q(N-1,P-1)+D(0,N-P)}

所以它可以通过动态规划来解决。
 Q(i,1) = D(N-i,N-1)

 Q(i,j) = MIN{ Q(i-1,j-1)+D(N-i,N-i); 
               Q(i-2,j-1)+D(N-i,N-i+1); 
               ...; 
               Q(j-1,j-1)+D(N-i,N-j)}

因此,算法步骤如下:
 1. Cal j=1:

    Q(1,1), Q(2,1)... Q(3,1)

 2. Cal j=2:

    Q(2,2) = MIN{Q(1,1)+D(N-2,N-2)};

    Q(3,2) = MIN{Q(2,1)+D(N-3,N-3); Q(1,1)+D(N-3,N-2)}

    Q(4,2) = MIN{Q(3,1)+D(N-4,N-4); Q(2,1)+D(N-4,N-3); Q(1,1)+D(N-4,N-2)}

 ... Cal j=...

 P. Cal j=P:

    Q(P,P), Q(P+1,P)...Q(N,P)

The final minimum Measure value is stored as Q(N,P)! 
To trace each subarray's length, you can store the 
MIN choice when calculate Q(i,j)=MIN{Q+D...}

空间用于 D(i,j);

时间用于计算 Q(N,P)

纯暴力算法相比,其消耗的时间为


3
如果我没记错的话,还有一种方法是动态规划。
你可以将P[pos, n]定义为在创建n个子数组时累积到位置pos的最小“惩罚”。显然存在某个位置pos',使得
P[pos', n-1] + penalty(pos', pos) = P[pos, n]
你可以只在pos'=1..pos上进行最小化。
朴素实现的时间复杂度为O(N^2 * M),其中N是原始数组的大小,M是分割数。

1
顺便提一下,如果所有数字都是非负数,则可以利用矩阵的 Monge 属性在 O(NM) 的时间内完成此操作,并使用 Hirschberg 技巧使用 O(N) 的空间。此外,还可以将此问题简化为 DAG,然后在 k 很大时更快地解决问题,请参见此论文:http://65.54.113.26/Publication/652857/finding-a-minimum-weight-k-link-path-in-graphs-with-monge-property-and-applications - Chao Xu

1
以下是可工作的代码(我使用的是PHP语言)。此代码可以自动决定零件数量。
$main = array(2,4,6,1,6,3,2,3,4,3,4,1,4,7,3,1,2,1,3,4,1,7,2,4,1,2,3,1,1,1,1,4,5,7,8,9,8,0);
$pa=0;
for($i=0;$i < count($main); $i++){
$p[]= $main[$i];
if(abs(15 - array_sum($p)) < abs(15 - (array_sum($p)+$main[$i+1])))
{
$pa=$pa+1;
$pi[] = $i+1;
$pc =  count($pi);

$ba = $pi[$pc-2] ;

$part[$pa] = array_slice( $main,  $ba, count($p));
unset($p);
}
}
print_r($part);
for($s=1;$s<count($part);$s++){
echo '<br>';
echo array_sum($part[$s]);
}

代码将会输出像下面这样的部分总和

13
14
16
14
15
15
17

0
我提出了一种基于回溯的算法。主函数随机选择原始数组中的一个元素,并将其添加到分区数组中。对于每个添加的元素,将检查是否可以获得比原来更好的解决方案。这将通过使用一个计算偏差的函数来实现,该函数将区分每次向分区中添加一个新元素。无论如何,我认为在循环中添加一些原始变量是有好处的,以防止程序达不到期望的解决方案而强制其结束。所谓的期望解决方案是指在满足if条件的条件下添加所有元素。
sum=CalculateSum(vector)
Read P
sigma=sum/P
initialize P vectors, with names vector_partition[i], i=1..P
list_vector initialize a list what pointed this P vectors
initialize a diferences_vector with dimension of P
//that can easy visualize like a vector of vectors
//construct a non-recursive backtracking algorithm
function Deviation(vector) //function for calculate deviation of elements from a vector
{
  dev=0
  for i=0 to Size(vector)-1 do
  dev+=|vector[i+1]-vector[i]|
  return dev 
}
iteration=0
//fix some maximum number of iteration for while loop
Read max_iteration
//as the number of iterations will be higher the more it will get  
//a more accurate solution
while(!IsEmpty(vector))
{   
   for i=1 to Size(list_vector) do
   {
       if(IsEmpty(vector)) break from while loop
       initial_deviation=Deviation(list_vector[i])
       el=SelectElement(vector) //you can implement that function using a randomized   
                               //choice of element
       difference_vector[i]=|sigma-CalculateSum(list_vector[i])|
       PutOnBackVector(vector_list[i], el)
       if(initial_deviation>Deviation(difference_vector))
          ExtractFromBackVectorAndPutOnSecondVector(list_vector, vector)
    }
    iteration++
    //prevent to enter in some infinite loop
   if (iteration>max_iteration) break from while loop    

} 你可以通过添加一些代码来增加计算偏差的数量,从而改变这个问题。 额外的数量=0 迭代=0 while { ... 如果(initial_deviation>Deviation(difference_vector)+additional_amount) ExtractFromBackVectorAndPutOnSecondVector(list_vector, vector) 如果(iteration>max_iteration) { iteration=0 aditional_amout+=1/some_constant } 迭代++ //从第一个版本中删除第二个if }


1
总是一个好主意给出你的算法的高层次解释。 - Bernhard Barker

0
我在想以下的方法是否可行:
从左边开始,一旦 sum > sigma,就分成两个部分,一个包括将其推过去的值,另一个不包括。使用 rightSum = totalSum-leftSum 和 rightP = P-1 递归处理右侧数据。
因此,在开始时,sum = 60。
2 4 6 7 6 3 3 3 4 3 4 4 4 3 3 1

对于2 4 6 7,总和为19>sigma,因此分成:

2 4 6     7 6 3 3 3 4 3 4 4 4 3 3 1

2 4 6 7     6 3 3 3 4 3 4 4 4 3 3 1

然后我们使用P = 4-1sum = 60-12以及sum = 60-19分别处理7 6 3 3 3 4 3 4 4 4 3 3 16 3 3 3 4 3 4 4 4 3 3 1

我认为这将导致O(P*n)的结果。

当1或2个值远大于其他值时,可能会出现问题,但是对于任何大于等于sigma的值,我们可能只需将其放入自己的分区中(预处理数组以找到这些值可能是最好的想法,并相应地减少总和)。

如果它起作用,它应该最小化平方误差和(或接近所需的度量)。


0

你的问题与最小完成时间调度问题非常相似,或者说完全相同,这取决于你如何定义你的目标。如果你想要最小化|sum_i - sigma|的最大值,那么它就是那个问题。

正如维基百科文章所引用的,对于p > 2,此问题是NP完备的。 Graham的列表调度算法p <= 3时是最优的,并且提供了一个近似比2-1/p。你可以查看维基百科文章了解其他算法及其近似度。

此页面上给出的所有算法都是要么解决不同的目标问题,要么错误/次优,要么可以用来解决任何NP问题 :)


尽管 OP 没有明确说明,但所需的结果是原始数组的一组分割。元素不能被重新排序。这个限制使问题更容易处理。 - Hilton Campbell

0

这与一维装箱问题非常相似,参见http://www.cs.sunysb.edu/~algorith/files/bin-packing.shtml。在相关书籍The Algorithm Design Manual中,Skienna建议采用首次递减适配法。即确定您的箱子大小(平均值=总和/ N),然后将剩余最大的对象分配到第一个有空间的箱子中。您要么达到了开始过度填充箱子的点,要么幸运地得到了完美的匹配。正如Skiena所说:“首次递减适配法具有直观吸引力,因为我们首先打包笨重的物体,希望小物体可以填补裂缝。”

正如之前的发帖者所说,该问题看起来是NP完全问题,因此您无法在合理的时间内完美解决它,需要寻找启发式方法。


0

我最近需要这个功能,实现方法如下:

  1. 创建一个给定子数组数量长度的初始子数组数组。子数组也应该有一个sum属性。例如:[[sum:0],[sum:0]...[sum:0]]
  2. 将主数组按降序排序。
  3. 查找具有最小总和的子数组,并插入一个来自主数组的项目,并通过插入项的值增加子数组的总和属性。
  4. 重复第3步,直到达到主数组的末尾。
  5. 返回initial数组。

以下是JS代码:

function groupTasks(tasks,groupCount){
  var  sum = tasks.reduce((p,c) => p+c),
   initial = [...Array(groupCount)].map(sa => (sa = [], sa.sum = 0, sa));
  return tasks.sort((a,b) => b-a)
              .reduce((groups,task) => { var group = groups.reduce((p,c) => p.sum < c.sum ? p : c);
                                         group.push(task);
                                         group.sum += task;
                                         return groups;
                                       },initial);
}

var tasks = [...Array(50)].map(_ => ~~(Math.random()*10)+1), // create an array of 100 random elements among 1 to 10
   result = groupTasks(tasks,7);                             // distribute them into 10 sub arrays with closest sums

console.log("input array:", JSON.stringify(tasks));
console.log(result.map(r=> [JSON.stringify(r),"sum: " + r.sum]));


-1

你可以使用最大流算法。


2
提供该算法的链接或解释将是礼貌的。 - Anders R. Bystrup
我非常怀疑这一点,因为它似乎是NP完全问题。 - Andrew Mao

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