雙指標演算法基本原理和實踐
什麼是雙指標
雙指標,指的是在遍歷物件的過程中,不是普通的使用單個指標進行訪問,而是使用兩個相同方向(快慢指標)或者相反方向(對撞指標)的指標進行掃描,從而達到相應的目的。
換言之,雙指標法充分使用了陣列有序這一特徵,從而在某些情況下能夠簡化一些運算。
在LeetCode
題庫中,關於雙指標的問題還是挺多的。雙指標
截圖來之 LeetCode 中文官網
對撞指標
對撞指標是指在陣列中,將指向最左側的索引定義為左指標(left)
,最右側的定義為右指標(right)
,然後從兩頭向中間進行陣列遍歷。
對撞陣列適用於連續陣列和字串,也就是說當你遇到題目給定連續陣列和字元床時,應該第一時間想到用對撞指標解題。
虛擬碼大致如下:
public void find (int[] list) { var left = 0; var right = list.length - 1; //遍歷陣列 while (left <= right) { left++; // 一些條件判斷 和處理 ... ... right--; } }
演算法例項
344. 反轉字串
編寫一個函式,其作用是將輸入的字串反轉過來。輸入字串以字元陣列 char[] 的形式給出。
不要給另外的陣列分配額外的空間,你必須原地修改輸入陣列、使用 O(1) 的額外空間解決這一問題。
你可以假設陣列中的所有字元都是 ASCII 碼錶中的可列印字元。
示例 1:
輸入:["h","e","l","l","o"]
輸出:["o","l","l","e","h"]
示例 2:
輸入:["H","a","n","n","a","h"]
輸出:["h","a","n","n","a","H"]
解答
可以套用前面的虛擬碼:
class Solution { public void reverseString(char[] s) { if (s.length == 0 || s.length == 1) return ; int left = 0; int right = s.length-1;while (left <right) { char temp = s[left]; s[left++] = s[right]; s[right--] = temp; } return ; } }
209. 長度最小的子陣列
給定一個含有n個正整數的陣列和一個正整數s ,找出該陣列中滿足其和 ≥ s 的長度最小的 連續 子陣列,並返回其長度。如果不存在符合條件的子陣列,返回 0。
示例:
輸入:s = 7, nums = [2,3,1,2,4,3] 輸出:2 解釋:子陣列 [4,3] 是該條件下的長度最小的子陣列。
解答
class Solution { public int minSubArrayLen(int s, int[] nums) { int right =0; int left=0; int sum =0; int len =Integer.MAX_VALUE; while(right < nums.length) { sum+=nums[right]; while (sum >=s) { len = Math.min(right -left+1,len); sum -= nums[left]; left++; } right++; } if (len == Integer.MAX_VALUE) return 0; return len; } }
雖然這道題目也是用的雙指標,但是實際上採用滑動視窗的演算法思想,具體可以看文章:滑動視窗演算法基本原理與實踐。
快慢指標
快慢指標也是雙指標,但是兩個指標從同一側開始遍歷陣列,將這兩個指標分別定義為快指標(fast)
和慢指標(slow)
,兩個指標以不同的策略移動,直到兩個指標的值相等(或其他特殊條件)為止,如 fast 每次增長兩個,slow 每次增長一個。
以LeetCode 141.環形連結串列為例,,判斷給定連結串列中是否存在環,可以定義快慢兩個指標,快指標每次增長一個,而慢指標每次增長兩個,最後兩個指標指向節點的值相等,則說明有環。就好像一個環形跑道上有一快一慢兩個運動員賽跑,如果時間足夠長,跑地快的運動員一定會趕上慢的運動員。
演算法示例
快慢指標一般都初始化指向連結串列的頭結點 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; } slow = head; while (slow != fast) { fast = fast.next; slow = slow.next; } return slow; }
可以看到,當快慢指標相遇時,讓其中任一個指標重新指向頭節點,然後讓它倆以相同速度前進,再次相遇時所在的節點位置就是環開始的位置。
3、尋找連結串列的中點
類似上面的思路,我們還可以讓快指標一次前進兩步,慢指標一次前進一步,當快指標到達連結串列盡頭時,慢指標就處於連結串列的中間位置。
ListNode slow, fast; slow = fast = head; 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;
滑動視窗演算法
這也許是雙指標技巧的最高境界了,如果掌握了此演算法,可以解決一大類子字串匹配的問題,不過「滑動視窗」演算法比上述的這些演算法稍微複雜些。
具體原理和實踐可以詳見文章:滑動視窗演算法基本原理與實踐
參考文章: