Java 算法实践(三):双指针与滑动窗口

双指针不仅仅是一种技巧,更是一种降维打击 的思维方式。它的核心在于:通过两个指针的协同移动,剪枝掉大量无效的搜索空间

在 LeetCode 的中等(Medium)难度题目中,双指针和滑动窗口占据了半壁江山,尤其是在字符串处理和子数组查找问题上。

一、 左右指针:对撞模式

适用场景

  1. 有序数组:利用有序性进行二分查找式的收缩。
  2. 字符串反转:典型的首尾交换。
  3. 盛水容器 :利用贪心策略从两端向中间逼近。
    核心逻辑
    定义两个指针 left = 0right = n - 1,根据题目特定的条件,决定是 left++ 还是 right--,直到 left == rightleft > right 停止。

1.1 实战例题:两数之和 II - 输入有序数组

题目链接LeetCode 167. Two Sum II - Input Array Is Sorted

题目描述 :给定一个下标从 1 开始的整数数组 numbers ,该数组已按非递减顺序排列,请你从数组中找出满足相加之和等于目标数 target 的两个数。
思路解析

虽然可以用 HashMap 解决( O ( N ) O(N) O(N) 空间),但题目强调"有序"且要求 O ( 1 ) O(1) O(1) 空间。

  • 如果 sum > target:说明和太大了,需要变小。因为数组有序,left 已经是最小的了,只能移动 right 让大数变小 ( → \rightarrow → right--)。
  • 如果 sum < target:说明和太小了,需要变大。只能移动 left 让小数变大 ( → \rightarrow → left++)。
    假设推演:
    假设 numbers = [2, 7, 11, 15], target = 9。
步骤 指针位置 (L, R) 对应值 (vL, vR) 当前和 (Sum) 判断逻辑 操作
1 L=0, R=3 2, 15 17 17 > 9 (太大了) right--
2 L=0, R=2 2, 11 13 13 > 9 (还是大) right--
3 L=0, R=1 2, 7 9 9 == 9 (命中) 返回 [1, 2]

Java 代码

java 复制代码
public int[] twoSum(int[] numbers, int target) {
    int left = 0;
    int right = numbers.length - 1;
    while (left < right) {
        int sum = numbers[left] + numbers[right];
        if (sum == target) {
            // 题目要求下标从 1 开始
            return new int[]{left + 1, right + 1};
        } else if (sum < target) {
            // 和太小,左指针右移
            left++;
        } else {
            // 和太大,右指针左移
            right--;
        }
    }
    return new int[]{-1, -1};
}

1.2 实战例题:盛最多水的容器

题目链接LeetCode 11. Container With Most Water

题目描述 :给定一个长度为 n 的整数数组 height 。有 n 条垂线,找出其中的两条线,使得它们与 x 轴共同构成的容器可以容纳最多的水。
思路解析

这道题的难点在于理解**"为什么要移动较短的那根柱子"**。

  • 面积公式: A r e a = ( r i g h t − l e f t ) × min ⁡ ( h e i g h t [ l e f t ] , h e i g h t [ r i g h t ] ) Area = (right - left) \times \min(height[left], height[right]) Area=(right−left)×min(height[left],height[right])
  • 当前状态 :假设 height[left] < height[right]
  • 决策
    • 如果移动高柱子 (right--):宽度减小,且高度不可能 超过原来的短板(受限于 height[left])。所以面积一定变小
    • 如果移动短柱子 (left++):宽度减小,但新遇到的柱子可能 比原来高,从而提升整体高度。面积有可能变大
  • 结论:为了寻找可能的最大值,必须舍弃当前的短板,去寻找更高的可能性。

逻辑证明(反证法思想)

双指针法本质上是在剪枝 (Pruning)。暴力解法需要计算所有 C ( n , 2 ) C(n, 2) C(n,2) 种组合,即 O ( N 2 ) O(N^2) O(N2)。

