重學資料結構和演算法(一)之複雜度、陣列、連結串列、棧、佇列、圖

夢和遠方發表於2021-02-20

最近學習了極客時間的《資料結構與演算法之美]》很有收穫,記錄總結一下。
歡迎學習老師的專欄:資料結構與演算法之美
程式碼地址:https://github.com/peiniwan/Arithmetic

資料結構

  • 舉個例子:圖書管理員會將書籍分門別類進行“儲存”,按照一定規律編號,這就是書籍這種“資料”的儲存結構。
  • 那我們如何來查詢一本書呢?有很多種辦法,你當然可以一本一本地找,也可以先根據書籍類別的編號,是人文,還是科學、計算機,來定位書架,然後再依次查詢。籠統地說,這些查詢方法都是演算法。
  • 資料結構和演算法是相輔相成的。資料結構是為演算法服務的,演算法要作用在特定的資料結構之上。 因此,我們無法孤立資料結構來講演算法,也無法孤立演算法來講資料結構
  • “儲存”需要的就是資料結構,“計算”需要的就是演算法

常用資料結構與演算法

  • 20 個最常用的、最基礎資料結構與演算法
    10 個資料結構:陣列、連結串列、棧、佇列、雜湊表、二叉樹、堆、跳錶、圖、Trie 樹;
  • 10 個演算法:遞迴、排序、二分查詢、搜尋、雜湊演算法、貪心演算法、分治演算法、回溯演算法、動態規劃、字串匹配演算法。
  • 在學習資料結構和演算法的過程中,你也要注意,不要只是死記硬背,不要為了學習而學習,而是要學習它的“來歷”“自身的特點”“適合解決的問題”以及“實際的應用場景”

複雜度

複雜度分析是整個演算法學習的精髓,只要掌握了它,資料結構和演算法的內容基本上就掌握了一半。

時間複雜度

基礎

所有程式碼的執行時間 T(n) 與每行程式碼的執行次數成正比。
T(n) =O( f(n) )

 int cal(int n) {
   int sum = 0;
   int i = 1;
   for (; i <= n; ++i) {
     sum = sum + i;
   }
   return sum;
 }
  • 其中第 2、3 行程式碼都是常量級的執行時間,與 n 的大小無關,所以對於複雜度並沒有影響。當 n 無限大的時候,就可以忽略。
  • 迴圈執行次數最多的是第 4、5 行程式碼,所以這塊程式碼要重點分析。前面我們也講過,這兩行程式碼被執行了 n 次,所以總的時間複雜度就是 O(n)。

經驗

  • 總的時間複雜度就等於量級最大的那段程式碼的時間複雜度
  • 如果 T1(n)=O(f(n)),T2(n)=O(g(n));那麼 T(n)=T1(n)+T2(n)=max(O(f(n)), O(g(n))) =O(max(f(n), g(n))).
  • 假設 T1(n) = O(n),T2(n) = O(n2),則 T1(n) * T2(n) = O(n3)

O(1)

int i = 8; int j = 6; int sum = i + j;
只要程式碼的執行時間不隨 n 的增大而增長,這樣程式碼的時間複雜度我們都記作 O(1)。或者說,一般情況下,只要演算法中不存在迴圈語句、遞迴語句,即使有成千上萬行的程式碼,其時間複雜度也是Ο(1)。
跟資料規模 n 沒有關係,都可忽略

O(logn)、O(nlogn)

 i=1; while (i <= n)  {   i = i * 2; }

從程式碼中可以看出,變數 i 的值從 1 開始取,每迴圈一次就乘以 2。當大於 n 時,迴圈結束。還記得我們高中學過的等比數列嗎?實際上,變數 i 的取值就是一個等比數列。如果我把它一個一個列出來,就應該是這個樣子的:

2x=n
x=log2n

 i=1; while (i <= n)  {   i = i * 3; }

這段程式碼的時間複雜度為 O(log3n)。
但是實際上,不管是以 2 為底、以 3 為底,還是以 10 為底,我們可以把所有對數階的時間複雜度都記為 O(logn)

