news 2026/10/15 11:47:00

LeetCode 150双指针题全解析:快慢、对撞、滑动窗口三大模式

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
LeetCode 150双指针题全解析:快慢、对撞、滑动窗口三大模式

前陣子有個朋友跟我抱怨,說他在 LeetCode 150 題清單裡刷到「雙指針」這個分類時,本來以為只是 array 兩邊各放一個 index 走來走去,結果一做題才發現根本不是那麼回事。有的題要用「快慢指針」在鏈結串列裡抓環,有的題要用「左右指針」在有序陣列裡逼近目標,還有的題表面上叫滑動視窗,骨子裡也是雙指針。我一聽就笑了,因為這正是絕大多數人卡關的地方:雙指針不是某一道題的技巧,它是一整類「用兩個移動位置來取代多層迴圈」的解題思維。

這篇文章我想好好把 LeetCode 150 題裡雙指針相關題目背後的套路拆開講一遍,包含三種主流模式、每種模式的適用條件、判斷依據、時間複雜度分析,再加上我實際刷題和複試時踩過的坑。不管你是剛開始準備演算法題,還是已經刷了兩三百題但總覺得雙指針題「每題長得都不一樣」,這篇應該都能給你一條比較清楚的脈絡。

1. 雙指針不是一個技巧,而是一類思維模式

老實說,我第一次看到「雙指針」這個分類時也覺得很困惑:這也算一個專題嗎?每個題目不都是兩個 index 走來走去?後來刷多了才明白,雙指針之所以能成為 LeetCode 150 題裡的固定分類,是因為它背後有一套共通的思維方式:用兩個獨立的移動狀態,把暴力解中重複掃描的部分壓縮掉。這不是「兩層 for 迴圈」的簡單替代,而是利用資料的某些性質(有序性、單調性、固定方向性),讓兩個指針各司其職,整體複雜度從 O(n^2) 降到 O(n)。

1.1 雙指針到底在解決什麼問題

你可以把所有雙指針題都想像成一個場景:有一條很長的走廊,走廊的牆上貼著很多數字卡片,你需要找到符合某個條件的「兩張卡片」。最笨的辦法當然是拿第一張卡片,然後把後面每一張都跟它比對一次,接著拿第二張再往後比對……這就是 O(n^2) 的由來。雙指針的做法則是:左手從走廊起點出發,右手從走廊終點出發(或右手先動、左手在後面追),兩個人根據已知條件有策略地移動,把明顯不可能的組合直接跳過。

這裡面最核心的洞察是:很多時候我們不需要重新檢查整個空間。舉例來說,如果陣列是排序過的,左指針指向的數很小,右指針指向的數很大,兩者相加超過目標值時,你不需要讓左指針後退去配對更大的數,你只需要讓右指針往前移動,把加總結果降下來。這種「一個條件決定誰移動」的機制,就是雙指針能省時間的關鍵。

1.2 常見的三種指針組合

以我自己的分類習慣,LeetCode 150 題裡出現的雙指針題可以切成三種模式,幾乎涵蓋了所有題目:

  • 對撞指針(相向雙指針):一個指針在頭、一個在尾,向中間逼近。典型特徵是陣列通常有序(或部分有序),題目要求找兩數和、三數和、容器面積、回文驗證等。
  • 快慢指針(同向雙指針):兩個指針都從起點出發,但速度不同。典型場景是鏈結串列相關問題,比如找環、找中點、找倒數第 K 個節點。
  • 滑動視窗(同向但區間可變):嚴格來說它也是同向雙指針,左右邊界都只往一個方向移動,維持一個「當前合法區間」。典型題是無重複字符的最長子串、最小覆蓋子串、長度最小的子陣列。

這三種模式的分類並不只是為了整理筆記,它直接決定了你解題時的「下一步判斷」。看到有序陣列就先想對撞;看到鏈結串列就先想快慢;看到「子串、子陣列、連續區間」就先想滑動視窗。接下來我會分節把這三種模式講透。

1.3 為什麼不能只用暴力解硬扛

很多人會問:就算暴力解是 O(n^2),LeetCode 的測資通常也就幾千筆,為什麼非要用雙指針?問題就在於測資規模沒那麼善良。以 n = 10^5 做估算,暴力雙層迴圈要做 10^10 次操作,多數評測環境一秒鐘只能跑大約 10^8 次簡單操作,所以暴力解會直接超時。雙指針把兩層迴圈壓成一層,操作數變成 10^5 這個量級,就能在幾毫秒內跑完。

更重要的是,雙指針解法通常能給出可以在面試時口頭證明正確性的方案。面試官要的不是「能跑就好」,而是「你能不能證明為什麼右指針可以這樣移動而不會漏答案」。雙指針題的單調性正好提供了這種證明素材。後面我會在對撞指針的章節裡具體示範怎麼證明。

2. 快慢指針:鏈結串列與隱藏迴圈的偵測器

快慢指針是我覺得最「優雅」的一種雙指針形式,因為它幾乎不需要任何預處理。題目給你一個鏈結串列的 head,既不知道長度,也不知道有沒有環,你可能連能不能遍歷完都不確定。但只要你讓一個指針一次走一步、另一個一次走兩步,很多資訊就會自動浮現。

2.1 為什麼快指針走兩步、慢指針走一步就能抓環

假設鏈結串列裡有環,慢指針進入環之後,快指針已經在環裡轉了不知道幾圈。因為兩者速度差是 1(快指針相對慢指針每步逼近 1 個節點),所以它們必然會相遇。這不是玄學,這是一個簡單的數學事實:在同一個環上,兩個物體速度不同,不管初始距離多遠,每單位時間距離都會縮短 1,總有追上的一刻。

這個性質可以用來解決三類高頻題:

  • 判斷鏈結串列是否有環:快慢指針相遇就代表有環。
  • 找到環的起點:相遇之後,把一個指針放回 head,兩個指針都改為一次走一步,再次相遇的位置就是環入口。
  • 找鏈結串列中點:快指針到終點時,慢指針剛好在中間節點(或前一個中間節點,取決於節點數量奇偶)。

第三種用途很多人會忽略,但它其實是「回文鏈結串列」「排序鏈結串列」等題目的前置步驟。你想判斷一個鏈結串列是不是回文,最自然的做法就是先找中點,再反轉後半段,然後兩邊同步比對。沒有快慢指針,你就得先遍歷一次算長度,再遍歷半次到中點;有了快慢指針,一趟就能定位。

2.2 找環起點的原理:相遇點不是環入口

抓環起點比較容易讓新手困惑,因為直覺上「兩個指針相遇的地方」應該就是環入口,但其實不是。假設從 head 到環入口的距離是 a,環入口到相遇點的距離是 b,相遇點再走 c 就能回到環入口(所以環的周長是 b + c)。慢指針走了 a + b,快指針走了 a + b + k × (b + c),其中 k 是快指針多繞的圈數。因為快指針速度是慢指針的兩倍,所以:

2 × (a + b) = a + b + k × (b + c)
a + b = k × (b + c)
a = k × (b + c) - b

這個式子告訴我們:從 head 出發走 a 步會到環入口;從相遇點出發,走 k × (b + c) - b 步也會到環入口。如果把相遇點的位置想像成「再走 c 步就回到環入口」,那麼從相遇點出發走 a 步,就等價於繞了 k 圈之後再退 b 步,同樣落在環入口。所以解法是:相遇後,一個指針從 head 重新出發,一個留在相遇點,兩者都一次走一步,相遇處就是環入口。

我第一次推導這個式子時覺得很繞,但後來在面試裡被問到「你怎麼證明這個演算法正確」時,才發現把這個式子寫清楚是很大的加分項。面試官通常不會滿足於你寫出代碼,他會追問「為什麼第二次相遇一定在入口」,能答出上面的 etc 推導,基本就把這題吃透了。

2.3 快慢指針的實作細節與邊界條件

實作時最需要注意的是「快指針能不能走兩步」的判斷。很多人會寫成 while (fast.next != null),然後在迴圈內讓 fast = fast.next.next,結果在 fast.next 為 null 時直接拋空指針異常。正確做法通常是用 while (fast != null && fast.next != null),確保 fast 可以安全地走兩步。我自己的習慣是:

def has_cycle(head): slow = fast = head while fast and fast.next: slow = slow.next fast = fast.next.next if slow is fast: return True return False

這段代碼有一個細節:slow 和 fast 都初始化為 head,但第一次進入迴圈時不會馬上判定相等,因為 slow 移動一步、fast 移動兩步之後才比較。如果你在移動前就判斷 slow == fast,那 head 是空串列或只有一個節點時會誤判。另外,判斷「是否有環」用的是slow is fast,這裡在 Python 中是身分比較,比==更安全,因為鏈結串列節點可能沒有定義__eq__,用==可能觸發遞迴比較或值比較,語意不一定對。

鏈結串列找中點的版本也值得記一下:

def middle_node(head): slow = fast = head while fast and fast.next: slow = slow.next fast = fast.next.next return slow

這裡的邊界是:節點數為奇數時,slow 落在正中間;節點數為偶數時,slow 落在靠右側的中間節點。如果你想要靠左側的中間節點,就要把迴圈條件改成while fast.next and fast.next.next,讓 fast 在倒數第二個節點時停住。這個「左中還是右中」的選擇會直接影響後續回文判斷的實作,不要想當然。

3. 對撞指針:有序世界裡最自然的雙人舞

講完鏈結串列,回到最經典的陣列場景。對撞指針適用於一個很明確的訊息:資料有某種單調性,導致我們可以從兩端往中間收斂。最常見的是「排序後的陣列」,因為排序讓 left 指針往右移動時數值只增不減,right 指針往左移動時數值只減不增。這種單調性讓我們可以根據當前結果決定捨棄哪一側的搜尋空間。

3.1 兩數之和類題目:最簡單的對撞模型

LeetCode 150 題裡最典型的對撞題就是「在有序陣列中找兩個數,使它們的和等於 target」。暴力解是雙層迴圈,對撞解則非常簡潔:

def two_sum_sorted(nums, target): left, right = 0, len(nums) - 1 while left < right: total = nums[left] + nums[right] if total == target: return [left, right] elif total < target: left += 1 else: right -= 1 return [-1, -1]

這裡的關鍵是total < target時為什麼一定要移動 left?因為陣列有序,right 已經在目前能提供的最小值位置(再往左只會更小),既然 right 不動時 total 都小於 target,那 left 右移讓 left 變大,才是唯一可能讓 total 變大的方向。同理,total > target時移動 right。這就是「根據有序性刪除一整個方向的可能性」的思維。

這個簡單的兩數之和模型,其實可以推廣到更多場景。三數之和就是在外面多套一層固定迴圈,固定一個數後,內層再用對撞找兩數;四數之和再多套一層。所以與其說要背「三數之和」的模板,不如先把二數之和的對撞邏輯練到反射級。

3.2 三數之和與去重細節:跳過重複是重點

三數之和最煩人的不是找到答案,而是「答案不能包含重複的三元組」。假設陣列是 [-1, 0, 1, 2, -1, -4],排序後變成 [-4, -1, -1, 0, 1, 2]。第一層迴圈固定 -1,內層對撞可能找到 [-1, 0, 1];如果第一層迴圈繼續往下走,又碰到第二個 -1,內層又會找到一次 [-1, 0, 1],這就是重複的來源。

所以需要兩個去重判斷:

  1. 第一層迴圈固定數時,如果當前數和前一個數相同,直接跳過。
  2. 內層找到一組答案後,left 指針要跳過所有與當前值相同的元素,right 同理。

