需求
最近工作中碰到一個需求:我們的數據表有多個維度,任意多個維度組合后進行 group by 可能會產生一些”奇妙”的反應,由於不確定怎么組合,就需要將所有的組合都列出來進行嘗試。
抽象一下就是從一個集合中取出任意元素,形成唯一的組合。如 [a,b,c]
可組合為 [a]、[b]、[c]、[ab]、[bc]、[ac]、[abc]
。
要求如下:
- 組合內的元素數大於 0 小於等於 數組大小;
- 組合內不能有重復元素,如 [aab] 是不符合要求的組合;
- 組合內元素的位置隨意,即 [ab] 和 [ba] 視為同一種組合;
看到這里,就應該想到高中所學習的排列組合了,同樣是從集合中取出元素形成一個另一個集合,如果集合內元素位置隨意,就是組合
,從 b 個元素中取 a 個元素的組合有 種。而如果要求元素順序不同也視為不同集合的話,就是排列
,從 m 個元素取 n 個元素的排列有 種。
我遇到的這個需求就是典型的組合,用公式來表示就是從元素個數為 n 的集合中列出 種組合。
轉載隨意,文章會持續修訂,請注明來源地址:https://zhenbianshu.github.io 。
文中算法用 Java 實現。
從排列到組合-窮舉
對於這種需求,首先想到的當然是窮舉。由於排列的要求較少,實現更簡單一些,如果我先找出所有排列,再剔除由於位置不同而重復的元素,即可實現需求。假設需要從 [A B C D E] 五個元素中取出所有組合,那么我們先找出所有元素的全排列,然后再將類似 [A B] 和 [B A] 兩種集合去重即可。
我們又知道 ,那么我們先考慮一種情況 ,假設是 ,從 5 個元素中選出三個進行全排列。
被選取的三個元素,每一個都可以是 ABCDE 之一,然后再排除掉形成的集合中有重復元素的,就是 5 選 3 的全排列了。
代碼是這樣:
private static Set<Set<String>> exhaustion() { List<String> m = Arrays.asList("a", "b", "c", "d", "e"); Set<Set<String>> result = new HashSet<>(); int count = 3; for (int a = 1; a < m.size(); a++) { for (int b = 0; b < m.size(); b++) { for (int c = 0; c < m.size(); c++) { Set<String> tempCollection = new HashSet<>(); tempCollection.add(m.get(a)); tempCollection.add(m.get(b)); tempCollection.add(m.get(c)); // 如果三個元素中有重復的會被 Set 排重,導致 Set 的大小不為 3 if (tempCollection.size() == count) { result.add(tempCollection); } } } } return result; }
對於結果組合的排重,我借用了 Java 中 HashSet 的兩個特性:
- 元素唯一性,選取三個元素放到 Set 內,重復的會被過濾掉,那么就可以通過集合的大小來判斷是否有重復元素了,
- 元素無序性,Set[A B] 和 Set[B A] 都會被表示成 Set[A B]。
- 另外又由於元素唯一性,被同時表示為 Set[A B] 的多個集合只會保留一個,這樣就可以幫助將全排列轉為組合。
可以注意得到,上面程序中 count 參數是寫死的,如果需要取出 4 個元素的話就需要四層循環嵌套了,如果取的元素個取是可變的話,普通的編碼方式就不適合了。
注: 可變層數的循環可以用 遞歸
來實現。
從排列到組合-分治
窮舉畢竟太過暴力,我們來通過分治思想來重新考慮一下這個問題:
分治思想
分治的思想總的來說就是”大事化小,小事化了”,它將復雜的問題往簡單划分,直到划分為可直接解決的問題,再從這個直接可以解決的問題向上聚合,最后解決問題。
從 M 個元素中取出 N 個元素整個問題很復雜,用分治思想就可以理解為:
- 首先,如果我們已經從 M 中元素取出了一個元素,那么集合中還剩下 M-1 個,需要取的元素就剩下 N-1 個。
- 還不好解決的話,我們假設又從 M-1 中取出了一個元素,集合中還剩下 M-2 個,需要取的元素只剩下 N-2 個。
- 直到我們可能取了有 M-N+1 次,需要取的元素只剩下一個了,再從剩余集合中取,就是一個簡單問題了,很簡單,取法有 M-N+1 種。
- 如果我們解決了這個問題,已經取完最后一次了產生了 M-N+1 種臨時集合,再考慮從 M-N+2 個元素中取一個元素呢,又有 M-N+2 種可能。
- 將這些可能聚合到一塊,直到取到了 N 個元素,這個問題也就解決了。
還是從 5 個元素中取 3 個元素的示例:
- 從 5 個元素中取 3 個元素是一個復雜問題,為了簡化它,我們認為已經取出了一個元素,還要再從剩余的 4 個元素中取出 2 個,求解公式為:。
- 從 4 個元素中取出 2 個依舊不易解決,那我們再假設又取出了一個元素,接下來的問題是如何從 3 個元素中取一個,公式為 。
- 從 3 個元素中取 1 個已經是個簡單問題了,有三種可能,再向上追溯,與四取一、五取一的可能性做乘,從而解決這個問題。
代碼實現
用代碼實現如下:
public class Combination { public static void main(String[] args) { List<String> m = Arrays.asList("a", "b", "c", "d", "e"); int n = 5; Set<Set<String>> combinationAll = new HashSet<>(); // 先將問題分解成 五取一、五取二... 等的全排列 for (int c = 1; c <= n; c++) { combinationAll.addAll(combination(m, new ArrayList<>(), c)); } System.out.println(combinationAll); } private static Set<Set<String>> combination(List<String> remainEle, List<String> tempCollection, int fetchCount) { if (fetchCount == 1) { Set<Set<String>> eligibleCollections = new HashSet<>(); // 在只差一個元素的情況下,遍歷剩余元素為每個臨時集合生成多個滿足條件的集合 for (String ele : remainEle) { Set<String> collection = new HashSet<>(tempCollection); collection.add(ele); eligibleCollections.add(collection); } return eligibleCollections; } fetchCount--; Set<Set<String>> result = new HashSet<>(); // 差多個元素時,從剩余元素中取出一個,產生多個臨時集合,還需要取 count-- 個元素。 for (int i = 0; i < remainEle.size(); i++) { List<String> collection = new ArrayList<>(tempCollection); List<String> tempRemain = new ArrayList<>(remainEle); collection.add(tempRemain.remove(i)); result.addAll(combination(tempRemain, collection, fetchCount)); } return