双指针法是一种常用的算法优化技术,能够在一些情况下将时间复杂度从O(n^2)优化到O(n)。这种方法的基本原理在于同时从序列的不同位置开始遍历,通过移动这两个指针来遍历整个序列或者序列的部分,从而达到减少遍历次数和提高效率的目的。
原理
双指针法的核心思想是,通过合理地移动两个指针的位置,来避免不必要的比较或计算。这种方法通常应用于有序数组或链表上,根据问题的不同,双指针法可以分为"对撞指针"和"滑动窗口"两种类型。
- 对撞指针:两个指针分别位于数据结构的起始位置和结束位置,向中心移动,直到满足某个特定条件。这种方法常用于有序数组的查找问题,如两数之和。
- 滑动窗口:两个指针初始位于序列的起始位置,然后一个指针固定,另一个指针向前移动扩大窗口,直到窗口内的数据满足某个条件后,再移动固定的指针缩小窗口,以此寻找最优解。这种方法常用于求解最长子串、最大和等问题。
为什么能优化到O(n)
双指针法之所以能将时间复杂度优化到O(n),主要是因为它通过精心设计的指针移动策略,减少了遍历的次数。在传统的O(n^2)算法中,通常会有两层循环,外层循环遍历每个元素,内层循环遍历除了外层当前元素之外的其他所有元素,进行比较或计算。而在双指针法中,通过同时移动两个指针,每个元素最多被访问一次或有限次数,从而避免了内层循环的大量计算。
举例说明
1. 对撞指针 - 两数之和
问题描述:给定一个有序数组和一个目标值,找出数组中和为目标值的两个数的索引。
解决思路:
- 初始化两个指针,一个指向数组的开始(
left
),另一个指向数组的末尾(right
)。 - 对数组进行遍历,通过比较
left
和right
指向的数之和与目标值的关系来决定如何移动指针。 - 如果两数之和等于目标值,就找到了答案。
- 如果两数之和小于目标值,移动左指针
left
向右(因为数组有序,这样做可以增加和的值)。 - 如果两数之和大于目标值,移动右指针
right
向左(减小和的值)。 - 当
left
>=right
时,结束循环。
这种方法利用了数组的有序性,通过对撞指针避免了不必要的计算,实现了时间复杂度为O(n)的解决方案。
rust
fn two_sum(numbers: Vec<i32>, target: i32) -> (i32, i32) {
// 初始化左右指针
let (mut left, mut right) = (0, numbers.len() - 1);
while left < right {
// 计算当前左右指针对应的数值之和
let sum = numbers[left] + numbers[right];
// 如果和等于目标值,则返回这两个数的索引(这里假设返回的是1-based索引)
if sum == target {
return (left as i32 + 1, right as i32 + 1);
} else if sum < target {
// 如果和小于目标值,说明需要增加和,因此左指针右移
left += 1;
} else {
// 如果和大于目标值,说明需要减少和,因此右指针左移
right -= 1;
}
}
// 如果没有找到满足条件的两个数,返回(-1, -1)
(-1, -1)
}
2. 滑动窗口 - 最长无重复字符的子串长度
问题描述:给定一个字符串,请你找出其中不含有重复字符的最长子串的长度。
解决思路:
- 维护一个滑动窗口,窗口内是一个没有重复字符的子串。使用两个指针表示窗口的左右边界
left
和right
,初始时都指向字符串的开始。 - 移动右指针
right
扩大窗口,直到遇到重复字符为止。 - 当遇到重复字符时,记录当前窗口的长度,并尝试通过移动左指针
left
来排除重复字符,同时继续移动右指针探索新的无重复字符子串。 - 重复上述过程,直到右指针到达字符串末尾。
- 在整个过程中,记录并更新最大子串长度。
此问题的关键是如何高效地检测和处理重复字符,通常可以通过哈希表来快速判断字符是否在当前子串中。
rust
use std::collections::HashSet;
fn length_of_longest_substring(s: String) -> i32 {
// 使用HashSet来存储当前窗口中的字符,确保无重复
let mut set = HashSet::new();
// 初始化左右指针和最大长度
let (mut left, mut right, mut max_length) = (0, 0, 0);
// 将字符串转换为字符数组,方便索引访问
let chars: Vec<char> = s.chars().collect();
while right < s.len() {
if !set.contains(&chars[right]) {
// 如果当前字符不在HashSet中,则添加进去,并移动右指针
set.insert(chars[right]);
right += 1;
// 更新最大长度
max_length = max_length.max(right - left);
} else {
// 如果当前字符在HashSet中,说明遇到了重复字符,移除左指针指向的字符,并移动左指针
set.remove(&chars[left]);
left += 1;
}
}
// 返回最大长度
max_length as i32
}
3. 对撞指针 - 移除元素
问题描述 :给你一个数组 nums
和一个值 val
,你需要原地移除所有数值等于 val
的元素,并返回移除后数组的新长度。
解决思路:
- 使用两个指针
left
和right
,初始时都指向数组的开始。 - 移动右指针
right
遍历数组,左指针left
指向下一个将要被覆盖的位置。 - 如果
right
指向的元素不等于val
,将其复制到left
指向的位置,然后同时移动left
和right
。 - 如果
right
指向的元素等于val
,只移动right
指针,因为需要排除这个元素。 - 遍历结束后,
left
的位置就是新的数组长度。
这个问题通过对撞指针的方式避免了额外的空间开销,实现了原地修改数组。
rust
fn remove_element(nums: &mut Vec<i32>, val: i32) -> i32 {
// 初始化两个指针,均指向数组的起始位置
let (mut left, mut right) = (0, 0);
while right < nums.len() {
if nums[right] != val {
// 如果右指针指向的元素不等于val,则将其赋值给左指针指向的位置,并同时移动两个指针
nums[left] = nums[right];
left += 1;
}
// 如果等于val,则只移动右指针
right += 1;
}
// 返回处理后数组的新长度,即左指针的位置
left as i32
}
4. 滑动窗口 - 和为S的连续正数序列
问题描述 :输入一个正整数 target
,输出所有和为 target
的连续正整数序列(至少含有两个数)。
解决思路:
- 使用两个指针
left
和right
表示当前探索的序列的左右边界,初始时left=1
,right=2
。 - 维护一个当前和
sum
,初始为left
和right
的和。 - 如果
sum
小于target
,则移动右指针right
扩大序列范围(因为需要增加序列的和)。 - 如果
sum
大于target
,则移动左指针left
缩小序列范围(因为需要减少序列的和)。 - 如果
sum
等于target
,记录当前序列,然后移动左指针开始探索新的序列。 - 重复上述过程,直到左指针超过
target
的一半(因为至少需要两个数,所以序列的起始数不会超过target
的一半)。
通过滑动窗口方法,可以有效地枚举所有可能的连续序列,而不需要进行复杂的嵌套循环。
rust
fn find_continuous_sequence(target: i32) -> Vec<Vec<i32>> {
// 初始化左右指针、当前序列的和以及结果集合
let (mut left, mut right, mut sum, mut result) = (1, 2, 3, Vec::new());
// 只需要考虑到target的一半,因为任何大于target/2的两个数之和都会大于target
while left < right {
if sum == target {
// 如果当前序列的和等于目标值,则记录这个序列
let sequence: Vec<i32> = (left..=right).collect();
result.push(sequence);
// 移动左指针,并从sum中减去左指针指向的数
sum -= left;
left += 1;
} else if sum < target {
// 如果当前序列的和小于目标值,则右指针右移,并更新sum
right += 1;
sum += right;
} else {
// 如果当前序列的和大于目标值,则左指针右移,并更新sum
sum -= left;
left += 1;
}
}
// 返回所有满足条件的序列
result
}
如何判断能否根据双指针发优化?
不是所有的双层循环都可以通过双指针法进行优化。双指针法的有效应用通常依赖于问题的特定条件或数据的特定性质。以下是一些关键因素,决定了双指针法是否可以用来优化给定的问题:
- 数据的有序性:对于一些问题,比如在有序数组中寻找两个数的和等于给定值的问题,双指针法能够有效地减少时间复杂度,因为数组的有序性使得我们可以根据当前和与目标值的比较结果来决定是移动左指针还是右指针。如果数据是无序的,双指针法可能就不适用,或者需要先对数据进行排序。
- 问题的性质:双指针法特别适合解决那些与区间(连续子数组、子序列等)相关的问题,如寻找满足某种条件的最长/最短子数组。如果问题的性质与区间无关,双指针法可能就不是一个好的选择。
- 特定的目标条件:双指针法通常适用于那些可以通过修改指针来逐步逼近答案的问题。例如,当需要在一个有序数组中找到两个数,使它们的和接近但不超过一个特定的值时,双指针法可以有效地找到这样的两个数。如果问题没有明确的逼近条件或目标条件,双指针法可能无法应用。
双指针法是一种非常实用的技巧,可以在适当的条件下显著优化算法的时间复杂度。然而,并不是所有的双层循环都能通过双指针法进行优化。评估双指针法是否适用于特定问题时,需要考虑问题的具体性质和数据的特点。在不能直接应用双指针法的场景中,可能需要探索其他算法优化技巧,如动态规划、分治法、贪心算法等。Pomelo_刘金,转载请注明原文链接。感谢!