Bootstrap

代码随想录算法训练营第12天:滑动窗口和前缀和

代码随想录算法训练营第12天:滑动窗口和前缀和

这里我参考了西法的博客,

前四道题都是滑动窗口的子类型,我们知道滑动窗口适合在题目要求连续的情况下使用, 而前缀和也是如此。二者在连续问题中,对于优化时间复杂度有着很重要的意义。 因此如果一道题你可以用暴力解决出来,而且题目恰好有连续的限制, 那么滑动窗口和前缀和等技巧就应该被想到。

除了这几道题, 还有很多题目都是类似的套路, 大家可以在学习过程中进行体会。今天我们就来一起学习一下。

母题 0

有 N 个的正整数放到数组 A 里,现在要求一个新的数组 B,新数组的第 i 个数 B[i]是原数组 A 第 0 到第 i 个数的和。

这道题可以使用前缀和来解决。 前缀和是一种重要的预处理,能大大降低查询的时间复杂度。我们可以简单理解前缀和为“数列的前 n 项的和”。

这个概念其实很容易理解,即一个数组中,第 n 位存储的是数组前 n 个数字的和。

例如,对 [1,2,3,4,5,6] 来说,其前缀和就是 pre=[1,3,6,10,15,21]。

我们可以使用公式 pre[𝑖]=pre[𝑖−1]+nums[𝑖]得到每一位前缀和的值,从而通过前缀和进行相应的计算和解题。如果想得到某一个连续区间[l,r]的和则可以通过 pre[r] - pre[l-1] 取得,不过这里 l 需要 > 0。我们可以加一个特殊判断,如果 l = 0,区间 [l,r] 的和就是 pre[r]。

我们也可以在前缀和首项前面添加一个 0 省去这种特殊判断,算是一个小技巧吧。

其实前缀和的概念很简单,但困难的是如何在题目中使用前缀和以及如何使用前缀和的关系来进行解题。

母题 1

如果让你求一个数组的连续子数组总个数,你会如何求?其中连续指的是数组的索引连续。 比如 [1,3,4],其连续子数组有:[1], [3], [4], [1,3], [3,4] , [1,3,4]​,你需要返回 6。

一种思路是总的连续子数组个数等于:以索引为 0 结尾的子数组个数 + 以索引为 1 结尾的子数组个数 + … + 以索引为 n - 1 结尾的子数组个数,这无疑是完备的。

同时利用母题 0 的前缀和思路, 边遍历边求和。

参考代码(c++):

‍‍```cpp
#include <iostream>
#include <vector>

int countSubArray(const std::vector<int>& nums) {
    int ans = 0;
    int pre = 0;
    for (const auto& num : nums) {
        pre += 1;
        ans += pre;
    }
    return ans;
}

int main() {
    std::vector<int> nums = {1, 2, 3, 4, 5};
    int result = countSubArray(nums);
    std::cout << "The result is: " << result << std::endl;
    return 0;
}
‍‍```

而由于以索引为 i 结尾的子数组个数就是 i + 1,因此这道题可以直接用等差数列求和公式 (1 + n) * n / 2​,其中 n 数组长度。

母题 2

我继续修改下题目, 如果让你求一个数组相邻差为 1 连续子数组的总个数呢?(如果只有一个数,那么我们也认为其实一个相邻差为 1 连续子数组)其实就是索引差 1 的同时,值也差 1。

和上面思路类似,无非就是增加差值的判断。