O(nlogn)
如果一段程式碼的時間複雜度是 O(logn),我們迴圈執行 n 遍,時間複雜度就是 O(nlogn) 了。而且,O(nlogn) 也是一種非常常見的演算法時間複雜度。比如,歸併排序、快速排序的時間複雜度都是 O(nlogn)

O(m+n)、O(m* n)

程式碼的複雜度由兩個資料的規模來決定


int cal(int m, int n) {
  int sum_1 = 0;
  int i = 1;
  for (; i < m; ++i) {
    sum_1 = sum_1 + i;
  }

  int sum_2 = 0;
  int j = 1;
  for (; j < n; ++j) {
    sum_2 = sum_2 + j;
  }

  return sum_1 + sum_2;
}

從程式碼中可以看出,m 和 n 是表示兩個資料規模。我們無法事先評估 m 和 n 誰的量級大,所以我們在表示複雜度的時候,就不能簡單地利用加法法則,省略掉其中一個。所以,上面程式碼的時間複雜度就是 O(m+n)。針對這種情況,原來的加法法則就不正確了

空間複雜度分析

  • 時間複雜度的全稱是漸進時間複雜度,表示演算法的執行時間與資料規模之間的增長關係。類比一下,空間複雜度全稱就是漸進空間複雜度(asymptotic space complexity),表示演算法的儲存空間與資料規模之間的增長關係
  • 我們常見的空間複雜度就是 O(1)、O(n)、O(n2) ,像 O(logn)、O(nlogn) 這樣的對數階複雜度平時都用不到。而且,空間複雜度分析比時間複雜度分析要簡單很多。

陣列

陣列(Array)是一種線性表資料結構。它用一組連續的記憶體空間,來儲存一組具有相同型別的資料。
線性表(Linear List)。顧名思義,線性表就是資料排成像一條線一樣的結構。每個線性表上的資料最多隻有前和後兩個方向。其實除了陣列,連結串列、佇列、棧等也是線性表結構。
而與它相對立的概念是非線性表,比如二叉樹、堆、圖等。之所以叫非線性,是因為,在非線性表中,資料之間並不是簡單的前後關係。

為什麼陣列從0開始


計算機會給每個記憶體單元分配一個地址,計算機通過地址來訪問記憶體中的資料。當計算機需要隨機訪問陣列中的某個元素時,它會首先通過下面的定址公式,計算出該元素儲存的記憶體地址:

a[i]_address = base_address + i * data_type_size

其中 base_address 是首地址,data_type_size 表示陣列中每個元素的大小。我們舉的這個例子裡,陣列中儲存的是 int 型別資料,所以 data_type_size 就為 4 個位元組。根據首地址和下標,通過定址公式就能直接計算出對應的記憶體地址。
但是,如果陣列從 1 開始計數,那我們計算陣列元素 a[k] 的記憶體地址就會變為:

a[k]_address = base_address + (k-1)*type_size

對比兩個公式,我們不難發現,從 1 開始編號,每次隨機訪問陣列元素都多了一次減法運算,對於 CPU 來說,就是多了一次減法指令。所以陣列從0開始。

一個錯誤:
在面試的時候,常常會問陣列和連結串列的區別,很多人都回答說,“連結串列適合插入、刪除,時間複雜度 O(1);陣列適合查詢,查詢時間複雜度為 O(1)”。
實際上,這種表述是不準確的。陣列是適合查詢操作,但是查詢的時間複雜度並不為 O(1)。即便是排好序的陣列,你用二分查詢,時間複雜度也是 O(logn)(k在第幾個位置)。所以,正確的表述應該是,陣列支援隨機訪問,根據下標隨機訪問的時間複雜度為 O(1)。

ArrayList
最大的優勢就是可以將很多陣列操作的細節封裝起來。比如前面提到的陣列插入、刪除資料時需要搬移其他資料等。另外,它還有一個優勢,就是支援動態擴容
如果使用 ArrayList,我們就完全不需要關心底層的擴容邏輯,ArrayList 已經幫我們實現好了。每次儲存空間不夠的時候,它都會將空間自動擴容為 1.5 倍大小。

Java ArrayList 無法儲存基本型別,比如 int、long,需要封裝為 Integer、Long 類,所以如果特別關注效能,或者希望使用基本型別,就可以選用陣列。

連結串列

從圖中看到,陣列需要一塊連續的記憶體空間來儲存,對記憶體的要求比較高。如果我們申請一個 100MB 大小的陣列,當記憶體中沒有連續的、足夠大的儲存空間時,即便記憶體的剩餘總可用空間大於 100MB,仍然會申請失敗。
而連結串列恰恰相反,它並不需要一塊連續的記憶體空間,它通過“指標”將一組零散的記憶體塊串聯起來使用,所以如果我們申請的是 100MB 大小的連結串列,根本不會有問題。

針對連結串列的插入和刪除操作,只需要考慮相鄰結點的指標改變,所以對應的時間複雜度是 O(1)。

雙向連結串列

334df425b32ee7c046b18e7cb04204bb.png
從結構上來看,雙向連結串列可以支援 O(1) 時間複雜度的情況下找到前驅結點,正是這樣的特點,也使雙向連結串列在某些情況下的插入、刪除等操作都要比單連結串列簡單、高效。
除了插入、刪除操作有優勢之外,對於一個有序連結串列,雙向連結串列的按值查詢的效率也要比單連結串列高一些。因為,我們可以記錄上次查詢的位置 p,每次查詢時,根據要查詢的值與 p 的大小關係,決定是往前還是往後查詢,所以平均只需要查詢一半的資料。
LinkedHashMap 的實現原理,就會發現其中就用到了雙向連結串列這種資料結構。(用空間換時間)

陣列和連結串列對比

已知前驅節點

陣列的缺點是大小固定,一經宣告就要佔用整塊連續記憶體空間。如果宣告的陣列過大,系統可能沒有足夠的連續記憶體空間分配給它,導致“記憶體不足(out of memory)”。如果宣告的陣列過小,則可能出現不夠用的情況。這時只能再申請一個更大的記憶體空間,把原陣列拷貝進去,非常費時。連結串列本身沒有大小的限制,天然地支援動態擴容,我覺得這也是它與陣列最大的區別。
如果你的程式碼對記憶體的使用非常苛刻,那陣列就更適合你。因為連結串列中的每個結點都需要消耗額外的儲存空間去儲存一份指向下一個結點的指標,所以記憶體消耗會翻倍。而且,對連結串列進行頻繁的插入、刪除操作,還會導致頻繁的記憶體申請和釋放,容易造成記憶體碎片,如果是 Java 語言,就有可能會導致頻繁的 GC(Garbage Collection,垃圾回收)。

寫連結串列程式碼技巧

技巧一:理解指標或引用的含義
我們知道,有些語言有“指標”的概念,比如 C 語言;有些語言沒有指標,取而代之的是“引用”,比如 Java、Python。不管是“指標”還是“引用”,實際上,它們的意思都是一樣的,都是儲存所指物件的記憶體地址。
如果你用的是 Java 或者其他沒有指標的語言也沒關係,你把它理解成“引用”就可以了

將某個變數賦值給指標,實際上就是將這個變數的地址賦值給指標,或者反過來說,指標中儲存了這個變數的記憶體地址,指向了這個變數,通過指標就能找到這個變數。

在編寫連結串列程式碼的時候,我們經常會有這樣的程式碼:p->next=q。這行程式碼是說,p 結點中的 next 指標儲存了 q 結點的記憶體地址。
還有一個更復雜的,也是我們寫連結串列程式碼經常會用到的:p->next=p->next->next。這行程式碼表示,p 結點的 next 指標儲存了 p 結點的下下一個結點的記憶體地址。

技巧三:利用哨兵簡化實現難度
針對連結串列的插入、刪除操作,需要對插入第一個結點和刪除最後一個結點的情況進行特殊處理。
如果我們引入哨兵結點,在任何時候,不管連結串列是不是空,head 指標都會一直指向這個哨兵結點。我們也把這種有哨兵結點的連結串列叫帶頭連結串列。相反,沒有哨兵結點的連結串列就叫作不帶頭連結串列。
我畫了一個帶頭連結串列,你可以發現,哨兵結點是不儲存資料的。因為哨兵結點一直存在,所以插入第一個結點和插入其他結點,刪除最後一個結點和刪除其他結點,都可以統一為相同的程式碼實現邏輯了。

利用哨兵簡化程式設計難度的技巧,在很多程式碼實現中都有用到,比如插入排序、歸併排序、動態規劃等。

技巧四:重點留意邊界條件處理
我經常用來檢查連結串列程式碼是否正確的邊界條件有這樣幾個:

  • 如果連結串列為空時,程式碼是否能正常工作?
  • 如果連結串列只包含一個結點時,程式碼是否能正常工作?
  • 如果連結串列只包含兩個結點時,程式碼是否能正常工作?
  • 程式碼邏輯在處理頭結點和尾結點的時候,是否能正常工作?

技巧五:舉例畫圖,輔助思考
舉例法和畫圖法
比如往單連結串列中插入一個資料這樣一個操作,我一般都是把各種情況都舉一個例子,畫出插入前和插入後的連結串列變化,如圖所示:

技巧六:多寫多練,沒有捷徑
我精選了 5 個常見的連結串列操作。你只要把這幾個操作都能寫熟練,不熟就多寫幾遍,我保證你之後再也不會害怕寫連結串列程式碼。
單連結串列反轉
連結串列中環的檢測
兩個有序的連結串列合併
刪除連結串列倒數第 n 個結點
求連結串列的中間結點
練習題LeetCode對應編號:206,141,21,19,876。大家可以去練習,另外建議作者兄每章直接給出LC的題目編號或連結方便大家練習。

當某個資料集合只涉及在一端插入和刪除資料,並且滿足後進先出、先進後出的特性,我們就應該首選“棧”這種資料結構。

實現一個棧

棧既可以用陣列來實現,也可以用連結串列來實現。用陣列實現的棧,我們叫作順序棧,用連結串列實現的棧,我們叫作鏈式棧。


// 基於陣列實現的順序棧
public class ArrayStack {
  private String[] items;  // 陣列
  private int count;       // 棧中元素個數
  private int n;           //棧的大小

  // 初始化陣列,申請一個大小為n的陣列空間
  public ArrayStack(int n) {
    this.items = new String[n];
    this.n = n;
    this.count = 0;
  }

  // 入棧操作
  public boolean push(String item) {
    // 陣列空間不夠了,直接返回false,入棧失敗。
    if (count == n) return false;
    // 將item放到下標為count的位置,並且count加一
    items[count] = item;
    ++count;
    return true;
  }
  
  // 出棧操作
  public String pop() {
    // 棧為空,則直接返回null
    if (count == 0) return null;
    // 返回下標為count-1的陣列元素,並且棧中元素個數count減一
    String tmp = items[count-1];
    --count;
    return tmp;
  }
}

不管是順序棧還是鏈式棧,我們儲存資料只需要一個大小為 n 的陣列就夠了。在入棧和出棧過程中,只需要一兩個臨時變數儲存空間,所以空間複雜度是 O(1)。
注意,這裡儲存資料需要一個大小為 n 的陣列,並不是說空間複雜度就是 O(n)。因為,這 n 個空間是必須的,無法省掉。
所以我們說空間複雜度的時候,是指除了原本的資料儲存空間外,演算法執行還需要額外的儲存空間。空間複雜度分析是不是很簡單?
時間複雜度也不難。不管是順序棧還是鏈式棧,入棧、出棧只涉及棧頂個別資料的操作,所以時間複雜度都是 O(1)。

如果要實現一個支援動態擴容的棧,我們只需要底層依賴一個支援動態擴容的陣列就可以了。當棧滿了之後,我們就申請一個更大的陣列,將原來的資料搬移到新陣列中。

