演算法圖鑑
Searching & Two Pointers · 04 / 05

Two Pointers雙指標

對撞指標與同向指標

用在:有序陣列配對、去重、回文判斷

時間複雜度O(n)
空間複雜度O(1)
難度進階
前置知識Array

01為什麼需要它

有序名單裡找一對加起來剛好的

一份依金額排序的交易紀錄,要找兩筆加起來等於某個對帳金額。暴力是每一筆配每一筆,n(n−1)/2 對,O(n²)。

為什麼用它資料有序就有結構可以用:最小加最大太小,表示最小配誰都不夠,直接淘汰;太大就淘汰最大。一左一右往中間夾,每步淘汰一個,最多 n − 1 步就結束。這是有序配對的標準解法。

原地整理:去重、搬移零、過濾

一個排好序的陣列裡有重複,要把重複去掉,而且不能開新陣列(記憶體受限或介面要求原地)。

為什麼用它一個指標往前讀,一個指標記「寫到哪了」。讀指標永遠不慢於寫指標,所以覆寫不會弄壞還沒讀的資料。這是同向雙指標,O(n) 時間、O(1) 額外空間。

回文判斷、合併兩份有序清單

判斷一個字串正著讀反著讀一樣;或把兩份各自有序的清單合成一份有序的。

為什麼用它回文是從兩端往中間比,合併是兩個指標各在一份清單上往前走。它們都是「用兩個位置的關係推進」,不需要巢狀迴圈。

看到這些關鍵字就想到它:已排序、配對、兩端往中間、原地修改、O(n²) 的雙迴圈裡兩個索引有單調關係。

02核心概念

雙指標是一種把兩層迴圈壓成一層的技巧:兩個索引在陣列上移動,但每一步都只往一個方向走,總移動距離加起來不超過 2n,所以是 O(n)。暴力雙迴圈之所以 O(n²),是因為外層每換一個位置,內層就重新掃一整段;雙指標能省下來,是因為問題有某種單調性讓「回頭」變得沒必要。

對撞指標:一個從最左、一個從最右往中間走。以有序陣列兩數之和為例,a[l] + a[r] 太小時,a[r] 已是剩下最大的,a[l] 無論配剩下的誰都不夠(和已淘汰元素的配對早就排除了),可以安全丟掉;太大時同理丟掉 a[r]。每一步都淘汰一個元素,且淘汰是有證明的,這才是它正確的原因,不是「看起來合理」。前提是資料有序,無序資料要先排序,或改用雜湊表。注意排序會打亂原本的索引,題目要回傳原索引(例如 LeetCode 1)時,要連索引一起排,或直接用雜湊表。

同向指標(快慢指標):兩個指標都往右,一個讀一個寫,或一個探路一個跟隨。移除重複時,w 是下一個寫入位置,r 往前讀,a[r] 和上一個保留值不同就寫進 a[w]。因為 w ≤ r 恆成立,覆寫的永遠是已經讀過的格子。搬移零、過濾、壓縮字串,都是同一個骨架。

和滑動視窗的關係:滑動視窗就是同向雙指標,兩個指標之間的區間有特別意義(視窗)。和二分搜尋的關係:兩者都靠有序性,二分一次砍一半找一個位置,雙指標一次淘汰一個找一對。三數之和是把一層迴圈固定住,裡面跑對撞指標,O(n²) 取代 O(n³)。

03演算法步驟

  1. 1對撞指標先問:資料有序嗎?無序而題目允許排序就先排(O(n log n)),不允許就考慮雜湊表。同向指標的搬移零、過濾這類題目不需要有序。
  2. 2對撞:l = 0r = n − 1while l < r。比較 a[l] + a[r] 和目標。
  3. 3太小 l += 1,太大 r −= 1,相等就是答案。每步問自己:被淘汰的那個,為什麼配任何人都不行?
  4. 4同向:w = 0(或 1),for r in range(n)a[r] 該保留就 a[w] = a[r]; w += 1
  5. 5結束時對撞指標回傳找到的一對或「沒有」,同向指標回傳 wa[:w] 是結果。

04互動示範

「對撞」模式在有序陣列找兩數之和 25,劃掉的格子是被證明不可能的。「同向」模式原地移除重複,綠色是已寫好的結果、黃色是正在讀的位置,注意 w 永遠不超過 r。

目標 25 · 已排序
有序陣列
20
l
31
52
83
114
145
176
217
r
l = 0,r = 7a[l] + a[r] = 23目標 25

劃掉的格子已被證明不可能是答案的一部分,之後不會再看。

步驟 0/5陣列已排序,要找兩數相加等於 25。左指標 l 放最小值,右指標 r 放最大值。

05程式碼

對撞指標的兩數之和、同向指標的移除重複,以及固定一個數再對撞的三數之和。三數之和的去重是最容易寫錯的地方,看清楚兩處跳過重複的位置。

# 對撞指標:有序陣列兩數之和,回傳 0-based 索引
# (LeetCode 167 要的是 1-based,交出去前兩個各加 1)
def two_sum_sorted(nums, target):
    l, r = 0, len(nums) - 1
    while l < r:
        s = nums[l] + nums[r]
        if s == target:
            return [l, r]
        if s < target:
            l += 1                         # nums[l] 配最大的都不夠,淘汰它
        else:
            r -= 1                         # nums[r] 配最小的都太大,淘汰它
    return [-1, -1]


# 同向指標:原地移除有序陣列的重複(LeetCode 26),回傳新長度
# w 是「下一個要寫的位置」,r 負責讀
def remove_duplicates(nums):
    if not nums:
        return 0
    w = 1
    for r in range(1, len(nums)):
        if nums[r] != nums[w - 1]:         # 和上一個保留的不同才是新值
            nums[w] = nums[r]
            w += 1
    return w                               # nums[:w] 是結果


# 對撞指標的另一個經典:三數之和(LeetCode 15)
# 排序後固定一個數,剩下的用兩數之和夾
def three_sum(nums):
    nums.sort()
    out = []
    for i in range(len(nums) - 2):
        if i > 0 and nums[i] == nums[i - 1]:
            continue                       # 跳過重複的第一個數
        l, r = i + 1, len(nums) - 1
        while l < r:
            s = nums[i] + nums[l] + nums[r]
            if s < 0:
                l += 1
            elif s > 0:
                r -= 1
            else:
                out.append([nums[i], nums[l], nums[r]])
                l += 1
                r -= 1
                while l < r and nums[l] == nums[l - 1]:
                    l += 1                 # 跳過重複的第二個數
    return out


if __name__ == "__main__":
    print(two_sum_sorted([2, 3, 5, 8, 11, 14, 17, 21], 25))   # [3, 6]
    a = [1, 1, 2, 2, 2, 3, 5, 5, 6, 6]
    n = remove_duplicates(a)
    print(a[:n])                                              # [1, 2, 3, 5, 6]
    print(three_sum([-1, 0, 1, 2, -1, -4]))                   # [[-1, -1, 2], [-1, 0, 1]]

06練習題

  • LeetCode 167Two Sum II - Input Array Is SortedMedium
  • LeetCode 26Remove Duplicates from Sorted ArrayEasy
  • LeetCode 283Move Zeroes(同向:讀寫指標)Easy
  • LeetCode 125Valid PalindromeEasy
  • LeetCode 153SumMedium
  • LeetCode 11Container With Most Water(淘汰矮的那邊)Medium