圖(Graph)是由頂點和鏈接頂點的邊構成的離散結構。在計算機科學中,圖是最靈活的數據結構之一,不少問題均可以使用圖模型進行建模求解。例如:生態環境中不一樣物種的相互競爭、人與人之間的社交與關係網絡、化學上用圖區分結構不一樣但分子式相同的同分異構體、分析計算機網絡的拓撲結構肯定兩臺計算機是否能夠通訊、找到兩個城市之間的最短路徑等等。
額,我都不研究這些問題。之因此從新回顧數據結構,僅僅是爲了好玩。圖(Graph)一般會放在樹(Tree)後面介紹,樹能夠說只是圖的特例,可是我以爲就基礎算法而言,樹比圖複雜不少,並且聽起來也沒什麼好玩的(左左旋、左右旋、右右旋、右左旋,好無聊~)。所以,我寫的第一篇數據結構的筆記就從圖開始。ios
圖的結構很簡單,就是由頂點$V$集和邊$E$集構成,所以圖能夠表示成$G=(V, E)$。
圖1-1:無向圖1
圖1-1就是無向圖,咱們能夠說這張圖中,有點集$V=\{1, 2, 3, 4, 5, 6\}$,邊集$E=\{(1, 2), (1, 5), (2, 3), (2, 5), (3, 4), (4, 5), (4, 6)\}$。在無向圖中,邊$(u, v)$和邊$(v, u)$是同樣的,所以只要記錄一個就好了。簡而言之,對稱。
圖1-2:有向圖 2
有向圖也很好理解,就是加上了方向性,頂點$(u, v)$之間的關係和頂點$(v,u)$之間的關係不一樣,後者或許不存在。例如,地圖應用中必須存儲單行道的信息,避免給出錯誤的方向。
加權圖:與加權圖對應的就是無權圖,若是以爲很差聽,那就叫等權圖。若是一張圖不含權重信息,咱們就認爲邊與邊之間沒有差異。不過,具體建模的時候,不少時候都須要有權重,好比對中國重要城市間道路聯繫的建模,總不能認爲從北京去上海和從北京去廣州同樣遠(等權)。
還有不少細化的概念,有興趣的本身瞭解咯。我以爲就不必單獨拎出來寫,好比:無向圖中,任意兩個頂點間都有邊,稱爲無向徹底圖;加權圖起一個新名字,叫網(network)……然而,如無必要,毋增實體。
兩個重要關係:c++
路徑(path):依次遍歷頂點序列之間的邊所造成的軌跡。注意,依次就意味着有序,先1後2和先2後1不同。
簡單路徑:沒有重複頂點的路徑稱爲簡單路徑。說白了,這一趟路里沒有出現繞了一圈回到同一點的狀況,也就是沒有環。
圖1-3:四頂點的有向帶環圖3
環:包含相同的頂點兩次或者兩次以上。圖1-3中的頂點序列$<1,2,4,3,1>$,1出現了兩次,固然還有其它的環,好比$<1,4,3,1>$。
無環圖:沒有環的圖,其中,有向無環圖有特殊的名稱,叫作DAG(Directed Acyline Graph)(最好記住,DAG具備一些很好性質,好比不少動態規劃的問題均可以轉化成DAG中的最長路徑、最短路徑或者路徑計數的問題)。
下面這個概念很重要:
圖1-4:兩個連通分支
連通的:無向圖中每一對不一樣的頂點之間都有路徑。若是這個條件在有向圖裏也成立,那麼就是強連通的。圖1-4中的圖不是連通的,我絲毫沒有侮辱你智商的意思,我只是想和你說,這圖是我畫的,頂點標籤有點小,應該看到a和d之間沒有通路。算法
圖1-5:有向圖的連通分支數組
圖1-6:關節點
關節點(割點):某些特定的頂點對於保持圖或連通分支的連通性有特殊的重要意義。若是移除某個頂點將使圖或者分支失去連通性,則稱該頂點爲關節點。如圖1-6中的c。
雙連通圖:不含任何關節點的圖。
關節點的重要性不言而喻。若是你想要破壞互聯網,你就應該找到它的關節點。一樣,要防範敵人的攻擊,首要保護的也應該是關節點。在資源總量有限的前提下,找出關節點並給予特別保障,是提升系統總體穩定性和魯棒性的基本策略。
橋(割邊):和關節點相似,刪除一條邊,就產生比原圖更多的連通分支的子圖,這條邊就稱爲割邊或者橋。安全
這一部分屬於圖論的內容,基礎圖算法不會用到,可是我以爲挺有意思的,小記以下。
同構4:圖看起來結構不同,但它是同樣的。假定有$G_1$和$G_2$,那麼你只要確認對於$G_1$中的全部的兩個相鄰點$a$和$b$,能夠經過某種方式$f$映射到$G_2$,映射後的兩個點$f(a)$、$f(b)$也是相鄰的。換句話說,當兩個簡單圖同構時,兩個圖的頂點之間保持相鄰關係的一一對應。
圖1-7:圖的同構
圖1-7就展現了圖的同構,這裏頂點個數不多判斷圖的同構很簡單。咱們能夠把v1當作u1,天然咱們會把u3看出v3。用數學的語言就是$f(u_1)=v_1$,$f(u_3)=v_3$。u1的另一個鏈接是到u2,v1的另一個鏈接是到v4,不難從相鄰頂點的關係驗證$f(u_2)=v_4$,$f(u_4)=v_2$。
歐拉回路(Euler Circuit):小學數學課本上的哥尼斯堡七橋問題,能不能從鎮裏的某個位置出發不重複的通過全部橋(邊)而且返回出發點。這也就小學的一筆畫問題,歐拉大神解決裏這個問題,開創了圖論。結論很簡單:至少2個頂點的連通多重圖存在歐拉回路的充要條件是每一個頂點的度都是偶數。證實也很容易,你們有興趣能夠閱讀相關資料。結論也很好理解,從某個起點出發,最後要回起點,中間不管路過多少次起點,都會再次離開,進、出的數目必然相等,故必定是偶數。
哈密頓迴路(Hamilton Circuit):哈密頓迴路條件就比歐拉回路嚴格一點,不能重複通過點。你可能會感到意外,對於歐拉回路,咱們能夠垂手可得地回答,可是咱們卻很難解決哈密頓迴路問題,實際上它是一個NP徹底問題。這個術語源自1857年愛爾蘭數學家威廉·羅萬·哈密頓爵士發明的智力題。哈密頓的智力題用到了木質十二面體(如圖1-8(a)所示,十二面體有12個正五邊形表面)、十二面體每一個頂點上的釘子、以及細線。十二面體的20個頂點用世界上的不一樣城市標記。智力題要求從一個城市開始,沿十二面體的邊旅行,訪問其餘19個城市,每一個剛好一次,最終回到第一個城市。
圖1-8:哈密頓迴路問題
由於做者不可能向每位讀者提供帶釘子和細線的木質十二面體,因此考慮了一個等價的問題:對圖1-8(b)的圖是否具備剛好通過每一個頂點一次的迴路?它就是對原題的解,由於這個平面圖同構於十二面體頂點和邊。
著名的旅行商問題(TSP)要求旅行商訪問一組城市所應當選取的最短路線。這個問題能夠歸結爲求徹底圖的哈密頓迴路,使這個迴路的邊的權重和儘量的小。一樣,由於這是個NP徹底問題,最直截了當的方法就檢查全部可能的哈密頓迴路,而後選擇權重和最小的。固然這樣效率幾乎難以忍受,時間複雜度高達$O(n!)$。在實際應用中,咱們使用的啓發式搜索等近似算法,能夠徹底求解城市數量上萬的實例,而且甚至能在偏差1%範圍內估計上百萬個城市的問題。網絡
關於旅行商問題目前的研究進展,能夠到http://www.math.uwaterloo.ca/...。數據結構
覺得能夠一帶而過,結果寫了那麼多。也沒什麼好總結的了,固然這些也至是圖論概念的一小部分,還有一些圖可能咱們之後也會見到,好比順着圖到網絡流,就會涉及二分圖,不過都很好理解,畢竟有圖。ide
圖最多見的表示形式爲鄰接鏈表和鄰接矩陣。鄰接連接在表示稀疏圖時很是緊湊而成爲了一般的選擇,相比之下,若是在稀疏圖表示時使用鄰接矩陣,會浪費不少內存空間,遍歷的時候也會增長開銷。可是,這不是絕對的。若是圖是稠密圖,鄰接鏈表的優點就不明顯了,那麼就能夠選擇更加方便的鄰接矩陣。
還有,頂點之間有多種關係的時候,也不適合使用矩陣。由於表示的時候,矩陣中的每個元素都會被看成一個表。函數
若是使用鄰接矩陣還要注意存儲問題。矩陣須要$n^2$個元素的存儲空間,聲明的又是連續的空間地址。因爲計算機內存的限制,存儲的頂點數目也是有限的,例如:Java的虛擬機的堆的默認大小是物理內存的1/4,或者1G。以1G計算,那麼建立一個二維的int[16384][16384]
的鄰接矩陣就已經超出內存限制了。含有上百萬個頂點的圖是很常見的,$V^2$的空間是不能知足的。
所以,偷個懶,若是對鄰接矩陣感興趣,能夠本身找點資料。很容易理解的。性能
鄰接鏈表的實現會比鄰接矩陣麻煩一點,可是鄰接鏈表的綜合能力,包括魯棒性、拓展性都比鄰接矩陣強不少。沒辦法,只能忍了。
圖1-9:鄰接鏈表示意圖
從圖1-9不能看出鄰接鏈表能夠用線性表構成。頂點能夠保持在數組或者向量(vector)中,鄰接關係則用鏈表實現,利用鏈表高效的插入和刪除,實現內存的充分利用。有利必有弊,鄰接矩陣能夠高效的斷定兩個頂點之間是否有鄰接關係,鄰接鏈表無疑要遍歷一次鏈表。
鄰接鏈表的瓶頸在於鏈表的查找上,若是換成高效的查找結構,就能夠進一步地提升性能。例如,把保存頂點鄰接關係的鏈表換成一般以紅黑樹爲基礎set
。若是必定要名副其實,就要叫成鄰接集。相似的,頂點的保存也有「改進」方案。好比,使用vector
一般用int
表示頂點,也沒法高效地進行頂點的插入刪除。若是把頂點的保存換成鏈表,無疑能夠高效地進行頂點的插入和刪除,可是訪問能力又會大打折扣。沒錯,咱們可使用set
或者map
來保存頂點信息。
C++11中引入了以散列表爲基礎unordered_set
和unordered_map
,就查找和插入而言,統計性能可能會高於紅黑樹,然而,散列表會帶來額外的內存開銷,這是值得注意的。
具體問題,具體分析,圖的結構不一樣,實現圖的結構也應該隨之不一樣。大概也是這個緣由,像C++、Java、Python等語言,都不提供具體的Graph
。舉個例子,直接使用vector
保存頂點信息,list
保存鄰接關係,使用的頂點id連續5。那麼在添加邊$O(1)$,遍歷頂點的鄰接關係$O(V)$還有空間消耗$O(V+E)$上都是最優的。固然,相似頻繁刪除邊,添加邊(不容許平行邊),刪除頂點,添加頂點,那麼這種比較簡易的結構就不太適合了。
咱們稍微量化一下稀疏圖和稠密圖的標準。當咱們聲稱圖的是稀疏的,咱們近似地認爲邊的數量$|E|$大體等於頂點的個數$|V|$,在稠密圖中,咱們能夠不可貴到$|E|$近似爲$|V^2|$。在此,咱們不妨定義均衡圖是邊的數量爲$|V^2|/\log |V|$的圖。
圖算法中,根據圖的結構,常常會有兩個算法變種,時間複雜度也不盡相同。可能有一個是$O((V+E)\log V)$,另外一個是$O(V^2+E)$。選擇哪一個算法更爲高效取決於圖是不是稀疏的。
圖類型 | $O((V+E)\log V)$ | 比較關係 | $O(V^2+E)$ |
---|---|---|---|
稀疏圖:$E$是$O(V)$ | $O(V\log V)$ | < | $O(V^2)$ |
均衡圖:$E$是$O(V^2/\log V)$ | $O(V^2+V\log V)=O(V^2)$ | = | $O(V^2+V^2/\log V)=O(V^2)$ |
稠密圖:$E$是$O(V^2)$ | $O(V^2\log V)$ | > | $O(V^2)$ |
由於用Markdown
,因此我怕有時候排版的時候空格出現問題,4空格調整太麻煩,加上可能4空格有時候不是特別緊湊,因此代碼所有是2空格縮進。另外,我就不打算像教科書同樣寫那種一本正經的代碼,拆成頭文件加源文件。還有不少偷懶和不負責的地方,不過,換來了性能。還有,auto
仍是挺好用的,所以代碼會用到少許C++11。// TODO(千凡): 回頭能夠改用Go語言實現一次
就學習算法的目的而言,頻繁添加和刪除頂點是不須要的,所以代碼實現時,爲方便起見頂點仍然使用vector
保存,邊的話進階點,使用set
,這樣就防止出現平行邊了。還有,我比較放心本身,不少方法不加檢查。仍是那句話,具體問題,具體分析,具體實現。
既然選擇用vector
+set
,咱們來考慮一下基本操做,至於那些後來算法用到的,後面再補充實現。
數據成員:
vector
和set
構成的圖結構功能:
begin
、cbegin
end
、cend
其它
n
個頂點#include <iostream> #include <vector> #include <set> #include <list> #include <fstream> #include <limits> #include <queue> // 鄰接集合 typedef std::set<int> AdjSet; // 鄰接集 class Graph { protected: // 鄰接表向量 std::vector<AdjSet> vertices_; // 頂點數量 int vcount_; // 邊的數量 int ecount_; bool directed_; public: Graph(bool directed = false) : ecount_(0), vcount_(0), vertices_(0), directed_(directed) {}; Graph(int n, bool directed) : ecount_(0), vcount_(n), vertices_(n), directed_(directed) {}; // 從文件中初始化 Graph(const char *filename, bool directed); virtual ~Graph() { vertices_.clear(); vcount_ = 0; ecount_ = 0; } // 取值函數 virtual int vcount() const { return vcount_; }; virtual int ecount() const { return ecount_; }; virtual bool directed() const { return directed_; }; // 某條邊是否存在 virtual bool IsAdjacent(const int &u, const int &v); // 約定:成功返回 0,不存在 -1,已存在 1 // 添加邊 virtual int AddEdge(const int &u, const int &v); // 添加頂點 virtual int AddVertex(); // 刪除邊 virtual int RemoveEdge(const int &u, const int &v); // 刪除頂點 virtual int RemoveVertex(const int &u); // 返回頂點的鄰接集 virtual std::set<int>& Adj(const int &u) { return vertices_[u]; } // 迭代器 virtual AdjSet::const_iterator begin(const int u) { return vertices_[u].begin(); }; virtual AdjSet::const_iterator end(const int u) { return vertices_[u].end(); }; virtual AdjSet::const_iterator cbegin(const int u) const { return vertices_[u].cbegin(); }; virtual AdjSet::const_iterator cend(const int u) const { return vertices_[u].cend(); }; }; // class Graph
由於圖結構實現仍是比較簡單的,代碼都很短。
文件格式,先頂點數量、邊數量,而後頂點對錶示邊。缺省bool
值默認無向
例如
6 8 0 1 0 2 0 5 2 3 2 4 2 1 3 5 3 4
代碼實現:
Graph::Graph(const char *filename, bool directed = false) { directed_ = directed; int a, b; // 默認能打開,若是想安全,使用if (!infile.is_open())做進一步處理 std::ifstream infile(filename, std::ios_base::in); // 節點和邊數量 infile >> a >> b; vcount_ = a; ecount_ = b; vertices_.resize(vcount_); // 讀取邊 for (int i = 0; i < ecount_; ++i) { infile >> a >> b; int v = a; int w = b; vertices_[v].insert(w); if (!directed_) { vertices_[w].insert(v); } } infile.close(); }
// 添加頂點 int Graph::AddVertex() { std::set<int> temp; vertices_.push_back(temp); ++vcount_; return 0; } // 刪除頂點 int Graph::RemoveVertex(const int &u) { if (u > vertices_.size()) { return -1; } // 遍歷圖,尋找與頂點的相關的邊 // 無向圖,有關的邊必定在該頂點的鄰接關係中 if (!directed_) { int e = vertices_[u].size(); vertices_.erase(vertices_.begin() + u); ecount_ -= e; --vcount_; return 0; } else { // 遍歷圖 for (int i = 0; i < vertices_.size(); ++i) { RemoveEdge(i, u); } vertices_.erase(vertices_.begin() + u); --vcount_; return 0; } return -1; }
// 添加邊 int Graph::AddEdge(const int &u, const int &v) { // 不綁安全帶,使用需謹慎 vertices_[u].insert(v); if (!directed_) { vertices_[v].insert(u); } ++ecount_; return 0; } // 刪除邊 int Graph::RemoveEdge(const int &u, const int &v) { auto it_find = vertices_[u].find(v); if (it_find != vertices_[u].end()) { vertices_[u].erase(v); --ecount_; } else { return -1; } if (directed_) { return 0; } // 無向圖刪除反向邊 it_find = vertices_[v].find(u); if (it_find != vertices_[u].end()) { vertices_[v].erase(u); } else { // 人和人之間的信任呢? return -1; } return 0; }
// 檢查兩個頂點之間是否有鄰接關係 bool Graph::IsAdjacent(const int &u, const int &v) { if (vertices_[u].count(v) == 1) { return true; } return false; }
這個用到了cout
,又考慮到非功能性方法,不建議放在類中。
// 打印圖 void PrintGraph(const Graph &graph) { for (int i = 0; i < graph.vcount(); i++) { std::cout << i << " -->"; for (auto it = graph.cbegin(i); it != graph.cend(i); ++it) { std::cout << " " << *it; } std::cout << std::endl; } }
圖是至關靈活的,我想這也是爲何STL庫不提供Graph
的緣由。咱們能夠發現,利用STL的基礎設施,咱們能夠很快的搭建Graph
。至於選擇什麼基礎設施,沒有標準答案。對於不一樣的問題會有不一樣的最佳答案。咱們只是演示,不對特定問題進行進行建模,能夠無論什麼性能,也沒打算泛化(不造庫,謝謝),不過度考慮實現和圖操做分離問題。嗯,就這樣咯,仍是趕忙進入更加激動人心的圖算法吧。
我水平有限,錯誤不免,還望各位加以指正。