算法學習筆記之複雜度分析與線性表
總結匯總一些關於算法時間和空間複雜度分析的表示方法和10種數據結構中的線性表結構(數組、鏈表、棧、隊列)。
文章目錄
1. 時間複雜度分析
1.1 大 O 複雜度表示法
所有代碼的執行時間 T(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,所以,這段代碼的時間複雜度就是 。不管是以 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 原子操作,可以實現非常高效的併發隊列。這也是循環隊列比鏈式隊列應用更加廣泛的原因。