第一章:双指针技巧的核心思想与适用场景
双指针技巧是一种在数组或链表等线性数据结构上高效解决问题的算法策略。其核心思想是通过维护两个指针,分别指向不同的位置,根据特定条件移动它们,从而减少时间复杂度,避免暴力遍历。
核心思想
双指针的核心在于利用两个指针协同工作,以降低嵌套循环带来的高时间开销。常见形式包括同向指针、相向指针和快慢指针。通过合理设计移动逻辑,可以在一次遍历中完成目标匹配、去重或查找操作。
典型适用场景
- 有序数组中的两数之和问题
- 移除数组中的重复元素
- 判断链表是否有环
- 滑动窗口类问题
快慢指针示例:检测链表环
使用快慢指针可以高效判断链表是否存在环。快指针每次走两步,慢指针每次走一步,若两者相遇则说明存在环。
// 定义链表节点
type ListNode struct {
Val int
Next *ListNode
}
// 检测链表是否有环
func hasCycle(head *ListNode) bool {
if head == nil || head.Next == nil {
return false
}
slow := head // 慢指针
fast := head.Next // 快指针
for slow != fast {
if fast == nil || fast.Next == nil {
return false // 快指针到达末尾,无环
}
slow = slow.Next // 走一步
fast = fast.Next.Next // 走两步
}
return true // 相遇说明有环
}
双指针类型对比
| 类型 | 移动方向 | 典型应用 |
|---|
| 相向指针 | 从两端向中间靠拢 | 两数之和(有序数组) |
| 同向指针 | 一前一后同向移动 | 滑动窗口、移除元素 |
| 快慢指针 | 速度不同,同向移动 | 链表判环、找中点 |
第二章:同向双指针经典题型解析
2.1 快慢指针判圈——从链表环检测说起
在链表中检测是否存在环是一个经典问题,快慢指针法以其简洁高效脱颖而出。该方法通过两个移动速度不同的指针遍历链表,若存在环,则快指针终将追上慢指针。
算法核心思想
快指针每次前进两步,慢指针每次前进一步。若链表无环,快指针会率先到达尾部;若有环,两者必在环内相遇。
代码实现(Go)
func hasCycle(head *ListNode) bool {
if head == nil || head.Next == nil {
return false
}
slow, fast := head, head
for fast != nil && fast.Next != nil {
slow = slow.Next // 慢指针走一步
fast = fast.Next.Next // 快指针走两步
if slow == fast { // 相遇则有环
return true
}
}
return false
}
上述代码中,
slow 和
fast 初始均指向头节点。循环条件确保不越界,指针同步移动。时间复杂度为 O(n),空间复杂度为 O(1),极具实用性。
2.2 移动零问题——如何原地高效重构数组
在处理数组重构问题时,"移动零"是一个经典案例:给定一个数组,要求将所有零元素移动到数组末尾,同时保持非零元素的相对顺序,并且必须在原地完成操作。
双指针策略
使用双指针法可实现一次遍历完成重构。左指针指向下一个非零元素应放置的位置,右指针遍历数组。
func moveZeroes(nums []int) {
left := 0
for right := 0; right < len(nums); right++ {
if nums[right] != 0 {
nums[left], nums[right] = nums[right], nums[left]
left++
}
}
}
该代码通过交换操作将非零元素前移,时间复杂度为 O(n),空间复杂度为 O(1)。当右指针遍历完毕,所有零已自动被推至末尾。
性能对比
- 暴力法:两次遍历,先移非零再补零,逻辑清晰但冗余操作多
- 双指针法:单次扫描完成重构,效率最优
2.3 最大连续1的个数——滑动窗口与指针协同
在处理数组中连续子序列问题时,滑动窗口结合双指针技术能高效求解“最大连续1的个数”。
问题建模
给定一个二进制数组,允许最多翻转k个0,目标是找到最长的连续1子数组。使用左指针
left和右指针
right维护窗口,窗口内0的个数不超过k。
算法实现
func longestOnes(nums []int, k int) int {
left, maxLen, zeroCount := 0, 0, 0
for right := 0; right < len(nums); right++ {
if nums[right] == 0 {
zeroCount++
}
for zeroCount > k { // 缩小窗口
if nums[left] == 0 {
zeroCount--
}
left++
}
maxLen = max(maxLen, right-left+1)
}
return maxLen
}
代码中
zeroCount记录窗口内0的个数,当超过k时移动左指针。时间复杂度为O(n),每个元素最多被访问两次。
核心优势
- 避免暴力枚举所有子数组
- 动态调整窗口大小,保证最优解
2.4 最小覆盖子串——动态调整窗口的经典范式
滑动窗口的核心思想
最小覆盖子串问题要求在源字符串中找到包含目标字符集的最短连续子串。该问题典型解法采用双指针构建可变长滑动窗口,通过动态调整左右边界实现最优解。
算法步骤与数据结构
- 使用哈希表记录目标字符串中各字符的出现频次
- 右指针扩展窗口直至满足覆盖条件
- 左指针收缩窗口以寻找最短有效子串
- 维护一个计数器跟踪已满足的字符种类数
func minWindow(s string, t string) string {
need := make(map[byte]int)
for _, c := range []byte(t) {
need[c]++
}
left, right, valid := 0, 0, 0
start, length := 0, len(s)+1
for right < len(s) {
// 扩展窗口
c := s[right]
right++
if _, ok := need[c]; ok {
need[c]--
if need[c] == 0 {
valid++
}
}
// 收缩窗口
for valid == len(need) {
if right-left < length {
start = left
length = right - left
}
d := s[left]
left++
if _, ok := need[d]; ok {
if need[d] == 0 {
valid--
}
need[d]++
}
}
}
if length == len(s)+1 {
return ""
}
return s[start : start+length]
}
上述代码中,need 哈希表记录目标字符需求量,valid 表示已满足的字符种类数。当 valid 等于 need 长度时,窗口已覆盖所有目标字符,尝试收缩左边界以优化结果。
2.5 找到字符串中所有字母异位词——模板化滑动窗口解法
在处理字符串子串匹配问题时,字母异位词的查找是一个经典场景。给定两个字符串 `s` 和 `p`,目标是找出 `s` 中所有 `p` 的字母异位词起始索引。使用**模板化滑动窗口**策略可高效解决此类问题。
核心思路
维护一个长度为 `len(p)` 的滑动窗口,在 `s` 上从左向右滑动,通过哈希表统计字符频次,比较窗口内字符分布是否与 `p` 相同。
代码实现
func findAnagrams(s, p string) []int {
var result []int
if len(s) < len(p) { return result }
target, window := [26]int{}, [26]int{}
for i := range p {
target[p[i]-'a']++
}
for i := 0; i < len(s); i++ {
window[s[i]-'a']++
if i >= len(p) {
window[s[i-len(p)]-'a']--
}
if window == target {
result = append(result, i-len(p)+1)
}
}
return result
}
上述代码通过固定窗口大小滑动,避免了动态调整边界。每次移动时更新右侧字符计数,超出窗口左侧字符则减去。当两个频次数组相等时,即找到一个异位词。时间复杂度为 O(n),空间复杂度 O(1)(仅使用固定大小数组)。
第三章:相向双指针高频实战
3.1 两数之和 II——有序数组中的高效查找策略
在已排序的整数数组中寻找两个数,使其和等于目标值,是双指针技术的经典应用场景。相比暴力枚举的时间复杂度 O(n²),利用数组有序特性可显著提升效率。
双指针算法原理
从数组两端各设一个指针,根据当前两数之和与目标值的比较结果,决定移动左指针(增大和)或右指针(减小和)。
func twoSum(numbers []int, target int) []int {
left, right := 0, len(numbers)-1
for left < right {
sum := numbers[left] + numbers[right]
if sum == target {
return []int{left + 1, right + 1} // 题目要求1-indexed
} else if sum < target {
left++
} else {
right--
}
}
return nil
}
上述代码中,
left 初始指向最小值,
right 指向最大值。每次迭代调整指针位置,时间复杂度为 O(n),空间复杂度 O(1)。
性能对比
- 暴力解法:无需排序,但时间复杂度高
- 二分查找:固定一个数,对另一个数二分搜索,O(n log n)
- 双指针法:仅需一次遍历,最优解
3.2 三数之和——去重逻辑与边界控制的艺术
在解决“三数之和”问题时,核心在于如何高效避免重复解的产生。通过排序预处理,可将时间复杂度控制在 O(n²),并利用双指针技术优化搜索过程。
去重策略的关键时机
去重操作需在两层循环中分别进行:外层遍历固定第一个数时跳过相同元素;内层双指针移动时也需跳过重复值,防止生成重复三元组。
参考实现(Java)
for (int i = 0; i < n; i++) {
if (i > 0 && nums[i] == nums[i-1]) continue; // 跳过重复的第一个数
int left = i + 1, right = n - 1;
while (left < right) {
int sum = nums[i] + nums[left] + nums[right];
if (sum == 0) {
res.add(Arrays.asList(nums[i], nums[left], nums[right]));
while (left < right && nums[left] == nums[left+1]) left++; // 去重
while (left < right && nums[right] == nums[right-1]) right--; // 去重
left++; right--;
} else if (sum < 0) {
left++;
} else {
right--;
}
}
}
上述代码中,每次添加结果后立即跳过左右指针的重复值,确保每组解唯一。边界控制严格依赖
left < right 条件,防止越界与重复计算。
3.3 接雨水——双指针优化空间复杂度的典范
问题核心与暴力解法局限
接雨水问题要求计算数组表示的高矮柱子能接多少单位的雨水。暴力方法对每个位置向左右扫描找最大值,时间复杂度为 O(n²),效率低下。
双指针优化思路
利用双指针从两端向中间遍历,维护左右最大高度。只要一侧最大值较小,当前格子的积水高度就由该侧决定,无需额外数组存储最大值,空间复杂度降至 O(1)。
func trap(height []int) int {
left, right := 0, len(height)-1
leftMax, rightMax, water := 0, 0, 0
for left < right {
if height[left] < height[right] {
if height[left] >= leftMax {
leftMax = height[left]
} else {
water += leftMax - height[left]
}
left++
} else {
if height[right] >= rightMax {
rightMax = height[right]
} else {
water += rightMax - height[right]
}
right--
}
}
return water
}
代码中,left 和 right 指针移动时,始终保证短板一侧的积水可安全累加,避免了冗余计算。leftMax 与 rightMax 动态更新边界最高柱,water 累计有效雨水量。
第四章:特殊变式与进阶挑战
4.1 环形链表检测——Floyd判圈算法深度剖析
环形链表检测是链表操作中的经典问题,广泛应用于内存管理与图结构遍历。Floyd判圈算法(又称龟兔赛跑算法)以双指针为核心,高效判断链表中是否存在环。
算法核心思想
使用两个指针:慢指针(slow)每次前移一步,快指针(fast)每次前移两步。若链表无环,快指针将率先到达尾部;若存在环,快指针终将追上慢指针。
代码实现与分析
func hasCycle(head *ListNode) bool {
if head == nil || head.Next == nil {
return false
}
slow, fast := head, head
for fast != nil && fast.Next != nil {
slow = slow.Next // 慢指针走一步
fast = fast.Next.Next // 快指针走两步
if slow == fast { // 指针相遇,存在环
return true
}
}
return false
}
上述代码中,
slow 和
fast 初始指向头节点。循环条件确保快指针不越界。当两指针相等时,表明链表成环,时间复杂度为 O(n),空间复杂度为 O(1)。
4.2 盛最多水的容器——贪心策略与指针移动方向
在解决“盛最多水的容器”问题时,核心在于最大化两根垂直线之间的面积。采用双指针技术,从数组两端向内收缩,是实现贪心策略的关键。
算法思路解析
每次计算当前左右指针所构成的容器容量:
area = min(height[left], height[right]) * (right - left)
为使面积尽可能大,应保留较高的一侧,移动较矮一侧的指针——因为面积受限于较短边,移动它才有可能找到更高的边界。
指针移动逻辑
- 初始化 left = 0, right = len(height) - 1
- 循环直至指针相遇
- 若 height[left] < height[right],则 left++;否则 right--
该策略确保每一步都尝试突破“短板效应”,在 O(n) 时间内找到最大面积。
4.3 删除排序数组中的重复项——快慢指针拓展应用
在处理有序数组去重问题时,快慢指针是一种高效且直观的策略。通过维护两个索引,一个用于遍历(快指针),另一个用于记录不重复元素的位置(慢指针),可在原地完成操作。
算法核心思路
当数组已排序时,重复元素必然相邻。快指针逐个扫描元素,慢指针指向下一个非重复位置。仅当当前元素与前一个非重复元素不同时,才将其复制到慢指针位置。
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
}
上述代码中,`slow` 初始为 1,因为首个元素无需比较。`fast` 从第二个元素开始遍历,若与前一元素不同,则写入 `slow` 位置并递增。最终返回 `slow` 作为新长度。
时间与空间复杂度
- 时间复杂度:O(n),每个元素访问一次
- 空间复杂度:O(1),仅使用常量额外空间
4.4 和为K的子数组——前缀和与双指针的界限辨析
在求解“和为K的子数组”问题时,前缀和是核心思想。若使用双指针(滑动窗口),仅适用于所有元素为正的场景,因为无法保证窗口收缩方向的单调性。
前缀和解法(通用)
Map<Integer, Integer> map = new HashMap<>();
map.put(0, 1);
int sum = 0, count = 0;
for (int num : nums) {
sum += num; // 当前前缀和
if (map.containsKey(sum - k)) {
count += map.get(sum - k); // 存在前缀和为 sum-k,则区间和为k
}
map.put(sum, map.getOrDefault(sum, 0) + 1);
}
该方法时间复杂度为 O(n),利用哈希表存储各前缀和出现次数,适用于正负数混合数组。
双指针局限性
当数组含负数时,右移左指针可能导致和增大,破坏窗口有效性。因此,双指针不适用于此类场景,前缀和才是通用解法。
第五章:从刷题到面试——双指针的体系化掌握
理解双指针的核心思想
双指针并非特指两个指针,而是利用两个变量在数组或链表中协同移动,以降低时间复杂度。常见模式包括对撞指针、快慢指针和前后指针。
快慢指针解决环形链表检测
使用快慢指针可高效判断链表是否存在环。快指针每次走两步,慢指针走一步,若相遇则存在环。
func hasCycle(head *ListNode) bool {
if head == nil {
return false
}
slow, fast := head, head
for fast.Next != nil && fast.Next.Next != nil {
slow = slow.Next
fast = fast.Next.Next
if slow == fast {
return true
}
}
return false
}
对撞指针求解两数之和 II
在有序数组中查找和为 target 的两个数,可使用左右指针从两端向中间逼近。
- 初始化 left = 0, right = len(nums)-1
- 若 nums[left] + nums[right] == target,返回结果
- 若和过大,right--;过小,left++
滑动窗口中的双指针应用
双指针常用于维护一个动态窗口。例如在字符串 s 中寻找包含 t 所有字符的最短子串,可通过左右指针扩展与收缩窗口实现。
| 问题类型 | 指针类型 | 典型题目 |
|---|
| 有序数组查找 | 对撞指针 | 两数之和 II |
| 链表环检测 | 快慢指针 | 环形链表 |
| 子数组/子串问题 | 滑动窗口 | 最小覆盖子串 |