--- tags: 進階電腦系統理論與實作 --- # 2020q3 Homework4 (quiz4) contributed by < `RinHizakura` > > [第 4 週測驗題](https://hackmd.io/@sysprog/2020-quiz4) > [GitHub](https://github.com/RinHizakura/NCKU-System-Software-Quiz/tree/master/quiz4) ## 測驗題 `1` 此題並不難理解,也就是讓兩個數字相同位元若相同則對應到 1,相異則對應到 0 | input a | input b | output | | ------- |:------- |:------ | | 0 | 0 | 1 | | 0 | 1 | 1 | | 1 | 0 | 1 | | 1 | 1 | 0 | 從真值表可以看出其實就是 XOR 運算,因此答案是 `OP = (c) ^` ### [477. Total Hamming Distance](https://leetcode.com/problems/total-hamming-distance/) 根據題意,要計算出陣列中的整數兩兩的 hamming distance,因此最粗暴的解法肯定是 ```cpp int totalHammingDistance(int *nums, int numsSize) { int hamming = 0; for (int i = 0; i < numsSize; i++) for (int j = i + 1; j < numsSize; j++) hamming += hammingDistance(nums[i], nums[j]); return hamming; } ``` 然而這個 O(n) 的解法如果不意外是會 TLE 的(~~反正我是不想試~~),因此我們必須思考更佳的加法。 #### solution `1` 我的最初想法是,以下面的數字為例: | num | bit2 | bit1 | bit0 | |:--- | ---- | ---- | ---- | | 1 | 0 | 0 | 1 | | 3 | 0 | 1 | 1 | | 4 | 1 | 0 | 0 | 對於 4 來說,他與其他數字的總 hamming distance 就是**從 bit2 到 bit0,該 bit 與自己相異的總數量**,所以: * 4 的 bit2 為 1,而 1、3 的 bit2 為 0 都和自己相異,因此這裡的總距離是 2 * 4 的 bit1 為 0,而 3 的 bit1 為 1 和自己相異,因此這裡的總距離是 1 * 4 的 bit0 為 0,而 1、3 的 bit0 為 1 都和自己相異,因此這裡的總距離是 2 所以 4 和 1、3 的 hamming distance 總和就是 2+1+2 = 5。 由此推廣,這題就可以理解為:對於目前數字 $a_n$ 的每個 bit,第 k 個 bit 前面共有多少相異的,則所有 bit 相異的數量總和,那就是 $a_n$ 和 $a_1$~$a_{n-1}$ 的總 hamming distance。那我們只要加總每次碰到的數字與前面所有數字的 hamming distance 總和,就可以得到這題的答案。 看程式碼應該會更好理解: ```cpp int totalHammingDistance(int *nums, int numsSize) { int bit[32] = {0,}; int hamming = 0; if (numsSize < 2) return 0; for (int i = 0; i < numsSize; i++) { for (int j = 0; j < 32; j++) { int b = nums[i] & 1; int tmp = (b == 0) ? bit[j] : i - bit[j]; bit[j] += b; nums[i] >>= 1; hamming += tmp; } } return hamming; } ``` * `bit` 用來紀錄每個 bit 至今為止出現過幾次 1 * 則對於目前掃描到的數字 n * 假如他的第 k 個 bit 為 0,則前面的第 k 個 bit 出現幾次 1,就是該 bit 與前面數字的總 hamming distance * 反之,如果第 k 個 bit 為 1,則前面的第 k 個 bit 出現幾次 0,也就是 i 減去出現 1 的次數,就是該 bit 與前面數字的總 hamming distance 在 leetcode 上測試後得到如下結果,可以看到還有許多改進的空間。 ![](https://i.imgur.com/IwjXjK8.png) #### solution `2` 前面的解法中,因為外層的數字是陣列數量,然後內側是 bit 數量,所以我們會需要額外陣列空間來保存,然而如果我們把迴圈的順序倒過來呢? ```c= int totalHammingDistance(int *nums, int numsSize) { unsigned int hamming = 0; unsigned int bit = 0; if (numsSize < 2) return 0; for (int i = 0; i < 32; i++) { bit = 0; for (int j = 0; j < numsSize; j++) { unsigned int b = nums[j] & 1; nums[j] >>= 1; hamming += (b == 0) ? bit : j - bit; bit += b; } } return hamming; } ``` :::info 我曾經想過把上面的第 14 行調整為沒有分支的 `hamming += ((unsigned)(b - 1) & bit ) | ((unsigned)-b & (j - bit));`,不過成效不彰,而且還會導致使用的 memory 還會因此增加。 ::: 則結果如下,因為不需要額外建立 array,且頻繁的去存取 array 中的內容,可以有更好的表現。 ![](https://i.imgur.com/znNP0Rh.png) #### solution `3` 我們可以換個思路來解決此題。 思考一下,如果假如所有的數字都是 1 bit,舉例來說 [0 1 0 0 1] 共 5 個數字,他們的 hamming bit 是多少? 我們知道,每次 hamming distance 加一,那就是找出一個 0 配上一個 1 的組合,因此上面問題的答案就是 $C_1^3 \times C_1^2$。則推廣到此題,程式碼為: ```cpp int totalHammingDistance(int *nums, int numsSize) { unsigned int hamming = 0; unsigned int bit = 0; if (numsSize < 2) return 0; for (int i = 0; i < 32; i++) { bit = 0; for (int j = 0; j < numsSize; j++) { if(nums[j] & 1) bit++; nums[j] >>= 1; } hamming += bit * (numsSize - bit); } return hamming; } ``` 別且可以得到結果如下: ![](https://i.imgur.com/IZvlcfe.png) ### Error–Correcting Codes 為求版面的清晰,包含 Hamming codes / Reed–Solomon error correction 的理解,以及其在 linux 中如何實作等相關記錄都已整理到 [Error-Correcting Codes(ECC)](https://hackmd.io/@RinHizakura/r1IbsLjUP) ! ### 抽離 [linux/lib/reed_solomon/](https://github.com/torvalds/linux/tree/master/lib/reed_solomon) 我們把 linux kernel 中的 Reed–Solomon 抽離。其中,進行了一些調整,使得操作的彈性比較小,但是在函式的呼叫則相對容易: * 只能修正 error,不提供 erasure 的修正 * 不當的 generator $\alpha$ 會使得 RS 不能滿足某些在 GF 中運算的假設,而導致演算法的錯誤 * 此範例中固定為 2,這是一個可行的選擇 * 同樣的,不當的 primitive polynomial 也會使得 RS 無法運作。 * 範例中,假設我們使用的 symbol 都是 8 位元表示的 0 ~ 255 * 因此設定固定的 primitive polynomial = $x^8 + x^4 + x^3 + x^2 +1$ (0x11d) 使用的方法以下列程式為例: ```c= int main() { static struct rs_control *rs_decoder; rs_decoder = init_rs(10); uint16_t par[10]; uint8_t data8[] = {0x40, 0xd2, 0x75, 0x47, 0x76, 0x17, 0x32, 0x06, 0x27, 0x26, 0x96, 0xc6, 0xc6, 0x96, 0x70, 0xec}; /* Initialize the parity buffer */ memset(par, 0, sizeof(par)); encode_rs8(rs_decoder, data8, 16, par); for (int i = 0; i < 16; i++) printf("%x ", data8[i]); for (int i = 0; i < 10; i++) printf("%x ", par[i]); printf("\n"); data8[0] = 0x50; data8[1] = 0x50; data8[2] = 0x50; int numerr = decode_rs8(rs_decoder, data8, 16, par); printf("numerr: %d\n", numerr); for (int i = 0; i < 16; i++) printf("%x ", data8[i]); for (int i = 0; i < 10; i++) printf("%x ", par[i]); printf("\n"); } ``` 以這個程式為例,會輸出 ``` 40 d2 75 47 76 17 32 6 27 26 96 c6 c6 96 70 ec bc 2a 90 13 6b af ef fd 4b e0 numerr: 3 40 d2 75 47 76 17 32 6 27 26 96 c6 c6 96 70 ec bc 2a 90 13 6b af ef fd 4b e0 ``` * `init_rs(10)`: 表示增加十個額外 parity,因此可以修正至多 5 個錯誤 * `encode_rs8`、`decode_rs8`: 編碼及解碼,input 為 `rs_control`、資料、資料長度、以及 parity 更多詳情請參考 [RinHizakura/Reed-Solomon-codes](https://github.com/RinHizakura/Reed-Solomon-codes),並對照 [linux kernel 中的 Reed–Solomon codes](https://hackmd.io/@RinHizakura/r1IbsLjUP#linux-kernel-%E4%B8%AD%E7%9A%84-Reed%E2%80%93Solomon-codes) 對照理解(雖然可能說明得很不清楚 qq) ## 測驗題 `2` ### 程式原理 以下逐步觀察解釋程式運行原理: #### `treeAncestorCreate` * 在閱讀程式前,先注意到程式中的關鍵 `max_level` 的作用: * 對於 n 個節點,最差情形下是有 n - 1 代的祖先,可以想到如果把每個節點從第 0 個到第 n - 1 個祖先都紀錄下來,就可以在 `treeAncestorGetKthAncestor` 時有 O(1) 的時間效率 * 然而這表示需要建立 `sizeof(int) * (n * n - 1)` 的空間,在 n 很大時是很龐大的成本 * 反之,如果不預先建立查詢的表,每一次 `treeAncestorGetKthAncestor` 就變成要在時間上承受成本 * 因此,這裡透過一個技巧去對時間和空間做出平衡,就是僅記錄每個點的第 1、2、4、...$2^n$ 個祖先是誰 因此回顧程式碼 * 首先 malloc 物件 TreeAncestor 的空間,以及每個節點與其一系列祖先的陣列空間,因此 `AAA = (b) int **parent`。也就是說 parent[i][j],第一個 index i 用來 access 節點 i,第二個 index j 用來 access 第 $2^j$ 個祖先 * `max_level` 先初始為 `32 - __builtin_clz(n) + 1`,其實就是 $(\lfloor log_2{n} \rfloor + 1) + 1$ * 原題中的 14 到 20 行是在初始化空間並填入每個節點的第一個祖先 * 22 的 for 迴圈則是在建立第 $2^j$ 個祖先(j>0),對於每個節點的第 j 個祖先,首先確認是否有第 j-1 個祖先,也就是 `obj->parent[i][j] = obj->parent[i][j + BBB] == -1 ? /**/ : /**/` 因此這裡的 `BBB = (b) (-1)` * 如果有的話,舉例來說 a 的第 $2^j$ 個祖先就是第 $2^{j-1}$ 個祖先的第 $2^{j-1}$ 個祖先,也就是 `obj->parent[i][j] = obj->parent[obj->parent[i][j - 1]][j - 1]` #### `treeAncestorGetKthAncestor` 則搜尋時,以搜尋第 5 個祖先為例,可以拆解成是搜尋第 4 個祖先的第 1 個祖先,也就是根據 5 = 0b101 去查詢即可,因此 `CCC = (f) 1 << i` #### 運行結果 運行測驗題中的實作後,其結果為: ![](https://i.imgur.com/NiEIARO.png) ### 改進程式碼 #### 前情提要 我認為 leetcode 的執行時間計算在需要精確的比較下,其參考價值是很有限的。我發現同一段程式碼的不同次 submission,其執行時間可能會有很大的差異,且此差異間的人數比例很高。以下是我同對同一段程式碼的兩次提交結果: ![](https://i.imgur.com/ESebc3b.png) ![](https://i.imgur.com/1rnwfl9.png) 原本我想,如果我可以成為 100% 應該就沒有這個問題吧?不過事實上,當我把第 1 名(顯示 240ms)的程式碼複製貼上重跑一次時,得到的結果則如下: ![](https://i.imgur.com/kPjNJhE.png) ~~就很頭痛。~~ 總之就儘可能的思考進步的空間吧 XD :::warning 我也苦於 LeetCode 在執行時間分析的不精準,你嘗試去聯繫有無合作改進的機會吧? :notes: jserv > :ok_hand: 我可以先嘗試詢問一下他們是否有想要解決這個問題 :laughing: ::: #### version 1: 在原始程式中,首先最顯而易見的浪費是額外的 n 空間( `max_level = 32 - __builtin_clz(n) + 1` 中的加一),其目的僅僅是要因應 `for (int j = 1;; j++)` 迴圈沒有終止條件,而是依據 if 條件結束而可能導致額外的越界所配置。然而這個作法不但造成了空間上的浪費,也可能間接導致額外 n 次的迴圈執行。 為修正此問題,將 max_level 修改為 `32 - __builtin_clz(n)`,並將迴圈終止條件改成 `for (int j = 1;j < max_level; j++)` 來避免越界的存取,而最後對 obj max_level 的 assign 也要因應調整成 `obj->max_level = max_level`。 可以看到小幅度減少使用的記憶體。 ![](https://i.imgur.com/DtUmHmJ.png) #### version 2: row-based or column-based 對於查詢的陣列的建立,我們可以建立成 `n * max_level` (前面的作法)或者 `max_level * n` 的儲存空間,兩者對於陣列操作的 locality 會有差異。 以搜尋操作而言,由於接續的 access 沒有固定在某個 row 或 column 上的規律,兩個方法的效能差距應該不大, 而從建立搜尋陣列的角度來看,`n * max_level` 會是 column-based 操作,然而 `max_level * n` 則是 row-based 的操作,因此我預期這個調整是對時間有影響的,不過考慮到原題目對節點的數量限制,這個調整即使有益可能也不是極大幅度的,加上如前所述 leetcode 的執行時間計算問題,單從 leetcode 上的執行時間來看,兩個方法並沒有太大的差異。 不過在空間的使用上,可以看到 `max_level * n` 的儲存方式會使用相對較少的記憶體。由於兩個實作的方法是相同的,因此變數的使用並無區別,此記憶體的差異應是來自於維護的指標數量(因為顯然 `max_level` 會小於 `n`)。 ![](https://i.imgur.com/4Y0S04f.png) ![](https://i.imgur.com/ESW7qX2.png) 雖然 leetcode 無法印證這個修改是否有益,但為了證明我的想法是正確的,我在自己的電腦上進行實驗,透過 perf 比較原本的實作以及我修正後的區別,實驗中會建立一個有 100000 個節點的 Complete Binary Tree,並進行 500 次的 隨機的 `GetKthAncestor`,可以看到實驗的結果如下: * 建立成 `n * max_level` ``` Performance counter stats for './kans' (100 runs): 511,493 cache-misses:u ( +- 3.24% ) 0.011990 +- 0.000321 seconds time elapsed ( +- 2.68% ) ``` * 建立成 `max_level * n` ``` Performance counter stats for './kans' (100 runs): 48,067 cache-misses:u ( +- 3.23% ) 0.003547 +- 0.000173 seconds time elapsed ( +- 4.87% ) ``` 視覺化時間的差異(圖中的 y 軸單位為 ns) ![](https://i.imgur.com/aWoA62J.png) 可以明顯的看到善用 cache locality 對執行時間的差距。而從圖中也可以看到,以題目最大節點 50000 個節點而言,兩者的差距的大約是 2 ms,以我們在 leetcode 計時系統本身的總時間而言可以說是輕如鴻毛,也難怪看不出顯著的提升了。 ## 測驗題 `3` ~~[這題首先要先建一個 neural network](https://joelgrus.com/2016/05/23/fizz-buzz-in-tensorflow/)...~~ 此題的關鍵是透過 div3 和 div5 去調整要複製 "FizzBuzz%u" 的起點和長度為何,首先來看 length 的調整: * 如果可以整除 "3" 和 "5",length = 2 << 1 << 1 = 8 * 如果可以整除 "3" 或 "5",length = 2 << 1 = 4 * 否則就是 2 而題目問的則是對起點的調整,我們可以觀察 "FizzBuzz%u" 這個字串 * 如果可以整除 "3" 或 "3 和 5",則要從字串的位置 0 開始複製 * 如果只能整除 5,則要從字串 4 的地方開始複製 * 否則就從 8 的地方開始 歸納以上的法則,表示 (MSG_LEN >> KK1) >> (KK2 << KK3) 計算後的結果 start 需如下表: | div3 | div5 | start | | ---- | ---- |:----- | | 0 | 0 | 8 | | 0 | 1 | 4 | | 1 | 0 | 0 | | 1 | 1 | 0 | 我們可以依據此推理出最佳解為 (MSG_LEN >> div5) >> (div3 << 2) ## 測驗題 `4` 此題的重點在於把可以用位移取代的除法先行計算,從而減少過多複雜的除法指令。觀察 `PAGE_QUEUES`,可以知道其數值都可以表示成 $k \times 2^n$,其中 k 是介於 1 到 7 之間且不為 2 倍數的整數,n 則介於 0 到 14 之間。且我們都知道,對於 $2^n$ 的除法都可以透過位移取代,因此: * `ffs32(x)` 的作用就是找到 $2^n$ 中的 n 是多少, * `blockidx = offset >> ffs32(divident)` 先將 offset /= $2^n$ * `divident >>= ffs32(divident)` 將 `PAGE_QUEUES` /= $2^n$ * 因此 switch case 只要針對前面的 k 去計算即可,可能的 k 有 1、3、5、7 不過 1 的狀況是不需要額外處理的,因此答案為 `(b) 3 (d) 5 (f) 7` ### [51. N-Queens](https://leetcode.com/problems/n-queens/) #### solution `1` 我們可以想像題目最簡單粗暴的解法就是直接窮舉所有可能,再一一檢查是否符合條件,但以 N-Queens 為例,4 x 4 的棋盤就有 $2^{16}$ 個下法(對於每一格,下或不下皇后),顯然不是很好的作法。 對於此類需要輸出所有滿足條件的題目類型,最先想到的解法是 [Backtracking](http://web.ntnu.edu.tw/~algo/Backtracking.html),大抵的概念可以理解為: 在遞迴中,每次前進到下個遞迴前就去檢查目前的狀況是否符合條件,如果已經不符合,就不需要去嘗試剩下的格子該怎麼下了。 初步想到的解法是直接分配一個 "棋盤" 的空間,並且在遞迴中去檢查棋盤是否符合條件。值得注意的是這裡使用了一些比較投機的作法: * `sizes` 陣列儲存了 n x n 可能解數量,這使得我們在一開始就可以分配出足夠的空間 * 否則,就需要 malloc 足夠大的空間,或者呼叫 realloc * 為甚麼這裡 `sizes` 的數量假設 `n <= 18` 呢? 這是因為 `n > 18` 的解數量甚至超過 int 可容許的範圍了,考慮到我們要回傳的 `*returnSize` 是一個 int,所以可以如此假設 * `*returnSize` 是回傳的 `charr***` 的 2D array 數量,也就是解的數量,因此同上概念預先 assign 為 `sizes[n]` * `**returnColumnSizes` 則是 `charr***` 每個 2D array 的 columns 數,實際上都必須 `= n`,所以就直接 assign 了 額外補上了一些幫助理解的註解,因此程式行數比較多,完整的程式請參考 [(4323917)NCKU-System-Software-Quiz4/NQueen.c](https://github.com/RinHizakura/NCKU-System-Software-Quiz4/blob/43239e75ab377f7e49fcbfc1f4ebd3ef0cb8dc5f/NQueen.c)。 這個解法有許多可避免的 overhead,不過我認為整體的程式邏輯是比較清晰的,作為初步的理解應該還不錯XD。執行後得到的結果如下: ![](https://i.imgur.com/Gf7tsJM.png) #### solution `2` 原本上述程式設計的考量點,除了比較容易理解之外,也考慮到 n 的大小是不可預期的,這個作法會比較 general 一點。不過在前面我們也看到了基於 n 對應的解數量,n 在測資中理應不會太大。 事實上,根據 [guaneec](https://hackmd.io/@guaneec/sp2020q3-quiz4#Q4---ffs) 的筆記,實際上的測資內容是: ``` 4 1 2 3 5 6 7 8 9 ``` 這使我想到之前在老師的課程中看到的 [2019q1 第 1 週測驗題](https://hackmd.io/@sysprog/SyrZMGYr4),如果 n 最多為 9,則一個整數的 bit 數量足夠對應每個位置,我們可以將檢驗正確與否的操作轉換成對 bitmask 的 bitwise operation!(有興趣的話可以參考我之前嘗試寫的[題解](https://hackmd.io/IuZvjukcRbu-DN74aB37Iw?view#2018q3-%E7%AC%AC-1-%E9%80%B1%E6%B8%AC%E9%A9%97%E9%A1%8C)) ```cpp static int sol = 0; int sizes[] = {1, 1, 0, 0, 2, 10, 4, 40, 92, 352, 724, 2680, 14200, 73712, 365596, 2279184, 14772512, 95815104, 666090624}; void recursive_solver(int n, int row, char **queen, char ***p, int maj_con, int min_con, int col_con) { int queen_pos; int conflicts = min_con | maj_con | col_con; min_con &= 65535; for (int col = 0; col < n; col++) { queen_pos = 1 << (16 - col); if (!(queen_pos & conflicts)) { if (row == n - 1) { queen[row][col] = 'Q'; for (int i = 0; i < n; i++) memcpy(p[sol][i], queen[i], n + 1); sol++; queen[row][col] = '.'; } else { queen[row][col] = 'Q'; recursive_solver( n, row + 1, queen, p, (maj_con | queen_pos) >> 1, (min_con | queen_pos) << 1, (col_con | queen_pos)); queen[row][col] = '.'; } } } } char ***solveNQueens(int n, int *returnSize, int **returnColumnSizes) { sol = 0; *returnColumnSizes = malloc(sizes[n] * sizeof(int)); *returnSize = sizes[n]; for (int i = 0; i < sizes[n]; i++) (*returnColumnSizes)[i] = n; char ***p; p = malloc(sizes[n] * sizeof(char **)); for (int i = 0; i < sizes[n]; ++i) p[i] = malloc(n * sizeof(char *)); for (int i = 0; i < sizes[n]; ++i) for (int j = 0; j < n; ++j) { p[i][j] = malloc((n + 1) * sizeof(char)); } char **queen; queen = malloc(n * sizeof(char *)); for (int i = 0; i < n; i++) { queen[i] = malloc((n + 1) * sizeof(char)); for (int j = 0; j < n; j++) queen[i][j] = '.'; queen[i][n] = '\0'; } recursive_solver(n, 0, queen, p, 0, 0, 0); for (int i = 0; i < n; i++) free(queen[i]); free(queen); return p; } ``` 將檢查轉換成 bitwise 操作後,可以看到執行時間可以得到進步。程式部份請參考[(723c31a)NCKU-System-Software-Quiz4/NQueen.c](https://github.com/RinHizakura/NCKU-System-Software-Quiz4/blob/723c31a429367cdd74f4d2dcae86cedca63dbbf6/NQueen.c) ![](https://i.imgur.com/xuDDsW7.png) :::warning 尚未想到何種作法下可以應用到 `__builtin_ffs (int x)`,唯一想到只有對 `queen_pos`,不過事實上已經等於 `col` 所以這個反而多此一舉了... :::