子集系列(一) 傳統subset 問題,例 [LeetCode] Subset, Subset II, Bloomberg 的一道面試題


引言

Coding 問題中有時會出現這樣的問題:給定一個集合,求出這個集合所有的子集(所謂子集,就是包含原集合中的一部分元素的集合)。

或者求出滿足一定要求的子集,比如子集中元素總和為定值,子集元素個數為定值等等。

我把它們歸類為子集系列問題。

這篇博文作為子集系列第一篇,着重討論最傳統的子集問題,也就是“給定一個集合,求出這個集合所有的子集”,沒有附加要求。我會討論解決此類題目的兩種思路,並做一些比較。

還是從具體題目開始

 

例題1, 不包含重復元素的集合S,求其所有子集

Given a set of distinct integers, S, return all possible subsets.

Note:

  • Elements in a subset must be in non-descending order.
  • The solution set must not contain duplicate subsets.

For example,
If S = [1,2,3], a solution is:

[
  [3],
  [1],
  [2],
  [1,2,3],
  [1,3],
  [2,3],
  [1,2],
  []
]
class Solution {
public:
    vector<vector<int> > subsets(vector<int> &S) {
    }
};

 

題目來自LeetCode Subsets

思路一

可以用遞推的思想,觀察S=[], S =[1], S = [1, 2] 時解的變化。

可以發現S=[1, 2] 的解就是 把S = [1]的所有解末尾添上2,然后再並上S = [1]里面的原有解。因此可以定義vector<vector<int> > 作為返回結果res, 開始時res里什么都沒有,第一步放入一個空的vecotr<int>,然后這樣迭代n次,每次更新res 內容,最后返回res。

代碼:

class Solution {
public:
    vector<vector<int> > subsets(vector<int> &S) {
        vector<vector<int> > res;
        vector<int> emp;
        res.push_back(emp);
        sort(S.begin(), S.end());
        if(S.size() == 0) return res;
        for(vector<int>::iterator ind = S.begin(); ind < S.end(); ++ind){
            int size = res.size();
            for(int i = 0; i < size; ++i){
                vector<int> v;
                for(vector<int>::iterator j = res[i].begin(); j < res[i].end(); ++j){
                   v.push_back(*j);
                }
                v.push_back(*ind);
                res.push_back(v);
            }
        }
        return res;
    }
};

10 / 10 test cases passed. Runtime: 16 ms

這里注意因為res一直在增長,所以遍歷res的時候不能用vector<int>::iterator,否則可能因為vector重新allocate內存而地址失效,因此直接使用數組下標。

 

思路二

所謂子集,就是包含原集合中的一些元素,不包含另一些元素。如果單獨看某一個元素,它都有兩種選擇:"被包含在子集中"和"不被包含在子集中",對於元素個數為n、且不含重復元素的S,子集總數是2n。因此我們可以遍歷S的所有元素,然后用遞歸考慮每一個元素包含和不包含的兩種情況。

代碼,這種思路需要用到遞歸

class Solution {
public:
    vector<vector<int> > subsets(vector<int> &S) {
        vector<int> v;
        sort(S.begin(), S.end());
        subsetsCore(S, 0, v);
        return res;
    }
private:
    vector<vector<int> > res;
    void subsetsCore(vector<int> &S, int start, vector<int> &v){
        if(start == S.size()) { res.push_back(v); return;}
        vector<int> v2;
        for(vector<int>::iterator i = v.begin(); i < v.end(); v2.push_back(*(i++)));
        v.push_back(S[start]);
        subsetsCore(S, start+1, v); //包含S[start]
        subsetsCore(S, start+1, v2); //不包含S[start]
    }
};

 

10 / 10 test cases passed. Runtime: 40 ms

 

例題2,S中包含有重復元素

原題中規定原集合S中的元素是distinct的。如果S中包含有重復元素(也就是LeetCode中題Subset II),這種思路需要如何改進?

Subsets II