int countSubArray(const std::vector<int>& nums) {
    int ans = 0;
    int pre = 0;
    for (const auto& num : nums) {
	if (nums[i] - nums[i-1]  == 1 ){
 	 pre += 1; 
    	}
	else{
		pre = 1;
		}
	ans+=pre;
    return ans;
}

如果我值差只要大于 1 就行呢?其实改下符号就行了,这不就是求上升子序列个数么?这里不再继续赘述, 大家可以自己试试。

母题 3

我们继续扩展。

如果我让你求出不大于 k 的子数组的个数呢?不大于 k 指的是子数组的全部元素都不大于 k。 比如 [1,3,4] 子数组有 [1], [3], [4], [1,3], [3,4] , [1,3,4]​,不大于 3 的子数组有 [1], [3], [1,3]​ ,那么 [1,3,4] 不大于 3 的子数组个数就是 3。 实现函数 atMostK(k, nums)。

‍‍```cpp
int countSubArray(int k, std::vector<int>& nums) {
    int ans = 0;
    int pre = 0;
    for (int i = 0; i < nums.size(); i++) { // 修改为 nums.size() 而不是 nums.count
        if (nums[i] <= k) {
            pre += 1;
        } else {
            pre = 0;
        }

        ans += pre;
    }
    return ans;
}
‍‍```

母题 4

如果我让你求出子数组最大值刚好是 k 的子数组的个数呢? 比如 [1,3,4] 子数组有 [1], [3], [4], [1,3], [3,4] , [1,3,4]​,子数组最大值刚好是 3 的子数组有 [3], [1,3]​ ,那么 [1,3,4] 子数组最大值刚好是 3 的子数组个数就是 2。实现函数 exactK(k, nums)。

实际上是 exactK 可以直接利用 atMostK,即 atMostK(k) - atMostK(k - 1),原因见下方母题 5 部分。

母题 5

如果我让你求出子数组最大值刚好是 介于 k1 和 k2 的子数组的个数呢?实现函数 betweenK(k1, k2, nums)。

实际上是 betweenK 可以直接利用 atMostK,即 atMostK(k1, nums) - atMostK(k2 - 1, nums),其中 k1 > k2。前提是值是离散的, 比如上面我出的题都是整数。 因此我可以直接 减 1,因为 1 是两个整数最小的间隔

如上,小于等于 10 的区域​减去 小于 5 的区域​就是 大于等于 5 且小于等于 10 的区域​。

注意我说的是小于 5, 不是小于等于 5。 由于整数是离散的,最小间隔是 1。因此小于 5 在这里就等价于 小于等于 4。这就是 betweenK(k1, k2, nums) = atMostK(k1) - atMostK(k2 - 1) 的原因。

因此不难看出 exactK 其实就是 betweenK 的特殊形式。 当 k1 == k2 的时候, betweenK 等价于 exactK。

因此 atMostK 就是灵魂方法,一定要掌握,不明白建议多看几遍。

467. 环绕字符串中唯一的子字符串(中等)

题目描述

把字符串 s 看作是“abcdefghijklmnopqrstuvwxyz”的无限环绕字符串,所以 s 看起来是这样的:"...zabcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcd....". 

现在我们有了另一个字符串 p 。你需要的是找出 s 中有多少个唯一的 p 的非空子串,尤其是当你的输入是字符串 p ,你需要输出字符串 s 中 p 的不同的非空子串的数目。 

注意: p 仅由小写的英文字母组成,p 的大小可能超过 10000。

 

示例 1:

输入: "a"
输出: 1
解释: 字符串 S 中只有一个"a"子字符。
 

示例 2:

输入: "cac"
输出: 2
解释: 字符串 S 中的字符串“cac”只有两个子串“a”、“c”。.
 

示例 3:

输入: "zab"
输出: 6
解释: 在字符串 S 中有六个子串“z”、“a”、“b”、“za”、“ab”、“zab”。.

前置知识

  • 滑动窗口

思路

题目是让我们找 p 在 s 中出现的非空子串数目,而 s 是固定的一个无限循环字符串。由于 p 的数据范围是 105,因此暴力找出所有子串就需要 1010 次操作了,应该会超时。而且题目很多信息都没用到,肯定不对。

仔细看下题目发现,这不就是母题 2 的变种么?话不多说, 直接上代码,看看有多像。

变种倒是有点不恰当,但是确实是求子数组的组成,下面展示leetcode的代码(确实是变种,是相邻的字母组成的子数组)

class Solution {
public:
    int findSubstringInWraproundString(string p) {
        vector<int> dp(26);
        int k = 0;
        for (int i = 0; i < p.length(); ++i) {
            if (i && (p[i] - p[i - 1] + 26) % 26 == 1) { // 字符之差为 1 或 -25
                ++k;
            } else {
                k = 1;
            }
            dp[p[i] - 'a'] = max(dp[p[i] - 'a'], k);
        }
        return accumulate(dp.begin(), dp.end(), 0);
    }
};

这里用到动态规划,但是大体逻辑和母题基本一致

Gnakuw66F2AABD-56AC-42BA-860D-8ACC2E6F2C42_1_201_a.jpeg

  • 确定dp数组以及下标的含义:

    • dp[i]:以字符串p[i]结尾的连续字符串的非空子串长度增量为dp[i].
  • 确定递推公式
    注:p[i]-'a'是得到对应dp数组下标

    • 当 p[i-1] == p[i],count++,与上一个字符构成连续 时: 此情况下最长字符串长度为 dp[p[i]-‘a’] = max(dp[p[i]-‘a’],count);
    • 当 p[i-1]!=p[i],count = 1,与上一个字符不连续 时: 此情况下最长字符串长度为 dp[p[i]-‘a’] = max(dp[p[i]-‘a’],count);。
    • 【转移方程】 dp[p[i]-'a'] = max(dp[p[i]-'a'],count)<span> </span>​。
  • 初始状态:

    • i = 0 时 dp[p[i]-‘a’] = 1。
  • 确定遍历顺序

    • 从递推公式上可以看出, dp[i] 依赖 dp[i-1],所以一定是从前向后遍历。
  • 确定返回值

    • dp数组求和即为结果。

795. 区间子数组个数(中等)

题目描述


给定一个元素都是正整数的数组 A ,正整数 L  以及  R (L <= R)。

求连续、非空且其中最大元素满足大于等于 L  小于等于 R 的子数组个数。

例如 :
输入:
A = [2, 1, 4, 3]
L = 2
R = 3
输出: 3
解释: 满足条件的子数组: [2], [2, 1], [3].
注意:

L, R  和  A[i] 都是整数,范围在  [0, 10^9]。
数组  A  的长度范围在[1, 50000]。

前置知识

  • 滑动窗口

思路

由母题 5,我们知道 betweenK 可以直接利用 atMostK,即 atMostK(k1) - atMostK(k2 - 1),其中 k1 > k2

由母题 2,我们知道如何求满足一定条件(这里是元素都小于等于 R)子数组的个数。

这两个结合一下, 就可以解决。

这里展现灵神的代码:

795-6-cut.png

i1−i0i_1-i_0i1− i0 是怎么得出来的?

:上一个不能包含的下标为 i0i_0i0,等价于上一个可以包含的下标为 i0+1i_0+1i0+ 1,这是子数组左端点的最小值。

设当前遍历到下标 iii,那么子数组 [i0+1,i],[i0+2,i],⋯ ,[i1,i][i_0+1,i],[i_0+2,i],\cdots,[i_1,i] [i0+1,i] , [i0+2,i] ,, [i1,i] 都是满足条件的,这有 i1−i0i_1-i_0i1− i0 个。

这仔细看清楚是很清晰的,比官方理解容易:

class Solution{
public:
    int numSubarrayBoundedMax(vector<int> &nums,int left,int right){
        int n = nums.size(),ans = 0,i0 = -1,i1 = -1;
        for(int i = 0;i<n;++i){
            if(nums[i] > right)i0 = i;
            if (nums[i]>= left)i1 = i;
            ans += i1 - i0;
        }
        return ans;
    }
};

904. 水果成篮(中等)

题目描述

在一排树中,第 i 棵树产生 tree[i] 型的水果。
你可以从你选择的任何树开始,然后重复执行以下步骤:

把这棵树上的水果放进你的篮子里。如果你做不到,就停下来。
移动到当前树右侧的下一棵树。如果右边没有树,就停下来。
请注意,在选择一颗树后,你没有任何选择:你必须执行步骤 1,然后执行步骤 2,然后返回步骤 1,然后执行步骤 2,依此类推,直至停止。

你有两个篮子,每个篮子可以携带任何数量的水果,但你希望每个篮子只携带一种类型的水果。

用这个程序你能收集的水果树的最大总量是多少?

 

示例 1:

输入:[1,2,1]
输出:3
解释:我们可以收集 [1,2,1]。
示例 2:

输入:[0,1,2,2]
输出:3
解释:我们可以收集 [1,2,2]
如果我们从第一棵树开始,我们将只能收集到 [0, 1]。
示例 3:

输入:[1,2,3,2,2]
输出:4
解释:我们可以收集 [2,3,2,2]
如果我们从第一棵树开始,我们将只能收集到 [1, 2]。
示例 4:

输入:[3,3,3,1,2,1,1,2,3,3,4]
输出:5
解释:我们可以收集 [1,2,1,1,2]
如果我们从第一棵树或第八棵树开始,我们将只能收集到 4 棵水果树。
 

提示:

1 <= tree.length <= 40000
0 <= tree[i] < tree.length

前置知识

  • 滑动窗口

思路

题目花里胡哨的。我们来抽象一下,就是给你一个数组, 让你选定一个子数组, 这个子数组最多只有两种数字,这个选定的子数组最大可以是多少。

这不就和母题 3 一样么?只不过 k 变成了固定值 2。另外由于题目要求整个窗口最多两种数字,我们用哈希表存一下不就好了吗?

set 是不行了的。 因此我们不但需要知道几个数字在窗口, 我们还要知道每个数字出现的次数,这样才可以使用滑动窗口优化时间复杂度。

这段代码是一个解决“水果篮子”问题的C++解决方案。问题的核心在于找到最长的连续子数组,其中只包含最多两种不同的数字。数组中的每个数字表示一种特定种类的水果,目标是选出一个最长的连续水果区间,使得这个区间里最多有两种不同种类的水果。

下面是这段代码的详细解释,包含了注释:

‍‍‍```cpp
class Solution {
public:
    int totalFruit(vector<int>& fruits) {
        int n = fruits.size();  // 数组的长度
        int ans = 0;  // 最长连续子数组的长度
        int a = -1;  // 用来记录过程中的一种水果类型,初始化为-1
        int i = 0, j = 0;  // 双指针i和j,用来标记当前考察的连续子数组的起始和结束位置

        while(i < n) {  // 遍历数组
            int b = fruits[i];  // 当前考察的水果类型
            int st = j;  // 记录此次寻找的起始位置
            // 循环条件判断当前水果类型是否是我们正在考察的两种水果之一
            while(i < n && (fruits[i] == a || fruits[i] == b)) {
                if (fruits[i] == b) {
                    swap(a,b);  // 如果当前水果是b,那么交换a和b,确保a始终是最后一次出现的新水果
                    j = i;  // 更新j为最新的水果b的位置
                }
                ++i;  // 继续向前查找
            }
            ans = max(i - st, ans);  // 更新最长连续子数组的长度
        }
        return ans;  // 返回结果
    }
};
‍‍‍```

核心思想是,使用滑动窗口的方法来找出只包含两种水果的最长连续子数组。变量a​和b​用来分别记录当前滑动窗口中的两种水果类型,而i​和j​则是用于标记当前考察的连续区间的两个端点。如果遇到不属于当前两种水果的第三种水果时,这个连续区间就结束了,此时计算出这个区间的长度,并和之前的长度进行比较,选出最大值。通过这种方式,遍历完整个数组后,就能获取到含有最多两种水果的最长区间长度。

解题思路

  1. 出发点是追求通法,笔者刚刚做完了76题。毫无疑问,这道题也是用滑动窗口的,lc.76也使用滑动窗口。但很奇怪,笔者在很快做出76题后,惊讶的发现在这道题76题的思路并不适用?!76题极简代码、思路
  2. 同样是滑动窗口,这两题有什么区别?区别在于76题求的是最小滑窗,而本题求的是最大滑窗
  3. 最小滑窗模板:给定数组 nums,定义滑窗的左右边界 i, j,求满足某个条件的滑窗的最小长度。
while j < len(nums):
    判断[i, j]是否满足条件
    while 满足条件:
        不断更新结果(注意在while内更新!)
        i += 1 (最大程度的压缩i,使得滑窗尽可能的小)
    j += 1
  1. 最大滑窗模板:给定数组 nums,定义滑窗的左右边界 i, j,求满足某个条件的滑窗的最大长度。
while j < len(nums):
    判断[i, j]是否满足条件
    while 不满足条件:
        i += 1 (最保守的压缩i,一旦满足条件了就退出压缩i的过程,使得滑窗尽可能的大)
    不断更新结果(注意在while外更新!)
    j += 1
  1. 是的,关键的区别在于,最大滑窗是在迭代右移右边界的过程中更新结果,而最小滑窗是在迭代右移左边界的过程中更新结果。因此虽然都是滑窗,但是两者的模板和对应的贪心思路并不一样,而真正理解后就可以在lc.76,lc.904,lc.3, lc.1004写出非常无脑的代码。
  2. 时间复杂度为:O(N), 空间复杂度为:O(N).

其实双指针和滑动窗口是有些许区别的。滑动窗口一句话就是右指针先出发,左指针视情况追赶右指针。可类比男生暗恋女生,两人都在往前走,但男生总是默默跟着女生走但又不敢超过她。因此,右指针最多遍历一遍数组,左指针也最多遍历一次数组,时间复杂度不超过O(2N)。接下来,如何判断滑动窗口内是否满足题设条件,有两种选择:(1) 要么你遍历这个滑窗,通过遍历来断滑窗是否满足需要O(N), 那么总的时间就退化为O(N^2), (2) 要么你选择字典,用空间换时间,那么判断划窗是否满足条件则需要 O(1),总时间为O(N).

lc904 水果成篮(最大滑窗)

白话题意:求满足某个条件(数组值最多就两类的连续数组,例如[1,2,2,1,2])的最长数组长度

class Solution:
    def totalFruit(self, fruits: List[int]) -> int:
        # 初始化
        i, j = 0, 0
        res = 0
        classMap = defaultdict(int)
        classCnt = 0
    
        # 移动滑窗右边界 
        while j < len(fruits):
            # 判断当前是否满足条件
            if classMap[fruits[j]] == 0:
                classCnt += 1
            classMap[fruits[j]] += 1

            # 若不满足条件,移动i
            while classCnt > 2:
                if classMap[fruits[i]] == 1:
                    classCnt -= 1
                classMap[fruits[i]] -= 1
                i += 1

            # 一旦满足条件,更新结果
            res = max(res, j - i + 1)
            j += 1
        return res

lc76 最小覆盖子串(最小滑窗)

class Solution:
    def minWindow(self, s: str, t: str) -> str:
        # 初始化
        i, j = 0, 0
        needMap = collections.defaultdict(int)
        needCnt = len(t)
        res = ''
    
        for char in t:
            needMap[char] += 1

        # 移动滑窗右边界
        while j < len(s):
            # 判断是否满足条件
            if s[j] in needMap:
                if needMap[s[j]] > 0:
                    needCnt -= 1
                needMap[s[j]] -= 1

            # 一旦满足条件,尽可能的压缩i,并且不断更新结果。
            while needCnt == 0:
                #print(i, j)
                if not res or j - i + 1 < len(res):
                    res = s[i:j+1]

                if s[i] in needMap:
                    if needMap[s[i]] == 0:
                        needCnt += 1
                    needMap[s[i]] += 1
                i += 1

            j += 1
        return res 

其他滑动窗口模板题:

lc.1004 最大连续1的个数 III

class Solution:
    def longestOnes(self, nums: List[int], k: int) -> int:

        res = 0
        i, j = 0, 0
        zeroCnt = 0

        while j < len(nums):

            if nums[j] == 0:
                zeroCnt += 1

            while zeroCnt > k:
                if nums[i] == 0:
                    zeroCnt -= 1
                i += 1

            res = max(res, j - i + 1)
            j += 1
        return res

是的,这不就是最大滑窗吗?秒做!

lc3. 无重复字符的最长子串

class Solution:
    def lengthOfLongestSubstring(self, s: str) -> int:
        i, j = 0, 0
        res = 0
        dic = collections.defaultdict(int)
    
        while j < len(s):
            dic[s[j]] += 1

            while len(dic) < j - i + 1:
                dic[s[i]] -= 1
                if dic[s[i]] == 0:
                    del dic[s[i]]
                i += 1

            if len(dic) == j - i + 1:
                res = max(res, j - i + 1)
            j += 1
        return res

还是最大滑窗,实际上这道题的判断条件可以进行优化,但是为了满足模板的统一,因此使用了字典的操作,这样做的好处在于代码结构和最大滑窗模板一模一样。

如果有帮助的话,点个赞吧!

个人题解网站 [https://fjljlzy.github.io/LeetCode-Cookbook-Py3.github.io/], 每天按顺序更新两道!

这是来自其他网友的题解和分析;

992. K 个不同整数的子数组(困难)

题目描述

给定一个正整数数组 A,如果 A 的某个子数组中不同整数的个数恰好为 K,则称 A 的这个连续、不一定独立的子数组为好子数组。

(例如,[1,2,3,1,2] 中有 3 个不同的整数:1,2,以及 3。)

返回 A 中好子数组的数目。

 

示例 1:

输入:A = [1,2,1,2,3], K = 2
输出:7
解释:恰好由 2 个不同整数组成的子数组:[1,2], [2,1], [1,2], [2,3], [1,2,1], [2,1,2], [1,2,1,2].
示例 2:

输入:A = [1,2,1,3,4], K = 3
输出:3
解释:恰好由 3 个不同整数组成的子数组:[1,2,1,3], [2,1,3], [1,3,4].
 

提示:

1 <= A.length <= 20000
1 <= A[i] <= A.length
1 <= K <= A.length

前置知识

  • 滑动窗口

思路

由母题 5,知:exactK = atMostK(k) - atMostK(k - 1), 因此答案便呼之欲出了。其他部分和上面的题目 904. 水果成篮​ 一样。

实际上和所有的滑动窗口题目都差不多。

最初直觉使用双指针算法遇到的问题

对于一个固定的左边界来说,满足「恰好存在 K​ 个不同整数的子区间」的右边界 不唯一,且形成区间。

示例 1:左边界固定的时候,恰好存在 222 个不同整数的子区间为 [1,2],[1,2,1],[1,2,1,2][1,2],[1,2,1],[1,2,1,2] [1,2] , [1,2,1] , [1,2,1,2] ,总数为 333。其值为下标 3−1+13 - 1 + 13 1 + 1,即区间 [1…3][1…3] [1…3] 的长度。

image.png

须要找到左边界固定的情况下,满足「恰好存在 K​ 个不同整数的子区间」最小右边界和最大右边界。对比以前我们做过的,使用双指针解决的问题的问法基本都会出现「最小」、「最大」这样的字眼。

把原问题转换成为容易求解的问题

友情提示:这里把 「恰好」 转换成为 「最多」须要一点求解「双指针(滑动窗口)」问题的经验。建立在熟练掌握这一类问题求解思路的基础上。

把「恰好」改成「最多」就可以使用双指针一前一后交替向右的方法完成,这是因为 对于每一个确定的左边界,最多包含 KKK 种不同整数的右边界是唯一确定的,并且在左边界向右移动的过程中,右边界或者在原来的地方,或者在原来地方的右边。

而「最多存在 KKK 个不同整数的子区间的个数」与「恰好存在 K​ 个不同整数的子区间的个数」的差恰好等于「最多存在 K−1K - 1K 1 个不同整数的子区间的个数」。

image.png

因为原问题就转换成为求解「最多存在 KKK 个不同整数的子区间的个数」与 「最多存在 K−1K - 1K 1 个不同整数的子区间的个数」,它们其实是一个问题。

方法:双指针(滑动窗口)

实现函数 atMostWithKDistinct(A, K)​ ,表示「最多存在 KKK 个不同整数的子区间的个数」。于是 atMostWithKDistinct(A, K) - atMostWithKDistinct(A, K - 1)​ 即为所求。

class Solution {
public:
    int GetMostDistinct(vector<int>& A, int K) {
        unordered_map<int, int> mp;
        int left = 0, right = 0, ret = 0;
        while (right < A.size()) {
            ++mp[A[right++]];
            while (mp.size() > K) {
                --mp[A[left]];
                if (mp[A[left]] == 0) mp.erase(A[left]);
                ++left;
            }
            // 如果这里改成 ret = max(ret, right - left),那么此函数就是 LeetCode 904 题的解:求长度最大的子数组(此子数组中包含不同整数个数最多为K)
            ret += right - left;
        }
        return ret;
    }

    int subarraysWithKDistinct(vector<int>& A, int K) {
        return GetMostDistinct(A, K) - GetMostDistinct(A, K - 1);
    }
};

说明res += right - left;​ 这行代码的意思:

用具体的例子理解:最多包含 3 种不同整数的子区间 [1, 3, 2, 3]​ (双指针算法是在左边界固定的前提下,让右边界走到最右边),当前可以确定 1​ 开始的满足最多包含 3 种不同整数的子区间有 [1]​、[1, 3]​、[1, 3, 2]​、[1, 3, 2, 3]​。

所有的 左边界固定前提下,根据右边界最右的下标,计算出来的子区间的个数就是整个函数要返回的值。用右边界固定的前提下,左边界最左边的下标去计算也是完全可以的。

总结

使用双指针(滑动窗口、两个变量一前一后交替向后移动)解决的问题通常都和这个问题要问的结果有关。以我们在题解中给出的 5 道经典问题为例:

  • 3. 无重复字符的最长子串:没有重复的子串,一定只会问「最长」,因为最短的没有重复字符的子串是只有一个字符的子串;
  • 76. 最小覆盖子串:求一个字符串的子串覆盖另一个字符串的长度一定是问「最小」,而不会问「最大」,因为最大一定是整个字符串;
  • 209. 长度最小的子数组:所有元素都是正整数,且子区间里所有元素的和大于等于定值 s​ 的子区间一定是问长度「最小」,而不会问「最多」,因为最多也一定是整个数组的长度;
  • 159. 至多包含两个不同字符的最长子串:最多包含两个不同字符一定是问「最长」才有意义,因为长度更长的子串可能会包含更多的字符;
  • 424. 替换后的最长重复字符:替换的次数 k​ 是定值,替换以后字符全部相等的子串也一定只会问「最长」。

练习

提示:在做这些问题的时候,一定要思考清楚为什么可以采用双指针(滑动窗口)算法解决如上的问题,为什么 左、右指针向右移动的时候可以不回头。如果不太熟悉这一类问题思路的朋友,一定要想清楚算法为什么有效,比知道这些问题可以用双指针(滑动窗口)算法解决重要得多。

思路一般是这样:固定左边界的前提下,如果较短的区间性质是什么样的,较长的区间的性质其实我们也可以推测出来。在右边界固定的前提下,我们须要将左边界右移,如此反复。这样的算法只遍历了数组两次,不用枚举所有可能的区间,把 O(N2)O(N^2) O ( N2) 的时间复杂度降到了 O(N)O(N) O ( N )

1109. 航班预订统计(中等)

题目描述


这里有  n  个航班,它们分别从 1 到 n 进行编号。

我们这儿有一份航班预订表,表中第  i  条预订记录  bookings[i] = [i, j, k]  意味着我们在从  i  到  j  的每个航班上预订了 k 个座位。

请你返回一个长度为 n 的数组  answer,按航班编号顺序返回每个航班上预订的座位数。



示例:

输入:bookings = [[1,2,10],[2,3,20],[2,5,25]], n = 5
输出:[10,55,45,25,25]



提示:

1 <= bookings.length <= 20000
1 <= bookings[i][0] <= bookings[i][1] <= n <= 20000
1 <= bookings[i][2] <= 10000

前置知识

  • 前缀和

思路

这道题的题目描述不是很清楚。我简单分析一下题目:

[i, j, k] 其实代表的是 第 i 站上来了 k 个人, 一直到 第 j 站都在飞机上,到第 j + 1 就不在飞机上了。所以第 i 站到第 j 站的每一站都会因此多 k 个人。

注意到里层的 while 循环是连续的数组全部加上一个数字,不难想到可以利用母题 0 的前缀和思路优化。

一种思路就是在 i 的位置 + k, 然后利用前缀和的技巧给 i 到 n 的元素都加上 k。但是题目需要加的是一个区间, j + 1 及其之后的元素会被多加一个 k。一个简单的技巧就是给 j + 1 的元素减去 k,这样正负就可以抵消。

  1. 拼车 是这道题的换皮题, 思路一模一样。
方法一:差分

注意到一个预订记录实际上代表了一个区间的增量。我们的任务是将这些增量叠加得到答案。因此,我们可以使用差分解决本题。

差分数组对应的概念是前缀和数组,对于数组 [1,2,2,4][1,2,2,4] [1,2,2,4],其差分数组为 [1,1,0,2][1,1,0,2] [1,1,0,2],差分数组的第 iii 个数即为原数组的第 i−1i-1i 1 个元素和第 iii 个元素的差值,也就是说我们对差分数组求前缀和即可得到原数组。

差分数组的性质是,当我们希望对原数组的某一个区间 [l,r][l,r] [l,r] 施加一个增量inc\textit{inc} inc 时,差分数组 ddd 对应的改变是:d[l]d[l] d [ l ] 增加 inc\textit{inc} inc,d[r+1]d[r+1] d [ r + 1 ] 减少 inc\textit{inc} inc。这样对于区间的修改就变为了对于两个位置的修改。并且这种修改是可以叠加的,即当我们多次对原数组的不同区间施加不同的增量,我们只要按规则修改差分数组即可。

在本题中,我们可以遍历给定的预定记录数组,每次 O(1)O(1) O ( 1 ) 地完成对差分数组的修改即可。当我们完成了差分数组的修改,只需要最后求出差分数组的前缀和即可得到目标数组。

注意本题中日期从 111 开始,因此我们需要相应的调整数组下标对应关系,对于预定记录 booking=[l,r,inc]\textit{booking}=[l,r,\textit{inc}] booking = [l,r,inc],我们需要让 d[l−1]d[l-1] d [ l 1 ] 增加 inc\textit{inc} inc,d[r]d[r] d [ r ] 减少 inc\textit{inc} inc。特别地,当 rrr 为 nnn 时,我们无需修改 d[r]d[r] d [ r ] ,因为这个位置溢出了下标范围。如果求前缀和时考虑该位置,那么该位置对应的前缀和值必定为 000。读者们可以自行思考原因,以加深对差分数组的理解。

class Solution {
public:
    vector<int> corpFlightBookings(vector<vector<int>>& bookings, int n) {
        vector<int> nums(n);
        for (auto& booking : bookings) {
            nums[booking[0] - 1] += booking[2];
            if (booking[1] < n) {
                nums[booking[1]] -= booking[2];
            }
        }
        for (int i = 1; i < n; i++) {
            nums[i] += nums[i - 1];
        }
        return nums;
    }
};

总结

这几道题都是滑动窗口和前缀和的思路。力扣类似的题目还真不少,大家只有多留心,就会发现这个套路。

前缀和的技巧以及滑动窗口的技巧都比较固定,且有模板可套。 难点就在于我怎么才能想到可以用这个技巧呢?

我这里总结了两点:

  1. 找关键字。比如题目中有连续,就应该条件反射想到滑动窗口和前缀和。比如题目求最大最小就想到动态规划和贪心等等。想到之后,就可以和题目信息对比快速排除错误的算法,找到可行解。这个思考的时间会随着你的题感增加而降低。
  2. 先写出暴力解,然后找暴力解的瓶颈, 根据瓶颈就很容易知道应该用什么数据结构和算法去优化。

最后推荐几道类似的题目, 供大家练习,一定要自己写出来才行哦。

要用啥数据结构呢?

#239. 滑动窗口最大值

力扣题目链接(opens new window)

给定一个数组 nums,有一个大小为 k 的滑动窗口从数组的最左侧移动到数组的最右侧。你只可以看到在滑动窗口内的 k 个数字。滑动窗口每次只向右移动一位。

返回滑动窗口中的最大值。

进阶:

你能在线性时间复杂度内解决此题吗?

提示:

  • 1 <= nums.length <= 10^5
  • -10^4 <= nums[i] <= 10^4
  • 1 <= k <= nums.length

#算法公开课

《代码随想录》算法视频公开课 ****(opens new window)****​ 单调队列正式登场!| LeetCode:239. 滑动窗口最大值 ****(opens new window)****​ ,相信结合视频再看本篇题解,更有助于大家对本题的理解

#思路

这是使用单调队列的经典题目。

难点是如何求一个区间里的最大值呢? (这好像是废话),暴力一下不就得了。

暴力方法,遍历一遍的过程中每次从窗口中再找到最大的数值,这样很明显是O(n × k)的算法。

有的同学可能会想用一个大顶堆(优先级队列)来存放这个窗口里的k个数字,这样就可以知道最大的最大值是多少了, 但是问题是这个窗口是移动的,而大顶堆每次只能弹出最大值,我们无法移除其他数值,这样就造成大顶堆维护的不是滑动窗口里面的数值了。所以不能用大顶堆。

此时我们需要一个队列,这个队列呢,放进去窗口里的元素,然后随着窗口的移动,队列也一进一出,每次移动之后,队列告诉我们里面的最大值是什么。

这个队列应该长这个样子:

class MyQueue {
public:
    void pop(int value) {
    }
    void push(int value) {
    }
    int front() {
        return que.front();
    }
};

每次窗口移动的时候,调用que.pop(滑动窗口中移除元素的数值),que.push(滑动窗口添加元素的数值),然后que.front()就返回我们要的最大值。

这么个队列香不香,要是有现成的这种数据结构是不是更香了!

其实在C++中,可以使用 multiset 来模拟这个过程,文末提供这个解法仅针对C++,以下讲解我们还是靠自己来实现这个单调队列。

然后再分析一下,队列里的元素一定是要排序的,而且要最大值放在出队口,要不然怎么知道最大值呢。

但如果把窗口里的元素都放进队列里,窗口移动的时候,队列需要弹出元素。

那么问题来了,已经排序之后的队列 怎么能把窗口要移除的元素(这个元素可不一定是最大值)弹出呢。

大家此时应该陷入深思…

其实队列没有必要维护窗口里的所有元素,只需要维护有可能成为窗口里最大值的元素就可以了,同时保证队列里的元素数值是由大到小的。

那么这个维护元素单调递减的队列就叫做单调队列,即单调递减或单调递增的队列。C++中没有直接支持单调队列,需要我们自己来实现一个单调队列

不要以为实现的单调队列就是 对窗口里面的数进行排序,如果排序的话,那和优先级队列又有什么区别了呢。

来看一下单调队列如何维护队列里的元素。

动画如下:

239.滑动窗口最大值

对于窗口里的元素{2, 3, 5, 1 ,4},单调队列里只维护{5, 4} 就够了,保持单调队列里单调递减,此时队列出口元素就是窗口里最大元素。

此时大家应该怀疑单调队列里维护着{5, 4} 怎么配合窗口进行滑动呢?

设计单调队列的时候,pop,和push操作要保持如下规则:

  1. pop(value):如果窗口移除的元素value等于单调队列的出口元素,那么队列弹出元素,否则不用任何操作
  2. push(value):如果push的元素value大于入口元素的数值,那么就将队列入口的元素弹出,直到push元素的数值小于等于队列入口元素的数值为止

保持如上规则,每次窗口移动的时候,只要问que.front()就可以返回当前窗口的最大值。

为了更直观的感受到单调队列的工作过程,以题目示例为例,输入: nums = [1,3,-1,-3,5,3,6,7], 和 k = 3,动画如下:

239.滑动窗口最大值-2

那么我们用什么数据结构来实现这个单调队列呢?

使用deque最为合适,在文章栈与队列:来看看栈和队列不为人知的一面 ​**(opens new window)** 中,我们就提到了常用的queue在没有指定容器的情况下,deque就是默认底层容器。

基于刚刚说过的单调队列pop和push的规则,代码不难实现,如下:

class MyQueue { //单调队列(从大到小)
public:
    deque<int> que; // 使用deque来实现单调队列
    // 每次弹出的时候,比较当前要弹出的数值是否等于队列出口元素的数值,如果相等则弹出。
    // 同时pop之前判断队列当前是否为空。
    void pop(int value) {
        if (!que.empty() && value == que.front()) {
            que.pop_front();
        }
    }
    // 如果push的数值大于入口元素的数值,那么就将队列后端的数值弹出,直到push的数值小于等于队列入口元素的数值为止。
    // 这样就保持了队列里的数值是单调从大到小的了。
    void push(int value) {
        while (!que.empty() && value > que.back()) {
            que.pop_back();
        }
        que.push_back(value);

    }
    // 查询当前队列里的最大值 直接返回队列前端也就是front就可以了。
    int front() {
        return que.front();
    }
};

这样我们就用deque实现了一个单调队列,接下来解决滑动窗口最大值的问题就很简单了,直接看代码吧。

C++代码如下:

class Solution {
private:
    class MyQueue { //单调队列(从大到小)
    public:
        deque<int> que; // 使用deque来实现单调队列
        // 每次弹出的时候,比较当前要弹出的数值是否等于队列出口元素的数值,如果相等则弹出。
        // 同时pop之前判断队列当前是否为空。
        void pop(int value) {
            if (!que.empty() && value == que.front()) {
                que.pop_front();
            }
        }
        // 如果push的数值大于入口元素的数值,那么就将队列后端的数值弹出,直到push的数值小于等于队列入口元素的数值为止。
        // 这样就保持了队列里的数值是单调从大到小的了。
        void push(int value) {
            while (!que.empty() && value > que.back()) {
                que.pop_back();
            }
            que.push_back(value);

        }
        // 查询当前队列里的最大值 直接返回队列前端也就是front就可以了。
        int front() {
            return que.front();
        }
    };
public:
    vector<int> maxSlidingWindow(vector<int>& nums, int k) {
        MyQueue que;
        vector<int> result;
        for (int i = 0; i < k; i++) { // 先将前k的元素放进队列
            que.push(nums[i]);
        }
        result.push_back(que.front()); // result 记录前k的元素的最大值
        for (int i = k; i < nums.size(); i++) {
            que.pop(nums[i - k]); // 滑动窗口移除最前面元素
            que.push(nums[i]); // 滑动窗口前加入最后面的元素
            result.push_back(que.front()); // 记录对应的最大值
        }
        return result;
    }
};
  • 时间复杂度: O(n)
  • 空间复杂度: O(k)

再来看一下时间复杂度,使用单调队列的时间复杂度是 O(n)。

有的同学可能想了,在队列中 push元素的过程中,还有pop操作呢,感觉不是纯粹的O(n)。

其实,大家可以自己观察一下单调队列的实现,nums 中的每个元素最多也就被 push_back 和 pop_back 各一次,没有任何多余操作,所以整体的复杂度还是 O(n)。

空间复杂度因为我们定义一个辅助队列,所以是O(k)。

#扩展

大家貌似对单调队列 都有一些疑惑,首先要明确的是,题解中单调队列里的pop和push接口,仅适用于本题哈。单调队列不是一成不变的,而是不同场景不同写法,总之要保证队列里单调递减或递增的原则,所以叫做单调队列。 不要以为本题中的单调队列实现就是固定的写法哈。

大家貌似对deque也有一些疑惑,C++中deque是stack和queue默认的底层实现容器(这个我们之前已经讲过啦),deque是可以两边扩展的,而且deque里元素并不是严格的连续分布的。

前K个大数问题,老生常谈,不得不谈

#347.前 K 个高频元素

力扣题目链接(opens new window)

给定一个非空的整数数组,返回其中出现频率前 k 高的元素。

示例 1:

  • 输入: nums = [1,1,1,2,2,3], k = 2
  • 输出: [1,2]

示例 2:

  • 输入: nums = [1], k = 1
  • 输出: [1]

提示:

  • 你可以假设给定的 k 总是合理的,且 1 ≤ k ≤ 数组中不相同的元素的个数。
  • 你的算法的时间复杂度必须优于 O ( n log ⁡ n ) O(n \log n) O(nlogn) , n 是数组的大小。
  • 题目数据保证答案唯一,换句话说,数组中前 k 个高频元素的集合是唯一的。
  • 你可以按任意顺序返回答案。

#算法公开课

《代码随想录》算法视频公开课 ****(opens new window)****​ 优先级队列正式登场!大顶堆、小顶堆该怎么用?| LeetCode:347.前 K 个高频元素 ****(opens new window)****​ ,相信结合视频再看本篇题解,更有助于大家对本题的理解

#思路

这道题目主要涉及到如下三块内容:

  1. 要统计元素出现频率
  2. 对频率排序
  3. 找出前K个高频元素

首先统计元素出现的频率,这一类的问题可以使用map来进行统计。

然后是对频率进行排序,这里我们可以使用一种 容器适配器就是优先级队列

什么是优先级队列呢?

其实就是一个披着队列外衣的堆,因为优先级队列对外接口只是从队头取元素,从队尾添加元素,再无其他取元素的方式,看起来就是一个队列。

而且优先级队列内部元素是自动依照元素的权值排列。那么它是如何有序排列的呢?

缺省情况下priority_queue利用max-heap(大顶堆)完成对元素的排序,这个大顶堆是以vector为表现形式的complete binary tree(完全二叉树)。

什么是堆呢?

堆是一棵完全二叉树,树中每个结点的值都不小于(或不大于)其左右孩子的值。 如果父亲结点是大于等于左右孩子就是大顶堆,小于等于左右孩子就是小顶堆。

所以大家经常说的大顶堆(堆头是最大元素),小顶堆(堆头是最小元素),如果懒得自己实现的话,就直接用priority_queue(优先级队列)就可以了,底层实现都是一样的,从小到大排就是小顶堆,从大到小排就是大顶堆。

本题我们就要使用优先级队列来对部分频率进行排序。

为什么不用快排呢, 使用快排要将map转换为vector的结构,然后对整个数组进行排序, 而这种场景下,我们其实只需要维护k个有序的序列就可以了,所以使用优先级队列是最优的。

此时要思考一下,是使用小顶堆呢,还是大顶堆?

有的同学一想,题目要求前 K 个高频元素,那么果断用大顶堆啊。

那么问题来了,定义一个大小为k的大顶堆,在每次移动更新大顶堆的时候,每次弹出都把最大的元素弹出去了,那么怎么保留下来前K个高频元素呢。

而且使用大顶堆就要把所有元素都进行排序,那能不能只排序k个元素呢?

所以我们要用小顶堆,因为要统计最大前k个元素,只有小顶堆每次将最小的元素弹出,最后小顶堆里积累的才是前k个最大元素。

寻找前k个最大元素流程如图所示:(图中的频率只有三个,所以正好构成一个大小为3的小顶堆,如果频率更多一些,则用这个小顶堆进行扫描)

347.前K个高频元素

我们来看一下C++代码:

class Solution {
public:
    // 小顶堆
    class mycomparison {
    public:
        bool operator()(const pair<int, int>& lhs, const pair<int, int>& rhs) {
            return lhs.second > rhs.second;
        }
    };
    vector<int> topKFrequent(vector<int>& nums, int k) {
        // 要统计元素出现频率
        unordered_map<int, int> map; // map<nums[i],对应出现的次数>
        for (int i = 0; i < nums.size(); i++) {
            map[nums[i]]++;
        }

        // 对频率排序
        // 定义一个小顶堆,大小为k
        priority_queue<pair<int, int>, vector<pair<int, int>>, mycomparison> pri_que;

        // 用固定大小为k的小顶堆,扫面所有频率的数值
        for (unordered_map<int, int>::iterator it = map.begin(); it != map.end(); it++) {
            pri_que.push(*it);
            if (pri_que.size() > k) { // 如果堆的大小大于了K,则队列弹出,保证堆的大小一直为k
                pri_que.pop();
            }
        }

        // 找出前K个高频元素,因为小顶堆先弹出的是最小的,所以倒序来输出到数组
        vector<int> result(k);
        for (int i = k - 1; i >= 0; i--) {
            result[i] = pri_que.top().first;
            pri_que.pop();
        }
        return result;

    }
};
  • 时间复杂度: O(nlogk)
  • 空间复杂度: O(n)

#拓展

大家对这个比较运算在建堆时是如何应用的,为什么左大于右就会建立小顶堆,反而建立大顶堆比较困惑。

确实 例如我们在写快排的cmp函数的时候,return left>right​ 就是从大到小,return left<right​ 就是从小到大。

优先级队列的定义正好反过来了,可能和优先级队列的源码实现有关(我没有仔细研究),我估计是底层实现上优先队列队首指向后面,队尾指向最前面的缘故!

;