棧的應用

棧在函式呼叫中的應用
作業系統給每個執行緒分配了一塊獨立的記憶體空間,這塊記憶體被組織成“棧”這種結構, 用來儲存函式呼叫時的臨時變數。每進入一個函式,就會將臨時變數作為一個棧幀入棧,當被呼叫函式執行完成,返回之後,將這個函式對應的棧幀出棧。


int main() {
   int a = 1; 
   int ret = 0;
   int res = 0;
   ret = add(3, 5);
   res = a + ret;
   printf("%d", res);
   reuturn 0;
}

int add(int x, int y) {
   int sum = 0;
   sum = x + y;
   return sum;
}

棧在表示式求值中的應用
編譯器就是通過兩個棧來實現的。其中一個儲存運算元的棧,另一個是儲存運算子的棧。我們從左向右遍歷表示式,當遇到數字,我們就直接壓入運算元棧;當遇到運算子,就與運算子棧的棧頂元素進行比較。
如果比運算子棧頂元素的優先順序高,就將當前運算子壓入棧;如果比運算子棧頂元素的優先順序低或者相同,從運算子棧中取棧頂運算子,從運算元棧的棧頂取 2 個運算元,然後進行計算,再把計算完的結果壓入運算元棧,繼續比較。

記憶體中的堆疊

記憶體中的堆疊和資料結構堆疊不是一個概念,可以說記憶體中的堆疊是真實存在的物理區,資料結構中的堆疊是抽象的資料儲存結構。

靜態資料區:儲存全域性變數、靜態變數、常量,常量包括final修飾的常量和String常量。系統自動分配和回收。
棧區:儲存執行方法的形參、區域性變數、返回值。由系統自動分配和回收。
堆區:new一個物件的引用或地址儲存在棧區,指向該物件儲存在堆區中的真實資料。

佇列

先進者先出,這就是典型的“佇列”。佇列跟棧一樣,也是一種操作受限的線性表資料結構。
佇列的應用也非常廣泛,特別是一些具有某些額外特性的佇列,比如迴圈佇列、阻塞佇列、併發佇列。

實現佇列

跟棧一樣,佇列可以用陣列來實現,也可以用連結串列來實現。用陣列實現的棧叫作順序棧,用連結串列實現的棧叫作鏈式棧。同樣,用陣列實現的佇列叫作順序佇列,用連結串列實現的佇列叫作鏈式佇列。

迴圈佇列

我們剛才用陣列來實現佇列的時候,在 tail==n 時,會有資料搬移操作,這樣入隊操作效能就會受到影響。那有沒有辦法能夠避免資料搬移呢?我們來看看迴圈佇列的解決思路。
迴圈佇列,顧名思義,它長得像一個環。原本陣列是有頭有尾的,是一條直線。現在我們把首尾相連,扳成了一個環。

圖中這個佇列的大小為 8,當前 head=4,tail=7。當有一個新的元素 a 入隊時,我們放入下標為 7 的位置。但這個時候,我們並不把 tail 更新為 8,而是將其在環中後移一位,到下標為 0 的位置。當再有一個元素 b 入隊時,我們將 b 放入下標為 0 的位置,然後 tail 加 1 更新為 1。所以,在 a,b 依次入隊之後,迴圈佇列中的元素就變成了下面的樣子:

實現迴圈佇列

要確定好隊空和隊滿的判定條件。
在用陣列實現的非迴圈佇列中,隊滿的判斷條件是 tail == n,隊空的判斷條件是 head == tail。那針對迴圈佇列,
如何判斷隊空和隊滿呢?佇列為空的判斷條件仍然是 head == tail。但佇列滿的判斷條件就稍微有點複雜了。我畫了一張佇列滿的圖

就像我圖中畫的隊滿的情況,tail=3,head=4,n=8,所以總結一下規律就是:(3+1)%8=4。多畫幾張隊滿的圖,你就會發現,當隊滿時,(tail+1)%n=head。
你有沒有發現,當佇列滿時,圖中的 tail 指向的位置實際上是沒有儲存資料的。所以,迴圈佇列會浪費一個陣列的儲存空間。


