Java 双指针 - 附LeetCode 经典题解
一、什么是双指针?
1.1 形象理解
想象你和朋友一起在操场跑步:
- 对撞指针:你们从操场两端相向而跑,最终在中间相遇
- 快慢指针:你跑得快,朋友跑得慢,你们从同一起点出发
- 滑动窗口:你们保持一定距离,一起向前移动
在算法中,双指针就是:
- 指针:数组或链表中的索引/引用
- 双:同时使用两个指针
- 目的:通过协同移动,在 O(n) 时间内解决问题
1.2 双指针的本质
双指针其实就是用两个变量追踪不同位置,避免嵌套循环:
tex
数组:[1, 2, 3, 4, 5, 6, 7, 8]
↑ ↑
left right
Java 特别注意: 在 Java 中,数组索引从 0 开始,链表使用 node.next 移动指针。
1.3 双指针的核心思想
核心: 通过两个指针的协同移动,将 O(n²) 的暴力解法优化为 O(n)。
关键点:
- 确定指针的初始位置
- 确定指针的移动规则
- 确定指针的终止条件
1.4 双指针的三大类型
1.4.1 对撞指针(相向双指针)
特点: 两个指针从两端向中间移动,直到相遇。
适用场景:
- 有序数组的查找问题
- 反转数组/字符串
- 两数之和、三数之和
- 判断回文串(如果是数字的话,建议用反转后半部分同前半部分比较来判断)
模板:
java
public void collisionPointer(int[] nums) {
int left = 0;
int right = nums.length - 1;
while (left < right) {
// 根据条件移动指针
if (满足某条件) {
left++;
} else {
right--;
}
}
}
图解:
tex
数组:[1, 2, 3, 4, 5, 6, 7, 8]
步骤1:left=0, right=7 → [1] ... [8]
步骤2:left=1, right=6 → [2] ... [7]
步骤3:left=2, right=5 → [3] ... [6]
步骤4:left=3, right=4 → [4] ... [5]
步骤5:left=4, right=3 → 相遇,结束
1.4.2 快慢指针(同向双指针)
特点: 两个指针从同一端出发,以不同速度移动。
适用场景:
- 链表环检测(Floyd 判圈算法)
- 链表中点查找
- 数组去重
- 删除元素
模板:
java
public void fastSlowPointer(int[] nums) {
int slow = 0;
int fast = 0;
while (fast < nums.length) {
if (满足某条件) {
nums[slow] = nums[fast];
slow++;
}
fast++;
}
}
图解:
tex
数组:[1, 1, 2, 2, 3, 3, 4, 4]
步骤1:slow=0, fast=0 → [1] ...
步骤2:slow=1, fast=1 → [1] [1] ...(重复,slow不动)
步骤3:slow=1, fast=2 → [1] [2] ...(不重复,slow移动)
步骤4:slow=2, fast=3 → [1] [2] [2] ...(重复,slow不动)
...
1.4.3 滑动窗口(特殊的双指针)
博主的下一篇文章会详细解释滑动窗口,在这里仅作简单解释。
特点: 两个指针维护一个窗口,窗口大小可变或固定。
适用场景:
- 子串/子数组问题
- 最长/最短满足条件的序列
- 字符串匹配
模板:
java
public int slidingWindow(int[] nums) {
int left = 0, right = 0;
int result = 0;
while (right < nums.length) {
// 扩大窗口
right++;
// 收缩窗口
while (窗口不满足条件) {
left++;
}
// 更新结果
result = Math.max(result, right - left);
}
return result;
}
1.4.4 对比总结
| 特性 | 对撞指针 | 快慢指针 | 滑动窗口 |
|---|---|---|---|
| 起点 | 两端 | 同一端 | 同一端 |
| 方向 | 相向 | 同向 | 同向 |
| 速度 | 相同/不同 | 不同 | 不同 |
| 典型题目 | 两数之和 | 链表环检测 | 最长子串 |
| 难度 | 简单/中等 | 中等 | 中等/困难 |
记忆技巧:
- 对撞指针:像两辆车相向而行
- 快慢指针:像龟兔赛跑
- 滑动窗口:像火车车厢移动
二、LeetCode 经典题解
2.1 对撞指针:两数之和 II(LeetCode 167)
题目描述
给你一个下标从 1 开始的整数数组 numbers,该数组已按非递减顺序排列 ,请你从数组中找出满足相加之和等于目标数 target 的两个数。
示例:
输入:numbers = [2,7,11,15], target = 9
输出:[1,2]
解释:2 与 7 之和等于目标数 9。因此 index1 = 1, index2 = 2。
解题思路
这是典型的对撞指针问题。
核心思想:
- 左指针指向数组开头,右指针指向数组末尾
- 计算两数之和
- 如果和等于 target,返回结果
- 如果和小于 target,左指针右移(增大和)
- 如果和大于 target,右指针左移(减小和)
为什么可以用对撞指针?
- 数组已排序,左边的数小,右边的数大
- 通过移动指针,可以有序地调整和的大小
完整代码
java
public class Solution {
public int[] twoSum(int[] numbers, int target) {
// 1. Java 中必须先判断 null
if (numbers == null || numbers.length < 2) {
return new int[0];
}
// 2. 初始化对撞指针
int left = 0;
int right = numbers.length - 1;
// 3. 对撞指针移动
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[0]; // 未找到
}
}
时间复杂度: O(n)
空间复杂度: O(1)
2.2 对撞指针:三数之和(LeetCode 15)
题目描述
给你一个整数数组 nums,判断是否存在三元组 [nums[i], nums[j], nums[k]] 满足 i != j、i != k 且 j != k,同时还满足 nums[i] + nums[j] + nums[k] == 0。
示例:
输入:nums = [-1,0,1,2,-1,-4]
输出:[[-1,-1,2],[-1,0,1]]
解题思路
这是典型的对撞指针问题,是两数之和的进阶版,思路大致一致,但是难度稍大一点
核心思想:
- 先对数组排序
- 固定一个数
nums[i] - 用对撞指针在剩余数组中找两数之和等于
-nums[i] - 去重处理
完整代码
java
import java.util.*;
public class Solution {
public List<List<Integer>> threeSum(int[] nums) {
List<List<Integer>> result = new ArrayList<>();
if (nums == null || nums.length < 3) {
return result;
}
// 1. 排序(Java 中使用 Arrays.sort())
Arrays.sort(nums);
// 2. 固定第一个数
for (int i = 0; i < nums.length - 2; i++) {
// 去重:跳过重复的第一个数
if (i > 0 && nums[i] == nums[i - 1]) {
continue;
}
// 3. 对撞指针找另外两个数
int left = i + 1;
int right = nums.length - 1;
int target = -nums[i];
while (left < right) {
int sum = nums[left] + nums[right];
if (sum == target) {
result.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 < target) {
left++;
} else {
right--;
}
}
}
return result;
}
}
时间复杂度: O(n²)
空间复杂度: O(1)(不计结果数组)
2.3 快慢指针:删除有序数组中的重复项(LeetCode 26)
题目描述
给你一个非严格递增排列 的数组 nums,请你原地 删除重复出现的元素,使每个元素只出现一次,返回删除后数组的新长度。
示例:
tex
输入:nums = [1,1,2]
输出:2, nums = [1,2,_]
解释:函数应该返回新的长度 2,并且原数组 nums 的前两个元素被修改为 1, 2。
解题思路
这是典型的快慢指针问题。
核心思想:
- 慢指针指向不重复元素的末尾
- 快指针遍历数组
- 当快指针指向的元素与慢指针不同时,将快指针的元素复制到慢指针的下一个位置
完整代码
java
public class Solution {
public int removeDuplicates(int[] nums) {
if (nums == null || nums.length == 0) {
return 0;
}
// 1. 初始化快慢指针
int slow = 0; // 慢指针:不重复元素的末尾
// 2. 快指针遍历数组
for (int fast = 1; fast < nums.length; fast++) {
// 3. 如果快指针指向的元素与慢指针不同
if (nums[fast] != nums[slow]) {
slow++;
nums[slow] = nums[fast];
}
}
// 4. 返回新长度
return slow + 1;
}
}
时间复杂度: O(n)
空间复杂度: O(1)
2.4 快慢指针:链表环检测(LeetCode 141)
** 题目描述**
给你一个链表的头节点 head,判断链表中是否有环。
示例:
tex
输入:head = [3,2,0,-4], pos = 1
输出:true
解释:链表中有一个环,其尾部连接到第二个节点。
解题思路
这是经典的 Floyd 判圈算法(龟兔赛跑算法)。
核心思想:
- 快指针每次走两步,慢指针每次走一步
- 如果有环,快指针最终会追上慢指针
- 如果无环,快指针会先到达链表末尾
完整代码
java
public class Solution {
public boolean hasCycle(ListNode head) {
// 1. 边界判断
if (head == null || head.next == null) {
return false;
}
// 2. 初始化快慢指针
ListNode slow = head;
ListNode fast = head;
// 3. 快慢指针移动
while (fast != null && fast.next != null) {
slow = slow.next; // 慢指针走一步
fast = fast.next.next; // 快指针走两步
// 4. 如果相遇,说明有环
if (slow == fast) {
return true;
}
}
// 5. 快指针到达末尾,说明无环
return false;
}
}
// Java 链表节点定义
class ListNode {
int val;
ListNode next;
ListNode(int x) {
val = x;
next = null;
}
}
时间复杂度: O(n)
空间复杂度: O(1)
2.5 快慢指针:链表的中间节点(LeetCode 876)
题目描述
给你单链表的头节点 head,请你找出并返回链表的中间节点。如果有两个中间节点,则返回第二个中间节点。
示例:
tex
输入:head = [1,2,3,4,5]
输出:[3,4,5]
解释:链表只有一个中间节点,值为 3。
完整代码
java
public class Solution {
public ListNode middleNode(ListNode head) {
if (head == null) {
return null;
}
// 快慢指针
ListNode slow = head;
ListNode fast = head;
// 快指针走两步,慢指针走一步
while (fast != null && fast.next != null) {
slow = slow.next;
fast = fast.next.next;
}
// 当快指针到达末尾时,慢指针正好在中间
return slow;
}
}
时间复杂度: O(n)
空间复杂度: O(1)
2.6 对撞指针:反转字符串(LeetCode 344)
题目描述
编写一个函数,其作用是将输入的字符串反转过来。输入字符串以字符数组 s 的形式给出。
示例:
tex
输入:s = ["h","e","l","l","o"]
输出:["o","l","l","e","h"]
完整代码
java
public class Solution {
public void reverseString(char[] s) {
if (s == null || s.length == 0) {
return;
}
// 对撞指针
int left = 0;
int right = s.length - 1;
while (left < right) {
// 交换
char temp = s[left];
s[left] = s[right];
s[right] = temp;
left++;
right--;
}
}
}
时间复杂度: O(n)
空间复杂度: O(1)
三、Java 易错点总结
3.1 错误一:链表空指针异常
java
// 错误写法:未判断 null
public boolean hasCycle(ListNode head) {
ListNode slow = head;
ListNode fast = head.next.next; // 如果 head.next 是 null,抛出 NullPointerException
}
// 正确写法:先判断 null
public boolean hasCycle(ListNode head) {
if (head == null || head.next == null) {
return false;
}
ListNode slow = head;
ListNode fast = head;
while (fast != null && fast.next != null) { // 必须同时判断
slow = slow.next;
fast = fast.next.next;
}
}
3.2 错误二:数组越界
java
// 错误写法:未判断边界
int left = 0;
int right = nums.length; // 应该是 nums.length - 1
while (left < right) {
int sum = nums[left] + nums[right]; // 当 right = nums.length 时,数组越界
}
// 正确写法
int left = 0;
int right = nums.length - 1; // Java 数组下标从 0 到 length-1
3.3 错误三:忘记去重
java
// 错误写法:未去重,导致结果包含重复的三元组
for (int i = 0; i < nums.length; i++) {
// 未跳过重复的第一个数
}
// 正确写法:去重
for (int i = 0; i < nums.length; i++) {
if (i > 0 && nums[i] == nums[i - 1]) {
continue; // 跳过重复的第一个数
}
}
3.4 错误四:快慢指针初始化错误
java
// 错误写法:快指针初始化错误
ListNode slow = head;
ListNode fast = head.next; // 这样会导致判断逻辑复杂
// 正确写法:快慢指针都从 head 开始
ListNode slow = head;
ListNode fast = head;
3.5 错误五:交换元素时未使用临时变量
java
// 错误写法:直接赋值会丢失数据
s[left] = s[right];
s[right] = s[left]; // 此时 s[left] 已经被修改,s[right] 也变成了原来的 s[right]
// 正确写法:使用临时变量
char temp = s[left];
s[left] = s[right];
s[right] = temp;
四、Java 双指针通用模板
4.1 对撞指针模板
java
public class CollisionPointerTemplate {
// Java 对撞指针通用模板
public void collisionPointer(int[] nums) {
if (nums == null || nums.length == 0) {
return;
}
int left = 0;
int right = nums.length - 1;
while (left < right) {
// 根据条件移动指针
if (满足某条件) {
// 处理逻辑
left++;
} else {
right--;
}
}
}
}
4.2 快慢指针模板(数组)
java
public class FastSlowPointerTemplate {
// Java 快慢指针通用模板(数组)
public int fastSlowPointer(int[] nums) {
if (nums == null || nums.length == 0) {
return 0;
}
int slow = 0;
for (int fast = 0; fast < nums.length; fast++) {
if (满足某条件) {
nums[slow] = nums[fast];
slow++;
}
}
return slow;
}
}
4.3 快慢指针模板(链表)
java
public class FastSlowPointerListTemplate {
// Java 快慢指针通用模板(链表)
public ListNode fastSlowPointer(ListNode head) {
if (head == null || head.next == null) {
return head;
}
ListNode slow = head;
ListNode fast = head;
while (fast != null && fast.next != null) {
slow = slow.next;
fast = fast.next.next;
// 根据题目需求处理
if (slow == fast) {
return slow;
}
}
return null;
}
}
五、Java 刷题优化技巧
5.1 数组排序
java
// Java 中对数组排序
Arrays.sort(nums); // 升序排序,时间复杂度 O(n log n)
// 降序排序(需要转换为 Integer[])
Integer[] arr = Arrays.stream(nums).boxed().toArray(Integer[]::new);
Arrays.sort(arr, Collections.reverseOrder());
5.2 链表操作
java
// 创建虚拟头节点(简化边界处理)
ListNode dummy = new ListNode(0);
dummy.next = head;
// 删除节点
prev.next = prev.next.next;
// 反转链表
ListNode prev = null;
ListNode curr = head;
while (curr != null) {
ListNode next = curr.next;
curr.next = prev;
prev = curr;
curr = next;
}
5.3 避免整数溢出
java
// 可能溢出
int sum = nums[left] + nums[right];
if (sum == target) { ... }
// 避免溢出(如果数值很大)
long sum = (long) nums[left] + nums[right];
if (sum == target) { ... }
六、总结
6.1 核心要点
- 双指针 = 两个变量协同移动
- 对撞指针:从两端向中间移动
- 快慢指针:从同一端以不同速度移动
- 滑动窗口:维护一个可变/固定大小的窗口
- 时间复杂度通常是 O(n)
- 空间复杂度通常是 O(1)
6.2 记忆口诀
tex
对撞指针,两端相向
快慢指针,同向不同速
滑动窗口,维护区间
数组排序,链表判空
去重处理,避免重复
Java 中,先判 null
6.3 同类题推荐
| 题号 | 题目 | 难度 | 指针类型 | 核心技巧 |
|---|---|---|---|---|
| 167 | 两数之和 II | 简单 | 对撞指针 | 有序数组 |
| 15 | 三数之和 | 中等 | 对撞指针 | 排序 + 去重 |
| 11 | 盛最多水的容器 | 中等 | 对撞指针 | 贪心 |
| 26 | 删除有序数组中的重复项 | 简单 | 快慢指针 | 原地修改 |
| 141 | 环形链表 | 简单 | 快慢指针 | Floyd 判圈 |
| 142 | 环形链表 II | 中等 | 快慢指针 | 找环入口 |
| 876 | 链表的中间节点 | 简单 | 快慢指针 | 找中点 |
| 344 | 反转字符串 | 简单 | 对撞指针 | 交换 |
| 125 | 验证回文串 | 简单 | 对撞指针 | 字符判断 |
希望这篇文章能帮你彻底掌握 Java 双指针算法!记住:对撞指针从两端,快慢指针同起点。多刷题,多总结,模板用熟了就能秒杀同类题。加油!
作者:[识君啊]
不要做API的搬运工,要做原理的探索者!