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
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,113 @@
---
comments: true
hide: [edit]
---
<!-- 此檔案由 utils/exercises/publish_exercises.py 根據 exercises.yaml 自動生成,請勿直接修改。 -->
# 4.6 &nbsp; 練習
## 4.6.1 &nbsp; 知識鞏固
### 1. &nbsp; 陣列和鏈結串列怎樣找到元素
陣列和單向鏈結串列中都按順序儲存了 `[A, B, C, D, E]`。現在要讀取第 4 個元素 `D`
<!-- numbered-subquestions -->
1. 陣列可以直接使用哪個索引?
2. 單向鏈結串列從頭節點 `A` 開始,要沿著 `next` 依次經過哪些節點?
3. 當要讀取的元素位置越來越靠後時,兩種結構所需的步驟會怎樣變化?哪一種更適合反覆按位置讀取?為什麼?
??? success "參考答案"
1. 若索引從 0 開始,第 4 個元素的索引是 3,陣列可直接讀取 `arr[3]`
2. 單向鏈結串列必須從頭開始,訪問路徑為 `A → B → C → D`,需要沿 `next` 前進 3 次。
3. 陣列可以根據首位址和索引直接定位元素,按位置訪問的時間複雜度為 $O(1)$。
單向鏈結串列訪問第 $k$ 個節點時,必須從頭節點開始,沿著 `next` 前進 $k-1$ 次,
最壞需要 $O(n)$ 時間。
這裡只比較按位置讀取,不表示鏈結串列在所有操作上都更慢。
### 2. &nbsp; 陣列和鏈結串列怎樣插入元素
陣列和單向鏈結串列中都儲存了 `A、B、C、D`。現在要在 `B` 後面插入 `X`
- 陣列容量為 5,當前狀態是 `[A, B, C, D, _]`
- 鏈結串列為 `A → B → C → D`,並且已經拿到了指向節點 `B` 的引用。
<!-- numbered-subquestions -->
1. 陣列需要移動哪些元素?寫出插入後的陣列。
2. 鏈結串列應按什麼順序修改 `X.next``B.next`?寫出插入後的鏈結串列。
3. 為什麼比較插入效率時,要特別說明“已經拿到了節點 B 的引用”?
??? success "參考答案"
1. 陣列要先把 `D` 向右移動一格,再把 `C` 向右移動一格,最後把 `X` 放入索引 2
得到 `[A, B, X, C, D]`
2. `B.next` 原本指向 `C`。應先令 `X.next = B.next`,讓 `X` 指向 `C`
再令 `B.next = X`。結果為 `A → B → X → C → D`
如果先覆蓋 `B.next` 又沒有儲存原來的連線,就可能找不到 `C`
3. 已知 `B` 的位置後,鏈結串列插入只需修改兩個連線,可以在 $O(1)$ 時間完成。
如果還要從頭查詢 `B`,查詢過程本身可能需要 $O(n)$ 時間。
### 3. &nbsp; 串列容量是怎樣增長的
一個基於陣列實現的串列當前內容為 `[A, B, C]`,長度 `size = 3`,容量 `capacity = 4`
規定容量不足時,新陣列的容量擴大為原來的 2 倍。
<!-- numbered-subquestions -->
1. 追加 `D` 後,串列的長度和容量分別是多少?是否需要擴容?
2. 接著追加 `E` 時,容量會變為多少?需要複製幾個原有元素?
3. 底層陣列的長度不可變,為什麼串列的容量看起來卻可以增長?
??? success "參考答案"
1. `D` 可以放入最後一個空位。此時內容為 `[A, B, C, D]`
`size = 4``capacity = 4`,不需要擴容。
2. 再追加 `E` 時已經沒有空位,需要建立容量為 8 的新陣列,
`A、B、C、D` 共 4 個原有元素複製過去,再加入 `E`
此時 `size = 5``capacity = 8`
3. 原來的陣列本身沒有變長。串列建立了一個更大的新陣列,複製原有元素,
再改用新陣列作為底層儲存,因此對使用者來說容量增長了。
## 4.6.2 &nbsp; 程式設計練習
### 1. &nbsp; 陣列表示的大整數加一
陣列 `digits` 從左到右儲存一個非負整數的各位數字,例如 `[3, 0, 8]` 表示 308。
數字 0 用 `[0]` 表示;其他輸入的第一位均不為 0。
請模擬一次十進位制豎式加法,將這個整數增加 1,並把結果仍按相同的陣列形式返回。
可以直接修改 `digits`;若最前面產生新的進位,可以返回一個更長的陣列。
??? tip "解題提示"
1. 像做豎式加法一樣,從陣列最後一位開始
2. 當前位小於 9 時加一後即可立即返回
3. 當前位等於 9 時把它改成 0;若所有位都是 9,需要在最前面補 1
[LeetCode](https://leetcode.cn/problems/plus-one/){ .rounded-button .exercise-button target="_blank" rel="noopener noreferrer" }
### 2. &nbsp; 反轉單向鏈結串列
給定一個單向鏈結串列的頭節點 `head`。每個節點包含一個值和指向下一節點的 `next`
請使用迭代方法反轉所有節點之間的連線,並返回反轉後的頭節點。
要求不建立新的鏈結串列節點。
??? tip "解題提示"
1. 先在紙上畫出三個相連的節點和 prev、cur 兩個指標
2. 改寫 cur.next 前,必須先用 nxt 儲存原來的下一個節點
3. 反轉 cur.next 後,令 prev = cur,再令 cur = nxt,繼續處理原鏈結串列中的下一個節點
[LeetCode](https://leetcode.cn/problems/reverse-linked-list/){ .rounded-button .exercise-button target="_blank" rel="noopener noreferrer" } [題目解析](https://leetcode.cn/problems/reverse-linked-list/solutions/2361282/206-fan-zhuan-lian-biao-shuang-zhi-zhen-r1jel/){ .rounded-button .exercise-button target="_blank" rel="noopener noreferrer" }
@@ -0,0 +1,23 @@
---
comments: true
icon: material/view-list-outline
---
# 第 4 章 &nbsp; 陣列與鏈結串列
![陣列與鏈結串列](../assets/covers/chapter_array_and_linkedlist.jpg){ class="cover-image" }
!!! abstract
資料結構的世界如同一堵厚實的磚牆。
陣列的磚塊整齊排列,逐個緊貼。鏈結串列的磚塊分散各處,連線的藤蔓自由地穿梭於磚縫之間。
## 本章內容
- [4.1 &nbsp; 陣列](array.md)
- [4.2 &nbsp; 鏈結串列](linked_list.md)
- [4.3 &nbsp; 串列](list.md)
- [4.4 &nbsp; 記憶體與快取 *](ram_and_cache.md)
- [4.5 &nbsp; 小結](summary.md)
- [4.6 &nbsp; 練習](exercises.md)
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
@@ -0,0 +1,84 @@
---
comments: true
status: new
---
# 4.4 &nbsp; 記憶體與快取 *
在本章的前兩節中,我們探討了陣列和鏈結串列這兩種基礎且重要的資料結構,它們分別代表了“連續儲存”和“分散儲存”兩種物理結構。
實際上,**物理結構在很大程度上決定了程式對記憶體和快取的使用效率**,進而影響演算法程式的整體效能。
## 4.4.1 &nbsp; 計算機儲存裝置
計算機中包括三種類型的儲存裝置:<u>硬碟(hard disk</u>、<u>記憶體(random-access memory, RAM</u>、<u>快取(cache memory</u>。表 4-2 展示了它們在計算機系統中的不同角色和效能特點。
<p align="center"> 表 4-2 &nbsp; 計算機的儲存裝置 </p>
<div class="center-table" markdown>
| | 硬碟 | 記憶體 | 快取 |
| -------------- | ---------------------------------------- | -------------------------------------- | ------------------------------------------------- |
| 用途 | 長期儲存資料,包括作業系統、程式、檔案等 | 臨時儲存當前執行的程式和正在處理的資料 | 儲存經常訪問的資料和指令,減少 CPU 訪問記憶體的次數 |
| 易失性 | 斷電後資料不會丟失 | 斷電後資料會丟失 | 斷電後資料會丟失 |
| 容量 | 較大,TB 級別 | 較小,GB 級別 | 非常小,MB 級別 |
| 速度 | 較慢,幾百到幾千 MB/s | 較快,幾十 GB/s | 非常快,幾十到幾百 GB/s |
| 價格(人民幣) | 較便宜,幾毛到幾元 / GB | 較貴,幾十到幾百元 / GB | 非常貴,隨 CPU 打包計價 |
</div>
我們可以將計算機儲存系統想象為圖 4-9 所示的金字塔結構。越靠近金字塔頂端的儲存裝置的速度越快、容量越小、成本越高。這種多層級的設計並非偶然,而是計算機科學家和工程師們經過深思熟慮的結果。
- **硬碟難以被記憶體取代**。首先,記憶體中的資料在斷電後會丟失,因此它不適合長期儲存資料;其次,記憶體的成本是硬碟的幾十倍,這使得它難以在消費者市場普及。
- **快取的大容量和高速度難以兼得**。隨著 L1、L2、L3 快取的容量逐步增大,其物理尺寸會變大,與 CPU 核心之間的物理距離會變遠,從而導致資料傳輸時間增加,元素訪問延遲變高。在當前技術下,多層級的快取結構是容量、速度和成本之間的最佳平衡點。
![計算機儲存系統](ram_and_cache.assets/storage_pyramid.png){ class="animation-figure" }
<p align="center"> 圖 4-9 &nbsp; 計算機儲存系統 </p>
!!! tip
計算機的儲存層次結構體現了速度、容量和成本三者之間的精妙平衡。實際上,這種權衡普遍存在於所有工業領域,它要求我們在不同的優勢和限制之間找到最佳平衡點。
總的來說,**硬碟用於長期儲存大量資料,記憶體用於臨時儲存程式執行中正在處理的資料,而快取則用於儲存經常訪問的資料和指令**,以提高程式執行效率。三者共同協作,確保計算機系統高效執行。
如圖 4-10 所示,在程式執行時,資料會從硬碟中被讀取到記憶體中,供 CPU 計算使用。快取可以看作 CPU 的一部分,**它透過智慧地從記憶體載入資料**,給 CPU 提供高速的資料讀取,從而顯著提升程式的執行效率,減少對較慢的記憶體的依賴。
![硬碟、記憶體和快取之間的資料流通](ram_and_cache.assets/computer_storage_devices.png){ class="animation-figure" }
<p align="center"> 圖 4-10 &nbsp; 硬碟、記憶體和快取之間的資料流通 </p>
## 4.4.2 &nbsp; 資料結構的記憶體效率
在記憶體空間利用方面,陣列和鏈結串列各自具有優勢和侷限性。
一方面,**記憶體是有限的,且同一塊記憶體不能被多個程式共享**,因此我們希望資料結構能夠儘可能高效地利用空間。陣列的元素緊密排列,不需要額外的空間來儲存鏈結串列節點間的引用(指標),因此空間效率更高。然而,陣列需要一次性分配足夠的連續記憶體空間,這可能導致記憶體浪費,陣列擴容也需要額外的時間和空間成本。相比之下,鏈結串列以“節點”為單位進行動態記憶體分配和回收,提供了更大的靈活性。
另一方面,在程式執行時,**隨著反覆申請與釋放記憶體,空閒記憶體的碎片化程度會越來越高**,從而導致記憶體的利用效率降低。陣列由於其連續的儲存方式,相對不容易導致記憶體碎片化。相反,鏈結串列的元素是分散儲存的,在頻繁的插入與刪除操作中,更容易導致記憶體碎片化。
## 4.4.3 &nbsp; 資料結構的快取效率
快取雖然在空間容量上遠小於記憶體,但它比記憶體快得多,在程式執行速度上起著至關重要的作用。由於快取的容量有限,只能儲存一小部分頻繁訪問的資料,因此當 CPU 嘗試訪問的資料不在快取中時,就會發生<u>快取未命中(cache miss</u>,此時 CPU 不得不從速度較慢的記憶體中載入所需資料。
顯然,**“快取未命中”越少,CPU 讀寫資料的效率就越高**,程式效能也就越好。我們將 CPU 從快取中成功獲取資料的比例稱為<u>快取命中率(cache hit rate</u>,這個指標通常用來衡量快取效率。
為了儘可能達到更高的效率,快取會採取以下資料載入機制。
- **快取行**:快取不是單個位元組地儲存與載入資料,而是以快取行為單位。相比於單個位元組的傳輸,快取行的傳輸形式更加高效。
- **預取機制**:處理器會嘗試預測資料訪問模式(例如順序訪問、固定步長跳躍訪問等),並根據特定模式將資料載入至快取之中,從而提升命中率。
- **空間區域性**:如果一個數據被訪問,那麼它附近的資料可能近期也會被訪問。因此,快取在載入某一資料時,也會載入其附近的資料,以提高命中率。
- **時間區域性**:如果一個數據被訪問,那麼它在不久的將來很可能再次被訪問。快取利用這一原理,透過保留最近訪問過的資料來提高命中率。
實際上,**陣列和鏈結串列對快取的利用效率是不同的**,主要體現在以下幾個方面。
- **佔用空間**:鏈結串列元素比陣列元素佔用空間更多,導致快取中容納的有效資料量更少。
- **快取行**:鏈結串列資料分散在記憶體各處,而快取是“按行載入”的,因此載入到無效資料的比例更高。
- **預取機制**:陣列比鏈結串列的資料訪問模式更具“可預測性”,即系統更容易猜出即將被載入的資料。
- **空間區域性**:陣列被儲存在集中的記憶體空間中,因此被載入資料附近的資料更有可能即將被訪問。
總體而言,**陣列具有更高的快取命中率,因此它在操作效率上通常優於鏈結串列**。這使得在解決演算法問題時,基於陣列實現的資料結構往往更受歡迎。
需要注意的是,**高快取效率並不意味著陣列在所有情況下都優於鏈結串列**。實際應用中選擇哪種資料結構,應根據具體需求來決定。例如,陣列和鏈結串列都可以實現“堆疊”資料結構(下一章會詳細介紹),但它們適用於不同場景。
- 在做演算法題時,我們會傾向於選擇基於陣列實現的堆疊,因為它提供了更高的操作效率和隨機訪問的能力,代價僅是需要預先為陣列分配一定的記憶體空間。
- 如果資料量非常大、動態性很高、堆疊的預期大小難以估計,那麼基於鏈結串列實現的堆疊更加合適。鏈結串列能夠將大量資料分散儲存於記憶體的不同部分,並且避免了陣列擴容產生的額外開銷。
@@ -0,0 +1,90 @@
---
comments: true
---
# 4.5 &nbsp; 小結
### 1. &nbsp; 重點回顧
- 陣列和鏈結串列是兩種基本的資料結構,分別代表資料在計算機記憶體中的兩種儲存方式:連續空間儲存和分散空間儲存。兩者的特點呈現出互補的特性。
- 陣列支持隨機訪問、佔用記憶體較少;但插入和刪除元素效率低,且初始化後長度不可變。
- 鏈結串列透過更改引用(指標)實現高效的節點插入與刪除,且可以靈活調整長度;但節點訪問效率低、佔用記憶體較多。常見的鏈結串列型別包括單向鏈結串列、環形鏈結串列、雙向鏈結串列。
- 串列是一種支持增刪查改的元素有序集合,通常基於動態陣列實現。它保留了陣列的優勢,同時可以靈活調整長度。
- 串列的出現大幅提高了陣列的實用性,但可能導致部分記憶體空間浪費。
- 程式執行時,資料主要儲存在記憶體中。陣列可提供更高的記憶體空間效率,而鏈結串列則在記憶體使用上更加靈活。
- 快取透過快取行、預取機制以及空間區域性和時間區域性等資料載入機制,為 CPU 提供快速資料訪問,顯著提升程式的執行效率。
- 由於陣列具有更高的快取命中率,因此它通常比鏈結串列更高效。在選擇資料結構時,應根據具體需求和場景做出恰當選擇。
### 2. &nbsp; Q & A
**Q**:陣列儲存在堆疊上和儲存在堆積上,對時間效率和空間效率是否有影響?
儲存在堆疊上和堆積上的陣列都被儲存在連續記憶體空間內,資料操作效率基本一致。然而,堆疊和堆積具有各自的特點,從而導致以下不同點。
1. 分配和釋放效率:堆疊是一塊較小的記憶體,分配由編譯器自動完成;而堆積記憶體相對更大,可以在程式碼中動態分配,更容易碎片化。因此,堆積上的分配和釋放操作通常比堆疊上的慢。
2. 大小限制:堆疊記憶體相對較小,堆積的大小一般受限於可用記憶體。因此堆積更加適合儲存大型陣列。
3. 靈活性:堆疊上的陣列的大小需要在編譯時確定,而堆積上的陣列的大小可以在執行時動態確定。
**Q**:為什麼陣列要求相同型別的元素,而在鏈結串列中卻沒有強調相同型別呢?
鏈結串列由節點組成,節點之間透過引用(指標)連線,各個節點可以儲存不同型別的資料,例如 `int``double``string``object` 等。
相對地,陣列元素則必須是相同型別的,這樣才能透過計算偏移量來獲取對應元素位置。例如,陣列同時包含 `int``long` 兩種型別,單個元素分別佔用 4 位元組和 8 位元組 ,此時就不能用以下公式計算偏移量了,因為陣列中包含了兩種“元素長度”。
```shell
# 元素記憶體位址 = 陣列記憶體位址(首元素記憶體位址) + 元素長度 * 元素索引
```
**Q**:刪除節點 `P` 後,是否需要把 `P.next` 設為 `None` 呢?
不修改 `P.next` 也可以。從該鏈結串列的角度看,從頭節點走訪到尾節點已經不會遇到 `P` 了。這意味著節點 `P` 已經從鏈結串列中刪除了,此時節點 `P` 指向哪裡都不會對該鏈結串列產生影響。
從資料結構與演算法(做題)的角度看,不斷開沒有關係,只要保證程式的邏輯是正確的就行。從標準庫的角度看,斷開更加安全、邏輯更加清晰。如果不斷開,假設被刪除節點未被正常回收,那麼它會影響後繼節點的記憶體回收。
**Q**:在鏈結串列中插入和刪除操作的時間複雜度是 $O(1)$ 。但是增刪之前都需要 $O(n)$ 的時間查詢元素,那為什麼時間複雜度不是 $O(n)$ 呢?
如果是先查詢元素、再刪除元素,時間複雜度確實是 $O(n)$ 。然而,鏈結串列的 $O(1)$ 增刪的優勢可以在其他應用上得到體現。例如,雙向佇列適合使用鏈結串列實現,我們維護一個指標變數始終指向頭節點、尾節點,每次插入與刪除操作都是 $O(1)$ 。
**Q**:圖“鏈結串列定義與儲存方式”中,淺藍色的儲存節點指標是佔用一塊記憶體位址嗎?還是和節點值各佔一半呢?
該示意圖只是定性表示,定量表示需要根據具體情況進行分析。
- 不同型別的節點值佔用的空間是不同的,比如 `int``long``double` 和例項物件等。
- 指標變數佔用的記憶體空間大小根據所使用的作業系統及編譯環境而定,大多為 8 位元組或 4 位元組。
**Q**:在串列末尾新增元素是否時時刻刻都為 $O(1)$ ?
如果新增元素時超出串列長度,則需要先擴容串列再新增。系統會申請一塊新的記憶體,並將原串列的所有元素搬運過去,這時候時間複雜度就會是 $O(n)$ 。
**Q**:“串列的出現極大地提高了陣列的實用性,但可能導致部分記憶體空間浪費”,這裡的空間浪費是指額外增加的變數如容量、長度、擴容倍數所佔的記憶體嗎?
這裡的空間浪費主要有兩方面含義:一方面,串列都會設定一個初始長度,我們不一定需要用這麼多;另一方面,為了防止頻繁擴容,擴容一般會乘以一個係數,比如 $\times 1.5$ 。這樣一來,也會出現很多空位,我們通常不能完全填滿它們。
**Q**:在 Python 中初始化 `n = [1, 2, 3]` 後,這 3 個元素的位址是相連的,但是初始化 `m = [2, 1, 3]` 會發現它們每個元素的 id 並不是連續的,而是分別跟 `n` 中的相同。這些元素的位址不連續,那麼 `m` 還是陣列嗎?
假如把串列元素換成鏈結串列節點 `n = [n1, n2, n3, n4, n5]` ,通常情況下這 5 個節點物件也分散儲存在記憶體各處。然而,給定一個串列索引,我們仍然可以在 $O(1)$ 時間內獲取節點記憶體位址,從而訪問到對應的節點。這是因為陣列中儲存的是節點的引用,而非節點本身。
與許多語言不同,Python 中的數字也被包裝為物件,串列中儲存的不是數字本身,而是對數字的引用。因此,我們會發現兩個陣列中的相同數字擁有同一個 id ,並且這些數字的記憶體位址無須連續。
**Q**C++ STL 裡面的 `std::list` 已經實現了雙向鏈結串列,但好像一些演算法書上不怎麼直接使用它,是不是因為有什麼侷限性呢?
一方面,我們往往更青睞使用陣列實現演算法,而只在必要時才使用鏈結串列,主要有兩個原因。
- 空間開銷:由於每個元素需要兩個額外的指標(一個用於前一個元素,一個用於後一個元素),所以 `std::list` 通常比 `std::vector` 更佔用空間。
- 快取不友好:由於資料不是連續存放的,因此 `std::list` 對快取的利用率較低。一般情況下,`std::vector` 的效能會更好。
另一方面,必要使用鏈結串列的情況主要是二元樹和圖。堆疊和佇列往往會使用程式語言提供的 `stack``queue` ,而非鏈結串列。
**Q**:操作 `res = [[0]] * n` 生成了一個二維串列,其中每一個 `[0]` 都是獨立的嗎?
不是獨立的。此二維串列中,所有的 `[0]` 實際上是同一個物件的引用。如果我們修改其中一個元素,會發現所有的對應元素都會隨之改變。
如果希望二維串列中的每個 `[0]` 都是獨立的,可以使用 `res = [[0] for _ in range(n)]` 來實現。這種方式的原理是初始化了 $n$ 個獨立的 `[0]` 串列物件。
**Q**:操作 `res = [0] * n` 生成了一個串列,其中每一個整數 0 都是獨立的嗎?
在該串列中,所有整數 0 都是同一個物件的引用。這是因為 Python 對小整數(通常是 -5 到 256)採用了快取池機制,以便最大化物件複用,從而提升效能。
雖然它們指向同一個物件,但我們仍然可以獨立修改串列中的每個元素,這是因為 Python 的整數是“不可變物件”。當我們修改某個元素時,實際上是切換為另一個物件的引用,而不是改變原有物件本身。
然而,當串列元素是“可變物件”時(例如串列、字典或類別例項等),修改某個元素會直接改變該物件本身,所有引用該物件的元素都會產生相同變化。