当我们因为 height[left] < height[right] 而决定 left++ 时,实际上我们舍弃(left, right-1), (left, right-2), ... (left, left+1) 这一系列的所有组合。

  • 为什么可以安全舍弃?
  • 因为这些被舍弃的组合,其宽度都比当前 right - left 小,而高度受限于 height[left] (短板) 也不可能超过当前高度。
  • 因此,这些组合的面积必然小于当前面积,不需要再计算。
  • 这就是为什么 O ( N ) O(N) O(N) 能找到最优解的原因。

Java 代码

java 复制代码
public int maxArea(int[] height) {
    int left = 0;
    int right = height.length - 1;
    int maxRes = 0;
    while (left < right) {
        // 计算当前面积
        int currentHeight = Math.min(height[left], height[right]);
        int currentWidth = right - left;
        maxRes = Math.max(maxRes, currentHeight * currentWidth);
        // 移动较短的一边
        if (height[left] < height[right]) {
            left++;
        } else {
            right--;
        }
    }
    return maxRes;
}

1.3 进阶思考:对撞指针的本质与扩展

掌握了基础的对撞指针后,可以总结这类问题的通用规律,以及如何处理更复杂的变体(如 3Sum)。

  1. 为什么是 while (left < right) 而不是 <=
    在大多数对撞指针问题中(如 Two Sum, Container),我们需要两个不同 的元素来构成结果。
    • left == right 时,两个指针指向同一个元素。对于"两数之和"而言,这通常意味着同一个数被用了两次(除非题目允许),或者是找到了对角线上的点。
    • 对于"盛水容器",宽度为 0,面积为 0,没有计算意义。
    • 特例 :如果是"二分查找"或"反转字符串(含中间字符)",有时需要处理 left == right 的情况,此时可能需要 <=。但在双指针解题中,90% 的情况使用 <
  2. 从 2Sum 到 3Sum / 4Sum
    面试中常考的 3Sum (LeetCode 15) 本质上是对撞指针的降维应用
    • 暴力法 :三层循环, O ( N 3 ) O(N^3) O(N3)。

    • 双指针法 : O ( N 2 ) O(N^2) O(N2)。

      • 步骤 1:先对数组排序。
      • 步骤 2 :固定一个数字 i
      • 步骤 3 :问题转化为"在 i 后面的数组中,寻找 target = -nums[i] 的 Two Sum 问题"。
      • 步骤 4 :利用双指针在 O ( N ) O(N) O(N) 内解决 Two Sum。
      • 总复杂度 :外层循环 N N N × \times × 内层双指针 N N N = O ( N 2 ) O(N^2) O(N2)。
  3. 唯一的难点:如何去重?
    在对撞指针中,去重是一个核心考点。例如输入 [-2, 0, 0, 2, 2],求 3Sum。
    • 指针跳过法 :当 left 移动后,如果 nums[left] == nums[left-1],说明这个数刚才已经处理过了,为了避免重复结果,必须 left++ 直到遇到不同的数。

    • 代码片段示例

      java 复制代码
      // 在找到一组解后,继续移动指针并去重
      while (left < right && nums[left] == nums[left + 1]) left++; // 跳过左侧重复
      while (left < right && nums[right] == nums[right - 1]) right--; // 跳过右侧重复
      left++;
      right--;

二、 快慢指针:追击模式

适用场景

  1. 原地修改数组 :在不使用额外空间 ( O ( 1 ) O(1) O(1)) 的前提下,利用指针覆盖数据,完成去重、移除元素、移动零。
  2. 链表特性探测:利用遍历速度的差异(相对位移),检测链表是否有环、寻找链表中点。

核心逻辑

  • 快指针 (fast):作为遍历指针,负责扫描整个数组或链表,寻找满足特定条件的新元素。
  • 慢指针 (slow) :作为维护指针,指向当前已处理好的序列的末尾,或者用于标记特定的位置(如中点)。

2.1 实战例题:删除有序数组中的重复项

题目链接LeetCode 26. Remove Duplicates from Sorted Array

题目描述:给你一个升序排列的数组 nums ,请你原地删除重复出现的元素,使每个元素只出现一次 ,返回删除后数组的新长度。

思路解析

题目要求原地修改,意味着不能通过新建数组来去重。利用快慢指针,可以将数组逻辑上分为两个区间:

  • [0, slow]有效区间。该区间内的元素不重复。
  • [fast, n-1]待处理区间