public class CircularQueue {
  // 陣列:items,陣列大小:n
  private String[] items;
  private int n = 0;
  // head表示隊頭下標,tail表示隊尾下標
  private int head = 0;
  private int tail = 0;

  // 申請一個大小為capacity的陣列
  public CircularQueue(int capacity) {
    items = new String[capacity];
    n = capacity;
  }

  // 入隊
  public boolean enqueue(String item) {
    // 佇列滿了
    if ((tail + 1) % n == head) return false;
    items[tail] = item;
    tail = (tail + 1) % n;
    return true;
  }

  // 出隊
  public String dequeue() {
    // 如果head == tail 表示佇列為空
    if (head == tail) return null;
    String ret = items[head];
    head = (head + 1) % n;
    return ret;
  }
}

阻塞佇列和併發佇列

阻塞佇列其實就是在佇列基礎上增加了阻塞操作。簡單來說,就是在佇列為空的時候,從隊頭取資料會被阻塞。因為此時還沒有資料可取,直到佇列中有了資料才能返回;如果佇列已經滿了,那麼插入資料的操作就會被阻塞,直到佇列中有空閒位置後再插入資料,然後再返回。

上述的定義就是一個“生產者 - 消費者模型”!是的,我們可以使用阻塞佇列,輕鬆實現一個“生產者 - 消費者模型”!
這種基於阻塞佇列實現的“生產者 - 消費者模型”,可以有效地協調生產和消費的速度。當“生產者”生產資料的速度過快,“消費者”來不及消費時,儲存資料的佇列很快就會滿了。這個時候,生產者就阻塞等待,直到“消費者”消費了資料,“生產者”才會被喚醒繼續“生產”。

基於阻塞佇列,我們還可以通過協調“生產者”和“消費者”的個數,來提高資料的處理效率。比如前面的例子,我們可以多配置幾個“消費者”,來應對一個“生產者”。

前面我們講了阻塞佇列,在多執行緒情況下,會有多個執行緒同時操作佇列,這個時候就會存線上程安全問題,那如何實現一個執行緒安全的佇列呢?

執行緒安全的佇列我們叫作併發佇列。最簡單直接的實現方式是直接在 enqueue()、dequeue() 方法上加鎖,但是鎖粒度大併發度會比較低,同一時刻僅允許一個存或者取操作。實際上,基於陣列的迴圈佇列,利用 CAS 原子操作,可以實現非常高效的併發佇列。這也是迴圈x佇列比鏈式佇列應用更加廣泛的原因。在實戰篇講 Disruptor 的時候,我會再詳細講併發佇列的應用。

ConcurrentLinkedQueue : 是一個適用於高併發場景下的佇列,通過無鎖的方式,實現
了高併發狀態下的高效能
CAS理論:compare and swap 比較並交換。該操作通過將記憶體中的值與指定資料進行比較,當數值一樣時將記憶體中的資料替換為新的值

執行緒池沒有空閒執行緒時,新的任務請求執行緒資源時,執行緒池該如何處理?各種處理策略又是如何實現的呢?
我們一般有兩種處理策略。第一種是非阻塞的處理方式,直接拒絕任務請求;另一種是阻塞的處理方式,將請求排隊,等到有空閒執行緒時,取出排隊的請求繼續處理。那如何儲存排隊的請求呢?

基於連結串列的實現方式,可以實現一個支援無限排隊的無界佇列(unbounded queue),但是可能會導致過多的請求排隊等待,請求處理的響應時間過長。所以,針對響應時間比較敏感的系統,基於連結串列實現的無限排隊的執行緒池是不合適的。
而基於陣列實現的有界佇列(bounded queue),佇列的大小有限,所以執行緒池中排隊的請求超過佇列大小時,接下來的請求就會被拒絕,這種方式對響應時間敏感的系統來說,就相對更加合理。不過,設定一個合理的佇列大小,也是非常有講究的。佇列太大導致等待的請求太多,佇列太小會導致無法充分利用系統資源、發揮最大效能。