Given a collection of integers that might contain duplicates, S, return all possible subsets.

Note:

  • Elements in a subset must be in non-descending order.
  • The solution set must not contain duplicate subsets.

思路一

我們以S=[1,2,2]為例:

可以發現從S=[1,2]變化到S=[1,2,2]時,多出來的有兩個子集[2,2]和[1,2,2],這兩個子集,其實就是 [2], [1,2]末尾都加上2 而產生。而[2], [1,2] 這兩個子集實際上是 S=[1,2]的解到 S=[1]的解 新添加的部分。

因此,若S中有重復元素,可以先排序;遍歷過程中如果發現當前元素S[i] 和 S[i-1] 相同,那么不同於原有思路中“將當前res中所有自己拷貝一份再在末尾添加S[i]”的做法,我們只將res中上一次添加進來的子集拷貝一份,末尾添加S[i]。

代碼:

class Solution {
public:
    vector<vector<int> > subsetsWithDup(vector<int> &S) {
        vector<vector<int> > subsets;
        vector<int> v;
        subsets.push_back(v);
        if(S.empty()) return subsets;
        sort(S.begin(), S.end());
        int m = 0; //m 用來存儲上一次加進來的子集們的起始index
        for(vector<int>::iterator i = S.begin(); i < S.end(); ++i){
            int start = ((i != S.begin() && *i == *(i-1)) ? m : 0); //如果S的當前元素和前一個元素相同,只拷貝上次加進來的子集
            int end = subsets.size();
            for(int j = start; j < end; ++j){
                vector<int> vt;
                for(vector<int>::iterator k = subsets[j].begin(); k < subsets[j].end(); ++k){
                    vt.push_back(*k);
                }
                vt.push_back(*i);
                subsets.push_back(vt);
            }
            m = end;
        }
        return subsets;
    }
};

 19 / 19 test cases passed,Runtime: 72 ms

 

小結:

思路一的切入點是:比較S=[1]和S=[1,2] 的解的區別,找到轉移方程。實現方式是不停迭代和更新res。

實現的優勢是不需要使用遞歸,迭代即可完成;但需要定義一個vector<vector<int> > res,然后迭代過程中不停基於res已有的子集生成新的子集,再添加到res中,也就是說res除了用於最終返回,在迭代過程中還有臨時存放點的作用。

 

用與上題類似的思路二來解:

對於含有重復元素的S,可以先排序,然后考慮去重:我們可以發現如果所遍歷的當前元素S[i] 和 目前的子集的末尾元素相同,那么就不再需要考慮"不包含當前元素到子集中"的情況,只需要考慮"包含當前元素到子集中一種情況"。舉個例子:對於S=[1,2,2],如果遍歷到第二個"2",當前子集v是[1, 2],這個時候如果考慮"不把2包含進子集的情況",即維持子集=[1,2]不動,遍歷下一個元素;這樣其結果會出現重復。因為考慮另一個遞歸調用,其當前子集v是[1],也遍歷到了S的第二個"2",它將這個"2"元素放入當前子集,雖然繼續遍歷下一個元素。這兩個遞歸調用的結果是重復的。因此,若當前遞歸調用所遍歷到的元素和當前子集v的末尾元素相同,只考慮"把當前元素添加到子集末尾"的情況。

代碼

class Solution {
public:
    vector<vector<int> > subsetsWithDup(vector<int> &S) {
        vector<int> v;
        sort(S.begin(), S.end());
        subsetsCore(S, 0, v);
        return res;
    }
private:
    vector<vector<int> > res;
    void subsetsCore(vector<int> &S, int start, vector<int> &v){
        if(start == S.size()) { res.push_back(v); return;}
        if(v.size() == 0 || v[v.size()-1] != S[start]){    //When S[start] != v[v.size()-1], we need to consider both case: add S[start] into v; not add S[start] to v. If S[start] == v[v.size()-1], we only need to consider the case add S[start] into v.
            vector<int> v2;
            for(vector<int>::iterator i = v.begin(); i < v.end(); v2.push_back(*(i++)));
            subsetsCore(S, start+1, v2);
        }
        v.push_back(S[start]);
        subsetsCore(S, start+1, v);
    }
};

