算法通关村第三关——双指针思想以及应用(白银)
1. 双指针思想
双指针是一种思想,一种技巧或一种方法,并不是什么特别具体的算法,在二分查找等算法中经常用到这个技巧。具体就是用两个变量动态存储两个或多个结点,来方便我们进行一些操作。通常用在线性的数据结构中,比如链表和数组,有时候也会用在图算法中。
在我们遇到像数组,链表这类数据结构的算法题目的时候,应该要想得到双指针的套路来解决问题。特别是链表类的题目,经常需要用到两个或多个指针配合来记忆链表上的节点,完成某些操作
1.1 用法
一般来讲,当遇到需要对一个数组进行重复遍历时,可以想到使用双指针法。
判断指针移动的条件是双指针的核心
1.2 类型
1.2.1 快慢指针
快慢指针:左右两个指针,一块一慢
类似于龟兔赛跑,两个链表上的指针从同一节点出发,其中一个指针前进速度是另一个指针的两倍。利用快慢指针可以用来解决某些算法问题,比如
- 计算链表的中点:快慢指针从头节点出发,每轮迭代中,快指针向前移动两个节点,慢指针向前移动一个节点,最终当快指针到达终点的时候,慢指针刚好在中间的节点。
- 判断链表是否有环:如果链表中存在环,则在链表上不断前进的指针会一直在环里绕圈子,且不能知道链表是否有环。使用快慢指针,当链表中存在环时,两个指针最终会在环中相遇。
- 判断链表中环的起点:当我们判断出链表中存在环,并且知道了两个指针相遇的节点,我们可以让其中任一个指针指向头节点,然后让它俩以相同速度前进,再次相遇时所在的节点位置就是环开始的位置。
- 求链表中环的长度:只要相遇后一个不动,另一个前进直到相遇算一下走了多少步就好了
- 求链表倒数第k个元素:先让其中一个指针向前走k步,接着两个指针以同样的速度一起向前进,直到前面的指针走到尽头了,则后面的指针即为倒数第k个元素。(严格来说应该叫先后指针而非快慢指针)
1.2.2 对撞指针
对撞指针:左右两个指针,向中间靠拢。
一般都是排好序的数组或链表,否则无序的话这两个指针的位置也没有什么意义。特别注意两个指针的循环条件在循环体中的变化,小心右指针跑到左指针左边去了。常用来解决的问题有
-
二分查找问题
-
n数之和问题:比如两数之和问题,先对数组排序然后左右指针找到满足条件的两个数。如果是三数问题就转化为一个数和另外两个数的两数问题。以此类推。
1.2.3 滑动窗口
滑动窗口:左右两个指针组成一个"窗口",右指针不断扩张,左指针按条件收缩。
两个指针,一前一后组成滑动窗口,并计算滑动窗口中的元素的问题。
这类问题一般包括
1、字符串匹配问题
2、子数组问题
2. 删除元素专题
2.1 原地移除所有数值等于val的元素
方法1:快慢指针
class Solution {
public int removeElement(int[] nums, int val) {
int slow = 0;
for(int fast = 0; fast <= nums.length - 1; fast++){
if(nums[fast] != val){
nums[slow++] = nums[fast];
}
}
return slow;
}
}
时间复杂度是O(n),其中n是数组的长度。在遍历数组过程中,每个元素最多被读取和写入一次。
空间复杂度是O(1),因为只使用了常量级别的额外空间。
方法2:碰撞指针
这里首先理解好题意:
题意说:元素的顺序可以改变。你不需要考虑数组中超出新长度后面的元素。
也就是,快慢指针多了一些重复赋值的操作,那么使用碰撞指针,一个队头一个队尾
class Solution {
public int removeElement(int[] nums, int val) {
int left = 0;
int right = nums.length;
while (left < right) {
if (nums[left] == val) {
nums[left] = nums[right - 1];
right--;
} else {
left++;
}
}
return left;
}
}
这里有个比较难理解的地方:
就是,如果右指针拿到的值是val呐?所以,这里做法就是,无论右指针拿到什么都赋给左指针,左指针直到值不等于val才移动
当我们使用例子来一步步讲解这个算法时,假设给定的数组为nums = [3, 2, 2, 3]
,要移除的元素是val = 3
。
- 初始化左指针
left
为0,右指针right
为数组长度4。 - 进入循环,首先比较
nums[left]
和val
。如果nums[left]
等于val
,则将nums[left]
替换为nums[right - 1]
,同时将right
减1。此时数组变为[2, 2, 2, 3]
,左指针不变,继续下一轮循环。 - 比较
nums[left]
和val
。如果nums[left]
不等于val
,则将左指针left
加1,此时left
变为1。 - 进入下一轮循环,比较
nums[left]
和val
。因为nums[left]
不等于val
,所以左指针不变,继续下一轮循环。 - 比较
nums[left]
和val
。因为nums[left]
等于val
,则将nums[left]
替换为nums[right - 1]
,同时将right
减1。此时数组变为[2, 2, 2, 2]
,左指针不变,继续下一轮循环。 - 比较
nums[left]
和val
。因为nums[left]
等于val
,则将nums[left]
替换为nums[right - 1]
,同时将right
减1。此时数组变为[2, 2, 2, 2]
,左指针不变,继续下一轮循环。 - 左指针小于右指针,进入下一轮循环,比较
nums[left]
和val
。因为nums[left]
不等于val
,则将左指针left
加1,此时left
变为2。 - 左指针大于等于右指针,退出循环。
- 返回左指针
left
,即为移除给定值后数组的长度2。
所以,最终移除给定值3后的数组为nums = [2, 2]
,数组长度为2。
2.2 删除有序数组中的重复项
方法1:快慢指针(自己的写法)
-
初始时将
slow
指针指向第一个元素, -
然后使用
fast
指针从第二个元素开始遍历数组。 -
当
fast
指针指向与slow
指针指向的元素不同时,将slow
指针向前移动一位,并将fast
指针指向的元素赋值给slow
指针所在位置。 -
最后返回
slow + 1
,即为去重后的数组长度。
class Solution {
public int removeDuplicates(int[] nums) {
int slow = 0;
for(int fast = 1; fast <= nums.length-1; fast++){
if(nums[slow] != nums[fast]){
slow++;
nums[slow] = nums[fast];
}
}
return slow+1;
}
}
时间复杂度为 O(n),其中 n 是数组的长度。因为只需对数组进行一次遍历。
空间复杂度为 O(1)
方法2:快慢指针(算法村方法)
public static int removeDuplicates(int[] nums) {
//slow表示可以放入新元素的位置,索引为0的元素不用管
int slow = 1;
//循环起到了快指针的作用
for (int fast = 0; fast < nums.length; fast++) {
if (nums[fast] != nums[slow - 1]) {
nums[slow] = nums[fast];
slow++;
}
}
return slow;
}
-
在这个方法中,我们使用
slow
指针来指示可以放入新元素的位置。 -
初始时,
slow
的值为1,因为索引为0的元素无论如何都会保留。 -
然后,我们使用一个循环来遍历整个数组,其中
fast
指针起到了快指针的作用。 -
对于每个遍历到的元素,我们通过条件判断
nums[fast] != nums[slow - 1]
来确定是否需要保留当前元素。 -
如果当前元素与
slow
指针之前的元素不相等,说明该元素应该保留。 -
如果满足条件,我们将当前元素赋值给
nums[slow]
的位置,并将slow
指针向后移动一位。
通过这样的遍历方式,我们可以确保只有不重复的元素被放入新数组中。最后,返回 slow
的值,即为去重后的数组长度。
2.3 删除有序数组中的重复项 II
给你一个有序数组 nums ,请你 原地 删除重复出现的元素,使得出现次数超过两次的元素只出现两次
,返回删除后数组的新长度。不要使用额外的数组空间,你必须在 原地 修改输入数组 并在使用 O(1) 额外空间的条件下完成。
方法1:快慢指针
这道题是上道题的变式,依然是使用双指针,但问题是,允许两个的重复元素。
也就是,慢指针!=快指针,这里,是快指针的前两个指针
-
首先,我们初始化
slow
为2,表示前两个元素无论如何都需要保留。 -
然后我们从第三个元素(即
fast
初始值为2)开始遍历数组。 -
接下来,我们使用一个条件判断
nums[slow - 2] != nums[fast]
来确定当前元素应该保留。这个判断的意思是,如果slow
指针之前的两个元素与当前元素不相等,就可以保留当前元素。 -
如果满足条件,我们将当前元素赋值给
slow
指针所在位置,并将slow
指针向后移动一位。
通过这样的遍历方式,我们可以确保每个元素最多出现两次。当遍历完整个数组后,slow
的值即为去重后的数组长度。
public int removeDuplicates(int[] nums) {
int slow = 2;
for (int fast = 2; fast < nums.length; fast++) {
if (nums[slow - 2] != nums[fast]) {
nums[slow++] = nums[fast];
}
}
return slow;
}
2.4 在有序数组中移除k个重复项
通过上面的方式,我们就能得出,slow指针决定了移除几个重复项
class Solution {
public int removeDuplicates(int[] nums) {
return process(nums, 2);
}
int process(int[] nums, int k) {
int slow = 0; // 慢指针,用于记录可以放入新元素的位置
int fast = 0; // 快指针,用于遍历数组
for (int num : nums) {
if (slow < k || nums[slow - k] != num) {
nums[slow] = num;
slow++;
}
}
return slow;
}
}
对于这个算法,我们使用两个指针:慢指针 slow
和快指针 fast
。
- 初始化
slow = 0
和fast = 0
,用于遍历数组。 - 遍历数组,将当前元素与
nums[slow - k]
进行比较:- 如果
slow
小于k
,表示前k
个元素都是可以保留的,因此直接将当前元素放入nums[slow]
的位置,并且slow++
。 - 否则,如果
nums[slow - k]
不等于当前元素,也将当前元素放入nums[slow]
的位置,并且slow++
。
- 如果
- 最后返回
slow
,即为新数组的长度。
这个算法的关键在于通过慢指针 slow
来记录可以放入新元素的位置,并且通过判断 nums[slow - k]
是否等于当前元素来保持最多只保留 k
个重复元素。
该算法的时间复杂度为 O(n),其中 n 是数组的长度。因为我们只遍历一次数组,而不需要额外的循环。
3. 元素奇偶移动专题
3.1按奇偶排序数组
题目:
给你一个整数数组 nums
,将 nums
中的的所有偶数元素移动到数组的前面,后跟所有奇数元素。
返回满足此条件的 任一数组 作为答案。
方法1:左右指针(我自己想的)
我想的方式是:
-
因为只需要判断右指针是否为偶数,如果为偶数就需要与左指针交换。
-
这时,左指针必须不为偶数才能进行交换
-
所以,需要首先排除左指针为偶数情况,如果为偶数就往右移动,直到为奇数,才有交换的资格
-
这时当左指针为奇数时,右指针往左移动,如果为偶数,就进行交换。
class Solution {
public int[] sortArrayByParity(int[] nums) {
int left = 0;
int right = nums.length-1;
while(left < right){
if(nums[left] % 2 == 0){
left++;
}else{
if(nums[right] % 2 == 0){
int temp = nums[left];
nums[left] = nums[right];
nums[right] = temp;
}
right--;
}
}
return nums;
}
}
时间复杂度是 O(n),其中 n 是数组的长度。在遍历数组的过程中,每个元素最多被读取和写入一次。
空间复杂度是 O(1),因为只使用了常量级别的额外空间。
方法2:左右指针(优雅一点)
在这个方法中,
-
使用左指针
left
和右指针right
分别从数组的两端开始向中间移动。 -
当
left
指向的元素为奇数而right
指向的元素为偶数时,交换它们的位置。 -
然后根据当前指针所指向的元素是否为偶数或奇数,分别更新
left
和right
的位置。
public static int[] sortArrayByParity(int[] A) {
int left = 0, right = A.length - 1;
while (left < right) {
if (A[left] % 2 > A[right] % 2) {
int tmp = A[left];
A[left] = A[right];
A[right] = tmp;
}
if (A[left] % 2 == 0) left++;
if (A[right] % 2 == 1) right--;
}
return A;
}
时间复杂度是 O(n),其中 n 是数组的长度。在遍历数组的过程中,每个元素最多被读取和写入一次。
空间复杂度是 O(1),因为只使用了常量级别的额外空间。
方法3:模仿冒泡的稳定移动方法(不推荐)
在这个方法中,
-
使用两层循环来遍历数组,并通过比较相邻元素的奇偶性来进行交换。
-
每一轮循环都会将最大的偶数交换到数组的最后面。重复执行这个过程直到数组完全有序。
public static int[] reOrderArray(int[] array) {
if (array == null || array.length == 0)
return new int[0];
int n = array.length;
for (int i = 0; i < n; i++) {
for (int j = 0; j < n - 1 - i; j++) {
// 左边是偶数, 右边是奇数的情况
if ((array[j] & 1) == 0 && (array[j + 1] & 1) == 1) {
int tmp = array[j];
array[j] = array[j + 1];
array[j + 1] = tmp;
}
}
}
return array;
}
该方法的时间复杂度为 O(n^2),其中 n 是数组的长度。因为需要进行两层循环,外层循环执行 n 次,内层循环执行的次数依次递减。
空间复杂度为 O(1),因为只使用了常量级别的额外空间。
虽然这个方法能够实现奇偶数的交换,但是由于时间复杂度较高,当数组规模较大时可能会导致性能问题。
4. 数组轮转问题
4.1 数组轮转
这里官方有三种做法,三种比较巧妙,前面两种是一个类型的方式,我这里略~
方法1:数组翻转
- 第一次反转:将整个数组反转,即
reverse(nums, 0, nums.length - 1)
。 - 第二次反转:将前k个元素反转,即
reverse(nums, 0, k - 1)
。 - 第三次反转:将剩余的元素反转,即
reverse(nums, k, nums.length - 1)
。
class Solution {
public void rotate(int[] nums, int k) {
k %= nums.length;
reverse(nums, 0, nums.length - 1);
reverse(nums, 0, k - 1);
reverse(nums, k, nums.length - 1);
}
public void reverse(int[] nums, int start, int end) {
while (start < end) {
int temp = nums[start];
nums[start] = nums[end];
nums[end] = temp;
start += 1;
end -= 1;
}
}
}
其中,reverse()
方法是一个辅助方法,用于反转数组的指定范围。它使用了双指针技巧,从数组的两端开始交换元素,直到指针相遇为止。
- 时间复杂度:该算法的时间复杂度为 O(n),其中 n 是数组的长度。原因是算法中进行了三次反转操作,每次反转的时间复杂度为 O(n/2)。所以总体上,时间复杂度为 O(n/2 + n/2 + n/2),即 O(n)。
- 空间复杂度:该算法的空间复杂度为 O(1),即常数级别的空间复杂度。无论输入数组的大小如何,算法只使用了几个额外的变量来存储索引和临时值,与输入规模无关。
方法2:环状替换
这种方法称为"环状替换",基本思路如下:
- 对于给定的 k 值,定义一个计数器 count,并初始化为 0。
- 从当前位置 start 开始,将当前位置的元素存储到临时变量 temp 中。
- 将 (start + k) % nums.length 的位置的元素移动到当前位置 start 上。
- 将 start 更新为 (start + k) % nums.length。
- 重复步骤 2-4,直到 count 等于 nums.length。
- 完成后,数组中的元素就按照要求向右旋转了k个位置。
public static void rotate(int[] nums, int k) {
k %= nums.length;
int count = 0;
for (int start = 0; count < nums.length; start++) {
int current = start;
int prev = nums[start];
do {
int next = (current + k) % nums.length;
int temp = nums[next];
nums[next] = prev;
prev = temp;
current = next;
count++;
} while (start != current);
}
}
我们结合实际的例子来讲解一下:
假设输入的数组为 nums = [1, 2, 3, 4, 5, 6, 7]
,要将其向右旋转k个位置,其中 k = 3。
- 首先,对k取模运算,即 k = k % nums.length,此处 k = 3 % 7 = 3。
- 定义一个计数器 count,并初始化为 0。
- 从当前位置 start = 0 开始:
- 将当前位置的元素 1 存储到临时变量 temp 中。
- 计算下一个位置 next = (start + k) % nums.length = (0 + 3) % 7 = 3。
- 将下一个位置的元素 4 移动到当前位置上,即 nums[0] = 4。
- 将 temp(之前的 1)赋值给 prev 变量。
- 更新当前位置 start 为 next,即 start = 3。
- 增加计数器 count 的值。
- 继续执行上述步骤,直到 count 等于 nums.length(即遍历完整个数组)。
- 最终得到的旋转后的数组为
[5, 6, 7, 1, 2, 3, 4]
。
整个过程可以用图示表示如下:
初始数组:[1, 2, 3, 4, 5, 6, 7]
[1, 2, 3, 4, 5, 6, 7]
↑
start
第一次旋转:
[4, 2, 3, 1, 5, 6, 7]
↑
start
第二次旋转:
[4, 5, 3, 1, 2, 6, 7]
↑
start
第三次旋转:
[4, 5, 6, 1, 2, 3, 7]
↑
start
第四次旋转:
[4, 5, 6, 7, 2, 3, 1]
↑
start
最终旋转结果为 [5, 6, 7, 1, 2, 3, 4]
。
通过这种环状替换的方法,我们可以在不使用额外的数组和反转操作的情况下,实现数组的旋转。
时间复杂度仍然是 O(n),但空间复杂度为 O(1)
5. 数组的区间专题
5.1 汇总区间
这个算法的目标是将一个有序整数数组转化为一组连续递增的区间表示,并返回这些区间的列表。
算法的核心思想是通过双指针来遍历数组,其中slow指针初始指向第一个区间的起始位置,fast指针则从第二个元素开始向后遍历。
在遍历过程中,如果当前元素nums[fast]与下一个元素nums[fast + 1]不满足连续递增(即nums[fast] + 1 != nums[fast + 1]),或者fast已经达到了数组边界,则说明当前连续递增区间[slow, fast]已经遍历完毕。此时将该区间写入结果列表中。
具体而言,在每个区间遍历完毕后,我们使用StringBuilder来构建区间的字符串表示。当slow等于fast时,表示当前区间只有一个元素,直接将该元素添加到StringBuilder中;否则,我们需要将区间的起始元素和结束元素用"->"连接起来。
最后,将构建好的区间字符串添加到结果列表中,并将slow指针更新为fast + 1,作为下一个区间的起始位置。重复上述步骤直到所有区间都遍历完毕。
下面是一个例子来帮助理解:
输入:nums = [0,1,2,4,5,7] 输出:[“0->2”,“4->5”,“7”]
具体的代码实现如下:
class Solution {
public List<String> summaryRanges(int[] nums) {
List<String> res = new ArrayList<>();
int slow = 0;
for (int fast = 0; fast < nums.length; fast++) {
if (fast + 1 == nums.length || nums[fast] + 1 != nums[fast + 1]) {
StringBuilder sb = new StringBuilder();
sb.append(nums[slow]);
if (slow != fast) {
sb.append("->").append(nums[fast]);
}
res.add(sb.toString());
slow = fast + 1;
}
}
return res;
}
}
6. 字符串替换空格问题
6.1 替换空格
方法1:遍历添加
这就比较简单了,不多说,直接创建之后遍历,拼接上去,唯独的问题就是对string的api了解,越熟悉越简单
class Solution {
public String replaceSpace(String s) {
StringBuilder sb = new StringBuilder();
for (char c : s.toCharArray()) {
if (c == ' ') {
sb.append("%20");
} else {
sb.append(c);
}
}
return sb.toString();
}
}
- 时间复杂度:O(n)
- 空间复杂度:O(n)
方法2:原地修改(双指针)
具体步骤如下:
- 遍历输入字符串s,统计其中空格的数量。假设空格的数量为spaceCount。
- 计算新字符串的长度newLength。由于每个空格需要替换成"%20",所以每个空格都会多占用两个额外的字符位置。因此,新字符串的长度等于原字符串长度s.length() + 2 * spaceCount。
- 创建一个字符数组result,长度为newLength。
- 使用双指针进行原地修改:
- 初始化两个指针:原字符串指针i和新字符串指针newIndex,初始都指向字符串的开头。
- 遍历输入字符串s的每个字符,执行以下操作:
- 如果当前字符是空格,则在result数组中依次插入"%20"三个字符,并将newIndex移动到下一个位置。
- 如果当前字符不是空格,则直接将当前字符复制到result数组中,并将newIndex移动到下一个位置。
- 循环结束后,newIndex指向新字符串的末尾位置。
- 将result数组转换为String并返回结果。
完整的代码如下:
class Solution {
public String replaceSpace(String s) {
int spaceCount = 0;
for (char c : s.toCharArray()) {
if (c == ' ') {
spaceCount++;
}
}
int newLength = s.length() + 2 * spaceCount;
char[] result = new char[newLength];
int newIndex = 0;
for (int i = 0; i < s.length(); i++) {
if (s.charAt(i) == ' ') {
result[newIndex++] = '%';
result[newIndex++] = '2';
result[newIndex++] = '0';
} else {
result[newIndex++] = s.charAt(i);
}
}
return new String(result);
}
}
下面以一个例子来演示这个过程:
输入字符串s为:“hello world”
- 统计空格的数量:spaceCount = 1
- 计算新字符串的长度:newLength = s.length() + 2 * spaceCount = 11 + 2 * 1 = 13
- 创建一个字符数组result,长度为newLength:result = new char[13]
- 使用双指针进行原地修改:
- 初始化两个指针i和newIndex,初始都指向字符串的开头:i = 0, newIndex = 0
- 遍历输入字符串s的每个字符:
- s.charAt(0) = ‘h’,不是空格,直接将’h’复制到result[newIndex],并将newIndex移动到下一个位置:result[0] = ‘h’,newIndex = 1
- s.charAt(1) = ‘e’,不是空格,同上:result[1] = ‘e’,newIndex = 2
- …
- s.charAt(5) = ’ ', 是空格,需要插入"%20"三个字符到result数组,并将newIndex移动到下一个位置:result[6] = ‘%’, result[7] = ‘2’, result[8] = ‘0’,newIndex = 9
- …
- 循环结束后,newIndex = 11
- 将result数组转换为String并返回结果。最终得到的结果为:“hello%20world”
//方式二:双指针法
public String replaceSpace(String s) {
if(s == null || s.length() == 0){
return s;
}
//扩充空间,空格数量2倍
StringBuilder str = new StringBuilder();
for (int i = 0; i < s.length(); i++) {
if(s.charAt(i) == ' '){
str.append(" ");
}
}
//若是没有空格直接返回
if(str.length() == 0){
return s;
}
//有空格情况 定义两个指针
int left = s.length() - 1;//左指针:指向原始字符串最后一个位置
s += str.toString();
int right = s.length()-1;//右指针:指向扩展字符串的最后一个位置
char[] chars = s.toCharArray();
while(left>=0){
if(chars[left] == ' '){
chars[right--] = '0';
chars[right--] = '2';
chars[right] = '%';
}else{
chars[right] = chars[left];
}
left--;
right--;
}
return new String(chars);
}
这样更好理解~
方法3:超级无敌方便方法
class Solution {
public String replaceSpace(String s) {
return s.replaceAll(" ", "%20");
}
}
注意:由于replaceAll()函数底层使用了正则表达式,因此可能会带来一些额外的性能开销。如果对性能有更高的要求,建议使用上述双指针原地修改字符串的方法。[狗头]