1. 程式人生 > 實用技巧 >高頻演算法面試題:快慢指標

高頻演算法面試題:快慢指標

前提

今天(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

如果我們要訪問中間節點,最終搜尋到的應該是n2n3節點,內容就是n2n3

先定義好節點類Node如下:


  1. @Data

  2. private static class Node<T> {

  3. /**

  4. * 當前節點的值

  5. */

  6. private T value;

  7. /**

  8. * 下一個節點的引用

  9. */

  10. private Node<T> next;

  11. }

我們可以很輕易地構建一個單鏈表如下:


  1. private static Node<String> buildLinkedList(int len) {

  2. Node<String> head = new Node<>();

  3. head.setValue("n1");

  4. Node<String> tail = head;

  5. for (int i = 1; i < len; i++) {

  6. Node<String> node = new Node<>();

  7. node.setValue("n" + (i + 1));

  8. tail.setNext(node);

  9. tail = node;

  10. }

  11. return head;

  12. }

接著我們可以編寫搜尋中間節點的方法,先編寫通過遍歷連結串列進行長度計算,再遍歷連結串列得到中間節點的「方案一」


  1. private static List<String> searchByTraversal(Node<String> head) {

  2. List<String> result = new ArrayList<>(2);

  3. Node<String> search = head;

  4. int len = 1;

  5. // 第一次遍歷連結串列,計算連結串列長度

  6. while (search.getNext() != null) {

  7. search = search.getNext();

  8. len++;

  9. }

  10. int index = 0;

  11. int mid;

  12. search = head;

  13. // 連結串列長度為偶數

  14. if ((len & 1) == 0) {

  15. mid = len / 2 - 1;

  16. while (search.getNext() != null) {

  17. if (mid == index) {

  18. result.add(search.getValue());

  19. result.add(search.getNext().getValue());

  20. }

  21. search = search.getNext();

  22. index++;

  23. }

  24. } else {

  25. mid = (len - 1) / 2;

  26. while (search.getNext() != null) {

  27. if (mid == index) {

  28. result.add(search.getValue());

  29. }

  30. search = search.getNext();

  31. index++;

  32. }

  33. }

  34. return result;

  35. }

寫個main方法試驗一下:


  1. public static void main(String[] args) throws Exception {

  2. Node<String> head = buildLinkedList(11);

  3. System.out.println(searchByTraversal(head));

  4. head = buildLinkedList(12);

  5. System.out.println(searchByTraversal(head));

  6. }

  7. // 輸出結果

  8. [n6]

  9. [n6, n7]

假設連結串列的長度為nn > 0),那麼進行兩次遍歷一共需要遍歷的元素個數如下:

  • 第一次遍歷整個連結串列,計算長度,必須遍歷n個元素。

  • 第二次需要遍歷n/2個元素(在n值比較大的時候,其實加減的影響不大)。

這種方案實現,最終的時間複雜度一定會大於O(n)。所以需要考慮優化方案,只需要遍歷一次連結串列就能定位到中間的節點值,這個就是方案二:「快慢指標」

「快慢指標」,簡單來說就是定義兩個指標,在遍歷連結串列的時候,快指標(Fast Pointer)總是遍歷兩個元素,而慢指標(Slow Pointer)總是遍歷一個元素。當快指標完成遍歷整個連結串列的時候,慢指標剛好指向連結串列的中間節點。演算法實現如下:


  1. /**

  2. * 基於快慢指標搜尋

  3. */

  4. private static List<String> searchByFastSlowPointer(Node<String> head) {

  5. List<String> result = new ArrayList<>();

  6. // fast pointer

  7. Node<String> fp = head;

  8. // slow pointer

  9. Node<String> sp = head;

  10. int len = 1;

  11. while (null != fp.getNext()) {

  12. if (fp.getNext().getNext() != null) {

  13. fp = fp.getNext().getNext();

  14. sp = sp.getNext();

  15. len += 2;

  16. } else {

  17. fp = fp.getNext();

  18. len += 1;

  19. }

  20. }

  21. // 連結串列長度為偶數

  22. if ((len & 1) == 0) {

  23. result.add(sp.getValue());

  24. result.add(sp.getNext().getValue());

  25. } else {

  26. result.add(sp.getValue());

  27. }

  28. return result;

  29. }

