LeetCode 90 | 經典遞迴問題,求出所有不重複的子集II

TechFlow2019發表於2020-08-12

本文始發於個人公眾號:TechFlow,原創不易,求個關注


今天是LeetCode專題第56篇文章,我們一起來看看LeetCode第90題,子集II(Subsets II)。

這題的官方難度是Medium,通過率46.8%,點贊1686,反對73。看得出來是一道偏基礎,然後質量很高的題。既然有Subsets II自然有Subsets I,它的前作是78題,和78題相比,題意稍稍有些改動,如果沒做過78題的,建議可以先看下,有個對比。

LeetCode 78,面試常用小技巧,通過二進位制獲得所有子集

題意

給定一個包含重複元素的陣列,要求生成出這些元素能夠構成的所有子集。注意,子集包括空集和全集

在之前的LeetCode78題當中,給定用來生成子集的陣列當中不包含重複的元素。這也是這兩題當中最大的差別。

樣例

Input: [1,2,2]
Output:
[
  [2],
  [1],
  [1,2,2],
  [2,2],
  [1,2],
  []
]

題解

全排列的問題也好,獲取子集也好,這些問題都已經算是老生常談了,我們之前做過不少。這些問題經過轉化之後,本質上還是搜尋問題。我們在樣本空間當中搜尋所有合法的解,儲存起來。

這道題的前身LeetCode78題用的正解也是搜尋的解法,對於使用搜尋演算法來解這道題問題不大,但問題是針對陣列當中的重複元素我們應該怎麼樣來處理。

最簡單也是最容易想到的方法當然是先把所有的子集全部找到之後,我們再進行去重。如果採用這樣的方法,還有一個便利是我們可以不用遞迴,而是可以通過二進位制列舉的方法獲取所有的子集。但也有一個問題,問題就是複雜度。我們把集合當中的每一個數字都看成是獨立的,那麼對於每一個數字來說都有取和不取兩種方案。對於n個數字來說,方案總數當然就是。並且我們還需要對這個集合進行去重,這帶來的開銷可想而知。

當然針對這個問題我們也有解決方案比如可以用hash演算法將一個集合hash成一個數,如果hash值一樣說明集合的構成相同。這樣我們就可以通過對數字去重來實現集合去重了。

但這樣仍然不是完美的,首先hash演算法也不是百分百可靠的,也可能會出現hash值碰撞的情況。其次,這種方案的實現複雜度也很大,我們找出所有集合之後再通過hash演算法進行過濾,整個過程非常麻煩。

很明顯,這題一定還存在更好的方法。

既然事後找補不靠譜,那麼我們可以試著事前避免。也就是說我們在搜尋所有子集的時候就設計一種機制可以過濾掉重複的集合或者是保證重複的集合不會出現。我們可以分析一下重複的集合出現的原因,兩個集合完全一樣,說明其中的元素構成完全一致。元素的構成一致又有兩種可能,第一種是重複的獲取,比如[1, 3],我們先拿1再拿3和先拿3再拿1本質上是一樣的。還有一種可能是元素的重複導致的集合重複,比如[1, 3]假如我們候選的1不止一個,那麼拿不同的1也會被認為是不同的方案。

針對第一種情況出現的重複非常簡單,我們可以對元素進行排序,之後限定拿取元素的順序。只能從左拿到右,不能先拿右邊的元素再回頭拿左邊的元素,這樣就禁止了第一種情況導致的重複。這個方法我們曾經在很多問題當中用到過,就不詳細介紹了。

下面來說說第二種情況,就是重複元素導致的重複集合。這一點需要結合程式碼來仔細說明,我們來看一段經典的搜尋程式碼:

def dfs(cur, subset):    
    for i in range(cur, n):
        nxt = subset + [nums[i]]
        ret.append(nxt)
        dfs(i+1, nxt)

這一段是一個經典的搜尋程式碼,我們在for迴圈當中執行的其實是一個列舉操作,也就是列舉這一輪我們要拿取哪一個元素。這裡我們限制了選擇的範圍只能在上一次選擇元素的右側,也就是上文當中說的針對第一種情況的方案。假設我們當前候選的元素是[1, 1, 3, 3],這裡雖然有4個元素,但是值得我們搜尋的其實只有兩個,就是1和3。因為第二個1和第二個3都沒有任何用處,只會導致結果重複。

並且假設我們希望得到[1, 1]這樣的結果,只能通過拿取左側的1實現。也就是說如果出現重複的元素,我們只需要考慮第一個出現的,其餘都沒有考慮的必要

為了更加形象, 我們畫出這一段的搜尋樹。這裡我們為了簡化圖示,只畫了[1, 1, 3]三個數的情況。可以看出我們選第一個1和第二個1,都構建出了[1, 3]這個集合,這是重複的。並且我們可以發現第二個1的所有情況第一個1都已經包括了,所以這一整個分支都是多餘的,可以剪掉。

最後,我們把上面的細節全部串起來寫出程式碼:

class Solution:
    def subsetsWithDup(self, nums: List[int]) -> List[List[int]]:
        # 對元素排序,將重複的元素挨在一起
        nums = sorted(nums)
        ret = [[]]
        n = len(nums)
        
        def dfs(cur, subset):    
            # 上一次選擇的元素,一開始置為None
            last = None
            for i in range(cur, n):
                if i == cur or nums[i] != last:
                    # 儲存集合
                    nxt = subset + [nums[i]]
                    ret.append(nxt)
                    # 更新last
                    last = nums[i]
                    dfs(i+1, nxt)
            
        dfs(0, [])
        return ret

總結

到這裡,我們關於這道題的介紹就結束了。從程式碼上來看,這道題的程式碼不長,涉及到需要推理的細節也並不多,總體的難度並不大。但作為一道搜尋問題,它仍然非常有價值。如果你能自己思考推導得出正確的遞迴程式碼,那麼說明你對遞迴的理解已經可以算是合格了,所以這題也非常適合面試,要準備找工作的小夥伴,可以仔細刷刷。

今天的文章到這裡就結束了,如果喜歡本文的話,請來一波素質三連,給我一點支援吧(關注、轉發、點贊)。

- END -

相關文章