Android程式設計師面試會遇到的演算法(part 3 深度優先搜尋-回溯backtracking)

qing的世界發表於2018-05-09

上一遍文章我們過了一次廣度優先演算法,算是比較好理解的,因為模式比較固定,使用佇列再進行while() 迴圈,既可以滿足大部分時候的需求。這一次我們來開始學習/複習一下我們的深度優先演算法。深度優先演算法其實在很多地方都可以應用到,其實在我的看法,只要搜尋集合相對固定,並且使用到遞迴的演算法都可以算是深度優先。而且在學習完回溯演算法的很多題目之後,大家也可以更直觀的體驗到,很多時候回溯也是暴力搜尋的一種程式上的實現而已。所以,

1.回溯

2.深度優先

3.暴力搜尋

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

那麼進入正題


1.理解遞迴

如果是計算機專業的同學可能可以忽略這一小節。

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

其實遞迴的方法和一般的方法有什麼區別呢?答案是完全沒有,遞迴的方法和一般的方法完全沒有區別。一個標準的方法/函式,都是需要在方法/函式棧裡面進行呼叫和返回的。舉個栗子。

static void a(){
	b();
}

static void b(){
	c();
}


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


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


複製程式碼

下面這段程式碼在方法棧中的執行過程如下兩圖所示。

方法執行

方法結束

如上圖所示,所有的方法在執行結束之後都會返回,return,這個return,代表的是return到該方法的呼叫者,也就是執行該方法的方法內,也就是上一層中。

理解了這個,遞迴也就很好理解,同樣是使用方法/函式棧,只不過是呼叫的方法是相同的方法而已。

2.理解回溯

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

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

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

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

1開頭的集合答案

可以看出來,我們每次獲取答案都要向上退後一步,回到之前的狀態,選取不同的元素放入結果集合。這個過程其實就是我們之前所講的回溯。至於怎麼樣遍歷集合,根據題目的要求我們可以有不同的策略,一般的回溯演算法都是涉及列表的遍歷,for迴圈足矣。

我們來看看程式碼

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答案集合滿了,我們加入到最終的結果集合內。
    if(temp.size() == list.size()){
        result.add(new ArrayList(temp));
    }
    else{
        //直接使用for迴圈進行對原集合的遍歷
        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掉最後一個元素,因為我們的全部的下一層遞迴已經結束,所以可以把該層的數字刪掉,進入for迴圈的下一個遍歷的開始了。這裡這個remove的動作就是我們所謂的“回溯”
                temp.remove(temp.size()-1);      
            }
        }
    }

}

複製程式碼

從以上程式碼我們可以看出,回溯演算法其實就是普通的遞迴,但是加上了對集合遍歷(for 迴圈)的過程,大家可以體會一下一個小小的區別。假如在以上的程式碼中,我們的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);
                //進入下一層遞迴,不刪除最後一個元素,每次都建立一個新的temp列表
                permutateHelper(result,new ArrayList<>(temp),list,visited);
                visited.remove(current);
            }
        }
    }

}

複製程式碼

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

關於排列組合的題目,還有更加難的,比如集合中有重複元素怎麼辦,如果不只是求全排列,而是求子集呢?

有興趣的同學可以看看leetcode上的題目:

1.Permutation II 2.Subset

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

3. 電話鍵盤

例題來自leetcode的一道關於電話鍵盤的題目

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

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

這題和上一題的區別是,搜尋集合不再是一個固定的大集合了,而是若干的小集合,每個數字對應一個小集合,滿足搜尋結果的答案的標準也不一樣,不再是以集合的元素數量為標準,而是以我們的輸入數字的數量為標準。

我們直接看程式碼

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, 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迴圈,遍歷當前該數字的字母集合
			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);
			}
		}
	}


複製程式碼

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

搜尋集合在每一個狀態都不一樣,排列組合在每個步驟都是相同的搜尋集合,電話按鍵根據按下的數字不一樣,對應的字母不一樣。

對於回溯演算法的精髓,大家通過對兩個題目的練習可以發現,就在於那個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()是一樣的。建立了一個新的String,
				phoneNumberHelper(result, current + possibilities[i], index + 1, allNum, com);
			}
		}
	}


複製程式碼

演算法大部分都是相同的,但是直接直接使用String連線一個String,這個做法其實和new String()是一樣的。建立了一個新的String,和我們的排列組合裡面的new ArrayList<>(temp)這段程式碼一樣,雖然最終結果沒錯,但是卻喪失了回溯演算法的本質和優勢,浪費了空間。一行之差。

4.N皇后問題

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

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

舉個栗子

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

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

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

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

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

祝大家新年快樂!!

u=3534538955,3983762870&fm=27&gp=0.jpg

相關文章