【问题标题】:Subset Sum algorithm子集和算法
【发布时间】:2024-01-11 08:24:02
【问题描述】:

我正在解决这个问题:

子集和问题将n 整数的集合X = {x1, x2 ,…, xn} 和另一个整数K 作为输入。问题是检查是否存在 X 的子集 X' 其元素总和为 K 并找到子集(如果有)。例如,如果X = {5, 3, 11, 8, 2}K = 16 则答案为YES,因为子集X' = {5, 11} 的总和为16。为运行时间至少为O(nK) 的子集和实现算法。

注意复杂性O(nK)。我认为动态编程可能会有所帮助。

我找到了一个指数时间算法,但是没有用。

有人可以帮我解决这个问题吗?

【问题讨论】:

  • 我想这个练习需要一个时间复杂度最多 O(nK)的算法。

标签: algorithm dynamic-programming subset-sum


【解决方案1】:

子集和是我在 Macalester 学到的第一个 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} 的一个子集。如果x1k1 = 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] 返回 true 或 false 以指示此问题的答案:“使用数组中的第一个 i 项我们可以找到 s 的子集和吗?” @987654358 @return true 表示是,false 表示否

(请注意*的答案或大多数人构建函数 m(i,s) 但我认为矩阵是理解动态编程的一种简单方法。当我们在集合或数组中只有正数时它很有效. 但是函数路由更好,因为您不必处理超出范围的索引,匹配数组的索引并求和到矩阵......)

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

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

我们将逐行构建矩阵。我们最终想知道单元格 m[n][k] 包含truefalse

第一行: 逻辑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)rememebr 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));

    }
}

构建矩阵m 需要 O((n+1)(k+1)),即 O(nk)。看起来它应该是多项式的,但事实并非如此!它实际上是伪多项式。阅读它here

同样,这仅在输入仅包含正数时才有效。您可以轻松调整它以使用负数。矩阵仍然有 n+1 行,但有B - A + 1 列。其中B 是上限,A 是下限(+1 以包括零)。矩阵仍然是您必须将s 与下限偏移。

用文本从头到尾解释 DP 问题是相当困难的。但我希望这对那些试图理解这个问题的人有所帮助。

请注意,在上面的示例中,DP 表的行已排序。不必如此。

这里是问题案例的 DP 表,即给定一组 {5, 3, 11, 8, 2}。为简洁起见,我省略了错误值。

┌─────────┬──────┬──────┬──────┬──────┬──────┬──────┬──────┬──────┬──────┬──────┬──────┬──────┐
│ (index) │  0   │  2   │  3   │  5   │  7   │  8   │  10  │  11  │  13  │  14  │  15  │  16  │
├─────────┼──────┼──────┼──────┼──────┼──────┼──────┼──────┼──────┼──────┼──────┼──────┼──────┤
│    0    │ true │      │      │      │      │      │      │      │      │      │      │      │
│    5    │ true │      │      │ true │      │      │      │      │      │      │      │      │
│    3    │ true │      │ true │ true │      │ true │      │      │      │      │      │      │
│    11   │ true │      │ true │ true │      │ true │      │ true │      │ true │      │ true │
│    8    │ true │      │ true │ true │      │ true │      │ true │ true │ true │      │ true │
│    2    │ true │ true │ true │ true │ true │ true │ true │ true │ true │ true │ true │ true │
└─────────┴──────┴──────┴──────┴──────┴──────┴──────┴──────┴──────┴──────┴──────┴──────┴──────┘

下面是 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)));

【讨论】:

  • 很好的解释,我从没想过如何处理 -ve 值,这就是我要寻找的地方。
  • 惊人的解释,非常感谢。
  • 这是我为这个问题找到的最好的解释。逻辑是正确的,但我认为您制作的矩阵是错误的。看 s = 2,x = {1,2,3}。 {1,2,3} 确实包含 2 的子集和,尽管矩阵表明它没有。
  • 我不明白是我做错了什么还是这实际上不能正常工作:我正在尝试一组 {2, 3, 5, 10, 20} 并查看 sum 是否可以是 11。它返回 false。关于出了什么问题的任何提示?我认为这是因为需要重用子集值才能达到总和。如何调整它以使其工作?谢谢!
  • 如果s - a[i-1]a 数组中的大元素变为负数,程序将抛出您应该处理的异常。
【解决方案2】:

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

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

最后,如果possible中的第k个值为真,那么可以形成k的子集和。问题在 O(NK) 时间内解决。

Wikipedia's page on the subset sum problem 详细解释了该算法应用于不保证为正的整数集。

【讨论】:

  • i + n 是否可能大于K + 1
【解决方案3】:

我建议阅读Wiki 的算法。该算法存在于那里,请参阅 Pseudo-polynomial time dynamic programming solution 了解O(P*n) 解决方案,该解决方案不是多项式时间,是 (p,n) 中的多项式,但它不是 n+log 中的多项式P(输入的大小)并且因为P 可以像 2^n 一样非常大,所以解 P*n = (2^n)*n 通常不是多项式时间解,但是当 p 受某个多项式限制时n的函数是多项式时间算法。

这个问题是NPC,但是有一个Pseudo polynomial time算法,属于weakly NP-Complete问题,还有Strongly NP-Complete问题,也就是说,你找不到任何pseudo polynomial time算法给他们除非P=NP,而且这个问题不在这个问题范围内,所以不知何故很容易。

我说得尽可能简单,但这并不是强 NP 完全问题或弱 NP 完全问题的准确定义。

详情见Garey and Johnson第4章。

【讨论】:

    【解决方案4】:

    看来我迟到了,这是我的两分钱。我们将创建一个boolean[] solution[n+1][k+1] 使得solution[i][j]true 如果使用第一个i 项目(索引0i-1),我们可以从集合中得到总和j;否则false。我们最终会返回solution[k][n]

    我们可以推断出以下几点:

    1. 如果总和为零,则对于任意数量的元素始终是一个可能的答案(空集)。所以都是真的。
    2. 如果 set 为空,我们就不能有任何子集,因此无法获得任何 K。所以永远不会有可能的答案。都是假的。
    3. 如果子集 X1(X 的子集没有 X 中的最后一个元素)具有 k 的子集和,则 X 也具有它,即 X1。例如。对于 X1={1,3,5} 和 k=8,如果 X1 有一个子集和,那么 X={1,3,5,7} 也有一个子集和
    4. 对于 i/p 集合 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];
        }
    }
    

    【讨论】:

    • 做一个简单的测试这不起作用:子集 = {2, 3, 5, 10, 20};总和 = 11;结果是假的。我认为这是因为对于此示例,子集中的值应多次使用。是否可以修改此示例以适用于该案例?谢谢!
    【解决方案5】:

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

    【讨论】:

    • 这可能不是一般情况。看我的回答。
    • -1:有一个运行在 OP 想要的复杂性中,所以你的答案真的没有帮助,也无关紧要。
    • @ivlad 有点苛刻,因为@DeadMG 在技术上是正确的。 OP 没有说明我的回答假设的整数集总是正数。
    • @IVlad:OP 没有明确说明任何限制,所以我该怎么办,但假设他想要一个一般情况的解决方案?
    • @marcog - 他们并不一定是积极的。例如,如果范围是[-t, t],则可以使用数组possible[i + t] = true if we can obtain sum i and false otherwise。也可以使用哈希表。
    【解决方案6】:
    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) ;
    }
    

    【讨论】:

    • 请您解释一下...好吗?
    • 如果存在元素 A[1 的子集,则将 S[i, j] 定义为真。 . . 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。
    • 由于您仅使用 table[i-1] 中的值计算 table[i] 中的值,因此您可以通过将其外部尺寸设为 2 而不是 size 并使用 i % 2 而不是索引来节省空间i。 IE。每次外部迭代交换“当前”数组。
    【解决方案7】:

    令 M 为所有元素的总和。 注意 K

    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
    【解决方案8】:
    boolean hasSubset(int arr[],int remSum,int lastElem){
        if(remSum==0) return true;
        else if(remSum!=0 && lastElem<0) return false;
    
        if(arr[lastElem]>remSum) return hasSubset(arr, remSum, lastElem-1);
        else return (hasSubset(arr, remSum, lastElem-1) ||hasSubset(arr, remSum-arr[lastElem], lastElem-1));
    }
    

    考虑第 i 个元素。它要么会为子集总和做出贡献,要么不会。如果它对总和有贡献,则“总和的值”会减少等于第 i 个元素的值。如果它没有贡献,那么我们需要在剩余元素中搜索“sum的值”。

    【讨论】:

      【解决方案9】:

      上述答案都很好,但并没有真正给出这样的东西如何适用于正数和负数的最广泛的概述。

      给定一组有序整数,定义两个变量 X 和 Y 使得

      X = 负数之和

      Y = 正元素的总和

      并通过按此顺序应用这些规则,对您的初始集合进行操作,就像您在二叉树中递归一样

      1. 如果最右边的元素等于您要检查的总和 为返回真
      2. 如果这样做不会留下空,则向左递归 设置,从排序数组中删除最右边的元素
      3. 如果你的集合中只剩下一个元素并且它不是总和,则返回 false
      4. 不是递归右,而是检查所有元素的总和 数组 q,如果 X
      5. 如果左子树或右“递归”返回 true,则返回 true 给父节点

      上面的答案更详细和准确,但是对于应该如何发挥作用的非常广泛的看法,请绘制二叉树。这个长度对运行时有何影响?

      【讨论】:

        【解决方案10】:
        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]]
        

        蛮力——忘记排序,尝试每个组合,eval 解析器胜过 Array.reduce(它也适用于负数)。

        【讨论】:

          【解决方案11】:

          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) 即;如果第一个元素构成一个子集,其总和等于给定总和。

          如果我在这里计算时间复杂度有误,请纠正我。

          【讨论】:

            【解决方案12】:

            具有一维数组的DP解决方案(DP数组处理顺序在这里很重要)。

            bool subsetsum_dp(vector<int>& v, int sum)
            {
                int n = v.size();
                const int MAX_ELEMENT = 100;
                const int MAX_ELEMENT_VALUE = 1000;
                static int dp[MAX_ELEMENT*MAX_ELEMENT_VALUE + 1]; memset(dp, 0, sizeof(dp));
            
                dp[0] = 1;
            
                for (int i = 0; i < n; i++)
                {
                    for (int j = MAX_ELEMENT*MAX_ELEMENT_VALUE; j >= 0; j--)
                    {
                        if (j - v[i] < 0) continue;
                        if (dp[j - v[i]]) dp[j] = 1; 
                    }
                }
            
                return dp[sum] ? true : false;
            }
            

            【讨论】: