Android程序員會遇到的算法(part 3 深度優(yōu)先搜索-回溯backtracking)

上一遍文章我們過了一次廣度優(yōu)先算法,算是比較好理解的,因為模式比較固定,使用隊列再進行while() 循環(huán),既可以滿足大部分時候的需求。這一次我們來開始學(xué)習(xí)/復(fù)習(xí)一下我們的深度優(yōu)先算法。深度優(yōu)先算法其實在很多地方都可以應(yīng)用到,其實在我的看法,只要搜索集合相對固定,并且使用到遞歸的算法都可以算是深度優(yōu)先。而且在學(xué)習(xí)完回溯算法的很多題目之后,大家也可以更直觀的體驗到,很多時候回溯也是暴力搜索的一種程序上的實現(xiàn)而已。所以,

1.回溯

2.深度優(yōu)先

3.暴力搜索

這三種算法,名字雖然不同,但是都在某些情況下是有很大的共同成分的。大家在看完題目之后可以好好感受一下。

那么進入正題


1.理解遞歸

如果是計算機專業(yè)的同學(xué)可能可以忽略這一小節(jié)。

[圖片上傳失敗...(image-1a1a5f-1518421713974)]

其實遞歸的方法和一般的方法有什么區(qū)別呢?答案是完全沒有,遞歸的方法和一般的方法完全沒有區(qū)別。一個標準的方法/函數(shù),都是需要在方法/函數(shù)棧里面進行調(diào)用和返回的。舉個栗子。

static void a(){
    b();
}

static void b(){
    c();
}


static void c(){
    System.out.println("methods")
}


public static void main(String[] args){
    a();
}


下面這段代碼在方法棧中的執(zhí)行過程如下兩圖所示。

方法執(zhí)行
方法結(jié)束

如上圖所示,所有的方法在執(zhí)行結(jié)束之后都會返回,return,這個return,代表的是return到該方法的調(diào)用者,也就是執(zhí)行該方法的方法內(nèi),也就是上一層中。

理解了這個,遞歸也就很好理解,同樣是使用方法/函數(shù)棧,只不過是調(diào)用的方法是相同的方法而已。

2.理解回溯

理解回溯,我們先從一個例題來看一下。

我們有一個集合/列表,含有若干整數(shù)(不含有重復(fù)),例如:{1,2,3}。 求該集合的全排列。
{1,2,3}
{1,3,2}
{2,1,3}
{2,3,1}
{3,1,2}
{3,2,1}

從直觀的感覺來說,第一眼遇到這個題目,我們可以這么去抽象的構(gòu)思:

我們按照步驟/狀態(tài)來抽象的話,我們每一刻都有一個可用集合一個答案集合。每一步都需要從可用集合里面抽取一個元素加入到答案集合。在答案集合滿了(或者是可用集合空了)的時候,代表我們獲取了一個答案,這時需要向后,往可用集合內(nèi)部退回元素。再重新做抽取的步驟,往答案集合中放置元素。

1開頭的集合答案

可以看出來,我們每次獲取答案都要向上退后一步,回到之前的狀態(tài),選取不同的元素放入結(jié)果集合。這個過程其實就是我們之前所講的回溯。至于怎么樣遍歷集合,根據(jù)題目的要求我們可以有不同的策略,一般的回溯算法都是涉及列表的遍歷,for循環(huán)足矣。

我們來看看代碼

public List<List> getPermutation(List<Integer> list){
    List result = new ArrayList<>();
    permutateHelper(result,new ArrayList<>(), list, new HashSet<Integer>());
    return result;
}

private void permutateHelper(List result, List<Integer> temp,List<Integer> list, HashSet<Integer> visited){
    //如果temp,temp答案集合滿了,我們加入到最終的結(jié)果集合內(nèi)。
    if(temp.size() == list.size()){
        result.add(new ArrayList(temp));
    }
    else{
        //直接使用for循環(huán)進行對原集合的遍歷
        for(int i = 0; i< list.size();i++){
            //如果沒有visit過,進行遞歸
            if(!visited.contains(list.get(i))){
                int current = list.get(i);
                temp.add(current);
                visited.add(current);
                //進入下一層遞歸
                permutateHelper(result,temp,list,visited);
                visited.remove(current);
                //這里需要直接remove掉最后一個元素,因為我們的全部的下一層遞歸已經(jīng)結(jié)束,所以可以把該層的數(shù)字刪掉,進入for循環(huán)的下一個遍歷的開始了。這里這個remove的動作就是我們所謂的“回溯”
                temp.remove(temp.size()-1);      
            }
        }
    }

}

從以上代碼我們可以看出,回溯算法其實就是普通的遞歸,但是加上了對集合遍歷(for 循環(huán))的過程,大家可以體會一下一個小小的區(qū)別。假如在以上的代碼中,我們的temp不刪除最后一個元素,改成這樣:

private void permutateHelper(List result, List<Integer> temp,List<Integer> list, HashSet<Integer> visited){
    if(temp.size() == list.size()){
        result.add(new ArrayList(temp));
    }
    else{
        for(int i = 0; i< list.size();i++){
            if(!visited.contains(list.get(i))){
                int current = list.get(i);
                temp.add(current);
                visited.add(current);
                //進入下一層遞歸,不刪除最后一個元素,每次都創(chuàng)建一個新的temp列表
                permutateHelper(result,new ArrayList<>(temp),list,visited);
                visited.remove(current);
            }
        }
    }

}

簡單的一行的修改,最后的答案也是對的(先不說這個修改浪費了多少空間),可是卻完全的改變了我們要的回溯的本質(zhì),沒有向前退的這個動作,這個程序就變成了單純的遞歸,暴力搜索了。

關(guān)于排列組合的題目,還有更加難的,比如集合中有重復(fù)元素怎么辦,如果不只是求全排列,而是求子集呢?

有興趣的同學(xué)可以看看leetcode上的題目:

1.Permutation II
2.Subset

相信大家對所謂的回溯已經(jīng)有點理解了。我們再來一個難一點點的題目。

3. 電話鍵盤

例題來自leetcode的一道關(guān)于電話鍵盤的題目

Screen Shot 2018-02-12 at 3.14.47 PM.png

這個題目就是說,在手機上按幾個數(shù)字鍵,對應(yīng)可能產(chǎn)生的所有可能的字母的集合。比如在手機上按23,就是從{a,b,c}和{d,e,f}中各取一個放入答案集合中。

這題和上一題的區(qū)別是,搜索集合不再是一個固定的大集合了,而是若干的小集合,每個數(shù)字對應(yīng)一個小集合,滿足搜索結(jié)果的答案的標準也不一樣,不再是以集合的元素數(shù)量為標準,而是以我們的輸入數(shù)字的數(shù)量為標準。

我們直接看代碼

public List<String> letterCombinations(String digits) {
        if (digits == null || digits.length() == 0) {
            return new ArrayList<>();
        }

                ///先初始化手機按鍵的數(shù)字和字母的關(guān)系
        String[] one = { "" };
        String[] two = { "a", "b", "c" };
        String[] three = { "d", "e", "f" };
        String[] four = { "g", "h", "i" };
        String[] five = { "j", "k", "l" };
        String[] six = { "m", "n", "o" };
        String[] seven = { "p", "q", "r", "s" };
        String[] eight = { "t", "u", "v" };
        String[] nine = { "w", "x", "y", "z" };
        String[] zero = { "" };

        HashMap<Integer, String[]> map = new HashMap<>();
        map.put(0, zero);
        map.put(1, one);
        map.put(2, two);
        map.put(3, three);
        map.put(4, four);
        map.put(5, five);
        map.put(6, six);
        map.put(7, seven);
        map.put(8, eight);
        map.put(9, nine);

        ArrayList<String> result = new ArrayList<>();
        int[] allNum = new int[digits.length()];
        for (int i = 0; i < digits.length(); i++) {
            allNum[i] = Integer.parseInt(digits.substring(i, i + 1));
        }
        phoneNumberHelper(result, new StringBuilder(), 0, allNum, map);
        return result;

    }

    private void phoneNumberHelper(ArrayList<String> result, StringBuilder current, int index, int[] allNum,
            HashMap<Integer, String[]> com) {
                //如果找到一個排列,加入答案中
        if (index == allNum.length) {
            result.add(current.toString());
            return;
        } else {
                        //使用for循環(huán),遍歷當前該數(shù)字的字母集合
            String[] possibilities = com.get(allNum[index]);
            for (int i = 0; i < possibilities.length; i++) {
                phoneNumberHelper(result, current.append(possibilities[i]), index + 1, allNum, com);
                                //一定要把StringBuilder的最后一位刪除掉。
                current.deleteCharAt(current.length()-1);
            }
        }
    }


