leetcode 周赛 525 - 排名 43

作者: | 更新日期:

贪心与频次阶梯枚举

本文首发于公众号:天空的代码世界,微信号:tiankonguse

零、背景

这次比赛第三题和第四题是同一道题,只有数据范围不同。
第三题的代码在第四题上超时了,加了一个关键剪枝后通过,最终排名第 43 名。
另外两题分别是模拟和贪心,都比较简单。

本场题型概览如下。

A 题:模拟
B 题:贪心
C 题:枚举 + 最小堆
D 题:枚举 + 最小堆 + 剪枝

一、二进制位得分

题意:给你两个整数 a 和 b。
设 s1 和 s2 分别为 a 和 b 的二进制表示中置位(值为 1 的位)的数量。
这两个整数的得分定义如下:如果 s1 >= s2,得分为 s1 * s2;否则得分为 s2 / s1,其中除法采用整数除法。
返回表示 a 和 b 得分的整数。

数据范围:
1 <= a, b <= 100

思路:模拟

直接用 __builtin_popcount 计算两个数的二进制置位数,然后按题意比较大小,选择对应的公式计算即可。

复杂度:O(1)

class Solution {
 public:
  int calculateScore(int a, int b) {
    int s1 = __builtin_popcount(a);
    int s2 = __builtin_popcount(b);
    if (s1 >= s2) {
      return s1 * s2;
    } else {
      return s2 / s1;
    }
  }
};

二、至多 K 次数字重排后的最大和

题意:给你一个长度为 n 的整数数组 nums 和一个整数 k。
在一次操作中,你可以选择 nums 中的任意一个元素,并以任意顺序重新排列其十进制数字,组成一个新的整数。
返回在执行至多 k 次操作后,数组元素之和的最大值。

数据范围:
1 <= n == nums.length <= 10^5
1 <= nums[i] <= 10^9
1 <= k <= n

思路:贪心

对一个数字重新排列十进制位,要使它最大,显然是把所有数字从大到小排序(如 123 -> 321、102 -> 210)。
一次操作可以让该元素增加一个确定的值:增加量 = 最大重排值 - 原值。
由于每个元素重排一次后即达到最大值,再次重排没有收益,所以 k 次操作等价于选择「增加量」最大的 k 个元素各操作一次。
先算出每个元素的增加量,按增加量从大到小排序,答案等于原始数组和加上前 k 个增加量。

复杂度:O(n log n)

typedef long long ll;

class Solution {
  ll CalDis(const ll v) {
    string s = to_string(v);
    sort(s.begin(), s.end(), greater<char>());
    return stoll(s) - v;
  }

 public:
  long long maximizeSum(vector<int>& nums, int k) {
    int n = nums.size();
    vector<pair<ll, ll>> A(n);
    ll sum = 0;
    for (int i = 0; i < n; i++) {
      ll v = nums[i];
      ll dis = CalDis(v);
      A[i] = {dis, v};
      sum += v;
    }
    sort(A.begin(), A.end(), greater<pair<ll, ll>>());
    ll ans = sum;
    for (int i = 0; i < k; i++) {
      ans += A[i].first;
    }
    return ans;
  }
};

三、统计频次阶梯子数组的数目 I

题意:给你一个整数数组 nums。
考虑一个子数组中的所有不同值,并按它们首次出现的顺序排列。
如果其中有 k 个不同值,且第一个不同值恰好出现一次,第二个不同值恰好出现两次,以此类推,第 k 个不同值恰好出现 k 次,则称该子数组为频次阶梯。
返回 nums 中频次阶梯子数组的数量。

数据范围:
1 <= nums.length <= 1500
1 <= nums[i] <= 10^5

思路:枚举起点 + 最小堆

关键性质

先推导一个关键性质:含 k 个不同值的频次阶梯子数组,长度恰好为 1 + 2 + ... + k = (1+k)*k/2。
所以从起点 i 出发,k 最多只有 O(sqrt(n)) 个候选,总枚举量是 O(n * sqrt(n))。

首次出现的不同值

剩下的问题是如何快速按首次出现顺序取出不同值。

经典转化:把「找下一个不同值」变成「找下一个值为 1 的位置」。
对每个值,把它第一次出现的位置标记为 1,之后出现的位置标记为 0,得到一个 0/1 数组。
显然,这个 0/1 数组里第 t 个 1,就是按首次出现顺序的第 t 个不同值。

于是「按首次出现顺序枚举不同值」等价于「依次取出 1 的位置」,有两种实现方式。
用线段树可以在 0/1 数组上二分查找下一个 1,复杂度 O(log n * log n)。
更优的做法是把所有 1 的位置放进最小堆,堆顶就是最近的 1,弹出后下一个堆顶就是下一个不同值,复杂度 O(log n)。

上面的 0/1 标记是从整个数组的视角定义的「首次出现」,而频次阶梯要统计的是以每个位置为起点的后缀子数组,起点一变,哪些位置算「首次出现」也会跟着变。
如果每个起点都重新构建一次数据结构,代价太高,所以采用从左到右扫描、边枚举边更新的方式。

更新下个位置

每枚举完一个起点 i,位置 i 就不再是它对应值 vi 的第一次出现了,需要把 vi 的 1 标记从位置 i 转移到 vi 下一次出现的位置 p。
这样当起点推进到 i+1 时,线段树或堆里的 1 标记恰好仍代表「以 i+1 为起点的首次出现位置」。

要完成这个转移,必须能快速找到某个值的下一次出现位置,所以每个值都要维护一个有序的出现位置列表,从而可以快速找到下个位置。

转移本身在两种数据结构上都是 O(log n)。
线段树把位置 i 置 0、位置 p 置 1。
最小堆则因为位置 i 恰好位于堆顶,直接弹出 i 再压入 p 即可。

恰好出现 k 次

拿到第 k 个不同值后,还要确认它在起点后是否出现 k 次,这同样用每个值的位置列表解决。
若位置列表按升序存储,二分找到该值当前位置在列表中的下标,再判断「下标 + k」是否超过列表长度。
超过说明总数不足 k 次,当前起点后面更大的 k 也不可能满足,提前结束。
没超过则取出第 k 个位置,子数组的右边界 R 至少需要覆盖它。

另一个思路是逆序存储位置列表,并随着起点右移及时清理小于起点的位置。
这样位置列表最后一个就是首次出现的位置,从尾部数第 k 个元素就是该值的第 k 次出现,复杂度降到了 O(1)。

合法性判断

最后判定合法性:令右边界 R 为前 k 个不同值各自第 k 次出现位置的最大值。
若 R - L + 1 == (1+k)*k/2,说明子数组长度恰好等于需求频次之和,而每个值的 k 次出现又都被 R 覆盖,所以不多不少,恰好构成频次阶梯。

为什么成立?

先看必要性:若子数组是频次阶梯,第 j 个出现值恰好出现 j 次,长度必然是 1 + 2 + ... + k = (1+k)*k/2,所以用长度相等来判不会漏掉合法答案。

再看充分性:把前 k 个不同值按首次出现顺序记为 v1, v2, ..., vk。
R 覆盖了每个 vj 的第 j 次出现位置,所以每个 vj 在 [L, R] 内至少出现 j 次,前 k 个值的出现次数之和至少是 (1+k)*k/2。
若长度恰好等于 (1+k)*k/2,而整个子数组内所有值的出现次数总和(即长度)也正是这个值,两者只能严格相等:子数组内不能出现其他值,每个 vj 也不能超过 j 次。

结合每个 vj 至少出现 j 次,恰好都是 j 次,子数组不多不少构成频次阶梯。

复杂度:O(n * sqrt(n) * log(n))

typedef long long ll;
template <class T>
using min_queue = priority_queue<T, vector<T>, greater<T>>;

class Solution {
 public:
  int countStaircase(vector<int>& nums) {
    const int n = nums.size();
    unordered_map<ll, vector<ll>> valPos;
    min_queue<pair<ll, ll>> minHeap;
    for (int i = 0; i < n; i++) {
      valPos[nums[i]].push_back(i);
    }
    for (auto& [val, ps] : valPos) {
      std::reverse(ps.begin(), ps.end());
      minHeap.push({ps.back(), val});
    }
    ll ans = 0;
    vector<pair<ll, ll>> sta;
    sta.reserve(n);
    for (int i = 0; i < n; i++) {
      const ll v = nums[i];
      ll L = i, R = i;
      for (ll k = 1;; k++) {
        const ll expLen = (1 + k) * k / 2;
        if (i + expLen > n) {
          break;
        }
        if (minHeap.empty()) {
          break;
        }
        sta.push_back(minHeap.top());
        auto [pos, val] = minHeap.top();
        minHeap.pop();
        ll valCnt = valPos[val].size();
        if (valCnt < k) {
          break;
        }
        ll valLastPos = valPos[val][valCnt - k];
        R = max(R, valLastPos);
        if (expLen == (R - L + 1)) {
          ans++;
        }
      }
      while (!sta.empty()) {
        minHeap.push(sta.back());
        sta.pop_back();
      }
      minHeap.pop();
      valPos[v].pop_back();
      if (!valPos[v].empty()) {
        minHeap.push({valPos[v].back(), v});
      }
    }
    return ans;
  }
};

四、统计频次阶梯子数组的数目 II

题意:同第三题。

数据范围:
1 <= nums.length <= 5 * 10^4
1 <= nums[i] <= 10^5

思路:枚举起点 + 最小堆 + 右边界上限剪枝

