双指针模板

常用算法与数据结构模板系列(十五)

Posted by John Mactavish on August 20, 2021

双指针不是某一种算法,而是一类算法,原则上使用两个变量作为指针操作数组、链表等的算法都可称为双指针。根据问题的不同,双指针采用不同的更新策略, 就形成了不同的模板。今天就来大概地总结一下这些模板。

前后双指针

“原地删除数组中特定元素”问题

例如 27.移除元素

给你一个数组 nums 和一个值 val,你需要 原地 移除所有数值等于 val 的元素,并返回移除后数组的新长度。
不要使用额外的数组空间,你必须仅使用 O(1) 额外空间并 原地 修改输入数组。
元素的顺序可以改变。你不需要考虑数组中超出新长度后面的元素。

说明:
为什么返回数值是整数,但输出的答案是数组呢?
请注意,输入数组是以「引用」方式传递的,这意味着在函数里修改输入数组对于调用者是可见的。
你可以想象内部操作如下:

// nums 是以“引用”方式传递的。也就是说,不对实参作任何拷贝
int len = removeElement(nums, val);

// 在函数里修改输入数组对于调用者是可见的。
// 根据你的函数返回的长度, 它会打印出数组中 该长度范围内 的所有元素。
for (int i = 0; i < len; i++) {
    print(nums[i]);
}
 
示例 :
输入:nums = [3,2,2,3], val = 3
输出:2, nums = [2,2]
解释:函数应该返回新的长度 2, 并且 nums 中的前两个元素均为 2。你不需要考虑数组中超出新长度后面的元素。例如,函数返回的新长度为 2 ,而 nums = [2,2,3,3] 或 nums = [2,2,0,0],也会被视作正确答案。

我们可以使用双指针来原地修改:右指针指向当前将要处理的元素 (即判断它是否为“特定元素”,在本题中,“特定元素”是某个具体的值), 左指针指向下一个将要赋值的位置,它的左侧是处理完的部分。不难想到右指针一定在左指针前面。

有两种思路来更新双指针:

一种是先更新左指针,再用循环更新右指针,使其跳过不满足要求的元素。

class Solution:
    def removeElement(self, nums: List[int], val: int) -> int:
        i, j = 0, 0
        while j <= len(nums) - 1:
            while j <= len(nums) - 1 and nums[j] == val:
                j += 1
            if j <= len(nums) - 1:
                nums[i] = nums[j]
                i += 1
                j += 1
        return i

不难发现,第一种思路中指针的移动较为复杂,所以更推荐下面这种思路。 在外层循环更新右指针,每次移动一步,只要没碰到“特定元素”就都可以移动左指针。即先更新右指针,再更新左指针。因为右指针只通过外层循环前进, 思路要清晰的多。

class Solution:
    def removeElement(self, nums: List[int], val: int) -> int:
        i = 0
        for j in range(len(nums)):
            if nums[j] != val:
                nums[i] = nums[j]
                i += 1
        return i

数组换成链表也是同样的做法,只不过,链表不能用 for-in 枚举右指针, 需要 while 配合指针迭代前进。

# 203. 移除链表元素

# 给你一个链表的头节点 head 和一个整数 val ,
# 请你删除链表中所有满足 Node.val == val 的节点,并返回新的头节点。

class ListNode:
    def __init__(self, val=0, next=None):
        self.val = val
        self.next = next

class Solution:
    def removeElements(self, head: ListNode, val: int) -> ListNode:
        dummy = ListNode(next=head)
        left, right = dummy, head
        while right:
            if right.val != val:
                left.next = right
                left = left.next
            right = right.next
        left.next = None
        return dummy.next

类似的题目还有 26.删除有序数组中的重复项80.删除有序数组中的重复项 II 等。以后者为例:

给你一个有序数组 nums,请你原地删除重复出现的元素,使每个元素最多出现两次,返回删除后数组的新长度。

不要使用额外的数组空间,你必须在原地修改输入数组并在使用 O(1) 额外空间的条件下完成。

示例 :
输入:nums = [1,1,1,2,2,3]
输出:5, nums = [1,1,2,2,3]
解释:函数应返回新长度 length = 5, 并且原数组的前五个元素被修改为 1, 1, 2, 2, 3。不需要考虑数组中超出新长度后面的元素。

它不过是对于“特定元素”的定义做了改变,我们可以还用上面的思路完成。 对于每个右指针所指元素,当且仅当左指针前面两个都已经是相同元素时需要删除。为了照顾边界,左右指针都从索引 2 出发。

