詳細總結組合排列的十餘種算法實現

 

目錄

•寫在前面

•問題引入

•暴力枚舉

循環枚舉

遞歸枚舉

回溯枚舉

•深度優先搜索

前序遍歷

中序遍歷

後序遍歷

•字典序

•二進制位運算

•帶重複數字

•總結


•寫在前面

排列組合的問題,如果沒有合適的算法去解決,時間複雜度會相當的大,畢竟階乘的時間複雜度不僅讓人頭大,也讓他計算機欲罷不能,而且我們遇到排列組合的相關問題的概率相當的大,所以非常有必要掌握排列組合相關的算法,碰到很多問題,我們心裏就有些底氣了。我這裏例舉幾種算法,其中想要特別強調二進制的相關解法,非常有趣。

•問題引入

我們把實際問題抽象一下,就是從一個集合中取出任意元素,形成唯一的組合。如 [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;
}

•總結

二進制是真的很美妙,我們的很多問題都可以通過二進制來解決,所以我們需要慢慢適應並融入二進制的世界,思考問題的角度和 思路也將變得更加開闊。

發佈了93 篇原創文章 · 獲贊 474 · 訪問量 93萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章