National Central University
Uedu Main Site
Explore Uedu
Student Console
Register as Member/Login
Research Informed Consent Center
Survey Center
Educational Big Data Challenge
Claim or create a course
Try an AI Scenario Task
Support & Messages
Uptime Data

UeduGPTs

--

Jupyters

3

Local AI

--

Uedu Code

--

中央大學 AQI 80 24°C PM2.5 21
AI Reply Desktop Notifications

Show a desktop notification when the AI TA finishes replying

Chat Message Notifications

Notify me when classmates post messages in the forum

Sound notification

Play an alert sound whenever there is a new notification

Unit 02

陣列與記憶體

連續配置、位址計算與二維陣列的列優先排列
Interaction實作 25 minute(s)

Learning Objectives

  • 說明陣列的元素在記憶體裡連續存放,並用「起點+索引×元素大小」算出任何元素的位址
  • 說明為什麼用索引讀取是 O(1),以及元素大小如何影響相鄰元素的位址差
  • 算出二維陣列在列優先與行優先兩種排列下,m[r][c] 是第幾格、位址是多少
  • 說明 Java 的二維陣列與 Python 的串列在記憶體裡和 C/C++ 有什麼不同
  • 在有容量上限的陣列裡插入與刪除,數出要搬移幾個元素

陣列為什麼能直接跳到第 i 格

上一課反覆用到一件事:陣列「可以直接跳到第 i 格」,所以二分搜尋每次都能馬上看中間那一個。這一課要看清楚它為什麼做得到。

先想像電腦的記憶體:一長條很多很多格的空間,每一格是一個位元組(byte,8 個位元),每一格都有一個編號,叫做位址(address)。 一個 int 通常占 4 個位元組,也就是連續的 4 格;一個 double 占 8 格。

陣列(array)的規則只有兩條:

  1. 所有元素的型別相同,所以每個元素占的位元組數一樣
  2. 元素一個緊接一個存放,中間沒有空隙(連續配置)

有了這兩條,第 i 個元素的位址可以直接算出來:

a[i] 的位址 = 起點 + i × 元素大小

不管 i 是 3 還是 300 萬,都只是一次乘法和一次加法,所以用索引讀取或修改是 O(1),這叫做隨機存取(random access)。 「索引從 0 開始」也是從這個公式來的:a[0] 就在起點,偏移是 0。

元素大小決定相鄰兩格差幾個位元組

int 陣列相鄰兩格差 4 個位元組,double 陣列差 8 個位元組。切換到 C++ 看看:C++ 的版本不是用公式算,而是真的取兩個元素的位址相減(&a[i] 是 a[i] 的位址),量出來的數字和公式完全一樣;右邊的記憶體圖也畫出了 a 和 d 的真實位址。Java 不讓程式看到記憶體位址,Python 的串列存的是物件的參考(下面會說明),所以這兩種寫法用公式示範。

元素大小與 sizeof

元素大小由型別決定。常見的大小如下:

型別 C/C++(常見的 64 位元系統) Java(語言規格固定)
char 1 2(Java 的 char 是 UTF-16)
short 2 2
int 4 4
long long/long 8 8
double 8 8
指標/參考 8 依 JVM 而定

C/C++ 用 sizeof(int) 查大小,sizeof(a) 是整個陣列的位元組數,所以常見 sizeof(a) / sizeof(a[0]) 這種算元素個數的寫法。 C/C++ 標準只規定了最小範圍,實際大小依平台而定;Java 的基本型別大小則由語言規格固定。

Python 的串列比較特別:串列底下的陣列存的不是數字本身,而是指向物件的參考。每個參考大小相同,所以「起點+索引×大小」的公式照樣成立,用索引讀取仍是 O(1); 但參考指向的物件散落在記憶體各處。需要真正緊密排列的數字陣列時,用標準函式庫的 array 模組或第三方套件 NumPy。

動手算位址

