【圖解算法】鏈表(上)鏈表反轉、迴文判斷

鏈表的題目比較基礎,但是越基礎的題目就越考驗代碼功底,這幾道題都是面試熱題,大家務必掌握。面試時不必一次性給出最優解,而是從最簡單的解決辦法開始,一步一步優化。因爲寫得有點長,所以分爲兩部分。

問題描述

  1. 單鏈表和雙向鏈表的反轉。
  2. 打印兩個有序鏈表的公共部分。
  3. 判斷一個鏈表是否迴文結構。

單鏈表反轉

這題相對基礎,一般會出現在面試中的第一道題,且可能要求寫出遞歸和非遞歸的兩種解法,如何又快又準地寫出來是具有一定挑戰性的,讀者們不妨在腦海中思考下兩種解法的輪廓,再結合文章看看有無遺漏之處。

首先來看看單鏈表的結構:

struct ListNode {
    int val; // 當前節點值
    ListNode *next; // 指向下一個節點
  
    ListNode(int x) : val(x), next(NULL) {} // 構造函數
};

一個單鏈表例子

首先來看看遞歸解法:

將鏈表分爲兩部分:head所在的頭節點,已經反轉了的部分reverse(head->next)

注意,這裏該如何理解已經反轉了的部分呢?我們假設我們已經完成了反轉函數ListNode* reverse(ListNode *head),我們將head->next傳入,則能得到反轉後的頭節點,因此下圖中reverse(head->next)指向3

將鏈表分爲兩部分

接下來我們只需將這兩部分進行反轉即可,爲此,我們得先獲取反轉部分的最後一個節點:

ListNode *p = reverse(head->next);
while(p != nullptr) {
    p = p->next;
}

獲取反轉部分的最後一個節點p

接下來,進行反轉,head的下一個應該是NULL,而p的下一個爲head

反轉

完成了調轉後,我們應該返回當前的頭節點,也就是reverse(head->next)所指向的位置,完整代碼如下:

ListNode *reverse(ListNode *head) { // 遞歸解法
    if (head == nullptr || head->next == nullptr) // 邊界處理
        return head;
    ListNode *newHead = reverse(head->next);
    
    ListNode *p = newHead; // 獲取後部分的最後一個節點指針
    while (p->next != nullptr) {
        p = p->next;
    }
    // 進行反轉
  	head->next = nullptr;
    p->next = head;
    return newHead; // 返回當前頭節點
}

接下來,我們看看非遞歸解法:

首先,我們使用兩個指針進行移動標記:pre指向前一個節點,cur爲當前節點,因爲head處沒有前一個節點,所以pre初始化爲NULL

使用pre和cur指針進行移動標記

接下來反轉節點1

先用temp指針記錄cur->next

記錄cur->next的位置

然後當前節點的next指向pre節點:

當前next指向前一個節點

再往下,則移動precur指針:

移動pre和cur指針

重複上述步驟,直到cur == NULL,反轉完成,返回pre指針即可:

反轉完成

完整代碼:

ListNode *reverse(ListNode *head) { // 非遞歸解法
    ListNode *pre = nullptr;
    ListNode *cur = head;
    while (cur != nullptr) { // 移動 pre 和 cur,對所有節點進行反轉
        ListNode *temp = cur->next;
        cur->next = pre;
        pre = cur;
        cur = temp;
    }
    return pre;
}

雙向鏈表反轉

前面講完了單向鏈表的反轉,雙向鏈表實際上只是在前者的基礎上增加對pre指針的考量:

struct ListNode {
    int val;
    ListNode *pre; // 指向前一個節點
    ListNode *next;

    ListNode(int x) : val(x), pre(NULL), next(NULL) {}
};

在遞歸解法中,要注意的headpre指針:

遞歸解法中增加pre指針

ListNode *reverse(ListNode *head) { // 遞歸解法
    if (head == nullptr)
        return head;
    if (head->next == nullptr) { // 不同點1:邊界處理也要考慮到對pre指針的處理
        head->pre = nullptr;
        return head;
    }
    ListNode *newHead = reverse(head->next);
    ListNode *p = newHead; // 獲取後部分的最後一個節點指針
    while (p->next != nullptr) {
        p = p->next;
    }
  	// 反轉鏈表
  	head->next = nullptr;
    head->pre = p; // 不同點2:增加對head的pre指針的處理
    p->next = head;
    return newHead;
}

而在非遞歸解法中:

非遞歸解法中增加pre指針

ListNode *reverse(ListNode *head) { // 非遞歸解法
    ListNode *pre = nullptr;
    ListNode *cur = head;
    while (cur != nullptr) {
        ListNode *temp = cur->next;
        cur->pre = cur->next; // 增加對pre指針的處理
        cur->next = pre;
        pre = cur;
        cur = temp;
    }
    return pre;
}

打印兩個有序鏈表的公共部分。

【題目】
給定兩個有序鏈表的頭指針head1head2,打印鏈表的公共部分。
【解析】

這題實際上比較簡單,但要注意理解題意,所謂有序鏈表,就是指按(升)序排列的鏈表,所謂公共部分,是指值相等的部分。如果面試過程中發現題意不是很清晰,是可以問面試官確認題意的。

【解答】

解法上就比較簡單了,先來看一個例子:

例子我們每次對head1head2的值進行比較,如果head1的值小於head2的值,那麼head1向後移動,將移動後的head1head2再次進行比較,相同則輸出,否則按誰小誰動的規則,直到head1head2NULL

1小於2,所以1移動

2小於1,所以2動

因爲head1->val == head2->val,所以輸出head1->val,然後head1head2一起向前移動:

1和2相等,輸出值,一起移動

如此往返,直到其中一個等於NULL,即停止。

代碼如下:

void printPublicPart(ListNode *head1, ListNode *head2) {
    while (head1 != nullptr && head2 != nullptr) {
        if (head1->val > head2->val) { // 誰小誰動
            head2 = head2->next;
        } else if (head1->val < head2->val) { // 誰小誰動
            head1 = head1->next;
        } else { // 相等則輸出
            cout << head1->val << " ";
            head1 = head1->next;
            head2 = head2->next;
        }
    }
}

判斷一個鏈表是否迴文結構

【題目】 給定一個鏈表的頭節點head,請判斷該鏈表是否爲迴文結構。 例如:

1->2->1,		返回true。 
1->2->2->1,	返回true。 
15->6->15,	返回true。 
1->2->3,		返回false。

【進階】如果鏈表長度爲N,時間複雜度達到O(N),額外空間複雜度達到O(1)。

【普通解法】

利用棧結構,我們將鏈表存起來,存完之後,再一個一個倒出來,同時遍歷鏈表,將鏈表的節點和棧中取出的節點一一比較,但凡一個不同,則說明鏈表不是迴文結構。

例子

如上圖所示,我們先遍歷一遍鏈表,將其節點一個個放入棧中, 這時從棧中推出的話就等於逆序遍歷鏈表,再次順序遍歷鏈表,與棧中推出的節點比較,那麼就相當於兩個指針,分別指向鏈表的開始和結尾,一個往後移動,一個往前移動。如果兩個指針指向的節點的值都相等,那麼說明這個鏈表就是迴文結構。

代碼如下:

bool isPalindrome(ListNode *head) { // 使用棧,空間複雜度 O(N),時間複雜度 O(N)
    stack<int> listStack;
    ListNode *p = head;

    while (p != nullptr) { // 第一遍遍歷,將鏈表放入棧中
        listStack.push(p->val);
        p = p->next;
    }
    p = head;
    while (p->next != nullptr) { // 第二遍遍歷,兩者從頭一起往後移動
        int top = listStack.top();
        if (p->val != top) {
            return false;
        }
        listStack.pop();
        p = p->next;
    }
    return true;
}

【N/2的空間優化】

事實上,我們並不需要把所有節點都推入棧中,只需要一半即可。我們將鏈表劃分爲左右兩半,然後將右半部分壓入到棧中,再對鏈表的左半部分做一次遍歷,同時彈出棧中節點進行比較。

來看一個例子:

例子

左邊的棧是我們壓入的右半部分節點,右邊是我們對一個奇數個鏈表的劃分(偶數個節點的鏈表怎麼分左右就不用我說了吧,各一半就是了)。

接下來我們需要知道鏈表中如何快速找到中點:快慢指針

快慢指針獲取鏈表中點位置

如上圖所示,當fast->next == NULL || fast->next->next == NULL時,slow所在位置就是中點,(偶數個時,slow會位於右半區的第一個,奇數個時,如上圖所示,位於中間,也就是右半區的前一個,所以我們需要進行調整)

注意這裏的條件fast->next == NULL || fast->next->next == NULL,爲什麼不寫成fast == NULL || fast->next == NULL呢?讀者們可以思考一下。

事實上,在奇數個的鏈表中,這兩種寫法都能使得slow指針到達中點,但是對偶數個節點的鏈表來說,兩種寫法會影響最後slow的位置,多一個next,會使得slow多走一步,看看下圖就明白了。

兩種判斷方式在偶數個鏈表中的影響

我們採用後者,這樣可以和奇數個的統一起來slow->next得到右半部分的起始位置:

ListNode *getMid(ListNode *head) { 
    ListNode *slow = head, *fast = head;
    while (fast->next != nullptr && fast->next->next != nullptr) {
        slow = slow->next;          // 慢指針每次移動一步
        fast = fast->next->next;    // 快指針每次移動兩步
    }
    return slow;
}
// 獲取右半區第一個節點的指針
ListNode *right = getMid(head)->next;

當我們獲取了右半部分的起始指針後,接下來就很簡單了,先將右半部分全部入棧,然後再從左半部分開始,同時彈棧,比較二者。

bool isPalindrome(ListNode *head) {
    ListNode *left = head;
  	ListNode *right = getMid(head)->next; // 獲取右半部分起始位置
    stack<int> rightStack;
    while (right != nullptr) { // 將右半部分入棧
        rightStack.push(right->val);
        right = right->next;
    }
    while (rightStack.empty()) { // 棧非空,則繼續比較
        if (left->val != rightStack.top()) { // 如果左半部分和彈出的棧頂不同,說明爲假
            return false;
        }
        left = left->next;
        rightStack.pop();
    }
    return true;
}

【O(1)的空間優化】

要將空間複雜度優化到O(1),還是比較考驗技巧的,前面我們已經談到了如何獲取中間節點,接下來我們說說如何不使用棧:直接將後半部分原地反轉:

例子
如上圖所示,我們將鏈表轉成上述結構後,只要分別從頭和尾開始,同時比較,只要有不同則說明不是迴文結構。至於如何原地反轉:

ListNode *getReverseRight(ListNode *head) { // 獲取反轉後的右起始節點
    // 獲取中點
    ListNode *pre = getMid(head);
    ListNode *cur = pre->next;
    pre->next = nullptr; // 將中點的 next 置空
    while (cur != nullptr) {
        ListNode *temp = cur->next;
        cur->next = pre;
        pre = cur;
        cur = temp;
    }
    return pre; // 此時 pre 指向反轉後的右半部分的起始節點
}

偶數個節點的鏈表的反轉結果:

偶數個節點的鏈表的反轉結果

接下來只要從headpre開始,兩兩比較即可:

bool isPalindromeNoStack(ListNode *head) {
    ListNode *right = getReverseRight(head);
    while (head != nullptr && right != nullptr) {
        if (head->val != right->val) {
            return false;
        }
        head = head->next;
        right = right->next;
    }
    return true;
}

歡迎大家關注我的公衆號瞭解更多內容。
在這裏插入圖片描述

發佈了97 篇原創文章 · 獲贊 80 · 訪問量 6萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章