寫個main方法試驗一下:


  1. public static void main(String[] args) throws Exception {

  2. Node<String> head = buildLinkedList(11);

  3. System.out.println(searchByFastSlowPointer(head));

  4. head = buildLinkedList(12);

  5. System.out.println(searchByFastSlowPointer(head));

  6. }

  7. // 輸出結果

  8. [n6]

  9. [n6, n7]

由於使用了快慢指標的方案,只做了一次連結串列的遍歷,並且由於快指標是每次兩個元素進行遍歷,最終的時間複雜度要小於O(n)

快慢指標的應用場景

快慢指標主要有如下的應用場景:

  1. 找到連結串列的中點。

  2. 判斷連結串列中是否存在環。

  3. 刪除連結串列中倒數第x個節點。

第一種情況已經作為覆盤案例分析過,下面分析一下第二和第三種場景。

判斷連結串列中是否存在環

假設連結串列有6個節點(head節點為n1,tail節點為n6),已經形成環(n6的下一個節點為n1):

j-a-l-f-l-3.png

使用快慢指標,快指標每次遍歷會比慢指標多一個元素,這樣子的話,如果連結串列已經成環,無論快指標和慢指標之間相隔多少個節點,快指標總是能夠追上慢指標(快指標和慢指標指向同一個節點),這個時候就可以判斷連結串列已經成環;否則快指標進行一輪遍歷之後就會跳出迴圈,永遠不可能和慢指標"重合"。簡陋的實現如下:


  1. // 判斷連結串列是否存在環

  2. private static boolean cyclic(Node<String> head) {

  3. // fast pointer

  4. Node<String> fp = head;

  5. // slow pointer

  6. Node<String> sp = head;

  7. while (fp.getNext() != null) {

  8. fp = fp.getNext().getNext();

  9. sp = sp.getNext();

  10. if (sp.equals(fp)) {

  11. return true;

  12. }

  13. }

  14. return false;

  15. }

  16. // 生成環形連結串列

  17. private static Node<String> buildCyclicLinkedList(int len) {

  18. Node<String> head = new Node<>();

  19. head.setValue("n1");

  20. Node<String> tail = head;

  21. for (int i = 1; i < len; i++) {

  22. Node<String> node = new Node<>();

  23. node.setValue("n" + (i + 1));

  24. tail.setNext(node);

  25. tail = node;

  26. }

  27. tail.setNext(head);

  28. return head;

  29. }

測試一下:


  1. public static void main(String[] args) throws Exception {

  2. Node<String> head = buildCyclicLinkedList(11);

  3. System.out.println(cyclic(head));

  4. head = buildLinkedList(11);

  5. System.out.println(cyclic(head));

  6. }

  7. // 輸出結果

  8. true

  9. false

刪除連結串列中倒數第N個節點

這個是LeetCode上的一道演算法題,裡面用到的是虛擬頭結點加上快慢指標的方法,只進行一次遍歷就能解決。這裡引用獲贊最多的回答裡面的解決思路:

上述演算法可以優化為只使用一次遍歷。我們可以使用兩個指標而不是一個指標。第一個指標從列表的開頭向前移動n+1步,而第二個指標將從列表的開頭出發。現在,這兩個指標被n個結點分開。我們通過同時移動兩個指標向前來保持這個恆定的間隔,直到第一個指標到達最後一個結點。此時第二個指標將指向從最後一個結點數起的第n個結點。我們重新連結第二個指標所引用的結點的next指標指向該結點的下下個結點。

演算法推演圖:

j-a-l-f-l-4.png

演算法程式碼如下:


  1. public ListNode removeNthFromEnd(ListNode head, int n) {

  2. ListNode dummy = new ListNode(0);

  3. dummy.next = head;

  4. ListNode first = dummy;

  5. ListNode second = dummy;

  6. // Advances first pointer so that the gap between first and second is n nodes apart

  7. for (int i = 1; i <= n + 1; i++) {

  8. first = first.next;

  9. }

  10. // Move first to the end, maintaining the gap

  11. while (first != null) {

  12. first = first.next;

  13. second = second.next;

  14. }

  15. second.next = second.next.next;

  16. return dummy.next;

  17. }

時間複雜度為O(L)L為連結串列長度。

小結

鑑於演算法比較弱,看到這些相對有實用價值的題目和解決方案,還是值得推演和學習一番。