题目与第三题完全相同,只是数据范围更大,于是直接提交第三题的代码,结果在第 996 / 999 个测试用例上超时了。

为什么超时?

第三题的枚举框架是:对每个起点 i,从小到大枚举 k,终止条件只有两个:长度超出数组、或第 k 个不同值不足 k 次。

但还少了一个终止条件的判断:当枚举到第 k 个不同值时,如果之前枚举到的某个不同值,出现次数已经超过自身的序号,那么后续更大的 k 也都是没有答案的,应该提前终止。

原因在于第 j 个不同值必须恰好出现 j 次。 一旦子数组右边界覆盖了它的第 j+1 次出现位置,它就出现至少 j+1 次,子数组必然不合法。
而右边界 R 随 k 增大只会变大,所以这种情况一旦发生,更大的 k 也不可能合法。

第三题代码没有这个判断,于是即使某个值早已超额、后续不可能再有答案,循环仍会继续枚举更大的 k,白白浪费轮次。
最坏数据下,大多数起点都会把 k 枚举到接近 O(sqrt(n)) 轮,每轮还带 O(log n) 的堆操作,总复杂度 O(n * sqrt(n) * log(n))。
在 n = 5 * 10^4 时约 1.8 * 10^8 次操作,直接超时。

判断是否超过个数限制

先看朴素实现:每枚举到一个新的第 k 个不同值,就检查前面所有已枚举值是否「超额且被右边界覆盖」。
每轮要检查 k 个值,复杂度 O(k);把所有轮次加起来,每个起点是 O(sqrt(n) * sqrt(n)) = O(n),总复杂度退化到 O(n^2),不可行。

优化的关键:终止条件「右边界覆盖了某个禁区」等价于「右边界大于等于所有禁区位置的最小值」。
一旦 R 超过最小的禁区就必须终止。
所以只需维护一个值:所有禁区位置的最小值 limitPos。

复杂度不变,仍是 O(n * sqrt(n) * log(n))

可以证明,很多子数组都会被这个剪枝减掉。
所以实际枚举的子数组数量远小于 O(n * sqrt(n))。

typedef long long ll;
template <class T>
using min_queue = priority_queue<T, vector<T>, greater<T>>;

min_queue<pair<ll, ll>> minHeap;
vector<pair<ll, ll>> sta;

class Solution {
 public:
  int countStaircase(vector<int>& nums) {
    const int n = nums.size();
    unordered_map<ll, vector<ll>> valPos;
    while (!minHeap.empty()) {
      minHeap.pop();
    }
    for (int i = 0; i < n; i++) {
      valPos[nums[i]].push_back(i);
    }
    for (auto& [val, ps] : valPos) {
      std::reverse(ps.begin(), ps.end());
      minHeap.push({ps.back(), val});
    }
    ll ans = 0;
    sta.reserve(n);
    sta.clear();
    for (int i = 0; i < n; i++) {
      const ll v = nums[i];
      ll L = i, R = i;
      ll limitPos = n;
      for (ll k = 1;; k++) {
        const ll expLen = (1 + k) * k / 2;
        if (i + expLen > n) {
          break;
        }
        if (minHeap.empty()) {
          break;
        }
        sta.push_back(minHeap.top());
        auto [pos, val] = minHeap.top();
        minHeap.pop();
        ll valCnt = valPos[val].size();
        if (valCnt < k) {
          break;
        }
        ll valLastPos = valPos[val][valCnt - k];
        R = max(R, valLastPos);
        if (k < valCnt) {
          limitPos = min(limitPos, valPos[val][valCnt - k - 1]);
        }
        if (expLen == (R - L + 1)) {
          ans++;
        }
        if (R >= limitPos) {
          break;
        }
      }
      while (!sta.empty()) {
        minHeap.push(sta.back());
        sta.pop_back();
      }
      minHeap.pop();
      valPos[v].pop_back();
      if (!valPos[v].empty()) {
        minHeap.push({valPos[v].back(), v});
      }
    }
    return ans;
  }
};

五、最后

这周赛前两题比较简单。

A 题模拟,计算两个数的二进制置位数后按题意比较即可。
B 题贪心,每个元素重排一次的收益固定,选择收益最大的 k 个元素即可。
C 题与 D 题是同一道题,核心是按首次出现顺序枚举不同值:位置列表逆序存储配合最小堆,利用长度恒等式 (1+k)*k/2 判断是否合法。
D 题数据范围更大,在 C 题基础上加了一个「右边界上限」剪枝后通过。

《完》

-EOF-

本文公众号:天空的代码世界
个人微信号:tiankonguse
公众号 ID:tiankonguse-code

本文首发于公众号:天空的代码世界,微信号:tiankonguse
如果你想留言,可以在微信里面关注公众号进行留言。

关注公众号,接收最新消息

tiankonguse + ≡
穿越