This commit is contained in:
krahets
2026-07-19 03:42:10 +08:00
parent cb5235bc3f
commit 2b8612bc41
238 changed files with 7477 additions and 3 deletions
@@ -0,0 +1,117 @@
---
comments: true
hide: [edit]
---
<!-- 此檔案由 utils/exercises/publish_exercises.py 根據 exercises.yaml 自動生成,請勿直接修改。 -->
# 2.6 &nbsp; 練習
## 2.6.1 &nbsp; 知識鞏固
### 1. &nbsp; 迭代與遞迴的時間和空間
下面兩段程式碼都計算 $1 + 2 + \dots + n$(設 $n \ge 1$)。請把 `n` 設為 4
按照程式實際執行的順序回答問題,然後比較兩種寫法的效率。
```python
def sum_iter(n):
s = 0
for i in range(1, n + 1):
s += i
return s
def sum_recur(n):
if n == 1:
return 1
return n + sum_recur(n - 1)
```
<!-- numbered-subquestions -->
1. 執行 `sum_iter(4)` 時,每輪迴圈結束後,變數 `s` 的值分別是多少?
2. 執行 `sum_recur(4)` 時,會依次呼叫哪些函式?從最深的一層開始返回時,結果怎樣得到?
3. 兩種寫法的時間複雜度和空間複雜度分別是多少?結合第 1、2 問的執行過程說明理由。
??? success "參考答案"
1. 迴圈變數 `i` 依次為 `1、2、3、4`,每輪結束後,`s` 依次變為
`1、3、6、10`,所以 `sum_iter(4)` 返回 10。
2. 函式依次呼叫
`sum_recur(4) → sum_recur(3) → sum_recur(2) → sum_recur(1)`
`sum_recur(1)` 返回 1,隨後各層依次得到 `2 + 1 = 3``3 + 3 = 6``4 + 6 = 10`
在最深處,4 次函式呼叫都尚未結束。
3. 兩段程式碼都進行與 $n$ 成正比的迴圈或呼叫,因此時間複雜度均為 $O(n)$ 。
空間複雜度不同:迭代版只使用常數個變數,為 $O(1)$ ;
遞迴版在到達終止條件前,前面的函式呼叫都要等待返回結果,因此呼叫堆疊中最多同時儲存 $n$ 次呼叫,
空間複雜度為 $O(n)$。
分析空間複雜度時,除程式碼中的變數外,還要考慮遞迴呼叫佔用的空間。
### 2. &nbsp; 三段程式碼的時間複雜度
以下三個程式碼片段的輸入均為正整數 $n$ 。請按時間複雜度從低到高排序,並寫出各自的複雜度。
```python
# 片段一
s = 0
for i in range(n):
s += i
# 片段二
s = 0
for i in range(n):
for j in range(i, n):
s += j
# 片段三
while n > 1:
n = n // 2
```
??? success "參考答案"
從低到高為:片段三 $O(\log n)$、片段一 $O(n)$、片段二 $O(n^2)$。
片段三每輪把 $n$ 縮小為原來的一半,約迴圈 $\log_2 n$ 次。
片段一的迴圈恰好執行 $n$ 次。片段二的內層迴圈次數依次為
$n,n-1,\dots,1$,總次數為 $n(n+1)/2$,因此屬於平方階。
### 3. &nbsp; 哪種反轉更節省空間
要將陣列 `nums` 中的元素全部反轉,有兩種做法:
<!-- numbered-subquestions -->
1. 新建一個等長陣列 `res`,倒序複製後返回;
2. 用兩個索引 `i``j` 分別從首、尾向中間移動,逐對交換 `nums[i]``nums[j]`
兩種做法的空間複雜度各是多少?哪種屬於“原地”操作?
??? success "參考答案"
1. 需要與輸入等長的輔助陣列,空間複雜度 $O(n)$。
2. 只使用兩個索引變數,
空間複雜度 $O(1)$ ,屬於原地操作。
需要注意:原地反轉會修改輸入陣列,
僅在允許修改輸入時才應優先選用;若需保留原陣列,第 1 種做法的複製開銷不可避免。
## 2.6.2 &nbsp; 程式設計練習
### 1. &nbsp; 費波那契數
費波那契數列滿足:$F(0)=0$、$F(1)=1$,並且當 $n\ge2$ 時,
$F(n)=F(n-1)+F(n-2)$。
給定非負整數 `n`,請使用迴圈計算並返回 $F(n)$,不使用遞迴。
??? tip "解題提示"
1. 先單獨處理 n 為 0 和 1 的情況
2. 計算下一項時只需要前兩項,無須儲存整個數列
3. 更新兩個變數時,注意不要過早覆蓋仍會用到的舊值
[LeetCode](https://leetcode.cn/problems/fibonacci-number/){ .rounded-button .exercise-button target="_blank" rel="noopener noreferrer" } [題目解析](https://leetcode.cn/problems/fibonacci-number/solutions/2361746/509-fei-bo-na-qi-shu-dong-tai-gui-hua-qi-so8h/){ .rounded-button .exercise-button target="_blank" rel="noopener noreferrer" }
@@ -0,0 +1,23 @@
---
comments: true
icon: material/timer-sand
---
# 第 2 章 &nbsp; 複雜度分析
![複雜度分析](../assets/covers/chapter_complexity_analysis.jpg){ class="cover-image" }
!!! abstract
複雜度分析猶如浩瀚的演算法宇宙中的時空嚮導。
它帶領我們在時間與空間這兩個維度上深入探索,尋找更優雅的解決方案。
## 本章內容
- [2.1 &nbsp; 演算法效率評估](performance_evaluation.md)
- [2.2 &nbsp; 迭代與遞迴](iteration_and_recursion.md)
- [2.3 &nbsp; 時間複雜度](time_complexity.md)
- [2.4 &nbsp; 空間複雜度](space_complexity.md)
- [2.5 &nbsp; 小結](summary.md)
- [2.6 &nbsp; 練習](exercises.md)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,53 @@
---
comments: true
---
# 2.1 &nbsp; 演算法效率評估
在演算法設計中,我們先後追求以下兩個層面的目標。
1. **找到問題解法**:演算法需要在規定的輸入範圍內可靠地求得問題的正確解。
2. **尋求最優解法**:同一個問題可能存在多種解法,我們希望找到儘可能高效的演算法。
也就是說,在能夠解決問題的前提下,演算法效率已成為衡量演算法優劣的主要評價指標,它包括以下兩個維度。
- **時間效率**:演算法執行時間的長短。
- **空間效率**:演算法佔用記憶體空間的大小。
簡而言之,**我們的目標是設計“既快又省”的資料結構與演算法**。而有效地評估演算法效率至關重要,因為只有這樣,我們才能將各種演算法進行對比,進而指導演算法設計與最佳化過程。
效率評估方法主要分為兩種:實際測試、理論估算。
## 2.1.1 &nbsp; 實際測試
假設我們現在有演算法 `A` 和演算法 `B` ,它們都能解決同一問題,現在需要對比這兩個演算法的效率。最直接的方法是找一臺計算機,執行這兩個演算法,並監控記錄它們的執行時間和記憶體佔用情況。這種評估方式能夠反映真實情況,但也存在較大的侷限性。
一方面,**難以排除測試環境的干擾因素**。硬體配置會影響演算法的效能表現。比如一個演算法的並行度較高,那麼它就更適合在多核 CPU 上執行,一個演算法的記憶體操作密集,那麼它在高效能記憶體上的表現就會更好。也就是說,演算法在不同的機器上的測試結果可能是不一致的。這意味著我們需要在各種機器上進行測試,統計平均效率,而這是不現實的。
另一方面,**展開完整測試非常耗費資源**。隨著輸入資料量的變化,演算法會表現出不同的效率。例如,在輸入資料量較小時,演算法 `A` 的執行時間比演算法 `B` 短;而在輸入資料量較大時,測試結果可能恰恰相反。因此,為了得到有說服力的結論,我們需要測試各種規模的輸入資料,而這需要耗費大量的計算資源。
## 2.1.2 &nbsp; 理論估算
由於實際測試具有較大的侷限性,我們可以考慮僅透過一些計算來評估演算法的效率。這種估算方法被稱為<u>漸近複雜度分析(asymptotic complexity analysis</u>,簡稱<u>複雜度分析</u>。
複雜度分析能夠體現演算法執行所需的時間和空間資源與輸入資料規模之間的關係。**它描述了隨著輸入資料規模的增加,演算法執行所需時間和空間的增長趨勢**。這個定義有些拗口,我們可以將其分為三個重點來理解。
- “時間和空間資源”分別對應<u>時間複雜度(time complexity</u>和<u>空間複雜度(space complexity</u>。
- “隨著輸入資料規模的增加”意味著複雜度反映了演算法執行效率與輸入資料規模之間的關係。
- “時間和空間的增長趨勢”表示複雜度分析關注的不是執行時間或佔用空間的具體值,而是時間或空間增長的“快慢”。
**複雜度分析克服了實際測試方法的弊端**,體現在以下幾個方面。
- 它無需實際執行程式碼,更加綠色節能。
- 它獨立於測試環境,分析結果適用於所有執行平臺。
- 它可以體現不同資料量下的演算法效率,尤其是在大資料量下的演算法效能。
!!! tip
如果你仍對複雜度的概念感到困惑,無須擔心,我們會在後續章節中詳細介紹。
複雜度分析為我們提供了一把評估演算法效率的“標尺”,使我們可以衡量執行某個演算法所需的時間和空間資源,對比不同演算法之間的效率。
複雜度是個數學概念,對於初學者可能比較抽象,學習難度相對較高。從這個角度看,複雜度分析可能不太適合作為最先介紹的內容。然而,當我們討論某個資料結構或演算法的特點時,難以避免要分析其執行速度和空間使用情況。
綜上所述,建議你在深入學習資料結構與演算法之前,**先對複雜度分析建立初步的瞭解,以便能夠完成簡單演算法的複雜度分析**。
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,59 @@
---
comments: true
---
# 2.5 &nbsp; 小結
### 1. &nbsp; 重點回顧
**演算法效率評估**
- 時間效率和空間效率是衡量演算法優劣的兩個主要評價指標。
- 我們可以透過實際測試來評估演算法效率,但難以消除測試環境的影響,且會耗費大量計算資源。
- 複雜度分析可以消除實際測試的弊端,分析結果適用於所有執行平臺,並且能夠揭示演算法在不同資料規模下的效率。
**時間複雜度**
- 時間複雜度用於衡量演算法執行時間隨資料量增長的趨勢,可以有效評估演算法效率,但在某些情況下可能失效,如在輸入的資料量較小或時間複雜度相同時,無法精確對比演算法效率的優劣。
- 最差時間複雜度使用大 $O$ 符號表示,對應函式漸近上界,反映當 $n$ 趨向正無窮時,操作數量 $T(n)$ 的增長級別。
- 推算時間複雜度分為兩步,首先統計操作數量,然後判斷漸近上界。
- 常見時間複雜度從低到高排列有 $O(1)$、$O(\log n)$、$O(n)$、$O(n \log n)$、$O(n^2)$、$O(2^n)$ 和 $O(n!)$ 等。
- 某些演算法的時間複雜度非固定,而是與輸入資料的分佈有關。時間複雜度分為最差、最佳、平均時間複雜度,最佳時間複雜度幾乎不用,因為輸入資料一般需要滿足嚴格條件才能達到最佳情況。
- 平均時間複雜度反映演算法在隨機資料輸入下的執行效率,最接近實際應用中的演算法效能。計算平均時間複雜度需要統計輸入資料分佈以及綜合後的數學期望。
**空間複雜度**
- 空間複雜度的作用類似於時間複雜度,用於衡量演算法佔用記憶體空間隨資料量增長的趨勢。
- 演算法執行過程中的相關記憶體空間可分為輸入空間、暫存空間、輸出空間。通常情況下,輸入空間不納入空間複雜度計算。暫存空間可分為暫存資料、堆疊幀空間和指令空間,其中堆疊幀空間通常僅在遞迴函式中影響空間複雜度。
- 我們通常只關注最差空間複雜度,即統計演算法在最差輸入資料和最差執行時刻下的空間複雜度。
- 常見空間複雜度從低到高排列有 $O(1)$、$O(\log n)$、$O(n)$、$O(n^2)$ 和 $O(2^n)$ 等。
### 2. &nbsp; Q & A
**Q**:尾遞迴的空間複雜度是 $O(1)$ 嗎?
理論上,尾遞迴函式的空間複雜度可以最佳化至 $O(1)$ 。不過絕大多數程式語言(例如 Java、Python、C++、Go、C# 等)不支持自動最佳化尾遞迴,因此通常認為空間複雜度是 $O(n)$ 。
**Q**:函式和方法這兩個術語的區別是什麼?
<u>函式(function</u>可以被獨立執行,所有參數都以顯式傳遞。<u>方法(method</u>與一個物件關聯,被隱式傳遞給呼叫它的物件,能夠對類別的例項中包含的資料進行操作。
下面以幾種常見的程式語言為例來說明。
- C 語言是程序式程式設計語言,沒有物件導向的概念,所以只有函式。但我們可以透過建立結構體(struct)來模擬物件導向程式設計,與結構體相關聯的函式就相當於其他程式語言中的方法。
- Java 和 C# 是物件導向的程式語言,程式碼塊(方法)通常作為某個類別的一部分。靜態方法的行為類似於函式,因為它被繫結在類別上,不能訪問特定的例項變數。
- C++ 和 Python 既支持程序式程式設計(函式),也支持物件導向程式設計(方法)。
**Q**:圖解“常見的空間複雜度型別”反映的是否是佔用空間的絕對大小?
不是,該圖展示的是空間複雜度,其反映的是增長趨勢,而不是佔用空間的絕對大小。
假設取 $n = 8$ ,你可能會發現每條曲線的值與函式對應不上。這是因為每條曲線都包含一個常數項,用於將取值範圍壓縮到一個視覺舒適的範圍內。
在實際中,因為我們通常不知道每個方法的“常數項”複雜度是多少,所以一般無法僅憑複雜度來選擇 $n = 8$ 之下的最優解法。但對於 $n = 8^5$ 就很好選了,這時增長趨勢已經佔主導了。
**Q** 是否存在根據實際使用場景,選擇犧牲時間(或空間)來設計演算法的情況?
在實際應用中,大部分情況會選擇犧牲空間換時間。例如資料庫索引,我們通常選擇建立 B+ 樹或雜湊索引,佔用大量記憶體空間,以換取 $O(\log n)$ 甚至 $O(1)$ 的高效查詢。
在空間資源寶貴的場景,也會選擇犧牲時間換空間。例如在嵌入式開發中,裝置記憶體很寶貴,工程師可能會放棄使用雜湊表,選擇使用陣列順序查詢,以節省記憶體佔用,代價是查詢變慢。
File diff suppressed because it is too large Load Diff