2.28-子串、矩阵

560. 和为 K 的子数组 - 力扣(LeetCode)

前置题目:请先完成前缀和模板题 303. 区域和检索 - 数组不可变

回顾一下 :定义s[0]=0, s[i+1]=nums[0]+nums[1]+⋯+nums[i]

下标从i j−1 的非空连续子数组的元素和等于 k,即\(s[j]−s[i]=k (i<j)\)
这个式子和 1. 两数之和 是类似的我们可以学到什么?

枚举 j,上式变成\(s[i]=s[j]−k\)
根据上式,计算 s[i] 的个数,等价于计算在 s[j] 左边的 s[j]−k 的个数

这可以在遍历 s[j] 的同时,用一个哈希表 cnt 统计 s[j] 的个数。那么枚举到 s[j] 时,从哈希表中就可以找到有 cnt[s[j]−k] 个 s[i],即为元素和等于 k 的子数组个数,加入答案。

nums=[1,1,−1,1,−1], k=1 为例,其前缀和 s=[0,1,2,1,2,1]

以下是将您提供的表格数据转化为Markdown格式的表格:

j s[j] s[j]−k 在 s[j] 左边的 s[j]−k 的个数 解释
0 0 −1 0
1 1 0 1 s[0]=0
2 2 1 1 s[1]=1
3 1 0 1 s[0]=0
4 2 1 2 s[1]=s[3]=1
5 1 0 1 s[0]=0

用哈希表记录前缀和的个数,就能 O(1) 算出左边有多少个 s[j]−k

总计有 0+1+1+1+2+1=6 个和为 k=1 的子数组。

答疑
问:为什么要把 s[0]=0 也加到哈希表中?

答:举个最简单的例子,nums=[1], k=1。如果不把 s[0]=0 加到哈希表中,按照我们的算法,没法算出这里有 1 个符合要求的子数组。也可以这样理解,要想把任意子数组都表示成两个前缀和的差,必须添加 s[0]=0,否则当子数组是前缀时,没法减去一个数。

问:为什么代码中要先更新 ans,再更新 cnt?这两行代码能否交换一下?

答:不行,这会在 k=0 的时候算错。例如 nums=[2], k=0,正确答案应该是 0,但如果先把 cnt[2] 加一,再把 cnt[2] 加到 ans 中,最后返回的 ans 就不是 0 了。

问:为什么这题不适合用滑动窗口做?

答:滑动窗口需要满足单调性,当右端点元素进入窗口时,窗口元素和是不能减少的。本题nums 包含负数,当负数进入窗口时,窗口左端点反而要向左移动,导致算法复杂度不是线性的。

边遍历边统计(这种写法注意初始化cnt[0] = 1):

class Solution {
  public:
      int subarraySum(vector<int>& nums, int k) {
          int ans = 0 , s = 0;
          unordered_map<int , int> cnt;
          cnt[0] = 1;//添加s[0] = 0,即0出现一次
          for (int x : nums) {
             s += x;
             //s[i] = s[j] - k,计算左边出现s[i]的次数
             //这里一定是先统计cnt[s - k],再将cnt[s]自增,k可能为0
             ans += cnt.contains(s - k) ? cnt[s - k] : 0;
             cnt[s] ++;
          }
          return ans;
      }
  };

复杂度分析

  • 时间复杂度:O(n),其中 n 为 nums 的长度。
  • 空间复杂度:O(m),其中 m 为 s 中的不同元素个数。如果像 Java 那样设置了哈希表的容量,则空间复杂度为 O(n)。

239. 滑动窗口最大值 - 力扣(LeetCode)

单调队列套路

  1. 入(元素进入队尾,同时维护队列单调性
  2. 出(元素离开队首
  3. 记录/维护答案(根据队首

image-20250228161701699

体现在代码里就是:走到i=2的时候nums[2] = 4,此时deque={2 , 1},通过while循环不断地将{1 、 2}出列,将下标i=2入列。

  while (!q.empty() && nums[q.back()] <= nums[i]) {
                q.pop_back(); // 维护 q 的单调性
            }
            q.push_back(i); // 入队

此时i=2 >= k-1=2,就把q.front()(当前窗口最大值)记录到ans里。

 // 2. 出
            if (i - q.front() >= k) { // 队首已经离开窗口了
                q.pop_front();
            }
            // 3. 记录答案
            if (i >= k - 1) {
                // 由于队首到队尾单调递减,所以窗口最大值就是队首
                ans.push_back(nums[q.front()]);
            }

image-20250228161743273

image-20250228162157575

问:为什么单调队列里保存的是下标而不是num[i]呢?

答:方便判断队列中的元素有没有离开窗口.

class Solution {
public:
    vector<int> maxSlidingWindow(vector<int>& nums, int k) {
        vector<int> ans;
        deque<int> q; // 双端队列
        for (int i = 0; i < nums.size(); i++) {
            // 1. 入
            while (!q.empty() && nums[q.back()] <= nums[i]) {
                q.pop_back(); // 维护 q 的单调性
            }
            q.push_back(i); // 入队
            // 2. 出
            if (i - q.front() >= k) { // 队首已经离开窗口了
                q.pop_front();
            }
            // 3. 记录答案
            if (i >= k - 1) {
                // 由于队首到队尾单调递减,所以窗口最大值就是队首
                ans.push_back(nums[q.front()]);
            }
        }
        return ans;
    }
};

76. 最小覆盖子串 - 力扣(LeetCode)

滑动窗口

  1. 什么是「涵盖」

    看示例 1,s 的子串 BANC 中每个字母的出现次数,都大于等于 t=ABC 中每个字母的出现次数,这就叫涵盖。

  2. 滑动窗口怎么滑
    原理和 209 题一样,按照视频中的做法,我们枚举 s 子串的右端点 right(子串最后一个字母的下标),如果子串涵盖 t,就不断移动左端点 left 直到不涵盖为止。在移动过程中更新最短子串的左右端点。

具体来说:

  1. 初始化 ansLeft=−1, ansRight=m,用来记录最短子串的左右端点,其中 m 是 s 的长度。

  2. 用一个哈希表(或者数组)cntT 统计 t 中每个字母的出现次数。

  3. 初始化 left=0,以及一个空哈希表(或者数组)cntS,用来统计 s 子串中每个字母的出现次数。

  4. 遍历 s,设当前枚举的子串右端点为 right,把s[right]的出现次数加一。

  5. 遍历cntS中的每个字母及其出现次数,如果出现次数都大于等于cntT中的字母出现次数:

    a.如果 right−left<ansRight−ansLeft,说明我们找到了更短的子串,更新 ansLeft=left, ansRight=right

    b.把 s[left] 的出现次数减一。

    c.左端点右移,即 left 加一。

    d.重复上述三步,直到 cntS 有字母的出现次数小于 cntT 中该字母的出现次数为止。

  6. 最后,如果 ansLeft<0,说明没有找到符合要求的子串,返回空字符串,否则返回下标 ansLeft 到下标 ansRight 之间的子串。

由于本题大写字母和小写字母都有,为了方便,代码实现时可以直接创建大小为 128 的数组,保证所有 ASCII 字符都可以统计。

法一:

class Solution {
    bool is_covered(int cnt_s[], int cnt_t[]) {
        for (int i = 'A'; i <= 'Z'; i++) {
            if (cnt_s[i] < cnt_t[i]) {
                return false;
            }
        }
        for (int i = 'a'; i <= 'z'; i++) {
            if (cnt_s[i] < cnt_t[i]) {
                return false;
            }
        }
        return true;
    }

public:
    string minWindow(string s, string t) {
        int m = s.length();
        int ans_left = -1, ans_right = m;
        int cnt_s[128]{}; // s 子串字母的出现次数
        int cnt_t[128]{}; // t 中字母的出现次数
        for (char c : t) {
            cnt_t[c]++;
        }

        int left = 0;
        for (int right = 0; right < m; right++) { // 移动子串右端点
            cnt_s[s[right]]++; // 右端点字母移入子串
            while (is_covered(cnt_s, cnt_t)) { // 涵盖
                if (right - left < ans_right - ans_left) { // 找到更短的子串
                    ans_left = left; // 记录此时的左右端点
                    ans_right = right;
                }
                cnt_s[s[left]]--; // 左端点字母移出子串
                left++;
            }
        }
        return ans_left < 0 ? "" : s.substr(ans_left, ans_right - ans_left + 1);
    }
};

复杂度分析

  • 时间复杂度:\(O(∣Σ∣m+n)\),其中 m 为 s 的长度,n 为 t 的长度,∣Σ∣ 为字符集合的大小,本题字符均为英文字母,所以 ∣Σ∣=52

    注意 left 只会增加不会减少,left 每增加一次,我们就花费 O(∣Σ∣) 的时间。因为 left 至多增加 m 次,所以二重循环的时间复杂度为 O(∣Σ∣m),再算上统计 t 字母出现次数的时间 O(n),总的时间复杂度为 \(O(∣Σ∣m+n)\)​。

  • 空间复杂度:O(∣Σ∣)。如果创建了大小为 128 的数组,则 ∣Σ∣=128。

法二:

上面的代码每次都要花费 O(∣Σ∣) 的时间去判断是否涵盖,能不能优化到 O(1) 呢?

可以。用一个变量 less 维护目前子串中有 less 种字母的出现次数小于 t 中字母的出现次数。

具体来说(注意下面算法中的 less 变量):

  1. 初始化 ansLeft=−1, ansRight=m,用来记录最短子串的左右端点,其中 m 是 s 的长度。

  2. 用一个哈希表(或者数组)cntT 统计 t 中每个字母的出现次数。

  3. 初始化 left=0,以及一个空哈希表(或者数组)cntS,用来统计 s 子串中每个字母的出现次数。

  4. 初始化 less 为 t 中的不同字母个数。

  5. 遍历 s,设当前枚举的子串右端点为 right,把字母 c=s[right] 的出现次数加一。加一后,如果 cntS[c]=cntT[c],说明 c 的出现次数满足要求,把 less 减一。

  6. 如果 less=0,说明 cntS 中的每个字母及其出现次数都大于等于 cntT 中的字母出现次数,那么:

    a.如果 right−left<ansRight−ansLeft,说明我们找到了更短的子串,更新 ansLeft=left, ansRight=right

    b.把字母 x=s[left] 的出现次数减一。减一前,如果 cntS[x]=cntT[x],说明 x 的出现次数不满足要求,把 less 加一。

    c.左端点右移,即 left 加一。

    d.重复上述三步,直到 less>0,即 cntS 有字母的出现次数小于 cntT 中该字母的出现次数为止。

  7. 最后,如果 ansLeft<0,说明没有找到符合要求的子串,返回空字符串,否则返回下标 ansLeft 到下标 ansRight 之间的子串。

代码实现时,可以把 cntS 和 cntT 合并成一个 cnt,定义cnt[x]=cntT[x]−cntS[x],如果 cnt[x]=0,就意味着窗口内字母 x 的出现次数和 t 的一样多。

class Solution {
public:
    string minWindow(string s, string t) {
        int m = s.length();
        int ans_left = -1, ans_right = m;
        int cnt[128]{};
        int less = 0;
        for (char c : t) {
            if (cnt[c] == 0) {
                less++; // 有 less 种字母的出现次数 < t 中的字母出现次数
            }
            cnt[c]++;
        }

        int left = 0;
        for (int right = 0; right < m; right++) { // 移动子串右端点
            char c = s[right]; // 右端点字母
            cnt[c]--; // 右端点字母移入子串
            if (cnt[c] == 0) {
                // 原来窗口内 c 的出现次数比 t 的少,现在一样多
                less--;
            }
            while (less == 0) { // 涵盖:所有字母的出现次数都是 >=
                if (right - left < ans_right - ans_left) { // 找到更短的子串
                    ans_left = left; // 记录此时的左右端点
                    ans_right = right;
                }
                char x = s[left]; // 左端点字母
                if (cnt[x] == 0) {
                    // x 移出窗口之前,检查出现次数,
                    // 如果窗口内 x 的出现次数和 t 一样,
                    // 那么 x 移出窗口后,窗口内 x 的出现次数比 t 的少
                    less++;
                }
                cnt[x]++; // 左端点字母移出子串
                left++;
            }
        }
        return ans_left < 0 ? "" : s.substr(ans_left, ans_right - ans_left + 1);
    }
};

73. 矩阵置零 - 力扣(LeetCode)

使用两个标记变量

我们可以用矩阵的第一行和第一列代替方法一中的两个标记数组,以达到 O(1) 的额外空间。但这样会导致原数组的第一行和第一列被修改,无法记录它们是否原本包含 0。

因此我们需要额外使用两个标记变量分别记录第一行和第一列是否原本包含 0。

在实际代码中:

  1. 我们首先预处理出两个标记变量
  2. 接着使用其他行与列去处理第一行与第一列
  3. 然后反过来使用第一行与第一列去更新其他行与列
  4. 最后使用两个标记变量更新第一行与第一列即可
class Solution {
  public:
      void setZeroes(vector<vector<int>>& matrix) {
          int m = matrix.size();
          int n = matrix[0].size();
          int flag_col0 = false , flag_row0 = false;
        //预处理出两个标记变量
          for (int i = 0; i < m; i++) {
             if(!matrix[i][0]) flag_col0 = true;
             
          }
          for (int j = 0; j < n; j++) {
             if(!matrix[0][j]) flag_row0 = true;
          }
				//使用其他行与列去处理第一行与第一列
          for (int i = 1; i < m; i++) {
             for (int j = 1; j < n; j++) {
                if(!matrix[i][j])  matrix[i][0] = matrix[0][j] = 0;
             }
          }
				//反过来使用第一行与第一列去更新其他行与列
          for (int i = 1; i < m; i++) {
             for (int j = 1; j < n; j++) {
                if(!matrix[i][0] || !matrix[0][j])  matrix[i][j] = 0;
             }
          }
				//最后使用两个标记变量更新第一行与第一列即可
          if(flag_col0){
            for (int i = 0; i < m; i++) {
               matrix[i][0] = 0;
            }
          }
          if(flag_row0){
            for (int j = 0; j < n; j++) {
               matrix[0][j] = 0;
            }
          }
      }
  };
posted @ 2025-02-28 23:13  七龙猪  阅读(2)  评论(0)    收藏  举报
-->