子集和算法

55
我正在解决这个问题:
子集和问题(Subset Sum problem)的输入为一个由 n 个整数组成的集合 X = {x1, x2 ,…, xn} 和另外一个整数 K。问题是检查是否存在 X 的子集 X',使得 X' 中的元素之和等于 K,并找到子集 X'。例如,如果 X = {5, 3, 11, 8, 2},K = 16,则答案是 YES,因为子集 X' = {5, 11} 的和为 16。请实现一个时间复杂度至少为 O(nK) 的子集和算法。注意复杂度 O(nK),我认为动态规划可能有帮助。
我已经找到了一个指数时间算法,但它没有帮助。请问是否有人可以帮助我解决这个问题?

我想这个练习要求一个时间复杂度最多为O(nK)的算法。 - Alan Evangelista
12个回答

74

子集和问题是我在麦卡莱斯特学到的第一个NP完全问题。这个问题已经被查看了36000多次,但我没有看到一个足够详细解释算法逻辑的答案。因此,我想尝试做到这一点。

假设:

为了简单起见,我首先假设输入集合X仅包含正整数,k为正数。然而,我们可以调整算法以处理负整数和k为负数的情况。

逻辑:

这个算法或者说任何DP问题的关键是将问题分解并从基本情况开始简单地开始。然后,我们可以使用我们所知道的一些知识来构建基本情况:

  1. 我们知道如果集合X为空,则无法将任何值的k相加。
  2. 如果集合X包含k,则它有一个子集相加为k
  3. 我们知道如果集合x1的子集之一是X的子集且和为k1,则X将有一个子集和为k1,即x1
  4. 我们有一个集合X = {x1, x1, x3, ......., xn, xn+1}。如果x1 = {x1, x1, x3, ......., xn}中有一个子集的和为k-k1,则我们知道它有一个子集和为k1

示例以说明1,2,3,4:

  1. 如果你有一个空集 {},那么你就没有子集,因此你也没有任何子集和。
  2. 一个集合 X = {4} 有一个子集和为 4,因为 4 本身就是该集合的一部分

  3. 假设你有一个集合 x1 = {1,3,5} 是集合 X = {1,3,5,2,8} 的子集。如果 x1 有一个子集和为 k1 = 8,那么这意味着 X 也有一个子集和为 8,因为 x1X 的子集

  4. 假设你有一个集合 X = {1,3,5,2,19},我们想知道它是否有一个子集和为 20。它有,并且可以通过以下方式确定: x1 = {1,3,5,2} 可以求和 (20 - 19) = 1。由于 x1 有一个子集和为 1,因此当我们将 19 添加到集合 x1 中时,我们可以取得新数字 1 + 19 = 20 来创建我们所需的总和 20。

动态构建矩阵 太棒了!现在让我们利用上述四个逻辑从基本情况开始构建。我们将构建一个矩阵 m。我们定义:

  • 矩阵mi+1行和k+1列。

  • 矩阵的每个单元格都有值truefalse

  • m[i][s]返回truefalse,以指示对于这个问题的答案:"使用数组中的前i个项目,我们是否可以找到一个子集和为s?"m[i][s]对于是返回true,否则返回false

(请注意,维基百科的答案或大多数人都会构建一个函数m(i,s),但我认为矩阵是理解动态编程的简单方法。当我们在集合或数组中只有正数时,它很有效。然而,函数路线更好,因为你不必处理索引超出范围,匹配数组的索引和总和到矩阵.....)

让我们用一个例子来构建矩阵:

X = {1,3,5,2,8}
k = 9

我们将逐行构建矩阵。最终,我们想知道单元格m[n][k]是否包含true或false。
第一行: 逻辑1告诉我们,矩阵的第一行应全部为false。
   0 1 2 3 4 5 6 7 8 9
   _ _ _ _ _ _ _ _ _ _
0| F F F F F F F F F F
1|
2|
3|
4|
5|

