陣列是一種能儲存相同資料型別、固定大小、並使用連續記憶體空間的資料結構。每個元素透過索引 (index) 可以快速存取資料。
舉例而言,陣列可以比喻成一條街,街上的每個住戶都是一個值,其門牌號則是索引。郵局若要送信,只需知道門牌即可正確送達。
不過 Python 的陣列與傳統的陣列有些許不同:
特性 | 傳統陣列(如 C) | Python |
---|---|---|
資料型別 | 同類型(int, float) | 可混合(int, str) |
記憶體配置 | 連續 | 背後用動態 array 實作 |
大小是否固定 | 是 | 否,會自動擴充 |
插入與刪除效率 | 慢(需位移) | 同樣是 $\mathcal{O}(n)$ |
而所謂連續記憶體,當一塊記憶體是一口氣切下來、一個接一個排好的,就稱為記憶體連續配置 (contiguous memory allocation)。陣列元素的記憶體位置計算方式為:
$$
\text{元素記憶體位址} = \text{陣列記憶體位址} + \text{元素長度} \times \text{元素索引}
$$
Python 因為是高階語言,所以無法輕易查看底層的記憶體配置邏輯,但可以使用 ctypes
模組驗證陣列元素記憶體計算邏輯是否為真:
1 | import ctypes |
透過上述執行結果,確實可以發現,陣列元素在底層記憶體配置時,是以該公式進行計算。
宣告方式
1 | k = 5 # 陣列個數 |
1 | print(arr) |
1 | arr: list[int] = [1, 2, 3, 4, 5] |
1 | print(arr) |
array 模組
雖然 Python 的陣列可以混合不同型別的資料,但實際上可以使用 array
模組進行操作:
1 | from array import array |
結果如下:
1 | print(arr) |
其中 'i'
是 型別碼 (type code),也就是指定陣列的資料型別,目的是為了達到與傳統陣列相同的功能。
Type code | C Type | Python Type | Minimum size (bytes) |
---|---|---|---|
'b' |
signed char | int | 1 |
'B' |
unsigned char | int | 1 |
'u' |
wchar_t | Unicode character | 2 |
'w' |
Py_UCS4 | Unicode character | 4 |
'h' |
signed short | int | 2 |
'H' |
unsigned short | int | 2 |
'i' |
signed int | int | 2 |
'I' |
unsigned int | int | 2 |
'l' |
signed long | int | 4 |
'L' |
unsigned long | int | 4 |
'q' |
signed long long | int | 8 |
'Q' |
unsigned long long | int | 8 |
'f' |
float | float | 4 |
'd' |
double | float | 8 |
陣列的底層原理與效能
特性 | 靜態陣列 (Static Array) | 動態陣列 (Dynamic Array) |
---|---|---|
記憶體配置 | 一開始就分配固定大小 | 根據需求動態擴充記憶體 |
資料型別 | 通常需相同(如 C 語言) | 依實作而定,Python 允許混合型別 |
空間效率 | 高(不需搬移記憶體) | 可能有多次搬移與浪費空間 |
增加元素效率 | 不允許超出原始容量 | 可以持續增加(但可能重新配置) |
代表語言/結構 | C/C++ 陣列、Python array.array |
Python list 、Java ArrayList |
動態陣列與 Python list
上面提到 Python 的 list
是個可無限擴充的容器,雖然背後仍使用連續的記憶體空間,但底層邏輯其實是動態陣列:每次 append()
資料時,並不會分配新空間,除非空間不足,才會配置更大一塊記憶體(通常是倍增),將原本的資料放進去,再加入新元素。因此,在 Python 中,list.append(x)
時最壞的效率是 $\mathcal{O}(n)$,平均而言是 $\mathcal{O}(1)$。
空間擴充策略:為什麼倍增?
如果不是倍增,而是每次只多分配一格記憶體給新資料,會發生什麼事?
假設有個陣列,每次 append()
新資料都是建立新陣列,大小為舊的陣列長度 $+1$,接著複製原本所有元素過去,再加上新元素,這樣總成本是:
1 | 加第 1 個元素:複製 0 個 |
總複製次數會是:
$$
0 + 1 + 2 + \cdots + (n - 1) = \mathcal{O}(n^{2})
$$
用程式碼實際執行一次,可以建立一個 DynamicArray
類別,然後紀錄複製次數。
1 | class DynamicArray: |
接著初始化陣列,然後加 $1000$ 次:
1 | arr = DynamicArray() |
印出結果:
1 | print("最終容量:", arr.capacity) |
但如果改為倍增,每次的成本會是:
1 | 加第 1 個元素:複製 1 個 |
總複製次數會是:
$$
1 + 2 + 4 + \cdots + \dfrac{n}{2} \simeq 2n = \mathcal{O}(n)
$$
將上面的 DynamicArray
類別中容量擴充的邏輯改為 self.capacity *= 2
,並執行結果後
1 | print("最終容量:", arr.capacity) |
可以驚人的發現,複製次數竟然比原本的還少!上面的這個現象,其實可以歸結成:若每次僅增加一格,每次新增元素時就必須複製 $n - 1$ 次;改為倍增後,雖然每次都需要擴充兩倍的空間,但是只有在空間不夠的時候才需要搬移。
一維陣列 (1-D Array)
一維陣列是一種線性資料結構,可以視為一串排列成一列的值,每個元素都有對應的索引。
操作 | 範例程式碼 | 時間複雜度 |
---|---|---|
查詢 | x = arr[i] |
$\mathcal{O}(1)$ |
修改 | arr[i] = x |
$\mathcal{O}(1)$ |
插入 | arr.insert(i, x) |
$\mathcal{O}(n)$ |
刪除 | arr.pop(i) |
$\mathcal{O}(n)$ |
走訪陣列 | for x in arr: |
$\mathcal{O}(n)$ |
查詢、修改與走訪的時間複雜度很好理解,但是為何插入與刪除是 $\mathcal{O}(n)$ 呢?
插入元素
假設一個陣列 arr = [1, 2, 3, 4, 5]
,我們想要在索引 2
與 3
中間插入 8
,變成 [1, 2, 3, 8, 4, 5]
,也就是原本 arr[3]
的值變成 8
。順序是:
- 在
arr
尾巴append(0)
,用於移動資料 - 將索引值為
3
之後的所有元素都往後移動一格 - 令
arr[3] = 8
實際操作如下:
1 | arr: list[int] = [1, 2, 3, 4, 5] |
執行結果為:
1 | print(arr) |
刪除元素
刪除元素與插入元素相似,只是不需要在尾部補上空格,而是要將最後一個元素刪除。假設我們要刪除索引 2
,順序如下:
- 從目標索引開始,將後面的元素移動到前面
- 刪除最後一個元素
實際操作如下:
1 | arr: list[int] = [1, 2, 3, 4, 5] |
執行結果為:
1 | print(arr) |
練習題
給一個整數陣列 nums
,與一個整數 val
,請就地 (in-place) 移除所有等於 val 的元素,並返回移除後的陣列長度。不需要維持原本順序,也不需要回傳新的陣列。
1 | 輸入: nums = [3, 2, 2, 3], val = 3 |
解答
1 | class Solution: |
給定一個整數陣列 nums
,請將所有數值為 0 的元素「移動到陣列末端」,但保持非 0 元素的相對順序不變。
注意:
- 必須「就地 (in-place)」修改陣列
- 不可以建立新陣列或使用內建
sort
/remove
等函式
1 | 輸入: nums = [0, 1, 0, 3, 12] |
解答
1 | class Solution: |
二維陣列 (2-D Array)
二維陣列可以視為陣列的陣列,每個元素本身也是一個陣列,其結構視覺上看起來就是一個表格或矩陣,包含列 (row)與欄 (column)。例如:
1 | [ |
- 第一層是列(外層
list
) - 第二層是欄(內層
list
)
實務上多以 arr[i][j]
表示第 i
列第 j
欄的元素。
宣告方式
在 Python 中可以使用 list comprehension 建立一個 $m \times n$ 的二維陣列:
1 | rows = 3 |
1 | print(matrix) |
事實上這個二維陣列在數學上就是一個 $3 \times 4$ 矩陣:
$$
M_{3 \times 4} =
\begin{bmatrix}
0 & 0 & 0 & 0\\
0 & 0 & 0 & 0\\
0 & 0 & 0 & 0\\
\end{bmatrix}
$$
但請注意,不能寫成這樣:
1 | matrix = [[0] * cols] * rows # 錯誤:會建立重複參照的列 |
這樣做會讓所有列指向同一個物件,修改其中一列的元素時,其他列也會改變。除了使用 list comprehension,如果知道二維陣列的長相,也可以直接宣告,例如以下建立一個 $4 \times 4$ 的單位矩陣:
1 | I = [[1, 0, 0, 0], |
1 | print(I) |
數學上的寫法則是:
$$
I_{4} =
\begin{bmatrix}
1 & 0 & 0 & 0\\
0 & 1 & 0 & 0\\
0 & 0 & 1 & 0\\
0 & 0 & 0 & 1\\
\end{bmatrix}
$$
元素存取與操作
二維陣列的元素存取與修改與一維陣列相似,只是今天多了一個維度需要考慮。
操作 | 範例程式碼 | 時間複雜度 |
---|---|---|
查詢 | x = arr[i][j] |
$\mathcal{O}(1)$ |
修改 | arr[i][j] = x |
$\mathcal{O}(1)$ |
插入列 | arr.insert(i, new_row) |
$\mathcal{O}(n)$ |
插入欄 | for row in arr: row.insert(j, x) |
$\mathcal{O}(m)$ |
刪除列 | arr.pop(i) |
$\mathcal{O}(n)$ |
刪除欄 | for row in arr: row.pop(j) |
$\mathcal{O}(m)$ |
走訪全部 | for i in range(m): for j in range(n): arr[i][j] |
$\mathcal{O}(m \times n)$ |
1 | matrix[0][1] = 10 # 修改第 0 列第 1 欄的值為 10 |
如果想走訪二維陣列,就需要使用巢狀迴圈,但請注意順序:先走訪列,再走訪欄。原因就是宣告陣列時,外層表示的是列,內層則是欄。
1 | for i in range(len(matrix)): # 列 |
更簡潔的寫法是善用 enumerate
函式,同時得到索引與值:
1 | for i, val in enumerate(matrix): |
練習題
給一個 $m \times n$ 的整數矩陣 matrix
,計算所有元素的總和。
1 | 輸入: |
解答
1 | class Solution: |
給一個 $m \times n$ 的整數矩陣 matrix
,計算主對角線(從左上到右下)的總和。
1 | 輸入: |
解答
1 | class Solution: |
給一個 $m \times n$ 的整數矩陣 matrix
,就地反轉每一列的元素順序。
1 | 輸入: |
解答
1 | class Solution: |