双指针算法技巧
双指针 - 力扣(LeetCode)全球极客挚爱的技术成长平台
双指针技巧秒杀七道链表题目 | labuladong 的算法笔记
双指针技巧秒杀七道数组题目 | labuladong 的算法笔记
双指针算法(Two Pointers)详解与 Go 实现
一、什么是双指针算法?
双指针算法是一种通过在数据结构(如数组、链表)中设置两个指针(索引),通过移动指针高效解决问题的技巧。核心思想是减少冗余遍历,将原本需要嵌套循环(O (n²))的问题优化为线性时间(O (n))。
根据指针移动方向,可分为两类:
-
相向指针:两个指针从两端向中间移动(如反转、回文判断)。
-
同向指针:两个指针从同一端出发,快慢指针(如链表环检测)或前后指针(如移除元素)。
二、经典场景与 Go 实现示例
1. 两数之和(有序数组)
问题:给定有序数组 nums
和目标值 target
,找到两个数使和为 target
,返回索引(LeetCode 167)。 思路:左指针(left
)从起点,右指针(right
)从终点,根据当前和与 target
的大小移动指针。
go
func twoSum(numbers []int, target int) []int {left, right := 0, len(numbers)-1for left < right {sum := numbers[left] + numbers[right]if sum == target {return []int{left + 1, right + 1} // 题目要求1-based索引} else if sum < target {left++ // 和太小,左指针右移(增大 sum)} else {right-- // 和太大,右指针左移(减小 sum)}}return []int{} // 题目保证有解,实际不会走到这 }
2. 三数之和
问题:找到数组中所有和为 0 的三元组,不重复(LeetCode 15)。 思路:排序后固定一个数,用双指针找另外两个数,注意去重。
go
import "sort" func threeSum(nums []int) [][]int {sort.Ints(nums)res := [][]int{}n := len(nums)for i := 0; i < n-2; i++ {// 去重:跳过重复的固定值if i > 0 && nums[i] == nums[i-1] {continue}left, right := i+1, n-1for left < right {sum := nums[i] + nums[left] + nums[right]if sum == 0 {res = append(res, []int{nums[i], nums[left], nums[right]})// 去重:跳过重复的左/右值for left < right && nums[left] == nums[left+1] {left++}for left < right && nums[right] == nums[right-1] {right--}left++right--} else if sum < 0 {left++} else {right--}}}return res }
3. 反转字符串
问题:原地反转字符串(LeetCode 344)。 思路:左指针从 0,右指针从末尾,交换值后向中间移动。
go
func reverseString(s []byte) {left, right := 0, len(s)-1for left < right {s[left], s[right] = s[right], s[left]left++right--} }
4. 验证回文串
问题:判断字符串是否为回文(只考虑字母和数字,忽略大小写,LeetCode 125)。 思路:双指针跳过非字母 / 数字,比较对应位置是否相等。
go
import "unicode" func isPalindrome(s string) bool {left, right := 0, len(s)-1for left < right {// 左指针找下一个字母/数字for left < right && !unicode.IsLetterOrNumber(rune(s[left])) {left++}// 右指针找下一个字母/数字for left < right && !unicode.IsLetterOrNumber(rune(s[right])) {right--}// 忽略大小写比较if unicode.ToLower(rune(s[left])) != unicode.ToLower(rune(s[right])) {return false}left++right--}return true }
5. 移除元素
问题:原地移除值为 val
的元素,返回新长度(LeetCode 27)。 思路:快指针遍历数组,慢指针记录有效元素位置,不等则赋值给慢指针。
go
func removeElement(nums []int, val int) int {slow := 0for fast := 0; fast < len(nums); fast++ {if nums[fast] != val {nums[slow] = nums[fast]slow++}}return slow }
6. 合并两个有序数组
问题:将 nums2
合并到 nums1
中(假设 nums1
有足够空间,LeetCode 88)。 思路:从后往前双指针,避免覆盖 nums1
中的元素。
go
func merge(nums1 []int, m int, nums2 []int, n int) {i, j, k := m-1, n-1, m+n-1 // i: nums1有效尾,j: nums2尾,k: 合并后尾for i >= 0 && j >= 0 {if nums1[i] > nums2[j] {nums1[k] = nums1[i]i--} else {nums1[k] = nums2[j]j--}k--}// 若nums2还有剩余元素,直接复制for j >= 0 {nums1[k] = nums2[j]j--k--} }
7. 环形链表(判断环)
问题:判断链表是否有环(LeetCode 141)。 思路:快慢指针,快指针每次走 2 步,慢指针走 1 步,若相遇则有环。
go
type ListNode struct {Val intNext *ListNode } func hasCycle(head *ListNode) bool {if head == nil || head.Next == nil {return false}slow, fast := head, head.Nextfor slow != fast {if fast == nil || fast.Next == nil {return false // 快指针到尾,无环}slow = slow.Nextfast = fast.Next.Next}return true }
8. 盛最多水的容器
问题:找到两条线,与 x 轴组成的容器能装最多水(LeetCode 11)。 思路:左右指针从两端出发,面积由短边决定,移动短边指针尝试更大面积。
go
func maxArea(height []int) int {maxArea := 0left, right := 0, len(height)-1for left < right {// 计算当前面积(宽*高)width := right - leftcurrentHeight := min(height[left], height[right])area := width * currentHeightif area > maxArea {maxArea = area}// 移动短边指针if height[left] < height[right] {left++} else {right--}}return maxArea }func min(a, b int) int {if a < b {return a}return b }
9. 链表的中间节点
问题:返回链表的中间节点(LeetCode 876)。 思路:快慢指针,快指针到尾时,慢指针在中间。
go
func middleNode(head *ListNode) *ListNode {slow, fast := head, headfor fast != nil && fast.Next != nil {slow = slow.Nextfast = fast.Next.Next}return slow }
10. 移除重复元素
问题:原地移除有序数组中的重复元素,返回新长度(LeetCode 26)。 思路:慢指针记录有效位置,快指针遍历,不等则赋值给慢指针。
go
func removeDuplicates(nums []int) int {if len(nums) == 0 {return 0}slow := 1 // 第一个元素必保留for fast := 1; fast < len(nums); fast++ {if nums[fast] != nums[fast-1] {nums[slow] = nums[fast]slow++}}return slow }
11. 分割链表
问题:将链表分割成所有小于 x 的节点在前,大于等于 x 的在后(LeetCode 86)。 思路:用两个链表分别存储小于 x 和大于等于 x 的节点,最后拼接。
go
func partition(head *ListNode, x int) *ListNode {dummy1, dummy2 := &ListNode{}, &ListNode{} // 虚拟头节点p1, p2 := dummy1, dummy2 // 双指针分别指向两个链表for head != nil {if head.Val < x {p1.Next = headp1 = p1.Next} else {p2.Next = headp2 = p2.Next}head = head.Next}p2.Next = nil // 避免环p1.Next = dummy2.Next // 拼接两个链表return dummy1.Next }
12. 最长回文子串
问题:找到字符串中最长的回文子串(LeetCode 5)。 思路:对每个字符(或两个字符)向两边扩展,用双指针找最长回文。
go
func longestPalindrome(s string) string {start, end := 0, 0for i := 0; i < len(s); i++ {// 奇数长度回文(中心为i)l1, r1 := expandAroundCenter(s, i, i)// 偶数长度回文(中心为i和i+1)l2, r2 := expandAroundCenter(s, i, i+1)// 更新最长回文边界if r1-l1 > end-start {start, end = l1, r1}if r2-l2 > end-start {start, end = l2, r2}}return s[start : end+1] } // 从l和r向两边扩展,返回最长回文的左右边界 func expandAroundCenter(s string, l, r int) (int, int) {for l >= 0 && r < len(s) && s[l] == s[r] {l--r++}return l + 1, r - 1 // 退出时多移了一步,回退 }
三、LeetCode 双指针题型总结(12 类)
题型分类 | 核心思路 | 典型题目(LeetCode) |
---|---|---|
两数 / 三数 / 四数之和 | 排序后用双指针减少一层循环,注意去重 | 167(两数)、15(三数)、18(四数) |
字符串反转 / 回文 | 相向指针交换或验证对称位置 | 344(反转)、125(回文串)、5(最长回文子串) |
数组元素操作(移除 / 去重) | 同向指针,快指针遍历,慢指针记录有效元素 | 27(移除元素)、26(去重)、80(去重 II) |
合并有序数组 / 链表 | 从后往前(数组)或从头往后(链表)双指针,避免覆盖 | 88(合并数组)、21(合并链表) |
链表环问题 | 快慢指针,快指针速度是慢指针 2 倍,相遇则有环 | 141(判断环)、142(找环入口) |
链表中点 / 倒数第 k 节点 | 快慢指针(中点:快 2 慢 1;倒数 k:快先走 k 步) | 876(中点)、19(倒数第 k) |
容器盛水问题 | 相向指针,由短边决定面积,移动短边寻找更大面积 | 11(盛最多水) |
分割链表 / 数组 | 双指针分别记录两类元素,最后拼接 | 86(分割链表)、75(颜色分类) |
子数组 / 子串问题 | 滑动窗口(双指针的变种),用左右指针维护窗口范围 | 3(无重复字符子串)、76(最小覆盖子串) |
链表相交问题 | 双指针分别遍历两链表,到达尾部后切换到另一链表,相遇点为交点 | 160(相交链表) |
有效三角形的个数 | 排序后固定最大边,双指针找两小边满足和大于最大边 | 611 |
区间列表的交集 | 双指针遍历两个区间列表,取重叠部分,移动较短区间的指针 | 986 |
四、双指针算法的核心优势
-
时间优化:将 O (n²) 降至 O (n) 或 O (n log n)(需排序时)。
-
空间优化:多数情况可原地操作,空间复杂度 O (1)。
-
适用广泛:数组、链表、字符串等线性结构均适用,尤其适合有序数据。
掌握双指针的关键是:明确指针移动的条件(何时移左、何时移右),以及如何通过指针协作减少冗余计算。
双指针算法的本质是通过两个指针的协同移动,利用线性数据结构(数组、链表、字符串)的 “有序性” 或 “连续性”,减少冗余遍历,将高复杂度问题转化为线性复杂度。其核心思想并非简单的 “两个指针”,而是通过指针的 “策略性移动” 来缩小问题规模,本质是对 “搜索空间” 的高效裁剪。
一、双指针的底层逻辑:搜索空间的裁剪
所有双指针问题的核心,都是通过指针移动排除无效的搜索空间,从而降低时间复杂度。
以 “两数之和(有序数组)” 为例:
-
暴力解法的搜索空间是所有可能的二元组
(i,j)
(i<j
),共 O (n²) 种组合。 -
双指针解法中,左指针
i
从左向右,右指针j
从右向左。当nums[i]+nums[j] < target
时,所有以i
为左、j' < j
为右的组合都无效(因为数组有序,nums[j'] ≤ nums[j]
,和会更小),因此直接移动i
即可;反之则移动j
。每次移动都排除了一整个子集的无效组合,最终仅需 O (n) 次遍历。
关键结论:双指针的有效性依赖于 “移动指针能明确排除部分搜索空间”,这种排除必须是 “绝对的”—— 即被排除的空间中不可能存在解。
二、双指针的分类与策略本质
根据指针的移动方式和目标,双指针可分为三大类,每类的策略本质不同:
1. 相向双指针(左右指针)
-
形式:指针
left
从起点(0)出发,right
从终点(n-1)出发,向中间移动,直到相遇。 -
核心场景:利用 “对称性” 或 “区间两端的关联性” 求解(如回文、最值区间)。
-
移动逻辑:根据两端元素的关系决定移动哪一端,每次移动排除一端的无效选项。
典型案例:
-
回文串判断:对称位置必须相等,否则非回文。
-
盛最多水的容器:面积由短边决定,移动短边才可能找到更大面积(移动长边只会让面积更小)。
-
反转字符串:直接交换对称位置元素。
本质:利用线性结构的 “两端对称性”,通过比较两端元素快速缩小范围。
2. 同向双指针(快慢指针)
-
形式:指针
slow
和fast
从同一起点出发,fast
移动速度 ≥slow
(通常fast
走 1 步,slow
走 0 或 1 步)。 -
核心场景:在序列中 “筛选” 或 “定位” 特定元素(如去重、找中点、移除元素)。
-
移动逻辑:
fast
负责 “探索”(遍历所有元素),slow
负责 “记录有效位置”,仅当fast
满足条件时,slow
才移动并更新值。
典型案例:
-
移除元素:
fast
遍历所有元素,slow
只记录非目标值的位置。 -
链表中点:
fast
速度是slow
的 2 倍,fast
到尾时slow
在中点。 -
去重:
fast
跳过重复元素,slow
记录不重复的元素。
本质:用 fast
遍历,slow
维护 “有效序列” 的边界,实现 “原地修改” 以节省空间。
3. 滑动窗口(双指针的高级变体)
-
形式:
left
和right
同向移动,共同维护一个 “区间 [left, right]”,通过调整区间范围求解(如子串、子数组问题)。 -
核心场景:寻找满足特定条件的 “连续区间”(如最长 / 最短子串、和为 k 的子数组)。
-
移动逻辑:
right
先扩张窗口,当窗口不满足条件时,left
收缩窗口,直到重新满足条件。
典型案例:
-
无重复字符的最长子串(LeetCode 3):
right
扩张,出现重复时left
跳到重复位置后。 -
最小覆盖子串(LeetCode 76):
right
扩张到覆盖所有目标字符,left
收缩到最小有效区间。 -
长度最小的子数组(LeetCode 209):
right
扩张到和≥target,left
收缩到最小长度。
本质:通过窗口的 “扩张 - 收缩” 动态维护有效区间,避免对每个区间的重复检查。
三、双指针的适用条件与局限性
适用条件:
-
线性结构:仅适用于数组、链表、字符串等线性数据结构(非线性结构如树、图无法直接应用)。
-
有序性依赖:多数问题需要数据有序(如两数之和、三数之和),无序时需先排序(增加 O (n log n) 时间)。
-
单调性条件:问题需满足 “指针移动方向唯一”—— 即移动后不会出现 “需要回退指针” 的情况(否则双指针失效,需用其他算法)。
局限性:
-
对无序数据不友好:若数据无序且无法排序(如链表无法高效排序),双指针可能不如哈希表直接。
-
复杂条件难以处理:当判断条件是非线性的(如涉及乘积、模运算),指针移动规则可能不明确,难以排除无效空间。
-
无法处理嵌套结构:如二维数组的多数问题(需用二分或 DFS),双指针难以覆盖所有维度。
四、双指针与其他算法的关联
-
与排序的协同: 双指针常与排序结合(如三数之和、有效三角形个数),排序的作用是 “赋予数据单调性”,让指针移动规则可明确(如 “小了右移,大了左移”)。
-
与哈希表的对比:
-
哈希表优势:无需排序,适合无序数据(如两数之和的无序版本)。
-
双指针优势:空间复杂度 O (1)(哈希表为 O (n)),适合空间敏感场景。
-
-
与二分查找的异同:
-
相同点:均通过缩小搜索空间降低复杂度。
-
不同点:二分查找依赖 “全局有序”,每次排除一半空间;双指针依赖 “局部有序性”,每次排除部分空间(比例不固定)。
-
五、如何判断何时使用双指针?
当遇到以下特征的问题时,优先考虑双指针:
-
问题涉及 “两个元素的关系”(如和、差、大小比较)。
-
需要 “原地修改” 数组 / 链表(如去重、移除元素)。
-
寻找 “连续区间”(如子串、子数组)。
-
数据是有序的,或可通过排序变为有序。
总结
双指针算法的核心是 “策略性移动指针以裁剪搜索空间”,其高效性来源于对线性结构特性的充分利用。从简单的反转字符串到复杂的滑动窗口,本质都是通过指针协作减少冗余计算。掌握双指针的关键不在于记住题型,而在于理解 “如何通过指针移动排除无效空间”—— 这需要结合问题的具体条件,设计出明确、无歧义的指针移动规则。