第二行及以上: 然后对于第二行或以上,我们可以使用2、3、4逻辑来帮助我们填充矩阵。

  • 逻辑2告诉我们m[i][s] = (X[i-1] == s)记住m[i]是指X中的第i个项目,即X[i-1]
  • 逻辑3告诉我们m[i][s] = (m[i-1][s])这是查看直接上方的单元格。
  • 逻辑4告诉我们m[i][s] = (m[i-1][s - X[i-1]])这是查看X[i-1]单元格上方和左侧的行。

如果任何一个逻辑为true,那么m[i][s]就是true,否则就是false。因此,我们可以将2、3、4重写为m[i][s] = (m[i-1][s] || a[i-1] == s || m[i-1][s - a[i-1]])

使用以上逻辑填充矩阵m。在我们的示例中,它看起来像这样。

   0 1 2 3 4 5 6 7 8 9
   _ _ _ _ _ _ _ _ _ _
0| F F F F F F F F F F
1| F T F F F F F F F F
2| F T F T T F F F F F 
3| F T F T T T T F T T
4| F T T T T T T T T T 
5| F T T T T T T T T T

现在使用矩阵来回答你的问题:

看一下原始问题m[5][9]。使用前5个项目(即所有项目),我们可以找到一个子集和为9(k)吗?答案由该单元格指示,即true

以下是代码:

import java.util.*;

public class SubSetSum {

    public static boolean subSetSum(int[] a, int k){

        if(a == null){
            return false;
        }

        //n items in the list
        int n = a.length; 
        //create matrix m
        boolean[][] m = new boolean[n + 1][k + 1]; //n + 1 to include 0, k + 1 to include 0 

        //set first row of matrix to false. This also prevent array index out of bounds: -1
        for(int s = 0; s <= k; s++){
            m[0][s] = false;
        }

        //populate matrix m
        for(int i = 1; i <= n; i++){
            for(int s = 0; s <= k; s++){    
                if(s - a[i-1] >= 0){ //when it goes left we don't want it to go out of bounds. (logic 4)
                    m[i][s] = (m[i-1][s] || a[i-1] == s || m[i-1][s - a[i-1]]); 
                } else {
                    m[i][s] = (m[i-1][s] || a[i-1] == s);
                }       

            }
        }

        //print matrix
        print(m);

        return m[n][k];

    }

    private static void print(boolean[][] m){
        for(int i = 0; i < m.length; i++){
            for(int j = 0; j < m[i].length; j++){
                if(m[i][j]){
                    System.out.print("T");
                } else {
                    System.out.print("F");
                }           
            }
            System.out.print("\n");
        }
    }

    public static void main(String[] args){
        int[] array = {1,3,5,2,8};
        int k = 9;

        System.out.println(subSetSum(array,k));

    }
}

构建矩阵的复杂度为O((n+1)(k+1)),即O(nk)。看起来应该是多项式的,但实际上它是伪多项式的。在这里了解更多信息。
再次说明,这仅适用于输入只包含正数的情况。您可以轻松地将其调整为使用负数的情况。矩阵仍将具有n+1行,但是B-A+1列。其中B是上限,A是下限(+1包括零)。矩阵仍将是的。您必须使用下限偏移s
通过文本从头到尾解释DP问题非常困难。但我希望这对那些试图理解此问题的人有所帮助。
请注意,在上面的示例中,DP表的行已排序。这不一定是必要的。
这是一个DP表,用于问题的情况,即给定{5、3、11、8、2}的集合。为简洁起见,我省略了错误值。
┌─────────┬──────┬──────┬──────┬──────┬──────┬──────┬──────┬──────┬──────┬──────┬──────┬──────┐
│ (index) │  023578101113141516  │
├─────────┼──────┼──────┼──────┼──────┼──────┼──────┼──────┼──────┼──────┼──────┼──────┼──────┤
│    0true │      │      │      │      │      │      │      │      │      │      │      │
│    5true │      │      │ true │      │      │      │      │      │      │      │      │
│    3true │      │ truetrue │      │ true │      │      │      │      │      │      │
│    11true │      │ truetrue │      │ true │      │ true │      │ true │      │ true │
│    8true │      │ truetrue │      │ true │      │ truetruetrue │      │ true │
│    2truetruetruetruetruetruetruetruetruetruetruetrue │
└─────────┴──────┴──────┴──────┴──────┴──────┴──────┴──────┴──────┴──────┴──────┴──────┴──────┘