class Solution:
    def removeDuplicates(self, nums: List[int]) -> int:
        i = 2
        for j in range(2, len(nums)):
            if nums[i - 2] != nums[j]:
                nums[i] = nums[j]
                i += 1
        return i

partition 问题

partition 问题通常表述为“对一个数组原地排序,使得同一类别的元素排在相邻位置”。 例如 905.按奇偶排序数组

# 给定一个非负整数数组 A,返回一个数组,在该数组中, 
# A 的所有偶数元素之后跟着所有奇数元素。
# 你可以返回满足此条件的任何数组作为答案。

# 示例:
# 输入:[3,1,2,4]
# 输出:[2,4,3,1]
# 输出 [4,2,3,1],[2,4,1,3] 和 [4,2,1,3] 也会被接受。

class Solution:
    def sortArrayByParity(self, nums: List[int]) -> List[int]:
        i = 0
        for j in range(0, len(nums)):
            if nums[j] % 2 == 0:
                nums[i], nums[j] = nums[j], nums[i]
                i += 1
        return nums

可以看到它的解答同“原地删除数组中特定元素”问题的解答十分相似。 左指针 i 标识数组左侧区域(偶数元素区)的下一个待填入位置,右指针 j 遍历数组, 把偶数交换到左侧。理解这种写法的关键在于注意到左指针的更新仅与右指针元素有关, 且 [i, j) 区间内全是奇数。特别的,因为左指针要么与右指针重合,要么指向奇数, 所以不可能把偶数交换到右侧去。

由此可以抽象出 partition 函数的模板。

def partition(nums: List[int], left: int, right: int, pred: Callable[[int], bool])-> int:
    i = left
    for j in range(left, right + 1):
        if pred(j):
            nums[i], nums[j] = nums[j], nums[i]
            i += 1
    return i

nums[left:right+1] 是要 partition 的区间,pred 是一个接收索引返回布尔值的函数, 令 pred 返回 True 的索引处的元素最终会聚集在区间的左侧,反之在右侧。 partition 函数最终返回分界面右端索引,即右侧区域起始索引。

我们可以用这个函数解决经典的“荷兰国旗问题”

# 给定一个包含红色、白色和蓝色,一共 n 个元素的数组,原地对它们进行排序,
# 使得相同颜色的元素相邻,并按照红色、白色、蓝色顺序排列。
# 此题中,我们使用整数 0、1 和 2 分别表示红色、白色和蓝色。

# 示例 :
# 输入:nums = [2,0,2,1,1,0]
# 输出:[0,0,1,1,2,2]

class Solution:
    def sortColors(self, nums: List[int]) -> None:
        def partition(nums: List[int], left: int, right: int, pred: Callable[[int], bool])-> int:
            # ...
        a = partition(nums, 0, len(nums) - 1, lambda j: nums[j] == 0)
        partition(nums, a, len(nums) - 1, lambda j: nums[j] == 1)

三类数的 partition 可以分为两次两类数的 partition。 同理,K 类数的 partition 可以分为 K-1 次两类数的 partition

partition 函数也是经典的快速排序的重要组成部分。

def quick_sort(array, left, right):
    if left >= right:
        return

    def partition(nums: List[int], left: int, right: int, pred: Callable[[int], bool])-> int:
        # ...

    pivot = array[right]
    mid = partition(array, left, right - 1, lambda j: array[j] <= pivot)
    array[right], array[mid] = array[mid], array[right]
    quick_sort(array, left, mid - 1)
    quick_sort(array, mid + 1, right)

滑动窗口

滑动窗口的基本模板是这样的:

def slidingWindow(s):
    ans = ...
    left, right = 0, 0
    window = ...
    while right < len(s):
        window.add(s[right])
        right += 1
        while window needs shrink:
            update(ans)
            window.remove(s[left])
            left += 1
    return ans

外层循环先把右指针元素添加进表示窗口的数据结构,再自增右指针; 内层循环则先把左指针元素移出窗口,再自增左指针。 先更新窗口、再更新指针的操作顺序不能颠倒,否则会出现越界错误。 更新答案的操作可以放在内层循环之中或之后, 取决于该处的左开右闭区间 [left, right) 是否符合题目要求。

例如 剑指 Offer II 008. 和大于等于 target 的最短子数组