注意第二點的執行時機:找到答案並把 left、right 往內收縮時,必須連續跳過重複值,否則會把相同組合再算一次。很多人寫了三數之和卻卡在重複上,往往就是只做了第一層去重,忘了內層去重。

另外,容器盛水那題(找兩根柱子之間能裝最多水的區域)也是對撞指針的經典應用,但它的移動依據不是「和 target 比較」,而是「誰矮誰移動」。因為容器的面積由較矮的柱子決定,移動較高的柱子不可能讓面積變大,所以每次只能移動矮的那一側。這題不需要排序,但依然依賴單調性:面積在移動過程中要嘛變大要嘛變小,你必須保留可能變大的方向。這跟兩數之和的「捨棄確定不可能的方向」是同一個思維。

3.3 對撞指針的正確性證明:面試時怎麼講

我見過不少程式碼寫得很順但講不出證明的人,面試時會被追問到卡住。對撞指針的證明套路其實可以統一成三步:

  1. 不變量:每一步執行前,所有可能的答案組合都在 [left, right] 這個區間內。因為我們只移動指針,從未把可能包含答案的區間從搜尋範圍中移除。
  2. 單調性:陣列有序,所以 left 右移時 nums[left] 非遞減,right 左移時 nums[right] 非遞增。
  3. 捨棄合理性:當 total < target 時,對任何 left 的右側值 x,x + nums[right] 也都 < target,因為 x <= nums[right]?不對,這裡要換個講法:因為陣列有序,nums[left] 是目前 left 位置能取到的最小值,而 right 不動時,left 與 right 之間所有 pair 的總和都不會比當前大?這裡要小心。更精確的證明是:total < target 時,固定 right,任何 left 右側的位置 j,nums[j] >= nums[left],所以 nums[j] + nums[right] >= 當前 total,但這不代表一定等於 target?其實如果 total < target,則 nums[left] + nums[right] < target。因為 nums 有序,對任意 j > left,nums[j] >= nums[left],所以 nums[j] + nums[right] >= nums[left] + nums[right]。但這個不等式只能說明「大於等於一個小於 target 的數」,並不能推出「大於 target」。我必須小心,不能寫錯證明。正確的捨棄邏輯是:當 total < target 時,要以 left 為左邊界、任意 right 左側的 k(k < right)為右邊界的組合,加總只會更小,因為 nums[k] <= nums[right]。所以這一步能安全捨棄的是「所有以 nums[left] 為左邊界、right 左側元素為右邊界的組合」。但移動 left 之後,未來還可能以新的 left 跟 right 組合,所以我們捨棄的是「當前 left 與所有右側箭頭」的集合。嗯,讓我整理一下,免得自己講錯。

正確證明:在有序陣列中,current = nums[left] + nums[right]。若 current < target,則對任意 k < right,nums[k] <= nums[right],所以 nums[left] + nums[k] <= current < target,故所有以 nums[left] 為左端點、任何右端點小於等於 right 的組合都不可能等於 target。因此 left 可以放心右移,因為所有「包含 nums[left] 的剩餘可能」都已被排除。這是正確的。若 current > target,則對任意 j > left,nums[j] >= nums[left],所以 nums[j] + nums[right] >= current > target,故所有以 nums[right] 為右端點、任何左端點大於等於 left 的組合都不可能等於 target,因此 right 可以左移。這個證明非常漂亮,也是面試官的標準預期。我先前提的「總和不會比當前大」是有問題的,正式寫文章時必須用上面這個版本。

面試時能把這個證明講清楚,等於告訴面試官你不只是背模板,而是真的理解為什麼對撞指針不會漏答案。這比多刷十題都有用。

4. 滑動視窗:另一種偽裝成「雙指針」的雙指針

如果你把 LeetCode 150 題裡雙指針分類的所有題目看一遍,會發現大概有三分之一其實是「滑動視窗」題。它們的代碼長相也是兩個指針,但行為邏輯和對撞指針差很多。滑動視窗的兩個指針都是從左往右移動,右指針負責「擴張」,左指針負責「收縮」,整個過程就像一條毛毛蟲在陣列上爬。

4.1 為什麼滑動視窗本質上也是雙指針

滑動視窗的核心訴求是:在一個陣列或字串中,找一個連續區間,使得區間滿足某個條件。暴力解是枚举所有起點和終點,O(n^2)。但很多條件具有「單調性」:右端點越往右,區間越長,某個指標(如總和、重複次數、不同字符數)只會增加;左端點越往右,區間越短,這些指標只會減少。

有了這個單調性,我們就可以用雙指針維護一個「當前窗口」:右指針一直往右走,把新元素納入窗口;當窗口不再滿足條件時,左指針往右收縮,直到窗口重新合法。整個過程中,左右指針都只往前不後退,每個元素最多被加入窗口一次、移出窗口一次,所以總複雜度是 O(n),而不是 O(n^2)。

判斷一個題目能不能用滑動視窗,我通常看兩個訊號:

  • 題目要求是「連續子陣列」或「子串」,不是任意子序列。
  • 條件對應的指標在窗口擴大/縮小時是單調變化的,而且容易維護(例如字符出現次數、窗口總和)。

4.2 無重複字符的最長子串:用一個計數器陣列就能搞定

無重複字符的最長子串是滑動視窗的入門題,也是面試出現率極高的題。思路是:右指針每次往右擴展一個字符,把該字符出現次數加一;如果出現次數變成 2,說明窗口內有重複字符,這時左指針就往右收縮,把移出的字符次數減一,直到窗口內沒有重複。過程中隨時更新窗口最大長度。

def length_of_longest_substring(s): count = [0] * 128 left = 0 ans = 0 for right, ch in enumerate(s): count[ord(ch)] += 1 while count[ord(ch)] > 1: count[ord(s[left])] -= 1 left += 1 ans = max(ans, right - left + 1) return ans

這裡的while迴圈不是每一輪都一定要執行,只有當新字符造成重複時才收縮。另外,為什麼用ord(ch)當索引?因為 ASCII 字符範圍很小,直接用長度 128 的陣列比用 dict 更快,這是解題時的一個常見優化。如果你處理 Unicode 字符,用 dict 更保險,但在 LeetCode 的字串題裡,ASCII 計數器陣列通常夠用。

4.3 最小覆蓋子串:收縮條件的精準控制

最小覆蓋子串是滑動視窗裡難度較高的題,它要求你在字串 s 中找到一個最短子串,使得該子串包含字串 t 的所有字符(含重複)。這題的滑動視窗邏輯是:右指針不斷擴張,直到窗口「覆蓋」了 t;然後試著收縮左指針,看看能不能在仍然覆蓋的情況下縮小窗口;每次收縮都記錄當前最小長度。

這裡的「覆蓋」不能只靠簡單計數,你需要維護一個needed計數器和一個matched計數器。needed記錄 t 中每個字符還需要多少個,matched記錄已經完全滿足需求的字符種類數。只有當某個字符的needed從正數變成 0,matched才加一;當matched == len(t_unique)時,窗口才是覆蓋狀態。

整體模板大概是:

def min_window(s, t): need = {} for ch in t: need[ch] = need.get(ch, 0) + 1 needed_chars = len(need) matched_chars = 0 left = 0 ans = "" min_len = float("inf") window = {} for right, ch in enumerate(s): window[ch] = window.get(ch, 0) + 1 if ch in need and window[ch] == need[ch]: matched_chars += 1 while matched_chars == needed_chars: if right - left + 1 < min_len: min_len = right - left + 1 ans = s[left:right + 1] left_ch = s[left] window[left_ch] -= 1 if left_ch in need and window[left_ch] < need[left_ch]: matched_chars -= 1 left += 1 return ans

這題最容易出錯的地方是matched_chars的增減時機。如果在窗口已經覆蓋之後,你收縮掉一個字符,而這個字符剛好是 t 需要的,你必須在window[left_ch]小於need[left_ch]的那一刻才把matched_chars減一。如果寫成「先減 window 再判斷 window[left_ch] == need[left_ch] - 1」,會出現 off-by-one 的錯誤。我建議用「先減,再比大小」的順序,判斷window[left_ch] < need[left_ch],這樣比較直觀。

