Try   HackMD

2025q1 Homework2 (quiz1+2)

contributed by < LambertWSJ >

quiz1

題目連結

測驗 1

按照 list_insert_before 的描述,將 item 插入到串列 lbefore 前,給定的實做使用指標的指標 p,經過 for 迴圈完後,dereference p 並指派為 item,表示將節點換成 itemitem 還需要將下個節點指向 before,因為 item 可能是從別的串列來的,而不是單一節點,因此 item->next 指向的是原串列的節點,而不是串列 lbefore

*p = before;
item->next = before;

for 迴圈的目的是要從串列 l 找出 before,因此 p 要指向 l 的開頭,並透過 &(address of) 運算子,依據 C99 6.5.3.1:3

The unary & operator yields the address of its operand. If the operand has type ‘‘type’’,
the result has type ‘‘pointer to type’’

轉換成指標的指標,才會和 p 是同型別,迴圈的終止條件是對 p 做 dereference 取得節點的位址和 before 做比較,不是 before 的話就更新 p 到下個節點。離開迴圈後 p 會指向 before,在由上個段落所描述的操作,就能將 item 插入到 before 之前。

for(p = &l->head; *p != before; p = &(*p)->next)

實做 Merge sort

先實做簡單的遞迴版 Merge sort,函式宣告如下

list_item_t *list_merge_sort(list_item_t *head);

傳入 list 的 head 會較容易撰寫,不用另外處理不同型別的問題,專注在節點上即可。Merge Sort 拆分為分割和合併兩個階段,分割的策略使用快慢指標,遞迴地處理串列的左半邊和右半邊,將左半邊的串列分割為單一節點,單一節點是排序好的,可以和其他排序好的子串列或單一節點合併,當左半邊都集中合併完之後,會將同一處理過程換到右半邊操作,當左右半邊的子串列都排序完後,再合併成排序好的串列。

list_item_t *list_merge_sort(list_item_t *head) {
    if (!head || !head->next)
        return head;

    list_item_t *slow = head, *fast;
    for (fast = head->next; fast && fast->next; fast = fast->next->next)
        slow = slow->next;

    list_item_t *mid = slow->next;
    slow->next = NULL;
    list_item_t *left = list_merge_sort(head);
    list_item_t *right = list_merge_sort(mid);
    return merge_two_lists(left, right);
}

合併函式 merge_two_lists 應用指標的指標,這樣就不需要額外配置記憶體或是 dummy 節點來當 head,只需要指標 list 作為合併完的串列開頭,再用指標的指標 indir 指向 list,當 indir 解參考就能存取到 list,把要合併的節點指派給 list,串列的開頭就有了,要更新下個節點的話只要將 indir 指向到下一個節點即可。

static list_item_t *merge_two_lists(list_item_t *l1, list_item_t *l2) {
    list_item_t *list = NULL, **indir = &list;
    list_item_t **node;
    for (node = NULL; l1 && l2; *node = (*node)->next) {
        node = l1->value < l2->value ? &l1 : &l2;
        *indir = *node;
        indir = &(*indir)->next;
    }
    /* ... */
}

node 的用途是指向下個要合併的節點,比較 l1l2 的值後,紀錄下個要合併的節點,依據 C99 規格書 6.5.15 的註腳

A conditional expression does not yield an lvalue

&(l1->value < l2->value ? l1 : l2) 這樣寫沒辦法讓 address of(&) 取得地址,因為三元表達式回傳的是 value 而非帶有地址的 lvaue,雖然不能夠回傳 lvalue,但可以回傳已經是地址的 value,即 &l1&l2

有了下個要合併的節點,需要對 node 透過解參考才能存取到它,並指派給 indir 合併,合併完的節點同樣也是透過解參考來達成 *node = (*node)->next,就會將已經合併完的節點更新到下一個。

當迴圈到達終止條件 l1 && l2 離開時,表示其中一個串列還沒合併完,需要將其合併到 indir 上,才算是完成。這裡可以應用一個小技巧。依據 C99 7.18.1.4:1, 使用 uintptr_t 將串列的地址轉為無號整數來操作

The following type designates an unsigned integer type with the property that any valid pointer to void can be converted to this type, then converted back to pointer to void, and the result will compare equal to the original pointer:
uintptr_t

