關於“列舉{0,1,...,n-1}所包含的所有大小為k的子集”的理解

pjhui發表於2020-09-14

前言

今天整理以前的競賽筆記時,發現了當時寫的一個模板:

列舉{0,1,…,n-1}所包含的所有大小為k的子集:

int comb = (1 << k) - 1;
while (comb < 1 << n) {
	//進行鍼對組合的處理 
	int x = comb & -comb, y = comb + x;
	comb = ((comb&~y) / x >> 1) | y;
}

我愣是看了半天,也沒想明白當時我想表達什麼(lll¬ω¬)

然後就百度了一下,結合一些描述,終於想起來這貌似是從小白書上扒下來的

話說我小白書已經失蹤一年了,到現在還沒找到......

以防以後又把它忘了,特此記錄

什麼是“列舉{0,1,…,n-1}所包含的所有大小為k的子集”

“列舉{0,1,…,n-1}所包含的所有大小為k的子集”與二進位制狀態壓縮關係密切,其本質為利用二進位制位元算表示和操作集合,舉個例子:

含有n個元素的集合{0,1,…,n-1},就有n個二進位制位,第i個二進位制位代表第i個元素,第i個二進位制位為1代表第i個元素存在於集合,第i位二進位制位為0代表第i個元素不存在於集合。(i<=n)

含有3個元素的集合{0,1,2},全部子集有00000001001000110100010101100111,其中0000代表空集

二進位制數與集合對應關係如下:

我們不難得出,列舉集合{0,1,…,n-1}的所有子集的方法:

for (int S = 0; S < 1 << n; S++) {
	//對子集的操作 
}

S < 1 << n等同於S <= ( (1<<n)-1 ),(1<<n)-1為含有n個元素的集合{0,1,…,n-1}。

解決“列舉{0,1,…,n-1}所包含的所有大小為k的子集”,我們只需弄清什麼是“大小為k的子集”

“大小為k的子集”就是有k個元素的子集,也就是二進位制中有k個1。

含有3個元素的集合{0,1,2}所包含的所有大小為2的子集:

如何列舉?

為了將所有情況列舉出來,我們可以列舉集合{0,1,…,n-1}的所有子集,在列舉時加入判斷,判斷當前子集是否滿足“大小為k的子集”

從實現上來看,這是可行的:

int n, k;
int getsum(int S) {// 統計二進位制中1的個數
	int ans = 0;
	while (S){
		if (S & 1)
			ans++;
		S >>= 1;
	}
	return ans;
}
for (int S = 0; S < 1 << n; S++) {
	if (getsum(S) == k) {
		// 對子集的操作
	}
}

但這不夠優秀,不如說相當低效,這時我們需要找到一種更優秀的列舉方法。

白書上提供了一種思路:

int comb = (1 << k) - 1;
while (comb < 1 << n) {
	//進行鍼對組合的處理 
	int x = comb & -comb, y = comb + x;
	comb = ((comb&~y) / x >> 1) | y;
}

comb是按字典序排列的最小子集,在while迴圈中,comb會一直增大,直到找完所有大小為k的子集

我們利用剛剛的例子,來模擬演算法找“含有3個元素的集合{0,1,2}所包含的所有大小為2的子集”的過程:

(此例中 k=2,n=3)

第一次迴圈,我們找到了按字典序排列的最小子集,也就是comb的初始值0011,之後comb“按演算法提供方法”增大,comb的值變為0101

第二次迴圈,我們找到的是0101,之後comb“按演算法提供方法”增大,comb的值變為0110

第三次迴圈,我們找到的是0110,之後comb“按演算法提供方法”增大,comb的值變為1001

此時,comb的值不滿足comb < 1<<n即不滿足"1001 < 1000",演算法結束於第四次迴圈的開始。

“按演算法提供方法”也就是每次求下一個子集的方法如下:

(以1100 1100到其下一個子集1101 0001為例)

int comb = (1 << k) - 1;
while (comb < 1 << n) {
	//進行鍼對組合的處理 
	int x = comb & -comb, y = comb + x;
	comb = ((comb&~y) / x >> 1) | y;
}

我們將核心程式碼提取並拆解:

    int x = comb & -comb; //步驟(2)
    int y = comb + x;	  //步驟(3)
    int z = comb & ~y;	  //步驟(1)
    int b = (z / x) >> 1; //步驟(4),'z/x'相當於去掉右側多餘的0,'>>1'則使剩下的1的個數減少一個
    comb = b | y;		  //步驟(5)

(1)取出字典序最小的1的連續區間,1100 1100 → 0000 1100

(2)找到字典序最小的1的位置,1100 1100 → 0000 0100

(3)將字典序最小的1的連續區間置為0,並將區間左側第一個0置為1,1100 1100 → 1101 0000

(4)將 (1) 取出的區間右移,直至區間中1的個數減少一個,0000 1100 → 0000 0001

(5)將 (4) 的結果與 (3) 的結果取並集,0000 0001 | 1101 0000 → 1101 0001

按照這種方法,我們不難找出後續的子集:

1101 00101101 01001101 10001110 00011110 00101110 01001110 10001111 0000...

(正文完)

後記

發現這個演算法人也太優秀了吧!!太巧妙了!

(小白書:挑戰程式設計競賽)

參考文獻

weixin_30443075.集合的整數表示與子集列舉

XDU_Skyline.集合的表示及其運算

相關文章