4.4 滑動視窗的變形:最長、最短、子陣列總和

滑動視窗題可以分為兩大類:一類是「最長合法窗口」,一類是「最短合法窗口」。最長類的典型是無重複字符子串、最多 K 個不同字符的最長子串;最短類的典型是長度最小的子陣列、最小覆蓋子串。

這兩類的思維差異在於收縮時機。最長類通常是「窗口非法時收縮」,收縮後窗口又恢復合法,於是我們一直試圖擴大窗口。最短類通常是「窗口已合法時持續收縮」,收縮到非法為止,記錄收縮過程中的最小長度。記住這個差別,看到題目時先判斷「要最長還是最短」,就能決定 while 迴圈裡放的是「非法條件」還是「合法條件」。

關於滑動視窗,我還要補充一個小提醒:有些題目看似可以用滑動視窗,但窗口內部的資料結構需要支援較複雜的統計,例如「窗口內的中位數」或「窗口內出現頻率最高的字符」。這種題目通常需要搭配有序集合或 heap,已經超出單純雙指針的範疇。LeetCode 150 題中的雙指針題大多控制在「計數器陣列 + 雙指針」的範圍,所以練好計數器就夠了。

5. 面對題目時怎麼識別與轉化:LeetCode 150 中的雙指針題型地圖

很多讀者可能已經刷了一些題,但遇到新題時還是不知道該不該用雙指針。這不奇怪,因為雙指針題的包裝非常多變。我自己的習慣是從三個訊號來判斷:資料結構、題目問法、時間複雜度暗示。

5.1 題目特徵快速判斷表

訊號大概率方向舉例特徵
有序陣列 + 找一個目標關係(和、差、面積)對撞指針two sum, three sum, container with most water
鏈結串列 + 找環 / 中點 / 倒數第 K 節點快慢指針linked list cycle, middle of linked list
字串 / 陣列 + 連續區間的最長或最短滑動視窗longest substring, min window substring
資料無序且需要找任意兩元素組合不一定是雙指針,先考慮排序或哈希如果排序後仍然需要 O(n^2),可能要哈希
要對所有子陣列都做統計通常是前綴和 / 動態規劃,不是雙指針subarray sum equals K 用前綴和更直接

這張表不是絕對的,但能幫你在讀題三十秒內建立第一假設。建立假設之後,先問自己:如果把暴力解寫出來,多出來的迴圈是不是「重複掃描了一段不可能有答案的區域」?如果是,雙指針就有機會。

5.2 雙指針跟二分搜尋、動態規劃的界線

很多人會把二分搜尋和雙指針搞混,因為兩者都依賴有序性。二分搜尋本質上是「每次砍掉一半搜尋空間」,它只有一個搜尋邊界在移動,而且移動依據是「目標值與中點值的比較」。雙指針則同時操作兩個邊界,移動依據是「當前左右端組合的某種性質」。兩數之和用對撞指針,也可以在固定一個數後對另一半做二分搜尋,複雜度 O(n log n),比雙指針慢但也是合法解。面試時如果被追問「有沒有更快的做法」,你就可以說「因為有序性讓指針可以單調移動,所以能到 O(n)」。

動態規劃則通常用在「子序列」或「子區間最優化」問題上,比如最長回文子序列、編輯距離。雙指針能處理的是「只需要兩個端點或一個連續區間的資訊」的問題。如果狀態轉移需要依賴多個先前子問題的結果,那就是 DP 的領域,不要硬用雙指針。

5.3 建議的刷題順序

如果你照著 LeetCode 150 題的雙指針分類刷,我建議的順序是:

  1. 先刷 3 到 5 道對撞指針題,建立「有序性 + 指針移動 = 捨棄不可能區域」的直覺。
  2. 練 3 道快慢指針題,集中在鏈結串列,練完就掌握鏈結串列的兩個基本操作:找環、找中點。
  3. 刷 4 到 5 道滑動視窗題,先做無重複字符子串,再做最小覆蓋子串,體會「擴張與收縮」的不同目的。
  4. 最後做幾道混合題,例如回文鏈結串列(快慢 + 反轉 + 雙指針比對)、三數之和(固定 + 對撞),檢驗自己能不能把多種技巧串起來。

如果時間有限,我個人認為優先級是:對撞指針 > 滑動視窗 > 快慢指針。因為對撞指針題出現頻率最高,滑動視窗的面試變體最多,快慢指針則比較依賴鏈結串列這個特定資料結構。

6. 我實際踩過的坑,以及刷題面試的心得

到這裡,雙指針的三種模式和識別方法都講完了。接下來我想分享一些比較「私密」的經驗,包括實際寫代碼時容易犯的錯、面試口述時怎麼組織思路,以及刷題到後期的心態調整。這些不是 LeetCode 題解裡會寫的東西,但往往是決定你能不能現場寫對的關鍵。

6.1 Off-by-One 與循環邊界:最常見的隱形殺手

雙指針題的 off-by-one 錯誤幾乎都出在兩處:指針初始化和循環結束條件。以對撞指針為例,用while left < right還是while left <= right,結果差很多。找兩數之和時,兩個指針不能指向同一個元素,所以用<;驗證回文時,左指針和右指針指向同一個字符時是可以接受的,所以用<=或直接讓left <= right時停止。

我自己的除錯習慣是:每次寫完雙指針迴圈,立刻問三個問題:

  • left 最多能走到哪?right 最少能走到哪?
  • 當 left 和 right 交錯時,迴圈內的邏輯是否還有意義?
  • 如果陣列長度是 0 或 1,我的初始條件會不會直接越界?

很多時候只要先在紙上跑一遍長度為 1 的測資,就能抓出一半的 off-by-one 問題。真的,花三十秒做這件事,勝過 debug 十分鐘。

6.2 while 迴圈內部的順序陷阱:先移動還是先比較

快慢指針的「先移動再比較」和滑動視窗的「先計數再判斷」看似都是小事,但在緊張的面試環境下很容易寫錯。我見過有人把無重複字符子串的 while 寫成while count[ord(ch)] > 1然後在 while 外面又對 ch 的 count 做減一,結果邏輯完全亂掉。

比較穩健的寫法是:右指針納入新字符 → 立刻檢查是否違法 → 違法時左指針收縮。每一步操作都對應一個明確的 invariants:窗口內字符計數一定等於「左指針到右指針之間真實字符的計數」。如果你把操作順序打亂,這個不變量就失效了,後面所有判斷都會跟著錯。面試時寫代碼,寧可多寫幾行看起來冗餘的變量,也不要用太聰明的精簡寫法增加心理負擔。

6.3 正確性證明怎麼說才不冷場

面試官問「你怎麼證明這個解法是對的」時,最忌諱直接開始念代碼。我習慣用三步結構回答:不變量 → 單調性 → 一步步排除。

以對撞指針的兩數之和為例,我會說:

  • 我維護的搜尋範圍是 [left, right] 之間的所有 pair。
  • 因為陣列有序,所以 left 右移只會讓左數變大,right 左移只會讓右數變小。
  • 當 total 小於 target 時,任何以 nums[left] 為左端點、右端點在 [left, right] 或其他更右邊位置的組合,總和只會更小或等於當前值,都不可能等於 target;所以我移動 left,排除所有包含 nums[left] 的組合。反之亦然。
  • 每一步都至少排除一個方向,且每個被排除的組合都不可能成為答案,所以最後要嘛找到答案,要嘛搜尋空間為空。

這個套路對滑動視窗也適用,只是把「排除方向」換成「窗口收縮後,所有被移出窗口的字符都不可能出現在合法最優解中」。能講出這種統一證明框架的人,通常會被面試官歸類為「真的懂演算法」,而不是「刷題機器」。