19 / 19 test cases passed,Runtime: 52 ms

 

上面的解法中因為老是要從v拷貝元素到v2,所以比較占用時間,可以設置一個全局vector<int>,回溯增刪。

class Solution {
public:
    vector<vector<int> > subsetsWithDup(vector<int> &S) {
        sort(S.begin(), S.end());
        subsetsCore(S, 0);
        return res;
    }
private:
    vector<int> path;
    vector<vector<int> > res;
    void subsetsCore(vector<int> &S, int start){
        if(start == S.size()) { res.push_back(path); return;}
        if(path.size() == 0 || path[path.size()-1] != S[start])
            subsetsCore(S, start+1);    //When S[start] != v[v.size()-1], we need to consider both case: add S[start] into v; not add S[start] to v. If S[start] == v[v.size()-1], we only need to consider the case add S[start] into v.
 path.push_back(S[start]);
        subsetsCore(S, start+1);
        path.pop_back();
    }
};

 19 / 19 test cases passed,Runtime: 48 ms

因為case區分度不夠的緣故,在這個例子中沒有快太多,但在下一篇文章中的Combination 例題中,使用全局的path會省去很多時間。

 

小結

從本質上來說,思路二和思路一是同一種解法,只是切入角度不同,致使實現方式不同。思路二雖然沒有顯示定義vector<vector<int>>來存放所有子集,但是所有遞歸里新開的vector<int>,加起來所占用的空間和思路一所占用空間一樣,思路二還多出了遞歸所占用的棧空間。

 

例題3,string 的子集

子集求解可以再做一些改變,比如:S不再是一個vector<int>,而是一個string,求其所有的sub string。

我參加Bloomberg的面試時,曾經遇到S為string的題目,S的長度小於30,要求求出S這個字符串所有的sub string。例如S = "abc",輸出 "a" "b" "c" "ab" "ac" "bc" "abc",空子串不需要輸出。

要求不能用遞歸,不能申請vector或者數組,直接輸出所有sub string。當時我曾經做過LeetCode上的Subset,也就是本文中拿來當例題的題目,刷LeetCode時,用第一種思路解出來了,就沒有再繼續深究下去。結果遇到這一題時(只有十多分鍾解題),一緊張,滿腦子都是原來的思路一,沒能給出符合要求的解。

其實如果從"每個元素都有包含進子集和不包含進子集兩種可能",也就是思路二入手,這個問題就可以解決。

思路二的本質是"考慮每一個元素的兩種情況",雖然不能用遞歸,但是因為S的長度小於30,我們可以用一個unsigned int 的每個bit位來表示S的每一個字符的兩種情況。當然這解法的前提是S這個string中不含重復字符。

代碼

void subsets(string s) {
    if(s.length() == 0) return;
    unsigned int i = 1, judgeEnd = (1 << s.length()) - 1; //judgeEnd用來判定i 遞增的終止
    unsigned int mask = 0; //mask用於濾出 i 的每一位
    int j = 0;
    for(; (i & judgeEnd) > 0; cout << endl, ++i){
        for(mask = 1 << (s.length() - 1), j = 0; j < s.length(); ++j, mask = mask >> 1){
            if(mask & i) cout << s[j];
        }
    }
}

 

做過類似的題目依然面試沒過,也算是一個慘痛的教訓吧。刷題的本質,是為了讓自己通過接觸不同的題目,在總結思考中提升coding能力。對於做的每一題,都需要發散開來,探究不同的解法和思路;如果僅僅滿足於AC,結果一旦題目有所變化,反而會被原來的思路束縛住手腳。

 

續篇:

子集系列(二) 滿足特定要求的子集 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM