我把雙指標技巧再分為兩類,一類是「快慢指標」,一類是「左右指標」。前者解決主要解決連結串列中的問題,比如典型的判定連結串列中是否包含環;後者主要解決陣列(或者字串)中的問題,比如二分查詢。
一、快慢指標的常見演算法
快慢指標一般都初始化指向連結串列的頭結點 head,前進時快指標 fast 在前,慢指標 slow 在後,巧妙解決一些連結串列中的問題。
1、判定連結串列中是否含有環
這應該屬於連結串列最基本的操作了,如果讀者已經知道這個技巧,可以跳過。
單連結串列的特點是每個節點只知道下一個節點,所以一個指標的話無法判斷連結串列中是否含有環的。
如果連結串列中不含環,那麼這個指標最終會遇到空指標 null 表示連結串列到頭了,這還好說,可以判斷該連結串列不含環。
boolean hasCycle(ListNode head) {
while (head != null)
head = head.next;
return false;
}
但是如果連結串列中含有環,那麼這個指標就會陷入死迴圈,因為環形陣列中沒有 null 指標作為尾部節點。
經典解法就是用兩個指標,一個跑得快,一個跑得慢。如果不含有環,跑得快的那個指標最終會遇到 null,說明連結串列不含環;如果含有環,快指標最終會超慢指標一圈,和慢指標相遇,說明連結串列含有環。
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、已知連結串列中含有環,返回這個環的起始位置
這個問題一點都不困難,有點類似腦筋急轉彎,先直接看程式碼:
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 函式
slow = head;
while (slow != fast) {
fast = fast.next;
slow = slow.next;
}
return slow;
}
可以看到,當快慢指標相遇時,讓其中任一個指標指向頭節點,然後讓它倆以相同速度前進,再次相遇時所在的節點位置就是環開始的位置。這是為什麼呢?
第一次相遇時,假設慢指標 slow 走了 k 步,那麼快指標 fast 一定走了 2k 步,也就是說比 slow 多走了 k 步(也就是環的長度)。
設相遇點距環的起點的距離為 m,那麼環的起點距頭結點 head 的距離為 k - m,也就是說如果從 head 前進 k - m 步就能到達環起點。
巧的是,如果從相遇點繼續前進 k - m 步,也恰好到達環起點。
所以,只要我們把快慢指標中的任一個重新指向 head,然後兩個指標同速前進,k - m 步後就會相遇,相遇之處就是環的起點了。
3、尋找連結串列的中點
類似上面的思路,我們還可以讓快指標一次前進兩步,慢指標一次前進一步,當快指標到達連結串列盡頭時,慢指標就處於連結串列的中間位置。
while (fast != null && fast.next != null) {
fast = fast.next.next;
slow = slow.next;
}
// slow 就在中間位置
return slow;
當連結串列的長度是奇數時,slow 恰巧停在中點位置;如果長度是偶數,slow 最終的位置是中間偏右:
尋找連結串列中點的一個重要作用是對連結串列進行歸併排序。
回想陣列的歸併排序:求中點索引遞迴地把陣列二分,最後合併兩個有序陣列。對於連結串列,合併兩個有序連結串列是很簡單的,難點就在於二分。
但是現在你學會了找到連結串列的中點,就能實現連結串列的二分了。關於歸併排序的具體內容本文就不具體展開了。
4、尋找連結串列的倒數第 k 個元素
我們的思路還是使用快慢指標,讓快指標先走 k 步,然後快慢指標開始同速前進。這樣當快指標走到連結串列末尾 null 時,慢指標所在的位置就是倒數第 k 個連結串列節點(為了簡化,假設 k 不會超過連結串列長度):
ListNode slow, fast;
slow = fast = head;
while (k-- > 0)
fast = fast.next;
while (fast != null) {
slow = slow.next;
fast = fast.next;
}
return slow;
二、左右指標的常用演算法
左右指標在陣列中實際是指兩個索引值,一般初始化為 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、兩數之和
直接看一道 LeetCode 題目(經典Two Sum)吧:
只要陣列有序,就應該想到雙指標技巧。這道題的解法有點類似二分查詢,通過調節 left 和 right 可以調整 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、反轉陣列
void reverse(int[] nums) {
int left = 0;
int right = nums.length - 1;
while (left < right) {
// swap(nums[left], nums[right])
int temp = nums[left];
nums[left] = nums[right];Java
nums[right] = temp;
left++; right--;
}
}
4、滑動視窗演算法
這也許是雙指標技巧的最高境界了,如果掌握了此演算法,可以解決一大類子字串匹配的問題,不過「滑動視窗」稍微比上述的這些演算法複雜些。
詳情見下文(來自東哥的演算法講解的思路)
三、滑動視窗技巧
滑動視窗演算法框架中,這裡轉自一首小詩來介紹。
本文就解決一類最難掌握的雙指標技巧:滑動視窗技巧。總結出一套框架,可以保你閉著眼睛都能寫出正確的解法。
說起滑動視窗演算法,很多讀者都會頭疼。這個演算法技巧的思路非常簡單,就是維護一個視窗,不斷滑動,然後更新答案麼。LeetCode 上有起碼 10 道運用滑動視窗演算法的題目,難度都是中等和困難。該演算法的大致邏輯如下:
int left = 0, right = 0;
while (right < s.size()) {`
// 增大視窗
window.add(s[right]);
right++;
while (window needs shrink) {
// 縮小視窗
window.remove(s[left]);
left++;
}
}
這個演算法技巧的時間複雜度是 O(N),比字串暴力演算法要高效得多。
其實困擾大家的,不是演算法的思路,而是各種細節問題。比如說如何向視窗中新增新元素,如何縮小視窗,在視窗滑動的哪個階段更新結果。即便你明白了這些細節,也容易出 bug,找 bug 還不知道怎麼找,真的挺讓人心煩的。
所以今天我就寫一套滑動視窗演算法的程式碼框架,我連再哪裡做輸出 debug 都給你寫好了,以後遇到相關的問題,你就默寫出來如下框架然後改三個地方就行,還不會出 bug:
/* 滑動視窗演算法框架 */
void slidingWindow(string s, string t) {
unordered_map<char, int> need, window;
for (char c : t) need[c]++;
int left = 0, right = 0;
int valid = 0;
while (right < s.size()) {
// c 是將移入視窗的字元
char c = s[right];
// 右移視窗
right++;
// 進行視窗內資料的一系列更新
...
/*** debug 輸出的位置 ***/
printf("window: [%d, %d)\n", left, right);
/********************/
// 判斷左側視窗是否要收縮
while (window needs shrink) {
// d 是將移出視窗的字元
char d = s[left];
// 左移視窗
left++;
// 進行視窗內資料的一系列更新
...
}
}
}
其中兩處 ...
表示的更新視窗資料的地方,到時候你直接往裡面填就行了。
而且,這兩個 ...
處的操作分別是右移和左移視窗更新操作,等會你會發現它們操作是完全對稱的。
說句題外話,我發現很多人喜歡執著於表象,不喜歡探求問題的本質。比如說有很多人評論我這個框架,說什麼雜湊表速度慢,不如用陣列代替雜湊表;還有很多人喜歡把程式碼寫得特別短小,說我這樣程式碼太多餘,影響編譯速度,LeetCode 上速度不夠快。
我服了。演算法看的是時間複雜度,你能確保自己的時間複雜度最優,就行了。至於 LeetCode 所謂的執行速度,那個都是玄學,只要不是慢的離譜就沒啥問題,根本不值得你從編譯層面優化,不要捨本逐末……