以下是JavaScript中的实现,将输出目标集{5, 11}:

var subSetSum = function(input, sum) {

    let y = input.length;
    let x = sum;

    if(input.length === 0) return 0;

    let d = [];

    //fill the rows
    for (let i = 0; i <= y; i++) {
      d[i] = [];
      d[i][0] = true;
    }
    
    for (let j = 1; j <= y; j++) { //j row
      for (let i = 1; i <= x; i++) { //i column
      let num = input[j-1];
        if(num === i) {
          d[j][i] = true;
        } else if(d[j-1][i]) {
          d[j][i] = true;
        } else if (d[j-1][i-num]) {
          d[j][i] = true;
        }
      }
    }
    
    //console.table(d); //uncomment to see the table
    if(!d[y][x]) return null;

    let searchedSet = [];
    for(let j=input.length, i=sum; j>0 && i != 0; j--) {
      if(input[j-1] !== i) {
        while(d[j-1][i]) { // go up
          j--;
        }
      }
      searchedSet.push(input[j-1]);
      i = i-input[j-1];
    }

    return searchedSet;
};

console.log('searched set:'+ JSON.stringify(subSetSum([5, 3, 11, 8, 2], 16)));


非常棒的解释,非常感谢。 - Kishy Nivas
这是我找到的最好的解释,逻辑是正确的,但我认为你制作的矩阵是错误的。看看 s = 2,x = {1,2,3}。{1,2,3} 确实包含子集和为 2,尽管矩阵表明它不包含。 - JellyKid
1
@ThatMarc 这个集合中没有任何子集的和为11。 - Ivan Hristov
1
如果在a数组中的大元素上,s - a[i-1]变为负数,程序将抛出异常,您需要处理它。 - anonymous38653
非常清晰的解释!非常感谢你!:D - Sreekiran A R
显示剩余2条评论

20

由于看起来您所有的数字都是正数,因此可以使用动态规划解决此问题:

首先创建一个大小为K+1的布尔数组possible,其中第一个值为true,其余为false。第i个值将表示是否可能实现i的子集和。对于集合中的每个数字n,请循环遍历possible数组,如果第i个值为true,则将第i+n个值也设置为true。

最后,如果possible中的第k个值为true,则可以形成k的子集和。该问题的解决时间为O(NK)。

维基百科关于子集和问题的页面详细解释了应用于不保证为正整数的整数集合的算法。


3
“i + n” 是否可能大于 “K + 1”? - MLister

10
我建议阅读Wiki的算法。该算法在那里存在,参见伪多项式时间动态规划解决方案以获取O(P*n)解决方案。该解决方案不是多项式时间,对(p,n)是多项式的,但对n+log P(输入大小)不是多项式的,因为P可能非常大,如2^n,所以P*n = (2^n)*n解决方案通常不是多项式时间解决方案,但当p受到n的某些多项式函数的限制时,它是多项式时间算法。
这个问题是NPC问题,但有一个伪多项式时间算法,属于弱NP完全问题。还有强NP完全问题,这意味着,除非P=NP,否则您无法为它们找到任何伪多项式时间算法,而此问题不在这些问题范围内,因此有点容易。
我尽可能地简单表述了这个问题,但这并不是“强NP完全问题”或“弱NP完全问题”的确切定义。
详见Garey and Johnson第4章。

6
看来我来晚了,这是我的两分钱。我们将创建一个boolean[] solution[n+1][k+1],其中solution[i][j]true,如果使用前i个项目(索引0i-1),我们可以从集合中获得总和j;否则为false。最后,我们将返回solution[k][n]
我们可以推出以下几点:
  1. 如果总和为零,则对于任何元素数量始终存在可能的答案(空集)。所以全部为真。
  2. 如果集合为空,则我们不能有任何子集,因此无法获得任何K。所以永远不是可能的答案。全部为假。
  3. 如果子集X1(X的最后一个元素之外的子集)具有k的子集和,则X也具有它,即X1。例如,对于X1 = {1,3,5}和k = 8,如果X1有一个子集和,则X = {1,3,5,7}也有一个子集和
  4. 对于输入集X = {1,3,5,7,19}和k = 20,如果X想知道是否存在子集和为20的可能性,则只需查看x1 = {1,3,5,7}是否可以拥有一个子集和为20-19,即1。仅在k>= 19即X的最后一个元素时适用。