# 给定一个含有 n 个正整数的数组和一个正整数 target。
# 找出该数组中满足其和 ≥ target 的长度最小的连续子数组,
# 并返回其长度。如果不存在符合条件的子数组,返回 0。
#
# 示例 1:
# 输入:target = 7, nums = [2,3,1,2,4,3]
# 输出:2
# 解释:子数组 [4,3] 是该条件下的长度最小的子数组。
#
# 示例 2:
# 输入:target = 11, nums = [1,1,1,1,1,1,1,1]
# 输出:0

import sys
class Solution:
    def minSubArrayLen(self, target: int, nums: List[int]) -> int:
        left = 0
        s = 0
        ans = sys.maxsize
        for right in range(len(nums)):
            s += nums[right]
            while s >= target:
                ans = min(ans, right - left + 1)
                s -= nums[left]
                left += 1

        return ans if ans != sys.maxsize else 0        

这里“表示窗口的数据结构”实际上是 s,存放窗口子数组的元素和。 另外,外层循环使用 for 来枚举,等价于右指针自增操作被放到了循环的最后进行。因而内层循环之中或之后处的区间变成了 [left, right], 此时左指针还有可能比右指针刚好大 1,用于表示空区间, 鉴于此,最好还是像模板中那样用两个 while 循环。

再看示例 剑指 Offer 48. 最长不含重复字符的子字符串

# 请从字符串中找出一个最长的不包含重复字符的子字符串,
# 计算该最长子字符串的长度。

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

# 示例 2:
# 输入: "bbbbb"
# 输出: 1
# 解释: 因为无重复字符的最长子串是 "b",所以其长度为 1。

class Solution:
    def lengthOfLongestSubstring(self, s: str) -> int:
        left, right = 0, 0
        ans = 0
        window = {}
        while right < len(s):
            c = s[right]
            if c not in window:
                window[c] = 0
            window[c] += 1
            right += 1

            while window[c] > 1:
                window[s[left]] -= 1
                left += 1

            ans = max(ans, right - left)
        return ans

哈希表 window 存放区间中字符的出现次数,区间收缩条件是区间最右端(即 right - 1 位置)元素出现过两次,自增左指针直到第一次出现的位置被跳过。这道题内层循环中的区间都不满足题意, 所以答案的更新放在内层循环之后。实际上,如果我们改用哈希表来存放每个字符上次出现的位置, 我们就可以让左指针一次性跳过上次出现的位置,但那就不是模板了,所以这里不做介绍。

更复杂的问题,如 76.最小覆盖子串,不过是增加了窗口维护的难度。

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

# 注意:
# 对于 t 中重复字符,我们寻找的子字符串中该字符数量必须不少于 t 中该字符数量。
# 如果 s 中存在这样的子串,我们保证它是唯一的答案。
#  
# 示例 :
# 输入:s = "ADOBECODEBANC", t = "ABC"
# 输出:"BANC"

class Solution:
    def minWindow(self, s: str, t: str) -> str:
        need, window = {}, {}
        need_cnt = len(t)
        for c in t:
            if c not in need:
                need[c] = 0
            need[c] += 1

        left = 0
        ans = (0, sys.maxsize)
        for right in range(len(s)):
            if s[right] not in window:
                window[s[right]] = 0
            window[s[right]] += 1
            if s[right] in need and window[s[right]] <= need[s[right]]:
                need_cnt -= 1

            while need_cnt == 0:
                if right - left < ans[1] - ans[0]:
                    ans = (left, right)

                if s[left] in need and window[s[left]] <= need[s[left]]:
                    need_cnt += 1
                window[s[left]] -= 1
                left += 1
        return s[ans[0]:ans[1] + 1] if ans[1] != sys.maxsize else ""

这里的哈希表 window 仍然存放区间中各个字符的出现次数, 而哈希表 need 则存放需要的各个字符及需要的个数。 显然,当 needwindow 的子集时,这个区间是符合题意的。 但是如果每次都比较两个哈希表的话,开销太大,因此引入一个变量 need_cnt 来标识 need 还缺少几个字符。因而,窗口更新的操作相对复杂了不少。

这一模板主要用来处理窗口大小可变的滑动窗口,事实上也能处理固定大小的窗口, 不过后者通常有更简单的写法。固定大小的滑动窗口例题可参考 567.字符串的排列438.找到字符串中所有字母异位词 等。


推荐阅读:

labuladong 的算法小抄

双指针算法模板和一些题目

如果你喜欢我的文章,请我吃根冰棒吧 (o゜▽゜)o ☆

contribution

最后附上 GitHub:https://github.com/gonearewe