長文預警!本文作者Vardan Grigoryan是一名後端程式設計師,但他認為圖論(應用數學的一個分支)的思維應該成為程式設計師必備。
本文從七橋問題引入,將會講到圖論在Airbnb房屋查詢、推特推送更新時間、Netflix和亞馬遜影片/商品個性化推薦、Uber尋找最短路線中的應用,附有大量手把手程式碼和手繪插圖,值得收藏。
圖論的傻瓜式教程
圖論是電腦科學中最重要、最有趣,同時也是最容易被誤解的領域之一。理解並使用圖論有助於我們成為更好的程式設計師。圖論思維應該成為我們的思維方式之一。
先看一下枯燥的定義……圖是一組節點V和邊E的集合,包括有序對G =(V,E)……?
在試圖研究理論並實現一些演算法的同時,我發現自己經常因為這些理論和演算法太過艱深而被卡住......
事實上,理解某些東西的最好方法就是應用它。我們將展示圖論的各種應用,及其詳細的解釋。
對於經驗豐富的程式設計師來說,下面的描述可能看起來過於詳細,但相信我,作為一個過來人,詳細的解釋總是優於簡潔的定義的。
所以,如果你一直在尋找一個“圖論的傻瓜式教程”,你看這篇文章就夠了。
免責宣告
免責宣告1:我不是電腦科學/演算法/資料結構(特別是圖論方面)的專家。 我沒有參與本文談及的公司的任何專案。本文問題的解決辦法並非完美,也有改進的空間。 如果你發現任何問題或不合理的地方,歡迎你發表評論。 如果你在上述某家公司工作或參與相應的軟體專案,請提供實際解決方案。請大家耐心閱讀,這是一篇很長的文章。
免責宣告2:這篇文章在資訊表述的風格上與其它文章有所不同,看起來可能圖片也與其所在部分的主題不是十分契合,不過耐心的話,相信最終會發現自己對圖片有完整的理解。
免責宣告3:本文主要是將初級程式設計師作為受眾而編寫的。在考慮目標受眾的同時,但更專業的人員也將透過閱讀本文有所發現。
哥尼斯堡的七橋問題
讓我們從經常在圖論書中看到的“圖論的起源”開始——哥尼斯堡七橋問題。
故事發生在東普魯士的首都哥尼斯堡(今俄羅斯加里寧格勒),普萊格爾河(Pregolya)橫貫其中。十八世紀在這條河上建有七座橋,將河中間的兩個島和河岸聯結起來。
現在這個地區變成這樣啦
問題是如何才能不重複,不遺漏地一次走完七座橋,最後回到出發點。他們當時沒有網際網路,所以就得找些問題來思考消磨時間。以下是18世紀哥尼斯堡七座橋樑的示意圖。
你可以嘗試一下,用每座橋只透過一次的方式來走遍整個城市。“每座橋”意味著不應該有沒有透過的橋。“只有一次”意味著不得多次透過每座橋。如果你對這個問題很熟悉,你會知道無論怎麼努力嘗試這都是不可能的;再努力最終你還是會放棄。
Leonhard Euler(萊昂哈德·尤拉)
有時,暫時放棄是合理的。尤拉就是這樣應對這個問題的。雖然放棄了正面解決問題,但是他選擇了去證明“每座橋恰好走過並只走過一遍是不可能的”。讓我們假裝“像尤拉一樣思考”,先來看看“不可能”。
圖中有四塊區域——兩個島嶼和兩個河岸,還有七座橋樑。先來看看連線島嶼或河岸的橋樑數量中的模式(我們會使用“陸地”來泛指島嶼和河岸)。
每塊區域連線橋樑的數量
只有奇數個的橋樑連線到每塊陸地。這就麻煩了。
兩座橋與陸地的例子
在上面的插圖中很容易看到,如果你透過一座橋進入一片陸地,那你可以透過走第二座橋離開這片陸地。
每當出現第三座橋時,一旦穿過橋樑進入,那就沒法走上每座橋且只走一次離開了。如果你試圖將這種推理推廣到一塊陸地上,你可以證明,如果橋樑數量是偶數的話,總有辦法離開這塊陸地,但如果橋樑數量是奇數,就不能每橋走且只走一次的離開。試著記住這個結論。
如果新增一個新的橋樑呢?透過下面這張圖,我們可以觀察各個陸地所連線的橋的數量變化及其影響。
注意新出現的橋
現在我們有兩個偶數和兩個奇數。讓我們畫一條包括了新橋樑的路線。
Wow
橋樑的數量是偶數還是奇數至關重要。現在的問題是:我們知道了橋樑數量是否就能夠斷定問題可不可解?為了解決問題橋的數量必須是偶數嗎?尤拉找到了一種方法證明這個問題。而且,更有趣的是,具有奇數個橋樑連線的陸地數量也很重要。
尤拉開始將陸地和橋樑“轉換”成我們所知道的圖。以下是代表哥尼斯堡七橋的圖的樣子(請注意,我們“暫時”增加的橋並不在圖中):
需要特別注意的一點是對問題的概括和抽象。無論何時你解決一個具體的問題,最重要的是歸納類似問題的解決方案。在這個例子中,尤拉的任務是歸納橋樑問題,以便能在未來推廣到類似問題上,比如說世界上所有的橋樑問題。視覺化還有助於以不同視角來看問題。下面的每張圖都表示前面描述的橋樑問題:
所以說圖形化可以很好地描繪問題,但我們需要的是如何使用圖來解決哥尼斯堡問題。觀察從圓圈中出來的線的數量。從專業角度而言,我們將稱之為“節點”(V),以及連線它們的“邊”(E)。V代表節點(vertex),E代表邊(edge)。
下一個重要的概念就是所謂的節點自由度,即入射(連線)到節點的邊的數量。在上面的例子中,與陸地連結的橋的數目可以視為圖的節點自由度。
尤拉證明了,一次並僅有一次穿過每條邊(橋)來遍歷圖(陸地)嚴格依賴於節點(陸地)自由度。由這些邊組成的路徑叫做尤拉路徑。尤拉路徑的長度是邊的數量。
有限無向圖G(V,E)的尤拉路徑是一條使G的每條邊出現並且只出現一次的路徑。如果G有一條尤拉路徑,那麼就可以稱之為尤拉圖。
定理:一個有限無向連通圖是一個尤拉圖,當且僅當只有兩個節點有奇數自由度或者所有節點都有偶數自由度。在後一種情況下,曲線圖的每條尤拉路徑都是一條閉環,前者則不是。
左圖正好有兩個節點具有奇數自由度,右圖則是所有節點都是奇數自由度
首先,讓我們澄清上述定義和定理中的新術語。
有限圖是具有有限數量的邊和節點的圖。
圖可以是有向圖也可以是無向圖,這是圖的有趣特性之一。舉個非常流行的關於有向圖和無向圖的例子,Facebook vs. Twitter。Facebook的友誼關係可以很容易地表示為無向圖,因為如果Alice是Bob的朋友,那麼Bob也必須是Alice的朋友。 沒有方向,都是彼此的朋友。
還要注意標記為“Patrick”的節點,它有點特別,因為沒有任何連線的邊。但“Patrick”點仍然是圖的一部分,但在這種情況下,我們會說這個圖是不連通的,它是非連通圖(與“John”,“Ashot”和“Beth”相同,因為它們與圖的其他部分分開)。在連通圖中沒有不可達的節點,每對節點之間必須有一條路徑。
與Facebook的例子相對,如果Alice在Twitter上關注Bob,那不需要Bob回粉Alice。所以一個“關注”關係必須有一個方向來指示是誰關注誰,圖表示中就是哪個節點(使用者)有一個有向邊(關注)到另一個節點。
現在,知道什麼是有限連通無向圖後,讓我們回到尤拉圖:
為什麼我們首先討論了哥尼斯堡橋問題和尤拉圖呢?透過思考實際問題和上述解決方案,我們觸及了圖理論的基本概念(節點,邊,有向,無向),避免了只有枯燥的理論。然而我們還未完全解決尤拉圖和上述問題。我們現在應該轉向圖的計算機表示,因為這對我們程式設計師來說是最重要的。透過在計算機程式中表示圖,我們能設計一個標記圖路徑的演算法,從而確定它是否是尤拉路徑。
圖表示法:介紹
這是一項非常乏味的任務,要有耐心。記得陣列和連結串列之間的競爭嗎?如果你需要對元素進行快速訪問,請使用陣列;如果你需要對元素進行快速插入/刪除等修改,請使用列表。
我不相信你會在“如何表示列表”的問題上有困惑。但是就圖的表示而言,實際上卻有很多麻煩,因為首先需要你決定的是用什麼來表示一個圖。相信我,你不會喜歡這個過程的。鄰接表,鄰接矩陣,還是邊表?拋個硬幣來決定?
決定了用什麼表示圖了嗎?我們將從一棵樹開始。你應該至少看過一次二叉樹(以下不是二叉搜尋樹)。
僅僅因為它由頂點和邊組成,它就是一個圖。你也許還記得最常見的一棵二叉樹(至少在教科書中)的樣子。
對於已經熟悉“樹”的人來說,這段文字可能看起來太過細緻了,但我還是要詳細解釋以確保我們的理解一致(請注意,我們仍在使用虛擬碼)。
BinTreeNode<Apple>* root = new BinTreeNode<Apple>("Green"); root->left = new BinTreeNode<Apple>("Yellow"); root->right = new BinTreeNode<Apple>("Yellow 2"); BinTreeNode<Apple>* yellow_2 = root->right; yellow_2->left = new BinTreeNode<Apple>("Almost red"); yellow_2->right = new BinTreeNode<Apple>("Red");
如果你不熟悉樹,請仔細閱讀上面的虛擬碼,然後按照此插圖中的步驟操作:
顏色只是為了有好的視覺表現
雖然二叉樹是一個簡單的節點“集合”,每個節點都有左右子節點,但二叉搜尋樹卻由於應用了一個允許快速鍵查詢的簡單規則顯得更加有用。
二叉搜尋樹(BST)對各節點排序。你可以自由地使用任何你想要的規則來實現你的二叉樹(儘管可能根據規則改變它的名字,例如最小堆或最大堆),BST一般滿足二分搜尋屬性(這也是二叉搜尋樹名字的來源),即“每個節點的值必須大於其左側子樹中的任何值,並且小於其右側子樹中的任何值”。
關於“大於”的闡述有個非常有趣的評論。這對於BST的性質也至關重要。當將“大於”更改為“大於或等於”,插入新節點後,BST可以保留重複的值,否則只將保留一個具有相同值的節點。你可以在網上找到關於二叉搜尋樹非常好的文章,我們不會提供二叉搜尋樹的完整實現,但為了保持一致性,我們還是會舉例說明一個簡單的二叉搜尋樹。
Airbnb房屋查詢例項:二叉搜尋樹
樹是一種非常實用的資料結構,你可能在專案開始的時候沒有打算應用樹這種結構,但是不經意間就用到了。讓我們來看一個構想的但非常有價值的例子,關於為什麼要首先使用二叉搜尋樹。
二叉搜尋樹中這個名字中包括了“搜尋”,所以基本上所有需要快速查詢的東西都應該放進二叉搜尋樹中。應該不代表必須,在程式設計中最重要的事情就是要牢記用合適的工具解決問題。在許多案例中用複雜度為O(N)的簡單連結串列查詢比複雜度為O(logN)的二叉搜尋樹查詢更好。
通常我們會使用一個庫實現BST,最常用的是C++裡面的std::set或是std::map,然而在這個教程中,我們完全可以重新造輪子,重寫一個BST庫(BST幾乎可以透過任何通用的程式語言庫實現,你可以找到你喜歡的語言的相應文件)。
下面是我們將要解決的問題:
Airbnb房屋搜尋截圖
如何用一些過濾器儘可能快地根據一些查詢語句搜尋住房是一個困難的問題;當我們考慮到Airbnb儲存了上百萬的房源資訊後這個問題會變得更困難。
所以當使用者搜尋住房時,他們有機會接觸到資料庫中大概四百萬個房源記錄。當然,網站主頁上只能顯示有限個排名靠前的住房列表,並且使用者幾乎不會好奇地去瀏覽住房列表裡百萬量級的住房資訊。我沒有任何關於Airbnb的分析,但是我們可以用一個在程式設計中叫做“假設”的強大工具,所以我們假設一個使用者最多瀏覽一千套房源。
這裡最重要的因素是實時使用者數量,因為這會導致資料結構、資料庫選擇的不同和專案結構的不同。可以很明顯的看出,如果只有100個使用者,我們一點不必擔心,但如果一個國家的實時使用者數量遠超過百萬量級的闕值,我們必須要十分明智地做每一個決定。
是的,“每一個”決定都需要明智的決策。這就是為什麼公司在服務方面力求卓越而僱傭最好的員工。Google, Facebook, Airbnb, Netflix, Amazon, Twitter還有許多其他的公司,他們需要處理大量的資料,做出正確的選擇以透過每秒數以百萬的資料來服務數以百萬的實時使用者,這一切從招聘好的工程師開始。這就是為什麼我們程式設計師在面試中要與資料結構、演算法和解決問題的邏輯作鬥爭,因為他們所需要工程師需要具有以最快最有效的方式解決大規模問題的能力。
現在案例是,一個使用者訪問Airbnb主頁並且希望找到最合適的房源。我們如何解決這個問題?(注意這是一個後端問題,所以我們不需關心前端或者網路阻塞或者多個http連結一個http或者Amazon EC2連結主叢集等等)。
首先,我們已經熟悉了一種強大的程式設計工具(是假設而不是抽象),讓我們從假設我們所處理的資料都能放在記憶體中。你也可以假設我們的記憶體已經足夠大。多大的記憶體是足夠的呢?這是另外一個好問題。儲存真實的資料需要多大的記憶體,如果我們正在處理四百萬單位個資料(假設),而且我們或許知道每個單位的大小,那麼我們就可以輕易的算出需要的記憶體大小,例如,4M*一個單位的大小。讓我們來考慮一個“home”物件和他的特徵,
事實上,讓我們考慮至少一個在解決問題時需要處理的特徵(一個“home”就是一個單位)。我們把它表示為C++語言結構的虛擬碼,你可以很輕鬆的將它轉換成MongoDB架構或者其它你想要的形式,我們只是討論特徵名字和型別(想象為節約空間所使用的二元位域或位集)。
// feel free to reorganize this struct to avoid redundant space // usage because of aligning factor // Remark 1: some of the properties could be expressed as enums, // bitset is chosen for as multi-value enum holder. // Remark 2: for most of the count values the maximum is 16 // Remark 3: price value considered as integer, // int considered as 4 byte. // Remark 4: neighborhoods property omitted // Remark 5: to avoid spatial queries, we're // using only country code and city name, at this point won't consider // the actual coordinates (latitude and longitude) struct AirbnbHome { wstring name; // wide string uint price; uchar rating; uint rating_count; vector<string> photos; // list of photo URLs string host_id; uchar adults_number; uchar children_number; // max is 5 uchar infants_number; // max is 5 bitset<3> home_type; uchar beds_number; uchar bedrooms_number; uchar bathrooms_number; bitset<21> accessibility; bool superhost; bitset<20> amenities; bitset<6> facilities; bitset<34> property_types; bitset<32> host_languages; bitset<3> house_rules; ushort country_code; string city; };
假設,上面的結構顯然不是完美的,還有許多假設和未完成的部分(免責宣告裡我也說了)。我只是觀察Airbnb的過濾器和設計屬性列表來滿足查詢需求。這只是一個例子。現在我們要計算每個AirbnbHome物件需要佔用多少記憶體。
名字只是一個支援多種語言標題的wstring,這意味著每個字元會佔用2位元組(如果我們用其它語言可能不需要考慮字元大小,但是在C++中,char佔用1位元組,wchar佔用2位元組)。
對於Airbnb住房列表的快速瀏覽讓我們假設住房的名字最多包含100個字元(大多數都在50個字元左右,而不是100),所以我們將假設最大值為100,這就意味著佔用不超過200位元組的記憶體。uint是4位元組,uchar為1位元組,ushort為2位元組(這些都是假設)。
假設圖片用第三方儲存服務儲存,比如Amazon S3(目前為止,我知道這個假設對於Airbnb來說很可能是真實的,但是這只是一個假設),另外我們有這些照片的連結,考慮到連結沒有標準的大小限制,但通常都不會超過2083個字元,所以我們用這個當作連結的最大大小。因為每個房源平均會有5張照片,所以圖片最多佔用10KB記憶體。
讓我們重新考慮,通常儲存服務提供的連結有一部分是固定的。
例如http(s)://s3.amazonaws.com/<bucket>/<object>,這是構建連結時常見的規律,所以我們只需要儲存真實圖片的ID。讓我們假設我們用一種ID生成器來對圖片產生20位元組長的不重複的ID字串。
那麼每張圖片的連結就會是這樣: https://s3.amazonaws.com/some-know-bucket/<unique-photo-id>
這讓我們節省了空間,儲存五張圖片的ID只需要100位元組的記憶體。同樣的小技巧也可以應用在房東的ID上面,例如房東的ID需要20位元組的記憶體(事實上我們對使用者只用了整數ID,但是考慮到一些資料庫系統有自己專用的ID生成器,如MongoDB,我們假設20位元組長度的ID只是一箇中位數,使它可以幾乎滿足所有資料庫系統的變化。Mongo產生的ID大小為24位元組)。
最後,我們用4個位元組表示長度為32的位集,對於長度大於32小於64的位集用8個位元組表示。注意這裡的假設,我們在這個例子中用位集表示任意一個數值特徵,但是它也可以取多個值,用另一種方法來說,就是一種多項選擇的核取方塊。
例如,每個Airbnb房源都有一個關於可用設施的列表,比如熨斗、洗衣機、電視、wifi、衣架、煙霧報警、甚至筆記本辦公區域等。或許房子裡有超過20種設施,但我們依然把這個數目固定為20,因為這個數目是Airbnb過濾頁面中可選擇的裝置數量。
如果我們用合理的順序排列裝置的名字,位集可以幫助我們節省一些空間。比如說,如果一個房子裡有上述提到的裝置(在截圖中打勾的裝置),我們可以在位集裡對應的位置填充一個1。
位集能夠用20個位元儲存20個不同值
例如,檢查房間裡是否有“洗衣機”:
bool HasWasher(AirbnbHome* h) { return h->amenities[2]; }
或者更專業的:
const int KITCHEN = 0; const int HEATING = 1; const int WASHER = 2; //... bool HasWasher(AirbnbHome* h) { return (h != nullptr) && h->amenities[WASHER]; } bool HasWasherAndKitchen(AirbnbHome* h) { return (h != nullptr) && h->amenities[WASHER] && h->amenities[KITCHEN]; } bool HasAllAmenities(AirbnbHome* h, const std::vector<int>& amenities) { bool has = (h != nullptr); for (const auto a : amenities) { has &= h->amenities[a]; } return has; }
你可以儘可能地改善程式碼(並且改正編寫的錯誤),我們只是想強調在這個問題背景下使用位集的思想。
同樣也適用於“房屋守則”、“住房型別”等。正如上面程式碼的註釋中提到的,我們不會儲存經緯度以避免地理位置上的查詢,我們會儲存國家程式碼和城市名字來縮小地址的查詢範圍(刪除掉街道只是為了簡化)。
國家程式碼可以用2或3個字元或3個數字表示,我們會用數字化的表示並且用ushort表示每個國家程式碼。
城市往往比國家多,所以我們不能用“城市程式碼”(儘管我們可以生成一些作為內部使用),我們直接使用真實的城市名稱,為每個名字平均預留50位元組的記憶體,對於特殊的城市名比如Taumatawhakatangihangakoauauotamateaturipukakapikimaungahoronukupokaiwhenuakitanatahu (85 個字母),我們最好用另外的布林變數來表示這個是一個特殊的非常長的城市名字(不要試圖讀出這個名字)。
所以,記住字串和向量的記憶體消耗,我們將新增額外的32位元組確保最後結構的大小不會超出。我們還會假設我們在64位的系統上工作,儘管我們為int和short選擇了最合適的值。
// Note the comments struct AirbnbHome { wstring name; // 200 bytes uint price; // 4 bytes uchar rating; // 1 byte uint rating_count; // 4 bytes vector<string> photos; // 100 bytes string host_id; // 20 bytes uchar adults_number; // 1 byte uchar children_number; // 1 byte uchar infants_number; // 1 byte bitset<3> home_type; // 4 bytes uchar beds_number; // 1 byte uchar bedrooms_number; // 1 byte uchar bathrooms_number; // 1 byte bitset<21> accessibility; // 4 bytes bool superhost; // 1 byte bitset<20> amenities; // 4 bytes bitset<6> facilities; // 4 bytes bitset<34> property_types; // 8 bytes bitset<32> host_languages; // 4 bytes, correct me if I'm wrong bitset<3> house_rules; // 4 bytes ushort country_code; // 2 bytes string city; // 50 bytes };
420位元組加上之前提到的額外32位元組,總共452位元組的容量,考慮到有時候你或許會受困於一些校準因素,所以讓我們把容量調到500位元組。那麼每條房屋資訊佔用500位元組記憶體,對於包含所有房屋資訊的列表(有時候我們會混淆列表數量和真實房屋數量,如果我犯錯了請告訴我),佔用記憶體約500位元組*4百萬=1.86GB,近似2GB,這個結果看起來很合理。
我們在建立結構的時候做了許多假設,努力節約記憶體來縮小成本,我估計會最後地結果會超過2GB,如果我在計算中出錯,請告訴我。接下來,無論我們要怎麼處理這些資料,我們都需要至少2GB的記憶體。請克服你無聊的感覺,我們才剛要開始。
現在是這項任務中最難的部分。針對這個問題選擇正確的資料結構(最高效的列表過濾方法)不是最難的部分。(對我來說)最難的是用大量的過濾器查詢這些列表。
如果只有一個查詢關鍵詞(只有一個過濾器)那麼問題會更容易解決。假設使用者只對價格感興趣,那麼我們需要做的就只是選擇在這個價格範圍內的Airbnb房源。下面是我們用二叉搜尋樹完成這項任務的例子。
想象全部四百萬的房屋物件,這棵樹會變得十分巨大。當然,記憶體也會增長很多,因為我們用BST儲存這些資料時,每個樹上的節點都有另外兩個指標指向它的左子節點和右子節點,這樣每個子節點指標都會佔用另外8位元組(假設是64位的系統)。
對於四百萬個節點來說,這些指標總共又會佔用62MB,儘管這跟2GB原有的資料大小相比不算什麼,但是我們也不能輕易忽視它們。
上個例子中樹的所有節點都可以用O(logN)複雜度的演算法找到。如果你不熟悉演算法複雜度,不能侃侃而談,我接下來會解釋清楚,或者你可以直接跳過複雜度這部分。
演算法複雜度
大多數情況下,計算一個演算法的大O複雜度是比較容易的。提醒一下,我們總是隻關心最壞的情況,也就是演算法獲得正確結果(解決問題)所需要的最大操作次數。
假設有一個包含100個元素的無序陣列,需要比較多少次我們才能找到任意指定元素(同時也考慮指定元素缺失的情況)?
我們需要將每個元素值和我們要找的值相比較,即使這個要找的元素有可能在陣列的第一位(只需要一次比較),但是我們只考慮最壞的情況(要麼元素缺失,要麼是在陣列的最後一位),所以答案是100次。
“計算”演算法複雜度其實就是在運算元和輸入大小之間找到一個依賴關係,上面那個例子中陣列有100個元素,運算元也是100次,如果陣列元素個數(輸入)增加到1423個,找到任意指定元素的運算元也增加到1423次(最壞的情況下)。
所以在這個例子中,輸入大小和運算元的關係是很明顯的線性關係:陣列輸入增加多少,運算元就增加多少。
複雜度的關鍵所在就是找到這個隨輸入數增加運算元怎麼變化的規律,我們說在無序陣列中查詢某一元素需要的時間為O(N),旨在強調查詢它的過程會佔用N次操作(或者說佔用N次操作*某個常數值,比如3N)。
另一方面,在陣列中訪問某個元素只需要常數時間,即O(1)。這是因為陣列結構是一個連續資料結構,持有相同型別的元素(別介意 JS陣列),所以“跳轉”到特定元素只需要計算其到陣列第一個元素間的相對位置。
我們非常清楚二叉搜尋樹將其節點按序排列。那麼在二叉搜尋樹中查詢一個元素的演算法複雜度是多少呢?
此時我們應該計算找到指定元素(在最壞情況下)所需要的運算元。看插圖,從根部開始搜尋,第一次比較行為將導致三種結果,(1)發現節點,(2)若指定元素值小於節點值,則繼續與節點左子樹比較,或(3)指定元素值大於節點值,則與右子樹比較。
在每一步中我們都可以把所需考慮的節點減少一半。在BST中查詢元素所需要的運算元(也就是比較次數)與樹的高度一致。
樹高是最長路徑上的節點數。在這個例子中樹高為4。如圖所示,高度計算公式為:以2為底logN+1。所以搜尋的複雜度為O(logN+1) = O(logN)。所以最壞情況下,在400萬個節點中搜尋需要log4000000=~22次比較。
再說回樹。二叉搜尋樹中元素訪問時間為O(logN)。為什麼不用雜湊表呢?雜湊表具有常數訪問時間,因此幾乎任何地方都可以用雜湊表。
在這個問題中我們必須要考慮一個很重要的條件,那就是我們必須能夠進行範圍搜尋,例如搜尋從房價80美元到162美元的區間內的房子。
在BST中只需要對樹進行一次中序遍歷並保留一個計數器就可以很容易可以得到一個範圍內的所有節點。而相同任務對於雜湊表有點費時了,所以對於特定的例子來說選擇BST還是很合理的。
不過還有另外一個地方讓我們重新考慮使用雜湊表。
那就是價格分佈。價格不可能一直上漲,大部分房子處在相同的價格區間。看上面這個截圖,直方圖向我們展示了價格的真實情況,數以百萬計的房價在同一個區間(18美元到212美元),他們平均價格是相同的。
簡單的陣列也許能起到很好的作用。假設一個陣列的索引是價格,房子列表按照價格排序,我們可以在常量時間內獲得任意價格區間(額,幾乎是常數)。下面是它的樣子(抽象來講):
就像雜湊表,我們可以按照價格訪問每一組房屋。價格相同的所有房屋都歸在同一個單獨的BST下。而且如果我們儲存的是房屋的ID而不是上面定義的整個物件(AirbnbHome結構),會節省下一些空間。最有可能的情況是,將所有房屋的完整物件儲存在一個雜湊表中,房屋ID對映房屋物件,同時建立另一個雜湊表(或者一個陣列),用房屋ID對映價格。
當使用者請求一個價格區間時,我們從價格表中獲取房屋ID,將結果分割成固定大小(也就是分頁,通常一個頁面上顯示10-30個條目),透過房屋ID獲取完整房屋資訊。記住這些方法。
同時,要記住平衡。平衡對BST來講至關重要,因為這是在O(logN)內完成操作的唯一保證。不平衡BST的問題很明顯,當你在排序中插入元素時,樹最終會變成一個連結串列,這顯然會導致具有線性時間性質的操作次數。
不過從現在開始可以忘記這些,假設我們所有的樹都是完全平衡的。再看一眼上面的插圖,每個陣列元素代表一顆樹。那如果我們把插圖改成這樣:
這更像一個“真實”的圖了。這個插圖描繪了了最會偽裝成其他資料結構的資料結構——圖。
圖表示法:進階
關於圖有一個壞訊息,那就是對於圖表示法並沒有一個固定的定義。這就是為什麼你在庫裡找不到std::graph的原因。不過BST其實可以代表一個“特殊”的圖。關鍵是要記住,樹是圖,但圖並不僅僅是樹。
上個插圖表示在單個抽象條件下可以有許多樹,圖中包含“價格vs房屋”和具有“不同”型別的節點,價格是隻具有價格數值的圖節點,並指向滿足指定價格的所有住房ID(住房節點)的樹。它很像一個混合的資料結構,而不是我們在課本例子中常常見到的簡單的一個圖。
這就是圖表示法的關鍵,它並沒有固定的和“合法的”結構用於圖表示(不像BST有指定的基於節點的表示法,有左/右子指標,儘管你也可以用一個陣列來表示一個BST)。
只要你“認為”它是一個圖,你可以用你覺得最方便的方式(對特定問題最便捷)表達它。“認為是一個圖”所表達的就是應用特定於圖的演算法。
其實N叉樹更像一個圖。
本文從七橋問題引入,將會講到圖論在Airbnb房屋查詢、推特推送更新時間、Netflix和亞馬遜影片/商品個性化推薦、Uber尋找最短路線中的應
首先想到來表示N叉樹節點的虛擬碼是這樣的:
struct NTreeNode { T value; vector<NTreeNode*> children; };
這個結構只表示這個樹的一個節點,整棵樹應該是這樣的:
// almost pseudocode class NTree { public: void Insert(const T&); void Remove(const T&); // lines of omitted code private: NTreeNode* root_; };
這個類模擬一個名為root_樹節點。我們可以用它構建任意大小的樹。這是樹的起始點。為了增加一個新的樹節點我們需要分配給它一個記憶體並將該節點新增到樹的根節點上。
圖與N叉樹雖然很像,但稍微有點不同。試著畫出來一下。
這個是圖嗎?說不是也是。它跟之前的N叉樹是一樣的,只是旋轉了一下。根據經驗來講,不管何時你看到一棵樹,也不管這是一顆蘋果樹,檸檬樹還是二叉搜尋樹,你都可以確定他也是一個圖。所以為圖節點(圖頂點)設計一個結構,我們可以得出相同的結構:
struct GraphNode { T value; vector<GraphNode*> adjacent_nodes; };
這足以構成一個圖嗎?當然不能。看看之前的兩個圖有什麼不同。
都是圖
左邊插圖中的圖中的樹沒有一個“輸入”點(更像是一個森林而不是單獨的樹),相反的是右側插圖中的圖沒有不可到達的頂點。聽起來很熟悉哈。
定義:如果每對節點間都有一條路徑,那麼我們認為這個圖是連通的。
很明顯,在“價格vs房屋”圖中並不是每對節點間都有路徑(如果從插圖中看不出來,就假設價格節點並沒有彼此相連吧)。儘管這只是一個例子來解釋我們不能構造一個具有單個GraphNode struct的圖,但有些情況下我們必須處理像這樣的非連通圖。看看這個類:
class ConnectedGraph { public: // API private: GraphNode* root_;
就像N叉樹是圍繞一個節點(根節點)構建的,一個連通圖也是圍繞一個根節點構造的。樹是“有根”的,也就是說存在一個起始點。連通圖可以表示成一個有根樹(和幾個屬性),這已經很明顯了,但是要記住即使對於一個連通圖,實際表示會因演算法不同而異,因問題不同而異。然而考慮到圖的基於節點的性質,非連通圖可以表示為:
class DisconnectedGraphOrJustAGraph { public: // API private: std::vector<GraphNode*> all_roots_; };
對於像DFS/BFS這樣的圖遍歷,很自然就使用樹狀表示了。這種表示方法幫助很大。然而,像有效路徑跟蹤這樣的例子就需要不同的表示了。還記得尤拉圖嗎?
為了找到一個圖具有“尤拉性”,我們應該在其中找到一個尤拉路徑。這意味著透過遍歷每條邊一次去訪問所有的節點,並且如果遍歷結束還有未走過的邊,那就說明這個圖沒有尤拉路徑,因此也就不是尤拉圖。
還有一個更快些的方法,我們可以檢查所有節點的自由度(假設每個節點都存有它的自由度),然後根據定義,如果圖有個數不等於兩個的奇自由度節點,那這個圖就不是尤拉圖。
這個檢查行為的複雜度是O(|V|),|V|是圖中節點的個數。當插入新的邊來增加奇/偶自由度時我們可以進行追蹤,這個行為複雜度為O(1)。這是非常快速。不過不用在意,我們只要畫一個圖就行了,僅此而已。下面的程式碼表示一個圖和返回路徑的Trace()函式。
// A representation of a graph with both vertex and edge tables // Vertex table is a hashtable of edges (mapped by label) // Edge table is a structure with 4 fields // VELO = Vertex Edge Label Only (e.g. no vertex payloads) class ConnectedVELOGraph { public: struct Edge { Edge(const std::string& f, const std::string& t) : from(f) , to(t) , used(false) , next(nullptr) {} std::string ToString() { return (from + " - " + to + " [used:" + (used ? "true" : "false") + "]"); } std::string from; std::string to; bool used; Edge* next; }; ConnectedVELOGraph() {} ~ConnectedVELOGraph() { vertices_.clear(); for (std::size_t ix = 0; ix < edges_.size(); ++ix) { delete edges_[ix]; } } public: void InsertEdge(const std::string& from, const std::string& to) { Edge* e = new Edge(from, to); InsertVertexEdge_(from, e); InsertVertexEdge_(to, e); edges_.push_back(e); } public: void Print() { for (auto elem : edges_) { std::cout << elem->ToString() << std::endl; } } std::vector<std::string> Trace(const std::string& v) { std::vector<std::string> path; Edge* e = vertices_[v]; while (e != nullptr) { if (e->used) { e = e->next; } else { e->used = true; path.push_back(e->from + ":-:" + e->to); e = vertices_[e->to]; } } return path; } private: void InsertVertexEdge_(const std::string& label, Edge* e) { if (vertices_.count(label) == 0) { vertices_[label] = e; } else { vertices_[label]->next = e; } } private: std::unordered_map<std::string, Edge*> vertices_; std::vector<Edge*> edges_; };
注意這些無處不在的bug。上面的程式碼包含大量的假設,比如打標籤,因此我們知道頂點有一個字串型別的標籤。你可以隨意將它換成任何你想要的東西,不必太在意這個例子裡的內容。然後還有命名,程式碼註釋中寫道,VELOGraph是Vertex Edge Label Only Graph的縮寫(我起的名字)。
重點是,這個圖表示法包含一個表,用來將節點標籤和節點連線的邊對映到該頂點,還包含一個邊的列表,該列表含有節點對(由特定邊連線)和僅由Trace()函式使用的標記(flag)。
回顧Trace()函式實現過程,邊的標記(flag)用來表示已經遍歷過的邊(在任何Trace()被呼叫後都需要重置標記)。
推特時間線例項:多執行緒推送
鄰接矩陣是另一種非常有用的有向圖的表示方法,推特的關注情況圖即是一個有向圖。
有向圖
這個推特的示例圖中,共有8個節點。因此我們只需將這個圖表示為|V|×|V|的方陣(|V|行|V|列,|V|為節點數)。如果節點v到u存在有向邊,則矩陣的[v][u]元素為真(1),否則為假(0)。
推特的示例
可以看出,這個矩陣有些過於稀疏,但卻有利於快速訪問。想要知道Patrick是否關注了Sponge Bob,只需訪問matrix["Patrick"]["Sponge Bob"];想要知道Ann的關注者都有哪些,只需執行出“Ann”的列(列頭標黃);想要知道Sponge Bob關注了誰,只需執行出“Sponge Bob”的行。
鄰接矩陣也可用於無向圖,與有向圖不同的是,若有一條邊將v和u相連,則矩陣的[v][u]元素和[u][v]元素都應為1。無向圖的鄰接矩陣是對稱的。
應注意到,鄰接矩陣除了可以儲存0和1,也可以儲存“更有用”的東西,比如邊的權重。表示地點之間距離的圖就是一個再恰當不過的例子。
上圖表示出了Patrick, Sponge Bob和其他人住址之間的距離(這也被稱作加權圖)。如果兩個頂點之間沒有直接路徑,矩陣對應元素就置無窮符號“∞”。
這一階段的無窮符號並不意味著二者之間一定不存在路徑,也不意味著一定存在。元素的值可能在用演算法找出頂點之間路徑長度以後重新定義(若要更好地儲存頂點和與之相連的邊的資訊,可利用關聯矩陣)。
雖然鄰接矩陣看起來很適合用於表示推特的關注情況圖,但儲存將近3億使用者(月活躍使用者數)的布林值需要300 * 300 * 1 位元組,這是大約82000Tb(百萬兆位元組),也就是1024 * 82000Gb。
不知道你的磁碟有多少簇,但我的筆記本可沒有這麼大的記憶體。如果用位集呢?位棋盤有一點用,可以讓所需儲存空間縮減到10000Tb左右,但這還是太大了。前面提到過,鄰接矩陣過於稀疏,因此想要儲存全部有效資訊,需要很多額外空間,因此表示與頂點相連的邊的鄰接表是更佳選擇。
所謂更佳,是因為鄰接矩陣既儲存了“已關注”資訊,也儲存了“未關注”資訊,而我們只需要知道關注情況,如下所示:
鄰接矩陣 vs 鄰接表
右側的插圖表示一個鄰接表。每個表體現了圖中一個頂點所有相鄰頂點的集合。要指出的是,一個圖可以用不同的鄰接表來表示(荒謬但事實如此)。插圖中標黃處使用了雜湊表,這是一種相當明智的方法,因為雜湊表查詢頂點的時間複雜度是O(1)。
我們沒有提到鄰接頂點列表應該用哪一種特定的資料結構儲存,連結串列、向量等都行,任你選擇。關鍵在於,要想知道Patrick有沒有關注Liz,我們需要訪問雜湊表(需要常數時間),遍歷這個連結串列中的所有元素,並與“Liz”元素進行比較(需要線性時間)。
這種情況下,線性時間並不太壞,因為我們只需迴圈與“Patrick”相鄰的頂點,而這些頂點的個數是一定的。那考慮到空間複雜度,這種表示方法是否還適用於推特的例子呢?我們需要至少3億個雜湊表來記錄,每個都指向一個向量(我選擇向量來避免列表左/右指標的記憶體消耗),每個向量裡包含了...多少元素呢?
沒有相關資料,只找到了推特關注數的平均值,707。假設每個雜湊表指向707個使用者id(每個佔8位元組),並假設雜湊表每個表頭只含有關鍵字(仍然是使用者id),則每個雜湊表佔據3億 * 8位元組,雜湊表關鍵字佔據707 * 8位元組,總共就是3億 * 8 * 707 * 8位元組,大約為12Tb。這個結果不算令人滿意,但和一萬多Tb相比已經好很多了。
說實在的,我不知道這個結果是否合理,但考慮到我在32Gb的專用伺服器上花費約為30美元,那麼分片儲存12Tb需要至少385個這樣的服務和400多臺控制伺服器(用於資料分發控制),因此每月需要大約1.2萬美元。考慮到資料有副本,而且總會有些問題出現,我們將伺服器的數量擴大到三倍,再增加一些控制伺服器。
假設至少需要1500臺伺服器,則每月需要大約4.5萬美元。我當然覺得這不可行,畢竟租用一臺伺服器於我都有些吃力,但對於Twitter則似乎算不上事(與真正的Twitter伺服器相比,確實不算事)。
這樣計算夠了嗎?並不是,我們只考慮了關注情況的資料。推特最重要的是什麼?我是指,從技術上來說,最大的問題是什麼?是能夠快速將某人的一條推文推送給其關注者。而且不僅是快速,更像是閃電一樣的光速。
假設Patrick發了一條關於食物想法的推文,他的所有關注者都應在一個合理的時間內收到這條推文。多長時間合理呢?我們當然可以隨意假設,但我們感興趣的是真實的系統。下圖說明了有人傳送推文的後續過程。
我們不知道一條推特需要多久才能推送給所有關注者,但公開的統計資料顯示,每天有5億條推文,因此上圖的過程每天要重複5億次。關於傳達速度我找不到有用的資料,但可以模糊地回憶出,大約5秒之內,所有關注者都可以接收到推文。
而且還要考慮處理量很大的情況,比如有著上百萬關注量的名人。他們可能只是在海濱別墅裡用推文分享了美妙的早餐,推特卻要辛辛苦苦把這“相當有用”的資訊推送給以百萬計的關注者。
為了解決推文推送的問題,我們不需要關注情況的圖,而需要關注者的圖。關注情況圖(用一個雜湊表和一些列表表示)可以很方便的找出被某一個使用者所關注的所有使用者,但卻不能找到某一個使用者的所有關注者,要想達到這個目標需要遍歷雜湊表所有的關鍵字。
因此我們應當構造另一個圖,這個圖在某種程度上對稱相反於前者。這個新的圖應該也由3億個頂點組成,每個頂點指向鄰接頂點的列表(資料結構不變),不過這個列表代表關注者。
在上圖所示的情況下,每當Liz傳送推文,Sponge Bob和Ann都會在他們的時間線上看到更新。實現這一點有一個常用技術,即為每位使用者的時間線保留一個單獨的結構。在推特這個有3億使用者的例子裡,我們大概假設需要至少3億個時間線(每位使用者一個)。
總的來說,當一個使用者傳送推文,我們應當獲取該使用者的關注者列表,並更新這些關注者的時間線(將內容相同的推文插入它們的時間線)。時間線可以用列表或是平衡樹表示(以推文傳送時間的資料作為節點)。
// 'author' represents the User object, at this point we are interested only in author.id // // 'tw' is a Tweet object, at this point we are interested only in 'tw.id' void DeliverATweet(User* author, Tweet* tw) { // we assume that 'tw' object is already stored in a database // 1. Get the list of user's followers (author of the tweet) vector<User*> user_followers = GetUserFollowers(author->id); // 2. insert tweet into each timeline for (auto follower : user_followers) { InsertTweetIntoUserTimeline(follower->id, tw->id); } }
這段程式碼只是我們從時間線真實的形式中提取出的思想,用多執行緒的方法可以加快推送的速度。
這種方法對於處理量很大的情況至關重要,因為對於上百萬關注者來說,相較於處在關注者列表前端的使用者,處於尾端的使用者總是較晚看到新推文。下面一段虛擬碼嘗試說明了多執行緒推送的想法:
// Warning: a bunch of pseudocode ahead void RangeInsertIntoTimelines(vector<long> user_ids, long tweet_id) { for (auto id : user_ids) { InsertIntoUserTimeline(id, tweet_id); } } void DeliverATweet(User* author, Tweet* tw) { // we assume that 'tw' object is already stored in a database // 1. Get the list of user's (tweet author's) followers's ids vector<long> user_followers = GetUserFollowers(author->id); // 2. Insert tweet into each timeline in parallel const int CHUNK_SIZE = 4000; // saw this somewhere for (each CHUNK_SIZE elements in user_followers) { Thread t = ThreadPool.GetAvailableThread(); // somehow t.Run(RangeInsertIntoTimelines, current_chunk, tw->id); } }
如此一來,無論關注者何時重新整理時間線,總能接收到新推文。
公道地說,我們不過才觸及Airbnb或推特所面臨問題的冰山一角。天才工程師們要耗費大量時間,才能構造出推特、谷歌、臉書、亞馬遜、airbnb等了不起的複雜系統。閱讀本文時別忘了這一點。
推特的例子的重點在於圖的使用,儘管沒有用到圖演算法,而只涉及到圖的表示。我們的確用虛擬碼寫了一個推送推文的函式,但這只是尋找解決方案過程的產物,我指的“圖演算法”是這個列表中的演算法。
令程式設計師心力交瘁的圖論和圖演算法應用,某種意義上來說是有些許差異的。前面我們討論了不用圖表示時,高效篩選Airbnb房源的問題。此時,一處明顯的缺陷在於,若有兩個或以上的篩選關鍵詞,則無法高效篩選。可以圖演算法可以解決這個問題嗎?這我不敢保證,不過可以試一試。
把每個篩選條件當作獨立的頂點會有什麼結果呢?字面地理解“每個篩選條件”,10美元到1000美元以上的價格、所有城市的名稱、國家程式碼、生活設施(電視、Wi-Fi等等)、成人房客數等等,每個資訊都作為一個獨立的頂點。
將“型別”頂點加入圖能使這些頂點更“好用”,比如將所有代表生活設施篩選條件的頂點連線到“生活設施”頂點。
如果我們將Airbnb房源表示為頂點,並將它們連線到各自符合的篩選條件所對應的頂點上,會是什麼情況呢(例如,若“房源1”有“廚房”這項設施,則將其連線至“廚房”)?
稍微處理一下這個示意圖,能使之更像一種特殊的圖:二分圖。
頂點數量比圖上顯示的更多
二分圖(亦稱偶圖)是一種特殊的圖,其頂點可分為兩個不相交且獨立的集合,從而每條邊的兩個頂點分別在兩個集合中。在我們的例子中,一個集合代表篩選條件(以F表示),另一個代表房源(H)。
例如,有10萬個房源標價為62美元,則有10萬條邊以“62美元”為一個頂點,每條邊的另一頂點為滿足該條件的房源。考慮空間複雜度最壞的情況,即每個房源滿足所有的篩選條件,邊總數應為7萬 * 400萬。
如果每條邊用{篩選條件;房源}來表示,並用4位元組的整型變數表示篩選條件、8位元組的長整型變數表示房源,則每條邊佔據12位元組。儲存7萬 * 400萬個12位元組的值大約需要3Tb的記憶體。
不過我們的計算中有一個小錯誤,由Airbnb的統計資料得知,7萬個左右的篩選條件中約有6.5萬個是房源所在城市。值得慶幸的是,一個房源不能位於兩個或以上的城市。
這意味著與城市相連的邊的總數實際應為400萬(每個房源只位於一座城市),因此只用計算70000-65000=5000個篩選條件,故而只需5千 * 400萬 * 12位元組的記憶體,總計小於0.3Tb。
這個結果還不錯。不過是什麼構造出了這樣的二分圖呢?一般而言,網頁/移動端請求會包括幾個不同的篩選條件,例如這樣:
house_type: "entire_place",adults_number: 2,price_range_start: 56,price_range_end: 80,beds_number: 2,amenities: ["tv", "wifi", "laptop friendly workspace"],facilities: ["gym"]
我們只需找到上述請求對應的所有“篩選條件頂點”,再執行得到所有和這些頂點相連的“房源頂點”,而這就將我們引向圖演算法。
圖演算法:介紹
任何針對圖形的處理都可以被稱作“圖演算法”。 你甚至可以以寫個列印一個圖裡所有節點的函式,就叫做“我的節點列印演算法”。大多數人覺得很難的是那些寫在教科書上的演算法。霍普克洛夫特-卡普演算法(Hopcroft-Karp)就是個二分圖匹配演算法的例子。下面我們就來試著用這個演算法來過濾Airbnb房源吧。
給定一個Airbnb房屋(H)和過濾器(F)的二分圖,其中H的每個元素(頂點)都可以有多個相鄰的F元素(頂點)相連。 請查詢一個和F子集內頂點相鄰的H頂點子集。
這個問題的描述已經很繞了,而我們現在甚至還不能確定Hopcroft-Karp演算法是否能解決這個的問題。不過我向你保證,解決問題的這個過程會讓我們學到很多在圖演算法背後的基本原理。不過,這個過程並不那麼短,所以請大家耐心。
Hopcroft-Karp演算法是種以一個二分圖作為輸入,併產生一個最大基數匹配(maximum cardinality matching)的演算法。 最大基數匹配得出的結果是一組由儘可能多的邊組成的集合,而集合中所有的邊都不會共享節點。
熟悉這種演算法的讀者可能已經意識到,這並不能解決我們的Airbnb問題,因為匹配要求所有的邊都不會共享節點。
我們來看一個例子。為了簡單起見,我們假設一共只有4個過濾器和8個房源。房源由A到H的字母表示,過濾器是隨機選擇的。假設四個過濾器分別為:每晚50美元,有一張床,提供Wifi,提供電視。
已知家庭A價格為50美元,有1張床。從A到H的所有房屋每晚價格都為50美元,床位為1張,但其中很少有提供WiFi或者電視的。下面這張插圖詳細說明了系統應返回哪一個房源,滿足了顧客需求,即一間同時符合所有四種過濾器的住房。
這個問題的解決方案(見上圖中左下角連線)要求能透過這些共享頂點的邊找到那些可以透過所有四種過濾器的房屋(即D和G),而Hopcroft-Karp演算法去掉了具有共同節點的邊,最後得到的是入射到兩個子集頂點的邊(見上圖中右下角的紅線)。
就像上圖中所展示的,在所有的選擇中我們只需要同時滿足所有條件的D和G就可以。我們真正需要找的,是共享節點的所有的邊。
我們可以給這樣的方法設計一種演算法,但處理時間對於要求立刻回覆的使用者來說並不理想。相比之下,建立一個節點帶有多個排序鍵值(key)的平衡二叉搜尋樹(balanced binary search tree)可能會更快。這有點像資料庫的索引檔案,直接把主鍵(primary keys)或者外來鍵(foreign keys)對映到一組所需要的資料記錄。
Hopcroft-Karp演算法(以及其他許多演算法)都是同時基於DFS(深度優先搜尋)和BFS(廣度優先搜尋)圖遍歷演算法。
實際上,在這裡介紹Hopcroft-Karp演算法的實際原因是可以從二叉樹這樣性質優良的圖循序漸進地轉入更難的圖遍歷問題。
二叉樹遍歷非常優美,主要是因為它們的遞迴性質。 二叉樹有三種基本遍歷,分別是先序,後序和中序遍歷(你當然可以自己編寫遍歷演算法)。
如果你曾經遍歷過連結串列,那麼遍歷很容易理解。 在連結串列中只需列印當前節點的值(下面程式碼中的命名項)並在下一個節點繼續這個操作即可。
// struct ListNode { // ListNode* next; // T item; // }; void TraverseRecursive(ListNode* node) // starting node, most commonly the list 'head' { if (!node) return; // stop std::cout << node->item; TraverseRecursive(node->next); // recursive call } void TraverseIterative(ListNode* node) { while (node) { std::cout << node->item; node = node->next; } }
二叉樹和這個差不多。首先列印這個節點的值(或其他任何需要使用的值),然後繼續到下一個節點。不過尋找下一個節點的時候會有兩種選擇,即當前節點的左子節點和右子節點。所以你應該對左右子節點都做同樣的事情。但你在這裡有三個不同的選擇:
先列印目前節點的值,然後列印相連的左子節點,最後是右子節點;
先列印相連的左子節點,然後列印目前節點的值,最後是右子節點;
先列印相連的左子節點,然後列印右子節點的值,最後是目前節點。
// struct TreeNode { // T item; // TreeNode* left; // TreeNode* right; // } // you cann pass a callback function to do whatever you want to do with the node's value // in this particular example we are just printing its value. // node is the "starting point", basically the first call is done with the "root" node void PreOrderTraverse(TreeNode* node) { if (!node) return; // stop std::cout << node->item; PreOrderTraverse(node->left); // do the same for the left sub-tree PreOrderTraverse(node->right); // do the same for the right sub-tree } void InOrderTraverse(TreeNode* node) { if (!node) return; // stop InOrderTraverse(node->left); std::cout << node->item; InOrderTraverse(node->right); } void PostOrderTraverse(TreeNode* node) { if (!node) return; // stop PostOrderTraverse(node->left); PostOrderTraverse(node->right); std::cout << node->item;
顯然,遞迴函式看起來非常優雅,雖然它們的執行時間一般都很長。 每次遞迴呼叫函式時,這意味著我們需要呼叫一個完全新的的函式(參見上圖)。
“新”的意思是,我們應該給函式引數和區域性變數分配另一個堆疊儲存空間。 這就是為什麼遞迴呼叫在資源上顯得很昂貴(額外的堆疊空間分配和多個函式呼叫)而且比較危險(需要注意堆疊溢位)。
因此相比之下更建議透過迭代法來實現同樣的功能。 在關鍵任務系統程式設計(比如飛行器或者NASA探測器)中,遞迴是完全禁止的(這個只是傳言,並沒有實際資料或者經驗證明這一點)。
Netflix和亞馬遜個性化推薦例項:倒排索引
假設我們要將所有Netflix影片儲存在二叉搜尋樹中,並將影片的標題作為排序鍵。
在這種情況下,無論何時使用者輸入開頭為“Inter”的內容,程式都會返回一系列以“Inter”開頭的電影列表,例如[Interstellar星際穿越,Interceptor截擊戰,Interrogation of Walter White絕命毒師]。
如果這個程式可以找出標題中包含“Inter”的所有電影(包括並沒有以“Inter”開頭,但是標題中包含這個關鍵字的電影),並且該列表將按電影的評分或與該特定使用者相關的內容進行排序就更好了(例如,某使用者更喜歡驚險片而不是戲劇)。
這個例子的重點在於對二叉搜尋樹進行有效的範圍查詢。但和之前一樣,在這裡我們不會繼研究冰山還沒露出的部分。
基本上,我們需要透過搜尋關鍵字進行快速查詢,然後獲得按關鍵字排序的結果列表。其實這很可能就是電影評分或基於使用者個性化資料的內部排名。 當然,我們會盡可能堅持KISK原則(Keep It Simple,Karl)。
“KISK”或““let’s keep it simple”或“for the sake of simplicity”,這簡直就是教程編寫者從真實問題中找一些抽象化的簡單的例子,並以虛擬碼形式來講解的超級藉口。這些事例的解決方案簡單到往往在祖父母一輩的電腦上都能執行。
這個問題也可以很容易應用到亞馬遜的商品搜尋中,因為使用者通常透過在亞馬遜上輸入他們感興趣的內容(如“圖演算法”)來查詢相關產品,並得到以商品評分排序的清單。
Netflix裡有不少影片,亞馬遜裡有很多商品,在這裡我們把影片和商品叫做“物品”,所以每當你查詢“物品”時,可以聯想Netflix中的電影或亞馬遜的任何合適的產品。
關於這些物品,最常見的就是用程式去解析標題和描述(在此我們只考慮標題)。所以如果一個操作員(比如一位透過管理板將物品資料插入Netflix / Amazon資料庫的員工)把新專案插入到資料庫裡,物品的標題就被一些被叫做“ItemTitleProcessor”的程式處理,從而產生一系列關鍵字。
每個物品都有一個唯一的ID,這個ID和物品標題中的關鍵字有直接關聯。這是搜尋引擎在爬全球各種各樣的網站時所做的事。他們分析每個文件的內容,對其進行標記(將其分解為更小的片語和單詞)並新增到列表中。
這個表將每個標記(單詞)對映到已被標記成 ”包含這個標記” 的文件或網站的ID上。
因此,無論何時搜尋“hello”,搜尋引擎都會獲取對映到關鍵字“hello”的所有文件。現實中這個過程非常複雜,因為最重要的是搜尋的相關性,這就是為什麼Google很好用。Netflix /亞馬遜也會用類似的表。同樣的,讀者們請在看到物品(item)時想想電影或網購的商品。
倒排索引
又來了,雜湊表。是的,我們將為這個倒排索引(索引結構儲存了一個和內容相關的對映)保留一個雜湊表。雜湊表會將關鍵字對映到構成二叉搜尋樹的許多物品當中。
為什麼選擇二叉搜尋樹呢? 這是因為我們希望可以保持物品的排序,並同時對按順序排好的部分進行處理(響應網頁前端的請求),例如請求分頁時的100個專案。
這並不能說明二叉搜尋樹的強大功能,但假設我們還需要在搜尋結果中進行快速查詢,例如,你想要查詢關鍵字為“機器”(machine)的所有三星電影。
這裡需要注意的是,在不同的樹中同一個物品重複出現並沒有問題,因為通常使用者可以使用多個不同的關鍵字找到同一個物品。我們將使用如下定義的物品進行操作:
我們將使用下面提到的這個方法執行程式:
// Cached representation of an Item // Full Item object (with title, description, comments etc.) // could be fetched from the database struct Item { // ID_TYPE is the type of Item's unique id, might be an integer, or a string ID_TYPE id; int rating; };
每次將新物品插入資料庫時,它的標題都會被處理,並新增到總索引表裡,該表會建立一個從關鍵字到對應物品的對映。
可能有很多物品共享相同的關鍵字,因此我們將這些專案儲存在按照評分排序的二叉搜尋樹中。當使用者搜尋某個關鍵字時,他們會得到按評分排序的物品列表。我們如何從排序了的樹中獲取列表呢?答案是透過中序遍歷。
// this is a pseudocode, that's why I didn't bother with "const&"'s and "std::"'s // though it could have look better, forgive me C++ fellows vector<Item*> GetItemsByKeywordInSortedOrder(string keyword) { // assuming IndexTable is a big hashtable mapping keywords to Item BSTs BST<Item*> items = IndexTable[keyword]; // suppose BST has a function InOrderProduceVector(), which creates a vector and // inserts into it items fetched via in-order traversing the tree vector<Item*> sorted_result = items.InOrderProduceVector(); return sorted_result; }
下面這段程式碼實現中序遍歷:
template <typename BlaBla> class BST { public: // other code ... vector<BlaBla*> InOrderProduceVector() { vector<BlaBla*> result; result.reserve(1000); // magic number, reserving a space to avoid reallocation on inserts InOrderProduceVectorHelper_(root_, result); // passing vector by reference return result; } protected: // takes a reference to vector void InOrderProduceVectorHelper_(BSTNode* node, vector<BlaBla*>& destination) { if (!node) return; InOrderProduceVectorHelper_(node->left, destination); destination.push_back(node->item); InOrderProduceVectorHelper_(node->right, destination); } private: BSTNode* root_; };
但是!首先我們首先需要評分最高的專案,而中序遍歷首先輸出的是評分最低的專案。這個結果和中序遍歷的性質有關,畢竟從低到高的中序遍歷是自下而上的。
為了得到理想的結果,即列表按降序而不是升序排列,我們應該仔細研究一下中序遍歷的實現原理。 我們所做的是透過左節點,然後列印當前節點的值,最後是右節點。
正因為我們一直從左開始,所以最先的得到的是“最靠左”的節點,也就是最左節點,這是在整個二叉樹裡具有最小值的節點。因此,簡單地將遍歷方法改成右節點優先,就可以得到降序排列的列表。
和其他人一樣,我們可以給這種方法命名,比如“reverse in-order traversal”,反序遍歷。現在我們來更新一下之前寫的程式碼(這裡引入單個列表,注意,前方bug出沒):
// Reminder: this is pseudocode, no bother with "const&", "std::" or others // forgive me C++ fellows template <typename BlaBla> class BST { public: // other code ... vector<BlaBla*> ReverseInOrderProduceVector(int offset, int limit) { vector<BlaBla*> result; result.reserve(limit); // passing result vector by reference // and passing offset and limit ReverseInOrderProduceVectorHelper_(root_, result, offset, limit); return result; } protected: // takes a reference to vector // skips 'offset' nodes and inserts up to 'limit' nodes void ReverseInOrderProduceVectorHelper_(BSTNode* node, vector<BlaBla*>& destination, int offset, int limit) { if (!node) return; if (limit == 0) return; --offset; // skipping current element ReverseInOrderProduceVectorHelper_(node->right, destination, offset, limit); if (offset <= 0) { // if skipped enough, insert destination.push_back(node->value); --limit; // keep the count of insertions } ReverseInOrderProduceVectorHelper_(node->left, destination, offset, limit); } private: BSTNode* root_; }; // ... other possibly useful code // this is a pseudocode, that's why I didn't bother with "const&"'s and "std::"'s // though it could have look better, forgive me C++ fellows vector<Item*> GetItemsByKeywordInSortedOrder(string keyword, offset, limit) // pagination using offset and limit { // assuming IndexTable is a big hashtable mapping keywords to Item BSTs BST<Item*> items = IndexTable[keyword]; // suppose BST has a function InOrderProduceVector(), which creates a vector and // inserts into it items fetched via reverse in-order traversing the tree // to get items in descending order (starting from the highest rated item) vector<Item*> sorted_result = items.ReverseInOrderProduceVector(offset, limit); return sorted_result; }
就是這樣,我們可以快速提供物品的搜尋結果。 如上所述,倒排索引主要用於Google之類的搜尋引擎。
儘管Google是個特別複雜的搜尋引擎,但它確實使用了一些簡單的想法(雖然用非常現代化的方法實現),以將搜尋查詢與物品文件進行匹配,並儘可能快地提供搜尋結果。
這裡,我們用了樹的遍歷來按照某種排序來處理搜尋結果。在這一點上,先序、中序和後序遍歷看起來可能綽綽有餘,但有時還需要另一種型別的遍歷。大家可以試著解決這個眾所周知的程式設計面試題“如何逐層列印一個二叉樹?”:
深度優先搜尋DFS和廣度優先搜尋BFS
如果你對這個問題不熟悉,請想一下遍歷樹時用來儲存節點的資料結構。如果我們拿逐層樹遍歷和先序遍歷、中序遍歷、後序遍歷比較,最後會得到兩種主要的圖遍歷方法,深度優先搜尋(DFS)和廣度優先搜尋(BFS)。
深度優先搜尋尋找最遠的節點,廣度優先搜尋先探索最近的節點。
深度優先搜尋:做的多,想的少。
廣度優先搜尋:先四處觀望再進行下一步動作。
DFS很像先序遍歷、中序遍歷、後序遍歷,而BFS可以逐層遍歷節點。為實現BFS,需要一個佇列(資料結構)來儲存圖的“層次”,同時列印(訪問)其“父母層”。
上圖中,放入佇列的節點為淺藍色。基本上逐層進行,每層節點都從佇列中獲取,訪問每個獲取的節點時,將其子節點插入到佇列中(用於下一層)。下面的程式碼非常簡單,很好地說明了BFS的思路。假設圖是連通圖,可以修改以適用於非連通圖。
// Assuming graph is connected // and a graph node is defined by this structure // struct GraphNode { // T item; // vector<GraphNode*> children; // } // WARNING: untested code void BreadthFirstSearch(GraphNode* node) // start node { if (!node) return; queue<GraphNode*> q; q.push(node); while (!q.empty()) { GraphNode* cur = q.front(); // doesn't pop q.pop(); for (auto child : cur->children) { q.push(child); } // do what you want with current node cout << cur->item; } }
這一思路對基於節點的連通圖表示非常簡單。請記住,對於不同表示,圖遍歷的實現也不相同。BFS和DFS是解決圖搜尋問題的重要方法(但記住圖搜尋演算法有很多很多)。
雖然DFS是優美的遞迴實現,但迭代實現也可行的。而BFS的迭代實現我們會用一個佇列,對DFS需要一個堆疊。圖論中的一個熱門問題,是找節點間最短路徑。來看最後一個例子。
Uber最短路線例項
Uber有5000萬乘客和700萬司機,所以高效地將司機與乘客進行匹配非常重要。司機使用Uber司機專用應用來接單,乘客就用Uber應用叫車。
首先是定位。後臺需要處理數百萬條乘客請求,併傳送給附近的司機,通常會傳送給多個司機。雖然將乘客請求傳送給附近所有司機看起來更簡單,甚至有時候也更有效,但是進行一些預處理往往效果更好。
除了處理收到的請求,根據乘客座標查詢定位區域,進而查詢座標最近的司機外,還需要為乘客找到合適的司機。假設已經有了包含乘客及幾個附近車輛的小地圖,(已經對比了車輛當前座標和乘客座標,並確定了附近車輛,這一過程稱為地理資訊處理),小地圖如下:
車輛到乘客的可能路線用黃色標出。那麼現在的問題是計算從車輛到乘客需要的最小距離,也就是說,要找到最短路線。雖然這個問題更像Google地圖而不是Uber應該考慮的,但我們還是要解決這個簡化的問題,因為通常乘客附近有多輛車,Uber需要計算距離最近且評分最高車輛傳送給乘客。
對於上圖而言,要分別計算三輛車抵達乘客的最短路線,再決定哪輛車是最佳選擇。為簡化問題,這裡討論只有一輛車的情況。以下標出三條抵達乘客的路線。
抵達乘客的可能路線
接下來進入重點,先將小地圖抽象成圖:
這是無向加權圖(確切地說是邊加權圖)。為了找到頂點B(汽車)和A(乘客)之間的最短路徑,應該找包含儘可能小的權重的邊的路徑。我們用Dijkstra版本;你也可以自行設計你的方案。以下是Dijkstra演算法的維基百科。
將開始的節點稱為起始節點。Y節點的路徑長度定義為從出發點到Y點的總路徑長度。Dijkstra演算法將會分配一些初始距離值然後逐步改善它們。
1.將所有節點設為未訪問。設定一個包含所有未被訪問節點的集合,稱為未訪問集合。
2. 對所有頂點的路徑長度賦暫定值:將起始節點的路徑長度設為0,所有其他頂點的路徑長度設為無窮大。將起始節點設為當前節點。
3.對於當前節點,考慮其周圍所有未訪問的相鄰節點,並且計算透過當前節點到它們的暫定距離。將新計算得到的暫定距離與當前分配的距離進行比較並選擇較小的值然後分配。例如,如果當前節點A的距離為6,連之相鄰B的長度為2,則透過A到B的距離將為6 + 2 = 8。如果B先前被標記的距離大於8,則將其更改為8.否則,則保持當前值。
4.當我們考慮完當前節點的所有相鄰節點時,將當前節點標記為已訪問並將其從未訪問節點集中移除。被訪問的節點將不會再次被檢視。
5.如果目標節點已經被標記為已訪問(當目標是兩個特定節點之間的路徑)或者未訪問集合中的節點之間的最小暫定距離是無窮大時(目標完全遍歷時;發生在初始節點和剩餘的未訪問節點之間沒有連線時),將會停止。演算法已經完成。
6.否則,選擇未訪問過的並且具有最小暫定距離的節點,把它作為新的“當前節點”,然後回到步驟3。
應用Dijkstra演算法到示例中,頂點B(車)是起始節點。前兩步如下:
所有的節點都屬於未訪問集合,需要注意圖示右側的表格。對於所有節點,它將包含從B到前一個(標記為“Prev”)節點的所有最短距離。例如,從B到F的距離是20,前一個節點是B。
將B標記為已訪問,然後移動到它的相鄰節點F。
現在將F標記為已訪問,並選擇具有最小暫定距離的點為下一個未訪問節點,即G。依然需要注意左側的表格,在前面的圖例中,節點C,F和G已經將它們的暫定距離設定為透過之前所提到的結點的距離。
如同演算法闡述的那樣,如果目標節點被標記為已訪問(在我們的例子中,當要兩個特定節點之間的路徑時),那我們可以結束演算法了。因此,下一步終止演算法返回下面的值。
所以我們既有從B到A的最短距離又有透過F節點和G節點的路徑。
這是Uber中的一個再簡單不過的例子,只是滄海一粟。然而,這是很好的起點,可以幫你邁出探索圖論應用的第一步。
關於圖論還有很多內容有待研究,這篇文章只是冰山一角。讀到最後的你非常棒,獎你一朵小紅花,別忘了點贊和分享喲,謝謝。