高頻演算法面試題:快慢指標
前提
今天(2019-05-10
)中午吃飯的時候刷了下技術型別的公眾號,看到有前輩過了Ant
的高P
面試,其中有一道題考查了單鏈表搜尋位於中間的節點的演算法。看著演算法就飯,覺得解決方案很有趣,於是這裡嘗試重現一下。
場景
面試官:如何訪問連結串列中間節點?
大佬X:簡單地實現,遍歷一遍整個的連結串列,然後計算出連結串列的長度,進而遍歷第二遍找出中間位置的資料。
面試官:要求只能遍歷一次連結串列,那又當如何解決?
大佬X:可以採取建立兩個指標,一個指標一次遍歷兩個節點,另一個節點一次遍歷一個節點,當快指標遍歷到空節點時,慢指標指向的位置為連結串列的中間位置,這裡解決問題的演算法稱為「快慢指標」。
覆盤
我們先設定單鏈表的長度大於等於3,這樣子比較容易分析演算法。先簡單假設一個長度為3的單鏈表如下:
j-a-l-f-l-1.png
如果我們要訪問中間節點,最終搜尋到的應該是n2
節點,內容就是n2
。
如果單鏈表的長度為偶數,這裡假設為4,那麼如下:
j-a-l-f-l-2.png
如果我們要訪問中間節點,最終搜尋到的應該是n2
和n3
節點,內容就是n2
和n3
。
先定義好節點類Node
如下:
-
@Data
-
private static class Node<T> {
-
/**
-
* 當前節點的值
-
*/
-
private T value;
-
/**
-
* 下一個節點的引用
-
*/
-
private Node<T> next;
-
}
我們可以很輕易地構建一個單鏈表如下:
-
private static Node<String> buildLinkedList(int len) {
-
Node<String> head = new Node<>();
-
head.setValue("n1");
-
Node<String> tail = head;
-
for (int i = 1; i < len; i++) {
-
Node<String> node = new Node<>();
-
node.setValue("n" + (i + 1));
-
tail.setNext(node);
-
tail = node;
-
}
-
return head;
-
}
接著我們可以編寫搜尋中間節點的方法,先編寫通過遍歷連結串列進行長度計算,再遍歷連結串列得到中間節點的「方案一」:
-
private static List<String> searchByTraversal(Node<String> head) {
-
List<String> result = new ArrayList<>(2);
-
Node<String> search = head;
-
int len = 1;
-
// 第一次遍歷連結串列,計算連結串列長度
-
while (search.getNext() != null) {
-
search = search.getNext();
-
len++;
-
}
-
int index = 0;
-
int mid;
-
search = head;
-
// 連結串列長度為偶數
-
if ((len & 1) == 0) {
-
mid = len / 2 - 1;
-
while (search.getNext() != null) {
-
if (mid == index) {
-
result.add(search.getValue());
-
result.add(search.getNext().getValue());
-
}
-
search = search.getNext();
-
index++;
-
}
-
} else {
-
mid = (len - 1) / 2;
-
while (search.getNext() != null) {
-
if (mid == index) {
-
result.add(search.getValue());
-
}
-
search = search.getNext();
-
index++;
-
}
-
}
-
return result;
-
}
寫個main方法試驗一下:
-
public static void main(String[] args) throws Exception {
-
Node<String> head = buildLinkedList(11);
-
System.out.println(searchByTraversal(head));
-
head = buildLinkedList(12);
-
System.out.println(searchByTraversal(head));
-
}
-
// 輸出結果
-
[n6]
-
[n6, n7]
假設連結串列的長度為n
(n > 0
),那麼進行兩次遍歷一共需要遍歷的元素個數如下:
-
第一次遍歷整個連結串列,計算長度,必須遍歷
n
個元素。 -
第二次需要遍歷
n/2
個元素(在n
值比較大的時候,其實加減的影響不大)。
這種方案實現,最終的時間複雜度一定會大於O(n)
。所以需要考慮優化方案,只需要遍歷一次連結串列就能定位到中間的節點值,這個就是方案二:「快慢指標」。
「快慢指標」,簡單來說就是定義兩個指標,在遍歷連結串列的時候,快指標(Fast Pointer
)總是遍歷兩個元素,而慢指標(Slow Pointer
)總是遍歷一個元素。當快指標完成遍歷整個連結串列的時候,慢指標剛好指向連結串列的中間節點。演算法實現如下:
-
/**
-
* 基於快慢指標搜尋
-
*/
-
private static List<String> searchByFastSlowPointer(Node<String> head) {
-
List<String> result = new ArrayList<>();
-
// fast pointer
-
Node<String> fp = head;
-
// slow pointer
-
Node<String> sp = head;
-
int len = 1;
-
while (null != fp.getNext()) {
-
if (fp.getNext().getNext() != null) {
-
fp = fp.getNext().getNext();
-
sp = sp.getNext();
-
len += 2;
-
} else {
-
fp = fp.getNext();
-
len += 1;
-
}
-
}
-
// 連結串列長度為偶數
-
if ((len & 1) == 0) {
-
result.add(sp.getValue());
-
result.add(sp.getNext().getValue());
-
} else {
-
result.add(sp.getValue());
-
}
-
return result;
-
}
寫個main方法試驗一下:
-
public static void main(String[] args) throws Exception {
-
Node<String> head = buildLinkedList(11);
-
System.out.println(searchByFastSlowPointer(head));
-
head = buildLinkedList(12);
-
System.out.println(searchByFastSlowPointer(head));
-
}
-
// 輸出結果
-
[n6]
-
[n6, n7]
由於使用了快慢指標的方案,只做了一次連結串列的遍歷,並且由於快指標是每次兩個元素進行遍歷,最終的時間複雜度要小於O(n)
。
快慢指標的應用場景
快慢指標主要有如下的應用場景:
-
找到連結串列的中點。
-
判斷連結串列中是否存在環。
-
刪除連結串列中倒數第
x
個節點。
第一種情況已經作為覆盤案例分析過,下面分析一下第二和第三種場景。
判斷連結串列中是否存在環
假設連結串列有6個節點(head節點為n1,tail節點為n6),已經形成環(n6的下一個節點為n1):
j-a-l-f-l-3.png
使用快慢指標,快指標每次遍歷會比慢指標多一個元素,這樣子的話,如果連結串列已經成環,無論快指標和慢指標之間相隔多少個節點,快指標總是能夠追上慢指標(快指標和慢指標指向同一個節點),這個時候就可以判斷連結串列已經成環;否則快指標進行一輪遍歷之後就會跳出迴圈,永遠不可能和慢指標"重合"。簡陋的實現如下:
-
// 判斷連結串列是否存在環
-
private static boolean cyclic(Node<String> head) {
-
// fast pointer
-
Node<String> fp = head;
-
// slow pointer
-
Node<String> sp = head;
-
while (fp.getNext() != null) {
-
fp = fp.getNext().getNext();
-
sp = sp.getNext();
-
if (sp.equals(fp)) {
-
return true;
-
}
-
}
-
return false;
-
}
-
// 生成環形連結串列
-
private static Node<String> buildCyclicLinkedList(int len) {
-
Node<String> head = new Node<>();
-
head.setValue("n1");
-
Node<String> tail = head;
-
for (int i = 1; i < len; i++) {
-
Node<String> node = new Node<>();
-
node.setValue("n" + (i + 1));
-
tail.setNext(node);
-
tail = node;
-
}
-
tail.setNext(head);
-
return head;
-
}
測試一下:
-
public static void main(String[] args) throws Exception {
-
Node<String> head = buildCyclicLinkedList(11);
-
System.out.println(cyclic(head));
-
head = buildLinkedList(11);
-
System.out.println(cyclic(head));
-
}
-
// 輸出結果
-
true
-
false
刪除連結串列中倒數第N個節點
這個是LeetCode
上的一道演算法題,裡面用到的是虛擬頭結點加上快慢指標的方法,只進行一次遍歷就能解決。這裡引用獲贊最多的回答裡面的解決思路:
❝上述演算法可以優化為只使用一次遍歷。我們可以使用兩個指標而不是一個指標。第一個指標從列表的開頭向前移動n+1步,而第二個指標將從列表的開頭出發。現在,這兩個指標被n個結點分開。我們通過同時移動兩個指標向前來保持這個恆定的間隔,直到第一個指標到達最後一個結點。此時第二個指標將指向從最後一個結點數起的第n個結點。我們重新連結第二個指標所引用的結點的next指標指向該結點的下下個結點。
❞
演算法推演圖:
j-a-l-f-l-4.png
演算法程式碼如下:
-
public ListNode removeNthFromEnd(ListNode head, int n) {
-
ListNode dummy = new ListNode(0);
-
dummy.next = head;
-
ListNode first = dummy;
-
ListNode second = dummy;
-
// Advances first pointer so that the gap between first and second is n nodes apart
-
for (int i = 1; i <= n + 1; i++) {
-
first = first.next;
-
}
-
// Move first to the end, maintaining the gap
-
while (first != null) {
-
first = first.next;
-
second = second.next;
-
}
-
second.next = second.next.next;
-
return dummy.next;
-
}
時間複雜度為O(L)
,L
為連結串列長度。
小結
鑑於演算法比較弱,看到這些相對有實用價值的題目和解決方案,還是值得推演和學習一番。