2024q1 Homework4 (quiz3+4) === contributed by < `wu81177` > # [第 3 週測驗題](https://hackmd.io/@sysprog/linux2024-quiz3) ## 測驗一 版本一當中,可以理解為使用了 binary search 來找平方根,程式先找到 `msb` 作為初始值,在 `msb` 和兩倍 `msb` 之間不斷二分來勘根,而因為整數型會截斷小數部分,因此 `a` 就會是小於等於 `n` 二的冪次最大值,之後若 `result` 加 `a` 後平方超過 `n` 就把 `a` 除以二查找,而由於 `a` 最小單位是 1 ,對於非平方數只能找到平方根的整數部分。 版本二和一的差別在於 `msb` 起始值的計算方式,版本一是接使用 `math.h` 的 `log2()` ,而版本二是將 n 值不斷除以二直到小於一 ( bitwise 的操作會使 n 最終變 0),過程中用 `msb` 記數,一樣也能算出 $lg N$ 整數部分。 而版本三的部分,根據 [Digit-by-digit calculation](https://en.wikipedia.org/wiki/Methods_of_computing_square_roots#Decimal_(base_10)) ,所求 $N$ 可以寫成 $N^2 = (a_n+a_{n-1}+a_{n-2}+...+a_0)^2$ ,其中 $a_n$ 為二的 $n$ 次冪或0,可整理成: $$ \begin{split} \\ N^2 =& a_n^2+[2a_n+a_{n-1}]a_{n-1}+[2(a_n+a_{n-1})+a_{n-2}]a_{n-2}+...+[2(\displaystyle\sum_{i=1}^{n}a_i)+a_0]a_0 \\ =& a_n^2+[2P_n+a_{n-1}]a_{n-1}+[2P_{n-1}+a_{n-2}]a_{n-2}+...+[2P_1+a_0]a_0 \\ P_m =& a_n+a_{n-1}+...+a_m \\ \end{split} $$ 可以發現 $P_0$ 肯定是所求,同時也存在某些 $m$ 滿足 $P_m = P_0$ ,因為某些 $a_m$ 是 0 ,令: - $X_m = N^2 - P_m^2 = X_{m+1} - Y_m$ - $Y_m = P_m^2 - P_{m+1}^2 = 2P_{m+1}a_m+a_m^2$ - 上一輪底數為 $m+1$ 都是已知,同時將 $Y_m$ 拆成 $c_m, d_m$ $$ c_m = P_{m+1}2^{m+1} ,\\ d_m = (2^m)^2 \\ ,Y_m=\left. \begin{cases} c_m+d_m & \text{if } a_m=2^m \\ 0 & \text{if } a_m=0 \end{cases} \right. $$ 就可以藉由位元運算來計算 $c_m, d_m$,迭代求出 $c_{-1}$ 即為所求 $N$ ,了解原理後,版本三 AAAA 應為 2 ,因為 $d_m = 4^m$, BBBB 應為 1 ,因為 $c_m = P_{m+1}2^{m+1}$ :::spoiler 版本三 ```c int i_sqrt(int x) { if (x <= 1) /* Assume x is always positive */ return x; int z = 0; for (int m = 1UL << ((31 - __builtin_clz(x)) & ~1UL); m; m >>= 2) { int b = z + m; z >>= 1; if (x >= b) x -= b, z += m; } return z; } ``` ::: ## 測驗二 為了使用 bitwise 取代 `/` 和 `%` ,由於 0.1 寫成二進位會是無窮小數,需要找到一個二進位值近似 0.1 ,目標是在 0~19 範圍內的 `temp` 乘上它到小數點後第一位以前都正確,而測驗說明中證明了只需要檢查範圍內 `temp` 最大值是否達標就好,因為近似值乘上 `temp` 誤差會隨著 `temp` 等比變化,若是最大值在誤差範圍內,絕對值較小值想必也會在範圍內 ::: spoiler 證明 假設目標的最大數是 `n` , `l` 則是比 `n` 還要小的非負整數。假設 $n = ab$ ($a$ 是十位數 b 是個位數),且 $l = cd$ ($c$ 是十位數,$d$ 是個位數),則只要以 $n$ 算出來的除數在 $n$ 除以該除數後在精確度內,則 $l$ 除與該除數仍然會在精確度內,證明如下: 假設 $x$ 是近似值, $$ a.b\leq\frac{n}{x}\leq a.b9\\ \Rightarrow\frac{n}{a.b9}\leq x\leq\frac{n}{a.b} $$ 1. 可見 $x$ 的右邊是 $10$,因此一定在精確度內。 2. $x$ 的左邊: $$ c.d\leq l\times\frac{a.b9}{n}\leq c.d9\\ \Rightarrow c.d\leq cd\times\frac{a.b9}{ab}\leq c.d9\\ $$ * $cd\times\frac{a.b9}{ab}$ 的左邊顯然成立 * $cd\times\frac{a.b9}{ab}$ 的右邊: $$ c.d + \frac{0.09cd}{ab}\leq c.d + 0.09 $$ 因為 $ab > cd$ 因此上述式子依然成立。 ::: 總之在這個例子中只需要檢查 `temp` 為 19 的情況就好。 0.1 近似值必須滿足一些條件,以有理數 $a/b$ 表示,其中 $b$ 為二的冪,就能用 bitwise 來運算,運算方式如下: 將 $a$ 轉為二進位 $a_n...a_2 a_1 a_0$ ,$b$ 寫為 $2^N$,可以將 $tmp*(a/b)$ 寫為以下通式: $$ \frac{a_ntmp}{2^{N-n}}+...+\frac{a_2tmp}{2^{N-2}}+\frac{a_1tmp}{2^{N-1}}+\frac{a_0tmp}{2^{N}} $$ 分母 2 的指數就是要右移的位數,以 $13/8$ 為例,$N = 3$,$a = (1101)_2$ ,可以寫為, d 是為了保留右移被捨棄的位元 : ``` d0 = q & 0b1; d1 = q & 0b11; d2 = q & 0b111; ((((tmp >> 3) + (tmp >> 1) + tmp) << 3) + d0 + d1 + d2) ``` 從上述通式能看出若要找 0.1 近似值, $a$ 取 $b/10$ 四捨五入至整數位誤差最小,但為了滿足小數點後第一位以前正確, $a$ 應該取 $b/10$ 無條件進位值,這樣誤差才會是正的,而 $N$ 越大誤差也就越小,如圖,橫軸為 N 縱軸為 0.1近似值*19的誤差: ![image](https://hackmd.io/_uploads/S1tbdvPyA.png) 可以看到當 $N>=7$ 就能滿足要求,然而 a 實際上可為小數。 接下來看題目實作的函式: ```clike#include <stdint.h> void divmod_10(uint32_t in, uint32_t *div, uint32_t *mod) { uint32_t x = (in | 1) - (in >> 2); /* div = in/10 ==> div = 0.75*in/8 */ uint32_t q = (x >> 4) + x; x = q; q = (q >> 8) + x; q = (q >> 8) + x; q = (q >> 8) + x; q = (q >> 8) + x; *div = (q >> CCCC); *mod = in - ((q & ~0x7) + (*div << DDDD)); } ``` ` uint32_t x = (in | 1) - (in >> 2)` 是將 `in` 減去 1/4 存為 `x` ,但不懂為何要 `in | 1`,而第二行 q 約為 $in(\frac{3}{4}*\frac{1}{16}+\frac{3}{4})$ 等於 $0.796875 in$ ,之後的5行讓 q 超級接近 0.8 ,這時我才發現它想讓近似值的分子為 0.8,我在前面的討論沒有意識到分子可為小數。 因為分子是 0.8 ,分母應該要為 8 ,所以依照上面的規則 CCCC 應為 3 。 `q & ~0x7` 的效果是將後面三位設成0,相當於 $0.8 in /8*8 = 8*div$ ,因此後面那項應該要是 $2*div$ 加起來才會是 $10*div$, DDDD 應為 1 ,我的疑惑是不知為何 `(q & ~0x7) + (*div << 1)` 兩項不統一為其中一種寫法就好,不知道這樣有什麼好處。 ## 測驗三 版本一的做法是逐位右移,相當於是一直除以二並且計數,直到等於0就能求出二為底的對數 版本二的作法也一樣,差別是對於很大的數檢查如果大於某數就一次右移很多位,這樣計算的次數比起版本一會少很多,所以 AAAA 應為 $2^{16} = 65536$,BBBB 應為 $2^8 = 256$ , CCCC 為 $2^4 = 16$ ```c static size_t ilog2(size_t i) { size_t result = 0; while (i >= AAAA) { result += 16; i >>= 16; } while (i >= BBBB) { result += 8; i >>= 8; } while (i >= CCCC) { result += 4; i >>= 4; } while (i >= 2) { result += 1; i >>= 1; } return result; } ``` 版本三使用了 __builtin_clz 來改寫 ,他是 GCC 中的 extention,功能是會計算括號內數左邊0的個數 根據 [GCC 手冊](https://gcc.gnu.org/onlinedocs/gcc/Other-Builtins.html) > Built-in Function: int __builtin_clz (unsigned int x) Returns the number of leading 0-bits in x, starting at the most significant bit position. If x is 0, the result is undefined. 所以括號中應該要填 `v` ,但為了避免 `v` 為 0 時 `__builtin_clz` 沒定義的問題, DDDD 應該要填 `v|1` 這樣最小值就是 1 不是 0 ## 測驗四 [Exponentially Weighted Moving Average](https://en.wikipedia.org/wiki/Moving_average#Exponential_moving_average) EWMA 是一種 moving average ,越新的樣本權重越大,隨著時間增加權重等比下降,但定義上永遠不會變0,所以和 simple moving average 不同,EWMA 是一種 IIR filter,然而得到新的點不用經過太多計算,因為可以用遞迴關係表示: $$ S_t = \begin{cases} Y_0 \quad\;\quad\;\quad\;\quad\;\quad\;\quad\;\text{, }t=0\\ \alpha Y_t + (1-\alpha) S_{t-1} \quad \text{, }t>0\\ \end{cases}\ $$ 其中 $0<\alpha \leq 1$ ,如同其他 ME ,它是個低通濾波器,可以觀察到若 $\alpha$ 值越大,過去的樣本影響越小,因此留下的高頻成分越多,若 $\alpha$ 為最大值 1 ,EWME 將不發揮作用 題目實作程式碼如下: :::spoiler ```c #include <assert.h> /* Exponentially weighted moving average (EWMA) */ struct ewma { unsigned long internal; unsigned long factor; unsigned long weight; }; /* This section contains universal functions designed for computing the EWMA. * It maintains a structure housing the EWMA parameters alongside a scaled * internal representation of the average value, aimed at minimizing rounding * errors. Initialization of the scaling factor and the exponential weight (also * known as the decay rate) is achieved through the ewma_init(). Direct access * to the structure is discouraged; instead, interaction should occur * exclusively via the designated helper functions. */ /** * ewma_init() - Initialize EWMA parameters * @avg: Average structure * @factor: Scaling factor for the internal representation of the value. The * highest achievable average is determined by the formula * ULONG_MAX / (factor * weight). To optimize performance, the scaling * factor must be a power of 2. * @weight: Exponential weight, also known as the decay rate, dictates the rate * at which older values' influence diminishes. For efficiency, the weight * must be set to a power of 2. * * Initialize the EWMA parameters for a given struct ewma @avg. */ void ewma_init(struct ewma *avg, unsigned long factor, unsigned long weight) { if (!is_power_of_2(weight) || !is_power_of_2(factor)) assert(0 && "weight and factor have to be a power of two!"); avg->weight = ilog2(weight); avg->factor = ilog2(factor); avg->internal = 0; } /** * ewma_add() - Exponentially weighted moving average (EWMA) * @avg: Average structure * @val: Current value * * Add a sample to the average. */ struct ewma *ewma_add(struct ewma *avg, unsigned long val) { avg->internal = avg->internal ? (((avg->internal << EEEE) - avg->internal) + (val << FFFF)) >> avg->weight : (val << avg->factor); return avg; } /** * ewma_read() - Get average value * @avg: Average structure * * Returns the average value held in @avg. */ unsigned long ewma_read(const struct ewma *avg) { return avg->internal >> avg->factor; } ``` ::: 一個 `ewma` 結構代表一個 EWMA 濾波器, `internal` 表示要輸出的樣本值, `factor` 是一個放大因子用來放大輸入樣本降低誤差, `weight` 則和 $\alpha$ 有關。 函式部分有 `ewma_init` ,用來初始化一個 EWMA 結構,它先檢查 `weight` 和 `factor` 是不是二的冪,然後將它們取 $lg$ ,方便後面用平移運算取代乘法,還有將 `internal` 設為 0 。 再來是 `ewma_add` 函式: ```clike= struct ewma *ewma_add(struct ewma *avg, unsigned long val) { avg->internal = avg->internal ? (((avg->internal << EEEE) - avg->internal) + (val << FFFF)) >> avg->weight : (val << avg->factor); return avg; } ``` 函式首先判斷 `internal` 是不是非 0,也就是在判斷是不是初始狀態,如果是就將 `val` 放大取 $lg$ 前的 `factor` 倍; 如果不是初始狀態則要進行前面提到的遞迴運算 $\alpha Y_t + (1-\alpha) S_{t-1}$ ,`(avg->internal << EEEE) - avg->internal` 可以對應到 $(1-\alpha) S_{t-1}$ 的部分,整段可以寫成(忽略 factor) $((Y_t*weight-Y_t)+Y_{t-1})/weight$ ,因此 EEEE 應為 avg->weight ,FFFF 應為 avg->factor。 從這邊也就能看的出來 `weight` 和 $\alpha$ 應為倒數關係。 ## 測驗五 ```clike= int ceil_ilog2(uint32_t x) { uint32_t r, shift; x--; r = (x > 0xFFFF) << 4; x >>= r; shift = (x > 0xFF) << 3; x >>= shift; r |= shift; shift = (x > 0xF) << 2; x >>= shift; r |= shift; shift = (x > 0x3) << 1; x >>= shift; return (r | shift | x > GGG) + 1; } ``` 函式一開始的 `x--` 是為了破壞 `x` 剛好是二的冪的情況,函式中會達到計算 $lg$ 並且捨棄小數的效果,最後 return 的地方會再加上一達成向上取整的功能,如果沒有 `x--` 二的冪的輸出就會多一。 再來的原理和測驗版本二類似,只是寫法更緊湊,需要好好思考; 由於 `x` 是 32 bits 的無號整數型,因此可以不使用迴圈逐步將檢查範圍覆蓋到八個 byte 就好, 依序檢查 x 是否大於 $2^{2^4}$, $2^{2^3}$, $2^{2^2}$,如果是就除掉,用 or operator 將結果依序更新到 `r` ,最後在 return 之前已經檢查了 $2^4 + 2^3 + 2^2 + 2 = 30$ 個 bits ,還剩兩個要檢查,於是我代入函式傳入時 x=1~4 推定 GGG 為 1 。 # [第 4 週測驗題](https://hackmd.io/@sysprog/linux2024-quiz4) ## 測驗一 [Popcount](https://en.wikichip.org/wiki/population_count) 用來計算二進位數值中有幾個 1 ,實作版本一如下: ```clike= unsigned popcount_naive(unsigned v) { unsigned n = 0; while (v) v &= (v - 1), n = -(~n); return n; } ``` `v &= (v - 1)` 的效果是將最低位的 1 變成 0 , `n = -(~n)` 的作用相當於 `n++` ,因為在二補數系統中負號是先取反再加一,所以 `-(~n)` = `~(~n)+1` = `n+1`, `n` 會在迴圈中計數直到 `v` 為 0 終止,但這樣程式不是常數時間,可能有資安疑慮,因此可以改進成版本二: ```clike= unsigned popcount_branchless(unsigned v) { unsigned n; n = (v >> 1) & 0x77777777; v -= n; n = (n >> 1) & 0x77777777; v -= n; n = (n >> 1) & 0x77777777; v -= n; v = (v + (v >> 4)) & 0x0F0F0F0F; v *= 0x01010101; return v >> 24; } ``` 這個方式是將每 4 個 bits 平行計算, `(v >> 1) & 0x77777777` 作用是先除以二,且為了保持每 4bits 的獨立性,將每四位第一位改成 0 。 考慮到計算 n bits popcount 公式如下: $$ nbitspopcount(x) = x - \lfloor \frac{x}{2^1} \rfloor- \lfloor \frac{x}{2^2} \rfloor-...- \lfloor \frac{x}{2^n} \rfloor $$ 所以 4 bits 就是 $x - \lfloor \frac{x}{2^1} \rfloor- \lfloor \frac{x}{2^2} \rfloor- \lfloor \frac{x}{2^3} \rfloor$,第 4~9 行用遞迴的方式達成此式。 但這樣還不是最終結果,還需要把 `v` 每 4bits 分開再加起來,第 11 行會讓第奇數個 nibble 為原本的每兩個相加,第偶數個變 0 ,因為原本每個 nibble 最大是 4 所以不會溢位,然後再乘以 0x01010101 再右移 24 bits 寫成直式就能看出會是所有 nibble 相加的效果。 #### Hamming distance 兩數的 Hamming distance 可以理解為將一個數變成另一個數需要翻轉的位元個數,實作上可以先做 bitwise XOR 再用 popcount 來數: ```clike= int hammingDistance(int x, int y) { return __builtin_popcount(x ^ y); } ``` 其中`__builtin_popcount` 是 GCC 內建函式。 考慮 [LeetCode 477. Total Hamming Distance](https://leetcode.com/problems/total-hamming-distance/) 的解法: ```clike= int totalHammingDistance(int* nums, int numsSize) { int total = 0;; for (int i = 0;i < numsSize;i++) for (int j = 0; j < numsSize;j++) total += __builtin_popcount(nums[i] ^ nums[j]); return total >> AAAA; } ``` 題目要求加總陣列每兩個數的 Hamming distance ,for loop 算的每一組會重複配對一次,所以結果要除以二, AAAA 為 2 。 ## 測驗二 若 $a = b(mod \quad m)$ , $c = d(mod \quad m)$ 則 $ac = bd(mod \quad m)$ , $a+c = b+d(mod \quad m)$ 。 若除數為 3 ,則套用上面第二個性質可以推導出 $$ 2^k = \begin{cases} 1(mod3) \quad\;\text{,k is even }\\ -1 \quad \text{, k is odd }\\ \end{cases}\ $$ 考慮正整數 $n$ 以二進位表示 : $b_{n-1}b_{n-2}...b_{0}$, $n = b_{n-1}2^{n-1}+b_{n-2}2^{n-2}+...+b_02^0$ ,套用上面性質得到 $n = \Sigma ^{n-1}_{i=0}b_i(-1)^i(mod\quad3)$ ,求和的部分相當於奇數位和減偶數位和: `popcount(n & 0x55555555) - popcount(n & 0xAAAAAAAA)` ,$mod 3$ 的部分則是重複操作前面直到 n = 0~2 ,類似的方法也可以推廣到任何除數為 $2^k\pm1$ 的時候。 考慮 `tictactoe.c` 中 mod 7 的部分: ```clike= static inline int mod7(uint32_t x) { x = (x >> CCCC) + (x & UINT32_C(0x7FFF)); /* Take reminder as (mod 8) by mul/shift. Since the multiplier * was calculated using ceil() instead of floor(), it skips the * value '7' properly. * M <- ceil(ldexp(8/7, 29)) */ return (int) ((x * UINT32_C(0x24924925)) >> 29); } ``` 不同於之前的討論,這裡是用 [Hacker's Delight](https://web.archive.org/web/20180517023231/http://www.hackersdelight.org/divcMore.pdf) 中 14 頁的方法,可以避免使用 popcount ,對於每個 $2^k\pm1$ 的除數,都可以從某些地方拆分成前後兩項再加起來而不影響求模結果,在這題由於後面是 & 0x7FFF 是 15 個 1,可以推測 CCCC 是 15。 ```clike= /* Determine if the tic-tac-toe board is in a winning state. */ static inline uint32_t is_win(uint32_t player_board) { return (player_board + BBBB) & 0x88888888; } ``` 如果想知道 BBBB 的答案就要先知道如何描述棋盤,它使用一個 32 bits 無號整數來描述棋盤狀態,經過比對 `move_masks` 可以發現,每四個 bits 描述一種連線,總共有八條剛好 32 bits,第一個 bit 為 0,第 2~4 分別代表那條線上三個位置有沒有值,所以 BBBB 是 0x11111111,因為這樣只要存在一條連線那條線的四個 bit 就會是 0111 + 0001 然後進位,接著 & 1000 看有沒有進位就完成檢查。 ## 測驗三 AVL tree 為了極致的降低搜尋速度,限制任何左右子樹高度差不能超過一,因此常常需要透過旋轉 (LL, LR, RR, RL)來平衡子樹,過程會消耗許多資源,因此 [XTree](https://gist.github.com/jserv/7374b3e031ec3a81441b1b93b008c5d2) 被設計出來,它比較不會消耗資源去平衡,但樹的分布較不平衡,平衡能力和搜尋時間介於 red-black tree 和 AVL tree 之間。 接著來看移除節點的函式: ```clike static void __xt_remove(struct xt_node **root, struct xt_node *del) { if (xt_right(del)) { struct xt_node *least = xt_first(xt_right(del)); if (del == *root) *root = least; xt_replace_right(del, AAAA); xt_update(root, BBBB); return; } if (xt_left(del)) { struct xt_node *most = xt_last(xt_left(del)); if (del == *root) *root = most; xt_replace_left(del, CCCC); xt_update(root, DDDD); return; } if (del == *root) { *root = 0; return; } /* empty node */ struct xt_node *parent = xt_parent(del); if (xt_left(parent) == del) xt_left(parent) = 0; else xt_right(parent) = 0; xt_update(EEEE, FFFF); } ``` 根據註解,如果要刪除的節點有右子節點,則待刪除的節點會先替換為右子樹中遇到的第一個節點,因此 AAAA 為 `least` ,它在前面被設為 `xt_first` ,接著會對新接上節點的右子節點做 `xt_update` 來更新 `hint` ,並且決定要不要平衡,因此 BBBB 是 `xt_right(least)` ,同理 CCCC 為 `most` , DDDD 為 `xt_left(most)`。 如果要刪除的節點是 leaf 的情況可以直接刪除,但就要檢查它的親代,確保平衡狀況,所以 EEEE 是 `root` , FFFF 是 `parent` 。