leetcode 周赛 516 - 分块莫队与子数组不同元素个数

作者: | 更新日期:

求子数组不同元素个数,很有意思的构造思路

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

零、背景

这次比赛的那几天我请假回老家了,所以就没参加比赛。
回来后,工作一直比较忙,所以直到周六才有时间把题目做了一遍。

最近几周我一直有一个感受,Leetcode 最后一题的难度升高了。
也许是我年纪大了,变菜了吧。

本场题型概览如下。

A 题:模拟。
B 题:枚举、二分、双指针、贪心。
C 题:分享两种滑动窗口与求质因子。
D 题:哈希前缀和、莫队、树状数组、滑动窗口等。

一、判断 ASCII 值回文

题意:给一个字符串,问把每个字符当做 ASCII 整数转化为 8 位的二进制,最终得到的字符串是否是回文串。

思路:模拟判断

按题意转为二进制字符串,前后双指针判断是否是回文串即可。

string ret;
ret.reserve(s.size() * 8);
for (int c : s) {
  for (int i = 0; i < 8; i++) {
    ret += (c & (1 << i)) ? '1' : '0';
  }
}
return CheckPalindromic(ret);

二、找到所有数组中消失的数字 II

题意:给一个数组,以及一个区间,问区间内的数字,有哪些不在数组内。
最终按递增顺序返回缺失的连续区间列表。
数据范围:10^5

方法一:枚举

数组存到哈希表中,然后从小到大枚举区间内的数字,判断是否存在。
不存在的追加到连续区间列表中。

vector<vector<int>> ret;
for (; lower <= upper; lower++) {
  if (s.count(lower)) continue;
  if (!ret.empty() && ret.back().back() == lower - 1) {
    ret.back().back() = lower;
  } else {
    ret.push_back({lower, lower});
  }
}
return ret;

方法二:二分

假设我们找到一个起始的缺失数字,如何找到这个区间有多大呢?
直接进行二分,即可找到下个数字的位置。
所以,可以不断压缩 lower,从而找到所有的缺失区间。

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

vector<vector<int>> ret;
while (lower <= upper) {
  auto it = lower_bound(nums.begin(), nums.end(), lower);
  if (it == nums.end() || *it > upper) {
    ret.push_back({lower, upper});
    break;
  } else {
    const int val = *it;
    if (val != lower) {
      ret.push_back({lower, val - 1});
    }
    lower = val + 1;
  }
}
return ret;

方法三:双指针

方法二没有利用上次操作的结果,每次都是二分的。
而查看整个过程,可以发现查询结果是按顺序查找到的。

故可以维护一个游标,来代替二分。

sort(nums.begin(), nums.end());
vector<vector<int>> ret;
int n = nums.size();
int i = 0;
while (lower <= upper) {
  while (i < n && nums[i] < lower) i++;
  if (i == n || nums[i] > upper) {
    ret.push_back({lower, upper});
    break;
  } else {
    const int val = nums[i];
    if (val != lower) {
      ret.push_back({lower, val - 1});
    }
    lower = val + 1;
  }
}
return ret;

方法四:左右连续性判断

假设没有区间的限制,直接问数组中空洞的区间有哪些,该怎么判断呢?
显然是排序后,看相邻元素之差是否大于 1。

有了区间限制后,区间的左右边界处理比较麻烦。
此时可以在区间前和区间后加入一个桩,从而解决边界问题。

nums.push_back(lower - 1);
nums.push_back(upper + 1);
sort(nums.begin(), nums.end());

vector<vector<int>> ret;
auto l = lower_bound(nums.begin(), nums.end(), lower) - nums.begin();
auto r = lower_bound(nums.begin(), nums.end(), upper) - nums.begin();
for (auto i = l; i <= r; i++) {
  if (nums[i] - nums[i - 1] > 1) {
    ret.push_back({nums[i - 1] + 1, nums[i] - 1});
  }
}
return ret;

三、至多 K 个不同质因数集合的最长子数组

题意:给一个数组,求最长的子数组,使得子数组中所有元素的不同质因子个数不超过 K。

思路:滑动窗口

对于质因子,提前筛选素数表,然后循环判断即可。

vector<int> factPrimes;
void GetFactPrime(ll v) {
  factPrimes.clear();
  for (int i = 0; prm[i] * prm[i] <= v; i++) {
    if (v % prm[i] == 0) {
      const ll a = prm[i];
      factPrimes.push_back(a);
      while (v % a == 0) {
        v /= a;
      }
    }
  }
  if (is[v]) {  // 最后剩余一个质数不满足 prm[i] * prm[i] <= v
    factPrimes.push_back(v);
  }
}

对于滑动窗口,如果是求不大于,固定右区间,游标维护左区间代码会简洁一些。

InitPrimes();
int n = nums.size();
int l = 0;
int ans = 0;
for (int i = 0; i < n; i++) {
  Add(nums[i]);
  while (mp.size() > k) {
    Remove(nums[l]);
    l++;
  }
  ans = max(ans, i - l + 1);  // [l, i]
}
return ans;

如果固定左区间,游标为右区间,就需要特殊判断当前的游标是开区间还是闭区间,很容易被坑。

int ans = 0;
for (int i = 0; i < n; i++) {
  while (r < n && mp.size() <= k) {
    Add(nums[r]);
    r++;
  }
  if (mp.size() <= k) {
    ans = max(ans, r - i);
  } else {
    ans = max(ans, r - i - 1);
  }
  Remove(nums[i]);
}
return ans;

四、有效 K 个不同元素子数组 I

