談指神通

都說指針是 C 語言的靈魂,其實這是由幾個重量級的數據結構決定的,如最基礎卻又最重要的:鏈表二叉樹兩位元老,全部操做幾乎都依賴指針。node

可謂是:無指針者,無鏈表與二叉樹也面試

想象一下,沒有鏈表與二叉樹,計算機世界將如何存在?數組

固然,數組的本質也是指針,但藏得較深,你們用腳標得過且過,倒也悠然自得。數據結構


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

因此想要把握指針的靈魂,練就一身彈"指"神通的俊功夫,還得多練練鏈表code

下面,我就隨意截取幾道經典的鏈表問題,陪諸君練練手。(爲簡化問題,凸顯實質,皆爲單鏈表)排序

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;
}

知道爲何面試官老說「連個插入排序都寫不出,還能要?」的話了吧,由於就是這麼簡單。插入排序的關鍵在於插入。這也是咱們上面大篇幅講解鏈表三件套來實現順序鏈表插入的緣由。

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


未完待續

相關文章
相關標籤/搜索