鏈表的題目比較基礎,但是越基礎的題目就越考驗代碼功底,這幾道題都是面試熱題,大家務必掌握。面試時不必一次性給出最優解,而是從最簡單的解決辦法開始,一步一步優化。因爲寫得有點長,所以分爲兩部分。
問題描述
- 單鏈表和雙向鏈表的反轉。
- 打印兩個有序鏈表的公共部分。
- 判斷一個鏈表是否迴文結構。
單鏈表反轉
這題相對基礎,一般會出現在面試中的第一道題,且可能要求寫出遞歸和非遞歸的兩種解法,如何又快又準地寫出來是具有一定挑戰性的,讀者們不妨在腦海中思考下兩種解法的輪廓,再結合文章看看有無遺漏之處。
首先來看看單鏈表的結構:
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;
}
接下來,進行反轉,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
。
接下來反轉節點1
:
先用temp
指針記錄cur->next
:
然後當前節點的next
指向pre
節點:
再往下,則移動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) {}
};
在遞歸解法中,要注意的head
的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;
}
而在非遞歸解法中:
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;
}
打印兩個有序鏈表的公共部分。
【題目】
給定兩個有序鏈表的頭指針head1
和head2
,打印鏈表的公共部分。
【解析】
這題實際上比較簡單,但要注意理解題意,所謂有序鏈表,就是指按(升)序排列的鏈表,所謂公共部分,是指值相等的部分。如果面試過程中發現題意不是很清晰,是可以問面試官確認題意的。
【解答】
解法上就比較簡單了,先來看一個例子:
我們每次對head1
和head2
的值進行比較,如果head1
的值小於head2
的值,那麼head1
向後移動,將移動後的head1
與head2
再次進行比較,相同則輸出,否則按誰小誰動的規則,直到head1
或head2
爲NULL
。
因爲head1->val == head2->val
,所以輸出head1->val
,然後head1
和head2
一起向前移動:
如此往返,直到其中一個等於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 指向反轉後的右半部分的起始節點
}
偶數個節點的鏈表的反轉結果:
接下來只要從head
和pre
開始,兩兩比較即可:
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;
}
歡迎大家關注我的公衆號瞭解更多內容。