找到幂集的第n个子集

5

我正在尝试寻找幂集中的第n个集合。这里的n是指按照以下顺序生成幂集 -- 首先按大小,然后按字典序 -- ,因此,对于[a, b, c]的幂集中的集合索引为:

0 - []
1 - [a]
2 - [b]
3 - [c]
4 - [a, b]
5 - [a, c]
6 - [b, c]
7 - [a, b, c]

在寻找解决方案时,我只能找到一个算法来返回元素列表的第n个排列,例如here
上下文: 我正在尝试检索元素向量V的整个幂集,但我需要逐个集合地执行此操作。
要求: - 我只能同时维护两个向量,第一个包含列表中的原始项目,第二个包含V的幂集中的第n个集合--这就是为什么我愿意在这里有一个“第n个集合”函数的原因; - 我需要在非线性时间内完成这个任务--这意味着它不能列出所有集合,然后选择第n个; - 我最初的想法是使用位来表示位置,并获得所需内容的有效映射--就像我发布的“不完整”解决方案一样。

3
由于集合没有顺序,因此您可能应该包括您对幂集中第n个集合的理解。在从您的答案中推断出具体含义之前,我以前从未听说过这一点。 - phant0m
@phant0m 感谢您的评论!我会添加那个解释。 - Rubens
3个回答

5
我没有这个函数的封闭形式,但我有一个位操作的非循环next_combination函数,如果有帮助的话,可以使用。它假设您可以将位掩码适配到某些整数类型中,鉴于64元素集合有264种可能性,这可能是一个不太合理的假设。
正如注释所说,我觉得这个“字典序排序”的定义有点奇怪,因为我会说字典序排序应该是:[],[a],[ab],[abc],[ac],[b],[bc],[c]。但我以前必须按“先按大小,然后按字典序”进行枚举。
// Generate bitmaps representing all subsets of a set of k elements,
// in order first by (ascending) subset size, and then lexicographically.
// The elements correspond to the bits in increasing magnitude (so the
// first element in lexicographic order corresponds to the 2^0 bit.)
//
// This function generates and returns the next bit-pattern, in circular order
// (so that if the iteration is finished, it returns 0).
//
template<typename UnsignedInteger>
UnsignedInteger next_combination(UnsignedInteger comb, UnsignedInteger mask) {
  UnsignedInteger last_one = comb & -comb;
  UnsignedInteger last_zero = (comb + last_one) &~ comb & mask;
  if (last_zero) return comb + last_one + (last_zero / (last_one * 2)) - 1;
  else if (last_one > 1) return mask / (last_one / 2);
  else return ~comb & 1;
}

第5行代码实现了位运算的替换操作,相当于使用(扩展)正则表达式查找字符串中最后一个01,将其翻转为10并将后面所有的1向右移动。

s/01(1*)(0*)$/10\2\1/

如果前一个操作失败,第6行代码执行以下操作:添加一个额外的1并将所有1向右移:

s/(1*)0(0*)/\21\1/

我不知道那个解释是帮助还是阻碍 :)


这是一个快速而简单的驱动程序(命令行参数是集合的大小,默认为5,最大值为无符号长整型的位数):

#include <iostream>

template<typename UnsignedInteger>
std::ostream& show(std::ostream& out, UnsignedInteger comb) {
  out << '[';
  char a = 'a';
  for (UnsignedInteger i = 1; comb; i *= 2, ++a) {
    if (i & comb) {
      out << a;
      comb -= i;
    }
  }
  return out << ']';
}

int main(int argc, char** argv) {
  unsigned int n = 5;
  if (argc > 1) n = atoi(argv[1]);
  unsigned long mask = (1UL << n) - 1;
  unsigned long comb = 0;
  do {
    show(std::cout, comb) << std::endl;
    comb = next_combination(comb, mask);
  } while (comb);
  return 0;
}

