鏈表問(wèn)題是面試過(guò)程中經(jīng)常被問(wèn)到的一部分,很考查編程功底。最近刷了 LeetCode 上鏈表部分的面試題,我總結(jié)了一些有代表性的鏈表問(wèn)題。
本文使用的是 Java 語(yǔ)言,下面是所用到的鏈表節(jié)點(diǎn)的定義:
public class ListNode {
int val;
ListNode next;
ListNode(int x) {
val = x;
}
}
1. 在 O(1) 時(shí)間刪除鏈表節(jié)點(diǎn)
Leetcode 237. Delete Node in a Linked List
題目描述:給定單鏈表中需要?jiǎng)h除的節(jié)點(diǎn)(不是尾節(jié)點(diǎn)),在 O(1) 時(shí)間刪除該節(jié)點(diǎn)。
分析:本題與《編程之美》上的「從無(wú)頭單鏈表中刪除節(jié)點(diǎn)」類似。主要思想都是「貍貓換太子」,即用下一個(gè)節(jié)點(diǎn)數(shù)據(jù)覆蓋要?jiǎng)h除的節(jié)點(diǎn),然后刪除下一個(gè)節(jié)點(diǎn)。但是如果節(jié)點(diǎn)是尾節(jié)點(diǎn)時(shí),該方法就行不通了。
代碼如下:
// 在 O(1) 時(shí)間從無(wú)頭單鏈表中刪除節(jié)點(diǎn)
public void deleteNode(ListNode node) {
// 不能為空,不能為尾節(jié)點(diǎn)
if (null == node || null == node.next) {
return;
}
node.val = node.next.val;
node.next = node.next.next;
}
2. 逆轉(zhuǎn)單鏈表
LeetCode 206. Reverse Linked List
題目描述:輸出一個(gè)單鏈表的逆序反轉(zhuǎn)后的鏈表。
分析:非遞歸的算法很簡(jiǎn)單,用三個(gè)臨時(shí)指針 prev、cur、next 在鏈表上循環(huán)一遍即可。遞歸算法是先逆轉(zhuǎn)下一個(gè)節(jié)點(diǎn),再逆轉(zhuǎn)當(dāng)前節(jié)點(diǎn)。
下面是兩種算法的代碼:
// 逆轉(zhuǎn)單鏈表,循環(huán)方法
public ListNode reverseByLoop(ListNode head) {
if (null == head || null == head.next) {
return head;
}
ListNode prev = null;
ListNode next = null;
// 用 head 作為 cur 指針
while (null != head) {
next = head.next;
head.next = prev;
prev = head;
head = next;
}
return prev;
}
// 逆轉(zhuǎn)單鏈表,遞歸方法
public ListNode reverseByRecursion(ListNode head) {
// 第一個(gè)條件判斷異常,第二個(gè)條件是結(jié)束遞歸
if (null == head || null == head.next) {
return head;
}
ListNode newHead = reverseByRecursion(head.next);
head.next.next = head;
head.next = null;
return newHead;
}
3. 刪除單鏈表倒數(shù)第 n 個(gè)節(jié)點(diǎn)
LeetCode 19. Remove Nth Node From End of List
題目描述:刪除單鏈表倒數(shù)第 n 個(gè)節(jié)點(diǎn),1 <= n <= length,盡量在一次遍歷中完成。
分析:看到題目時(shí)的第一想法是先遍歷一次計(jì)算出單鏈表的長(zhǎng)度 length,然后在遍歷第二次刪除第 length - n + 1 個(gè)節(jié)點(diǎn),但是這需要遍歷兩次。正常的刪除第 n 個(gè)節(jié)點(diǎn)只需要遍歷一次就可以,如何只遍歷一次找到倒數(shù)第 n 個(gè)節(jié)點(diǎn)呢?可以設(shè)置兩個(gè)指針 p1、p2,首先 p1 和 p2 都指向 head,p2 移動(dòng)到第 n 個(gè)節(jié)點(diǎn),然后 p1 和 p2 同時(shí)向后移動(dòng),當(dāng) p2 移動(dòng)到末尾時(shí),p1 剛好指向倒數(shù)第 n 個(gè)節(jié)點(diǎn)。因?yàn)樽詈笠獎(jiǎng)h除倒數(shù)第 n 個(gè)節(jié)點(diǎn),所以可以找到倒數(shù)第 n + 1 個(gè)節(jié)點(diǎn),方便刪除節(jié)點(diǎn)。
代碼如下:
// 遍歷一次,刪除單鏈表倒數(shù)第 n 個(gè)節(jié)點(diǎn)
public ListNode removeNthFromEnd(ListNode head, int n) {
if (null == head) {
return head;
}
ListNode p1 = head;
ListNode p2 = head;
// 1. p2 移動(dòng)到第 n + 1 個(gè)節(jié)點(diǎn)
for (int i = 0; i < n; i ++>) {
p2 = p2.next;
}
// n == 鏈表長(zhǎng)度時(shí),p2 指向第 n + 1 節(jié)點(diǎn)為空,倒數(shù)第 n 個(gè)節(jié)點(diǎn)就是頭節(jié)點(diǎn)
if (null == p2) {
p1 = head.next;
return p1;
}
// p1 和 p2 同時(shí)向后移動(dòng),直到 p2 到達(dá)尾節(jié)點(diǎn)
while (null != p2.next) {
p1 = p1.next;
p2 = p2.next;
}
// 此時(shí) p1 指向倒數(shù)第 n + 1 個(gè)節(jié)點(diǎn),刪除它的下一個(gè)節(jié)點(diǎn)
p1.next = p1.next.next;
return head;
}
4. 求單鏈表的中間節(jié)點(diǎn)
題目描述:求單鏈表的中間節(jié)點(diǎn),如果鏈表的長(zhǎng)度為偶數(shù),返回中間兩個(gè)節(jié)點(diǎn)的任意一個(gè),若為奇數(shù),則返回中間節(jié)點(diǎn)。
分析:這道題的思路和第 3 題「刪除單鏈表倒數(shù)第 n 個(gè)節(jié)點(diǎn)」很相似。如果要求只能遍歷一遍鏈表的花,也通過(guò)兩個(gè)指針來(lái)完成。兩個(gè)指針從頭節(jié)點(diǎn)開(kāi)始,慢指針每次向后移動(dòng)一步,快指針每次向后移動(dòng)兩步,直到快指針移動(dòng)到尾節(jié)點(diǎn)時(shí),慢指針移動(dòng)到中間節(jié)點(diǎn)。
// 遍歷一次,找出單鏈表的中間節(jié)點(diǎn)
public ListNode findMiddleNode(ListNode head) {
if (null == head) {
return;
}
ListNode slow = head;
ListNode fast = head;
//如果要求在單鏈表長(zhǎng)度為偶數(shù)的情況下,返回中間兩個(gè)節(jié)點(diǎn)的第一個(gè),可以用下面的循環(huán)條件
//while(null != fast.next && null != fast.next.next)
while (null != fast && null != fast.next) {
fast = fast.next.next;
slow = slow.next;
}
return slow;
}
5. 判斷單鏈表是否存在環(huán)
LeetCode 141. Linked List Cycle
題目描述:判斷一個(gè)單鏈表是否有環(huán)
分析:還是通過(guò)快慢指針來(lái)解決,兩個(gè)指針從頭節(jié)點(diǎn)開(kāi)始,慢指針每次向后移動(dòng)一步,快指針每次向后移動(dòng)兩步,如果存在環(huán),那么兩個(gè)指針一定會(huì)在環(huán)內(nèi)相遇。
代碼如下:
// 判斷單鏈表是否有環(huán)
public boolean hasCycle(ListNode head) {
if (null == head) {
return false;
}
ListNode slow = head;
ListNode fast = head;
while (null != fast.next && null != fast.next.next) {
fast = fast.next.next;
slow = slow.next;
if (fast == slow) {
return true;
}
}
return false;
}
6. 單鏈表是否有環(huán)擴(kuò)展:找到環(huán)的入口點(diǎn)
LeetCode 142. Linked List Cycle II
題目描述:判斷單鏈表是否有環(huán),如果有,找到環(huán)的入口點(diǎn)
分析:由上題可知,按照 p2 每次兩步,p1 每次一步的方式走,發(fā)現(xiàn) p2 和 p1 重合,確定了單向鏈表有環(huán)路了。接下來(lái),讓 p2 回到鏈表的頭部,重新走,每次步長(zhǎng)不是走 2 了,而是走 1,那么當(dāng) p1 和 p2 再次相遇的時(shí)候,就是在環(huán)路的入口點(diǎn)。
假設(shè)起點(diǎn)到環(huán)入口的距離尾 a,p1 和 p2 第一次相遇的相交點(diǎn) M 與環(huán)入口的距離為 b,環(huán)的周長(zhǎng)為 L,當(dāng) p1 和 p2 第一次相遇時(shí),假設(shè) p1 走了 n 步。其中 p1 和 p2 第一次相遇時(shí),p1 在環(huán)內(nèi)走過(guò)的步數(shù)為 b,因?yàn)楫?dāng) p1 走到環(huán)入口時(shí),p2 已經(jīng)在環(huán)內(nèi)了,假設(shè)此時(shí) p2 走到環(huán)入口的步數(shù)為 c,那么 p1 再走 c 步 p2 剛好追上來(lái)和 p1 相遇,c < L,所以此時(shí) p1 肯定還沒(méi)走完一圈。那么根據(jù)上面的假設(shè),有下面的關(guān)系:
p1 走的路徑:a + b = n
p2 走的路徑:a + b + k * L = 2n
,假設(shè)此時(shí) p2 比 p1 多走了 k 圈環(huán)路,k >= 1
根據(jù)上面的兩個(gè)等式可以得出k * L = n = a + b
,那么從相交點(diǎn) M 開(kāi)始,p1 再走 a(a = k * L - b) 步,就相當(dāng)于走了 k 圈,然后回退 b 步,注意環(huán)入口到相交點(diǎn)的距離剛好為 b,所以 p1 再走 a 步時(shí)到達(dá)環(huán)入口;而 p2 從頭開(kāi)始走 a 的話也到達(dá)了環(huán)入口,與 p1 相遇。
而在后面這個(gè)步驟中,p1 和 p2 前 a 步走的路徑不同,再次相遇時(shí)必然在環(huán)的入口點(diǎn)。
代碼如下:
// 找到環(huán)的入口點(diǎn)
public ListNode findLoopPort(ListNode head) {
if (null == head) {
return null;
}
ListNode p1 = head;
ListNode p2 = head;
boolean hasCycle = false;
// 1. 判斷是否有環(huán)
while (null != p2.next && null != p2.next.next) {
p1 = p1.next;
p2 = p2.next.next;
if (p1 == p2) {
hasCycle = true;
break;
}
}
if (!hasCycle) {
return null;
}
// p2 從頭開(kāi)始走,步長(zhǎng)變?yōu)?1
p2 = head;
while (p1 != p2) {
p1 = p1.next;
p2 = p2.next;
}
return p1;
}
7. 判斷兩個(gè)無(wú)環(huán)單鏈表是否相交
題目描述:給出兩個(gè)無(wú)環(huán)單鏈表
A: a1 → a2
↘
c1 → c2 → c3 → null
↗
B: b1 → b2 → b3
判斷 A 和 B 是否相交。
分析:
1.最直接的方法是判斷 A 鏈表的每個(gè)節(jié)點(diǎn)是否在 B 鏈表中,但是這種方法的時(shí)間復(fù)雜度為 O(Length(A) * Length(B))。
2.轉(zhuǎn)化為環(huán)的問(wèn)題。把 B 鏈表接在 A 鏈表后面,如果得到的鏈表有環(huán),則說(shuō)明兩個(gè)鏈表相交。可以之前討論過(guò)的快慢指針來(lái)判斷是否有環(huán),但是這里還有更簡(jiǎn)單的方法。如果 B 鏈表和 A 鏈表相交,把 B 鏈表接在 A 鏈表后面時(shí),B 鏈表的所有節(jié)點(diǎn)都在環(huán)內(nèi),所以此時(shí)只需要遍歷 B 鏈表,看是否會(huì)回到起點(diǎn)就可以判斷是否相交。這個(gè)方法需要先遍歷一次 A 鏈表,找到尾節(jié)點(diǎn),然后還要遍歷一次 B 鏈表,判斷是否形成環(huán),時(shí)間復(fù)雜度為 O(Length(A) + Length(B))。
3.除了轉(zhuǎn)化為環(huán)的問(wèn)題,還可以利用“如果兩個(gè)鏈表相交于某一節(jié)點(diǎn),那么之后的節(jié)點(diǎn)都是共有的”這個(gè)特點(diǎn),如果兩個(gè)鏈表相交,那么最后一個(gè)節(jié)點(diǎn)一定是共有的。所以可以得出另外一種解法,先遍歷 A 鏈表,記住尾節(jié)點(diǎn),然后遍歷 B 鏈表,比較兩個(gè)鏈表的尾節(jié)點(diǎn),如果相同則相交,不同則不相交。時(shí)間復(fù)雜度為 O(Length(A) + Length(B)),空間復(fù)雜度為 O(1),思路比解法 2 更簡(jiǎn)單。
解法 3 的代碼如下:
// 判斷兩個(gè)無(wú)環(huán)單鏈表是否相交
public boolean isIntersect(ListNode headA, ListNode headB) {
if (null == headA || null == headB) {
return false;
}
if (headA == headB) {
return true;
}
while (null != headA.next) {
headA = headA.next;
}
while (null != headB.next) {
headB = headB.next;
}
return headA == headB;
}
8. 兩個(gè)鏈表相交擴(kuò)展:判斷兩個(gè)有環(huán)單鏈表是否相交
題目描述:上面的問(wèn)題是針對(duì)無(wú)環(huán)鏈表的,如果是鏈表有環(huán)呢?
分析:如果兩個(gè)有環(huán)單鏈表相交,那么它們一定共有一個(gè)環(huán)。因此可以先用之前快慢指針的方式找到兩個(gè)鏈表中位于環(huán)內(nèi)的兩個(gè)節(jié)點(diǎn),如果相交的話,兩個(gè)節(jié)點(diǎn)在一個(gè)環(huán)內(nèi),那么移動(dòng)其中一個(gè)節(jié)點(diǎn),在一次循環(huán)內(nèi)肯定可以與另外一個(gè)節(jié)點(diǎn)相遇。
代碼如下:
// 判斷兩個(gè)有環(huán)單鏈表是否相交
public boolean isisIntersectWithLoop(ListNode headA, ListNode headB) {
if (null == headA || null == headB) {
return false;
}
if (headA == headB) {
return true;
}
headA = hasCycle(headA);
headB = hasCycle(headB);
// 沒(méi)有環(huán),則退出
if (null == headA || headB) {
return false;
}
ListNode p = headB.next;
// p 在環(huán)內(nèi)循環(huán)一次,直到與 headA 相遇
while (p != headB) {
if (p == headA) {
return true;
}
p = p.next;
}
return false;
}
// 判斷單鏈表是否有環(huán),并返回環(huán)內(nèi)的某一節(jié)點(diǎn)
public ListNode hasCycle(ListNode head) {
if (null == head) {
return null;
}
ListNode slow = head;
ListNode fast = head;
while (null != fast.next && null != fast.next.next) {
fast = fast.next.next;
slow = slow.next;
if (fast == slow) {
return slow;
}
}
return null;
}
9. 兩個(gè)鏈表相交擴(kuò)展:求兩個(gè)無(wú)環(huán)單鏈表的第一個(gè)相交點(diǎn)
LeetCode 160. Intersection of Two Linked Lists
題目描述:找到兩個(gè)無(wú)環(huán)單鏈表第一個(gè)相交點(diǎn),如果不相交返回空,要求在線性時(shí)間復(fù)雜度和常量空間復(fù)雜度內(nèi)完成。
分析:
下面所說(shuō)的對(duì)齊:表示指針到鏈表末尾的距離相同。
分為先判斷是否有環(huán),再求第一個(gè)相交點(diǎn)的方式。分別遍歷 A 鏈表和 B 鏈表,判斷它們的最后一個(gè)節(jié)點(diǎn)是否相交。然后利用對(duì)齊的思想,計(jì)算兩個(gè)鏈表的長(zhǎng)度(這個(gè)可以放在之前的遍歷中做),分別用 p1 和 p2 指向兩個(gè)鏈表的頭,然后將較長(zhǎng)鏈表的 p1 (假設(shè)為 p1)向后移動(dòng)
LB - LA
個(gè)節(jié)點(diǎn)。這樣 p1 和 p2 對(duì)齊了,然后同時(shí)向后移動(dòng) p1 和 p2,直到p1 == p2
,相遇的點(diǎn)就是第一個(gè)節(jié)點(diǎn)。解法 1 中為了對(duì)齊需要計(jì)算鏈表的長(zhǎng)度,有沒(méi)有什么方法可以不用計(jì)算鏈表長(zhǎng)度呢?假設(shè) A 鏈表和 B 鏈表的長(zhǎng)度為 LA 和 LB,假設(shè) LB >= LA,兩個(gè)指針 p1 和 p2 分別指向 A 鏈表和 B 鏈表的頭節(jié)點(diǎn)。同時(shí)向后移動(dòng),當(dāng) p1 移動(dòng) A 鏈表的末尾時(shí),p2 距離 B 鏈表的末尾的距離為
LB - LA
,此時(shí)可以看出我們已經(jīng)得到了長(zhǎng)度差,如何利用這個(gè)長(zhǎng)度差對(duì)齊呢。這時(shí)將 p1 移動(dòng)到 B 鏈表的頭部,兩個(gè)指針繼續(xù)移動(dòng),當(dāng) p2 移動(dòng)到 B 鏈表的末尾時(shí),p1 剛好移動(dòng)了LB - LA
步。此時(shí)再將 p2 移動(dòng)到 A 鏈表的頭部,這樣 p1 和 p2 就對(duì)齊了,然后繼續(xù)移動(dòng),直到p1 == p2
。如果兩個(gè)鏈表不相交,p1 和 p2 移動(dòng)會(huì)同時(shí)移動(dòng)到末尾都指向空,而相交的話,第一次相等時(shí)就是第一個(gè)相交點(diǎn)。這種方法的時(shí)間復(fù)雜度為 O (2 * (Length(B))),最多要遍歷兩次長(zhǎng)度較長(zhǎng)的鏈表。
?解法 2 的代碼如下:
// 求兩個(gè)無(wú)環(huán)單鏈表的第一個(gè)相交點(diǎn)
public ListNode getIntersectionNode(ListNode headA, ListNode headB) {
if (null == headA || null == headB) {
return null;
}
if (headA == headB) {
return headA;
}
ListNode p1 = headA;
ListNode p2 = headB;
while (p1 != p2) {
// 遍歷完所在鏈表后從另外一個(gè)鏈表再開(kāi)始
// 當(dāng) p1 和 p2 都換到另一個(gè)鏈表時(shí),它們對(duì)齊了:
// (1)如果鏈表相交,p1 == p2 時(shí)為第一個(gè)相交點(diǎn)
// (2)如果鏈表不相交,p1 和 p2 同時(shí)移動(dòng)到末尾,p1 = p2 = null,然后退出循環(huán)
p1 = (null == p1) ? headB : p1.next;
p2 = (null == p2) ? headA : p2.next;
}
return p1;
}
10. 總結(jié)
回過(guò)頭來(lái),會(huì)發(fā)現(xiàn)上面的鏈表問(wèn)題主要用到了「貍貓換太子」、「對(duì)齊」以及「兩個(gè)指針」的方式來(lái)提高效率。其中利用兩個(gè)指針來(lái)提供效率的方式經(jīng)常用到,在遇到鏈表問(wèn)題時(shí)可以多考慮下這種思路。推薦大家記住這幾種典型的鏈表問(wèn)題,以后很多類似的題目都可以轉(zhuǎn)換到熟悉的問(wèn)題再解決。
參考文章: