高效算法寻找所有极大子集

30

我有一组独特的集合(表示为位掩码),希望消除所有是其他元素的真子集的元素。例如:

input = [{1, 2, 3}, {1, 2}, {2, 3}, {2, 4}, {}]
output = [{1, 2, 3}, {2, 4}]

我无法找到这个问题的标准算法,甚至连这个问题的名称也不知道,所以我将其称为“最大子集”(因为没有别的可用名称)。以下是一个O(n^2)算法(使用Python来具体说明),假设is_subset_func的时间复杂度为O(1):1

def eliminate_subsets(a, cardinality_func, is_subset_func):
    out = []
    for element in sorted(a, reverse=True, key=cardinality_func):
        for existing in out:
            if is_subset_func(element, existing):
                break
        else:
            out.append(element)
    return out

有没有更高效的算法,希望是O(n log n)或更好的复杂度?


1 对于常数大小的位掩码,就像在我的情况下一样,is_subset_func 就是 element & existing == element,它可以在常数时间内运行。


1
如果元素的范围是有限的,您可以通过将集合表示为位掩码(x是y的子集当且仅当y == x|y)来获得大的常数因子加速。此外,请注意,除非“is_subset_func”是O(1),否则您的函数不是O(n^2),这似乎不太可能。 - Nemo
5
你可以查看类似问题的答案:https://dev59.com/3mw15IYBdhLWcg3wmM19 - Peter de Rivaz
1
感谢@PeterdeRivaz和Tinctorious。这看起来是一个不错的解决方案,尽管*更加复杂。我会尝试弄清楚如何编写代码。如果你们中任何人想要发布一些伪代码或Python代码作为答案,如果没有其他更好的答案,我将很乐意接受它。 - Mark Lodato
我认为你无法做到低于O(n^2),因为在最坏情况下,解的长度本身就可以达到O(n^2)。但是你可以通过应用下面提到的启发式方法来降低常数权重。 - ElKamina
1
@ElKamina,我使用n作为集合的数量,而不是所有集合中元素的总数,因此解决方案的长度仅为O(n)。 - Mark Lodato
显示剩余8条评论
4个回答

17

假设您对所有输入集进行标记。

A={1, 2, 3}, B={1, 2}, C={2, 3}, D={2, 4}, E={}

现在建立中间集合,每个集合对应于宇宙中的一个元素,其中包含其出现的集合的标签:

1={A,B}
2={A,B,C,D}
3={A,C}
4={D}

现在,对于每个输入集合,计算其元素标签集的交集:

For A, {A,B} intesect {A,B,C,D} intersect {A,C} = {A}   (*)

如果交集中包含除了集合本身的标签,那么它是该集合的子集。这里没有其他元素,所以答案是否定的。但是,

For C, {A,B,C,D} intersect {A,C} = {A,C}, which means that it's a subset of A.
这种方法的成本取决于集合的实现方式。假设为位图(如您所示)。 假设有n个最大大小为m的输入集和宇宙中的| U |项。 然后,中间集合构造会生成| U |个大小为n位的集合,因此初始化它们需要O(| U | n)的时间。 设置这些位需要O(nm)的时间。 如上所述,在(*)处计算每个交集需要O(mn); 所有内容均为O(mn ^ 2)。
将所有这些组合在一起,我们有O(| U | n)+ O(nm)+ O(mn ^ 2)= O(| U | n + mn ^ 2)。使用相同的约定,“all pairs”算法的复杂度为O(|U| ^ 2 n ^ 2)。由于m <= |U|,因此该算法在渐近意义下更快。在实践中也可能更快,因为没有繁琐的簿记来添加常数因子。 附加:在线版本 OP问是否有此算法的在线版本,即可以在逐个到达输入集时增量地维护最大集合的集合。答案似乎是肯定的。中间集合告诉我们新集合是否是已经看到的某个集合的子集。但如何快速确定它是否是一个超集?如果是,又是哪些现有的最大集合?因为在这种情况下,这些最大集合不再是最大的,并且必须被新集合替换。
关键是注意到A是B的超集,当且仅当A'是B'的子集(倾斜符号表示集合补)。
按照这个灵感,我们像以前一样维护中间集。当一个新的输入集S到达时,请执行与上述描述相同的测试:让I(e)是输入元素e的中间集。然后进行此测试
For X = \intersect_{e \in S} . I(e), |X| > 0

