小話資料結構-圖 (聚焦與於實現的理解)

Simon5ei發表於2020-08-04

數學使我們能夠發現概念和聯絡這些概念的規律,這些概念和規律給了我們理解自然現象的鑰匙。

——愛因斯坦

前言

本文程式碼基於C++實現,閱讀本文,需要有以下知識

  1. 教熟練使用C++ STL庫中的vector,map,pair等;

  2. 對於遞迴和簡單搜尋演算法(dfs,bfs)有粗淺的理解;

  3. 稍微的離散數學或者是線性代數知識(可能是我瞎掰的,沒有也罷 ?

本文針對演算法或資料結構初學者(比如我)寫下,本人不才,如有錯誤請輕噴 ?

瓶頸

在學習“資料結構”這門課之前,“圖論”這個略顯高階的詞彙看起來還與我那麼的遙遠,在經過了“離散數學”的學習之後,我慢慢認識到其實資料結構就是離散數學模型的程式碼實現,並且在不斷的學習中,我開始能以我自己的思維去理解圖論的知識。

資料結構的教科書上,每個知識點都會系統的從“定義——術語——儲存結構——相關操作”娓娓道來,然而,各種各樣的障礙阻礙著我們認知的過程。對於離散數學不熟悉的,一時間無法抽象出模型,教材上冗長的程式碼實現,給讀者一種晦澀難懂的感覺。這時候我們就要思考離散數學的本質——將具體問題抽象為一般問題,在由演算法解決。因此,我們在一開始不必過於在意方法,而是應該聚焦與實現,像大學物理做實驗一樣,多操作幾遍,自然就熟能生巧,甚至開發出新的理解。

你得從使用者的體驗出發,推倒用什麼技術。 ——賈伯斯在1997年迴歸蘋果釋出會上回答提問

案例-引入

什麼是圖?簡單說就是點與點之間的網狀關係。

比如以下有6個城市之間的鐵路線。

 

 

 

地圖就是一個很經典的圖

那麼,我們是如何表達他們的關係的呢

我們使用鄰接表or鄰接矩陣

為什麼是鄰接表/矩陣

鄰接表和鄰接矩陣是圖的兩種常用儲存表示方式,用於記錄圖中任意兩個頂點之間的連通關係,包括權值。

(在圖論相關的案例中,我們會特別頻繁的用到這兩種表示方式)

我們不談什麼二元組三元組的,想想啊,圖重要的頂多就仨玩意:節點,邊,權值,而使用鄰接表和鄰接矩陣已經可以清晰簡潔的表達這些關係了。

我們先看看鄰接表和鄰接矩陣怎麼建;

鄰接表

emm,用圖表示就是上面那張圖啊,程式碼實現的話,我們用stl的vector更方便一點

#include <iostream>
#include <vector>struct edge{//邊資訊,邊有啥屬性往裡丟就行
    int to;//該邊可以去往的下一個節點編號,有明確指向,是單向邊。
    int value;//存邊的權值,如果沒有可以直接忽略
    edge(int t,int v) :to(t), value(v){};//建構函式
};
​
vector<edge> node[n];//用vector實現鄰接表的儲存
//ps:邊資訊只有一個可以直接存int,兩個可以用stl的pair,三個及以上就肯定要用struct了
//vector<int> node[n],vector<pair<int, int> >都是可行的,自己清楚意思就行

這裡是什麼意思呢,就是我有n個vector陣列,每一個陣列表示一個點的資訊,vector裡存的是邊(edge),每一個點到點的通路意味著有一個對應的edge資訊

那麼,如何把資訊存入鄰接表呢,我們假設用一下流程輸入(大概就是平時做題的時候題目要求的輸出啦)

先假設每條邊的權值都是一樣的,就設為“1”吧

//第一行為兩個整數n,e,分別表示節點數,邊數,隨後n行,輸入節點資訊,在隨後e行,接受邊資訊

6 8

武漢 岳陽 南昌 長沙 株洲 湘潭

武漢 岳陽

岳陽 南昌

武漢 長沙

南昌 長沙

岳陽 長沙

長沙 株洲

長沙 湘潭

湘潭 株洲

#EOF

那麼,在C語言裡面我們這麼處理輸入

map<string,int> tab;
map<int,string> tab0;
//建立雜湊表(雜湊表),使每個城市的編號和名字可以相互連線
int n,m;
cin>>n>>m;
for(int i=0;i<n;i++){
    string name;
    cin>>name;
    tab0.insert({i,name});
    tab.insert({name,i});
    //給每一個城市編號
}
while(m--){
    edge tmp;
    string a,b;
    node[map[a]].push_back(edge(map[b],1));
    //建構函式中map[a]表示名為a的城市對應的編號,1表示權值,存入vector陣列g中
    node[map[b]].push_back(edge(map[a],1));
    //因為是雙向邊所以正向反向都存一遍
}

這樣的話,我們的鄰接表就完全儲存好了

對於任意一個點,我們要遍歷其相鄰點,只需要用一下程式碼

//輸入城市名字 輸出其相鄰所有城市的名字
string name;
cin>>name;
for(auto it=node[tab[name]].begin();it!=g[tab[name]].begin();it++){//遍歷name節點的所有邊
    cout<<tab0[it->to]<<endl;
}
 

假如我們輸入

岳陽

那麼就會返回

武漢

南昌

長沙

整個鄰接表的儲存和訪問過程就是以上的樣子了

鄰接矩陣

這個就很好理解了,就是一個n*n的二維陣列模擬矩陣,表達的是點與點之間的關係,我們沿用上一個例子裡的輸入,我們建立出來的矩陣大概是這樣

 

 

這個鄰接矩陣只是判定有無直接相連的,我們用一個6*6的二維陣列可以很輕鬆的建出來,沒有自旋,未聯通和自我比較設定為0(false),已聯通即設定為1(true)。

案例-常見問題

(PS:本人才疏學淺,只介紹部分案例的大致思維路線,細節歡迎各位深入思考)

我們接著上一個案例看,對於很多情況,鄰接矩陣像上面這樣,就算建出來了,但是我們現在用的,是一個實實在在的生活中的例子,誰都直到武漢和南昌之間必定可以通過鐵路線到達,只是會經過別的站臺。

這個時候就引出了一個問題,按照鄰接表來看,武漢和南昌實際上是通過其他的節點連線起來了的,只是沒有直接連線

然而此時,從“武漢“到”南昌”實際上有多條線路

武漢->岳陽->南昌

武漢->長沙->南昌

武漢->長沙->岳陽->南昌

武漢->岳陽->長沙->南昌

武漢->長沙->湘潭->株洲->南昌

………………

那我們給其付的權值到底是2,3,4還是多少呢?

這就可以引入到一個常見的圖論問題——“最短路”

最短路

(PS:最短路問題在演算法競賽和數學建模競賽中都是非常常見的)

故名思意,當我們想要直接去往某個目的地時,一定是講究時間效率的,我們不願意走太長,更不願意繞圈子,用規範的話說就是:“找最短路,並且避免系統資源浪費”,那我們就要先走一遍所有路徑,看看哪個路徑可行(好比每天高鐵第一班車是“探路車”)。

假設我們要從武漢出發,去南昌:

我們從武漢開始遍歷武漢接下來可以到達每一個城市

 

 

如此以來,逐個分析每個為直接相連的點,我們可以得到整個圖的帶權值的的鄰接矩陣表示,其中有一個要點,即點不能重複訪問,而BFS按照層次遍歷鄰接表的模式非常契合這個目的。我們沿用上方的輸入和鄰接表的儲存形式,以下給出大致的虛擬碼:

