1. 程式人生 > 其它 >雙指標技巧總結

雙指標技巧總結

https://labuladong.gitee.io/algo/2/21/53/

讀完本文,你不僅學會了演算法套路,還可以順便去 LeetCode 上拿下如下題目:

141.環形連結串列(簡單)

142.環形連結串列II(簡單)

167.兩數之和 II - 輸入有序陣列(中等)

344.反轉字串(簡單)

19.刪除連結串列倒數第 N 個元素(中等)

876. 連結串列的中間結點

———–

我把雙指標技巧再分為兩類,一類是「快慢指標」,一類是「左右指標」。前者解決主要解決連結串列中的問題,比如典型的判定連結串列中是否包含環;後者主要解決陣列(或者字串)中的問題,比如二分查詢。

一、快慢指標的常見演算法

快慢指標一般都初始化指向連結串列的頭結點head

,前進時快指標fast在前,慢指標slow在後,巧妙解決一些連結串列中的問題。

1、判定連結串列中是否含有環

這屬於連結串列最基本的操作了,學習資料結構應該對這個演算法思想都不陌生。

單鏈表的特點是每個節點只知道下一個節點,所以一個指標的話無法判斷連結串列中是否含有環的。

如果連結串列中不含環,那麼這個指標最終會遇到空指標null表示連結串列到頭了,這還好說,可以判斷該連結串列不含環:

boolean hasCycle(ListNode head) {
    while (head != null)
        head = head.next;
    return false;
}

但是如果連結串列中含有環,那麼這個指標就會陷入死迴圈,因為環形陣列中沒有null指標作為尾部節點。

經典解法就是用兩個指標,一個跑得快,一個跑得慢。如果不含有環,跑得快的那個指標最終會遇到null,說明連結串列不含環;如果含有環,快指標最終會超慢指標一圈,和慢指標相遇,說明連結串列含有環。

力扣第 141 題就是這個問題,解法程式碼如下:

boolean hasCycle(ListNode head) {
    ListNode fast, slow;
    fast = slow = head;
    while (fast != null && fast.next != null) {
        fast = fast.next.next;
        slow = slow.next;
        
        if (fast == slow) return true;
    }
    return false;
}

2、已知連結串列中含有環,返回這個環的起始位置

這是力扣第 142 題,其實一點都不困難,有點類似腦筋急轉彎,先直接看程式碼:

ListNode detectCycle(ListNode head) {
    ListNode fast, slow;
    fast = slow = head;
    while (fast != null && fast.next != null) {
        fast = fast.next.next;
        slow = slow.next;
        if (fast == slow) break;
    }
    // 上面的程式碼類似 hasCycle 函式
    if (fast == null || fast.next == null) {
        // fast 遇到空指標說明沒有環
        return null;
    }

    slow = head;
    while (slow != fast) {
        fast = fast.next;
        slow = slow.next;
    }
    return slow;
}

可以看到,當快慢指標相遇時,讓其中任一個指標指向頭節點,然後讓它倆以相同速度前進,再次相遇時所在的節點位置就是環開始的位置。這是為什麼呢?

第一次相遇時,假設慢指標slow走了k步,那麼快指標fast一定走了2k步:

fast一定比slow多走了k步,這多走的k步其實就是fast指標在環裡轉圈圈,所以k的值就是環長度的「整數倍」。

說句題外話,之前還有讀者爭論為什麼是環長度整數倍,我舉個簡單的例子你就明白了,我們想一想極端情況,假設環長度就是 1,如下圖:

那麼fast肯定早早就進環裡轉圈圈了,而且肯定會轉好多圈,這不就是環長度的整數倍嘛。

言歸正傳,設相遇點距環的起點的距離為m,那麼環的起點距頭結點head的距離為k - m,也就是說如果從head前進k - m步就能到達環起點。

巧的是,如果從相遇點繼續前進k - m步,也恰好到達環起點。你甭管fast在環裡到底轉了幾圈,反正走k步可以到相遇點,那走k - m步一定就是走到環起點了:

所以,只要我們把快慢指標中的任一個重新指向head,然後兩個指標同速前進,k - m步後就會相遇,相遇之處就是環的起點了。

3、尋找連結串列的中點

類似上面的思路,我們還可以讓快指標一次前進兩步,慢指標一次前進一步,當快指標到達連結串列盡頭時,慢指標就處於連結串列的中間位置。