除了前面講到佇列應用線上程池請求排隊的場景之外,佇列可以應用在任何有限資源池中,用於排隊請求,比如資料庫連線池等。實際上,對於大部分資源有限的場景,當沒有空閒資源時,基本上都可以通過“佇列”這種資料結構來實現請求排隊。

基礎概念

我們知道,樹中的元素我們稱為節點,圖中的元素我們就叫作頂點(vertex)。從我畫的圖中可以看出來,圖中的一個頂點可以與任意其他頂點建立連線關係。我們把這種建立的關係叫作邊(edge)。

如何儲存微博、微信等社交網路中的好友關係?
我們就拿微信舉例子吧。我們可以把每個使用者看作一個頂點。如果兩個使用者之間互加好友,那就在兩者之間建立一條邊。所以,整個微信的好友關係就可以用一張圖來表示。其中,每個使用者有多少個好友,對應到圖中,就叫作頂點的度(degree),就是跟頂點相連線的邊的條數

如果使用者 A 關注了使用者 B,我們就在圖中畫一條從 A 到 B 的帶箭頭的邊,來表示邊的方向。如果使用者 A 和使用者 B 互相關注了,那我們就畫一條從 A 指向 B 的邊,再畫一條從 B 指向 A 的邊。我們把這種邊有方向的圖叫作“有向圖”。以此類推,我們把邊沒有方向的圖就叫作“無向圖”

無向圖中有“度”這個概念,表示一個頂點有多少條邊。在有向圖中,我們把度分為入度(In-degree)和出度(Out-degree)。

頂點的入度,表示有多少條邊指向這個頂點;頂點的出度,表示有多少條邊是以這個頂點為起點指向其他頂點。對應到微博的例子,入度就表示有多少粉絲,出度就表示關注了多少人。

帶權圖(weighted graph)。在帶權圖中,每條邊都有一個權重(weight),我們可以通過這個權重來表示 QQ 好友間的親密度。

掌握了圖的概念之後,我們再來看下,如何在記憶體中儲存圖這種資料結構呢?

實現

鄰接矩陣


我們儲存的是稀疏圖(Sparse Matrix),也就是說,頂點很多,但每個頂點的邊並不多,那鄰接矩陣的儲存方法就更加浪費空間了。比如微信有好幾億的使用者,對應到圖上就是好幾億的頂點。但是每個使用者的好友並不會很多,一般也就三五百個而已。如果我們用鄰接矩陣來儲存,那絕大部分的儲存空間都被浪費了。

鄰接表儲存方法

鄰接矩陣儲存起來比較浪費空間,但是使用起來比較節省時間。
鄰接表儲存起來比較節省空間,但是使用起來就比較耗時間。
我們可以將鄰接表中的連結串列改成平衡二叉查詢樹。實際開發中,我們可以選擇用紅黑樹。這樣,我們就可以更加快速地查詢兩個頂點之間是否存在邊了。當然,這裡的二叉查詢樹可以換成其他動態資料結構,比如跳錶、雜湊表等。除此之外,我們還可以將連結串列改成有序動態陣列,可以通過二分查詢的方法來快速定位兩個頂點之間否是存在邊。

實現無向圖

public class Graph { // 無向圖
  private int v; // 頂點的個數
  private LinkedList<Integer> adj[]; // 鄰接表

  public Graph(int v) {
    this.v = v;
    adj = new LinkedList[v];
    for (int i=0; i<v; ++i) {
      adj[i] = new LinkedList<>();
    }
  }

  public void addEdge(int s, int t) { // 無向圖一條邊存兩次
    adj[s].add(t);
    adj[t].add(s);
  }
}

搜尋

廣度優先搜尋(BFS)
直觀地講,它其實就是一種“地毯式”層層推進的搜尋策略,即先查詢離起始頂點最近的,然後是次近的,依次往外搜尋。

深度優先搜尋(DFS)

相關文章