算法學習筆記之複雜度分析與線性表

算法學習筆記之複雜度分析與線性表

總結匯總一些關於算法時間和空間複雜度分析的表示方法和10種數據結構中的線性表結構(數組、鏈表、棧、隊列)。

1. 時間複雜度分析

1.1 大 O 複雜度表示法

所有代碼的執行時間 T(n) 與每行代碼的執行次數成正比。
T(n)=O(f(n))T_{(n)}=O(f_{(n)}),T(n) 表示代碼執行的時間;n 表示數據規模的大小;f(n) 表示每行代碼執行的次數總和。因爲這是一個公式,所以用 f(n) 來表示。公式中的 O,表示代碼的執行時間 T(n) 與 f(n) 表達式成正比。

1.2 時間複雜度分析

  • 只關注循環執行次數最多的一段代碼
  • 加法法則:總複雜度等於量級最大的那段代碼的複雜度
  • 乘法法則:嵌套代碼的複雜度等於嵌套內外代碼複雜度的乘積

1.3 常見時間複雜度

常見時間複雜度大體可以分爲兩類,多項式量級非多項式量級。其中,非多項式量級只有兩個:O(2n) 和 O(n!)。我們把時間複雜度爲非多項式量級的算法問題叫作 NP(Non-Deterministic Polynomial,非確定多項式)問題。當數據規模 n 越來越大時,非多項式量級算法的執行時間會急劇增加,求解問題的執行時間會無限增長。所以,非多項式時間複雜度的算法其實是非常低效的算法。

1.3.1 O(1)

只要代碼的執行時間不隨 n 的增大而增長,這樣代碼的時間複雜度我們都記作 O(1)。或者說,一般情況下,只要算法中不存在循環語句、遞歸語句,即使有成千上萬行的代碼,其時間複雜度也是Ο(1)。

1.3.2 O(logn)/O(nlogn)

通過 2x=n 求解 x,即:x=log2n,所以,這段代碼的時間複雜度就是 O(log2n)O(log_{2}n)。不管是以 2 爲底、以 3 爲底,還是以 10 爲底,我們可以把所有對數階的時間複雜度都記爲 O(logn)。

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

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

1.3.3 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;
}

1.4 空間複雜度分析

時間複雜度的全稱是漸進時間複雜度,表示算法的執行時間與數據規模之間的增長關係。類比一下,空間複雜度全稱就是漸進空間複雜度(asymptotic space complexity),表示算法的存儲空間與數據規模之間的增長關係。我們常見的空間複雜度就是 O(1)、O(n)、O(n2)。

2. 線性表

線性表就是數據排成像一條線一樣的結構。每個線性表上的數據最多隻有前和後兩個方向。其實除了數組,鏈表、隊列、棧等也是線性表結構。與它相對立的概念是非線性表,比如二叉樹、堆、圖等。之所以叫非線性,是因爲,在非線性表中,數據之間並不是簡單的前後關係。

2.1 數組

數組(Array)是一種線性表數據結構。它用一組連續的內存空間,來存儲一組具有相同類型的數據。
特點:隨機訪問,低效的“插入”和“刪除”


int[] a = new int[3];
a[3] = 10;
2.2 鏈表

鏈表不需要一塊連續的內存空間,它通過“指針”將一組零散的內存塊串聯起來使用。最常見的鏈表結構分別是:單鏈表、雙向鏈表和循環鏈表

  • 單鏈表

鏈表通過指針將一組零散的內存塊串聯在一起。其中,我們把內存塊稱爲鏈表的“結點”。爲了將所有的結點串起來,每個鏈表的結點除了存儲數據之外,還需要記錄鏈上的下一個結點的地址。我們把這個記錄下個結點地址的指針叫作後繼指針 next。其中有兩個結點是比較特殊的,它們分別是第一個結點和最後一個結點。我們習慣性地把第一個結點叫作頭結點,把最後一個結點叫作尾結點。其中,頭結點用來記錄鏈表的基地址。有了它,我們就可以遍歷得到整條鏈表。而尾結點特殊的地方是:指針不是指向下一個結點,而是指向一個空地址 NULL,表示這是鏈表上最後一個結點。

  • 雙向鏈表

雙向鏈表跟單鏈表唯一的區別就在尾結點。我們知道,單鏈表的尾結點指針指向空地址,表示這就是最後的結點了。而循環鏈表的尾結點指針是指向鏈表的頭結點。

  • 循環鏈表

雙向鏈表,顧名思義,它支持兩個方向,每個結點不止有一個後繼指針 next 指向後面的結點,還有一個前驅指針 prev 指向前面的結點。雙向鏈表需要額外的兩個空間來存儲後繼結點和前驅結點的地址。

2.3 棧

從下往上一個一個放;取的時候,我們也是從上往下一個一個地依次取,不能從中間任意抽出。後進者先出,先進者後出,這就是典型的“棧”結構。
棧既可以用數組來實現,也可以用鏈表來實現。用數組實現的棧,我們叫作順序棧,用鏈表實現的棧,我們叫作鏈式棧。以下爲實現順序棧的Java代碼示例。


// 基於數組實現的順序棧
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;
  }
}
  • 函數調用棧

操作系統給每個線程分配了一塊獨立的內存空間,這塊內存被組織成“棧”這種結構, 用來存儲函數調用時的臨時變量。每進入一個函數,就會將臨時變量作爲一個棧幀入棧,當被調用函數執行完成,返回之後,將這個函數對應的棧幀出棧。

  • 表達式求值

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

2.4 隊列

先進者先出,這就是典型的“隊列”。最基本的操作也是兩個:入隊 enqueue(),放一個數據到隊列尾部;出隊 dequeue(),從隊列頭部取一個元素。用數組實現的隊列叫作順序隊列,用鏈表實現的隊列叫作鏈式隊列。下面是用Java實現的順序隊列。


// 用數組實現的隊列
public class ArrayQueue {

  // 數組:items,數組大小:n
  private String[] items;
  private int n = 0;
  // head表示隊頭下標,tail表示隊尾下標
  private int head = 0;
  private int tail = 0;

  // 申請一個大小爲capacity的數組
  public ArrayQueue(int capacity) {
    items = new String[capacity];
    n = capacity;
  }

   // 入隊操作,將item放入隊尾
  public boolean enqueue(String item) {
    // tail == n表示隊列末尾沒有空間了
    if (tail == n) {
      // tail ==n && head==0,表示整個隊列都佔滿了
      if (head == 0) return false;
      // 數據搬移
      for (int i = head; i < tail; ++i) {
        items[i-head] = items[i];
      }
      // 搬移完之後重新更新head和tail
      tail -= head;
      head = 0;
    }
    
    items[tail] = item;
    ++tail;
    return true;
  }

  // 出隊
  public String dequeue() {
    // 如果head == tail 表示隊列爲空
    if (head == tail) return null;
    String ret = items[head];
    ++head;
    return ret;
  }
}
  • 循環隊列

像一個環。原本數組是有頭有尾的,是一條直線。現在我們把首尾相連,扳成了一個環。


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 原子操作,可以實現非常高效的併發隊列。這也是循環隊列比鏈式隊列應用更加廣泛的原因。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章