1.双指针的基本思想
双指针,指的是在遍历对象的过程中,不是普通的使用单个指针进行访问,而是使用两个相同方向(快慢指针)或者相反方向(对撞指针)的指针进行扫描,从而达到相应的目的。
换言之,双指针法充分使用了数组有序这一特征,从而在某些情况下能够简化一些运算。
不过这里的指针并不是真正意义的指针,只是两个遍历数组的游标罢了。在链表中也可以使用双指针,定义了两个引用,也不算真正的指针。所以双指针只是一种思想的统称,没必要追究其名字。
双指针有两种基本的策略,一个称为快慢指针,一个称为对撞指针。快慢指针是从一头开始,一个走的快一个走的慢,一般的快是用来选择满足条件的参数,而慢的则是已经固定好的元素,该场景我们在数组和链表章节介绍了很多,而冒泡排序也属于这种思想。另一种是对撞型的,两个指针分别从两侧向中间走,很多场景不能一次就移动完,可能要多次执行才可以,典型的例子就是快速排序。
两个指针看似简单,但是能解决非常多的复杂问题。
2.快慢指针
快慢指针是两个指针从同一侧开始遍历数组,将这两个指针分别定义为快指针(fast)
和慢指针(slow)
,两个指针以不同的策略移动,直到两个指针的值相等(或其他特殊条件)为止,如 fast 每次增长两个,slow 每次增长一个等等。
在基本算法中典型的例子就是对数组进行增加或者删除元素的时候,为了防止大量移动元素,我们会采用两个游标来辅助操作,一个是当前正访问的对象(fast),我们要判断该元素是否满足我们的要求,不满足会跳过继续向前走。第二个用来标记已经调整好的(slow),当fast找到符合要求的元素时就会将其添加到slow位置。
典型的题目有:
1.LeetCode26 给你一个有序数组 nums ,请你原地删除重复出现的元素,使每个元素只出现一次 ,返回删除后数组的新长度。
2.剑指offer题目:请实现一个函数,将一个字符串中的每个空格替换成“%20”。例如,当字符串为We Are Happy.则经过替换之后的字符串为We%20Are%20Happy。
3.链表问题,LeetCode141判断链表中是否有环。
4.寻找链表中间结点。
我们来看一下做法。LeetCode26是一个常规的题目,使用双指针最方便,一个指针负责责数组遍历,一个指向有效数组的最后一个位置。当两个指针的值不一样时,才将指向有效位的向下移动。
public int removeDuplicates1(int[] nums) {
int n = nums.length;
//j用来标记有效位
int j = 0;
for (int i = 0; i < n; i++) {
if (nums[i] != nums[j]) {
nums[++j] = nums[i];
}
}
return j + 1;
}
剑指offer字符串替换的问题,这个题比较特别的一点是移动的时候要先计算一遍空格的数量,算出调整之后的总长度,然后从后向前开始移动。这样做的好处同样是为了每次替换空格都要进行大量的元素移动。
public String replaceSpace(StringBuffer str) {
String res="";
for(int i=0;i<str.length();i++){
char c=str.charAt(i);
if(c==' ')
res += "%20";
else
res += c;
}
return res;
}
对于链表,快慢指针同样适用,而且应用还非常广。
LeetCode141,给定一个链表,判断链表中是否有环。一个快指针(一次走两步),一个慢指针(一次走一步)。如果快的能到达表尾就不会有环,否则如果存在圈,则慢指针一定会在某个位置与快指针相遇。这就像在操场长跑,一个人快一个人慢,只要时间够,快的一定能在某个时候再次追上慢的人(也就是所谓的套圈)。代码就是这样:
public boolean hasCycle(ListNode head) {
if(head==null || head.next==null){
return false;
}
ListNode fast=head;
ListNode slow=head;
while(fast!=null && fast.next!=null){
fast=fast.next.next;
slow=slow.next;
if(fast==slow)
return true;
}
return false;
}
而寻找链表的中点更容易,就是上面题目的一部分,还可以让快指针一次前进两步,慢指针一次前进一步,当快指针到达链表尽头时,慢指针就处于链表的中间位置。
ListNode slow, fast;
slow = fast = head;
while (fast != null && fast.next != null) {
fast = fast.next.next;
slow = slow.next;
}
// slow 就在中间位置
return slow;
3.对撞指针
对撞指针是指在数组中,将指向最左侧的索引定义为左指针(left)
,最右侧的定义为右指针(right)
,然后从两头向中间进行数组遍历。
对撞数组适用于连续数组和字符串,也就是说当你遇到题目给定连续数组和字符串问题时,应该第一时间想想对撞指针行不行。
这个最典型的例子就是快速排序、以及数组的奇偶移动等等。
例如LeetCode905 输入一个整数数组,通过一个函数来调整该数组中数字的顺序,使得所有的奇数位于数组的前半部分,所有的偶数位于数组的后半部分。
这个题最直接的方式是使用一个临时数组,第一遍查找并将所有的偶数复制给新数组,第二遍查找并复制所有的奇数给数组。代码如下:
class Solution {
public int[] sortArrayByParity(int[] A) {
int[] ans = new int[A.length];
int t = 0;
for (int i = 0; i < A.length; ++i)
if (A[i] % 2 == 0)
ans[t++] = A[i];
for (int i = 0; i < A.length; ++i)
if (A[i] % 2 == 1)
ans[t++] = A[i];
return ans;
}
}
# 4. 两种指针的移动场景
双指针的应用非常广,那怎么判断使用哪种双指针呢?这个问题比较难回答,如果思考的角度不同就可能有不同的解答,甚至两种指针方式都可以用,例如leetcode27 给你一个数组 nums 和一个值 val,你需要原地移除所有数值等于 val 的元素,并返回移除后数组的新长度。
在删除的时候,从删除位置开始的所有元素都要向前移动下,所以这题的关键就是如果很多值为val的元素,如何避免反复向前移动。 有两种处理方式,为了开拓思路,我们都看一下:
(1)第一种方法:快慢指针
将数组分成前后两段,定义两个指针i和j,初始值都是0。
i之前的位置都是有效部分,j表示当前要访问的元素。
这样遍历的时候,j不断向后移动:
如果array[j]的值不为val,则将其移动到array[++i]处。
如果array[j]的值为val,则j继续向前移动
这样,前半部分是有效部分,后半部分是无效部分。
其实只可以借助for的语法进一步简化实现:
public int removeElement(int[] nums, int val) {
int ans = 0;
for(int num: nums) {
if(num != val) {
nums[ans] = num;
ans++;
}
}
//最后剩余元素的数量
return ans;
}
这里虽然只有一个变量ans,但是for循环的写法帮助我们减少了一个定义。
(2)第二种方式:对撞双指针
这里还有一种解法,有的地方叫做交换移除,思路就是我们还是从两端开始向中间遍历,left遇到num[i]=val的时候停下来,右侧继续。当右侧遇到num[j]!=val的位置的时候,将num[j]交换或者直接覆盖num[i]。之后i继续向右走。
public int removeElement(int[] nums, int val) {
int ans = nums.length;
for (int i = 0; i < ans;) {
if (nums[i] == val) {
nums[i] == nums[ans - 1];
ans--;
} else {
i++;
}
}
return ans;
}
这个思路其实和快速排序中执行一轮的过程非常类似。快速排序就是标定一个元素,比它小的全在其左侧,比它大的全在右侧。