力扣第 876 題就是找連結串列中點的題目,解法程式碼如下:

ListNode middleNode(ListNode head) {
    ListNode fast, slow;
    fast = slow = head;
    while (fast != null && fast.next != null) {
        fast = fast.next.next;
        slow = slow.next;
    }
    // slow 就在中間位置
    return slow;
}

當連結串列的長度是奇數時,slow恰巧停在中點位置;如果長度是偶數,slow最終的位置是中間偏右:

尋找連結串列中點的一個重要作用是對連結串列進行歸併排序。

回想陣列的歸併排序:求中點索引遞迴地把陣列二分,最後合併兩個有序陣列。對於連結串列,合併兩個有序連結串列是很簡單的,難點就在於二分。

但是現在你學會了找到連結串列的中點,就能實現連結串列的二分了。關於歸併排序的具體內容本文就不具體展開了。

4、尋找連結串列的倒數第n個元素

這是力扣第 19 題「刪除連結串列的倒數第n個元素」,先看下題目:

我們的思路還是使用快慢指標,讓快指標先走n步,然後快慢指標開始同速前進。這樣當快指標走到連結串列末尾null時,慢指標所在的位置就是倒數第n個連結串列節點(n不會超過連結串列長度)。

解法比較簡單,直接看程式碼吧:

ListNode removeNthFromEnd(ListNode head, int n) {
    ListNode fast, slow;
    fast = slow = head;
    // 快指標先前進 n 步
    while (n-- > 0) {
        fast = fast.next;
    }
    if (fast == null) {
        // 如果此時快指標走到頭了,
        // 說明倒數第 n 個節點就是第一個節點
        return head.next;
    }
    // 讓慢指標和快指標同步向前
    while (fast != null && fast.next != null) {
        fast = fast.next;
        slow = slow.next;
    }
    // slow.next 就是倒數第 n 個節點,刪除它
    slow.next = slow.next.next;
    return head;
}

二、左右指標的常用演算法

左右指標在陣列中實際是指兩個索引值,一般初始化為left = 0, right = nums.length - 1

1、二分查詢

前文二分查詢框架詳解有詳細講解,這裡只寫最簡單的二分演算法,旨在突出它的雙指標特性:

int binarySearch(int[] nums, int target) {
    int left = 0; 
    int right = nums.length - 1;
    while(left <= right) {
        int mid = (right + left) / 2;
        if(nums[mid] == target)
            return mid; 
        else if (nums[mid] < target)
            left = mid + 1; 
        else if (nums[mid] > target)
            right = mid - 1;
    }
    return -1;
}

2、兩數之和

直接看力扣第 167 題「兩數之和 II」吧:

只要陣列有序,就應該想到雙指標技巧。這道題的解法有點類似二分查詢,通過調節leftright可以調整sum的大小:

int[] twoSum(int[] nums, int target) {
    int left = 0, right = nums.length - 1;
    while (left < right) {
        int sum = nums[left] + nums[right];
        if (sum == target) {
            // 題目要求的索引是從 1 開始的
            return new int[]{left + 1, right + 1};
        } else if (sum < target) {
            left++; // 讓 sum 大一點
        } else if (sum > target) {
            right--; // 讓 sum 小一點
        }
    }
    return new int[]{-1, -1};
}

3、反轉陣列

一般程式語言都會提供reverse函式,其實非常簡單,力扣第 344 題是類似的需求,讓你反轉一個char[]型別的字元陣列,我們直接看程式碼吧:

void reverseString(char[] arr) {
    int left = 0;
    int right = arr.length - 1;
    while (left < right) {
        // 交換 arr[left] 和 arr[right]
        char temp = arr[left];
        arr[left] = arr[right];
        arr[right] = temp;
        left++; right--;
    }
}

4、滑動視窗演算法

這也許是雙指標技巧的最高境界了,如果掌握了此演算法,可以解決一大類子字串匹配的問題,不過「滑動視窗」稍微比上述的這些演算法複雜些。

不過這類演算法是有框架模板的,而且前文我寫了首詩,把滑動視窗演算法變成了默寫題就講解了「滑動視窗」演算法模板,幫大家秒殺幾道子串匹配的問題,如果沒有看過,建議去看看。

_____________