可以看出,回溯的題目并不難,在理解了排列組合題目之后,理解這個就簡單一些了。我們對比一下和排列組合題目的不同。

搜索集合在每一個狀態(tài)都不一樣,排列組合在每個步驟都是相同的搜索集合,電話按鍵根據(jù)按下的數(shù)字不一樣,對應(yīng)的字母不一樣。

對于回溯算法的精髓,大家通過對兩個題目的練習(xí)可以發(fā)現(xiàn),就在于那個remove的動作,假如上面這個算法我改成這樣,不使用StringBuilder,而直接使用Sting.


public List<String> letterCombinations(String digits) {
        if (digits == null || digits.length() == 0) {
            return new ArrayList<>();
        }
        String[] one = { "" };
        String[] two = { "a", "b", "c" };
        String[] three = { "d", "e", "f" };
        String[] four = { "g", "h", "i" };
        String[] five = { "j", "k", "l" };
        String[] six = { "m", "n", "o" };
        String[] seven = { "p", "q", "r", "s" };
        String[] eight = { "t", "u", "v" };
        String[] nine = { "w", "x", "y", "z" };
        String[] zero = { "" };

        HashMap<Integer, String[]> map = new HashMap<>();
        map.put(0, zero);
        map.put(1, one);
        map.put(2, two);
        map.put(3, three);
        map.put(4, four);
        map.put(5, five);
        map.put(6, six);
        map.put(7, seven);
        map.put(8, eight);
        map.put(9, nine);

        ArrayList<String> result = new ArrayList<>();
        int[] allNum = new int[digits.length()];
        for (int i = 0; i < digits.length(); i++) {
            allNum[i] = Integer.parseInt(digits.substring(i, i + 1));
        }
        phoneNumberHelper(result, "", 0, allNum, map);
        return result;

    }

    private void phoneNumberHelper(ArrayList<String> result, String current, int index, int[] allNum,
            HashMap<Integer, String[]> com) {
        if (index == allNum.length) {
            result.add(current);
            return;
        } else {
            String[] possibilities = com.get(allNum[index]);
            for (int i = 0; i < possibilities.length; i++) {
                //不使用StringBuilder,直接使用String連接一個String,這個做法其實和new String()是一樣的。創(chuàng)建了一個新的String,
                phoneNumberHelper(result, current + possibilities[i], index + 1, allNum, com);
            }
        }
    }


算法大部分都是相同的,但是直接直接使用String連接一個String,這個做法其實和new String()是一樣的。創(chuàng)建了一個新的String,和我們的排列組合里面的new ArrayList<>(temp)這段代碼一樣,雖然最終結(jié)果沒錯,但是卻喪失了回溯算法的本質(zhì)和優(yōu)勢,浪費了空間。一行之差。

4.N皇后問題

這一篇的最后一個問題,當然非N皇后問題莫屬,題目太經(jīng)典,我就不浪費篇幅再寫一次算法了。我這次就著重分析一下這個怎么把這個問題抽象成回溯問題。怎么把這個問題模型化,通俗點說,怎么把這個問題和排列組合問題找到相同的地方,方便大家理解。

我們每一次在棋盤上放棋子,其實就是從原集合,往答案集合中加入元素的一個動作。和排列組合問題不同的是,往答案集合里面加入元素這個動作不是每次都是合法的,而排列組合是無論怎么加都對。

舉個栗子

u=4191624954,1499040036&fm=27&gp=0.jpg

在放置第五個棋子的時候,我們在遍歷的過程中需要判斷第五個可以合法的放置在哪個位置,第一行?不行,因為第一個棋子也在第一行。第二第三第四同理。都通不過我們的檢查。所以在for循環(huán)中要對之前已經(jīng)放置的棋子做比較,看看能不能放置到我們想放置的位置,如果不行,那么我們需要回退到上一層。

所以N皇后問題最后的難點就在于,怎么表示放置棋子的位置?怎么做所謂的合法檢查?這些大家可以自己思考一下再用java實現(xiàn)一下。

當我以前在復(fù)習(xí)N皇后問題的時候,我有那么一剎那和排列組合問題做了個比較,頓時恍然大悟。原來原理是相同的。

這次的回溯算法的分析就到此位置,下一次我會做一個更全面的深度優(yōu)先的算法分析。

祝大家新年快樂!!

u=3534538955,3983762870&fm=27&gp=0.jpg
最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容