資料結構與演算法
1演算法(algorithm)
1.1演算法定義
在有限時間內解決特定問題的一組指令或者步驟
特性:
- 明確問題,包含輸入輸出
- 可行性,能在有限步驟和時間空間內完成
- 每一步定義明確,在相同輸入和執行條件下,輸出結果始終相同
1.2資料結構定義(data structure)
組織和儲存資料的方式,涵蓋資料內容,資料之間關係和操作方法
設計目標:
- 空間佔用儘量小------------節省記憶體
- 資料操作快速---------------資料訪問控制
- 邏輯和資料表示簡潔------高效執行
1.3資料結構與演算法的關係
- 資料結構為演算法提供了結構化儲存的資料和運算元據的方法
- 資料結構本身僅儲存資料,結合演算法才能解決特定問題
- 演算法通常可以基於不同資料結構實現,但執行效率相差很大,選擇合適資料結構很關鍵
2複雜度
2.1演算法效率評估
演算法設計中,先後追求兩個層面目標
-
找到問題解法:在規定的要求內找到真確解
-
尋找最優解法:高效解決方法
演算法效率的維度:
- 時間效率:執行時間
- 空間效率:佔用記憶體空間大小
效率評估方法:實際測試,理論估算
2.1.1實際測試
實際測試最直觀的方法就是在裝置上執行並記錄執行時間和記憶體佔用情況,反映真實情況但存在很大侷限性。
一,測試環境的干擾:硬體配置、演算法並行程度、演算法記憶體操作機密。種種因素需要我們在各種機器測試並統計平均效率(不現實)
二,完整測試耗費資源:資料量的不同演算法效率會浮動。因此,需要測試各種規模的輸入資料
2.1.2理論估算
透過計算評估演算法的效率:漸進複雜度分析(描述輸入資料大小的增加,演算法執行時間和空間增長的趨勢)
複雜度分析相較於實際測試的優點
- 不需要實際執行程式碼
- 分析結果適用於所有平臺
- 可以體現不同資料量下的演算法效率
2.2迭代和遞迴
在程式中實現重複執行任務,兩種基本的程式控制結構:迭代、遞迴
2.2.1迭代
迭代(iteration)是重複執行某個任務的控制結構。在迭代中程式會滿足一定條件下重複執行某段程式碼,直至不在滿足
1. for迴圈
//適合在預先知道迭代次數時使用
/* for 迴圈 */
int forLoop(int n) {
int res = 0;
// 迴圈求和 1, 2, ..., n-1, n
for (int i = 1; i <= n; ++i) {
res += i;
}
return res;
}
2. while迴圈
//while中程式每輪先檢查條件決定是否執行
/* while 迴圈 */
int whileLoop(int n) {
int res = 0;
int i = 1; // 初始化條件變數
// 迴圈求和 1, 2, ..., n-1, n
while (i <= n) {
res += i;
i++; // 更新條件變數
}
return res;
}
while比for的自由度更高
/* while 迴圈(兩次更新) */
int whileLoopII(int n) {
int res = 0;
int i = 1; // 初始化條件變數
// 迴圈求和 1, 4, 10, ...
while (i <= n) {
res += i;
// 更新條件變數
i++;
i *= 2;
}
return res;
}
3. 巢狀迴圈
以for為例:
/* 雙層 for 迴圈 */
string nestedForLoop(int n) {
ostringstream res;
// 迴圈 i = 1, 2, ..., n-1, n
for (int i = 1; i <= n; ++i) {
// 迴圈 j = 1, 2, ..., n-1, n
for (int j = 1; j <= n; ++j) {
res << "(" << i << ", " << j << "), ";
}
}
return res.str();
}
2.2.2遞迴
遞迴(recursion)----演算法策略,透過函式呼叫自身解決問題
- 遞:不斷呼叫,通常傳入更小或者更簡化引數,直至終止
- 歸:觸發終止條件後逐層返回(每一層)
- 終止條件:
- 遞迴呼叫:函式呼叫自身通常傳入更小或者更簡化引數
- 返回結果:將當前層的結果返回到上一層
/* 遞迴 */
int recur(int n) {
// 終止條件
if (n == 1)
return 1;
// 遞:遞迴呼叫
int res = recur(n - 1);
// 歸:返回結果
return n + res;
}
迭代和遞迴不同:思考和解決問題的正規化
- 迭代:”自下而上“,最基礎開始
- 遞迴:”自上而下“,將問題分解為子問題,子問題和原問題有相同形式,重複過程直到基本情況
- 設f(n) = 1+ 2 + ... + n
- 迭代:從1遍歷到n,每輪進行求和
- 遞迴:將問題分解為f(n) = f(n-1) + n,不斷遞迴下去知道f(1) = 1
1.呼叫棧
遞迴函式每次呼叫自身時,每次都會為新開啟的函式分配記憶體--儲存區域性變數,呼叫地址和其他資訊。
- 函式上下文資料都儲存在稱為”棧空間“,函式返回後會被釋放。so,遞迴比迭代更耗費空間記憶體。
- 因為額外開銷,所以比迴圈的時間效率更低
//在實際中,過深的遞迴可能導致棧溢位錯誤
2.尾遞迴
若函式在返回前最後一步才進行遞迴呼叫,那麼該函式可以被編譯或者直譯器最佳化,這種情況叫做尾遞迴(tail recursion)。
- 普通遞迴:當函式返回上一層後,需要繼續執行程式碼,所以系統需要儲存上一層呼叫的上下文。
- 尾遞迴:遞迴呼叫是函式返回前的最後一個操作
/* 尾遞迴 */
int tailRecur(int n, int res) {
// 終止條件
if (n == 0)
return res;
// 尾遞迴呼叫
return tailRecur(n - 1, res + n);
}
- 普通遞迴:求和操作在歸的過程中執行,每層返回都需要執行求和
- 尾遞迴:求和在遞的過程中執行
3.遞迴樹
當問題需要分化處理時,遞迴比迭代的思路更加直觀,以斐波那契數列為例:
- 數列中每個數字時前兩個數字的和:f(n) = f(n - 1) + f(n - 2)
/* 斐波那契數列:遞迴 */
int fib(int n) {
// 終止條件 f(1) = 0, f(2) = 1
if (n == 1 || n == 2)
return n - 1;
// 遞迴呼叫 f(n) = f(n-1) + f(n-2)
int res = fib(n - 1) + fib(n - 2);
// 返回結果 f(n)
return res;
}
2.2.3差異
迭代 | 遞迴 | |
---|---|---|
實現方式 | 迴圈結構 | 函式效用自身 |
時間效率 | 效率比較高,沒有函式呼叫的開銷 | 每次函式呼叫都會產生開銷 |
記憶體使用 | 通常使用固定記憶體大小 | 累積呼叫可能會使用大量的棧幀空間 |
適用問題 | 簡單迴圈任務 | 適用於分解問題,如樹、圖、分治、回溯等 |
- 遞:當函式被呼叫時,系統會在“呼叫棧”上為該函式分配新的棧幀,用於儲存函式的區域性變數、引數、返回地址等資料。
- 歸:當函式完成執行並返回時,對應的棧幀會被從“呼叫棧”上移除,恢復之前函式的執行環境。
使用一個顯式棧模擬呼叫棧的行為,從而將遞迴轉化為迭代:
/* 使用迭代模擬遞迴 */
int forLoopRecur(int n) {
// 使用一個顯式的棧來模擬系統呼叫棧
stack<int> stack;
int res = 0;
// 遞:遞迴呼叫
for (int i = n; i > 0; i--) {
// 透過“入棧操作”模擬“遞”
stack.push(i);
}
// 歸:返回結果
while (!stack.empty()) {
// 透過“出棧操作”模擬“歸”
res += stack.top();
stack.pop();
}
// res = 1+2+3+...+n
return res;
}
2.3時間複雜度
評估一段程式碼的執行時間:
- 確定執行的平臺:硬體,語言,系統
- 評估各個操作所佔用的時間
- 統計程式碼中所有的計算操作:總和
// 在某執行平臺下
void algorithm(int n) {
int a = 2; // 1 ns
a = a + 1; // 1 ns
a = a * 2; // 10 ns
// 迴圈 n 次
for (int i = 0; i < n; i++) { // 1 ns
cout << 0 << endl; // 5 ns
}
}
以上的方法總執行時間為1+1+10+(1+5)*n = 6n + 12 //但實際上這種統計方式不現實
2.3.1時間增長趨勢
// 演算法 A 的時間複雜度:常數階
void algorithm_A(int n) {
cout << 0 << endl;
}
// 演算法 B 的時間複雜度:線性階
void algorithm_B(int n) {
for (int i = 0; i < n; i++) {
cout << 0 << endl;
}
}
// 演算法 C 的時間複雜度:常數階
void algorithm_C(int n) {
for (int i = 0; i < 1000000; i++) {
cout << 0 << endl;
}
}
-
演算法
A
只有 1 個列印操作,演算法執行時間不隨著 𝑛 增大而增長。我們稱此演算法的時間複雜度為“常數階”。 -
演算法
B
中的列印操作需要迴圈 𝑛 次,演算法執行時間隨著 𝑛 增大呈線性增長。此演算法的時間複雜度被稱為“線性階”。 -
演算法
C
中的列印操作需要迴圈 1000000 次,雖然執行時間很長,但它與輸入資料大小 𝑛 無關。因此C
的時間複雜度和A
相同,仍為“常數階”。 -
時間複雜度能有效評估演算法效率。演算法
B
在n>1時比A
效率低,演算法B
在n>1000000時比演算法C
更慢 -
時間複雜度的推算方法更簡單。
-
時間複雜度也存在侷限性:相同複雜度的形況下,執行時間差別很大,例如
A
和C
,在輸入資料較小情況下,B
優於C
2.3.2函式漸進上界
void algorithm(int n) {
int a = 1; // +1
a = a + 1; // +1
a = a * 2; // +1
// 迴圈 n 次
for (int i = 0; i < n; i++) { // +1(每輪都執行 i ++)
cout << 0 << endl; // +1
}
}
上面函式的運算元量為:T(n) = 3 + 2n
將線性階的時間複雜度記為 𝑂(𝑛) ,這個數學符號稱為大 𝑂 記號(big-𝑂 notation),表示函式 𝑇(𝑛) 的漸近上界(asymptotic upper bound)。
2.3.3推算漸進上界
在確定f(n)之後可以得到時間複雜度O(f(n)),之後分為兩步:1.統計運算元量,2.判斷漸進上界
1.第一步:統計運算元量
若存在正實數 𝑐 和實數 𝑛0 ,使得對於所有的 𝑛>𝑛0 ,均有 𝑇(𝑛)≤𝑐⋅𝑓(𝑛) ,則可認為 𝑓(𝑛) 給出了 𝑇(𝑛) 的一個漸近上界,記為 𝑇(𝑛)=𝑂(𝑓(𝑛)) 。
- 忽略 𝑇(𝑛) 中的常數項。因為它們都與 𝑛 無關,所以對時間複雜度不產生影響。
- 省略所有係數。例如,迴圈 2𝑛 次、5𝑛+1 次等,都可以簡化記為 𝑛 次,因為 𝑛 前面的係數對時間複雜度沒有影響。
- 迴圈巢狀時使用乘法。總運算元量等於外層迴圈和內層迴圈運算元量之積,每一層迴圈依然可以分別套用第
1.
點和第2.
點的技巧。
例:
void algorithm(int n) {
int a = 1; // +0(技巧 1)
a = a + n; // +0(技巧 1)
// +n(技巧 2)
for (int i = 0; i < 5 * n + 1; i++) {
cout << 0 << endl;
}
// +n*n(技巧 3)
for (int i = 0; i < 2 * n; i++) {
for (int j = 0; j < n + 1; j++) {
cout << 0 << endl;
}
}
}
總運算元:
$$
T(n) = 2n(n+1) + (5n + 1) +2 = 2n^2+7n+3
$$
$$
T(n) = n^2 + n
$$
2.判斷漸進上界
時間複雜度主要是T(n)中的最高項決定,例:
運算元量T(n) | 時間複雜度O(f(n)) |
---|---|
100 | O(1) |
3n+2 | O(n) |
2n^2 + 3n | O(n^2) |
n^3 + 2n^2 | O(n3) |
2.3.4常見型別
常見時間複雜度:
$$
O(1)<O(logn)<O(n)<o(nlogn)<O(n2)<O(2n)<O(n!)
$$
1.常數階O(1)
運算元量儘管很大,但與n無關
2.線性階O(n)
線性階運算元量相對於n線性增長,通常出現在單層迴圈中
/* 線性階 */
int linear(int n) {
int count = 0;
for (int i = 0; i < n; i++)
count++;
return count;
}
遍歷陣列或者連結串列時間複雜度為O(n)
/* 線性階(遍歷陣列) */
int arrayTraversal(vector<int> &nums) {
int count = 0;
// 迴圈次數與陣列長度成正比
for (int num : nums) {
count++;
}
return count;
}
3.平方階
通常出現在巢狀中:
/* 平方階 */
int quadratic(int n) {
int count = 0;
// 迴圈次數與資料大小 n 成平方關係
for (int i = 0; i < n; i++) {
for (int j = 0; j < n; j++) {
count++;
}
}
return count;
}
以冒泡為例,外層執行n-1,內層執行n-1,n-2,......,2,1次,那麼時間複雜度為O(n^2)
/* 平方階(氣泡排序) */
int bubbleSort(vector<int> &nums) {
int count = 0; // 計數器
// 外迴圈:未排序區間為 [0, i]
for (int i = nums.size() - 1; i > 0; i--) {
// 內迴圈:將未排序區間 [0, i] 中的最大元素交換至該區間的最右端
for (int j = 0; j < i; j++) {
if (nums[j] > nums[j + 1]) {
// 交換 nums[j] 與 nums[j + 1]
int tmp = nums[j];
nums[j] = nums[j + 1];
nums[j + 1] = tmp;
count += 3; // 元素交換包含 3 個單元操作
}
}
}
return count;
}
4.指數階
指數增長例子:細胞分裂
/* 指數階(迴圈實現) */
int exponential(int n) {
int count = 0, base = 1;
// 細胞每輪一分為二,形成數列 1, 2, 4, 8, ..., 2^(n-1)
for (int i = 0; i < n; i++) {
for (int j = 0; j < base; j++) {
count++;
}
base *= 2;
}
// count = 1 + 2 + 4 + 8 + .. + 2^(n-1) = 2^n - 1
return count;
}
指數演算法在窮舉(暴力搜尋,回溯)中比較常見
5.對數階
對數階是每輪縮減的情況,log2n:
/* 對數階(迴圈實現) */
int logarithmic(int n) {
int count = 0;
while (n > 1) {
n = n / 2;
count++;
}
return count;
}
對數階也常出現在遞迴函式中:
/* 對數階(遞迴實現) */
int logRecur(int n) {
if (n <= 1)
return 0;
return logRecur(n / 2) + 1;
}
6.線性對數階
線性對數階經常出現在巢狀迴圈中,兩層迴圈的時間複雜度分別為 𝑂(log𝑛) 和 𝑂(𝑛) :
/* 線性對數階 */
int linearLogRecur(int n) {
if (n <= 1)
return 1;
int count = linearLogRecur(n / 2) + linearLogRecur(n / 2);
for (int i = 0; i < n; i++) {
count++;
}
return count;
}
主流排序演算法的時間複雜度通常為 𝑂(𝑛log𝑛) ,例如快速排序、歸併排序、堆排序等。
7.階乘階
階乘相似“全排列” n!
/* 階乘階(遞迴實現) */
int factorialRecur(int n) {
if (n == 0)
return 1;
int count = 0;
// 從 1 個分裂出 n 個
for (int i = 0; i < n; i++) {
count += factorialRecur(n - 1);
}
return count;
}
2.3.5最佳,最差,平均時間複雜度
演算法的效率不固定,假設一個長度為n的陣列要返回1的索引,那麼1的位置久回影響演算法的效率
- 當
nums = [?, ?, ..., 1]
,即當末尾元素是 1 時,需要完整遍歷陣列,達到最差時間複雜度 𝑂(𝑛) 。 - 當
nums = [1, ?, ?, ...]
,即當首個元素為 1 時,無論陣列多長都不需要繼續遍歷,達到最佳時間複雜度 Ω(1) 。
“最差時間複雜度”對應函式漸近上界,使用大 𝑂 記號表示。相應地,“最佳時間複雜度”對應函式漸近下界,用 Ω 記號表示:
/* 生成一個陣列,元素為 { 1, 2, ..., n },順序被打亂 */
vector<int> randomNumbers(int n) {
vector<int> nums(n);
// 生成陣列 nums = { 1, 2, 3, ..., n }
for (int i = 0; i < n; i++) {
nums[i] = i + 1;
}
// 使用系統時間生成隨機種子
unsigned seed = chrono::system_clock::now().time_since_epoch().count();
// 隨機打亂陣列元素
shuffle(nums.begin(), nums.end(), default_random_engine(seed));
return nums;
}
/* 查詢陣列 nums 中數字 1 所在索引 */
int findOne(vector<int> &nums) {
for (int i = 0; i < nums.size(); i++) {
// 當元素 1 在陣列頭部時,達到最佳時間複雜度 O(1)
// 當元素 1 在陣列尾部時,達到最差時間複雜度 O(n)
if (nums[i] == 1)
return i;
}
return -1;
}
最差時間複雜度更為實用,因為它給出了一個效率安全值
2.4空間複雜度
空間複雜度(space complexity)用於衡量演算法佔用記憶體空間隨著資料量變大時的增長趨勢。
2.4.1演算法相關空間
演算法在執行過程中所使用的記憶體空間主要有一下幾種:
- 輸入空間:儲存演算法的輸入資料
- 輸出空間:儲存演算法的輸出資料
- 暫存空間:儲存演算法在執行過程中的變數,物件,函式上下文資料等
一般空間複雜度的計算範圍是輸出空間加上暫存空間
暫存空間:
- 暫存資料:儲存演算法執行過程中的變數,常量,物件等
- 棧幀空間:儲存呼叫函式的上下文資料。系統會在每次呼叫函式的時候在棧頂部建立一個棧幀,函式返回後棧幀空間被釋放。
- 指令空間:儲存編譯後的程式指令
/* 結構體 */
struct Node {
int val;
Node *next;
Node(int x) : val(x), next(nullptr) {}
};
/* 函式 */
int func() {
// 執行某些操作...
return 0;
}
int algorithm(int n) { // 輸入資料
const int a = 0; // 暫存資料(常量)
int b = 0; // 暫存資料(變數)
Node* node = new Node(0); // 暫存資料(物件)
int c = func(); // 棧幀空間(呼叫函式)
return a + b + c; // 輸出資料
}
2.4.2推算方法
與時間複雜度不同的是,我們通常只關注最差空間複雜度。這是因為記憶體空間是一項硬性要求,我們必須確保在所有輸入資料下都有足夠的記憶體空間預留。
最差空間複雜度中的“最差”有兩層含義:
- 以最差輸入資料為準:當 𝑛<10 時,空間複雜度為 𝑂(1) ;但當 𝑛>10 時,初始化的陣列
nums
佔用 𝑂(𝑛) 空間,因此最差空間複雜度為 𝑂(𝑛) 。 - 以演算法執行中的峰值記憶體為準:例如,程式在執行最後一行之前,佔用 𝑂(1) 空間;當初始化陣列
nums
時,程式佔用 𝑂(𝑛) 空間,因此最差空間複雜度為 𝑂(𝑛) 。
void algorithm(int n) {
int a = 0; // O(1)
vector<int> b(10000); // O(1)
if (n > 10)
vector<int> nums(n); // O(n)
}
在遞迴函式中,需要注意統計棧幀空間。
int func() {
// 執行某些操作
return 0;
}
/* 迴圈的空間複雜度為 O(1) */
void loop(int n) {
for (int i = 0; i < n; i++) {
func();
}
}
/* 遞迴的空間複雜度為 O(n) */
void recur(int n) {
if (n == 1) return;
return recur(n - 1);
}
函式 loop()
和 recur()
的時間複雜度都為 𝑂(𝑛) ,但空間複雜度不同。
- 函式
loop()
在迴圈中呼叫了 𝑛 次function()
,每輪中的function()
都返回並釋放了棧幀空間,因此空間複雜度仍為 𝑂(1) 。 - 遞迴函式
recur()
在執行過程中會同時存在 𝑛 個未返回的recur()
,從而佔用 𝑂(𝑛) 的棧幀空間。
2.4.3常見型別
$$
O(1)<O(logn)<O(n)<O(n2)<O(2n)
$$
1.常數階
在迴圈中初始化變數或者呼叫函式佔用的記憶體,進入下一個迴圈後就會被釋放,不會累積佔用空間,複雜度為O(1):
/* 函式 */
int func() {
// 執行某些操作
return 0;
}
/* 常數階 */
void constant(int n) {
// 常量、變數、物件佔用 O(1) 空間
const int a = 0;
int b = 0;
vector<int> nums(10000);
ListNode node(0);
// 迴圈中的變數佔用 O(1) 空間
for (int i = 0; i < n; i++) {
int c = 0;
}
// 迴圈中的函式佔用 O(1) 空間
for (int i = 0; i < n; i++) {
func();
}
}
2.線性階
線性階常見於元素數量與n成正比的陣列,連結串列,棧,佇列等:
/* 線性階 */
void linear(int n) {
// 長度為 n 的陣列佔用 O(n) 空間
vector<int> nums(n);
// 長度為 n 的列表佔用 O(n) 空間
vector<ListNode> nodes;
for (int i = 0; i < n; i++) {
nodes.push_back(ListNode(i));
}
// 長度為 n 的雜湊表佔用 O(n) 空間
unordered_map<int, string> map;
for (int i = 0; i < n; i++) {
map[i] = to_string(i);
}
}
3.平方階
常見與矩陣和圖,元素數量和n成平方關係:
/* 平方階 */
void quadratic(int n) {
// 二維列表佔用 O(n^2) 空間
vector<vector<int>> numMatrix;
for (int i = 0; i < n; i++) {
vector<int> tmp;
for (int j = 0; j < n; j++) {
tmp.push_back(0);
}
numMatrix.push_back(tmp);
}
}
函式的遞迴深度為 𝑛 ,在每個遞迴函式中都初始化了一個陣列,長度分別為 𝑛、𝑛−1、…、2、1 ,平均長度為 𝑛/2 ,因此總體佔用 𝑂(𝑛2) 空間:
/* 平方階(遞迴實現) */
int quadraticRecur(int n) {
if (n <= 0)
return 0;
vector<int> nums(n);
cout << "遞迴 n = " << n << " 中的 nums 長度 = " << nums.size() << endl;
return quadraticRecur(n - 1);
}
4.指數階
指數階常見於二叉樹。觀察圖 2-19 ,層數為 𝑛 的“滿二叉樹”的節點數量為 2𝑛−1 ,佔用 𝑂(2𝑛) 空間:
/* 指數階(建立滿二叉樹) */
TreeNode *buildTree(int n) {
if (n == 0)
return nullptr;
TreeNode *root = new TreeNode(0);
root->left = buildTree(n - 1);
root->right = buildTree(n - 1);
return root;
}
5.對數階
對數階常見於分治演算法。例如歸併排序,輸入長度為 𝑛 的陣列,每輪遞迴將陣列從中點處劃分為兩半,形成高度為 log𝑛 的遞迴樹,使用 𝑂(log𝑛) 棧幀空間。
再例如將數字轉化為字串,輸入一個正整數 𝑛 ,它的位數為 ⌊log10𝑛⌋+1 ,即對應字串長度為 ⌊log10𝑛⌋+1 ,因此空間複雜度為 𝑂(log10𝑛+1)=𝑂(log𝑛) 。
2.4.4權衡時間與空間
降低時間複雜度通常需要以提升空間複雜度為代價,反之亦然。我們將犧牲記憶體空間來提升演算法執行速度的思路稱為“以空間換時間”;反之,選擇哪種思路取決於我們更看重哪個方面。在大多數情況下,時間比空間更寶貴,因此“以空間換時間”通常是更常用的策略。當然,在資料量很大的情況下,控制空間複雜度也非常重要。