contributed by < Potassium-chromate >
list_insert_before
的具體用途為將 item
插入到 before
前面。具體方式為持續遍歷鍊結串列直到遇到 before
,接著把 before
前一個節點的 next
指向 item
。
static inline void list_insert_before(list_t *l,
list_item_t *before,
list_item_t *item)
{
list_item_t **p;
for (p = &l->head; *p != before; p = &(*p)->next)
;
*p = item;
item->next = before;
}
本次實作為 Top-Down 的方式,其中尋找中間節點的作法為利用快慢指標 (slow and fast pointers)。
get_middle()
get_middle()
函數中,我們使用快慢指標 (slow and fast pointers) 來找到鏈結串列的中間節點:slow
指標每次移動一步。fast
指標每次移動兩步。fast
到達鏈結串列的末端時,slow
剛好位於中間位置。merge()
left
的值較小,則 left->next
指向遞迴合併後的結果,返回 left
。right
的值較小,則 right->next
指向遞迴合併後的結果,返回 right
。3static inline list_item_t *merge(list_item_t *left, list_item_t *right) {
if (!left) return right;
if (!right) return left;
if (left->value <= right->value) {
left->next = merge(left->next, right);
return left;
} else {
right->next = merge(left, right->next);
return right;
}
}
merge_sort()
head
為空或僅有一個節點時,直接返回 head
。get_middle()
找到中間節點。merge()
合併已排序的左右兩部分,並返回排序後的鏈結串列頭部。list_item_t* middle = get_middle(head);
list_item_t* next_to_middle = middle->next;
middle->next = NULL;
list_item_t* left = merge_sort(head);
list_item_t* right = merge_sort(next_to_middle);
由於目的是要找到左子樹的最右節點,因此需要持續遍歷 (*pred_ptr)->r
直到遇到 NULL
。因此 EEEE
為 (*pred_ptr)->r
, FFFF
為 (*pred_ptr)->r
。
while ((*pred_ptr)->r) // EEEE
*pred_ptr = (*pred_ptr)->r; // FFFF
其刪除節點的邏輯如下:
情況 1:節點有兩個子節點
具體過程如下:
步驟 1:尋找前驅節點
50
/ \
30 70
/ \ / \
10 40 60 80
/
35
步驟 2:移除 40,並將其左子節點 35 接回
50
/ \
30 70
/ \ / \
10 35 60 80
步驟 3:使用 40 替換 50
40
/ \
30 70
/ \ / \
10 35 60 80
情況 2:節點只有一個子節點
以下為用 graviz 視覺化刪除過程的範例:
程式碼細節可以參考 Commit 3c697d6
參考 memory-allocators,可以得知 allocators 分為以下四種:
1. Linear Allocator:
2. Stack Allocator
3. Pool Allocator
記憶體管理策略
工作原理
實做方式
以下為memory-allocators 的實做方式。
1. 初始化 - 分配一個大記憶體區域
void PoolAllocator::Init() {
m_start_ptr = malloc(m_totalSize);
this->Reset();
}
2. 建立自由列表 - 初始化可用區塊
void PoolAllocator::Reset() {
m_used = 0;
m_peak = 0;
// Create a linked-list with all free positions
const int nChunks = m_totalSize / m_chunkSize;
for (int i = 0; i < nChunks; ++i) {
std::size_t address = (std::size_t) m_start_ptr + i * m_chunkSize;
m_freeList.push((Node *) address);
}
}
4. Free list allocator
在作業提供的資料結構中,二元搜尋樹 (BST) 沒有自平衡機制,這可能導致插入與刪除節點時出現樹的高度不均問題,影響搜尋與操作效率。
因此,我本次實作紅黑樹 (Red-Black Tree, RBT),並比較:
參考 Linux 核心的紅黑樹,可以得知紅黑數的基本性質為:
紅黑數的實做細節可以參考 Commit 50e822e
下圖是隨機插入 1 到 30 的結果並使用 Graphviz 視覺化的結果:
刪除 10 個節點 (13, 16, 24, 11, 25, 27, 10, 12, 19, 21) 後,結果如下:
下方為 RBT 與 BST 於插入 3000 個節點與 刪除 1500 節點的時間:
測量方式為使用 Linux 內建效能分析工具: Perf。
紅黑數:
eason@eason-System-Product-Name:~/2025_lab_1/memory_allocators$ sudo perf stat -r 100 ./RBtree
Performance counter stats for './RBtree' (100 runs):
16.65 msec task-clock # 0.987 CPUs utilized ( +- 0.99% )
0 context-switches # 0.000 /sec
0 cpu-migrations # 0.000 /sec
91 page-faults # 5.466 K/sec ( +- 0.09% )
<not counted> cpu_atom/cycles/ ( +-100.00% ) (0.00%)
6743,6385 cpu_core/cycles/ # 4.050 GHz ( +- 0.29% )
<not counted> cpu_atom/instructions/ ( +-100.00% ) (0.00%)
9391,3135 cpu_core/instructions/ # 128.96 insn per cycle ( +- 0.01% )
<not counted> cpu_atom/branches/ ( +-100.00% ) (0.00%)
2030,7443 cpu_core/branches/ # 1.220 G/sec ( +- 0.01% )
<not counted> cpu_atom/branch-misses/ ( +-100.02% ) (0.00%)
7,8665 cpu_core/branch-misses/ # 37.70% of all branches ( +- 0.16% )
TopdownL1 (cpu_core) # 65.4 % tma_backend_bound
# 2.0 % tma_bad_speculation
# 3.2 % tma_frontend_bound
# 29.4 % tma_retiring ( +- 0.29% )
0.016874 +- 0.000169 seconds time elapsed ( +- 1.00% )
二元搜尋樹:
eason@eason-System-Product-Name:~/2025_lab_1/memory_allocators$ sudo perf stat -r 100 ./BST
Performance counter stats for './BST' (100 runs):
58.54 msec task-clock # 0.995 CPUs utilized ( +- 0.49% )
0 context-switches # 0.000 /sec
0 cpu-migrations # 0.000 /sec
106 page-faults # 1.811 K/sec ( +- 0.06% )
<not counted> cpu_atom/cycles/ ( +- 32.80% ) (0.00%)
2,5534,4994 cpu_core/cycles/ # 4.362 GHz ( +- 0.28% )
<not counted> cpu_atom/instructions/ ( +- 32.91% ) (0.00%)
5,2419,1360 cpu_core/instructions/ # 29.97 insn per cycle ( +- 0.11% )
<not counted> cpu_atom/branches/ ( +- 32.96% ) (0.00%)
1,0172,8554 cpu_core/branches/ # 1.738 G/sec ( +- 0.10% )
<not counted> cpu_atom/branch-misses/ ( +- 34.71% ) (0.00%)
79,0093 cpu_core/branch-misses/ # 14.99% of all branches ( +- 1.56% )
TopdownL1 (cpu_core) # 23.0 % tma_backend_bound
# 10.0 % tma_bad_speculation
# 29.5 % tma_frontend_bound
# 37.6 % tma_retiring ( +- 0.30% )
0.058839 +- 0.000291 seconds time elapsed ( +- 0.49% )
可以發現 RBT 快了 BST 約 4 倍。並且理論上 BST 的刪除節點的時間複雜度為
TODO: 比較在不同數量節點的情況下,BST 與 RBT 的效能表現。
判斷式 L != R
代表當左右兩邊的linked list頭尾不相等時,表示該linked list不只包含一個節點;而在 else
內,則代表該linked list只剩一個節點,此時可以直接將該節點插入最終的要回傳linked list。
然後,還可以發現在else
中會將i
減1,再搭配上面提到的部分。可以發現當right
只剩一個節點並執行i--
後,begin[i]
就會退回pivot,然而pivot必定只有一個節點,因此又會再做一次i--
,然後begin[i]
又會退回left
的部分,最後便可以持續分割原本的left
並對其做排序。
while (i >= 0) {
node_t *L = begin[i], *R = end[i];
if (L != R) {
...
i += 2;
} else {
if (L)
list_add(&result, L);
i--;
}
}
最後,我們可以用Graphviz來觀察一下quick_sort中right
, left
與pivot
的變化。
這是最一開始的狀態。共10個節點。
一開始一定是取第一個節點作為pivot,也就是8
。因此我們可以發現right
, left
與pivot
會變成以下狀態。
之後,9
和8
就會被併入最終的要回傳linked list。
之後再對原本的那條left做排序即可。可以發現,整結構其實就是一個stack。
實做細節可以參考 Commit c9912d7
For quick sort, Hoare's original algorithm [3] cannot
be used since this algorithm "bums a candle from
both ends".
在論文中,對於在雙向鍊結串列上使用 Hoare's 快速排序的主要擔憂是,霍爾原始的分區演算法 "bums a candle from both ends" ,同時從前面和後面進行遍歷。雖然這在陣列上很簡單(透過索引隨機存取很容易),但在鍊錶上卻很麻煩,特別是在單向的鍊結串列上。
However, since quick sort is not stable (Sedgewick
[6]), it is not included. Instead, an algorithm which
was originally designed to make quick sort stable
and to handle equal keys is selected for this study.
This algorithm was first proposed by Motzkin [5]
and then analyzed by Wegner [7].
隨後論文指出了第二個缺點:傳統的快速排序不穩定,這代表具有相同鍵值的節點在排序後可能不會保持其原始順序。由於這種不穩定性,作者選擇放棄傳統的快速排序,而採用一種變體(由 Motzkin 提出並由 Wegner 分析),結合以上兩點,就成為作業利用堆疊來實做快速排序演算法的方式。
首先來看 getnum 函式,其實作上是利用了 線性同餘生成器(Linear Congruential Generator, LCG) 的原理來產生偽隨機數。
LCG 的基本公式如下:
其中
static inline uint8_t getnum(void)
{
static uint16_t s1 = UINT16_C(2);
static uint16_t s2 = UINT16_C(1);
static uint16_t s3 = UINT16_C(1);
s1 *= UINT16_C(171);
s1 %= UINT16_C(30269);
s2 *= UINT16_C(172);
s2 %= UINT16_C(30307);
s3 *= UINT16_C(170);
s3 %= UINT16_C(30323);
return s1 ^ s2 ^ s3;
}
s1
, s2
, s3
)。getnum()
時,這三個狀態會根據各自的乘數與模數進行更新。static
,它們只在第一次呼叫時初始化,之後會保留上次的狀態,形成一個持續變化的序列。^
) 結合三個數值,以增加隨機性與分佈品質。static inline void random_shuffle_array(uint16_t *operations, uint16_t len)
{
for (uint16_t i = 0; i < len; i++) {
/* WARNING biased shuffling */
uint16_t j = get_unsigned16() % (i + 1);
operations[i] = operations[j];
operations[j] = i;
}
}
可以發現以上程式碼有以下問題:
i==j
時,可能會造成無效的洗牌。% (i + 1)
可能會產生模數偏差,除非 get_unsigned16()
能均勻的生成 1
~ i+1
的變數。改進 random_shuffle_array
:
可以採用 Fisher-Yates Shuffle 的方式來洗牌,如下:
#include <stdint.h>
#include <stdlib.h>
static inline void random_shuffle_array(uint16_t *operations, uint16_t len)
{
for (uint16_t i = len - 1; i > 0; i--) {
uint16_t j = rand() % (i + 1);
// Swap operations[i] and operations[j]
uint16_t temp = operations[i];
operations[i] = operations[j];
operations[j] = temp;
}
}
並且以熵的方式來衡量兩個洗牌算法之間的亂度:
length of array | Original | Fisher Yate | Ideal entropy |
---|---|---|---|
7 | 12.2954 | 12.2956 | 12.2922 |
8 | 15.2696 | 15.2700 | 15.2992 |
9 | 18.1794 | 18.1802 | 18.4691 |
可以發現 Fisher-Yates Shuffle 的效果其實比原本的演算法略好一點。
詳細的 python 腳本實做可以參考:Commit bf5b69f
list_move_tail
換為 list_move
,會無法滿足 stable sorting?當兩個元素具有相同的值時相當於 cmpint() == 0
,item->list
會被放入 list_greaterelse
中。
如果使用 list_move_tail()
,則兩個節點中的第一個會更早出現在 list_greater
,從而保持順序。
如果我們使用 list_move()
,則第二個節點反而會被插入到前面,這會反轉相等鍵值節點的原始順序。