基于上述要点,我们可以轻松编写以下算法。
public class SubSetSum {
    boolean[][] solution; 
    int[] input;
    int k;

    public SubSetSum(int[] input, int targetSum) {
        this.input = input;
        this.k = targetSum;
        this.solution = new boolean[input.length+1][k+1];
    }

    public boolean subsetSum() {
        int n = input.length;

        for (int i = 0; i <= n; i++) {     //case 1
            solution[i][0] = true;
        }

        for (int j = 0; j <= k; j++) {    // case 2
            solution[0][j] = false;
        }

        for (int i = 1; i <= n; i++) {                  // n times
            for (int j = 1; j <= k; j++) {              // k times and time complexity O(n*k)
                if(solution[i-1][j]) {
                    solution[i][j] = solution[i-1][j];      // case 3
                    continue;
                }
                if(j >= input[i-1])  {                       // case 4
                    solution[i][j] = solution[i-1][j-input[i-1]];
                }
            }
        }
        return solution[n][k];
    }
}

做了一个简单的测试,结果不起作用:Subset = {2, 3, 5, 10, 20}; Sum = 11; 结果为false。我认为这是因为在这个例子中,子集中的值应该被多次使用。是否有可能修改这个例子来适应这种情况?谢谢! - That Marc

4

在一般情况下,没有已知的子集和算法可以在少于O(2^(n/2))的时间内运行。

这里所说的“子集和”是指给定一个集合和一个目标值,判断该集合中是否存在一个子集的元素之和等于目标值。

13
有一个符合提问者所需的复杂度的解决方案,因此你的回答实际上并没有帮助,也不相关。 - IVlad
3
有点严厉,因为@DeadMG在技术上是正确的。OP没有说明整数集始终为正数,而我的回答假设了这一点。 - moinudin
@ivlad 实际上,它将需要可能[i + 负数之和]。并且这个总和取决于值的大小,因此它改变了解决方案的顺序为O(N(K+N.max_num))。 - moinudin
@DeadMG,子集和问题是伪多项式 NP 问题,有一个 O(nK) 的解决方案(参见维基百科)。所有解决此问题的算法的顺序都与 nk 相关,而不仅仅是 n。如果 log(k)>>2^n,那么数字 k 的编码比你所说的答案更复杂。 - Saeed Amiri
@Saeed:不,你去看维基百科。 “这个解决方案在复杂性理论中不被视为多项式时间,因为P-N不是问题大小的多项式,而问题大小是用于表示它的位数。该算法在N和P的值上是多项式的,这些值在其位数上是指数级的。” 哎呀。 @IVlad:子集和是一个NP完全问题。如果您有一种通用的多项式时间解决方案,那么恭喜您,您解决了P = NP。 - Puppy
显示剩余9条评论

4
void subsetSum (int arr[], int size, int target) {
  int i, j ;
  int **table ;
  table = (int **) malloc (sizeof(int*) * (size+1)) ;
  for ( i = 0 ; i <= size ; i ++ ) {
    table[i] = (int *) malloc (sizeof(int) * (target+1)) ;
    table[i][0] = 1 ;
  }
  for ( j = 1 ; j <= target ; j ++ )
    table[0][j] = 0 ;
  for ( i = 1 ; i <= size ; i ++ ) {
    for ( j = 1 ; j <= target ; j ++ )
      table[i][j] = table[i-1][j] || (arr[i-1] <= j && table[i-1][j-arr[i-1]] ) ;
  } 
  if ( table[size][target] == 1 )
    printf ( "\ntarget sum found\n" ) ; 
  else printf ( "\nTarget sum do not found!\n" ) ;
  free (table) ;
}

