大家好,我是程序员牛奶,本文讲解的内容是双指针技巧的相关习题,读完本文可以去解决以下题目:
| LeetCode | 力扣 | 难度 |
|---|---|---|
| 26. Remove Duplicates from Sorted Array | 26. 删除有序数组中的重复项 | 🟢 |
| 83. Remove Duplicates from Sorted List | 83. 删除排序链表中的重复元素 | 🟢 |
| 27. Remove Element | 27. 移除元素 | 🟢 |
| 283. Move Zeroes | 283. 移动零 | 🟢 |
| 167. Two Sum II - Input Array Is Sorted | 167. 两数之和 II - 输入有序数组 | 🟡 |
| 344. Reverse String | 344. 反转字符串 | 🟢 |
| 5. Longest Palindromic Substring | 5. 最长回文子串 | 🟡 |
前置知识
阅读本文前,你需要先学习我的单链表双指针相关的算法内容。
全局地图
在处理数组和链表相关问题时,双指针技巧是经常用到的,主要分为两类:
- 左右指针:两个指针相向而行或者相背而行
- 快慢指针:两个指针同向而行,一快一慢
对于单链表来说,大部分技巧都属于快慢指针,比如链表环判断、倒数第 K 个节点等。在数组中并没有真正意义上的指针,但我们可以把索引当做指针,同样施展双指针技巧。本文主要讲数组相关的双指针算法。
一、快慢指针技巧
快慢指针的通用模板:
ini
int slow = 0, fast = 0;
while (fast < nums.length) {
if (满足条件) {
nums[slow] = nums[fast];
slow++;
}
fast++;
}
// slow 就是结果数组的长度
核心思想:fast 负责探路遍历整个数组,slow 负责维护结果数组的边界。fast 每次前进一步,只有满足条件时才把元素「收录」到 slow 位置。后续每道题都是往这个骨架里填不同的判断条件。
原地修改数组
26. 删除有序数组中的重复项
给你一个非严格递增排列的数组 nums,请你原地删除重复出现的元素,使每个元素只出现一次,返回删除后数组的新长度。
如果不是原地修改,我们直接 new 一个新数组把去重元素放进去就行了。但题目要求原地删除,不允许 new 新数组,只能在原数组上操作。
朴素思路的问题: 由于数组已经排序,重复元素一定连在一起,找出它们并不难。但如果每找到一个重复元素就立即原地删除,由于数组删除元素涉及数据搬移,整个时间复杂度会达到 O(N²)。
快慢指针的思路: 我们让慢指针 slow 走在后面,快指针 fast 走在前面探路,找到一个不重复的元素就赋值给 slow 并让 slow 前进一步。
具体来说,slow 和 fast 都从 0 出发。fast 每次前进一步,然后比较 nums[fast] 和 nums[slow]:
- 如果相等,说明是重复元素,
fast继续前进,slow不动 - 如果不等,说明遇到了新元素,先让
slow前进一步,再把nums[fast]赋值给nums[slow]
这样就保证了一个循环不变量:nums[0..slow] 始终是无重复的元素 。当 fast 遍历完整个数组后,nums[0..slow] 就是去重后的结果。
为什么返回 slow + 1 而不是 slow?因为 slow 是最后一个有效元素的索引,而数组长度 = 索引 + 1。
ini
class Solution {
public int removeDuplicates(int[] nums) {
if (nums.length == 0) {
return 0;
}
int slow = 0, fast = 0;
while (fast < nums.length) {
if (nums[fast] != nums[slow]) {
slow++;
// 维护 nums[0..slow] 无重复
nums[slow] = nums[fast];
}
fast++;
}
// 数组长度为索引 + 1
return slow + 1;
}
}
注意这里的操作顺序是先 slow++ 再赋值 。为什么?因为 nums[0](即初始的 slow 位置)一定是结果的一部分------有序数组的第一个元素不可能是「重复」的,所以 slow 需要先前进到下一个空位,再写入新值。
83. 删除排序链表中的重复元素
如果给你一个有序的单链表,如何去重呢?其实和数组去重一模一样,唯一的区别是把数组赋值操作变成操作指针而已。
思路对比: 数组中我们用 nums[slow] = nums[fast] 来「收录」新元素,链表中对应的操作是 slow.next = fast,即让 slow 的 next 指向 fast,跳过中间那些重复节点。同样地,slow = slow.next 对应数组中的 slow++。
判断条件也完全一致:fast.val != slow.val 时说明遇到了新元素。循环结束后,需要额外做一步 slow.next = null,断开 slow 与后面重复元素的连接。
ini
class Solution {
public ListNode deleteDuplicates(ListNode head) {
if (head == null) return null;
ListNode slow = head, fast = head;
while (fast != null) {
if (fast.val != slow.val) {
// nums[slow] = nums[fast];
slow.next = fast;
// slow++;
slow = slow.next;
}
// fast++
fast = fast.next;
}
// 断开与后面重复元素的连接
slow.next = null;
return head;
}
}
Note: 链表中那些重复的元素并没有被删掉,就让这些节点挂着合适吗?像 Java/Python 这类带有垃圾回收的语言,可以自动回收这些「悬空」节点的内存;而像 C++ 这类语言则需要手动释放。就算法思维的培养来说,我们只需要知道这种快慢指针技巧即可。
27. 移除元素
除了在有序数组/链表中去重,题目还可能让你对数组中的某些元素进行「原地删除」。给你一个数组 nums 和一个值 val,原地移除所有数值等于 val 的元素,返回移除后数组的新长度。
和去重的区别: 去重时判断条件是 nums[fast] != nums[slow](和 slow 比),这里的判断条件是 nums[fast] != val(和固定值比)。思路完全一样------如果 fast 遇到值为 val 的元素则直接跳过,否则赋值给 slow 并让 slow 前进一步。
但注意一个关键的细节差异:这里是先给 nums[slow] 赋值然后再 slow++ ,而去重那题是先 slow++ 再赋值。为什么?因为去重时 nums[0] 一定是结果的一部分(有序数组的第一个元素不可能重复),所以 slow 从 0 开始、先自增再赋值到新位置;而移除元素时 nums[0] 本身可能就等于 val,所以必须先赋值到当前 slow 位置再自增,确保不遗漏。
这导致循环不变量也略有不同:去重维护的是 nums[0..slow] 无重复,而移除元素维护的是 nums[0..slow-1] 不包含 val。最终返回值也不同:去重返回 slow + 1,移除元素直接返回 slow。
ini
class Solution {
public int removeElement(int[] nums, int val) {
int fast = 0, slow = 0;
while (fast < nums.length) {
if (nums[fast] != val) {
nums[slow] = nums[fast];
slow++;
}
fast++;
}
return slow;
}
}
283. 移动零
给你输入一个数组 nums,请你原地修改,将数组中的所有值为 0 的元素移到数组末尾。比如输入 nums = [0,1,4,0,2],算法会把 nums 原地修改成 [1,4,2,0,0]。
你是否已经有了答案?题目让我们将所有 0 移到最后,其实就相当于移除 nums 中的所有 0,然后再把后面的元素都赋值为 0。
为什么可以直接复用 removeElement: 「移动零」本质上就是「移除值为 0 的元素」+ 「把剩余位置填 0」。第一步和上一题完全一样,只是 val = 0;第二步只需要一个简单的 for 循环把 slow 之后的位置全部置零。
这就是快慢指针框架的威力------同一个模板,换个判断条件就能解决不同的题目,甚至可以组合使用。
ini
class Solution {
public void moveZeroes(int[] nums) {
// 去除 nums 中的所有 0,返回去除 0 之后的数组长度
int p = removeElement(nums, 0);
// 将 p 之后的所有元素赋值为 0
for (; p < nums.length; p++) {
nums[p] = 0;
}
}
int removeElement(int[] nums, int val) {
int fast = 0, slow = 0;
while (fast < nums.length) {
if (nums[fast] != val) {
nums[slow] = nums[fast];
slow++;
}
fast++;
}
return slow;
}
}
到这里,原地修改数组的这些题目就已经差不多了。回顾一下,四道题用的都是同一个快慢指针框架,区别仅在于判断条件和赋值顺序。
滑动窗口
数组中另一大类快慢指针的题目就是「滑动窗口算法」。left 指针在后,right 指针在前,两个指针中间的部分就是「窗口」,算法通过扩大和缩小「窗口」来解决某些问题。具体题目可参见滑动窗口算法核心框架详解。
二、左右指针的常用算法
左右指针的通用模板:
sql
int left = 0, right = nums.length - 1;
while (left < right) {
if (找到目标) {
return 结果;
} else if (需要收缩左边界) {
left++;
} else {
right--;
}
}
核心思想:left 从最左出发,right 从最右出发,两个指针相向而行,通过某种条件判断来决定移动哪个指针,逐步逼近目标。
167. 两数之和 II - 输入有序数组
给你一个下标从 1 开始的整数数组 numbers,该数组已按非递减顺序排列,请你从数组中找出满足相加之和等于目标数 target 的两个数,返回它们的下标。
关键洞察: 只要数组有序,就应该想到双指针技巧。
左右指针的思路: left 指向最小的元素,right 指向最大的元素,计算两者之和 sum:
- 如果
sum == target,找到答案,返回下标 - 如果
sum < target,说明需要让sum大一点,而数组是升序的,所以让left右移,这样numbers[left]会变大 - 如果
sum > target,说明需要让sum小一点,让right左移,这样numbers[right]会变小
为什么这样做是正确的?因为数组有序,left 右移一定让 sum 增大,right 左移一定让 sum 减小,所以我们可以通过调整指针来精确控制 sum 的大小,逐步逼近 target。而且由于每次操作都排除了一个不可能的元素,两个指针最终一定会相遇,时间复杂度为 O(N)。
sql
class Solution {
public int[] twoSum(int[] numbers, int target) {
// 一左一右两个指针相向而行
int left = 0, 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++; // 让 sum 大一点
} else if (sum > target) {
right--; // 让 sum 小一点
}
}
return new int[]{-1, -1};
}
}
类似的左右指针技巧还可以推广到 nSum 问题的通用解法,详见一个函数秒杀所有 nSum 问题。
344. 反转字符串
给你一个 char[] 类型的字符数组,请你原地反转这个数组。
思路: 这是左右指针最直观的应用。left 从最左出发,right 从最右出发,每次交换 s[left] 和 s[right],然后两个指针同时向中间靠拢,直到相遇。
为什么这样就能反转?因为每次交换都把「应该在右边的元素」和「应该在左边的元素」互换了位置。当 left 和 right 相遇时,所有元素都已经到了正确的位置。
ini
void reverseString(char[] s) {
// 一左一右两个指针相向而行
int left = 0, right = s.length - 1;
while (left < right) {
// 交换 s[left] 和 s[right]
char temp = s[left];
s[left] = s[right];
s[right] = temp;
left++;
right--;
}
}
5. 最长回文子串
给你一个字符串 s,找到 s 中最长的回文子串。
回文串就是正着读和反着读都一样的字符串,比如 aba 和 abba。判断一个串是不是回文,自然可以用左右指针从两端向中间逼近:
sql
boolean isPalindrome(String s) {
int left = 0, right = s.length() - 1;
while (left < right) {
if (s.charAt(left) != s.charAt(right)) {
return false;
}
left++;
right--;
}
return true;
}
但找最长回文子串的难点在于,回文串的长度可能是奇数也可能是偶数,我们不知道回文的中心在哪里。
从中心向两端扩展的思路: 换个角度------与其从两端向中间收缩来「验证」回文,不如从中间向两端扩展来「寻找」回文。我们枚举每一个可能的中心位置,然后用左右指针从中心向两边展开,直到不满足回文条件为止。
具体来说,我们实现一个辅助函数 palindrome(s, l, r),从 s[l] 和 s[r] 开始向两边展开:
- 只要
l >= 0 && r < s.length() && s.charAt(l) == s.charAt(r),就继续展开(l--,r++) - 当条件不满足时停止,此时
s[l+1..r-1]就是以该中心找到的最长回文串
如果输入相同的 l 和 r(如 palindrome(s, i, i)),就相当于寻找长度为奇数的回文串(中心是一个字符);如果输入相邻的 l 和 r(如 palindrome(s, i, i+1)),则相当于寻找长度为偶数的回文串(中心是两个字符之间的间隙)。
所以完整算法就是:遍历每个位置 i,分别以 i 为中心和以 i, i+1 为中心找最长回文,取所有结果中最长的那个。
ini
class Solution {
public String longestPalindrome(String s) {
String res = "";
for (int i = 0; i < s.length(); i++) {
// 以 s[i] 为中心的最长回文子串(奇数长度)
String s1 = palindrome(s, i, i);
// 以 s[i] 和 s[i+1] 为中心的最长回文子串(偶数长度)
String s2 = palindrome(s, i, i + 1);
res = res.length() > s1.length() ? res : s1;
res = res.length() > s2.length() ? res : s2;
}
return res;
}
String palindrome(String s, int l, int r) {
// 向两边展开,直到不满足回文条件
while (l >= 0 && r < s.length()
&& s.charAt(l) == s.charAt(r)) {
l--;
r++;
}
// 循环结束时 s[l] != s[r],所以回文串是 s[l+1..r-1]
return s.substring(l + 1, r);
}
}
你应该能发现最长回文子串使用的左右指针和之前题目有所不同:之前的左右指针都是从两端向中间相向而行,而回文子串问题则是让左右指针从中心向两端扩展(相背而行)。不过这种情况也就回文串这类问题会遇到,所以也归为左右指针。
时间复杂度为 O(N²):外层循环 O(N),每次展开最坏 O(N)。存在更优的 Manacher 算法可以做到 O(N),但面试中掌握中心扩展法已经足够,感兴趣可自行了解。
总结
| 题目 | 指针类型 | 核心判断条件 | 返回值 |
|---|---|---|---|
| 26. 删除有序数组中的重复项 | 快慢 | nums[fast] != nums[slow] |
slow + 1 |
| 83. 删除排序链表中的重复元素 | 快慢 | fast.val != slow.val |
head |
| 27. 移除元素 | 快慢 | nums[fast] != val |
slow |
| 283. 移动零 | 快慢 | 复用 removeElement(nums, 0) | 无 |
| 167. 两数之和 II | 左右 | sum 与 target 的大小关系 |
下标数组 |
| 344. 反转字符串 | 左右 | left < right 时交换 |
无 |
| 5. 最长回文子串 | 左右(中心扩展) | s[l] == s[r] 时继续展开 |
子串 |
决策路径:
- 遇到「原地修改/删除」→ 快慢指针,
slow维护结果边界 - 遇到「有序数组 + 查找/配对」→ 左右指针相向而行
- 遇到「回文」→ 左右指针从中心向两端扩展
- 遇到「子数组/子串 + 条件约束」→ 滑动窗口(快慢指针变体),详见我的滑动窗口相关文章。