Android程式設計師會遇到的演算法(part 7 拓撲排序)

qing的世界發表於2019-04-07

Android程式設計師面試會遇到的演算法系列:

Android程式設計師面試會遇到的演算法(part 1 關於二叉樹的那點事) 附Offer情況

Android程式設計師面試會遇到的演算法(part 2 廣度優先搜尋)

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

Android程式設計師面試會遇到的演算法(part 4 訊息佇列的應用)

Android程式設計師會遇到的演算法(part 5 字典樹)

Android程式設計師會遇到的演算法(part 6 優先順序佇列PriorityQueue)

Android程式設計師會遇到的演算法(part 7 拓撲排序)

這一期是我打算做的安卓演算法面試系列的最後一期了,一來是自從來了美國之後,每天的工作實在太忙了,除了週末之外很少時間能完完整整的總結一些東西。不過第二個原因,也是最重要的原因,就是在這之後我打算好好沉澱積累一下,等有更多的心得體會再分享出來。

這期我打算聊一聊拓撲排序這個演算法。在Java裡面具體的實現和一些細節。這裡我儘量不用太多的專業術語,用比較通俗的講法來解釋一些概念。(其實是我的狗嘴也吐不出啥象牙。。。以前學的演算法知識早就還給老師了)

d2fce9868ad44bb98cb89ae4d780c369_th.jpg

其實拓撲排序和廣度優先搜尋演算法在程式碼上真的很像,說穿了其實就是圖的遍歷,只不過遍歷的順序和規則有些少許不同。

相信各位學習電腦科學專業的同學應該都對高等數學或者大學物理有深刻的陰影。。。我還記得我當時考完大學物理2已經覺得自己要掛了,沒忍住給老師打了一個電話求情,雖然最後老師說我離掛科還遠,但是69分的大學物理2也讓我與那個學期的獎學金無緣了。

download (1).jpeg

可能有人問為什麼計算機專業不直接學Java,C++或者web開發?一定要先上大學物理或者高等數學?說了這麼多廢話,我想說的重點是,每個學科都有一個自己的課程安排,學習一門專業課之前必須要有一些基礎課程的支撐才行。我們不能不學高等數學和線性代數直接跳去學機器學習,我們也不能不學Java或者python直接上手web專案。這也引申出了這一期的內容,拓撲排序, 怎麼樣在已知某些節點的前序(prerequisites) 節點的情況下,把這些節點的順序排列出來。就好比,我知道一定課程的前後順序的情況下,把我這四年大學的課程時間安排排列出來,最後列印成課程表。

Screen Shot 2019-04-06 at 12.12.31 PM.png

比如上面這幅圖,我們怎麼可以將其課程的依賴關係,按照先後順利排列起來,這就是拓撲排序可以解決的其中一種,也是最經典的問題。


1.怎麼定義資料結構

首先對於圖來說,我們要知道每個節點有多少子節點,也就是後繼節點,在課程安排例子裡面可以理解為,學了A課程之後可以學的課程B。那麼A就是B的前驅節點,B就是A的後繼節點。 在Java中我們可以使用HashMap來實現,根據題目的不同,有時候也可以使用別的資料結構比如二維陣列。不過我個人比較喜歡HashMap。

那麼節點的關係可以用一個HashMap來表達,課程使用String 來表示

//節點的後繼節點
HashMap<String, HashSet<String>>  courses = new HashMap();

複製程式碼

同時,在拓撲排序中,我們還需要記錄某個節點的前驅節點的數量,因為只有當某個節點的前驅節點為0的時候,我們才能處理該節點。對應到課程學習中,就是隻有當我們學習完畢了某個課程的所有前驅課程,我們才能學習該課程。比如圖中的計算機網路課程,需要先學習組成原理和通訊原理一樣。

//記錄每個點的前驅節點數量
HashMap<String,Integer> preCount = new HashMap<String,Integer>
複製程式碼

2.拓撲排序

假設我們已經有了這兩個資料結構並且資料已經填充好了。我們就可以開始進行拓撲排序了。演算法很簡單,把前驅節點數量為0的節點先放入佇列,每次從佇列彈出的時候把自己的後繼節點的preCount數量減少1,假如此時後繼節點的preCount數量減少到0了,就把節點加入到佇列中。在這個例子裡面,彈出一個節點的意義就是學習一門課程。

這個很好理解,比如我們學習完組成原理,距離學習計算機網路還差一門課。

Screen Shot 2019-04-06 at 1.10.59 PM.png

當我們把通訊原理學習完畢之後,計算機網路的前驅節點數量從1減少為0,我們才可以學習計算機網路。

用程式碼來表示的話,如下

//課程排程佇列
		Queue<String> queue = new LinkedList<>();
		//最後課程的順序
		List<String> sequence = new ArrayList<>();
		while (!queue.isEmpty()) {
			//獲取當前佇列中的第一個課程,將其加入到最後的課程順序列表中
			String currentCourse = queue.poll();
			sequence.add(currentCourse);
			
			//每當一個課程結束學習之後,找到它的後繼課程
			for (String course : courses.get(currentCourse)) {
				//加入後繼課程的前驅節點數量還是大於0 的,說明該課程還沒被學習
				if (preCount.get(course) > 0) {
					//減少該後繼課程的前驅節點數量
					preCount.put(course, preCount.get(course) - 1);
					//如果前去梳理減到0,說明我們已經可以開始學習該課程了,
					//加到佇列裡面
					if (preCount.get(course) == 0) {
						queue.add(course);
					}
				}
			}

		}
       return sequence;
複製程式碼

3.和廣度優先的不同

其實看程式碼大家也可以知道,拓撲排序其實就是廣度優先搜尋的一種,只不過拓撲排序在插入子節點到佇列的時候,有一些限制。就是在這裡:

 if (preCount.get(course) == 0) {
                        queue.add(course);
                    }

複製程式碼

一般的廣度優先只要遍歷了當前節點,就要把當前節點的所有自己點都一股腦的插入到佇列中。在拓撲排序裡面,因為每個節點的前驅節點數量可能會大於1,所以,不能簡單的插入子節點(或者說後繼節點),而是需要額外的資料結構,preCount這個HashMap來決定是否可以把後繼節點插入。

4.有環?

圖搜尋的一個經典問題是,如果有環怎麼辦?同樣的,在拓撲排序裡面,也可能出現存在環的情況。比如

Screen Shot 2019-04-06 at 1.26.07 PM.png

在下圖這種情況,學生就沒辦法學了。。。。

download.jpeg

但是在拓撲排序下面,判斷是否有環的方法還不太一樣,比如寬度優先搜尋的情況下,我們可以用一個叫visited的HashSet來記錄已經訪問過的節點。但是拓撲排序不行。

比如下圖這種情況

Screen Shot 2019-04-06 at 1.32.26 PM.png

當我們學習完A之後,其實我們是不能遍歷完全所有節點的,因為B和C的前驅節點數量都為1,程式在跑完第一個迴圈

  while (!queue.isEmpty()) {
            //獲取到A
            String currentCourse = queue.poll();
            sequence.add(currentCourse);

複製程式碼

之後,就會直接結束了。 所以其實我們判斷環的方法要換成->判斷我們是否能學習完所有課程。

HashMap<String, HashSet<String>>  courses = new HashMap();
//假如最後我們能學習完所有課程
if(result.size() == course.keySet().size()){
     return true;
}else{
     return false;
}

複製程式碼

5.應用的範圍

拓撲排序的題目可以出現很多種,但是都是萬變不離其宗,掌握好我們需要的資料結構,熟練的寫出廣度優先演算法的模板程式碼, 其實就萬事大吉了。以後比如還有類似的問題,像安裝軟體,比如要安裝A,要先安裝依賴C,等等之類的問題,相信大家都可以迎刃而解了。總結的來講,一旦我們發現需要進行對依賴之間進行排序的,用拓撲排序都沒毛病。

6.題目程式碼

Leetcode 裡面的Course Schedule, 大家可以自己練習一下。 我沒有講的部分就是資料初始化的部分,不過很簡單,大家自己摸索。 我的答案

public int[] findOrder(int numCourses, int[][] prerequisites) {
		// record dependecy counts
		HashMap<Integer, Integer> dependeciesCount = new HashMap<>();
		HashMap<Integer, HashSet<Integer>> dependeciesRelation = new HashMap<>();
		for (int i = 0; i < numCourses; i++) {
			dependeciesCount.put(i, 0);
			dependeciesRelation.put(i, new HashSet<>());
		}
		for (int i = 0; i < prerequisites.length; i++) {
			int pre = prerequisites[i][1];
			int suf = prerequisites[i][0];
			dependeciesCount.put(suf, dependeciesCount.get(suf) + 1);
			dependeciesRelation.get(pre).add(suf);
		}
		Queue<Integer> queue = new LinkedList<>();
		for (Map.Entry<Integer, Integer> entry : dependeciesCount.entrySet()) {
			if (entry.getValue() == 0) {
				queue.add(entry.getKey());
			}
		}

		int[] index = new int[numCourses];
		int currentIndex = 0;
		while (!queue.isEmpty()) {
			Integer currentCourse = queue.poll();
			index[currentIndex] = currentCourse;
			currentIndex++;

			for (Integer nei : dependeciesRelation.get(currentCourse)) {
				if (dependeciesCount.get(nei) > 0) {
					dependeciesCount.put(nei, dependeciesCount.get(nei) - 1);
					if (dependeciesCount.get(nei) == 0) {
						queue.add(nei);
					}
				}
			}

		}

		int[] empty = {};
		return currentIndex == numCourses ? index : empty;
	}
複製程式碼

後記

最後一期演算法教程寫完了,其實感覺如果大家能把這7個大塊給充分理解,面對大部分的公司的演算法面試其實也沒多大問題了。這也是我2017年-2018年初面試各個公司的演算法題的一些心得體會。 雖然我的標題一直都是以面試 開頭,但是我覺得最重要的還是學習,或者說是複習演算法的這個過程。去理解去學習的這個過程才是精髓。當然,這些內容也是上學就應該學好的,現在重新複習,也算是還債(technical debt)。。。。 回頭看這個系列的初衷,也是希望大家在面對面試的同時,能回顧一些以前上學時候的知識,做到溫故而知新。只要讀者看了我的文章,能發出一種“挖槽這個以前好像學過啊”的感嘆,我也就滿足了~

2019年對我來說是一個新的起點,我也要不停的督促自己好好工作,多反思多學習,以後爭取能分享更多高質量的文章和知識。希望自己永遠不要忘掉當初雄心壯志面試矽谷公司的那顆赤子之心。

相關文章