双指针,分为快慢指针,左右指针,滑动窗口,指针交换,中心扩展这些常见用法
1. 快慢指针
快慢指针(也称为龟兔赛跑算法)是一种在链表或数组中常用的双指针技术,其中一个指针(快指针)移动的速度是另一个指针(慢指针)的两倍。这种技术特别适用于解决与链表中的环、链表的中间节点、链表的周期性等问题。
1.1 环形链表(141)
问题描述:给定一个链表,判断链表中是否有环。
解题思路:设置两个指针,快指针每次移动两步,慢指针每次移动一步。如果链表中存在环,那么快慢指针最终会在环内的某个节点相遇;如果链表中没有环,那么快指针会先到达链表末尾的null。
public class Solution {
public boolean hasCycle(ListNode head) {
ListNode n1 = head;
ListNode n2 = head;
while (n2!=null&&n2.next!=null){
n1=n1.next;
n2=n2.next.next;
if (n1==n2){
return true;
}
}
return false;
}
}
时间复杂度: O(n),其中 n 是链表中节点的数量,因为它最多遍历链表一次(在存在环的情况下,快慢指针会在环内相遇;在不存在环的情况下,快指针会到达链表末尾)。
空间复杂度: O(1),因为它只使用了常量级别的额外空间(即快慢指针本身)。
1.2 环形链表II(22)
问题描述:如果链表中存在环,找出环的入口节点。
解题思路:在检测到环后,将其中一个指针(通常是慢指针)移动到链表头部,然后两个指针以相同的速度移动,直到它们再次相遇。相遇点即为环的入口节点。
public class Solution {
public ListNode detectCycle(ListNode head) {
ListNode n1 = head;//慢指针初始化
ListNode n2 = head;//快指针初始化
while (n2 != null && n2.next != null) {
//如果链表为空或只有一个节点,则不可能有环
n1 = n1.next;//慢指针每次移动一步
n2 = n2.next.next;//快指针每次移动两步
if (n1 == n2) {// 有环
ListNode index1 = n2;
ListNode index2 = head;
// 两个指针,从头结点和相遇结点,各走一步,直到相遇,相遇点即为环入口
while (index1 != index2) {
index1 = index1.next;
index2 = index2.next;
}
return index1;
}
}
return null;
}
}
按照代码随想录的解释
1.3 链表的中间节点(876)
问题描述:找到链表的中间节点。
解题思路:用两个指针 slow 与 fast 一起遍历链表。slow 一次走一步,fast 一次走两步。那么当 fast 到达链表的末尾时,slow 必然位于中间。
class Solution {
public ListNode middleNode(ListNode head) {
ListNode slow = head, fast = head;
while (fast != null && fast.next != null) {
//防止了在尝试访问 n2.next.next 时发生 NullPointerException
slow = slow.next;
fast = fast.next.next;
}
return slow;
}
}
1.4 跳跃游戏(55)
问题描述:给定一个非负整数数组,你最初位于数组的第一个位置。数组中的每个元素代表你在该位置可以跳跃的最大长度。判断你是否能够到达数组的最后一个位置。
解题思路:虽然这本质上是在数组上操作,但可以使用快慢指针的思想,通过维护一个“最远可达位置”来模拟快指针,同时遍历数组(慢指针)来更新这个最远可达位置。
public class Solution {
public boolean canJump(int[] nums) {
int n = nums.length;
int farthest = 0; // 这个变量类似于“快指针”,但它不是指针,而是表示当前能跳到的最远距离
for (int i = 0; i < n - 1; i++) { // 注意是n-1,因为我们要检查是否能跳到n-1(即数组的末尾)
farthest = Math.max(farthest, i + nums[i]); // 更新能跳到的最远距离
// 如果当前位置i已经超过了之前计算出的最远距离,那么就无法跳到更远的地方了
// 这类似于快指针“超过了”慢指针,但在跳跃游戏中,我们并不真正使用两个指针
if (i >= farthest) { //注意,这里的i是数组的倒数第二个位置,如果i=farthest发生,说明最多只能到达这个位置,不能到达i+1的后面倒数第一个位置
return false;
}
}
// 如果能完成循环而没有返回false,则表示可以从起点跳到数组的末尾
return true;
}
}
这个示例并不是快慢指针的直接应用,而是借用了快慢指针中“相对速度”或“相对进度”的概念来模拟跳跃过程。
这和贪心算法是一致的,对于数组中的任意一个位置 y,我们如何判断它是否可以到达?
只要存在一个位置 x,它本身可以到达,并且它跳跃的最大长度为 x+nums[x],这个值大于等于 y,即 x+nums[x]≥y,那么位置 y 也可以到达。
这样以来,我们依次遍历数组中的每一个位置,并实时维护 最远可以到达的位置。对于当前遍历到的位置 x,如果它在 最远可以到达的位置 的范围内,那么我们就可以从起点通过若干次跳跃到达该位置,因此我们可以用 x+nums[x] 更新 最远可以到达的位置。
时间复杂度:O(n),其中 n 为数组的大小。只需要访问 nums 数组一遍,共 n 个位置。
空间复杂度:O(1),不需要额外的空间开销。
2. 左右指针
应用场景:主要用于有序数组或可排序数组的问题中,通过两个指针从数组的两端向中间移动来寻找满足条件的元素对或元素组合。
2.1 两数之和-输入有序数组
问题描述:给定一个已排序的整数数组和一个目标值,找到数组中和为目标值的两个数,返回它们的索引。
解题思路:初始化两个指针,一个指向数组开头(左指针),一个指向数组末尾(右指针)。
class Solution {
public int[] twoSum(int[] numbers, int target) {
int low = 0, high = numbers.length - 1;
while (low < high) {
int sum = numbers[low] + numbers[high];
if (sum == target) {
return new int[]{low, high};
} else if (sum < target) {
++low;
} else {
--high;
}
}
return new int[]{-1, -1};
}
}
2.2 盛最多水的容器
问题描述:给定一个固定大小的数组,将其想象为接雨水的容器两边的高度,计算能够接到的最大雨水量。
解决思路:
public class Solution {
public int maxArea(int[] height) {
// 初始化两个指针,一个指向数组的开始位置l=0,一个指向数组的结束位置r=height.length - 1
int l = 0, r = height.length - 1;
// 初始化最大面积ans为0,用于记录过程中遇到的最大面积
int ans = 0;
//当左指针小于右指针时,执行循环
while (l < r) {
// 计算当前左右指针所代表的容器面积
// 面积 = 较小的高度 * 指针之间的距离
int area = Math.min(height[l], height[r]) * (r - l);
// 更新最大面积ans,如果当前面积大于ans,则更新ans
ans = Math.max(ans, area);
// 移动指针的策略:
// 如果左指针的高度小于等于右指针的高度,那么移动左指针(l++)
// 因为在移动左指针的过程中,容器的宽度在减小,而高度受限于左指针的高度,所以可能找到更大的面积的唯一机会是左指针的高度增加
// 同理,如果左指针的高度大于右指针的高度,那么移动右指针(r--)
if (height[l] <= height[r]) {
++l;
}
else {
--r;
}
}
// 循环结束后,ans中存储的就是能够盛放最多水的容器的面积
return ans;
}
}
3. 滑动窗口
应用场景:用于在数组或字符串中查找满足特定条件的连续子数组或子字符串的长度、数量或和等。
3.1 滑动窗口最大值
问题描述:给定一个数组和滑动窗口的大小,找出所有滑动窗口里元素的最大值。
解决思路:使用双端队列(Deque)的解法思路(似双指针)
维护一个单调递减的双端队列:队列头部存储的是当前窗口的最大值。
滑动窗口:随着窗口向右滑动,我们需要从队列中移除不再属于窗口的元素,并从左向右遍历新进入窗口的元素,将比队列尾部小的元素全部移除,以保持队列的单调递减性。
更新结果:在每次窗口滑动后,队列的头部即为当前窗口的最大值。
import java.util.Deque;
import java.util.LinkedList;
public class SlidingWindowMaximum {
public int[] maxSlidingWindow(int[] nums, int k) {
if (nums == null || nums.length == 0) return new int[0];
Deque<Integer> deque = new LinkedList<>(); // 使用双端队列来存储索引
int[] result = new int[nums.length - k + 1]; // 结果数组大小
int index = 0; // 结果数组的索引
for (int i = 0; i < nums.length; i++) {
// 移除队列中所有小于当前元素的索引,保证队列的单调递减性
while (!deque.isEmpty() && nums[deque.peekLast()] <= nums[i]) {
deque.pollLast();
}
deque.offerLast(i); // 将当前元素的索引加入队列
// 移除窗口外的索引
if (deque.peekFirst() <= i - k) {
deque.pollFirst();
}
// 当窗口大小达到k时,开始记录最大值
if (i >= k - 1) {
result[index++] = nums[deque.peekFirst()];
}
}
return result;
}
}
3.2 无重复字符的最长子串
问题描述:给定一个字符串,找出其中不含有重复字符的最长子串的长度。
class Solution {
public int lengthOfLongestSubstring(String s) {
// 哈希集合,记录每个字符是否出现过
Set<Character> occ = new HashSet<Character>();
int n = s.length();
// 右指针,初始值为 -1,相当于我们在字符串的左边界的左侧,还没有开始移动
int rk = -1, ans = 0;
for (int i = 0; i < n; ++i) {
if (i != 0) {
// 左指针向右移动一格,移除一个字符
occ.remove(s.charAt(i - 1));
}
while (rk + 1 < n && !occ.contains(s.charAt(rk + 1))) {
// 不断地移动右指针
occ.add(s.charAt(rk + 1));
++rk;
}
// 第 i 到 rk 个字符是一个极长的无重复字符子串
ans = Math.max(ans, rk - i + 1);
}
return ans;
}
}
4. 指针交换
应用场景:用于在数组或链表中进行元素的原地交换或排序,以达到特定的排序顺序或满足特定的条件。
4.1 移动零
问题描述:给定一个数组,将数组中的所有0移动到数组的末尾,同时保持非零元素的相对顺序不变。
解决思路:使用两个指针分别指向需要交换的元素或链表的节点。
通过交换指针所指向的元素或节点的值(或指向的下一个节点),来达到排序或反转的目的。
class Solution {
public void moveZeroes(int[] nums) {
int slow = 0;
int size = nums.length;
for (int fast=0;fast<size;fast++){
if (nums[fast]==0){
continue;
}
if (slow<fast){
nums[slow]=nums[fast];
}
slow++;
}
for (;slow<size;slow++){
nums[slow]=0;
}
}
}
4.2 反转链表
问题描述:给定链表的头节点,反转链表并返回反转后的链表的头节点。
class Solution:
def reverseList(self, head: ListNode) -> ListNode:
cur, pre = head, None
while cur:
tmp = cur.next # 暂存后继节点 cur.next
cur.next = pre # 修改 next 引用指向
pre = cur # pre 暂存 cur
cur = tmp # cur 访问下一节点
return pre
5. 中心扩展
应用场景:主要用于求解最长回文子串等问题,通过枚举中心点并向两侧拓展来寻找最长的回文子串。
class Solution {
public String longestPalindrome(String s) {
if (s == null || s.length() < 1) {
return "";
}//如果输入的字符串为null或者长度为0,则直接返回空字符串,因为没有回文子串可以返回。
int start = 0, end = 0;//初始化两个变量start和end来记录最长回文子串的起始和结束位置。初始时,假设最长回文子串就是整个字符串的第一个字符(如果有的话),即start和end都指向第一个字符的索引
for (int i = 0; i < s.length(); i++) {
//遍历字符串s中的每个字符,以当前字符为中心或两个相邻字符为中心,对于奇数长度和偶数长度的回文子串)向两边扩展,寻找回文子串。
int len1 = expandAroundCenter(s, i, i);//以当前字符s.charAt(i)为中心(奇数长度回文子串的情况),调用expandAroundCenter函数进行扩展,并返回扩展后的回文子串长度。
int len2 = expandAroundCenter(s, i, i + 1);//以当前字符和下一个字符s.charAt(i)和s.charAt(i+1)为中心(偶数长度回文子串的情况),调用expandAroundCenter函数进行扩展,并返回扩展后的回文子串长度。
int len = Math.max(len1, len2);
if (len > end - start) {
start = i - (len - 1) / 2;
end = i + len / 2;
}//如果当前找到的回文子串长度len大于之前记录的最长回文子串的长度(end - start),则更新start和end为当前回文子串的起始和结束位置。这里使用了(len - 1) / 2和len / 2来计算新的start和end,因为中心可能是一个字符(奇数长度)或两个字符(偶数长度)。
}
return s.substring(start, end + 1);
}
public int expandAroundCenter(String s, int left, int right) {
while (left >= 0 && right < s.length() && s.charAt(left) == s.charAt(right)) {
--left;
++right;
}
return right - left - 1;//最后,使用substring方法根据start和end的值从原字符串s中截取并返回最长回文子串。
}
}
整体思路是通过中心扩展法来寻找最长回文子串。对于字符串中的每个字符(或每对相邻字符),都尝试以它们为中心向两边扩展,看能形成多长的回文子串。通过遍历所有可能的中心,并保留最长的回文子串的起始和结束位置,最终返回这个最长回文子串。