重學數據結構和算法(一)之復雜度、數組、鏈表、棧、隊列、圖


最近學習了極客時間的《數據結構與算法之美]》很有收獲,記錄總結一下。
歡迎學習老師的專欄:數據結構與算法之美
代碼地址: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)。

雙向鏈表


從結構上來看,雙向鏈表可以支持 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)


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM