双指针①--快慢指针(循环不变量+单调性)

双指针

对于双指针类型的题目,他通常可以被应用于: 快慢指针、滑动窗口、排序、相向指针 等类型的题目中,它不仅可以在一个序列中维持某种性质,也在两个序列使用。

在这些题目中,我们在实现算法时通常要考虑以下几点:

  • 保证循环不变量,维持一段区间保持某种性质
  • 大部分题目中,其中一个指针相对于另一个指针具有单调性

使用双指针还有一个明显的特征 : 我们使用朴素(暴力)的算法时,需要两层循环来实现,时间复杂度为 <math xmlns="http://www.w3.org/1998/Math/MathML"> O ( n 2 ) O(n^2) </math>O(n2),此时就可以使用双指针,将两层循环中的第二层循环抵消,时间复杂度降低到 <math xmlns="http://www.w3.org/1998/Math/MathML"> O ( n ) O(n) </math>O(n)

看一道基础题体会一下:

LeetCode-27.移除元素

给你一个数组 nums 和一个值 val,你需要[原地] 移除所有数值等于 val的元素,并返回移除后数组的新长度。不要使用额外的数组空间,你必须仅使用 O(1) 额外空间并 [原地]修改输入数组

示例:给出数组[3,2,2,3]以及要移除的元素3,我们要做的就是把不是3的数值全部放入数组的前面,后面的数值无所谓。

text 复制代码
输入: nums = [3,2,2,3], val = 3
输出: 2, nums = [2,2]

暴力解法如何?代码如下:

js 复制代码
var removeElement = function (nums, val) {
  let size = nums.length;
  for (let i = 0; i < size; i++) {
    if (nums[i] === val) {
      for (let j = i + 1; j < size; j++) {
        nums[j - 1] = nums[j]
      }
      size--;
      i--;
    }
  }
  return size;
}

我们可以看到两层循环,并且i,j就相当于两个指针,此时我们考虑以下双指针是否可行:我们定义指针i(快),j(慢),然后需要维护一段区间[0,j)中没有目标值(循环不变量),i每向前一步会遇到两种情况:

① 不是要移除元素--将其放到j+1所指空间,然后j++

② 是要移除元素--继续前进

发现了什么? i在递增的过程中,j只会向前不会回退,换句话讲,j相对于i是单调的。 那么双指针可解:

js 复制代码
var removeElement = function (nums, val) {
  let j = 0;
  for (let i = 0; i < nums.length; i++) {
    if (nums[i] !== val) {
      nums[j] = nums[i];
      j++;
    }
  }
  return j;
};

分析一下上述代码是否遵循了循环不变量:我们的不变量就是[0,j)中没有目标值

  • 初始状态:区间为[0,0),此时区间无元素,故更不存在要删除的数
  • 每次循环后:
    • 如果不是要删除的值,将该值放入j,此时[0,j)无要移除元素,j++,指向下一次遇到不是要移除元素时要放置的地方
    • 如果是要删除的值,i++j没有变,因此保证了于上一次维护的区间一样,没问题
  • 最终[0,j)内无要移除元素,那么长度就是j了

如果此时维护的区间是[0,j]呢? 看代码:

js 复制代码
var removeElement = function (nums, val) {
  let j = 0;
  //保证初始时[0,0]即首位元素不是要移除元素
  if (nums[0] === val) {
    for (let i = 0; i < nums.length; i++) {
      if (nums[i] !== val) {
        nums[0] = nums[i];
        nums[i] = val;
        break;
      }
    }
  }
  if (nums[0] === val) return 0;
  for (let i = 1; i < nums.length; i++) {
    if (nums[i] !== val) {
      nums[++j] = nums[i];
    }
  }
  return j + 1;
};

再次分析循环不变量:

  • 初始化: 区间为[0,0],那么第一个元素是否是目标元素我们并没有判断,因此这里需要进行初始化操作,初始化后可能出现首位还是要移除元素,说明整个数组中只有要移除元素,因此直接返回零,否则[0,0]这个区间被我们维护好了
  • 每次循环后:由于初始化时已经确定了首位,因此i应该从1开始遍历
    • 如果是要移除元素,i++,j不变,区间不变
    • 如果不是要移除元素,此时我们应该将其放到j+1的地方,然后让j前移,这样区间不会变化
  • 最后区间[0,j]中有j+1个元素

ok,到这里已经有点感觉了,开始上几道题看看吧:

LeetCode-27.删除有序数组中的重复项

给你一个 非严格递增排列 的数组 nums ,请你 [原地] 删除重复出现的元素,使每个元素 只出现一次 ,返回删除后数组的新长度。元素的 相对顺序 应该保持 一致 。然后返回 nums 中唯一元素的个数。

示例:

text 复制代码
输入: nums = [0,0,1,1,1,2,2,3,3,4]
输出: 5, nums = [0,1,2,3,4]

首先我们确定当一个快指针向前时,遇到了重复元素,慢指针需要回退? 不需要,因为遇到重复的就继续,不是重复的就放入区间,慢指针前移即可,满足了单调性

其次确定循环不变量:[0,j)内无重复元素

  • 初始化:j为零时,表示[0,0)内无元素,由于题目是去重,实际上j可以从1开始,即[0,1)为初始值
  • 每次循环:
    • i遇到一个新的元素时(值与j-1不相同),直接放到j处,j前移,此时区间仍保持
    • i遇到的值与j-1相同时(重复值),j不动,区间不变
  • 结束时,长度为j

着手写代码:

js 复制代码
var removeDuplicates = function (nums) {
  let j = 1;
  for (let i = 0; i < nums.length; i++) {
    if (nums[i] !== nums[j - 1]) {
      nums[j] = nums[i];
      j++;
    }
  }
  return j;
};

捋清楚后,直接秒杀🤩,继续。

LeetCode-283.移动零

给定一个数组 nums,编写一个函数将所有 0 移动到数组的末尾,同时保持非零元素的相对顺序。

示例 :

text 复制代码
输入: nums = [0,1,0,3,12]
输出: [1,3,12,0,0]

这不就是移除元素0嘛😉,希望大家自己分析一下循环不变量,这里直接上代码了:

js 复制代码
function swap(nums, i, j) {
  const temp = nums[i];
  nums[i] = nums[j];
  nums[j] = temp;
}
var moveZeroes = function (nums) {
  let j = 0;
  for (let i = 0; i < nums.length; i++) {
    if (nums[i] !== 0) {
      swap(nums, i, j);
      j++;
    }
  }
};

注:由于这里不仅仅是删除0,而是把0移动到后面,因此我们遇到非零元素交换。

LeetCode-977.有序数组的平方

给你一个按 非递减顺序 排序的整数数组 nums,返回 每个数字的平方 组成的新数组,要求也按 非递减顺序 排序。

示例 1:

text 复制代码
输入: nums = [-4,-1,0,3,10]
输出: [0,1,9,16,100]

这道题与上面有所不同了,实际上并不是快慢指针,而是相向指针的题目,但是为了巩固之前谈到的性质,故而引出。

我们先来分析一下题目,一个非递减顺序的数组,并且数组中包含负数,最终需要一个该数组平方后的递增序列,那么他的最大值一定是左端点或右端点的平方,此时我们只要每次比较最左和最右就能确定当前的最大值了。

还具有我们之前说的单调性吗? 我的理解是仍然具有,因为我们需要每次循环时找到最大值,最大值又一定在两端,因此两个指针分别为头尾指针,无论当前这次循环那个指针动了,另一个指针一定要么不动,要么向内缩进,仍然单调。

再来分析一下循环不变量:

  • 初始化:我们需要返回一个新数组,新增一个数组res,我们的循环不变量是什么呢?每次循环都从原数组中取出当前最大值放入res,每次循环,总循环次数减一。
  • 每次循环:比较得出最大值,最大值处的指针内缩,循环结束,等待下一次循环。
  • 结束时,两指针相遇,将最后的元素平方推入res顶部。(这个res可以当作栈看)
js 复制代码
var sortedSquares = function (nums) {
  let i = 0, j = nums.length - 1;
  const res = [];
  let pos = nums.length - 1;
  while (i <= j) {
    //左边平方大于右边平方,将其
    if (nums[i] * nums[i] >= nums[j] * nums[j]) {
      res[pos] = (nums[i] * nums[i]);
      i++;
    } else {
      res[pos] = (nums[j] * nums[j]);
      j--;
    }
    pos--;
  }
  return res;
};

这道题中我们完全可以使用unshift()来模拟栈操作,但是这样时间复杂的就上来了,因为unshift()底层实际就是将目标数组的元素全部后移一位再添加到首部。好在JS中的数组是一个动态数组(长度动态扩展),因此我们可以设定pos,由于res的长度一定等于nums,那么pos就可以从nums.length-1开始,每次循环前移一位即可,我们的while循环时间复杂度严格的等于了n,你甚至不用怕他越界👻。

本次双指针类题目大多是快慢指针,下次重点关注滑动窗口

相关推荐
pianmian11 小时前
python数据结构基础(7)
数据结构·算法
cs_dn_Jie3 小时前
钉钉 H5 微应用 手机端调试
前端·javascript·vue.js·vue·钉钉
好奇龙猫3 小时前
【学习AI-相关路程-mnist手写数字分类-win-硬件:windows-自我学习AI-实验步骤-全连接神经网络(BPnetwork)-操作流程(3) 】
人工智能·算法
开心工作室_kaic3 小时前
ssm068海鲜自助餐厅系统+vue(论文+源码)_kaic
前端·javascript·vue.js
有梦想的刺儿3 小时前
webWorker基本用法
前端·javascript·vue.js
sp_fyf_20244 小时前
计算机前沿技术-人工智能算法-大语言模型-最新研究进展-2024-11-01
人工智能·深度学习·神经网络·算法·机器学习·语言模型·数据挖掘
香菜大丸4 小时前
链表的归并排序
数据结构·算法·链表
jrrz08284 小时前
LeetCode 热题100(七)【链表】(1)
数据结构·c++·算法·leetcode·链表
oliveira-time4 小时前
golang学习2
算法
清灵xmf4 小时前
TypeScript 类型进阶指南
javascript·typescript·泛型·t·infer