算法流程

  1. 初始状态:slow = 0, fast = 1
  2. 比较 nums[fast]nums[slow]
    • 如果相等:说明 fast 指向的是重复元素,不需要保留,fast 继续后移。
    • 如果不等:说明 fast 指向了一个新元素。需要将这个新元素加入到有效区间
      • 操作:先 slow++(扩充有效区间),再 nums[slow] = nums[fast](赋值)。

状态演变推演 (输入 [1, 1, 2]):

步骤 指针位置 (fast, slow) 比较逻辑 数组状态 说明
Init fast=1, slow=0 nums[1] (1) == nums[0] (1) [1, 1, 2] 重复,忽略 fast
1 fast=2, slow=0 nums[2] (2) != nums[0] (1) [1, 1, 2] 发现不同,执行赋值
2 fast=2, slow=1 赋值操作 [1, 2, 2] nums[slow] 更新为 2
End - - [1, 2, ...] 返回 slow+1 = 2

Java 代码

java 复制代码
public int removeDuplicates(int[] nums) {
    if (nums.length == 0) return 0;
    
    // slow 维护的是 [0...slow] 这个无重复区间的边界
    int slow = 0;
    
    // fast 从 1 开始,因为第 0 个肯定是不重复的
    for (int fast = 1; fast < nums.length; fast++) {
        // 当发现 fast 指向的元素与 slow 不同时
        if (nums[fast] != nums[slow]) {
            // 扩充有效区间
            slow++;
            // 将新元素覆盖写入
            nums[slow] = nums[fast];
        }
    }
    // 返回有效区间的长度(长度 = 索引 + 1)
    return slow + 1;
}

2.2 实战例题:环形链表

题目链接LeetCode 141. Linked List Cycle

题目描述:给你一个链表的头节点 head ,判断链表中是否有环。

思路解析(Floyd 判圈算法)

利用两个指针遍历链表,slow 步长为 1,fast 步长为 2。

  • 无环情况fast 步长更大,会率先到达链表尾部 (null),直接返回 false
  • 有环情况fast 到达尾部后会重新进入环。此时,问题转化为两个指针在环上的追击问题

逻辑证明(为什么一定会相遇?)

假设进入环时,fast 落后 slow 的距离为 N N N 个节点。

每次迭代:

  • slow 移动 1 步。
  • fast 移动 2 步。
  • 两者间距变化: D i s t a n c e = N − ( 2 − 1 ) = N − 1 Distance = N - (2 - 1) = N - 1 Distance=N−(2−1)=N−1。
    由此可见,每次迭代两者的距离缩短 1。经过 N N N 次迭代后,距离变为 0,即 fast 追上 slow
    注:如果 fast 步长为 3,相对速度为 2,若 N N N 为奇数则可能跳过 slow 导致无法立即相遇,因此步长为 2 是最严谨的解法。

Java 代码

java 复制代码
public boolean hasCycle(ListNode head) {
    // 边界条件处理
    if (head == null || head.next == null) return false;
    
    ListNode slow = head;
    ListNode fast = head;
    
    // 循环条件:fast 当前节点和下一节点都必须存在
    while (fast != null && fast.next != null) {
        slow = slow.next;       // 步长 1
        fast = fast.next.next;  // 步长 2
        
        // 必须先移动,再判断。如果重合,说明有环。
        if (slow == fast) {
            return true;
        }
    }
    
    return false;
}

2.3 进阶:快慢指针的通用扩展

掌握快慢指针的逻辑后,可以解决一系列链表位置计算问题,其核心在于控制两个指针的间隔

  1. 寻找链表中点 (LeetCode 876):
    • 原理fast 速度是 slow 的 2 倍。当 fast 到达末尾时,slow 恰好走过了一半的距离。
    • 应用:归并排序链表、判断回文链表的前置步骤。
  2. 寻找倒数第 K 个节点 (LeetCode 19):
    • 原理 :先让 fast 移动 K K K 步,此时 fastslow 间隔 K K K。
    • 同步移动 :然后 fastslow 以相同速度移动。当 fast 到达末尾 (null) 时,slow 所在位置即为倒数第 K K K 个节点。