#include<queue>//stl的queue容器
void bfs(int st){
    queue<edge> qu;//建立佇列
    qu.push(起始狀態入隊);
    while(!qu.empty()){//當佇列非空
        if(當前狀態x方向可走)
            qu.push(當前狀態->x);//該狀態入隊
        if(當前狀態向y方向可走)
            qu.push(當前狀態->y);//該狀態入隊
        …………………
        處理(隊頂)qu.top();
        相應操作;
        qu.pop();//隊首彈出隊
    }//一次迴圈結束,執行下一次迴圈
}

如此以來,我們就得到了整個圖,每個節點的詳細資訊,可以根據需求進行更細節的操作。

這便是最短路的基本思想,當然,實際情況會更加複雜,比如邊的權值各有不同,是有向邊,出現負權值等情況,也會有相應的演算法(迪特斯科拉,貝爾曼-福德,弗洛伊德,SPFA,A_Star等演算法),同時,在圖的遍歷時,通過鄰接矩陣我們也可以瞥見連通圖的很多性質,優美的現象能吸引人的思考,數學之美就在於這些奇妙之處。

尤拉路

我們有目的性出行,肯定也有旅遊出行,肯定有人喜歡欣賞沿途的風景,我舉個例子,加入有一個岳陽人,他很喜歡看火車沿路的風景,他把以上6個城市作為了自己旅行可規劃的目的地,他想在各個路線中穿梭,路線越長越好,但是他不喜歡看重複的風景,他想規劃一個走過的路不重複,而且最長的路線。走過的路不重複,就是所謂的尤拉路

因為我們著重考慮路徑,所以使用之前判定有無直接連線的鄰接矩陣就可以了,我們使用DFS來遍歷所有邊並找出最長的一條。

int g[N][N];//鄰接矩陣2維陣列
//預設鄰接矩陣資訊已經存入了該二維陣列中
bool st[N][N];//標記某一條邊是否被訪問過
int ans;//儲存答案
void dfs(int start, int res) {
    for (int i = 0; i < n; i++) {
        if (g[start][i] == 1 || st[start][i] || st[i][start]) 
        //該邊可以通過並且是第一次通過
            continue;
        st[u][i] = st[i][u] = true;//標記
        dfs(i, res + 1);//下一步
        st[u][i] = st[i][u] = false;//回溯
    }
    ans = max(ans, res);//保證得到最大的結果
}

尤拉(回)路問題其實就是經典的“一筆畫問題”,應為我們每一步的判定和操作都是固定的,通過dfs的“自相性”我們往往能簡潔而優美的解決這一系列問題。

 

為了繼續思考圖論的模型,我們接下來不使用程式碼討論另外兩種模型,這兩種模型的程式碼模板很方便理解,瞭解了基本思路和模型,就很方便應用了。

 

並查集

我們都知道每個省有很多地放,現在隨意給你兩個市區的名稱,想要你判斷以下他們是否屬於同一個省份。

我們將每個城市存入其資料結構,可以得出以下的情況

 

 

並查集的關鍵在於處理父子節點的關係,這樣的資料架構可以處理大量的“集合合併”操作

最小生成樹

再來一個例子,假設我們要再部分城市之間架設最新最快的交通軌道,為了使成本最低,現在要你選出一個方案,使架設的軌道線路最低:(現在我們給邊附上權值)

 

 

這樣便是一個基礎的無向圖最小生成樹問題,我們根據我們已經建立的關係,採用並查集的資料結構,採用Kruskal或者Prim演算法的模板可以求出以下結果

更多……

圖論的問題和每一個節點的資訊息息相關,而如何使用圖論模型,關鍵在於如何定義“節點”

關於這個問題,我很喜歡《演算法圖解》裡的講解方式——將“狀態”轉化為“資訊”儲存到“節點”裡,每個狀態是一個“節點”,狀態變化的過程就是“邊”。這便是連線實際問題和圖論演算法的橋樑,理解了這個思想,很多模型建立的困惑就能迎刃而解了,圖論的其他問題基本都能通過這個思想來建模。

希望我的拋磚引玉能引起更多的思考! ? (蒟蒻鞠躬)

相關文章