5.7

1094. 拼车 - 力扣(LeetCode)

举例

考虑数组 a=[1,3,3,5,8],对其中的相邻元素两两作差(右边减左边),得到数组 [2,0,2,3]。然后在开头补上 a[0],得到差分数组d=[1,2,0,2,3]

这有什么用呢?如果从左到右累加 d 中的元素,我们就「还原」回了 a 数组 [1,3,3,5,8]。这类似求导与积分的概念。

这又有什么用呢?现在把连续子数组 a[1],a[2],a[3] 都加上 10,得到 a′=[1,13,13,15,8]。再次两两作差,并在开头补上 a′[0],得到差分数组d′=[1,12,0,2,−7]

对比 d 和 d′,可以发现只有 d[1] 和 d[4] 变化了,这意味着对 a 中连续子数组的操作,可以转变成对差分数组 d 中两个数的操作。

定义和性质

对于数组 a,定义其差分数组(difference array)为

\(d[i]=left{begin{array}{ll} a[0], & i=0 a[i]-a[i-1] & i geq 1 end{array}right.\)

性质 1:从左到右累加 d 中的元素,可以得到数组 a。

性质 2:如下两个操作是等价的。

  • 把 a 的子数组 a[i],a[i+1],…,a[j] 都加上 x。
  • 把 d[i] 增加 x,把 d[j+1] 减少 x。

利用性质 2,我们只需要 O(1) 的时间就可以完成对 a 的子数组的操作。最后利用性质 1 从差分数组复原出数组 a。

注:也可以这样理解,d[i] 表示把下标 ≥i 的数都加上 d[i]。

本题思路

对于本题,设 a[i] 表示车行驶到位置 i 时车上的人数。我们需要判断是否所有 a[i] 都不超过 capacity。

trips[i] 相当于把 a 中下标从 from_i 到 to_i−1 的数都增加 numPassengers_i。这正好可以用上面讲的差分数组解决。

例如示例 1 对应的 d 数组,d[1]=2, d[5]=−2, d[3]=3, d[7]=−3,即

d = [0,2,0,3,0,−2,0,−3,…]

从左到右累加,得到

a=[0,2,2,5,5,3,3,0,…](本题是to的点立马就下人了,所以是d[to] -= n)

capacity=4,由于 max(a)=5>4,所以返回 false

实现方法

有两种写法:

  1. 第一种写法是,创建一个长为 1001 的差分数组,这可以保证 d 数组不会下标越界。(1 <= trips.length <= 1000)
  2. 第二种写法是,用平衡树(C++ 中的 map,Java 中的 TreeMap)代替差分数组,因为我们只需要考虑在 fromi 和 toi 这些位置上的乘客数,其余位置的乘客是保持不变的,无需考虑。平衡树可以保证我们是从小到大遍历这些位置的。当然,如果你不想用平衡树的话,也可以用哈希表,把哈希表的 key 取出来排序,就可以从小到大遍历这些位置了。

代码实现时,其实无需创建数组 a,只需要用一个变量 s 累加差分值,如果在累加过程中发现 s > capacity 就返回 false。如果没有出现这种情况,就返回 true

第一种写法

class Solution {
public:
    bool carPooling(vector<vector<int>> &trips, int capacity) {
        int d[1001]{};
        for (auto &t : trips) {
            int num = t[0], from = t[1], to = t[2];
            d[from] += num;
            d[to] -= num;
        }
        int s = 0;
        for (int v : d) {
            s += v;
            if (s > capacity) {
                return false;
            }
        }
        return true;
    }
};

复杂度分析

  • 时间复杂度:O(n+U),其中 n 为 trips 的长度,U=max(toi)。
  • 空间复杂度:O(U)。

第二种写法

class Solution {
public:
    bool carPooling(vector<vector<int>> &trips, int capacity) {
        map<int, int> d;
        for (auto &t : trips) {
            int num = t[0], from = t[1], to = t[2];
            d[from] += num;
            d[to] -= num;
        }
        int s = 0;
        for (auto [_, v] : d) {
            s += v;
            if (s > capacity) {
                return false;
            }
        }
        return true;
    }
};

在C++代码中,for (auto [_, v] : d) 这一写法是 C++17引入的结构化绑定(Structured Binding) 的语法,结合了占位符 _ 的用法。下面详细解释其含义和用途:


1. 结构化绑定(Structured Binding)

结构化绑定允许将结构体、数组或 std::pair/std::tuple 的成员直接绑定到变量名,简化对复杂数据类型的访问。

  • 语法

    auto [var1, var2, ...] = expression;
    

    这里的 expression 必须是一个可以解构的类型(如 std::pair, std::tuple, 结构体等)。

  • 示例

    std::map<int, int> d = {{1, 10}, {2, 20}};
    for (auto [key, value] : d) {
        // key 是 d 的键(如1, 2)
        // value 是 d 的值(如10, 20)
    }
    

2. 占位符 _ 的作用

在结构化绑定中,_ 是一个 占位符,表示忽略对应的成员变量。它的作用是:

  • 明确告知编译器(和代码阅读者):某个成员在后续代码中不会被使用。
  • 避免未使用变量的警告(需编译器支持)。

在代码 for (auto [_, v] : d) 中:

  • _ 忽略 std::pair 的第一个成员(即 d 的键,此处是时间点)。
  • v 绑定到 std::pair 的第二个成员(即 d 的值,此处是人数变化量)。

3. 代码解析

map<int, int> d;
// ... 填充 d ...
for (auto [_, v] : d) {
    s += v;
    if (s > capacity) return false;
}
  • d 的类型std::map<int, int>,每个元素是 std::pair<const int, int>
  • 结构化绑定:将 std::pair 的键绑定到 _(忽略),值绑定到 v
  • 遍历逻辑:按时间顺序处理每个事件(上车或下车),累加乘客数量(s += v),并检查是否超载。

4. 注意事项

  • C++版本要求:结构化绑定需要 C++17 或更高标准。

  • 占位符 _ 的特殊性

    • 单个 _ 可以作为变量名,但某些编码规范或静态分析工具可能不建议这样做。

    • 若需严格避免警告,可使用 [[maybe_unused]]

      for (auto [[maybe_unused]] _, v : d) { ... }
      

5. 等价传统写法

如果不使用结构化绑定,传统写法如下:

for (auto& entry : d) {       // entry 是 std::pair<const int, int>&
    int key = entry.first;    // 键(被忽略)
    int value = entry.second; // 值
    s += value;
    // ...
}

总结

  • for (auto [_, v] : d) 是 C++17 引入的语法,用于简化对 std::map 元素的遍历。
  • _ 表示忽略键(时间点),v 表示值(人数变化量)。
  • 这种写法使代码更简洁,并明确表达意图:只关心值,不关心键。

复杂度分析

  • 时间复杂度:O(nlogn),其中 n 为 trips 的长度。
  • 空间复杂度:O(n)。

练习题(右边数字为难度分)

1109. 航班预订统计 - 力扣(LeetCode)

image-20250507143552582

注意看样例,在last=2的时刻,那10个乘客还没走,因此差分数组其实是d[last + 1] -= seat

但由于记录是从1开始的,我们把差分数组整体前移一个单位。注意下标对应。

class Solution {
  public:
      vector<int> corpFlightBookings(vector<vector<int>>& bookings, int n) {
          vector<int>res (n , 0);
          int d[20001]{};
          for(auto &b : bookings){
            int first = b[0] , last = b[1] , seat = b[2];
            d[first - 1] += seat;
            d[last] -= seat;
          }
          res[0] = d[0];
          for(int i = 1 ; i < n ; i ++){
            res[i] = res[i - 1] + d[i];
          }
          return res;
      }
  };

264. 丑数 II - 力扣(LeetCode)

方法一:最小堆

要得到从小到大的第 n 个丑数,可以使用最小堆实现。

初始时堆为空。首先将最小的丑数 1 加入堆。

每次取出堆顶元素 x,则 x 是堆中最小的丑数,由于 2x,3x,5x 也是丑数,因此将 2x,3x,5x 加入堆。

上述做法会导致堆中出现重复元素的情况。为了避免重复元素,可以使用哈希集合去重,避免相同元素多次加入堆。

在排除重复元素的情况下,第 n 次从最小堆中取出的元素即为第 n 个丑数。

class Solution {
  public:
      int nthUglyNumber(int n) {
          vector<int> factors = {2 , 3 , 5};
          unordered_set<long> uset;
          priority_queue<long , vector<long> , greater<long>> heap;
          uset.insert(1L);
          heap.push(1L);
          int ugly;
          for (int i = 0; i < n; i++) {
             long cur = heap.top();
             heap.pop();
             ugly = (int)cur;
             for(int factor : factors){
              long next = cur * factor;
              if(!uset.count(next)){
                uset.insert(next);
                heap.push(next);
              }
             }
          }
          return ugly;
      }
  };

复杂度分析

  • 时间复杂度:O(nlogn)。得到第 n 个丑数需要进行 n 次循环,每次循环都要从最小堆中取出 1 个元素以及向最小堆中加入最多 3 个元素,因此每次循环的时间复杂度是 O(log(3n)+3log(3n)) = O(logn),总时间复杂度是 O(nlogn)。

  • 空间复杂度:O(n)。空间复杂度主要取决于最小堆和哈希集合的大小,最小堆和哈希集合的大小都不会超过 3n。

方法二:动态规划

方法一使用最小堆,会预先存储较多的丑数,维护最小堆的过程也导致时间复杂度较高。可以使用动态规划的方法进行优化。

定义数组 dp,其中 dp[i] 表示第 i 个丑数,第 n 个丑数即为 dp[n]。

由于最小的丑数是 1,因此 dp[1]=1。

如何得到其余的丑数呢?定义三个指针 p2,p3,p5,表示下一个丑数是当前指针指向的丑数乘以对应的质因数。初始时,三个指针的值都是 1。

当 2≤i≤n 时,令 dp[i]=min(dp[p2]×2,dp[p3]×3,dp[p5]×5),然后分别比较 dp[i] 和 dp[p2]×2,dp[p3]×3,dp[p5]×5 是否相等,如果相等则将对应的指针加 1。

正确性证明

对于 i>1,在计算 dp[i] 时,指针 px(x∈{2,3,5}) 的含义是使得 dp[j] * x > dp[i−1] 的最小的下标 j,即当 j≥px 时 dp[j] * x > dp[i−1],当 j < px 时 dp[j] * x≤dp[i−1]。

因此,对于 i>1,在计算 dp[i] 时,dp[p2]×2,dp[p3]×3,dp[p5]×5 都大于 dp[i−1],dp[p2−1]×2,dp[p3−1]×3,dp[p5−1]×5 都小于或等于 dp[i−1]。令 dp[i]=min(dp[p2]×2,dp[p3]×3,dp[p5]×5),则 dp[i]>dp[i−1] 且 dp[i] 是大于 dp[i−1] 的最小的丑数。

在计算 dp[i] 之后,会更新三个指针 p2,p3,p5,更新之后的指针将用于计算 dp[i+1],同样满足 dp[i+1]>dp[i] 且 dp[i+1] 是大于 dp[i] 的最小的丑数。

理解:

dp[i]肯定是其前面的某个数乘以 2 (或乘以 3、乘以 5)得出来的,而dp[i] x 2 、dp[i] x 3、dp[i] x 5 这三个数,肯定也是dp 数组后面某个位置的数。

那就可以推断出来,dp这个数组上的数,每个位置肯定都要x2x3x5 一遍,其结果是放在 dp 数组后面某个位置。
那我们就可以从这个数组初始的状态,即dp[1]=1 开始,用p2p3p5 表示当前该哪个位置该乘以235 了。我们只要每次取乘以 2、3、5 后的结果中最小的值,那这个最小的值就是最新一个的dp 值,然后相应地移动一下计算出这个新dp 值的 p2(或 p3 或p5)索引,即该下一个数去乘以2(或3 或5)了。按次遍历,计算出第i个数,即为dp[i]

class Solution {
  public:
      int nthUglyNumber(int n) {
          vector<int> dp(n + 1);
          dp[1] = 1;
          int p2 = 1 , p3 = 1 , p5 = 1;
          for (int i = 2; i <= n; i++) {
             int num2 = dp[p2] * 2 , num3 = dp[p3] * 3 , num5 = dp[p5] * 5;
             dp[i] = min({num2 , num3 , num5});
             if(dp[i] == num2)  p2 ++;
             if(dp[i] == num3)  p3 ++;
             if(dp[i] == num5)  p5 ++;
          }
          return dp[n];
      }
  };

复杂度分析

  • 时间复杂度:O(n)。需要计算数组 dp 中的 n 个元素,每个元素的计算都可以在 O(1) 的时间内完成。

  • 空间复杂度:O(n)。空间复杂度主要取决于数组 dp 的大小。

相似题目

  1. 合并 K 个升序链表
  2. 计数质数
  3. 丑数
  4. 完全平方数
  5. 超级丑数
  6. 丑数 III

263. 丑数 - 力扣(LeetCode)

题目说:-2^31 <= n <= 2^31 - 1

  1. 首先,如果 n≤0,不符合题目正整数的要求,返回 false。

  2. 去掉 n 中的因子 3,也就是不断把 n 除以 3,直到 n 不是 3 的倍数为止。

  3. 去掉 n 中的因子 5,也就是不断把 n 除以 5,直到 n 不是 5 的倍数为止。

  4. 最后,n 必须只剩下因子 2,即 n = 2k。用 231. 2 的幂 中的技巧判断:

    把 n 看成二进制。如果 n 是 2 的幂,那么 n 的二进制形如 100⋯0,例如 4=100(2), 8=1000(2)。

    注意到 8−1=1000 - 1 = 0111

    所以 8&(8−1)=0 一定成立。

    一般地,如果 n 是 2 的幂,把 n 减一会使 n 的最高位变成 0,其余低位变成 1,所以 n&(n−1)=0 一定成立。

    如果 n 不是 2 的幂,那么 n 的二进制至少有两个 1。把 n 减一,并不会影响 n 最高位的 1,所以 n&(n−1)!=0 必然成立。

    综上所述:

    • 如果 n>0,可以用 n&(n−1)=0 判断,若成立,则 n 是 2 的幂;若不成立,则 n 不是 2 的幂。
    • 如果 n≤0,n 不是 2 的幂。
class Solution {
public:
    bool isUgly(int n) {
        if (n <= 0) {
            return false;
        }
        while (n % 3 == 0) {
            n /= 3;
        }
        while (n % 5 == 0) {
            n /= 5;
        }
        return (n & (n - 1)) == 0;
    }
};

复杂度分析

  • 时间复杂度:O(logn)。有 O(logn) 个因子 3 和因子 5。
  • 空间复杂度:O(1)。

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

比喻

这是一个降本增笑的故事:

  1. 如果新员工比老员工强(或者一样强),把老员工裁掉。(元素进入窗口)
  2. 如果老员工 35 岁了,也裁掉。(元素离开窗口)

裁员后,资历最老(最左边)的人就是最强的员工了。

视频讲解

请看 单调队列【基础算法精讲 27】,欢迎点赞关注~

单调队列套路

  1. 入(元素进入队尾,同时维护队列单调性
  2. 出(元素离开队首
  3. 记录/维护答案(根据队首
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;
    }
};

复杂度分析

  • 时间复杂度:O(n),其中 n 为 nums 的长度。由于每个下标至多入队出队各一次,所以二重循环的循环次数是 O(n) 的。
  • 空间复杂度:O(min(k,U)),其中 U 是 nums 中的不同元素个数(本题至多为 20001)。双端队列至多有 k 个元素,同时又没有重复元素,所以也至多有 U 个元素,所以空间复杂度为 O(min(k,U))。返回值的空间不计入。

课后作业(右边数字为难度分)

209. 长度最小的子数组 - 力扣(LeetCode)

class Solution {
  public:
      int minSubArrayLen(int target, vector<int>& nums) {
          int start = 0 , sum = 0;
          int res = INT_MAX;
          for(int end = 0 ; end < nums.size() ; end ++){
              sum += nums[end];
              while(sum >= target){
                res = min(res , end - start + 1);
                sum -= nums[start ++];//注意这里把start右移别忘了
              }
          }
          return res == INT_MAX ? 0 : res;
      }
  };

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

什么是「涵盖」

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

滑动窗口怎么滑

原理和 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 中的字母出现次数:
    1. 如果 right−left<ansRight−ansLeft,说明我们找到了更短的子串,更新 ansLeft=left, ansRight=right。
    2. 把 s[left] 的出现次数减一。
    3. 左端点右移,即 left 加一。
    4. 重复上述三步,直到 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 中的字母出现次数,那么:
    1. 如果 right−left<ansRight−ansLeft,说明我们找到了更短的子串,更新 ansLeft=left, ansRight=right。
    2. 把字母 x=s[left] 的出现次数减一。减一前,如果 cntS[x]=cntT[x],说明 x 的出现次数不满足要求,把 less 加一。
    3. 左端点右移,即 left 加一。
    4. 重复上述三步,直到 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);
    }
};

复杂度分析

  • 时间复杂度:O(m+n) 或 O(m+n+∣Σ∣),其中 m 为 s 的长度,n 为 t 的长度,∣Σ∣=128。注意 left 只会增加不会减少,二重循环的时间复杂度为 O(m)。使用哈希表写法的时间复杂度为 O(m+n),数组写法的时间复杂度为 O(m+n+∣Σ∣)。
  • 空间复杂度:O(∣Σ∣)。无论 m 和 n 有多大,额外空间都不会超过 O(∣Σ∣)。
posted @ 2025-05-07 16:21  七龙猪  阅读(2)  评论(0)    收藏  举报
-->