keep moving

enumerate subset

Posted on By condy

生成集合的所有子集

基于2进制的方法

一开始2B的我直接想到是模拟2进制加法,一点一点加上去。这种方法在数据小的情况下速度是优良的,但稍大一点可能就吃不消了。而且在这个模拟的进程中,剪枝有一些限制,不能剪得彻底。

当需要有规律的2进制生成时,一个广为人知的code就是Gray code。它是以前用来发现数据错误的,其具有2个相邻子集只相差1个元素的性质。其实只要具有这种性质,都可以称之为Gray code。例如有2位2进制数,最具有美感的Gray code的生成方式为:

G(x) = x ^ (x >> 1), 其中^为异或操作,»是右移操作,它来源于对n维正方体的顶点用Gray code进制编码。以4位2进制数为例。

0000 0001 0011 0010 0110 0111 0101 0100 1100 1101 1111 1110 1010 1011 1001 1000

for (int i = 0; i < (1 << 4); ++i)
    g = i ^ (i >> 1);

不过还一种方法,它按照选中的个数增加的方式来生成2进制数。称为banker’s sequence。 这里先坑着,面包会有的。。。

这里再来一个比较风骚的写法。假设共有10个物品,枚举则可以这样写。

int S = (1 << 10) - 1;
for (int i = S; i; i = (i - 1) & S)
    // do sth...

就这样是看不出来优点的,如果你已经把某些不可能的情况可剪枝掉了,即0b1110011111是全部的情况。只要将上面的S改变一下就行了。

基于数组的方法

向量的方式,比2进制的方法速度慢,只是它可以所针对的长度远高于2进制数的表示范围。 一般的构造方式都是这样:

void subset(int a[], int cur, int n)
{
    if (cur == n) {
        // do sth...
        return;
    }

    a[cur] = 0;
    subset(a, cur + 1, n);

    a[cur] = 1;
    subset(a, cur + 1, n);
}

或许你看到了这个尾递归,想把它给优化掉,不过一般现在的C/C++编译器都支持尾递归优化了(-O2)。

上面这种方法是间接的通过一个数组来决定是否选择了元素。

还有一种比较特殊的情况,就是你要枚举的对象具有先后顺序、可比较的性质。故可以采用基于字典序来生成。例如你要枚举0 1 2这3个数的选中情况,直接对它们进行选取,不必应用中间数组。

主要思想就是:在当前的状态下选择1个比前1个元素刚刚大的元素,选择它,然后就遇到了相同的问题了,则显然递归。

以数字的来举例。

void subset(int a[], int cur, int limit)
{
    // todo sth. print `array a', etc...

    int s = cur > 0 ? a[cur - 1] + 1: 0;
    for (int i = s; i < limit; ++i) {
        a[cur] = i;
        subset(a, cur + 1, limit)
    }
}

程序显得相当的简洁。:)

并且这个算法产生的序列是lexicographic,如果在每一个subset操作前都打印一下数组a,则显示的内容具有 ‘empty 0 01 012 02 1 12 2’ 的形式。

看到了这个序列中相同长度的串从左到右都是递增的(这不是废话嘛?从subset中就可以看出来了)。如果从整个序列中拿出来,就可以更加明显地显示出它的特点。

以2位的为例:’01 02 12’,这就好比是1个{A, B, C}3个数的集合先出现了前2位,再出现了前后2位,最后产生后2位。如果把完整的2进制形式写出来,显得更加明显了。

‘110’ ‘101’ ‘011’,这就是一步一步把1向右推进嘛。即此刻我们正在enumerate。并且enumerate的2进制形式中1的个数都是一样的。其实啊,这就是banker’s sequence.只要将subset稍候改写一下,即可生成这些2进制序列。

int length = 4;// the size of set

void output(int a[], int limit)
{
    int order[length];

    memset(order, 0, sizeof(order));
    for (int i = 0; i < limit; ++i)
        order[a[i]] = 1;
    for (int i = 0; i < length; ++i)
        cout << order[i];
    cout << endl;
}

void subset(int a[], int cur, int limit)
{
    if (cur == limit) {
        output(a, limit);
        return;
    }

    int start = cur > 0 ? a[cur - 1] + 1 : 0;
    for (int i = start; i < length; ++i) {
        a[cur] = i;
        subset(a, cur + 1, limit);
    }
}

int main()
{
    int a[length];
    for (int i = 0; i <= length; ++i)
        subset(a, 0, i);

    return 0;
}

The End.

总结

其实banker sequence与lexicographic sequence都是用了一个思想,把要enumerate的对象的位置给找到了,只不过banker sequence多了一步转换的过程。

参考

http://www.applied-math.org/subset.pdf