引言
双指针(Two Pointers)是一种非常经典的算法技巧,主要用于数组或链表的处理。通过使用两个指针在序列中移动,可以有效提高算法的效率,通常用于解决涉及子数组、子序列、滑动窗口等问题。
双指针技巧主要分为:左右指针和快慢指针。
本期文章主要介绍使用快慢指针来解决常见三类问题。
快慢指针
快慢指针:顾名思义,一个指针(快指针)移动速度快,另一个指针(慢指针)移动速度慢。
通常用于解决的问题有:
- 有序数组的原地修改
- 滑动窗口
- 链表是否有环
1 数组的原地修改
力扣原题
26. 删除有序数组中的重复项
给你一个 非严格递增排列 的数组 nums ,请你 原地 删除重复出现的元素,使每个元素 只出现一次 ,返回删除后数组的新长度。元素的 相对顺序 应该保持 一致 。然后返回 nums 中唯一元素的个数。
考虑 nums 的唯一元素的数量为 k ,你需要做以下事情确保你的题解可以被通过:
- 更改数组 nums ,使 nums 的前 k 个元素包含唯一元素,并按照它们最初在 nums 中出现的顺序排列。nums 的其余元素与 nums 的大小不重要。
- 返回 k 。
示例 1:
输入:nums = [1,1,2]
输出:2, nums = [1,2,_]
解释:函数应该返回新的长度 2 ,并且原数组 nums 的前两个元素被修改为 1, 2 。不需要考虑数组中超出新长度后面的元素。示例 2:
输入:nums = [0,0,1,1,1,2,2,3,3,4]
输出:5, nums = [0,1,2,3,4]
解释:函数应该返回新的长度 5 , 并且原数组 nums 的前五个元素被修改为 0, 1, 2, 3, 4 。不需要考虑数组中超出新长度后面的元素。
解题思路
原地修改就是不允许创建一个新数组,必须在原数组上进行操作。
采用双指针的方法来解决此问题:
- 慢指针(slow):用于标记处理后数组中唯一元素的位置,初始化为 0。随着遍历的进行,慢指针所指向的位置及之前的元素将构成最终的无重复元素序列。
- 快指针(fast):用于遍历数组,从 1 开始,依次检查数组中的每个元素。
在遍历过程中,对比快指针所指元素和慢指针所指元素:
- 若快指针
fast
所指元素与慢指针slow
所指元素不相等,意味着找到了一个新的唯一元素。此时,将慢指针slow
向后移动一位,并把快指针fast
所指的这个新的唯一元素赋值到慢指针所指的位置,以此确保唯一元素依次排列在数组的前面部分。 - 若快指针
fast
所指元素与慢指针slow
所指元素相等,表明遇到了重复元素,此时无需对慢指针slow
进行操作,直接继续移动快指针fast去检查下一个元素。
当快指针fast遍历完整个数组后,慢指针slow所指向的位置加 1 就是数组中唯一元素的个数 k,而数组的前 k 个元素就是满足要求的无重复元素序列。
代码实现
function removeDuplicates(nums: number[]): number {
if(nums.length === 0) return 0;
let fast = 0, slow = 0;
while(fast < nums.length){
if(nums[fast] !== nums[slow]){
slow++;
// 维护 nums[0..slow] 无重复
nums[slow] = nums[fast];
}
fast++;
}
// 数组长度为索引 + 1
return slow + 1;
}
在上述代码中:
- 首先判断数组是否为空,如果为空则返回 0。
- 接着通过 for 循环使用双指针 slow 和 fast 按照既定思路遍历数组。在循环内,根据快指针和慢指针所指元素是否相等来决定是否移动慢指针以及更新数组元素。
- 最后返回 slow + 1 作为数组中唯一元素的个数,输出了相应的结果。
这样就实现了在原数组上删除重复元素,使每个元素只出现一次,并返回唯一元素个数的功能,同时满足题目对于数组前 k 个元素的排列要求。
2 滑动窗口
在滑动窗口算法中,虽然不像传统快慢指针那样一个指针固定比另一个指针移动速度快,但这里的左右指针(可以类比快慢指针的概念,右指针相对更主动地去扩展窗口,类似快指针的作用;左指针则根据条件收缩窗口,相对慢一些)协同工作来实现窗口的滑动和对数据的处理,以达到我们寻找特定子串或子序列的目的。
力扣原题
76.最小覆盖子串
给你一个字符串 s 和一个字符串 t,请你找出 s 中包含 t 所有字符的最小子串。如果不存在这样的子串,就返回空字符串 ""。
示例 1:
输入:s = "ADOBECODEBANC", t = "ABC"
解释:最小覆盖子串是 "BANC",它包含了字符串 "ABC" 中的所有字符。
解题思路
窗口的初始化
使用指针left和指针right来定义一个滑动窗口的左右边界。使用合适的数据结构保存滑动窗口中的元素,根据实际场景进行设计,具体的:
(1)想要记录窗口中元素出现的次数,可以用 Map。
(2)想要记录窗口中的元素和,可以用 number。
其实也可以使用数组或对象等数据结构。
窗口的移动与条件判断
(1)向右扩展窗口,移动right指针,每次将s[right]
加入到窗口中,并更新对应元素出现的次数。
(2)判断窗口是否符合条件,通过变量 valid,用于记录当前窗口内已经满足目标字符串 t 中字符需求的字符种类数量。每次更新窗口的元素记录后,检查当前窗口的每个元素是否符合条件,达到了valid进行加1操作。
(3)收缩窗口,移动left指针。当找到了一个包含目标字符串所有字符的窗口后,通过移动 left 指针来缩小窗口,以找到最小的覆盖子串。
在移动 left 指针时,需要更新 windows Map 中对应字符的出现次数,并检查是否会因为移除了某个字符而导致窗口不再满足条件(即 valid 的值小于目标字符串 t 中不同字符的种类数量)。
代码实现
function minWindow(s: string, t: string): string {
// 如果字符串 s 的长度小于字符串 t 的长度,则不可能找到符合条件的子串,直接返回空字符串
if (s.length < t.length) return "";
// 将 t 中的字符存进 need Map 中进行计数
const need = new Map();
for (const char of t) {
need.set(char, (need.get(char) || 0) + 1);
}
// 定义一个 Map 来统计滑动窗口中的字符
const windows = new Map();
// 滑动窗口的左右指针索引
let left = 0, right = 0;
let valid = 0; // 用来存滑动窗口中满足 need 条件的字符个数(即有效字符数)
let start = 0; // 保存符合条件的最小子串的起始索引
let len = Number.MAX_SAFE_INTEGER; // 当前最小子串的长度
// 开始滑动窗口
while (right < s.length) {
// 即将进入窗口的字符
const r = s[right];
right++; // 扩大窗口
// 如果 r 是需要的字符,则加入到窗口统计中
if (need.has(r)) {
windows.set(r, (windows.get(r) || 0) + 1);
// 判断窗口中的这个字符统计数是否已经满足 need 的要求,如果满足则有效字符数 valid 加一
if (windows.get(r) === need.get(r)) {
valid++;
}
}
// 当窗口中的有效字符个数等于需要的字符个数时,尝试收缩窗口
while (valid === need.size) {
// 更新最小子串的索引和长度
if (right - left < len) {
len = right - left;
start = left;
}
// 即将移除窗口的字符
const l = s[left];
left++; // 收缩窗口
// 如果 l 是需要的字符,则更新窗口统计数据
if (need.has(l)) {
// 如果当前窗口中的 l 个数刚好等于 need 中的个数,移除后有效字符数 valid 减一
if (windows.get(l) === need.get(l)) {
valid--;
}
// 更新窗口中 l 字符的数量
windows.set(l, windows.get(l) - 1);
}
}
}
// 如果 len 还是初始值,说明没有符合条件的子串,返回 ""
return len === Number.MAX_SAFE_INTEGER ? "" : s.slice(start, start + len);
}
3 链表是否有环
在处理链表数据结构时,判断链表是否存在环是一个常见的问题。所谓环,就是链表中的某个节点可以通过连续跟踪 next 指针再次回到之前已经访问过的节点,形成一个类似环形的结构。
使用快慢指针算法可以高效地解决这个问题,通过让两个指针以不同的速度在链表上移动,根据它们是否相遇来判断链表是否存在环。
力扣原题
141. 环形链表
给你一个链表的头节点 head ,判断链表中是否有环。
如果链表中有某个节点,可以通过连续跟踪 next 指针再次到达,则链表中存在环。 为了表示给定链表中的环,评测系统内部使用整数 pos 来表示链表尾连接到链表中的位置(索引从 0 开始)。
注意:pos 不作为参数进行传递 。仅仅是为了标识链表的实际情况。
如果链表中存在环 ,则返回 true 。 否则,返回 false 。
示例 1:
输入:head = [3,2,0,-4], pos = 1
输出:true
解释:链表中有一个环,其尾部连接到第二个节点。
解题思路
- 指针的定义与初始化:
- 我们定义两个指针,分别为慢指针(slow)和快指针(slow)。
- 这两个指针都初始化为链表的头节点(head),即 slow = head,fast = head。
- 指针的移动规则:
- 慢指针(slow)每次移动一步,也就是 slow = slow.next。
- 快指针(fast)每次移动两步,即 fast = fast.next.next。这里要特别留意,在移动快指针时,必须先确保 fast 和 fast.next 不为空,不然会出现空指针访问的错误。
- 判断是否有环的依据:
- 当链表中存在环时,由于快指针的移动速度是慢指针的两倍,所以快指针会在环内逐渐追上慢指针。具体而言,在持续移动指针的过程中,最终将会出现 slow === fast 的情形,此时便可判定链表存在环。
- 倘若在移动指针的过程中,快指针碰到了链表的末尾(也就是 fast === null 或者 fast.next === null),这就表明链表中不存在环,因为快指针已经遍历完整个链表且未进入环中。
代码实现
// 判断链表是否有环的函数
function hasCycle(head: ListNode | null): boolean {
// 如果链表头节点为空,说明链表为空链表,自然不存在环,直接返回false
if (head === null) {
return false;
}
// 初始化慢指针slow,使其指向链表的头节点head
let slow: ListNode | null = head;
// 初始化快指针fast,同样使其指向链表的头节点head
let fast: ListNode | null = head;
// 进入循环,只要快指针fast不为空且快指针的下一个节点fast.next也不为空,就继续循环
// 因为快指针每次移动两步,所以需要确保它和它的下一个节点都存在,否则会出现空指针访问的错误
while (fast!== null && fast.next!== null) {
// 慢指针每次移动一步,将慢指针指向它当前所指节点的下一个节点
slow = slow!.next;
// 快指针每次移动两步,先将快指针指向它当前所指节点的下一个节点的下一个节点
fast = fast!.next.next;
// 在每次移动指针后,检查慢指针和快指针是否指向了同一个节点
// 如果是,说明快指针在环内追上了慢指针,也就意味着链表存在环,此时返回true
if (slow === fast) {
return true;
}
}
// 如果循环结束后,没有出现慢指针和快指针指向同一个节点的情况,
// 那就说明快指针已经遍历完整个链表且未进入环中,即链表不存在环,返回false
return false;
}
总结
双指针技巧中的快慢指针在算法问题解决中意义重大。
- 对于有序数组原地修改,可高效去重;
- 滑动窗口能借助其找到最小覆盖子串;
- 链表中可判断是否有环。
它通过巧妙的指针移动规则和条件判断,有效提高处理数组和链表相关问题的效率。