筆試時,鏈表的題能過儘快過,不考慮空間複雜度;面試時,則儘量考慮如何將空間複雜度降到
O(1)
。
問題描述
- 將單向鏈表按某值劃分成左邊小、中間相等、右邊大的形式。
- 複製含有隨機指針節點的鏈表。
- 兩個單鏈表相交的系列問題。
##將單向鏈表按某值劃分成左邊小、中間相等、右邊大的形式
這道題實際上就是荷蘭國旗問題
的單鏈表版本,所謂荷蘭國旗問題:(leetcode中的顏色分類
問題)
給定一個包含紅色、白色和藍色,一共 n 個元素的數組,原地對它們進行排序,使得相同顏色的元素相鄰,並按照紅色、白色、藍色順序排列。
此題中,我們使用整數 0、 1 和 2 分別表示紅色、白色和藍色。
實際上,這裏是0、1、2或者是隨便給一些數字+一個閾值K,小於K的放左邊,大於K的放右邊,二者是一樣的。
注意:
不能使用代碼庫中的排序函數來解決這道題。且空間複雜度爲O(1)
。
示例:
輸入: [2,0,2,1,1,0]
輸出: [0,0,1,1,2,2]
這道題如果知道怎麼做,那麼一個直觀的做法就是先將鏈表存儲在一個節點數組中,然後使用數組版本的荷蘭國旗解法解決,然後再重新連接成鏈表,但是這樣需要一個O(N)
的空間複雜度,推薦在筆試過程中使用該方法快速過掉這道題;在面試中,我們則應該儘量優化,體現我們的思考深度。一個是使用O(1)
的空間複雜度,另一個則是保持鏈表原來的相對順序。
荷蘭國旗問題解法
這裏我們先來說下荷蘭國旗怎麼解,首先要注意,我們使用的是數組;我們只需三個變量來記錄數組中的位置,left
、right
記錄分割的位置(整個數組需要劃分爲三個部分),使用index
記錄掃描到的數組元素位置。
從上圖中可以看到,left = -1
、index = 0
、right = arr.length
。
接下來我們將對index
向右進行遍歷,每次遍歷需要注意一些條件,如果當前的值arr[index]
小於K
,說明這個值應該放左邊區域,如果arr[index]
大於K
,說明這個值應該放右邊,我們來看下代碼就很容易明白:
輔助函數:交換數組中的值
void swap(int *arr, int i, int j) {
int temp = arr[i];
arr[i] = arr[j];
arr[j] = temp;
}
排序代碼:
void colorSort(int *arr, int len, int K) { // 荷蘭國旗問題排序
int left = -1;
int right = len;
int index = 0;
while (index < right) {
if (arr[index] < K) { // 當前值小於閾值 K
swap(arr, ++left, index++);
} else if (arr[index] > K) { // 當前值大於閾值 K
swap(arr, --right, index);
} else { // 當前值等於閾值 K
index++;
}
}
}
理解了荷蘭國旗問題,再來理解我們的單鏈表版本就好理解很多了,事實上,除了使用額外的數組空間外,我們可以使用幾個變量來解決這個問題。
單鏈表版本的荷蘭國旗問題
我們可以考慮使用三對Head
和End
來記錄三個區域的鏈表的頭和尾,如下圖所示:
如上圖所示,我們利用上述的六個變量,將一個單鏈表分成三個鏈表,最後將它們串起來:
好,我們知道最終結果長啥樣後,接下來就是考慮如何得到這樣的結果,實際上代碼思路很簡單,首先遍歷一次鏈表,用三個head
指針分別標記第一個小於K、第一個等於K、和第一個大於K的數的指針,當然,我們要處理邊界問題,比如這裏面沒有等於K的,那麼中間的鏈表就是空的,連接的時候就要十分注意。
代碼:
ListNode *colorSort(ListNode *head, int K) {
ListNode *leftHead = nullptr, *leftEnd = nullptr;
ListNode *midHead = nullptr, *midEnd = nullptr;
ListNode *rightHead = nullptr, *rightEnd = nullptr;
ListNode *next = nullptr;
while (head != nullptr) { // 將一個鏈表拆分爲三個鏈表
next = head->next; // 保存下一個節點
head->next = nullptr;
if (head->val < K) {
if (leftHead == nullptr) {
leftHead = head;
leftEnd = head;
} else {
leftEnd->next = head;
leftEnd = leftEnd->next;
}
} else if (head->val > K) {
if (rightHead == nullptr) {
rightHead = head;
rightEnd = head;
} else {
rightEnd->next = head;
rightEnd = rightEnd->next;
}
} else {
if (midHead == nullptr) {
midHead = head;
midEnd = head;
} else {
midEnd->next = head;
midEnd = midEnd->next;
}
}
head = next;
}
// 連接左鏈表和中間的鏈表
if (leftEnd != nullptr) {
leftEnd->next = midHead;
// 如果中間鏈表爲空,midEnd 應指向 leftEnd
midEnd = (midEnd == NULL) ? leftEnd : midEnd;
}
// 連接中間鏈表和右鏈表
if (midEnd != nullptr) {
midEnd->next = rightHead;
}
// 返回三個鏈表串起來後的頭節點
return (leftHead != NULL) ? leftHead : ((midHead != NULL) ? midHead : rightHead);
}
可以看到,雖然思路簡單,但是整個代碼細節非常多,面試過程中一不小心就會出錯,大家寫代碼前一定要捋清後再寫。
複製含有隨機指針節點的鏈表
【題目】 一種特殊的鏈表節點類描述如下:
struct ListNode {
int val;
ListNode *next; // 和普通的單鏈表一樣
ListNode *rand; // 隨機指向某個節點
ListNode(int x) : val(x), next(nullptr), rand(nullptr) {}
};
rand
指針是這種鏈表中新增的指針,這個指針可能指向鏈表中的任意一個節點,也可能指向NULL
。 給定一個由含有隨機指針節點的鏈表的頭節點head,請實現一個函數完成這個鏈表中所有結構的複製,並返回複製的新鏈表的頭節點。
進階:不使用額外的數據結構,只用有限幾個變量,且在時間複雜度爲 O(N)
內完成原問題要實現的函數。
【題意解析】
我們首先畫個圖來理解一下這種含有隨機指針節點的鏈表:
如上圖所示,黃線即爲rand
指針的指向,可以看到指向是完全隨機沒有規律的。
【普通解法】
接下來我們說說簡單的解法,以節點1
爲例子,我們知道它的rand
指針指向節點2
,但是拷貝後的新節點1
如何找到新節點2
呢?這裏我們就會想到使用哈希表,我們可以將 <節點
,新節點
> 的映射結構存入哈希表中。
我們首先遍歷一遍鏈表,拷貝一份只複製了next
指針的鏈表,同時建立哈希表;再對這個新鏈表進行一次遍歷,每遍歷一個節點,將這個節點在哈希表中對應的節點取出,用rand
指針去連接即可。
我們先來看看單純的拷貝單鏈表:
ListNode *copyByHash(ListNode *head) {
ListNode *newHead = new ListNode(head->val);
ListNode *q = newHead;
ListNode *p = head;
while (p->next != nullptr) { // 拷貝單鏈表
p = p->next;
q->next = new ListNode(p->val);
q = q->next;
}
return newHead;
}
接下來我們加入哈希表部分:
ListNode *copy(ListNode *head) {
if (head == nullptr) { // 空指針判斷
return nullptr;
}
ListNode *newHead = new ListNode(head->val);
ListNode *q = newHead;
ListNode *p = head;
map<ListNode *, ListNode *> nodeMap;
nodeMap[head] = newHead;
while (p->next != nullptr) { // 拷貝單鏈表
p = p->next;
q->next = new ListNode(p->val);
q = q->next;
nodeMap[p] = q; // 第一次遍歷的過程中即可同時進行哈希映射
}
p = head;
q = newHead;
while (q != nullptr) { // 拷貝 rand 指針
q->rand = p->rand == nullptr ? nullptr : nodeMap[p->rand];
q = q->next;
p = p->next;
}
return newHead;
}
使用哈希表的方法固然簡單,但需要
O(N)
的空間複雜度,有沒有O(1)
的解決辦法呢?繼續看我們的優化方法。
【空間優化】
實際上,我們的解題關鍵就是節點
和新節點
之間的聯繫,假如我們可以使用某種方法將它們聯繫起來,那麼也就不需要哈希表了。這種辦法就是將新節點
連接在原節點
後面,這樣通過節點->next
就可以獲取到新節點
了。如下圖所示:
我們首先通過一次遍歷達成上述效果:
藍色箭頭處爲cur->next
的指向變更
將圖結合代碼看更加直觀:
ListNode *cur = head;
ListNode *next = nullptr;
while (cur != nullptr) { // 拷貝單鏈表
next = cur->next;
cur->next = new ListNode(cur->val);
cur->next->next = next;
cur = next;
}
接下來,在拷貝rand
指針的時候,我們只需要拷貝節點->rand->next
即可:
cur = head;
ListNode *curCopy = nullptr;
while (cur != nullptr) { // 拷貝 rand 指針
next = cur->next->next;
curCopy = cur->next;
curCopy->rand = cur->rand == nullptr ? nullptr : cur->rand->next;
cur = next;
}
最終還需要對它們進行拆分:
完整代碼:
ListNode *copy(ListNode *head) {
if (head == nullptr) { // 邊界判斷
return nullptr;
}
ListNode *cur = head;
ListNode *next = nullptr;
while (cur != nullptr) { // 拷貝單鏈表
next = cur->next;
cur->next = new ListNode(cur->val);
cur->next->next = next;
cur = next;
}
cur = head;
ListNode *curCopy = nullptr;
while (cur != nullptr) { // 拷貝 rand 指針
next = cur->next->next;
curCopy = cur->next;
curCopy->rand = cur->rand == nullptr ? nullptr : cur->rand->next;
cur = next;
}
ListNode* res = head->next;
cur = head;
while (cur != nullptr) { // 拆分並還原
next = cur->next->next;
curCopy = cur->next;
cur->next = next;
curCopy->next = next == nullptr ? nullptr : next;
cur = next;
}
return res;
}
兩個單鏈表相交的系列問題
【題目】 在本題中,單鏈表可能有環,也可能無環。給定兩個單鏈表的頭節點 head1
和head2
,這兩個鏈表可能相交,也可能不相交。請實現一個函數, 如果兩個鏈表相交,請返回相交的第一個節點;如果不相交,返回null
即可。
【 要求】如果鏈表1 的長度爲N
,鏈表2的長度爲M
,時間複雜度請達到 O(N+M)
,額外空間複雜度請達到O(1)
。
【題意解析】
注意了,本題實際上將鏈表相交的常見問題都糅合在一起了,是有一定難度的,我們需要先將問題切分開來:
-
如何判斷單鏈表有環無環?
我們寫一個函數來實現返回一個鏈表的環判定,有環返回入環的第一個點,無環則返回
null
; -
如何判斷單鏈表相交與否?
這個需要分開討論,需要意識到,一個鏈表有環 ,另一個鏈表無環,是不可能相交的。
- 兩個鏈表都無環
- 兩個鏈表都有環
對於有環相交2
,我們在返回環的起點的時候,從兩個起點中任意返回一個即可。
判斷單鏈表有環無環?
這裏也是用到我們前面講過的快慢指針,如果快慢指針有機會相等,那說明這個鏈表有環,兩個指針在環裏面兜圈,否則,任何一個指針到達了NULL
,說明這個鏈表無環。這裏理解了後,我們再考慮如何返回入環的第一個節點。
如下方的代碼:
- 不要忘了處理邊界,要形成環至少有三個節點;
- 第一次循環中,如果快指針遇到了
null
,說明鏈表無環,直接返回null
即可;如果快指針和慢指針相遇,即可確定鏈表有環,接下來就是確定環的入口(或起點); - 在第二次循環中,我們先將快指針重新指向頭節點,然後從一次走兩步變爲一次走一步,當快慢指針再次相遇時,相遇的節點必然是環的入口節點。
關於第三點,我們如何證明呢?
如下圖所示,整個鏈表的長度爲L
,其中環部分的長度爲R
,非環的部分長度爲x
,當快慢指針相遇時,相遇點到入環節點的距離爲y
,假設相遇時慢指針走的距離爲s
,所以s = x + y
;同時,對於快指針來說,快指針的速度是慢指針的2
倍,我們假設了慢指針走了s
,那麼快指針就走了2s
,且快指針走的距離等價於s
加上比慢指針在環中多走了n
圈,所以2*s = s + n*R
,化簡得到s = n * R
;另外,整個鏈表的長度等價於非環部分的長度x
加上環的長度R
。
我們根據上述得到的三條式子進行代換,可以得到下圖中x = (n-1)*R + L-x-y
,這條式子表明,我們將快指針重新放到起點,慢指針則不變,快指針從非環部分移動的距離x
等價於慢指針從第一次相遇點再次到達入環點的距離(n-1)*R + L-x-y
。其中的(n-1)*R
表明在這個過程中,慢指針可能會在環中轉n-1
圈,但不影響快慢指針同時到達入口點;
ListNode *getCircleEntry(ListNode *head) {
// 處理邊界,至少要有三個點纔能有環
if (head == nullptr || head->next == nullptr || head->next->next == nullptr) {
return nullptr;
}
ListNode *slow = head->next;
ListNode *fast = head->next->next;
while (slow != fast) {
if (fast == nullptr || fast->next == nullptr) { // 遇到 null,說明無環
return nullptr;
}
fast = fast->next->next;
slow = slow->next;
}
fast = head; // 快指針回到頭節點,慢指針不變
while (slow != fast) {
slow = slow->next;
fast = fast->next; // 快指針每次只走一步
}
return fast;
}
如何判斷單鏈表相交與否?
我們根據前面判斷有無環的函數,可以將單鏈表相交問題分爲兩個部分進行討論:
ListNode *getIntersectNode(ListNode *head1, ListNode *head2) {
ListNode *p1 = getCircleEntry(head1);
ListNode *p2 = getCircleEntry(head2);
if (p1 == nullptr && p2 == nullptr) {
// 兩個無環單鏈表相交問題
} else if (p1 != nullptr && p2 != nullptr) {
// 兩個有環單鏈表相交問題
}
}
接下來我們看如何解決無環單鏈表的相交問題:
- 先各自遍歷兩個鏈表,記錄長度,同時判斷最後一個節點是否相等,若不等,則一定不相交,返回
null
,否則,二者相交,到下一步; - 將鏈表1的長度記錄爲
len1
,將鏈表2的長度記錄爲len2
,如果len1
大於len2
,那麼鏈表1先移動len1-len2
步,然後兩個指針一起走,判斷兩個指針是否相等,相等返回當前節點即可。 - 在第二點的基礎上,我們可以做一個小優化,使用一個變量
count
來記錄二者的長度差值,對鏈表1,每次自增,對鏈表2,每次自減,即可根據最終count
的正負來判斷鏈表1、2何者更長。
ListNode *getNoLoopNode(ListNode *head1, ListNode *head2) {
ListNode *p = head1;
ListNode *q = head2;
int count = 0;
while (p->next != nullptr) { // 計算鏈表1長度
count++;
p = p->next;
}
while (q->next != nullptr) { // 計算鏈表2長度
count--;
q = q->next;
}
if (p != q) {
return nullptr;
}
p = count > 0 ? head1 : head2; // 將較長的鏈表賦給 p
q = count > 0 ? head2 : head1; // 將較短的鏈表賦給 q
for (int i = 0; i < abs(count); ++i) { // 因爲 p 較長,所以移動 p
p = p->next;
}
while (p != q) { // 直到找到第一個 p 和 q 相等的節點
p = p->next;
q = q->next;
}
return p;
}
接下來,我們看如何解決有環鏈表的相交問題:
我們前面已經得到了獲取一個有環鏈表的入環節點的函數:getCircleEntry(ListNode *head)
,因此我們可以獲取兩個鏈表的入環節點loop1
和loop2
;
如果loop1 == loop2
,說明是第一種有環相交:
我們只需要把前面的判斷無環鏈表的相交節點的函數的邊界從null
改爲loop1
/loop2
即可:
if (loop1 == loop2) {
ListNode *p = head1;
ListNode *q = head2;
int count = 0;
while (p->next != loop1) { // 計算鏈表1長度
count++;
p = p->next;
}
while (q->next != loop2) { // 計算鏈表2長度
count--;
q = q->next;
}
p = count > 0 ? head1 : head2; // 將較長的鏈表賦給 p
q = count > 0 ? head2 : head1; // 將較短的鏈表賦給 q
for (int i = 0; i < abs(count); ++i) { // 因爲 p 較長,所以移動 p
p = p->next;
}
while (p != q) { // 直到找到第一個 p 和 q 相等的節點
p = p->next;
q = q->next;
}
return p;
}
如果loop1 != loop2
,那麼兩個有環鏈表有可能不相交,也可能如第二種有環相交情況所示:
解決辦法:
我們讓鏈表1從loop1
的位置開始行動,如果在再次回到loop1
的過程中沒有遇到loop2
,說明兩個鏈表不相交,直接返回null
,否則,返回loop1
或者loop2
都可以。
else {
ListNode *p = loop1->next;
while (p != loop1) {
if (p == loop2) {
return loop2;
}
p = p->next;
}
return nullptr;
}
整合之後:
ListNode *getLoopNode(ListNode *head1, ListNode *head2) {
ListNode *loop1 = getCircleEntry(head1);
ListNode *loop2 = getCircleEntry(head2);
if (loop1 == loop2) {
ListNode *p = head1;
ListNode *q = head2;
int count = 0;
while (p->next != loop1) { // 計算鏈表1長度
count++;
p = p->next;
}
while (q->next != loop2) { // 計算鏈表2長度
count--;
q = q->next;
}
p = count > 0 ? head1 : head2; // 將較長的鏈表賦給 p
q = count > 0 ? head2 : head1; // 將較短的鏈表賦給 q
for (int i = 0; i < abs(count); ++i) { // 因爲 p 較長,所以移動 p
p = p->next;
}
while (p != q) { // 直到找到第一個 p 和 q 相等的節點
p = p->next;
q = q->next;
}
return p;
} else {
ListNode *p = loop1->next;
while (p != loop1) {
if (p == loop2) {
return loop2;
}
p = p->next;
}
return nullptr;
}
}
主函數:
ListNode *getIntersectNode(ListNode *head1, ListNode *head2) {
ListNode *p1 = getCircleEntry(head1);
ListNode *p2 = getCircleEntry(head2);
if (p1 == nullptr && p2 == nullptr) { // 兩個無環單鏈表相交問題
return getNoLoopNode(head1, head2);
} else if (p1 != nullptr && p2 != nullptr) { // 兩個有環鏈表相交問題
return getLoopNode(head1, head2);
}
}
【優化】
我們前面求兩個無環單鏈表的相交節點時,先各遍歷一遍得到長度,再遍歷第二次去獲取位置,實際上我們可以使用更加簡潔的寫法:
ListNode *getNoLoopNode2(ListNode *head1, ListNode *head2) {
if (head1 == nullptr || head2 == nullptr)
return nullptr;
ListNode *p = head1;
ListNode *q = head2;
while (p != q) {
if (p == nullptr)
p = head2;
else
p = p->next;
if (q == nullptr)
q = head1;
else
q = q->next;
}
return p;
}
原理如下圖所示:
p
點從帶圓圈的橙線(上面那條)出發,到達NULL
之後跳轉到head2
繼續,而q
從帶圓圈的藍線(下面那條)出發,達到NULL
之後跳轉到head1
繼續,二者會在相交處相遇,此時p == q
,跳出循環,返回p
,即當前的相交節點。
這種方法在兩個鏈表長度相同時,只需遍歷相交前的部分即可。
在loop1 == loop2
中整合該方法也非常容易,跟之前的方法一樣,將對NULL
的判斷改爲對loop1
/loop2
的判斷即可。