6.4 刷題心態與記憶策略:別追求每題都從零想

最後說點實在話。雙指針題的面試價值在於「規律性強」,所以它非常適合用「模板 + 變體」的方式準備。你不需要從零推導每一題,但你需要能解釋每個模板為什麼長這樣。

我自己的復習方式是:每刷完一類題,就用一張紙畫出模板的核心骨架。對撞指針就畫一個縮減的區間,標註移動規則;滑動視窗就畫一條毛毛蟲,標註擴張和收縮的觸發條件;快慢指針就畫一條鏈,標註快指針落地的邊界。這張紙比任何筆記都有效,因為它強迫你把「題目」抽象成「移動規則」。

如果某段時間刷題刷到懷疑人生,我會提醒自己:雙指針題是所有演算法題裡投入產出比最高的一塊。它的模式少、擴展性好、面試出現率高,只要把這三種指針模式刻進肌肉記憶,LeetCode 150 題裡的雙指針題基本上都能在十分鐘內寫出正確解。

我自己後來在實際面試中遇到某道「找最長無重複子串」的變體題,幾乎就是無縫切換到滑動視窗模板,五分鐘寫完,剩下的時間都花在跟面試官討論優化和正確性證明上。那種「題目再怎麼變,都能認出它是雙指針」的感覺,就是刷題最值得追求的狀態。希望這篇文章能幫你也達到這個狀態。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/15 11:46:33

告别AI生成PPT的塑料感:从内容到设计的可编辑工作流

1. 为什么AI生成的PPT总有一股“塑料味”1.1 从一次翻车现场说起上个月帮一个做咨询的朋友救火。他用某AI工具生成了一份给客户汇报的PPT&#xff0c;发过来让我“润色一下”。打开文件的那一刻我就明白了——这哪是润色能解决的&#xff0c;这是推倒重来的问题。封面是一张饱和…

作者头像 李华
网站建设 2026/10/15 11:46:22

SpringBoot游戏周边商城实战:电商系统开发全解析

1. 项目概述与核心价值 作为一名在Java全栈开发领域摸爬滚打多年的老手&#xff0c;我见过太多毕业设计项目沦为"玩具级"演示品。但这个基于SpringBoot的游戏周边商城设计&#xff0c;却是个能真实锻炼工程能力的实战项目。它完整覆盖了电商系统核心模块&#xff1a;…

作者头像 李华
网站建设 2026/10/15 11:45:19

Agent-Reach:多智能体系统服务发现与可靠调用框架实践

这年头聊到多智能体系统&#xff08;Multi-Agent System&#xff09;&#xff0c;大家第一反应都是“大模型提示词编排”&#xff0c;但真正落地过的人都懂&#xff1a;最难的不是让单个 Agent 变聪明&#xff0c;而是让一群 Agent 之间能够互相找到、互相调用、互相兜底。Agen…

作者头像 李华
网站建设 2026/10/15 11:44:37

飞桨手写数字识别零基础实践:从MLP到LeNet训练调试全链路

1. 项目概述&#xff1a;为什么“手写数字识别”是深度学习入门的黄金标尺“手写数字识别”这六个字&#xff0c;听起来像教科书里一个被翻烂了的例题&#xff0c;但在我带过的二十多期深度学习实践训练中&#xff0c;它依然是新手跨过“理论懂、代码懵、调参崩”这道门槛最稳的…

作者头像 李华
网站建设 2026/10/15 11:44:20

SpringBoot+Vue书城阅读器全栈开发实践

1. 项目概述 这个基于SpringBootVue的书城阅读器系统是一个典型的全栈Web应用项目&#xff0c;它整合了后端Java技术和前端Vue框架&#xff0c;构建了一个完整的在线阅读平台。作为一名参与过多个类似项目的开发者&#xff0c;我认为这类系统最核心的价值在于它完美展现了现代W…

作者头像 李华