上半部可以自由調整:一維或二維、元素大小、起點位址;點記憶體條上的任何一格,下面會寫出位址的算法。下方有 5 個任務,輪到哪一題,畫面就自動換成那一題的設定。二維的部分可以先看完下一段說明再做。

二維陣列:表格要攤平成一排

記憶體只有一條,二維的表格要先攤平成一排才能存。有兩種攤法:

  • 列優先(row-major):先放第 0 列的全部元素,再放第 1 列……同一列的元素連在一起
  • 行優先(column-major):先放第 0 行的全部元素,再放第 1 行……同一行的元素連在一起

一個 rows 列、cols 行的表格,m[r][c] 排在第幾格:

排列 m[r][c] 是第幾格 位址
列優先 r × cols + c(前面有 r 整列,每列 cols 個) 起點 + (r × cols + c) × 元素大小
行優先 c × rows + r(前面有 c 整行,每行 rows 個) 起點 + (c × rows + r) × 元素大小

C 與 C++ 的二維陣列、NumPy 的預設都是列優先;Fortran、MATLAB、R 用行優先。 下面的程式把同一個 2 × 3 的表格用兩種方式攤平,看每個元素被放到哪一格。

同一個表格,兩種攤平方式

外層迴圈一列一列走,內層一格一格走。列優先的指標每次往右一格,一列走完剛好接到下一列;行優先的指標每次跳 rows = 2 格,第 0 列填的是偶數格,第 1 列填的是奇數格。最後一行印出 m[1][2] 在兩種排列裡各是第幾格。

Java 與 Python 的「二維陣列」

不是每種語言的二維陣列都攤平成一整排:

語言 m[r][c] 在記憶體裡
C/C++ int m[3][4] 12 個 int 連續存放,列優先
Java int[][] m = new int[3][4] 陣列的陣列:外層是存 3 個參考的陣列,每個參考指向一個獨立的 int[4]。同一列連續,但列和列之間不一定相鄰;各列的長度甚至可以不同(不規則陣列,jagged array)
Python [[0] * 4 for _ in range(3)] 串列的串列:外層串列存 3 個參考,每個指向一個內層串列,內層再存元素的參考

所以 Java 讀 m[r][c] 要先讀外層的 m[r] 拿到那一列,再在那一列裡用 c 算位址;C/C++ 則直接用 r × cols + c 一次算出來。 切換到 Java 版的上一個範例,可以看到 m 是一個指向兩個列陣列的物件。

Python 還有一個常見的陷阱:[[0] * 4] * 3 會讓外層的 3 個參考都指向同一個內層串列,改 m[0][0] 時三列一起變。建立二維串列要用 [[0] * 4 for _ in range(3)]。

走訪的順序會影響速度

處理器讀記憶體時,不是一次只拿一個位元組,而是把附近一整塊(常見是 64 個位元組,叫做一條快取線,cache line)一起搬進快取(cache)。 接下來讀到同一塊裡的資料就很快;讀到另一塊,就得再去比較慢的主記憶體拿。

列優先存放的表格:

  • 一列一列走訪,每次讀的位置和上一次相鄰,同一塊快取線裡的資料都用上了
  • 一行一行走訪,每次跳過一整列(cols 個元素),表格很大時,幾乎每讀一個元素都要換一塊

兩種走訪做的運算次數完全一樣,都是 O(rows × cols),但實際速度可能差好幾倍。下面的程式記下兩種走訪依序讀到的位置。

一列一列走與一行一行走

前半段一列一列走,▲ 每次往右一格,從頭走到尾;後半段一行一行走,▲ 每次往右跳 4 格,走到底再折回前面。輸出的兩行就是兩種走訪讀到的位置順序。

想一想:位址與排列

1.

一個 int 陣列(每個元素 4 個位元組)從位址 2000 開始,a[7] 的位址是多少?

2.

int m[3][5] 從位址 1000 開始,每個 int 4 個位元組。m[2][1] 在列優先與行優先時的位址分別是?