三、 滑动窗口:子串/子数组的核心

滑动窗口是双指针的一种特定应用模式,专门用于解决 "连续子串" (Substring) 或 "连续子数组" (Subarray) 问题。

核心原理

暴力解法通常需要两层循环枚举所有子串( O ( N 2 ) O(N^2) O(N2))。滑动窗口通过维护两个指针 leftright,定义一个闭区间(或半开区间)作为"窗口"。

  • 扩张 (Expand) :移动 right 指针,将新元素纳入窗口,直到满足某种特定条件。
  • 收缩 (Shrink) :移动 left 指针,将旧元素移出窗口,直到窗口恢复合法状态或不再满足条件。
    通过这两个操作的交替进行,保证了每个元素最多"进窗"一次、"出窗"一次,将时间复杂度降低至 O ( N ) O(N) O(N)

3.1 通用解题模板

大多数滑动窗口题目都遵循以下代码结构的变体。

Java 伪代码模板:

java 复制代码
/* 滑动窗口通用模板 */
public void slidingWindow(String s) {
    // 定义维护窗口状态的数据结构 (如 HashMap, int[] 计数器, 或数值 sum)
    // 对于字符集有限的情况,优先使用 int[] window = new int[128];
    Map<Character, Integer> window = new HashMap<>();
    
    int left = 0, right = 0;
    
    // 主循环:右指针主动扩张
    while (right < s.length()) {
        // c 是将要移入窗口的字符
        char c = s.charAt(right);
        right++;
        
        // TODO: 进行窗口内数据的更新 (如 window.put(c, count+1))
        
        // 子循环:左指针被动收缩
        // 判断条件通常是:窗口不再满足题目限制 (求最长时) 或 窗口已经满足条件尝试优化 (求最短时)
        while (/* 窗口需要收缩的条件 */) {
            // d 是将要移出窗口的字符
            char d = s.charAt(left);
            left++;
            
            // TODO: 进行窗口内数据的更新 (如 window.put(d, count-1))
        }
        
        // TODO: 根据题意,在扩张或收缩后更新最终结果 (如 res = Math.max/Min(...))
    }
}

3.2 场景一:求最长子串(不定长窗口)

题目链接LeetCode 3. Longest Substring Without Repeating Characters

题目描述 :给定一个字符串 s ,请你找出其中不含有重复字符的 最长子串 的长度。

思路解析

本题的目标是找到满足"无重复"条件的最大窗口。

  • 状态定义 :窗口 [left, right) 内必须没有重复字符。
  • 扩张逻辑right 向右移动,将字符加入计数器。
  • 违例处理(收缩) :一旦当前字符的计数 > 1,说明窗口内出现了重复。此时必须移动 left,并不断从计数器中移除左侧字符,直到窗口内那个重复字符的计数恢复为 1 为止。
  • 结果更新 :在每次窗口恢复合法状态后,计算当前长度 right - left 并更新最大值。
    优化点 :使用 int[128] 数组代替 HashMap 统计 ASCII 字符,性能提升巨大(Java 中 Map 操作较重)。

Java 代码

java 复制代码
public int lengthOfLongestSubstring(String s) {
    // 性能优化:使用数组代替 HashMap 统计 ASCII 字符
    int[] window = new int[128];
    
    int left = 0, right = 0;
    int maxLen = 0;
    
    while (right < s.length()) {
        char c = s.charAt(right);
        right++;
        
        // 入窗更新,记录字符出现的次数
        window[c]++;
        
        // 收缩条件:如果当前字符 c 的数量 > 1,说明重复
        while (window[c] > 1) {
            char d = s.charAt(left);
            left++;
            // 出窗更新
            window[d]--;
        }
        
        // 3. 此时窗口一定是合法的(无重复),尝试更新最大长度
        maxLen = Math.max(maxLen, right - left);
    }
    
    return maxLen;
}

3.3 场景二:求最短子数组(不定长窗口)

题目链接LeetCode 209. Minimum Size Subarray Sum