6
可以提供一个解释吗? - Austin Henley
假设当且仅当存在元素A[1...i]的子集之和为j时,S[i,j]被定义为真。那么S[n,T]就是我们问题的解决方案。 一般来说: S[i,j] = S[i-1, j-A[i]]∨S[i-1,j] 初始条件为S[i,0]=True,而S[0,j]=False(对于j>0)。 - Psycho
1
由于您在table[i]中仅使用table[i-1]中的值来计算值,因此可以通过使其外部维度仅为2而不是size,并使用i%2而不是i进行索引来节省空间。 即,在每次外部迭代中交换“当前”数组。 - j_random_hacker

1
以上答案都很好,但并不能给出如何处理正负数的最广泛概述。给定一组有序整数,定义两个变量X和Y,使得 X = 负数之和,Y = 正数之和,并按照以下顺序应用这些规则来操作您的初始集合,就像您正在通过二叉树进行递归一样:
  1. 如果最右边的元素等于您要检查的总和,则返回true
  2. 如果向左递归不会留下空集,请向左递归,并从排序后的数组中删除最右边的元素
  3. 如果您的集合中只剩下一个元素且它不是总和,则返回false
  4. 而不是向右递归,请检查q中所有元素的总和,如果 X <= B <= Y,则返回true,否则返回false
  5. 如果左子树或右“递归”返回true,则返回true到父节点
上面的答案更为详细和准确,但要想对此有一个非常广泛的理解,请画一个二叉树。这意味着运行时间的长度是多少?

1

让M为所有元素的总和。 请注意,K≤M。

let m be a Boolean array [0...M]
set all elements of m to be False
m[0]=1
for all numbers in the set let a[i] be the ith number
    for j = M to a[i]
        m[j] = m[j] | m[j-a[i]];

然后简单地测试 m[k]。

对于初始值,将 m[0] 标记为 true 是正确的,但如果 x 在数组 [0....M] 中,则还应将 m[x] 标记为 true。 - OLIVER.KOO

1
递归解决方案,时间复杂度为n^2。
public void solveSubsetSum(){
    int set[] = {2,6,6,4,5};
            int sum = 9;
            int n = set.length;

            // check for each element if it is a part of subset whose sum is equal to given sum
            for (int i=0; i<n;i++){
                if (isSubsetSum(set, sum, i, n)){
                    Log.d("isSubset:", "true") ;
                    break;
                }
                else{
                    Log.d("isSubset:", "false") ;
                }
                k=0; // to print time complexity pattern
            }
        }

private boolean isSubsetSum(int[] set, int sum, int i, int n) {

            for (int l=0;l<k; l++){
            System.out.print("*"); 
            // to print no of time is subset call for each element
        }
        k++;
        System.out.println();     
        if (sum == 0){
            return true;
        }

        if (i>=n){
            return false;
        }

        if (set[i] <= sum){ 
        // current element is less than required sum then we have to check if rest of the elements make a subset such that its sum is equal to the left sum(sum-current element)
            return isSubsetSum(set, sum-set[i], ++i, n);
        }
        else { //if current element is greater than required sum
            return isSubsetSum(set, sum, ++i, n);
        }
   }

最坏情况时间复杂度:O(n^2)

最好情况时间复杂度:O(n),即第一个元素就可以组成和为给定值的子集。

如果我在计算时间复杂度方面出现了错误,请纠正我。


1
function subsetsum(a, n) {
    var r = [];
    for (var i = parseInt(a.map(function() { return 1 }).join(''), 2); i; i--) {
        var b = i.toString(2).split('').reverse().map(function(v, i) {
            return Number(v) * a[i]
        }).filter(Boolean);
        if (eval(b.join('+')) == n) r.push(b);
    }
    return r;
}

var a = [5, 3, 11, 8, 2];
var n = 16;
console.log(subsetsum(a, n)); // -> [[3, 11, 2], [5, 3, 8], [5, 11]]

暴力破解——不要排序,尝试每种组合,评估解析器胜过Array.reduce(它也适用于负数)。

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