3.

用索引讀取陣列的第 i 個元素是 O(1),最主要的原因是?

第 1 次作答

在陣列裡插入與刪除:大小與容量

陣列一建立,格子數就固定了,這叫做容量(capacity)。實際放了幾個元素另外用一個整數記錄,叫做大小(size)。 不變條件是:a[0] 到 a[size - 1] 是有效的元素,size 不超過容量;size 以後的格子放什麼都不算。

在索引 k 插入 x:

  1. 檢查:size 已經等於容量(滿了),或 k 不在 0~size 之間,就不能插入
  2. 從最後一個元素開始,把 a[size - 1]、a[size - 2]……a[k] 各往右挪一格(從前面開始挪會把還沒挪的值蓋掉)
  3. 把 x 放進 a[k],size 加 1

要挪 size - k 個元素。刪除 a[k] 則反過來,從 k 開始把右邊的元素往左補,要挪 size - 1 - k 個。 所以在最前面插入或刪除是 O(n),在最後面是 O(1)。

演算法軌第 03 課數過「在最前面插入」的搬移次數;這裡把它寫成有大小、容量檢查的函式,在中間插入,也示範不合法的位置。

有容量上限的插入與刪除

容量 7 的陣列放了 5 個元素。在索引 2 插入 15 時,33、26、19 依序往右挪;刪除索引 0 時,右邊 5 個元素往左補,最後一格的舊值還在,但已經落在 size 之外(虛線)。最後在索引 9 插入,9 大於 size,不合法,什麼都不改。

想一想:搬移與走訪

1.

這段程式會印出什麼?

a = [2, 4, 6, 8, 10, 0, 0]
size = 5
moves = 0
for j in range(size, 1, -1):
    a[j] = a[j - 1]
    moves += 1
a[1] = 3
print(moves, a[3], a[5])
2.

C++ 有一個很大的 int m[2000][2000]。要把所有元素加總,下列哪一種寫法通常比較快?

3.

Java 的 int[][] m = new int[3][4]; 在記憶體裡是什麼樣子?

第 1 次作答

實作:一次刪除所有指定的值

要從陣列刪除所有等於 x 的元素,如果每找到一個就呼叫一次「刪除 a[k]」,每次都要把右邊整段往左挪,最壞是 O(n²)。更好的做法是用兩個索引:r 由左往右讀每個元素,w 指向下一個要寫入的位置;a[r] 不等於 x 就寫到 a[w]、w 加 1。掃完一遍,w 就是新的大小,只要 O(n)。請完成 remove_all,回傳新的大小。

你的輸出
預期輸出

重點整理

  • 記憶體以位元組為單位編號;陣列的元素型別相同、連續存放
  • a[i] 的位址=起點+i×元素大小,一次乘法、一次加法,所以用索引讀取是 O(1)
  • 元素大小由型別決定:C/C++ 用 sizeof 查;Python 串列存的是參考,參考大小相同,公式照樣成立
  • 二維陣列要攤平:列優先第 r×cols+c 格(C/C++、NumPy 預設),行優先第 c×rows+r 格(Fortran、MATLAB、R)
  • Java 的二維陣列是陣列的陣列,Python 是串列的串列,列與列之間不一定相鄰
  • 依存放順序走訪(列優先就一列一列走)能善用快取
  • 陣列有容量與大小;在索引 k 插入要挪 size - k 個、刪除要挪 size - 1 - k 個,最壞 O(n)

下一課:動態陣列。容量滿了怎麼辦?Python 的串列、C++ 的 vector、Java 的 ArrayList 都能一直加元素,下一課親手做一個,並算出「平均每次 append 要幾步」。

這一課完成了!
Next lesson:動態陣列與攤銷分析
你目前沒有登入,學習進度只存在這個瀏覽器。 Login 後,進度會跟著帳號,換電腦也看得到。