题目描述:找出该数组中满足其和 ≥ target 的长度最小的 连续子数组。

思路解析

本题与上一题相反,目标是找到满足"和 >= target"条件的最小窗口。

  • 扩张逻辑right 向右移动,累加数值到 sum
  • 达标处理(收缩) :一旦 sum >= target,说明当前窗口合法。为了寻找更短 的解,我们尝试移动 left 缩小窗口。
  • 循环收缩 :这是一个 while 循环,因为可能移除一个左侧元素后,剩余窗口的和依然 >= target。我们需要不断尝试收缩,直到 sum < target 为止。
  • 结果更新:在每次满足条件时(收缩前),更新最小长度。

Java 代码

java 复制代码
public int minSubArrayLen(int target, int[] nums) {
    int left = 0, right = 0;
    int sum = 0;
    // 初始化为最大整数,用于后续比较
    int minLen = Integer.MAX_VALUE;
    
    while (right < nums.length) {
        // 入窗:累加
        sum += nums[right];
        right++;
        
        // 收缩条件:只要 sum 达标,就尝试缩小窗口以获取最小长度
        while (sum >= target) {
            // 更新结果:注意此时 right 已经自增过,当前窗口长度为 right - left
            minLen = Math.min(minLen, right - left);
            
            // 出窗:减去左侧元素
            sum -= nums[left];
            left++;
        }
    }
    
    return minLen == Integer.MAX_VALUE ? 0 : minLen;
}

3.4 场景三:固定窗口 / 异位词问题

题目链接LeetCode 438. Find All Anagrams in a String

题目描述 :给定两个字符串 s 和 p,找到 s 中所有是 p 的异位词的子串,返回这些子串的起始索引。
异位词:指字母相同,但排列不同的字符串。

思路解析

这就相当于在一个长字符串 s 中,找一个长度和 p 一样,且字符计数也一样的子串。

  • 窗口大小 :此题隐含了一个限制:窗口长度必须固定 ,等于 p.length()
  • 状态定义:需要比较"窗口内的字符分布"与"p 的字符分布"是否一致。
  • 扩张逻辑right 向右移动,更新窗口内字符计数。
  • 固定窗口逻辑 :当 right - left (当前窗口大小)超过 p.length() 时,必须移动 left,保持窗口大小恒定。
  • 匹配检查 :当窗口大小达到 p.length() 时,检查窗口内的计数数组是否与 p 的计数数组完全一致。即:维护一个 window 计数器和一个 needs 计数器。当 window 中的字符数量完全匹配 needs 时,记录结果。

Java 代码

java 复制代码
public List<Integer> findAnagrams(String s, String p) {
    List<Integer> res = new ArrayList<>();
    if (s.length() < p.length()) return res;
    
    // 预处理:统计 p 的字符分布
    int[] needs = new int[26];
    for (char c : p.toCharArray()) needs[c - 'a']++;
    
    // 定义窗口计数器
    int[] window = new int[26];
    int left = 0, right = 0;
    
    while (right < s.length()) {
        // 入窗
        char c = s.charAt(right);
        window[c - 'a']++;
        right++;
        
        // 收缩:当窗口大小 > p.length() 时,必须收缩,保持窗口固定大小
        if (right - left > p.length()) {
            char d = s.charAt(left);
            window[d - 'a']--;
            left++;
        }
        
        // 检查是否匹配:Arrays.equals 可以直接比较两个数组的内容
        // 在 right - left == p.length() 时检查, 这个判断可以省略。
        if (right - left == p.length() && Arrays.equals(window, needs)) {
            res.add(left);
        }
    }
    return res;
}

(注:对于极高性能要求的场景,可以引入一个 valid 变量来记录"已匹配字符种类的数量",从而避免每次循环都调用 Arrays.equals,但在面试中,上述写法因逻辑清晰、代码简短而更受青睐。)

收到。这一部分我将剔除"利剑"等修辞,直接从模式识别算法效率本质 以及工程实现细节三个维度进行总结,使其更贴近技术面试的评分标准。

以下是修改后的 第四部分:总结与最佳实践


