目錄
•寫在前面
排列組合的問題,如果沒有合適的算法去解決,時間複雜度會相當的大,畢竟階乘的時間複雜度不僅讓人頭大,也讓他計算機欲罷不能,而且我們遇到排列組合的相關問題的概率相當的大,所以非常有必要掌握排列組合相關的算法,碰到很多問題,我們心裏就有些底氣了。我這裏例舉幾種算法,其中想要特別強調二進制的相關解法,非常有趣。
•問題引入
我們把實際問題抽象一下,就是從一個集合中取出任意元素,形成唯一的組合。如 [a,b,c] 可組合爲 [a]、[b]、[c]、[ab]、[bc]、[ac]、[abc]。我們既然說的是排列組合,當然就有區分這個集合裏面,是否存在重複的元素的問題,如 [a,a,b,c] 可組合爲 [a]、[b]、[c]、[ab]、[aa]、[bc]、[ac]、[abc]、[aab]等等。針對是否存在重複的元素問題,我們自然需要不同的解決方案,不過有些方法思路存在共性,接下來我們以不同算法爲基礎,結合不同的問題進行講解,完成思路的理解(我們的重點是思路,有了思路,實現代碼的時候就簡單多了)。這裏我們拋出一個問題,即如下
給定一組不含重複元素的整數數組 nums,返回該數組所有可能的子集(冪集)。
說明:解集不能包含重複的子集。
輸入: nums = [1,2,3]
輸出:
[
[3],
[1],
[2],
[1,2,3],
[1,3],
[2,3],
[1,2],
[]
]
我們先按照不含重複元素來進行思考,然後附上含重複元素的思路。
•暴力枚舉
對於這種排列組合的問題,首先想到的當然是窮舉。由於排列的要求較少,實現更簡單一些,如果我先找出所有排列,再剔除由於位置不同而重複的元素,即可實現需求。假設需要從 [A B C D E] 五個元素中取出所有組合,那麼我們先找出所有元素的全排列,然後再將類似 [A B] 和 [B A] 兩種集合去重即可。當然,我們可以在枚舉的時候進行一些剪枝,即在枚舉個過程中進行一些判斷,也可以當做一種優化。總結就是,逐個枚舉,空集的冪集只有空集,每增加一個元素,讓之前冪集中的每個集合,追加這個元素,就是新增的子集。還是不知道啥意思?沒關係,我麼來看思路和代碼實現,這裏我們分爲兩種枚舉方式。
循環枚舉
循環枚舉的思路其實特別簡單(暴力的思路就是人類的直覺,思路當然簡單啦,哈哈哈),我們先創建結果集(res),然後往結果集裏面添加一個空集,現在完成結果集的初始化之後(初始化的結果集中只有一個空集),我們開始在集合(nums)中開始循環遍歷所有的元素,每次遍歷一個元素,我們就把這個元素添加在當前結果集的每一個子集後,並形成新的子集,添加到結果集中,思路非常的簡單暴力,代碼吐下:
public static List<List<Integer>> enumerate(int[] nums) {
List<List<Integer>> res = new ArrayList<List<Integer>>();
res.add(new ArrayList<Integer>());
for (Integer n : nums) {
int size = res.size();
for (int i = 0; i < size; i++) {
List<Integer> newSub = new ArrayList<Integer>(res.get(i));
newSub.add(n);
res.add(newSub);
}
}
return res;
}
遞歸枚舉
遞歸枚舉的總體思路和循環枚舉一致,只不過我們的實現方式是使用遞歸,代碼的變化其實就是把循環枚舉裏面的最外面那層遍歷集合(nums)的循環,使用遞歸來代替,很簡單的,看代碼你就明白了。
public static void recursion(int[] nums, int i,
List<List<Integer>> res) {
if (i >= nums.length) return;
int size = res.size();
for (int j = 0; j < size; j++) {
List<Integer> newSub = new ArrayList<Integer>(res.get(j));
newSub.add(nums[i]);
res.add(newSub);
}
recursion(nums, i + 1, res);
}
回溯枚舉
回溯枚舉就和上面的兩種枚舉思路有點不一樣了,這種枚舉思路就好像是我們人的思路,進行一個一個拼湊,只要符合我們子集的條件,就將這個子集添加進結果集,代碼如下:
public static void backtrack(int[] nums, int i, List<Integer> sub,
List<List<Integer>> res) {
for (int j = i; j < nums.length; j++) {
if (j > i && nums[j] == nums[j - 1]) continue;
sub.add(nums[j]);
res.add(new ArrayList<Integer>(sub));
backtrack(nums, j + 1, sub, res);
sub.remove(sub.size() - 1);
}
}
•深度優先搜索
集合中每個元素其實就兩種狀態,就是選和不選,我們通過這兩種狀態,可以構成了一個滿二叉狀態樹,比如,左子樹是不選,右子樹是選,從根節點、到葉子節點的所有路徑,構成了所有子集。可以有前序、中序、後序的不同寫法,結果的順序不一樣。本質上,其實是比較完整的中序遍歷。我這樣光說,可能比較抽象,我這裏通過畫的一張中序遍歷的圖進行講解,如下。
前序遍歷
public static void preOrder(int[] nums, int i,
ArrayList<Integer> subset, List<List<Integer>> res) {
if (i >= nums.length) return;
subset = new ArrayList<Integer>(subset);
res.add(subset);
preOrder(nums, i + 1, subset, res);
subset.add(nums[i]);
preOrder(nums, i + 1, subset, res);
}
中序遍歷
public static void inOrder(int[] nums, int i,
ArrayList<Integer> subset, List<List<Integer>> res) {
if (i >= nums.length) return;
subset = new ArrayList<Integer>(subset);
inOrder(nums, i + 1, subset, res);
subset.add(nums[i]);
res.add(subset);
inOrder(nums, i + 1, subset, res);
}
後序遍歷
public static void postOrder(int[] nums, int i,
ArrayList<Integer> subset, List<List<Integer>> res) {
if (i >= nums.length) return;
subset = new ArrayList<Integer>(subset);
postOrder(nums, i + 1, subset, res);
subset.add(nums[i]);
postOrder(nums, i + 1, subset, res);
res.add(subset);
}
•字典序
如果你足夠理解了前面的思路,其實你應該會發現前面本質思路可以分爲兩類,暴力、遞歸、回溯一類,以及深度優先搜索一類,分類的依據是什麼呢?其實就是思考的角度不一樣,前面的那一類,我們思考的對象是每個子集,我們對每個子集進行相關算法的實現,而深度優先搜索開始,我們將關注的點放在集合(nums)中的每個元素的狀態,我們集合中的每個元素在子集中只有兩個狀態,也就是存在和不存在。現在我們將關注的點轉到了元素的狀態上,即01狀態,上面的深度優先搜索的實現只是一個過渡,本質上和遞歸等效率差不了多少,因爲01狀態是二進制的天下,我們自然使用二進制來代替,效率很高。利用二進制的思想去解決這個問題,就很簡單了,值得一提的是,我們在使用二進制思想解決問題的時候,並不一定說使用位運算,而這裏要講的字典序,就不適用位運算來實現二進制思路。爲了更好的理解思路,我們暫時先將問題簡化爲:
給定兩個整數 n 和 k,返回 1 ... n 中所有可能的 k 個數的組合。
輸入: n = 4, k = 2
輸出:
[
[2,4],
[3,4],
[2,3],
[1,2],
[1,3],
[1,4],
]
算法其實非常的直觀,不過有一個值得注意的是,我們在子集的最後需要放一個標誌位(也就是所說的哨兵),在每次循環的時候,我們只要找到nums中的第一個滿足 nums[j] + 1 != nums[j + 1]的元素,並將其加一nums[j]++ 以轉到下一個組合。這種思想代替了位運算,完成了二進制的實現
public List<List<Integer>> combine(int n, int k) {
LinkedList<Integer> nums = new LinkedList<Integer>();
for(int i = 1; i < k + 1; ++i)
nums.add(i);
nums.add(n + 1);
List<List<Integer>> output = new ArrayList<List<Integer>>();
int j = 0;
while (j < k) {
output.add(new LinkedList(nums.subList(0, k)));
j = 0;
while ((j < k) && (nums.get(j + 1) == nums.get(j) + 1))
nums.set(j, j++ + 1);
nums.set(j, nums.get(j) + 1);
}
return output;
}
•二進制位運算
很多算法都能通過位運算巧秒地解決,其優勢主要有兩點:一者位運算在計算機中執行效率超高,再者由於位運算語義簡單,算法大多直指本質。組合算法也能通過位運算實現,而且代碼實現之後,簡直令人回味無窮。我們將問題迴歸到最開始的問題,現在我們假設,從 M 個元素中取任意個元素形成組合,組合內元素不能重複、元素位置無關。我們使用狀態的思想,對於每個元素來說,要麼被放進組合,要麼不放進組合。每個元素都有這麼兩種狀態。我們這裏舉個例子,如果從 5 個元素中任意取 N 個元素形成組合的話,用二進制位來表示每個元素是否被放到組合裏,就是:
每種組合都可以拆解爲 N 個二進制位的表達形式,而每個二進制組合同時代表着一個十進制數字,所以每個十進制數字都就能代表着一種組合。十進制數字的數目我們很簡單就能算出來,從00000... 到 11111... 一共有種,排除掉全都不被放進組合這種可能,結果有幾種。
public List<List<String>> combination(String[] m) {
List<List<String>> result = new ArrayList<>();
for (int i = 1; i < Math.pow(2, m.length) - 1; i++) {
List<String> eligibleCollections = new ArrayList<>();
for (int j = 0; j < m.length; j++) {
if ((i & (int) Math.pow(2, j)) == Math.pow(2, j)) {
eligibleCollections.add(m[j]);
}
}
result.add(eligibleCollections);
}
return result;
}
當然,還想更秀操作一點,我們在第一和二層循環那裏,使用位移運算,來完成與運算,本質是一樣的,代碼如下
public static List<List<Integer>> binaryBit(int[] nums) {
List<List<Integer>> res = new ArrayList<List<Integer>>();
for (int i = 0; i < (1 << nums.length); i++) {
List<Integer> sub = new ArrayList<Integer>();
for (int j = 0; j < nums.length; j++)
if (((i >> j) & 1) == 1) sub.add(nums[j]);
res.add(sub);
}
return res;
}
當然,如果我們還有一種稍微繞一點點的二進制實現方式,就是數1的個數,這種思路有點繞,不過不管怎麼樣都還是二進制的一種實現方式,帶着二進制的特色,我這裏就大概的提一下數二進制中1的個數的實現方式,具體的問題解決代碼,感興趣的可以自己去實現以下,數二進制中1的個數的代碼如下,想要關於數二進制的個數的其他算法,可以看我另一篇文章
int BitCount1(unsigned int n)
{
unsigned int c =0 ; // 計數器
for (c =0; n; n >>=1) // 循環移位
c += n &1 ; // 如果當前位是1,則計數器加1
return c ;
}
•帶重複數字
我們在集合中帶有重複數字,這樣的集合和不帶重複數字有什麼區別呢?我們按照這個思路,其實可以使用不重複數字的求解方式,在求解帶重複數字集合的過程中,進行去重即可。有了這種思路,我們去設計算法其實就不難了,在暴力、遞歸、回溯、深搜的方法中,我們很容易的將算法進行改進,我在這裏就不多提了,這裏要說的是二進制的算法改進。你仔細想一下我們怎麼樣才能在二進制的算法中進行相應的去重,這其實不容易想到的。
我們來假設nums中有[1,2,3],那麼它的結果集以及對應的二進制形如下面這這樣
1 2 3
0 0 0 -> [ ]
0 0 1 -> [ 3]
0 1 0 -> [ 2 ]
0 1 1 -> [ 2 3]
1 0 0 -> [1 ]
1 0 1 -> [1 3]
1 1 0 -> [1 2 ]
1 1 1 -> [1 2 3]
但是如果有了重複數字,很明顯就行不通了。例如對於 nums = [ 1 2 2 2 3 ]。
1 2 2 2 3
0 1 1 0 0 -> [ 2 2 ]
0 1 0 1 0 -> [ 2 2 ]
0 0 1 1 0 -> [ 2 2 ]
上邊三個數產生的數組重複的了。三個中我們只取其中 1 個,取哪個呢?我們仔細想一下應該好想到,就是我們只要去重複數字的開頭的序列就可以了,比如重複了三個2,那麼我們只要分別取一個2開頭即“2”,兩個2開頭即“22”以及三個2開頭即“222”就可以了,這樣就可以避免重複,更形象的解釋,我們舉個五個2的例子,然後例舉出他們不重複的序列,像下面這樣
2 2 2 2 2
1 0 0 0 0 -> [ 2 ]
1 1 0 0 0 -> [ 2 2 ]
1 1 1 0 0 -> [ 2 2 2 ]
1 1 1 1 0 -> [ 2 2 2 2 ]
1 1 1 1 1 -> [ 2 2 2 2 2 ]
那麼這個時候,我們就可以將問題轉成,我們怎麼去取不同個數的2在一起,其實仔細思考也不難理解,我們只要對二進制稍微研究一下就知道了,我們先拿兩個2來舉例子,在五位二進制中,有兩個2開頭的二進制序列有哪些?
2 2 2 2 2
1 1 0 0 0 -> [ 2 2 ]
1 0 1 0 0 -> [ 2 2 ]
0 1 1 0 0 -> [ 2 2 ]
0 1 0 1 0 -> [ 2 2 ]
0 0 0 1 1 -> [ 2 2 ]
上面所有兩個2的情況,我們只需要去其中一種,那麼我們應該取哪種?怎麼取呢?這個時候我們觀察一下他們是否存在不同點,而且其中存在一個和其他情況都不同的形式,我們來看一下出現了重複數字,並且當前是 1 的前一個的二進位。
對於 1 1 0 0 0 ,是 1。
對於 1 0 1 0 0 , 是 0。
對於 0 1 1 0 0 ,是 0。
對於 0 1 0 1 0 ,是 0。
對於 0 0 0 1 1 ,是 0。
可以看到只有第一種情況對應的是 1 ,其他情況都是 0。其實除去從開頭是連續的 1 的話,就是兩種情況。第一種就是,佔據了開頭,類似於這種 10...1....。第二種就是,沒有佔據開頭,類似於這種 0...1...。這兩種情況,除了第一位,其他位的 1 的前邊一定是 0。所以的話,我們的條件是看出現了重複數字,並且當前位是 1 的前一個的二進位。有了這種思路,我們只需要在不重複的代碼中,進行一個判斷就可以了,代碼如下
public static List<List<Integer>> binaryBit(int[] nums) {
List<List<Integer>> res = new ArrayList<List<Integer>>();
for (int i = 0; i < (1 << nums.length); i++) {
List<Integer> sub = new ArrayList<Integer>();
boolean illegal=false;
for (int j = 0; j < nums.length; j++)
if (((i >> j) & 1) == 1) {
if(j>0&&nums[j]==nums[j-1]&&(i>>(j-1)&1)==0){
illegal=true;
break;
}else{
sub.add(nums[j]);
}
}
if(!illegal){
res.add(sub);
}
}
return res;
}
•總結
二進制是真的很美妙,我們的很多問題都可以通過二進制來解決,所以我們需要慢慢適應並融入二進制的世界,思考問題的角度和 思路也將變得更加開闊。