题意:给一个数组,问指定子数组是否恰好包含 k 个不同的数字,且每个数字出现的频率都是偶数个。

思路:随机哈希、莫队、树状数组

首先可以发现两个条件互相独立,所以可以分别来判断是否满足。

先来看每个数字是否都出现偶数次。

最容易想到的就是前缀异或。
但是很容易构造出一些数据,来使得异或得到错误的答案。
所以,需要对数据进行随机映射,之后就很难构造出 hack 数据了。

所以,第一个解法是随机数+前缀异或。
直接通过 prefixXor[r] == prefixXor[l - 1] 判断是否都是偶数。

ll Rand() {
  // 随机生成一个 64 位的随机数
  return rand() | ((ll)rand() << 32);
}
unordered_map<int, ll> mp;
vector<ll> prefixXor;
void InitXor(const vector<int>& nums) {
  const int n = nums.size();
  prefixXor.resize(n + 1);
  for (int i = 1; i <= n; i++) {
    const int v = nums[i - 1];
    if (mp.count(v) == 0) {
      mp[v] = Rand();
    }
    prefixXor[i] = prefixXor[i - 1] ^ mp[v];
  }
}

对于区间统计问题,如果没有其他高效算法,那就上离线莫队算法来暴力计算。

莫队算法是对查询的区间进行排序,然后暴力对区间左边界或者右边界加减一次,直到与询问区间一致。

这时候很容易怀疑,难道不会超时吗?
这就需要使用分块的思想来排序,使得最坏情况下,复杂度依旧不超过 n sqrt(n)

具体来说,按其中一个边界来划分为 B=sqrt(N) 块,每个块内有 sqrt(N) 个元素。

排序时,先按块排序,之后按另一个边界排序。
这里不妨假设是按左边界分块,其次右边界排序。

由于左边界是按分块排序的,这导致块内的左边界是乱序的。
所以,块内 sqrt(N) 个元素每次对齐左边界都最多移动 sqrt(N) 次,综合移动 N 次。
对于右边界,由于是有序的,累计也是最多移动 N 次。
综合起来,一个块最多移动 2*N 次。

总共分了 sqrt(N) 块,故综合复杂度为 N sqrt(N)

for (auto v : nums) mp[v] = 0;
int current_cnt = 0;  // 元素偶数个的个数
auto Add = [&](int v) {
  mp[v]++;
  if (mp[v] % 2 == 0) {
    current_cnt++;  // 变成偶数
  } else {
    current_cnt--;  // 变成奇数
  }
};
auto Remove = [&](int v) {
  mp[v]--;
  if (mp[v] % 2 == 0) {
    current_cnt++;  // 变成偶数
  } else {
    current_cnt--;  // 变成奇数
  }
};
moQueAns.resize(queries.size());
int current_l = 0, current_r = -1;  // [left, right]
for (auto& [left, right, index] : sortedQueries) {
  while (current_l > left) Add(nums[--current_l]);
  while (current_l < left) Remove(nums[current_l++]);
  while (current_r < right) Add(nums[++current_r]);
  while (current_r > right) Remove(nums[current_r--]);
  moQueAns[index] = current_cnt == 0;
}

接下来看另一个条件,恰好包含 k 个不同的数字。

由于 k 固定,最容易想到的方法是滑动窗口。
预处理出每个右边界恰好包含 k 个不同数字的左边界区间,然后询问时直接判断是否在区间内。

这就和上一题很相似了,找到包含 k 个不同数字的最小数组,以及包含 k+1 个不同数字的最小数组,合起来就是目标区间。

vector<int> InitRange(const vector<int>& nums, const int K) {
  int n = nums.size();
  mp.clear();
  vector<int> left(n);
  int l = 0;
  for (int i = 0; i < n; i++) {
    mp[nums[i]]++;
    while (mp.size() >= K) {
      mp[nums[l]]--;
      if (mp[nums[l]] == 0) {
        mp.erase(nums[l]);
      }
      l++;
    }
    left[i] = l;
  }
  return left;
}

另一种思路则是构造出一种特殊的数据结构,从而可以统计区间内不同数字的个数。

例如,固定右区间时,定义每个数字最后一次出现的位置值为 1,之前出现的位置值为 0。
则求区间和,就可以统计出这个区间内有多少个数字。
对于区间和,可以使用线段树或者树状数组来实现。

auto Update = [&](const int i) {
  const int v = nums[i - 1];
  if (mp[v] != 0) {
    const int idx = mp[v];
    FenwickTree::bit_update(idx, -1);
  }
  mp[v] = i;
  FenwickTree::bit_update(i, 1);
};

由于固定了右区间,所以需要离线处理所有查询,按右区间排序。
这种算法还可以做到任意 k 都可以查询判断。

int offset = 0;
FenwickTree::bit_init(n);
for (int i = 1; i <= n && offset < Q; i++) {
  Update(i);
  while (offset < Q && get<0>(sortedQueries[offset]) == i) {
    const auto [right, left, idx] = sortedQueries[offset];
    const int k = FenwickTree::bit_query(right) - FenwickTree::bit_query(left - 1);
    if (k == K && prefixXor[right] == prefixXor[left - 1]) {
      ans[idx] = true;
    }
    offset++;
  }
}

五、最后

这次比赛最后一题其实比较难。
统计区间内不同数字出现次数,需要用到莫队算法。
统计区间内不同数字的个数,需要特殊构造,然后使用线段树或树状数组来解决。
这种构造法挺有意思的。

《完》

-EOF-

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

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

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

tiankonguse +
穿越