四、 总结

双指针与滑动窗口是解决线性结构(数组、链表、字符串)问题的核心技巧,其本质是将嵌套循环的暴力枚举优化为单次遍历。

4.1 模式识别:如何选择?

在看到题目时,可以通过以下特征快速定位算法模式:

  1. 对撞指针 (Two Pointers - Collision)
    • 特征 :输入数据有序(或部分有序)。
    • 目标:寻找两个数使其满足特定条件(如和为 target),或进行数组/字符串反转。
    • 典型题:Two Sum II, 盛水容器, 验证回文串。
  2. 快慢指针 (Two Pointers - Fast & Slow)
    • 特征 :链表操作,或数组原地修改。
    • 目标:检测环、找中点、倒数第 K 个节点;原地去重、移除元素。
    • 典型题:环形链表, 移除元素, 移动零。
  3. 滑动窗口 (Sliding Window)
    • 特征 :题目明确涉及 "连续子串" (Substring) 或 "连续子数组" (Subarray)。
    • 目标 :求满足条件的 "最长 / 最短 / 定长" 子结构。
    • 典型题:无重复最长子串, 最小覆盖子串, 异位词查找。

4.2 复杂度分析

  • 时间复杂度O ( N ) O(N) O(N)
    虽然滑动窗口代码中包含 while (收缩) 嵌套在 while (扩张) 内部,但从全局来看,每个元素最多被 right 指针访问加入窗口一次,被 left 指针访问移出窗口一次。总操作次数是 2 N 2N 2N,属于线性复杂度。
  • 空间复杂度O ( 1 ) O(1) O(1)O ( C ) O(C) O(C)
    通常只需要固定大小的辅助空间(如 int[128] 数组或常数个变量)来存储窗口状态,与输入规模 N N N 无关。

4.3 实现细节

  1. 区间定义
    建议始终维护 左闭右开区间 [left, right)
    • 初始时 left = right = 0,窗口为空。
    • 这样定义的优势在于:窗口的长度始终为 right - left,无需处理 +1 / -1 的下标偏移,减少边界错误。
  2. 结果更新时机
    不同类型的题目,更新结果(Answer Update)的位置不同,需严格区分:
    • 求最长子串 (如 LC 3):
      • 先扩张 (right++)。
      • 再收缩直到窗口合法 (valid)。
      • 收缩结束后 ,此时窗口一定是合法的最长状态,更新 maxLen
    • 求最短子数组 (如 LC 209):
      • 先扩张 (right++)。
      • 一旦窗口达标,进入收缩循环
      • 在收缩过程中 ,每次左移前都可能是一个解,因此在收缩循环内部更新 minLen
  3. 容器选择
    • 在处理字符串字符统计时(如 ASCII 字符),优先使用 int[] 数组 代替 HashMap
    • 原因:数组在内存中连续分布,CPU 缓存命中率极高,且省去了哈希计算和对象封装的开销。在 Java 中,这种优化通常能带来运行效率的提升。
相关推荐
Pluchon1 小时前
硅基计划4.0 算法 图的存储&图的深度广度搜索&最小生成树&单源多源最短路径
java·算法·贪心算法·深度优先·动态规划·广度优先·图搜索算法
今儿敲了吗2 小时前
19| 海底高铁
c++·笔记·学习·算法
冰暮流星2 小时前
javascript之字符串索引数组
开发语言·前端·javascript·算法
Hag_202 小时前
LeetCode Hot100 3.无重复字符的最长子串
算法·leetcode·职场和发展
好学且牛逼的马2 小时前
【Hot100|23-LeetCode 234. 回文链表 - 完整解法详解】
算法·leetcode·链表
小冻梨6662 小时前
ABC444 C - Atcoder Riko题解
c++·算法·双指针
我命由我123452 小时前
Kotlin 面向对象 - 匿名内部类、匿名内部类简化
android·java·开发语言·java-ee·kotlin·android studio·android jetpack
fu的博客2 小时前
【数据结构1】实现线性表
数据结构
学到头秃的suhian2 小时前
Redis分布式锁
java·数据库·redis·分布式·缓存