双指针技巧

169 阅读1分钟

1 导读

双指针的技巧主要分为两类:左右指针快慢指针

  • 左右指针:两个指针相向而行或者相背而行
  • 快慢指针:两个指针同向而行,但是一快一慢

2 快慢指针技巧

2.1 原地修改

[T26删除有序数组的重复项](#t26 删除有序数组中的重复项)、[T27 移除元素](#t27 移除元素)、[T283 移动零](#t283 移动零)

2.2滑动窗口

滑动窗口主要用来解决子数组的问题,比如寻找某个条件的最长或者最短的子数组。

滑动窗口本质上就是维护一个可变大小的窗口,不断滑动,更新答案,算法的逻辑如下:

int left = 0, right = 0;

while (right < nums.length) {
    // 增大窗口
    window.addLast(nums[right]);
    right++;
    
    // 缩小窗口
    while (windows needs shrink) {
        window.removeFirst(nums[left]);
        left++;
    }
}

基于滑动窗口的算法的时间复杂度为o(N)o(N)左右双指针不会回退,只会增加,每个元素最多完成一次入窗或者出窗的操作。

注意:滑动窗 并不能穷举出所有的子串,滑动窗是利用已有条件,帮助我们聪明地穷举,进行剪枝优化,避免冗余计算。

==重要== 以下是一套滑动窗口的代码框架,适用性非常强,只需要按照要求在相关的地方修改即可。

void slidingWindow(String s) {
    /* 使用合适的数据结构记录窗口中的数据,根据场景变通
     * 例如记录元素出现的频次,就用 map
     * 例如记录窗口中出现的元素和,可以使用 int
     */
    Object window = new Object;
    
    int l = 0, right = 0; // 窗口的边界
    while (r < s.length()) {
        // c是即将进入窗口的字符
        char c = s[r];
        window.add(cur);
        r++; // 增大窗口
        // 进行窗口内数据的更新
        ...
            
        // 判断左侧窗口是否要收缩
        while (l < r && need2Shrink()) {
            // 将字符 d 移出窗口
            char d = s[l];
            window.remove(d);
            l++; // 缩小窗口
            // 窗口之内数据的更新
            ...
            
        }
    }
    
}

上面的框架中, ...表示更新窗口数据地方,在具体的题目中,需要填写代码逻辑。

3 左右指针心法

3.1 二分搜索

二分搜索并不是一个简单的算法,要非常注意细节问题

最令人头痛的问题:

  • 就是mid + 1还是mid - 1
  • while里头 到底用的是<=还是<严格小于

二分查找的基本框架为:

int binaraySearch(int[] nums, int target) {
    int left = 0, right = ...;
    
    while(condition) {
        int mid = left + (right - left) / 2;
        if (nums[mid] == target) {
            // TODO
        } else if (nums[mid] < target) {
            left = ...;
        } else if (nums[mid] > target) {
            right = ...;
        }
    }
}

分析二分查找法的技巧就是:将所有的 if条件书写明白,不要出现含义不明的else,这样有注意把握细节。

为了防止mid溢出,采用left + (right - left) / 2 而不是 (left + right) / 2

1)寻找一个数

二分法最常用的一个场景。例如[T704 二分查找](#t704 二分查找)。

在此讨论一下二分法的实现细节:

为什么是while (l <= r)

初始化r的值为nums.length - 1而不是nums.length,算法的搜索区间[l, r]是一个左闭右闭的区间。

对于<=算法的终止条件为l = r + 1,搜索区间为[r+1, r]里面不含有有效元素,而对于<,算法的终止条件为l = r[r, r],搜索区间依旧有一个有效元素为r,会遗漏掉此元素。

此算法有什么缺陷

如果有多个目标值,例如nums = [1, 2, 2, 2, 3]target = 2,返回结果为index = 2,无法得出左侧边界1和右侧边界索引3

2)寻找左侧边界的二分法

int leftBound(int[] nums, int target) {
    int l = 0;
    int r = nums.length; // :note1
    
    while (l < r) { // :note2
        int mid = l + (r - l) / 2;
        if (nums[mid] == target) {
            r = mid;
        } else if (nums[mid] < target) {
            l = mid + 1;
        } else if (nums[mid] > target) {
            r = mid; // :note3
        }
    }
    return left; // note4
}

note2 为什么循环终止条件为<

这是因为初始化右边界为nums.length,搜索区间是一个左闭右开的区间即:[l,r)[l, r),所以l == r即可保证正确终止。

note4:当target不存在的时候,返回的值是什么?

target不存在的时候,返回的是大于target值的最小值的索引

note3:为什么是r == mid

还是和算法的搜索区间有关,搜索区间是个开区间,为了不漏检掉mid - 1,右侧开区间只能为r = mid

为什么算法能够搜索左侧边界

关键在于nums[mid] == target,关键是锁定上界right,在区间[l, mid)中继续搜索,不断向左收缩,锁定左边界。

3)寻找右侧边界的二分查找

int rightBound(int[] nums, int target) {
    int l = 0; int r = nums.length;
    
    while (l < r) {
        int mid = l + (r - l) / 2;
        if (nums[mid] == target) {
            l = mid + 1;
        } else if (nums[mid] < target) {
            l = mid + 1;
        } else if (nums[mid] > target) {
            right = mid;
        }
    }
    return right - 1;
}

target不存在的时候,返回的是小于target最大值的索引。

总之,最主要的就是确定算法的搜索区间是开区间还是闭区间,一定不能遗漏搜索任何一个元素。

3.2 n数之和

[T167 两数之和II-输入为有序数组](#t167 两数之和II-输入为有序数组)

左右指针动态调整的大小,框架代码有一点像二分法。

3.3 反转数组

[T344 反转字符串](#t344 反转字符串)

3.4 回文串判断

所谓回文串,就是从左往右读和从右往左读都是相同的,例如aba等。

4 双指针经典例题

本节所有的源码地址为:源码地址

4.1 双指针解决链表

当我们需要创建一条新的单向链表的时候,可以使用虚拟头结点来简化边界处理的情况。

T21 合并两个有序链表

题目表述

将两个升序链表合并为一个新的 升序 链表并返回。新链表是通过拼接给定的两个链表的所有节点组成的。

我的思路

  • 虚拟头结点,两个指针分别指向两个链表
  • 循环指向两个链表
  • 最后处理一下边界条件

T86 分隔链表

题目描述

给你一个链表的头节点 head 和一个特定值 x ,请你对链表进行分隔,使得所有 小于 x 的节点都出现在 大于或等于 x 的节点之前。

你应当 保留 两个分区中每个节点的初始相对位置。

我的思路

  • 创建两个子链表,分别用来存储小于等于和大于的节点
  • 使用双指针,将原链表中的节点拆分到两个子链表
  • 两个链表合并起来,返回

一定注意链表的指针问题,修改原链表的时候,要使用临时指针存储。

构建新链表的时候,只需要当前节点的值,所以我们一定要将当前节点和原链表断开连接,防止对新链表造成影响

T23 合并K个升序链表

题目描述

给你一个链表数组,每个链表都已经按升序排列。

请你将所有链表合并到一个升序链表中,返回合并后的链表。

我的思路

  • 将所有的链表放到优先队中
  • 取出最小的,构建新链表
  • 将下一个节点放到优先队列,重构大顶堆

复杂度分析

  • 时间复杂度

    队列元素一共为k个,每次add或者poll复杂度为o(logK)o(logK),总共有N个节点,那么时间复杂度有o(NlogK)o(NlogK)

  • 空间复杂度

    优先队列:元素为kk个,所以空间复杂度为kk

T19 删除链表的倒数第N个节点

题目描述

给你一个链表,删除链表的倒数第 n 个结点,并且返回链表的头结点。

我的思路

  • 快慢两个指针,快指针fast先走n
  • 快慢指针一起走,直到快指针走到tail
  • 此时慢指针的位置在倒数第N1N-1个元素
  • 删除慢指针的next元素

链表最后一个非null节点的条件:p.next != null

这一题同样需要使用虚拟头结点

T876 链表的中间节点

题目描述

给你单链表的头结点 head ,请你找出并返回链表的中间结点。

如果有两个中间结点,则返回第二个中间结点。

我的思路

  • 使用快慢指针,快指针fast一次走两步,慢指针slow一次走一步
  • 循环条件为fast != null && fast.next != null
  • 跳出循环之后,需要检查fast的值
    • 如果fast != null 说明当前是偶数节点,需要返回slow.next
    • 如果fast == null说明当前是奇数节点,需要返回slow
  • 同样需要使用虚拟头结点

T141 环形链表

题目描述

给你一个链表的头节点 head ,判断链表中是否有环。

如果链表中有某个节点,可以通过连续跟踪 next 指针再次到达,则链表中存在环。 为了表示给定链表中的环,评测系统内部使用整数 pos 来表示链表尾连接到链表中的位置(索引从 0 开始)。注意:pos 不作为参数进行传递 。仅仅是为了标识链表的实际情况。

如果链表中存在环 ,则返回 true 。 否则,返回 false

我的思路

  • 使用快慢指针
  • 快指针循环条件为fast != null && fast.next != null
    • 如果跳出循环条件:无环
    • 如果快慢指针相遇:则有环

T142 环形链表II

题目描述

给定一个链表的头节点 head ,返回链表开始入环的第一个节点。 如果链表无环,则返回 null

如果链表中有某个节点,可以通过连续跟踪 next 指针再次到达,则链表中存在环。 为了表示给定链表中的环,评测系统内部使用整数 pos 来表示链表尾连接到链表中的位置(索引从 0 开始)。如果 pos-1,则在该链表中没有环。注意:pos 不作为参数进行传递,仅仅是为了标识链表的实际情况。

不允许修改 链表。

我的思路

  • 使用快慢指针

  • 快指针循环条件为fast != null && fast.next != null

  • 如果两个指针相遇:

    • 快指针走到的路程:2k2k

    • 慢指针的路程:kk

    • 假设head->node头结点到环节点的路程为LL,环节点到相遇节点的距离为xx,相遇节点到尾结点的距离为yy,则

      {L+2x+y=2k,L+x=k\left\{ \begin{array}{rl} L + 2x + y &= 2k,\\ L + x &= k\\ \end{array} \right.

    根据公式可以推导出L=yL = y

  • 两个指针相遇的时候,快指针指向头结点,速度降为1,走LL路程之后,二者相遇,此时记为环节点

两个指针相遇之后,快指针往后走一步fast = head;,慢指针一定也要往后走一步slow = slow.next;

T160 相交链表

题目描述

给你两个单链表的头节点 headAheadB ,请你找出并返回两个单链表相交的起始节点。如果两个链表不存在相交节点,返回 null

我的思路

  • 链表list1的长度为L1+L0L_1 + L_0,链表list2的长度为L2+L0L_2 + L_0,其中L0L_0为公共部分的长度
  • p1指针遍历完list1之后,开始遍历list2
  • p2指针遍历list2之后,开始遍历list1
  • 相遇时两个指针走的距离为L0+L1+L2L_0 + L_1 + L_2
  • 返回相遇时候的节点

由于不确定两个链表是否相交,最好遍历之后找到两个链表的长度和即lenA + lenB

遍历lenA + lenB个节点,如果相同节点,就直接跳出,否则循环结束返回null

记住,遍历到末尾的时候,如果p1.next == null,不能将p1.next = headB的方式指定下一跳,这样会修改链表的结构,正确的做法为:p1 = p1.next == null ? headB : p1.next

T83 删除有序链表中重复的节点

题目描述

给定一个已排序的链表的头 head删除所有重复的元素,使每个元素只出现一次 。返回 已排序的链表

我的思路

  • 快慢指针,慢指针指向有效元素,快指针遍历链表
  • 如果元素相同,快指针网前走一步,慢指针指向快指针
  • 如果元素不同,因为上一步中,慢指针的下个节点已经连接到快指针了,所以慢指针需要往前走一步,指向新的不同的节点,快指针走一步
  • 循环结束条件:fast == null

4.2 双指针解决数组

T26 删除有序数组中的重复项

题目描述

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

考虑 nums 的唯一元素的数量为 k ,你需要做以下事情确保你的题解可以被通过:

  • 更改数组 nums ,使 nums 的前 k 个元素包含唯一元素,并按照它们最初在 nums 中出现的顺序排列。nums 的其余元素与 nums 的大小不重要。
  • 返回 k

我的思路

  • 快慢指针,快指针去检测发生跳变的点,慢指针标记有效数组的索引
  • 如果发生跳变,nums[++slow] = nums[fast]
  • 循环结束条件fast = nums.length
  • 返回值为数组的有效长度,即++slow

T27 移除元素

题目描述

给你一个数组 nums 和一个值 val,你需要 原地 移除所有数值等于 val 的元素。元素的顺序可能发生改变。然后返回 nums 中与 val 不同的元素的数量。

假设 nums 中不等于 val 的元素数量为 k,要通过此题,您需要执行以下操作:

  • 更改 nums 数组,使 nums 的前 k 个元素包含不等于 val 的元素。nums 的其余元素和 nums 的大小并不重要。
  • 返回 k

我的思路

  • 快慢双指针,慢指针指向有效索引位置,快指针遍历寻找不同于val的元素
  • 如果fast == val相同,快指针直接跳过
  • 如果fast != null不同,慢指针所在索引添加元素nums[slow++] = nums[fast++],双指针各向前移动一步
  • 注意慢指针的索引是个左闭右开的区间,即有效元素索引为[0,slow)[0, slow)
  • 返回有效长度为slow

T283 移动零

题目描述

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

请注意 ,必须在不复制数组的情况下原地对数组进行操作。

我的思路

  • 和上一题换汤不换药,快慢双指针,快指针遍历元素,寻找非零元素,慢指针指数组有效位置末尾
  • 同样需要注意的是慢指针是个左闭右开的区间,即有效元素索引为[0,slow)[0, slow)
  • 最后需要将闭区间[slow,nums.length1][slow, nums.length - 1]的元素置为00

T76 最小覆盖字串

题目描述

给你一个字符串 s 、一个字符串 t 。返回 s 中涵盖 t 所有字符的最小子串。如果 s 中不存在涵盖 t 所有字符的子串,则返回空字符串 ""

注意:

  • 对于 t 中重复字符,我们寻找的子字符串中该字符数量必须不少于 t 中该字符数量。
  • 如果 s 中存在这样的子串,我们保证它是唯一的答案。

示例 1:

输入:s = "ADOBECODEBANC", t = "ABC"
输出:"BANC"
解释:最小覆盖子串 "BANC" 包含来自字符串 t 的 'A''B''C'

我的思路

  • 使用两个Map来存储字符的词频,即tDictwinDict
  • 涵盖:需要有一个方法来判断是否是涵盖关系,即isCovering(winStr, t)
  • 如果涵盖的话,左指针进行剪枝,缩减窗口,直到不涵盖
  • 如果不涵盖,右指针前进,直到涵盖
  • 找到结果了,就将结果存储在minWin

T567 字符串的排列

题目描述

给你两个字符串 s1s2 ,写一个函数来判断 s2 是否包含 s1 的 排列。如果是,返回 true ;否则,返回 false

换句话说,s1 的排列之一是 s2子串

我的思路

和上一题基本相同,都是涵盖的问题。

  • 创建一个数组int[26]记录子串的频次表,一个数组win[26]表示当前窗口的频次表
  • 如果不涵盖,窗口右扩,添加新字符

注意,此题中窗口的长度固定为s1.length

T438 找到字符串中所有字母异位词

题目描述

给定两个字符串 sp,找到 s 中所有 p异位词 的子串,返回这些子串的起始索引。不考虑答案输出的顺序。

示例 1:

输入: s = "cbaebabacd", p = "abc"
输出: [0,6]
解释:
起始索引等于 0 的子串是 "cba", 它是 "abc" 的异位词。
起始索引等于 6 的子串是 "bac", 它是 "abc" 的异位词。

我的思路

  • 滑动窗口,窗口固定
  • 两个字典记录字符频次int[26] tarint[26] win
  • 初始化两个字典之后需要判断是否涵盖
  • 窗口左边界l即为起始索引

需要单独判断最后一个窗口,它会因为右开区间跳出循环而少一次判断

T3 无重复字符串的最长字串

题目描述

给定一个字符串 s ,请你找出其中不含有重复字符的 最长 子串 的长度。

示例 1:

输入: s = "abcabcbb"
输出: 3 
解释: 因为无重复字符的最长子串是 "abc",所以其长度为 3。

我的思路

  • 记录窗口字符频次的字典int[128] winDict
  • 是否重复:判断winDict中某个字符的频次大于2
  • 如果未出现重复:窗口往右扩,添加新字符
  • 如果出现重复:
    • 记录此时窗口的大小,更新最从长字符串
    • 窗口左边缩小,左边界移除s.charAt(r)
  • 返回最长字符串的长度

需要单独判断最后一个窗口,他会因为右开区间跳出循环而少一次判断

T704 二分查找

题目描述

给定一个 n 个元素有序的(升序)整型数组 nums 和一个目标值 target ,写一个函数搜索 nums 中的 target,如果目标值存在返回下标,否则返回 -1

我的思路

  • 左右指针实现二分查找
  • 左指针初始化为l = 0,右指针初始化为r = nums.length - 1
  • 循环结束条件为while (l <= r)

T34 在排序数组中查找元素第一和最后一个位置

题目描述

给你一个按照非递减顺序排列的整数数组 nums,和一个目标值 target。请你找出给定目标值在数组中的开始位置和结束位置。

如果数组中不存在目标值 target,返回 [-1, -1]

你必须设计并实现时间复杂度为 O(log n) 的算法解决此问题。

我的思路

  • 使用二分法查找左边界,然后从左边界线性寻找右边界
  • 左右指针初始化int l = 0, r = nums.length
  • 循环结束条件l < r
  • 判断l是否越界,即 l > nums.length - 1 || nums[l] == target
  • l线性寻找r的位置

T167 两数之和II-输入为有序数组

题目描述

给你一个下标从 1 开始的整数数组 numbers ,该数组已按 非递减顺序排列 ,请你从数组中找出满足相加之和等于目标数 target 的两个数。如果设这两个数分别是 numbers[index1]numbers[index2] ,则 1 <= index1 < index2 <= numbers.length

以长度为 2 的整数数组 [index1, index2] 的形式返回这两个整数的下标 index1index2

你可以假设每个输入 只对应唯一的答案 ,而且你 不可以 重复使用相同的元素。

你所设计的解决方案必须只使用常量级的额外空间。

示例 1:

输入:numbers = [2,7,11,15], target = 9
输出:[1,2]
解释:2 与 7 之和等于目标数 9 。因此 index1 = 1, index2 = 2 。返回 [1, 2] 。

我的思路

  • 左右双指针,往中间收缩
  • 初始化为int l = 0, r = nums.length - 1
  • 如果和大于目标,右指针移动
  • 如果和小于目标,左指针移动
  • 循环条件为l < r
  • 结果要加1,表示第n个索引

T344 反转字符串

题目描述

编写一个函数,其作用是将输入的字符串反转过来。输入字符串以字符数组 s 的形式给出。

不要给另外的数组分配额外的空间,你必须**原地修改输入数组**、使用 O(1) 的额外空间解决这一问题。

我的思路

  • 左右双指针,临时变量进行交换。
  • 循环条件为while (l < r)

T5 最长回文子串

题目描述

给你一个字符串 s,找到 s 中最长的 回文 子串。

示例 1:

输入:s = "babad"
输出:"bab"
解释:"aba" 同样是符合题意的答案。

我的思路

  • 判断回文串的方法isPalindrome(l, r, s)
  • 递归判断:[超时]
    • 判断当前串是否是回文串isPalindrome(l, r, s),是的话直接返回
    • 左缩1位,判断是否是回文串isPalindrome(l+1, r, s)
    • 左缩1位,判断是否是回文串isPalindrome(l, r-1, s)

思路2

  • 判断区间为[0,s.length()2][0, s.length()-2]
  • 对区间每个字符向两边扩散,双指针向北而行
  • 分别计算出奇数扩散和偶数扩散的回文串,取最长更新resStr
  • 扩散边界条件为l > 0, r < s.length() - 1