l1 和 l2 其中一個是合併完的串列,地址就會是 NULL, NULL 的地址是零,零和其他數值就 or 操作,都會是原來的數值,所以將兩個串列的地址做 or 得到的地址就是還沒合併完的串列,再轉形成 list_item_t * 並指派給 *indir 就完成合併了

list_item_t *merge_two_lists(list_item_t *l1, list_item_t *l2) {
    /* ... */
    *indir = (list_item_t *) ((uintptr_t) l1 | (uintptr_t) l2);
    return list;
}

測驗 2

測驗 3

quiz2

題目連結

測驗 1

測驗 2

clz2

題目給定分治法的 clz,參數 x 和 c 分別為 32 位元無號輸入數值和 leading zero 的位置,為方便說明,leading zero 簡寫為 LZ。

因為是拆成 upper 和 lower 各 16 bit 來做,等最後 2 bit 再來判斷位置,而 LZ 只會在其中一個,依據最後 2 bit 作為判斷條件,16 向右位移 3 就會剩下 2 bit。

c 和判斷 upper/lower 的長度,如下表,acc 表示從 c 等於 0 累加 16 >> c

c upper/lower acc(16 >> c)
0 16 16
1 8 24
2 4 28
3 2 30
unsigned clz2(uint32_t x, int c) {
    /* ... */
    if (c == 3)
        return upper ? magic[upper] : KKKK + magic[lower];
    return upper ? clz2(upper, c + 1) : (16 >> (c)) + clz2(lower, c + 1);
}

雖然看到 maskmagic 已經知道 c 很可能是 3,但是這樣子判斷欠缺合理性,maskmagic 再多塞幾個數值在陣列裡面的話,就被騙到了。

因為每次都要少一半長度的判斷,下次 lower 的遞迴則是 (16 >> (c)) + clz2(lower, c + 1),遞迴函式執行到終止條件時會逐步返回,過程中累加 16 >> c 到 30。

若 LZ 在 upper,則當 c == 3 會剩下兩個 bit,可以用來推測 magic,此時的 upper 是 x 的第 30 和 31 個 bit,

x[31:30] 表示 x 的第 30 到 31 個 bit,用 2 進位表示數值,由小到大整理成下表,LZ 也代表 magic 的數值

x[31:30]
or
x[1:0]
LZ
002
2
012
1
102
0
112
0

有了 magic 可以判斷 KKKK 應為 2。

從每次要從 upper 或 lower 判斷 LZ 位置來看,maskGGGG 應為 14,這樣在 c 為 3 時,判斷的長度才會是 2

static const int mask[] = {0, 8, 12, GGGG};
unsigned clz2(uint32_t x, int c) {
    /* ... */
    uint32_t lower = (x & (0xFFFF >> mask[c]));
    /* ... */
}

sqrti

ToDo:本文說明可能有誤,釐清詞彙中
帶入和代入
除和除以

實做完 clz 後,嘗試實做基於 2 進位的 digit-by-digit method 的整數平方根,研讀講義Wiki 後,可能是篇幅問題所以部份公式沒有推導,直接給出來,藉此機會來練習推導看看。

假設

N2 可以用二進位系統取得逼近解

N2=(an+an1++a1+a0)2

其中

am 可能是
2m
或是
0
,計算
2n
20
並累加起來,會得到平方根
Pm

Pm=an+an1++am

要找出

am
2m
0
,先將
Pm
改寫成另一種等價的形式

Pm=Pm+1+am

過去看到數學公式通常都是下一項等於前一項做運算得出來,這裡的

Pm 則是相反,用下一項
Pm+1
運算得出來,把
Pm
帶數字展開來就知道為什麼了,令
m=4,n=7

P4=a7+a6+a5+a4P5=a7+a6+a5

可以看出

Pm
Pm+1
少了一個
am

如果

Pm2 沒有超過
N2
,則
am=2m
,表示
am
是包含在平方和
Pm
裡面的係數,不會因為加上
am
就超過
N2
,反之如果超過的話,就不屬於
Pm
am=0