很难想象这个函数对于一个超过64个元素的集合可能是有用的,鉴于枚举的大小,但它可能对于枚举一些有限的部分是有用的,例如所有三个元素的子集。在这种情况下,位运算技巧只有在修改适合单个字时才真正有用。幸运的是,这很容易测试;您只需要在位集中的最后一个单词上执行上述计算,直到测试last_zero为零为止。(在这种情况下,您不需要使用mask进行按位与操作,实际上您可能希望选择一种不同的指定集合大小的方式。)如果last_zero结果为零(这实际上非常少见),那么您需要以其他方式进行转换,但原则是相同的:找到在1之前的第一个0(注意0在一个字的结尾而1在下一个字的开头的情况);将01更改为10,找出需要移动的1的数量,并将它们移到末尾。


还没有仔细阅读,但肯定会有很大帮助(: 一旦理解了,我会尽快回复。另外,感谢指出定义的问题; 我会立即更改!我也会考虑一下这个问题,因为实际上我不确定哪种排序是合适的 ^^ - Rubens
你介意加上一个使用示例吗?我对你使用的位操作技巧不是很熟悉。(: - Rubens
1
请注意,由于迭代是循环的,从0开始并以0结束,因此您不能使用for循环样式的预测试/后增量。如果这让您感到困扰,您可以将其适应不同的接口。 - rici
1
@Rubens:我勾画了多字过程的草图。但是,如果您有成千上万的值,您在枚举中走得不会很远,并且您可能希望使用基于列表的方法。至于“我从哪里挖掘这些表达式”,它们是位的标准操作;要记住的主要事情是连续的“1”字符串传输进位,而“0”传输借位。(此外,在二进制补码中,“-a == ~a + 1”。尽管gcc也知道这一点,所以您不必担心。) - rici
好的,所以这是归纳的工作吗?你如何确定要开始的集合,或者你只是从空集开始迭代n-1次? - G. Bach
显示剩余10条评论

4

考虑一个元素列表 L = [a, b, c]L 的幂集由以下组成:

P(L) = {
    [],
    [a], [b], [c],
    [a, b], [a, c], [b, c],
    [a, b, c]
}

将每个位置视为一个位,您将具有以下映射:

id  | positions - integer | desired set
 0  |  [0 0 0]  -    0    |  []
 1  |  [1 0 0]  -    4    |  [a]
 2  |  [0 1 0]  -    2    |  [b]
 3  |  [0 0 1]  -    1    |  [c]
 4  |  [1 1 0]  -    6    |  [a, b]
 5  |  [1 0 1]  -    5    |  [a, c]
 6  |  [0 1 1]  -    3    |  [b, c]
 7  |  [1 1 1]  -    7    |  [a, b, c]

正如你所看到的,id并不直接映射到整数。需要应用适当的映射,以便你有:
id  | positions - integer |  mapped  - integer
 0  |  [0 0 0]  -    0    |  [0 0 0] -    0
 1  |  [1 0 0]  -    4    |  [0 0 1] -    1
 2  |  [0 1 0]  -    2    |  [0 1 0] -    2
 3  |  [0 0 1]  -    1    |  [0 1 1] -    3
 4  |  [1 1 0]  -    6    |  [1 0 0] -    4
 5  |  [1 0 1]  -    5    |  [1 0 1] -    5
 6  |  [0 1 1]  -    3    |  [1 1 0] -    6
 7  |  [1 1 1]  -    7    |  [1 1 1] -    7

为了解决这个问题,我想到使用二叉树进行映射-- 我发布它是为了让其他人能够从中看到一个解决方案:

                                        #
                          ______________|_____________
        a               /                             \
                  _____|_____                   _______|______
        b        /           \                 /              \
              __|__         __|__           __|__            __|__
        c    /     \       /     \         /     \          /     \
           [ ]     [c]    [b]   [b, c]    [a]   [a, c]    [a, b]  [a, b, c]
index:      0       3      2       6       1      5         4         7

1
对于第四组,这将给你[1,0,0] = [a],而第三组将是[0,1,1] = [b,c]。您需要找到一种将数字映射到词典顺序的方法,因为使用您答案中描述的方法表示集合的数字的顺序将不会与您问题中描述的集合的词典顺序匹配。 - G. Bach
@G.Bach 感谢您的评论。我已经进行了映射,但我并没有真正注意到解决方案仍然没有准备好^^。我已编辑我的答案。 - Rubens
1
我怀疑反转位并不能给你想要的结果。虽然你提供的链接中整个讨论都忽略了这一点,但它谈到了“重复排列”,因为字母可能会出现多次。另一方面,你的问题需要没有重复的排列(在群论和组合数学中简称排列),因为你想要子集,而集合是简单的(意味着它们不包含同一个元素多次)。 - G. Bach
1
这导致了一个问题,即虽然有n^k个长度为k的带重复排列的可能性,但只有n个选择k个排列,扭曲了计数。不幸的是,我只能指出这一点,因为我找不到解决这个问题的有效方法。 - G. Bach
@G.Bach 哎呀,巴赫,我以为我的问题解决了,现在我完全迷失了。^^ - Rubens
显示剩余3条评论

2
假设你的集合大小为N。
因此,有(N choose k)个大小为k的集合。只需从n中减去(N choose k),直到n即将变为负数,就可以快速找到正确的k(即第n个集合的大小)。这将使您的问题简化为查找N集合的第n个k子集。
您的N集合的前(N-1 choose k-1)个k子集将包含其最小元素。因此,如果n小于(N-1 choose k-1),则选择第一个元素并在其余集合上递归。否则,您有其他(N-1 choose k)个集合之一;丢弃第一个元素,从n中减去(N-1 choose k-1),然后递归。
代码:
#include <stdio.h>

int ch[88][88];
int choose(int n, int k) {
 if (n<0||k<0||k>n) return 0;
 if (!k||n==k) return 1;
 if (ch[n][k]) return ch[n][k];
 return ch[n][k] = choose(n-1,k-1) + choose(n-1,k);
}

int nthkset(int N, int n, int k) {
 if (!n) return (1<<k)-1;
 if (choose(N-1,k-1) > n) return 1 | (nthkset(N-1,n,k-1) << 1);
 return nthkset(N-1,n-choose(N-1,k-1),k)<<1;
}

int nthset(int N, int n) {
 for (int k = 0; k <= N; k++)
  if (choose(N,k) > n) return nthkset(N,n,k);
  else n -= choose(N,k);
 return -1; // not enough subsets of [N].
}

int main() {
 int N,n;
 scanf("%i %i", &N, &n);
 int a = nthset(N,n);
 for (int i=0;i<N;i++) printf("%i", !!(a&1<<i));
 printf("\n");
}

听起来差不多对了(没有检查递归的问题),但我不会谈论“非常快”。计算一个二项式系数最坏情况下需要O(n^2)时间,而你可能需要计算n/2个这样的系数,因此在这种情况下复杂度为O(n^3)(肯定比朴素的暴力枚举方法好,但仍然有些复杂)。假设考虑到大型集合,其幂集将被考虑在内,这可能不会很好地扩展。我自己想不出更好的办法了。 - G. Bach
这绝对不是立方的。 - tmyklebu
你说得对,我不知道为什么会认为计算二项式系数是O(n^2)的。 - G. Bach
@G.Bach:这个问题比较微妙。从头计算单个二项式系数确实是二次的。从头计算n个二项式系数将是立方的。但我不会从头计算它们,我会进行记忆化处理。因此,计算相关二项式系数的总体复杂度为二次。 - tmyklebu
从我的角度来看,如果你计算一个二项式系数为(n!)/(k!(n-k)!),这是不必要的复杂性,因为我们可以在分母中留下一个阶乘,和分子中更大的“一半”,因为它们会抵消掉。这样,分子中有n个乘法,分母中有k + n - k个乘法,总共有2n个乘法和一个除法。我有什么遗漏的吗? - G. Bach
啊,我现在明白了递归和重复使用以前计算的二项式系数;+1相当优雅。但是复杂度还不清楚。 - G. Bach

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