談指神通

pezy發表於2019-05-13

都說指標是 C 語言的靈魂,其實這是由幾個重量級的資料結構決定的,如最基礎卻又最重要的:連結串列二叉樹兩位元老,所有操作幾乎都依賴指標。

可謂是:無指標者,無連結串列與二叉樹也

想象一下,沒有連結串列與二叉樹,計算機世界將如何存在?

當然,陣列的本質也是指標,但藏得較深,大家用腳標得過且過,倒也怡然自得。


若只論連結串列與二叉樹,連結串列又更容易將指標指的出神入化,二叉樹稍遜,一個 left, 一個 right 的二次元世界,弄不出什麼花來。

所以想要把握指標的靈魂,練就一身彈”指”神通的俊功夫,還得多練練連結串列

下面,我就隨意擷取幾道經典的連結串列問題,陪諸君練練手。(為簡化問題,凸顯實質,皆為單連結串列)

cppstruct ListNode {
    int val;
    ListNode *next;
    ListNode(int x) : val(x), next(nullptr) {}
};

連結串列的逆

 1->2->3->4->5
 ^
 root

想要逆序,最直接的想法,就是希望上圖中的連結串列指向反過來。我們借用一個空指標 node 指向一個空節點:

 1->2->3->4->5 | ListNode* reverse(ListNode *root) {
 ^             |      ListNode *node = nullptr;    
 root          | }
               |
 null          |
 ^             |
 node          |

第一步,我們希望節點1從單連結串列中剝離,於是讓其指向 node, 但我們不能因此而找不到連結串列索引,故需要一個額外的指標 next, 指向後續節點:

 2->3->4->5    | ListNode* reverse(ListNode *root) {
 ^             |      ListNode *node = nullptr;   
 root          |      ListNode *next = root->next;     // next refer to 2
               |      root->next = node;               // root point to node
 1->null       |      node = root;                     // node refer to root(1)
 ^             |      root = next;                     // root refer to next(2)
 node          | }

幾個簡單的指標轉移,便將節點1反向的去指向了 node 節點。如法炮製的話,節點2, 節點3, 節點4, 節點5 都調轉槍頭,我們的目的便達到了。

cppListNode* reverse(ListNode *root) {
     ListNode *node = nullptr;
     while (root) {
          ListNode *next = root->next;
          root->next = node;
          node = root;
          root = next;
     }
     return node;
}

連結串列除重

1->1->2->2->3->4
^
head
cur

如果用一個指標 cur 來指向當前節點的話,出現重複的條件即為:cur->value == cur->next->value,如上圖中,1 與 1 是重複的。我們只要想辦法去掉重複的那個 1 即可。

1->1->2->2->3->4    |  if (cur->val == cur->next->val) {
^  ^  ^             |      ListNode *next = cur->next->next;
cur   next          |      delete cur->next;
|     ^             |      cur->next = next;
|_____|             |  }

這個思路簡單,易懂,但這個問題卻又是很多複雜問題的基礎。還是需要注意的。

cppListNode *removeDuplicates(ListNode *head) {
    if (head == nullptr) return head;
    for (ListNode *cur=head; cur->next; )
        if (cur->val == cur->next->val) {
            ListNode *next = cur->next->next;
            delete cur->next;
            cur->next = next;
        } else { cur = cur->next; }
    return head;
}

連結串列合併

1->2->3
^
a
            ==>    1->4->2->5->3->6
4->5->6            ^
^                  new_list
b

這個問題本身非常簡單,但想通過這個基本問題,引申出連結串列問題一個非常常見的技巧。即設立 dummy 節點,可以稱為是傀儡節點,其作用在於讓合成的新連結串列有一個著手點。這個節點的值可以隨意,我們最終返回的,實際上是 dummy.next;

     a ------>         | while (a && b) {
0 -> 1   2   3         |     tail->next = a;
^    |  /|  /|         |     tail = a;
|    V / V / V         |     a = a->next;
|    4   5   6 -> null |     tail->next = b;
|    b ------>         |     tail = b;
dummy                  |     b = b->next;

要注意,每一步指標的搗騰都是按照順序的,用筆紙畫一畫會比較清楚。

cppListNode *shuffleMerge(ListNode *a, ListNode *b) {
    ListNode dummy(0), *tail = &dummy;
    while (a && b) {
        tail->next = a;
        tail = a;
        a = a->next;
        tail->next = b;
        tail = b;
        b = b->next;
    }
    tail->next = a ? a : b;
    return dummy.next;
}

移動節點

1->2->3            2->3
^                  ^
a                  a
            ==> 
1->2->3            1->1->2->3
^                  ^
b                  b

這個問題幾乎不足為道,但這個操作,將有助於我們們更深入的對連結串列進行研究。封裝這個操作,我們可以避免糾纏於非常基本的問題。(a 為 source(s), b 為 dest(d))

    s->s        |
    1  2->3     | void moveNode(ListNode **destRef, ListNode **sourceRef) {
  ->n           |     ListNode *newNode = *sourceRef;
 |  |           |     *sourceRef = newNode->next;
 |  V           |     newNode->next = *destRef;
 |  1->2->3     |     *destRef = newNode;
 ---d           | }

順序合併

1->3->5      
         ==>  1->2->3->4->5->6
2->4->6

這也是非常基本的操作,結合上述的傀儡節點與 moveNode 兩個技巧,應該可以很輕鬆的寫出如下思路:

cppListNode *sortedMerge(ListNode *a, ListNode *b) {
    ListNode dummy(0), *tail = &dummy;
    for ( ;a && b; tail = tail->next) {
        if (a->val <= b->val) moveNode(&(tail->next), &a);
        else moveNode(&(tail->next), &b);
    }
    tail->next = a ? a : b;
    return dummy.next;
}

傀儡節點畢竟耗費了額外的空間,同樣的思路,能否改進為不耗費額外空間呢?我們來思考另一個例子:

1->null
^
a
        ==>  1->2->3
2->3         ^  ^
^            a  b
b

這是一個簡單到不能再簡單的連結串列連線了,使用 a->next = b 即可完成。但若此刻指標 a 沒有指向 1, 而是指向了 null, 想過怎麼辦沒有?

1->null
    ^
    a
        ==> 1->2->3
2->3
^
b

我們展開想象,如果能把 b 指標”生生的挪到” a 的位置就好了。可不可以呢?再深入一點,指標 a 指向 null, 記憶體裡應該是這樣子:

 ____    ______    ______     |
|null|  |0x2342|  |0x6787|    | ListNode **aRef = &a; // 0x9899
|____|  |__a___|  |__&a__|    | *aRef = b; // 
0x2342   0x6787    0x9899     |
 ____    ______    ______     |  ______
| 2  |  |0x1221|  |0x3554|    | |0x1221|
|____|  |__b___|  |__&b__|    | |__&a__| // 當我們找指標 a 的地址時,實際卻找到了 b.
0x1221   0x3554    0x0980     |  0x9899  // 所以現在的連結串列為:1->2->3.

理解了這個技巧後(在 C++ 中有一個更合適的名字:Reference, 引用),這個問題有一個更好的辦法:

cppListNode *sortedMerge(ListNode *a, ListNode *b) {
    ListNode *ret = nullptr, **lastPtrRef = &ret;
    for (; a && b; lastPtrRef = &((*lastPtrRef)->next)) {
        if (a->val <= b->val) moveNode(lastPtrRef, &a);
        else moveNode(lastPtrRef, &b);
    }
    *lastPtrRef = a ? a : b;
    return ret;
}

思路完全一致,但不消耗額外空間。即無需傀儡,直接上位。

另,這個問題也可以用遞迴解決,權當額外思考題了(可能更加直觀):

cppListNode *sortedMerge(ListNode *a, ListNode *b) {
    ListNode *ret = nullptr;
    if (a == nullptr) return b;
    else if (b == nullptr) return a;

    if (a->val <= b->val) { ret = a; ret->next = sortedMerge(a->next, b); }
    else { ret = b; ret->next = sortedMerge(a, b->next); }

    return ret;
}

順序插入

4
^
newNode
               ==>  1->3->4->5->7->8
1->3->5->7->8
^
head

給一個有序連結串列 head, 一個新節點 newNode. 將新節點插入該連結串列中。

問題本身簡單到不行,但我們僅僅是以此來複習一下上次所講的三種策略。

  1. 直接插入法(教科書法)
  2. 傀儡節點
  3. 引用法(指標的指標)

首先最樸素的第一種方法,也是教科書上經常講述的方案。在這個問題裡,我們需要分別考慮兩種情況:其一,newNode 的值比 head 還要小,那麼它應該直接放到最前面(這個動作是連線而非插入);其二,newNode 的值比 head 要大,那麼毫無疑問,需要遍歷整個連結串列,找到 newNode 應該插入的位置,進行插入。

cpp1 2->3->4->5    |    if (*headRef == nullptr || (*headRef)->val >= newNode->val) {
^ ^             |        newNode->next = *headRef;
| head          |        *headRef = newNode;
newNode         |    } else {
----------------|        ListNode *curr = *headRef;
1->2        4->5|        while (curr->next != nullptr && curr->next->val < newNode->val)
   ^        ^   |            curr = curr->next;
   curr->3--|   |        newNode->next = curr->next;
         ^      |        curr->next = newNode;
         newNode|    }

簡單又好理解。

然後我們來看看第二種,很常用的傀儡法。為了避免像上面分兩種情況分別處理那麼麻煩,不如自立山頭,統一處理。

cppvoid sortedInsert(ListNode **headRef, ListNode *newNode) {
    ListNode dummy(0), *tail = &dummy;
    dummy.next = *headRef;

    while (tail->next != NULL && tail->next->val < newNode->val)
        tail = tail->next;
    newNode->next = tail->next;
    tail->next = newNode;
    *headRef = dummy.next;
}

可以看到,程式碼完全照搬上面的第二種情況。更加緊湊。

好了,最後我們來看看最精簡的第三種方案,使用引用。細心的童鞋會發現,上面我們定位的一直是 curr->next 節點。這個 next 很羅嗦,但普通的插入,必須要知道前後節點,所以也是不得已為之。如果我們採用引用,則只需要知道後面的節點即可。

cpp1->3->5        | ListNode **currRef = headRef;
      ^        | while (*currRef != nullptr && (*currRef)->val < newNode->val)
  4-> curr     |     currRef = &((*currRef)->next);
  ^            | newNode->next = *currRef;
  newNode      | *currRef = newNode;

可以看到,我們將 newNode->next 指向 curr 節點後,直接將 newNode 節點生生挪到連結串列裡去了。這是因為 currRef 處於連結串列中第 2 個(從 0 開始)位置,當 *currRef = newNode 之後,相當於將這個位置指向的地址換成了 newNode. 而 newNode 已經和後面的節點相連,所以很順利的順延了後續連結串列。

寥寥五行,非常精簡。上述三種思路都應該掌握,而核心應該掌握最後一種方案。


連結串列排序

我們趁熱打鐵,上面討論了 sortedInsert 方法的實現。那麼我們倒過來,實現最基礎的面試題,插入排序。

思路呢,非常簡單,弄一個空連結串列:ListNode *newHead = nullptr;, 然後遍歷整個連結串列,將每一個節點 sortedInsertnewHead 中。程式碼如下:

cppvoid insertSort(ListNode **headRef) {
    ListNode *newHead = nullptr;
    for (ListNode *curr = *headRef, *next; curr; curr = next) {
        next = curr->next;
        sortedInsert(&newHead, curr);
    }
    *headRef = newHead;
}

知道為什麼面試官老說“連個插入排序都寫不出,還能要?”的話了吧,因為就是這麼簡單。插入排序的關鍵在於插入。這也是我們上面大篇幅講解連結串列三件套來實現順序連結串列插入的原因。

這僅僅是最基礎的一種排序手段,先留個思考題,還有那些常用的排序手段,如何實現呢?


未完待續

相關文章