--- title: Sparse Matrix Abstract Data Type sticky: false tags: - Data Structure categories: [Data Structure] comments: true date: --- # Sparse Matrix Abstract Data Type ## 1. 什麼是稀疏矩陣 (Sparse Matrix)? 一個矩陣中,大部分元素為零,只有少部分是非零元素,就稱為稀疏矩陣。 以下是一個 $6 \times 6$ 的矩陣,只有 **8/36** 的元素不為零: $$ \left[ \begin{array}{cccccc} 15 & 0 & 0 & 22 & 0 & -15 \\ 0 & 11 & 3 & 0 & 0 & 0 \\ 0 & 0 & 0 & -6 & 0 & 0 \\ 0 & 0 & 0 & 0 & 0 & 0 \\ 91 & 0 & 0 & 0 & 0 & 0 \\ 0 & 0 & 28& 0 & 0 & 0 \\ \end{array} \right] $$ ## 2. 為什麼需要 Sparse Matrix ADT? 前情提要: ### 二維陣列表示法 在數學中,一個矩陣包含 $m$ 行與 $n$ 列的元素,通常寫成 $m \times n$(讀作 "m by n")。 矩陣的總元素數量是 $mn$。(若 $m = n$,則此矩陣稱為 **方陣 (square matrix)**。) 在 computer science 中,矩陣的**標準表示法**是一個二維陣列,例如: `a[MAX_ROWS][MAX_COLS]` ### 標準表示法的問題 然而,若矩陣中存在大量 **零元素**,上述表示法就會造成 **記憶體浪費**。 舉例來說: - 在上一個 $6 \times 6$ 的矩陣中,只有 8 個元素不為零,卻要存下 36 個位置。 - 更極端的例子是 $1000 \times 1000$ 矩陣(共 $1,000,000$ 個元素),若大部分元素為 0,則浪費空間非常嚴重。 此外,**運算效率** 也會下降: - 矩陣加法或乘法會進行大量「$0 + 0$」或「$0 \times 0$」的無效運算。 因此,我們應該用其他方法來表示稀疏矩陣。 ## 3. 稀疏矩陣的表示 表示稀疏矩陣最常見的方法之一是 **三元組 (3-Tuple) 表示法**。 ### 三元組 (3-Tuple) 表示法 在這種表示法中,每個非零元素都以一個三元組 `<row, col, value>` 來記錄: - `row`:該元素的列索引 - `col`:該元素的行索引 - `value`:該元素的值 這樣,我們就不用存下所有的 $0$,只需要記錄非零值與它的位置即可。 舉例來說,剛剛的 $6 \times 6$ 矩陣可以被轉換成下列表格: | a[i] | row | col | value | |------|-----|-----|-------| | a[0] | 6 | 6 | 8 | | a[1] | 0 | 0 | 15 | | a[2] | 0 | 3 | 22 | | a[3] | 0 | 5 | -15 | | a[4] | 1 | 1 | 11 | | a[5] | 1 | 2 | 3 | | a[6] | 2 | 3 | -6 | | a[7] | 4 | 0 | 91 | | a[8] | 5 | 2 | 28 | :::warning 在實作時,通常會把第一筆資料 `a[0]` 用來存放矩陣的基本資訊: - 總列數 (rows) - 總行數 (cols) - 非零元素數量 (nonzeros) 其餘的 `a[1] ~ a[k]` 則存放真正的三元組資料。 ::: :::info 為了讓後續對矩陣的操作更有效率,我們要求: - 三元組按照 **row 升序** 排列 - 同一 row 內再按照 **col 升序** 排列 ::: ## 4. 稀疏矩陣的操作 (Operations) 稀疏矩陣 (Sparse-Matrix) ADT 主要有四種基本操作: ### Create (max-row, max-col) 建立一個稀疏矩陣,並記錄其大小與非零元素。 ### Transpose (a) 將矩陣轉置,把每個元素的 row 與 col 對調。 ### Add (a, b) 若兩個矩陣的大小相同,則可進行加法,將對應位置的元素相加。 ### Multiply (a, b) 若 `a` 的欄數等於 `b` 的列數,則可進行矩陣乘法,產生新的稀疏矩陣。 :::info 這四種操作構成了 Sparse-Matrix ADT 的核心功能,後續我們會再說明它們的實作方式。 ::: ## 5. 矩陣 Create ### Create (max-row, max-col) 的功能 建立一個稀疏矩陣,並能記錄: - 矩陣的總列數 (rows) - 矩陣的總行數 (cols) - 非零元素的數量 (nonzeros) - 各個非零元素的三元組 <row, col, value> ### 實作範例 C ```c #define MAX_TERMS 101 // 最多能存放的三元組數量 (+1 儲存矩陣資訊) typedef struct { int row; // 列索引 int col; // 行索引 int value; // 非零元素的值 } term; term a[MAX_TERMS]; // 用來存放稀疏矩陣 ``` C++ ```C++ const int MAX_TERMS = 101; // 最多能存放的三元組數量 (+1 儲存矩陣資訊) struct Term { int row; // 列索引 int col; // 行索引 int value; // 非零元素的值 }; Term a[MAX_TERMS]; // 用來存放稀疏矩陣 ``` #### 二維轉一維的概念 在一般情況下,矩陣是一個 **二維結構**,必須用 `a[i][j]` 來存取元素。 但在稀疏矩陣中,大多數元素為零,沒必要全部存下,所以我們將它「壓縮」成一個 **一維陣列**: - **a[0]**:用來存放矩陣的基本資訊 - `row` → 總列數 - `col` → 總行數 - `value` → 非零元素數量 - **a[1] ~ a[k]**:存放真正的非零元素 `<row, col, value>` 這樣就相當於把原本的 **二維矩陣**,轉換成一個 **一維的三元組陣列**: - 節省了空間 - 提升了矩陣操作的效率 ## 6. 矩陣轉置 (Transpose) ### 1. 定義 **轉置 (Transpose)**:將矩陣的 **行 (row)** 和 **列 (col)** 交換。 - 原矩陣的元素:`a[i][j]` → 轉置後變成 `b[j][i]`。 - 在三元組表示法下,就是把 `<row, col, value>` → 轉置後變成`<col, row, value>`。 ### 2. 直觀想法 演算法流程: ```text for each row i take element <i, j, value> and store it as <j, i, value> of the transpose; ``` ::: warning 如果我們單純按照 **row 的順序** 來處理轉置,會遇到一個問題: 無法立即確定 `<i, j, value>` 在轉置矩陣中的正確位置,往往要等到之前所有元素都處理完後才能決定。 例如: (0, 0, 15) → (0, 0, 15) (0, 3, 22) → (3, 0, 22) (0, 5, -15) → (5, 0, -15) (1, 1, 11) → (1, 1, 11) 前三個元素轉置後看似符合順序,但當 `(1, 1, 11)` 放進去時,才發現它應該排在 `(0, 0, 15)` 之後。 這代表在插入新元素時,必須重新掃描、調整順序,甚至將後續元素整體往下移動,導致效率非常低。 ::: ### 3. 一般轉置 (Transpose) 演算法流程: ```text for all elements in column j place element <i, j, value> in element <j, i, value> ``` 換句話說,這個演算法的核心概念是: - 先找到原矩陣 **第 0 欄 (column 0)** 的所有元素,把它們依序放到轉置矩陣的 **第 0 列 (row 0)**。 - 接著處理 **第 1 欄 (column 1)**,把元素放到轉置後的 **第 1 列 (row 1)**。 - 依此類推,直到所有欄位處理完畢。 因為原本的三元組矩陣是依照 **row(列)優先,再按照 col(行)遞增** 來存放的。 所以: - 在原矩陣裡,元素是照 **列數由小到大** 排列,同一列再按照 **行數由小到大**。 - 轉置後,會變成照 **行數由小到大** 排列,同一行再按照 **列數由小到大**。 因此不需要額外重排。 #### 實作範例 ```c void transpose(term a[], term b[]) /* b is set to the transpose of a */ { int n, i, j, currentb; n = a[0].value; /* 非零元素的總數 */ b[0].row = a[0].col; /* b 的總列數 = a 的總行數 */ b[0].col = a[0].row; /* b 的總行數 = a 的總列數 */ b[0].value = n; /* 非零元素個數一樣 */ if (n > 0) { /* 如果不是零矩陣 */ currentb = 1; /* b 的第一個非零元素位置 */ /* 外層:依照 a 的 column 來掃描 */ for (i = 0; i < a[0].col; i++) { /* 內層:檢查所有非零元素 */ for (j = 1; j <= n; j++) { if (a[j].col == i) { /* 找到屬於當前欄 i 的元素 */ /* 將它轉置後存入 b */ b[currentb].row = a[j].col; b[currentb].col = a[j].row; b[currentb].value = a[j].value; currentb++; } } } } } ``` :::info 註:變數 `currentb`用來記錄轉置矩陣 b 中下一個要存放的位置。 ::: #### 時間複雜度分析 對轉置演算法的時間分析其實很簡單,因為決定計算時間的關鍵是 **巢狀 for 迴圈**。 其他語句(例如 `if` 判斷與幾個指派語句)只需要常數時間,可以忽略不計。 - 外層 `for` 迴圈:會執行 `a[0].col` 次,其中 `a[0].col` 是原始矩陣的 **欄數 (columns)**。 - 內層 `for` 迴圈:每次迭代需要掃描 `a[0].value` 個元素,其中 `a[0].value` 是矩陣中的 **非零元素數量 (elements)**。 因此,巢狀迴圈的總時間大約是: $T \approx \text{columns} \times \text{elements}$ 所以,這個演算法的漸近時間複雜度為: $O(\text{columns} \times \text{elements})$ 這樣的時間稍微令人困擾,因為如果矩陣是用 **二維陣列 (rows × columns)** 來表示,只需要: $O(\text{rows} \times \text{columns})$ 的時間就能完成轉置。 實現方式很簡單: ```c for (i = 0; i < rows; i++) for (j = 0; j < cols; j++) b[j][i] = a[i][j]; ``` 換句話說,為了節省空間(使用三元組表示法),我們付出了額外的時間成本。 那麼,有沒有辦法 在壓縮空間的同時,讓轉置速度更快 呢? ### 4. 快速轉置 (Fast Transpose) :::success 目標:把一般轉置的時間從 $O(\text{columns} \times \text{elements})$ 降到 $O(\text{columns} + \text{elements})$ ::: 做法(核心三步): 1. **統計每欄數量**:掃描原矩陣 `a`,計算每個欄位的非零元素數量 → `row_terms[col]`。 2. **計算起始位置**:依 `row_terms` 做前綴和,得到轉置矩陣 `b` 中每一列(=原矩陣的每一欄)的**起始寫入位置** → `starting_pos[row]`。 3. **直接放到正確位置**:再掃一遍 `a`,把 `<r,c,val>` 轉成 `<c,r,val>`,寫入 `b[starting_pos[c]]`,並將 `starting_pos[c]++`。 > 直觀理解:先知道「每一列會放幾個、從哪裡開始放」,就能一次把元素丟到對的位置,完全避免搬移與重排。 --- #### 實作範例 ```c #define MAX_COL 50 /* 最大欄數 + 1,依需求調整 */ typedef struct { int row, col, value; } term; /* 將 a 的轉置放入 b(a, b 皆為三元組陣列;a[0] 與 b[0] 存基本資訊) */ void fast_transpose(term a[], term b[]) { int row_terms[MAX_COL], starting_pos[MAX_COL]; int i, j; int num_cols = a[0].col; /* 原矩陣的欄數 = 轉置後的列數 */ int num_terms = a[0].value; /* 非零元素數量 */ /* b 的基本資訊 */ b[0].row = num_cols; /* b 的列數 = a 的欄數 */ b[0].col = a[0].row; /* b 的欄數 = a 的列數 */ b[0].value = num_terms; if (num_terms > 0) { /* Step 1: 統計每欄的非零數量 */ for (i = 0; i < num_cols; ++i) row_terms[i] = 0; for (i = 1; i <= num_terms; ++i) row_terms[a[i].col]++; /* Step 2: 計算每列的起始位置(前綴和) */ starting_pos[0] = 1; /* 三元組資料從 b[1] 開始寫入 */ for (i = 1; i < num_cols; ++i) starting_pos[i] = starting_pos[i-1] + row_terms[i-1]; /* Step 3: 按正確位置寫入轉置結果 */ for (i = 1; i <= num_terms; ++i) { j = starting_pos[a[i].col]++; /* 取得該列的下一個可用位置 */ b[j].row = a[i].col; /* 轉置後的 row = 原本的 col */ b[j].col = a[i].row; /* 轉置後的 col = 原本的 row */ b[j].value = a[i].value; } } } ``` ::: info 在轉置矩陣中,要計算 **第 $i$ 列 $(i > 1)$** 的起始位置: $\text{starting_pos}[i] = \text{row_terms}[i-1] + \text{starting_pos}[i-1]$ 其中: - `row_terms[i-1]` = 轉置矩陣第 $i-1$ 列中的元素個數。 - `starting_pos[i-1]` = 第 $i-1$ 列的起始位置。 這樣我們就能保證每一列的起點與元素數量正確,進而確保元素被放到正確位置。 ::: #### 時間複雜度分析 快速轉置演算法的時間主要來自四個迴圈: 1. **第一個迴圈**:初始化 `row_terms`,次數 = `num_cols`。 2. **第二個迴圈**:計算每一欄的元素數量,次數 = `num_terms`。 3. **第三個迴圈**:計算每列的起始位置 `starting_pos`,次數 = `num_cols - 1`。 4. **第四個迴圈**:將三元組搬到轉置矩陣的正確位置,次數 = `num_terms`。 由於每個迴圈內部的操作只需要常數時間,所以總時間為: $O(\text{columns} + \text{elements})$ ### 5. 演算法比較 一般的 Transpose 演算法需要的時間是 $O(\text{columns} \times \text{elements})$ 因為外層要跑過所有欄數,內層則必須掃描所有非零元素。當非零元素數量很多時,這種方法的效率會變得很差。 Fast Transpose 的時間則能降到 $O(\text{columns} + \text{elements})$ 它透過先計算每一欄的非零元素數量,再決定轉置矩陣中每一列的起始位置,最後把元素一次放到正確位置,避免了不必要的重複掃描。這使得在稀疏矩陣中,它的效率特別突出。 至於二維陣列轉置,如果我們直接用完整的 rows × columns 陣列來存矩陣,那麼轉置僅需要 $O(\text{rows} \times \text{columns})$ 因為每個元素(不論是否為零)都能直接搬到正確位置。這種方法對於非稀疏矩陣來說很合適,但在稀疏矩陣中就浪費了大量運算。 ## 7. 矩陣乘法 (Matrix Multiplication) ### 1. 定義 假設有兩個矩陣: - $A$:大小 $m \times n$ - $B$:大小 $n \times p$ 則它們的乘積 $D = A \times B$ 為一個大小 $m \times p$ 的矩陣, 其元素定義為: $d_{ij} = \sum_{k=0}^{n-1} a_{ik} \cdot b_{kj},\quad 0 \leq i < m, \; 0 \leq j < p$ ### 2. 實作 #### 基本想法 計算方式如下: - 選取矩陣 $A$ 的一列 $i$。 - 對於每個 $j = 0, 1, \dots, \text{cols}_B - 1$,尋找矩陣 $B$ 的第 $j$ 欄的所有元素。 - 將 $A$ 的第 $i$ 列與 $B$ 的第 $j$ 欄做內積,得到 $D_{ij}$。 #### 問題 如果單純依序尋找 $B$ 的每一欄,我們必須 **掃描整個 $B$**,才能找到指定欄位的所有元素,這樣效率非常差。 --- #### 改進方法 先計算 $B$ 的轉置矩陣 $B^T$。 這樣一來: - $B$ 的「第 $j$ 欄」元素,在 $B^T$ 中就會變成「第 $j$ 列」。 - 這能保證同一欄的元素在記憶體中是 **連續存放** 的。 :::info #### 為什麼要掃完整個 $B$ 才能找到欄 $j$? 以三元組序列表示的稀疏矩陣,資料是依 **列優先、同列內再依欄遞增** 排序並存放的。也就是說,記憶體中的三元組會先按 $row$ 分成一段一段的區間,每一段內再按 $col$ 排序。當我們想要「取出欄 $j$ 的所有非零元素」時,這些元素其實散落在不同的 $row$ 區間之中,並不連續。因此,在未轉置的情況下,想收集欄 $j$ 的元素,就只能把 $B$ 的所有三元組都檢查一遍,篩出 $col = j$ 的那些元素。 #### 為什麼先轉置 $B$ 就能變快? 將 $B$ 轉成 $B^{\mathsf T}$ 後,原本的「欄 $j$」就變成 $B^{\mathsf T}$ 的「列 $j$」。而三元組是 **列優先** 儲存的,於是列 $j$ 的所有三元組在 $B^{\mathsf T}$ 中恰好形成一段連續區間。 ::: #### 程式碼範例 $mmult$ ```c void mmult(term a[], term b[], term d[]) /* multiply two sparse matrices */ { int i, j, column, totalb = b[0].value, totald = 0; int rows_a = a[0].row, cols_a = a[0].col, totala = a[0].value; int cols_b = b[0].col, row_begin = 1, row = a[1].row, sum = 0; int new_b[MAX_TERMS][3]; if (cols_a != b[0].row) { fprintf(stderr,"Incompatible matrices\n"); exit(1); } fast_transpose(b,new_b); /* set boundary condition */ a[totala+1].row = rows_a; new_b[totalb+1].row = cols_b; new_b[totalb+1].col = 0; for (i = 1; i <= totala;) { column = new_b[1].row; for (j = 1; j <= totalb+1;) { /* multiply row of a by column of b */ if (a[i].row != row) { storesum(d,&totald,row,column,&sum); i = row_begin; for (; new_b[j].row == column; j++); column = new_b[j].row; } else if (new_b[j].row != column) { storesum(d,&totald,row,column,&sum); i = row_begin; column = new_b[j].row; } else switch (COMPARE(a[i].col, new_b[j].col)) { case -1: /* go to next term in a */ i++; break; case 0: /* add terms, go to next term in a and b */ sum += (a[i++].value * new_b[j++].value); break; case 1: /* advance to next term in b */ j++; break; } } for (; a[i].row == row; i++); row_begin = i; row = a[i].row; } d[0].row = rows_a; d[0].col = cols_b; d[0].value = totald; } ``` $storesum$ ```c void storesum(term d[], int *totald, int row, int column, int *sum) { /* if *sum != 0, then it along with its row and column position is stored as the *totald+1 entry in d */ if (*sum) if (*totald < MAX_TERMS) { d[++*totald].row = row; d[*totald].col = column; d[*totald].value = *sum; *sum = 0; } else { fprintf(stderr,"Numbers of terms in product exceeds %d\n",MAX_TERMS); exit(1); } } ``` 接下來我們來解析一下這個程式 **各變數的角色** `a[]`:矩陣 $A$ 的三元組,依 $row$ 優先,再 $col$ 升序排列。 `b[]`:矩陣 $B$ 的三元組。 `new_b[]`:矩陣 $B$ 的轉置(即 $B^T$),這樣「$B$ 的欄」會變成「`new_b` 的列」,方便掃描。 `d[]`:結果矩陣 $D$ 的三元組 ( $D = A \times B$ )。 `row`:目前正在處理的 $A$ 的列。 `row_begin`:$A$ 中該列的第一個元素在陣列 `a` 的起始位置。 `column`:目前正在與 $A$ 的某列相乘的 $B$ 的欄(實作上對應 `new_b` 的某一列)。 `i`:掃描 $A$ 的當前列中的元素。 `j`:掃描 `new_b` 的當前列(即 $B$ 的某一欄)。 `sum`:累加器,用來計算當前結果元素 $D \ [row, column]$。 `totala` = `a[0].value`:$A$ 的非零元素數量。 `totalb` = `b[0].value`:$B$ 的非零元素數量。 --- **核心想法** 矩陣乘法定義: $D\ [row,column] = \sum_{k} A\ [row,k] \cdot B\ [k,column]$ 所以我們需要「合併兩個有序串列」: 一邊是 $A\ [row, *]$ 的所有 $k$ 值。 一邊是 $B\ [*, column]$ 的所有 $k$ 值 。 當兩邊的 $k$ 相等時 → 相乘後累加到 `sum`。 否則 → 移動較小的一邊。 這就是 `COMPARE(a[i].col, new_b[j].col)` 的邏輯。 ---- **函式解釋** #### `storesum` 在做什麼? **1. 檢查 sum 是否非零** `*sum` 代表「當前正在計算的某一個結果位置 (row, column) 的累加值」。 如果 `*sum == 0`,就代表這個位置沒有結果(完全是零),不用存。 **2. 把結果存進矩陣 d** `++*totald` 會把總元素數量加一,並把這個新元素存到 `d[*totald]`。 同時記錄它的 `row`、`col`、`value`。 **3. 把 sum 歸零** 因為這個位置的結果已經存起來了,`sum` 要重設成 0,方便下一次累加新的位置。 **4. 錯誤檢查** 如果結果超過 `MAX_TERMS`,表示結果矩陣裝不下,就輸出錯誤並退出。 --- #### 為什麼需要 `storesum`? 在矩陣相乘過程中,我們其實是「逐行 × 逐欄」去做: 對 $A$ 的第 $i$ 列和 $B$ 的第 $j$ 欄進行比對,會產生一個 $\text{sum} = \sum_k a_{ik} b_{kj}$ 這個 sum 算完以後,就應該存到結果矩陣 $D$ 的位置 $(i, j)$。 `storesum` 就是專門做這件事的函式。 --- #### `mmult` 在做什麼? `mmult` 是用來計算兩個稀疏矩陣相乘的函式,輸入矩陣為 `a` 和 `b`,輸出結果放到 `d`。 **1. 初始化與檢查** - 先取出矩陣 $A$ 和 $B$ 的維度(列數、欄數、非零元素數量)。 - 檢查是否符合矩陣相乘的條件:$A$ 的欄數要等於 $B$ 的列數。 - 如果不符合,直接輸出錯誤訊息並結束程式。 **2. $B$ 轉置處理** - 呼叫 `fast_transpose(b, new_b)` 把矩陣 $B$ 轉置,得到 `new_b`。 - 為什麼要轉置? 因為矩陣相乘時需要頻繁存取 $B$ 的「欄」,轉置後就能用 **列的方式連續存取**,效率更高。 **3. 設定邊界條件 (sentinels)** - 在 `a` 和 `new_b` 的最後各加上一個「$dummy \ term$ 」。 - 這能避免在迴圈中不斷檢查「是否到尾端」,讓程式更簡潔。 **4. 外層迴圈:掃描 $A$ 的每一列** - 使用 `row_begin` 和 `row` 記錄目前在 $A$ 的哪一列。 - 每次固定 $A$ 的一列,接著和 $B$ 的所有欄逐一相乘。 **5. 內層迴圈:合併列 $×$ 欄** - 設定 `column = new_b[1].row`,也就是 B 的第一欄。 - 掃描 $A$ 的一列與 $B$ 的一欄,把對應元素做比較 (`COMPARE`): - **$case -1$**:$A$ 的欄索引 < $B$ 的欄索引 → 移動到 $A$ 的下一個元素。 - **$case 0$**:索引相同 → 把兩元素相乘並累加到 `sum`。 - **$case 1$**:$B$ 的欄索引比較小 → 移動到 $B$ 的下一個元素。 :::spoiler 內層邏輯詳細說明 1. 初始化 `column` ```c column = new_b[1].row; ``` → 從 $B$ 的第一欄開始(在 `new_b` 裡就是第 $1$ 列)。 2. 掃描 `new_b` ```c for (j = 1; j <= totalb + 1;) ``` → 依序走過 `new_b` 的所有元素。 3. 檢查是否換 $A$ 的列 ```c if (a[i].row != row) { storesum(d,&totald,row,column,&sum); i = row_begin; for (; new_b[j].row == column; j++); column = new_b[j].row; } ``` → 如果已經超過目前這一列,就把 `sum` 存起來,然後回到這一列的開頭,準備處理下一個 `column`。 4. 檢查是否換 $B$ 的欄 ```c else if (new_b[j].row != column) { storesum(d,&totald,row,column,&sum); i = row_begin; column = new_b[j].row; } ``` → 當 `j` 移動到 $B$ 的下一個欄時,先把這一列$×$上一欄的結果存好,再從頭開始處理這一列和新的欄。 5. 比較 $A$ 和 $B$ 的索引 ```c else switch (COMPARE(a[i].col, new_b[j].col)) { case -1: i++; break; case 0: sum += a[i++].value * new_b[j++].value; break; case 1: j++; break; } ``` → 執行「合併操作」,對齊同樣的 $k$,才能計算內積。 ::: **6. 存結果** - 當一個 $(row, column)$ 的內積計算完成時,呼叫 `storesum` 把 `sum` 存入結果矩陣 `d`,然後把 `sum` 歸零。 **7. 更新迴圈狀態** - 用 `row_begin` 記錄下一列的起始位置,準備處理 $A$ 的下一列。 - 最後,更新 `d[0]` 的 `row`、`col`、`value`,表示乘積矩陣 $D$ 的維度與總非零元素數量。 :::info #### 什麼是 $dummy\ terms$? 在 `mmult` 的實作中,我們會在矩陣 $A$ 和 $B$ 的三元組陣列最後,各加上一個「假的元素 ($dummy \ term$)」,這些元素不屬於矩陣本身,只是用來輔助演算法判斷邊界。 #### 為什麼需要 $dummy\ terms$? 當我們在迴圈裡處理三元組時,需要知道「目前是否已經到達最後一列或最後一欄」。 如果沒有 $dummy\ terms$,就必須額外檢查「是否越界」,讓程式碼變得複雜。 加入 $dummy\ terms$ 後,演算法就可以在迴圈中自動結束。 **在程式中的使用** ```c a[totala+1].row = rows_a; // A 的 dummy term new_b[totalb+1].row = cols_b; // B 的 dummy term new_b[totalb+1].col = 0; ``` `a[totala+1].row = rows_a` → 標記 $A$ 已經處理到最後一列。 `new_b[totalb+1].row = cols_b, new_b[totalb+1].col = 0` → 標記 $B$ 的轉置矩陣已經處理到最後一欄。 ::: #### 時間複雜度分析 #### **前置步驟(轉置 $B$ 矩陣**) `fast_transpose(b, new_b)` 的時間複雜度是 $$ O(\text{cols}_b + \text{total}_b) $$ --- #### 單次外層迭代(固定 A 的某一列) 外層迴圈的名義上限是 `totala`,但實際執行過程要更仔細拆開來看: - 在處理 A 的某一列時,`i` 會依序前進,最多掃過該列的 $\text{terms_row}$ 個非零元素。 - 由於這一列必須和 B 的**所有 $\text{cols}_b$ 欄**相乘,每當切換到下一欄,就需要把 `i` 重設為 `row_begin`,重新從頭比對。 因此,對一列來說,重設的次數上界就是 $O(\text{cols}_b)$。 - 同時,`j` 在整體過程中會遍歷 B 的所有非零元素,因此最多前進 $\text{total}_b + 1$ 次。 綜合來看,掃描一列的成本來自兩部分: 1. `i` 的掃描與重設,對於 A 的這一列,會有 $\text{terms_row}$ 個非零元素需要逐一處理。 而且因為這一列要和 **所有 $\text{cols}_b$ 個欄**相乘,每次跨欄都要重新回到該列的開頭來做比對。 因此,對這一列來說,額外的開銷就是 $O(\text{cols}_b \cdot \text{terms_row})$。 。 2. `j` 的遍歷,在固定 A 的某一列時,這一列的所有元素都必須與 B 的每一欄相乘。 由於 B 的每一欄中可能包含非零項,我們必須逐一檢查,因此需要 **跑過 B 的所有非零項**。 換句話說,就是要掃過 $\text{total}_b$ 個元素。 合併起來,單次迭代的時間成本為: $$ O(\text{cols}_b \cdot \text{terms_row} + \text{total}_b) $$ --- #### 全部外層迭代(掃完 A 的所有列) 把上式對所有列加總(所有列的非零數量總和為 $\text{total}_a$,列數為 $\text{rows}_a$),得到: $$ \sum_{\text{rows}} O(\text{cols}_b \cdot \text{terms_row} + \text{total}_b) = O(\text{cols}_b \cdot \underbrace{\sum \text{terms_row}}_{=\ \text{total}_a}) \;+\; O(\underbrace{\sum 1}_{=\ \text{rows}_a} \cdot \text{total}_b) $$ 化簡後為: $$ O(\text{cols}_b \cdot \text{total}_a + \text{rows}_a \cdot \text{total}_b) $$ --- #### 合併前置成本 再把一開始轉置 $b$ 的成本 $O(\text{cols}_b + \text{total}_b)$ 合併進來,整體時間複雜度為: $$ O(\text{cols}_b + \text{total}_b) \;+\; O(\text{cols}_b \cdot \text{total}_a + \text{rows}_a \cdot \text{total}_b) = O(\text{cols}_b \cdot \text{total}_a + \text{rows}_a \cdot \text{total}_b) $$