am={2m,if Pm2N20,otherwise

找出每一項

am 並累加,就可以得到整數平方根
Pm

為避免每一次計算

am 都要對
Pm
做平方,可以儲存兩數之差
Xm=N2Pm2
,並逐步更新
Xm
,為此要推導下一項
Xm+1

Xm+1=N2Pm+12

Pm+12 可以展開成
(Pmam)2
並帶入如差平方的公式

Xm+1=N2(Pmam)2=N2(Pm22Pmam+am2)=N2Pm2+2Pmamam2

其中

Xm=N2Pm2 以及
Pm=Pm+1+am
,代入展開後的式子

Xm+1=N2Pm2+2Pmamam2=Xm+2(Pm+1+am)amam2=Xm+2Pm+1am+2am2am2=Xm+2Pm+1am+am2

得出

Xm+1=Xm+2Pm+1am+am2

Ym=2Pm+1am+am2
Xm=Xm+1Ym
,探討
am
不為零時的情況,並將
Ym
拆成
cm
dm

cm=2Pm+1am=Pm+12m+1dm=(2m)2

Ym={cm+dmif am=2m0if am=0

cm
dm
的更新如下

cm1=Pm2m=(Pm+1+am)2m=Pm+12m+am2m

其中

am 可能是
0
或是
2m
,如果
am=2m
,帶入
cm1

cm1=Pm+1am+am2=cm2+dm
am=0
代入
cm1

cm1=(Pm+1+am)2m=Pm+12m=cm2

故得知更新

cm
dm
的公式如下

cm1={cm2+dmif am=2mcm2if am=0dm1=(2m1)2=dm4

公式都推導出來後,回頭看程式碼,先回答為什麼要取出 msb 而且還要是 4 的冪?

Pm
an
累加到
am
am
不確定是多少,有幾個,但至少會有一個,那就是最高項
an
,這也是為什麼
Pm
要從
an
開始累加到
am

初始值

Pn=an=2n,
Pn
要滿足
Pn2N2
, 則
Pn2=(2n)2=4n
,因此初始值必須是最接近
N
的 4 的冪。

uint64_t sqrti(uint64_t x) {
    /* ... */
    int shift = (total_bits - 1 - clz64(x)) & -2;
    m = 1ULL << shift;
    /* ... */
}

有了

an 就能按照更新公式來迭代求得逼近解
Pm
剩下的
am
。將
an
代入並計算初始
Ym=cm+dm

Ym=cm+dm=Pm+12m+1+(2m)2=(Pm2n)2n+1+(2n)2
初始值
Pm=Pn=an
代回上式

Ym=(Pm2n)2n+1+(2n)2=(2n2n)2n+1+(2n)2cm=0,dm=an2
因為
cm=0
dm
可在迴圈中算出來,所以一開始 y 才設為 0,y 也可以一開始就設為 m 但是迴圈要另外處理。

uint64_t sqrti(uint64_t x) {
    uint64_t m, y = 0; /* y = c_m */
    int shift = (total_bits - 1 - clz64(x)) & -2;
    m = 1ULL << shift; /* m = d_m = a_n^2 */
    /* ... */
}

初始的

Ym 算好之後,就可以準備更新
cm
dm
了,更新公式如下

cm1={cm2+dmif am=2mcm2if am=0dm1=(2m1)2=dm4
無論
am
是否為零,
cm
都要除 2,
dm
每次都要除 4,若
am
存在,則
cm1
要再加上
dm
,而
dm
每一輪都要除 4

am 是否存在,要藉由
Xm+1Ym
判斷,如果存在的話,
Xm
也要更新,公式如下
Xm=Xm+1Ym

這裡看上去不直覺,為什麼又是用下一項 (

Xm+1) 來更新?

嘗試推導

Xm+1 來解釋
Xm+1=N2Pm+12=N2(Pmam)2

因為是從最高位來計算,令
m=n
,會得出
Xm+1=N2

Xm+1=N2(anan)2=N2

m=n 時,
Xm+1
剛剛好是
N2
,此外
m
所代表的意義是位數,因此當
Xm+1Ym
時,更新
Xm
的時候是用前一次高位的
Xm+1
減去
Ym

uint64_t sqrti(uint64_t x) {
    /* ... */
    while (m) {
        uint64_t b = y + m; /* Y_m = c_m + d_m */
        y >>= 1;
        if (x >= b) { /* check a_m exist     */
            x -= b;   /* X_m = X_{m-1} - Y_m */
            y += m;   /* c_{m-1} += d_m      */
        }
        m >>= 2;
    }
    return y;
}

測驗 3