(在这种情况下,它大于1而不是像上面那样大于0,因为S还没有出现在I中。)如果测试成功,则新集合是现有最大集合的(可能是不适当的)子集,因此可以丢弃它。

否则,我们必须将S添加为一个新的最大集合,但在执行此操作之前,请计算:

Y = \intersect_{e \in S'} . I'(e) = ( \union_{e \in S'} . I(e) )'

再次提醒,这里的撇号表示补集。并集形式可能更快计算。Y包含已被S取代的极大集合。它们必须从最大集合和I中删除。最后将S作为一个极大集合添加,并使用S的元素更新I.

让我们通过例子来理解。当A到达时,我们将其添加到I中:

1={A}  2={A}  3={A}

B 到达时,我们发现 X = {A} intersect {A} = {A},因此将 B 丢掉并继续。 对于 C,也是同样的情况。 当 D 到达时,我们发现 X = {A} intersect {} = {},因此继续执行 Y = I'(1) intersect I'(3) = {} intersect {}。这正确地告诉我们最大集合 A 不包含在 D 中,因此没有什么可以删除。但必须将其添加为新的最大集合,并且 I 变为

1={A}  2={A,D}  3={A}  4={D}

出现 E 不会引起任何变化。现在假设有一个新集合 F={2, 3, 4, 5},我们发现

X = {A} isect {A,D} isect {A} isect {D} isect {}

所以我们不能抛弃F。继续进行

Y = \intersect_{e in {1}} I'(e) = I'(1) = {D}

这告诉我们DF的一个子集,因此应该被丢弃,同时添加F,剩下的是

1={A} 2={A,F} 3={A,F} 4={F} 5={F}

计算补集在算法的在线性质下既棘手又美妙。输入补集的宇宙只需要包括到目前为止已经出现的输入元素。中间集合的宇宙仅包含当前最大集合中标记的集合。对于许多输入流,这个集合的大小会随着时间稳定或减少。

希望这对您有所帮助。

摘要

这里使用的主要原则是算法设计中经常出现的一个强大思想:反向映射。每当您发现自己正在进行线性搜索以查找具有给定属性的项时,请考虑从该属性构建映射回项。通常情况下,构建此映射的成本很低,并且它可以大大减少搜索时间。最佳示例是变换映射p[i],它告诉您数组在排列后i位置上的元素。如果您需要搜索位于给定位置a的项,您必须在p中进行搜索,这是一个线性时间操作。另一方面,一个逆映射pipi[p[i]] == i不会比p计算时间长(因此它的成本是“隐藏的”),但是pi[a]可以在常数时间内产生所需结果。

原帖作者的实现方式

import collections
import operator
from functools import reduce # only in Python 3

def is_power_of_two(n):
    """Returns True iff n is a power of two.  Assumes n > 0."""
    return (n & (n - 1)) == 0

def eliminate_subsets(sequence_of_sets):
    """Return a list of the elements of `sequence_of_sets`, removing all
    elements that are subsets of other elements.  Assumes that each
    element is a set or frozenset and that no element is repeated."""
    # The code below does not handle the case of a sequence containing
    # only the empty set, so let's just handle all easy cases now.
    if len(sequence_of_sets) <= 1:
        return list(sequence_of_sets)
    # We need an indexable sequence so that we can use a bitmap to
    # represent each set.
    if not isinstance(sequence_of_sets, collections.Sequence):
        sequence_of_sets = list(sequence_of_sets)
    # For each element, construct the list of all sets containing that
    # element.
    sets_containing_element = {}
    for i, s in enumerate(sequence_of_sets):
        for element in s:
            try:
                sets_containing_element[element] |= 1 << i
            except KeyError:
                sets_containing_element[element] = 1 << i
    # For each set, if the intersection of all of the lists in which it is
    # contained has length != 1, this set can be eliminated.
    out = [s for s in sequence_of_sets
           if s and is_power_of_two(reduce(
               operator.and_, (sets_containing_element[x] for x in s)))]
    return out

不错!我编写了这个代码,它可以更快地运行,特别是对于大量的输入。我会等一两天再接受,以防有人发布了更好的算法。 - Mark Lodato
你介意我编辑你的帖子并加入我的Python实现吗? - Mark Lodato
当你说“对于所有这些,我们有O(|U|mn)”时,我认为你的意思是“对于所有这些,我们有O(mn ^ 2)”,因为(如你所说)每个交集需要O(nm)时间 - 但是您需要针对n个输入集合执行此步骤一次,即n次(而不是|U|次)。假设他的“is_subset_func()”实现为已排序集合的O(m)列表合并,则这与OP的解决方案具有完全相同的复杂度。很抱歉要减1分。 - j_random_hacker
@j_random_hacker 很好的发现。我修复了大O计算。但它的复杂度与OP不同。我们仍然领先。 - Gene
1
感谢您的更新和评论,但我认为您对OP的解决方案有误。如果他使用位集,则每次调用is_subset_func()仅需要O(|U|)时间,总共需要O(|U|n^2)时间,而不是O(|U|^2)时间(似乎隐含在您声称的O(|U|^2*n^2)时间中)。如果他使用排序后的整数列表表示而不是位集,则他将获得与您相同的总时间复杂度为O(mn^2)。如果您提到这一点(或者说服我我错了!),我会撤销-1。 - j_random_hacker
显示剩余5条评论

3
这个问题已经在文献中得到了研究。给定S_1,...,S_k是{1,...,n}的子集,Yellin [1]提供了一种算法,可以在O(kdm)时间内找到{S_1,...,S_k}的最大子集,其中d是S_i的平均大小,m是{S_1,...,S_k}的最大子集的基数。后来,Yellin和Jutla [2]针对某些参数范围将其改进为O((kd)^ 2 / sqrt(log(kd)))。人们认为,这个问题的真正次二次算法不存在。
[1] Daniel M. Yellin:Subset Testing and Finding Maximal Sets的算法。 SODA 1992:386-392。
[2] Daniel M. Yellin,Charanjit S. Jutla:Less than Quadratic Time中找到极值集。 Inf. Process. Lett. 48(1):29-34(1993年)。

你应该清理一下你的回答,以获得良好的格式。 - lensovet
请参阅Bayardo&Panda(2011)的快速查找极值集算法。 - Mu-Tsun Tsai

2

我能想到的是一个O(D*N*log(N))的算法,其中D是唯一数字的数量。

递归函数“helper”的工作方式如下: @arguments是集合和域(集合中唯一数字的数量): 基本情况:

  1. 如果域为空,则返回
  2. 如果集合为空或集合长度等于1,则返回

迭代情况:

  1. 从集合中删除所有空集
  2. 选择域中的一个元素D
  3. 从域中删除D
  4. 根据集合是否包含D将集合分成两个集合(set1&set2)
  5. 从集合中的每个集合中删除D
  6. 设置结果= union(helper(set1,domain), helper(set2,domain))
  7. 对于set1中的每个集合,重新添加D
  8. 返回结果

请注意,运行时间取决于所使用的Set实现。如果使用双向链表来存储集合,则:

步骤1-5,7需要O(N) 步骤6的union是通过排序然后合并的O(N*log(N))

因此,整个算法的时间复杂度为O(D*N*log(N))

以下是执行此操作的Java代码:

import java.util.*;

public class MyMain {

    public static Set<Set<Integer>> eliminate_subsets(Set<Set<Integer>> sets) throws Exception {
        Set<Integer> domain = new HashSet<Integer>();
        for (Set<Integer> set : sets) {
            for (Integer i : set) {
                domain.add(i);
            }
        }
        return helper(sets,domain);
    }

    public static Set<Set<Integer>> helper(Set<Set<Integer>> sets, Set<Integer> domain) throws Exception {
        if (domain.isEmpty()) { return sets; }
        if (sets.isEmpty()) { return sets; }
        else if (sets.size() == 1) { return sets; }

        sets.remove(new HashSet<Integer>());

        // Pop some value from domain
        Iterator<Integer> it = domain.iterator();
        Integer splitNum = it.next();
        it.remove();

        Set<Set<Integer>> set1 = new HashSet<Set<Integer>>(); 
        Set<Set<Integer>> set2 = new HashSet<Set<Integer>>();
        for (Set<Integer> set : sets) {
            if (set.contains(splitNum)) {
                set.remove(splitNum);
                set1.add(set);
            }
            else {
                set2.add(set);
            }
        }

        Set<Set<Integer>> ret = helper(set1,domain);
        ret.addAll(helper(set2,domain));

        for (Set<Integer> set : set1) {
            set.add(splitNum);
        }
        return ret;
    }

    /**
     * @param args
     * @throws Exception 
     */
    public static void main(String[] args) throws Exception {
        // TODO Auto-generated method stub
        Set<Set<Integer>> s=new HashSet<Set<Integer>>();
        Set<Integer> tmp = new HashSet<Integer>();
        tmp.add(new Integer(1)); tmp.add(new Integer(2)); tmp.add(new Integer(3));
        s.add(tmp);

        tmp = new HashSet<Integer>();
        tmp.add(new Integer(1)); tmp.add(new Integer(2));
        s.add(tmp);

        tmp = new HashSet<Integer>();
        tmp.add(new Integer(3)); tmp.add(new Integer(4));
        s.add(tmp);
        System.out.println(eliminate_subsets(s).toString());
    }


}

新年是具有破坏性的


好主意。如果D << N,我相信这将比我的算法更有效率。 - Mark Lodato
你仍然需要将列表1中的所有项目与列表2进行比对。尽管如此,这仍然是一个好主意。 - tmyklebu

1

预处理假设:

  • 输入集按长度降序排序
  • 每个集合按值升序排序
  • 可以访问每个集合的总数和长度
  • 方法二 - 使用桶方法

    相同的假设。 可以假定唯一性吗?(即不存在{1,4,6},{1,4,6}) 否则,在创建桶后,您需要在某个时候检查不同之处,可能是一旦创建桶。

    半伪代码

    List<Set> Sets;//input
    List<Set> Output;
    List<List<Set>> Buckets;
    int length = Sets[0].length;//"by descending lengths"
    List<Set> Bucket = new List<Set>();//current bucket
    
    //Place each set with shared length in its own bucket
    for( Set set in Sets )
    {
     if( set.length == length )//current Bucket
     {
      Bucket.add(set);
     }else//new Bucket
     {
      length = set.length;
      Buckets.Add(Bucket);
      Bucket = new Bucket();
      Bucket.Add(set);
     }
    }
    Buckets.add(Bucket);
    
    
    
    //Based on the assumption of uniqueness, everything in the first bucket is
    //larger than every other set and since it is unique, they are not proper subsets
    Output.AddRange(Buckets[0]);
    
    //Iterate through the buckets
    for( int i = 1; i < Buckets.length; i++ )
    {
     List<Set> currentBucket = Buckets[i];
    
     //Iterate through the sets in the current bucket
     for( int a = 0; a < currentBucket.length; a++ )
     {
      Set currentSet = currentBucket[a];
      bool addSet = true;
      //Iterate through buckets with greater length
      for( int b = 0; b < i; b++ )
      {
       List<Set> testBucket = Buckets[b];
    
       //Iterate through the sets in testBucket
       for( int c = 0; c < testBucket.length; c++ )
       {
        Set testSet = testBucket[c];
        int testMatches = 0;
    
        //Iterate through the values in the current set
        for( int d = 0; d < currentSet.length; d++ )
        {
         int testIndex = 0;
    
         //Iterate through the values in the test set
         for( ; testIndex < testSet.length; testIndex++ )
         {
          if( currentSet[d] < testSet[testIndex] )
          {
           setClear = true;
           break;
          }
          if( currentSet[d] == testSet[testIndex] )
          {
           testMatches++;
           if( testMatches == currentSet.length )
           {
            addSet = false;
            setClear = true;
            break;
           }
          }
         }//testIndex
         if( setClear ) break;
        }//d
        if( !addSet ) break;
       }//c
       if( !addSet ) break;
      }//b
      if( addSet ) Output.Add( currentSet );
     }//a
    }//i
    

    方法一(O(n(n+1)/2))... 不够高效

    半伪代码

    //input Sets
    List<Set> results;
    for( int current = 0; current < Sets.length; current++ )
    {
     bool addCurrent = true;
     Set currentSet = Sets[current];
     for( int other = 0; other < current; other++)
     {
      Set otherSet = Sets[other];
      //is current a subset of other?
      if( currentSet.total > otherSet.total 
       || currentSet.length >= otherSet.length) continue;
      int max = currentSet.length;
      int matches = 0;
      int otherIndex = 0, len = otherSet.length;
      for( int i = 0; i < max; i++ )
      {
       for( ; otherIndex < len; otherIndex++ )
       {
         if( currentSet[i] == otherSet[otherInex] )
         {
          matches++;
          break;
         }
       }
       if( matches == max )
       {
        addCurrent = false;
        break;
       }
      }
      if( addCurrent ) results.Add(currentSet);
     }
    }
    

    这将获取一组集合,并迭代每个集合。对于每个集合,它将再次迭代集合中的每个集合。随着嵌套迭代的进行,它将比较外部集合是否与嵌套集合(来自内部迭代)相同(如果是,则不进行检查),它还将比较外部集合的总数是否大于嵌套集合(如果总数更大,则外部集合不能是适当的子集),然后它将比较外部集合的项数是否小于嵌套集合。

    完成这些检查后,它从外部集合的第一项开始,并将其与嵌套集合的第一项进行比较。如果它们不相等,则会检查嵌套集合的下一项。如果它们相等,则将计数器加1,并将外部集合的下一项与内部集合中离开的位置进行比较。

    如果它达到匹配比较数量等于外部集合中的项目数的点,则发现外部集合是内部集合的适当子集。它被标记为要排除,并停止比较。


    我看到了四个for循环,其中两个迭代输入,使其成为Ω(n^2)。这不是渐进优于OP的解决方案,其时间复杂度为O(n^2) - user824425
    @Tinctorius - 是的,但是排除掉无关项才能提高效率。虽然我认为嵌套循环可以更加高效。 - Travis J
    由于集合按长度排序,因此先前的任何集合都不能是当前集合的子集。这消除了大部分嵌套迭代,并使此算法的最坏情况复杂度为O(n log n)。 - Travis J
    啊,现在我明白了continue的作用。这改变了一些东西,但我仍然怀疑它是否在渐近意义下更好。 - user824425
    @MarkLodato - 实际上,它不是我所断言的 O(n log n)。这将被执行 (0 + 1 + 2 + 3 + 4 + .. + n - 1),这相当于从 0 到 i 的 n 的总和,即 n(n+1)/2。因此,这就是效率。从技术上讲,它比你的快两倍。对于1000个项目的输入,你的程序将运行1,000,000次,而这个程序将运行500,500次,如果有一个nlogn的解决方案,它将运行100,000次。也许可以通过树的方法来找到 nlogn,正如你问题的评论中所建议的那样。 - Travis J
    显示剩余2条评论

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