# 分析「快慢指標」 在 LeetCode 上,「快慢指標」(fast and slow pointers) 是常見處理[鏈結串列](https://hackmd.io/@sysprog/c-linked-list)的手法,不過以「快慢指標」的方式來取得鏈結串列的中點,是否較「單一指標」走訪二遍能更快呢?其實二者具備相同的時間複雜度 $O(n)$,本文嘗試從編譯器最佳化和記憶體階層架構,觀察二者的表現。 對比演算法的 C 語言描述 首先,作為鏈結串列節點的結構體為: ```c struct list_node { int val; struct list_node *next; }; ``` 「快慢指標」方式的 C 語言描述為: ```c struct list_node *middle_node(struct list_node *head) { struct list_node *slow, *fast; slow = fast = head; while (fast && fast->next) { slow = slow->next; fast = fast->next->next; } return slow; } ``` 圖示如下: ```graphviz digraph "Circular Linked List" { rankdir = "LR"; node1[shape = circle, label = "1"]; node2[shape = circle, label = "2"]; node3[shape = circle, label = "3"]; node4[shape = circle, label = "4"]; node5[shape = circle, label = "5"]; node0__[shape = none, label = "fast", fontcolor = "blue"]; node1__[shape = circle, label = "1", style = invis]; node2__[shape = circle, label = "2", style = invis]; node0_[shape = none, label = "slow", fontcolor = "red"]; node1_[shape = circle, label = "1", style = invis]; node0_ -> node1_; node0__ -> node1__ -> node2__; node1 -> node2; node2 -> node1; node2 -> node3; node3 -> node2; node3 -> node4; node4 -> node3; node4 -> node5; node5 -> node4; } ``` 「單一指標」方式的 C 語言描述為: ```c struct list_node *middle_node(struct list_node *head) { struct list_node *cur = head; int n = 0; while (cur) { ++n; cur = cur->next; } int k = 0; cur = head; while (k < n / 2) { ++k; cur = cur->next; } return cur; } ``` 假設有一條長度為 9 個節點的鏈結串列,記憶體存取的次數為: * 使用「快慢指標」方式,快指標完整的走訪鏈結串列 9 個節點,慢指標走訪前 5 個節點 * 使用「單一指標」方式,首先走訪鏈結串列一次共 9 個節點,再逐一走訪至中點,亦即 5 個節點 兩種方式都涉及對鏈結串列的 $9 + 5 = 14$ 個節點的記憶體位置進行存取,二者的時間複雜度皆為: $$ O(n) + O\left(\frac{n}{2}\right) = O\left(\frac{3}{2}n\right). $$ 接著,對照上述二者的編譯器輸出。用 GCC 分別編譯上述二個程式後,可得以下組合程式碼: - [ ] 「快慢指標」方式的組合程式碼: ``` movq %rdi, %rax jmp .L2 .L4: movq 8(%rax), %rax ; fast = tmp->next movq 8(%rdi), %rdi ; slow = slow->next .L2: testq %rax, %rax ; 判斷 fast == 0 ? 亦即判斷 fast == NULL ? je .L3 ; 若 fast == NULL 則跳躍至 .L3 movq 8(%rax), %rax ; 把 tmp = fast->next testq %rax, %rax ; 判斷 tmp == NULL ? jne .L4 ; 若 tmp 不為 NULL,則跳躍至 .L4 .L3: movq %rdi, %rax ; 把返回值複製至 %rax ret ``` - [ ] 「單一指標」方式的組合程式碼: ```= movq %rdi, %rax ; cur = head movl $0, %edx ; n = 0 jmp .L2 .L3: addl $1, %edx ; ++n movq 8(%rax), %rax ; cur = cur->next .L2: testq %rax, %rax ; cur == NULL ? jne .L3 ; 若 cur 不為 NULL,則跳躍至 .L3 movl $0, %ecx ; k = 0 jmp .L4 .L5: addl $1, %ecx ; ++k movq 8(%rdi), %rdi ; cur = cur->next .L4: movl %edx, %eax ; int tmp = n shrl $31, %eax ; tmp = tmp < 0 ? 1 : 0 addl %edx, %eax ; tmp +=n sarl %eax ; tmp >>= 1 cmpl %ecx, %eax ; tmp - k jg .L5 movq %rdi, %rax ret ``` 「單一指標」方式的組合程式碼中,第 16 行至第 19 行的指令是為了計算 n / 2 的結果,因為除數是 2 的冪,於是除法可取代為位移運算,即使開啟更積極的編譯器最佳化 (如 `-O3`),上述的程式碼沒有顯著的指令差異。 排除編譯器最佳化的因素後,我們來看現代處理器的影響。在具有良好時間局部性 (temporal locality) 的程式中,被參照過一次的記憶體位置可能不久後仍會被多次參照;在具有良好空間局部性 (spatial locality) 的程式中,若一個記憶體位置被參照過,那程式很可能在隨後參照其鄰近的記憶體位置。 從空間局部性來看:鏈結串列中的節點儲存的位置並無規律,亦即每個節點在記憶體中分散地保存,都具有較差的空間局部性,於是可推論二者演算法的空間局部性表現相同。 從時間據局部性來看: * 「快慢指標」方式的迴圈中,鏈結串列的前半部分中,快指標存取完成後、迴圈結束前,慢指標已存取相同的節點.同一個節點被二次存取的時間間隔較近。 * 「單一指標」方式的迴圈中,鏈結串列的前半部分中,同一個節點的第二次存取在第一次走訪完成之後,同一個節點被兩次存取的時間間隔較遠。 於是,「快慢指標」方式具有更好的時間局部性,而「單一指標」方式的第二次走訪鏈結串列時,可供其利用的「前半段鏈結串列中的節點」的快取更容易被鏈結串列的「後半段鏈結串列中的節點」的快取所替換,由此將更頻繁的觸發快取錯失 (cache miss)。更甚者,若鏈結串列的節點數量增大,則在相同的快取中能快取的節點更少,快取的替換將更頻繁,「單一指標」方式相較於「快慢指標」方式,蒙受更大的效能衝擊,因為前半段的鏈結串列的節點的快取更容易被替換。 延伸閱讀: [現代處理器設計: Cache 原理和實際影響](https://hackmd.io/@sysprog/HkW3Dr1Rb)