Chapter III. 漸入佳境
實作技巧
偽指標
作者WiwiHo
先備知識Linked List

偽指標是一種指標的替代寫法,舉個例子,假設我們想要寫一個普通的單向 Linked List,好好使用指標寫的話,可能會寫出一個像這樣的 struct:

struct Node {
    int val = -1;
    Node *next = nullptr;
};

// b 放在 a 後面,假設 b->next == nullptr
void insert(Node *a, Node *b) {
    b->next = a->next;
    a->next = b;
}

void traverse(Node *cur) {
    while (cur != nullptr) {
        cout << cur->val << " ";
        cur = cur->next;
    }
    cout << "\n";
}

void delete_list(Node *cur) {
    while (cur != nullptr) {
        Node *tmp = cur;
        cur = cur->next;
        delete tmp;
    }
}

void test() {
    int n = 10;
    Node *start = nullptr, *last = nullptr;
    for (int i = 0; i < n; i++) {
        Node *node = new Node();
        node->val = i;
        if (last) insert(last, node);
        if (!start) start = node;
        last = node;
    }
    // 遍歷 linked list,輸出 0 1 2 ... 9
    traverse(start);
    delete_list(start);
}

在這個程式碼裡面,每個節點存了指向下一個節點的指標,要是不喜歡用指標的話,就可以使用偽指標。偽指標的精神非常簡單:真正的指標存的是東西在記憶體裡的位置,我們也可以仿照指標的精神,把東西都放在一個陣列裡面,然後直接用它在陣列裡的 index 來作為指向它的指標。

這樣寫的話,就會變成:

struct Node {
    int val = 0;
    int next = -1; // 用 -1 代表 nullptr
};
Node arr[100];
int cnt = 0;

int newnode() {
    return cnt++;
}

void insert(int a, int b) {
    arr[b].next = arr[a].next;
    arr[a].next = b;
}

void traverse(int cur) {
    while (cur != -1) {
        cout << arr[cur].val << " ";
        cur = arr[cur].next;
    }
    cout << "\n";
}

// 不用寫 delete 了

void test() {
    int n = 10;
    int start = -1, last = -1;
    for (int i = 0; i < n; i++) {
        int node = newnode();
        arr[node].val = i;
        if (last != -1) insert(last, node);
        if (start == -1) start = node;
        last = node;
    }
    // 遍歷 linked list,輸出 0 1 2 ... 9
    traverse(start);
}

基本上是在做一模一樣的事情,只是 new Node() 改成呼叫 newnode() 這個 function,它會回傳用來存節點的陣列 arr 裡一個還沒用過的位置,然後本來使用指標的地方,都改成存在那個節點 arr 裡的 index,這個 index 就是所謂的偽指標。

這看起來只是換個寫法而已,除了不喜歡指標的人,在需要用指標時可以改用偽指標以外,有時候這麼寫還會有額外的好處。

例題
kevin 愛殺殺

$n$ 個人站成一列,請執行 $m$ 次操作,每次都會把編號 $k$ 後面的人殺掉,並輸出這個被殺掉的人的編號。

條件限制
  • $1\leq n, m\leq 10^6$

看起來就是一件標準的雙向 linked list 可以做到的事情,不過題目指定要刪的節點的方式是「指定某個節點編號、刪掉它的下一個節點」,所以我們必須要能夠很快的找到編號 $k$ 的節點在哪裡。雖然 linked list 本身不支援隨機存取,但當然總是可以很無腦的開一個陣列,存下指向每一個節點的指標,不過既然都開陣列了,要是用的是偽指標,只要直接好好把第 $i$ 個節點放在陣列的第 $i$ 格,那就不需要做額外的事了!

要是懶得寫成 struct、每個節點裡的資訊夠少,像是這題中,每個節點需要存的資訊只有左邊的人和右邊的人而已(第 $i$ 個人放在陣列第 $i$ 格,就也不需要額外再存節點編號),更可以直接開兩個陣列 lrl[i]r[i] 就是第 $i$ 個人左、右的兩人,然後用 linked list 的方式處理刪點即可。這樣也可以用完全跟指標無關的語言解讀:l[i] 就是站在 $i$ 左邊的人的編號、r[i] 就是站在 $i$ 右邊的人的編號。

當然的,只要沒有省記憶體之類的需求,用指標實作或是使用 std::list 並儲存 iterator 也是完全可以的,偽指標只是一種替代方案。

Memory Pool


偽指標除了是實作上的替代品之外,其實在記憶體管理方面是可以帶來真正的好處的。

在剛才實作 linked list 的例子中,注意到我們寫的指標版本裡,有一個 function delete_list(),這個 function 是用來把一個 linked list 中所有節點都 delete,這是因為我們的 Node 都是用 new 開出來的,或是不手動刪除的話,他們在程式執行結束前都會佔用一塊記憶體空間,沒用了就刪掉可以節省不必要的浪費。不過,這樣要多實作一段找出所有節點的指標並一一 delete 的程式碼,有一點麻煩,如果用的是陣列的話,不需要用到了之後,有更輕鬆的作法能避免浪費空間,像是:

不管怎樣都比遍歷一次 linked list 再一一刪除節點輕鬆很多,在遇到更複雜的資料結構的時候,這麼做的方便性就會相當顯著了。這種「開一個陣列來儲存物件」的方法叫作 memory pool,就是開一個陣列當成記憶體使用,其實不一定要跟偽指標搭配,若比較喜歡指標的話,用指標來當作儲存位置的方式也是可以的,這個方法主要的目的是避免使用 new

如果一開始沒辦法確定 memory pool 會需要存多少東西,也可以用像是 std::vector 的動態陣列來當作儲存物件的陣列,只不過有一件事情需要注意:當 std::vector 的大小成長時,有時會為了找到一塊更大的記憶體空間,而自動搬家到記憶體裡比較空曠的地方,此時如果你本來有存指向 vector 中位置的指標或 iterator,它是不會自動指到搬家後的位置的,得用偽指標的方法,直接儲存在陣列裡的 index 才不會出事。

最後,使用 memory pool 還有一個好處是,電腦在讀取一塊連續記憶體上的資料時,會比讀取一堆散在各處的資料來的快,而使用 memory pool 就能讓資料都放在一個陣列裡,確保它們都在一塊連續記憶體內,而且使用 memory pool 也能避免頻繁使用 newdelete 等等記憶體操作,能夠提升一點程式的執行速度。