leetcode 第 446 场算法比赛 - 高级线段树

作者: | 更新日期:

最近一直没状态

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

零、背景

发现最近一直没状态,赛后想一下,题目都不难。

A: 模拟
B: 单调栈
C: 简单DP
D: 高级线段树

排名:200+
代码地址: https://github.com/tiankonguse/leetcode-solutions/tree/master/contest

一、执行指令后的得分

题意:给一个指令列表,按指令操作,求最终得分。

指令分为两个:
1)add,当前位置的值是得分。
2)move,当前位置的值是移动距离,向右移动,如果是负数则向左移动。

要求:每个位置只能到达一次,第二次到达或者移动到区间外则结束指令。

思路:模拟

按题意进行模拟,并记录曾经到达的位置。

二、非递减数组的最大长度

题意:给一个数组,每次可以选择一个子数组替换为一个数字,值为子数组中的最大值,问最终使得数组非递减的最大长度。

思路:单调栈

分析:子数组替换为最大值,又要求非递减,则对于最大值,后面的元素必然需要与最大值一起组成子数组,否则无法保证非递减。

基于这个分析递推推导,第一次把最大值后面的都替换为最大值,第二次把次大值到最大值之间的子数组替换为次大值,依次类推。

这个可以使用逆序单调栈来实现。
复杂度:O(n)

vector<int> st;
st.reserve(nums.size());
for (int i = nums.size() - 1; i >= 0; --i) {
  while (!st.empty() && st.back() < nums[i]) {
    st.pop_back();
  }
  st.push_back(nums[i]);
}
return st.size();

三、求出数组的 X 值 I

题意:给一个数组和X,一次操作可以删除数组的前缀和后缀得到一个非空子数组,非空子数组各元素乘积模 k 的值为 x,问有多少个子数组满足这个条件。
数据范围:1<=k<=5

思路:简单DP

状态定义:dp[i][j]表示前 i 个元素中,以 i 位置为后缀的所有子数组中,乘积 模 k 的值为 j 的子数组的个数。
状态转移方程:

dp[i+1][(j * nums[i]) % k] += dp[i][j]

答案是所有 dp[i][x] 的和。
复杂度:O(n*k)

复杂思路:逆元前缀积

假设我们计算统计了所有前缀积的个数,需要找到一个当前位置为后缀的子数组,使得子数组的乘积模 k 的值为 x,则可以推导出下面的公式:

prefix * x % k = now

从而可以计算出 prefix = now * x^-1 % k,其中 x^-1 是 x 的逆元。

这个公式对于大部分 k 都成立,但是对于 k=4 不成立,原因是 x 为 2 时不存在逆元。
另外,对于所有 x 为 0 的情况,逆元也都不成立。

我的想法是,对于其他情况,使用逆元计算。
对于特殊计算,特殊计算即可。

特殊情况1: x=0, 子数组只要包含1个0 就满足条件,故枚举起始位置,计算最短子数组,之后的都满足情况。
0 处理后,可以对数组分割为若干个不包含0 的子数组,每个子数组单独处理。

特殊情况2: k=4 & x=0, 当两个2组合在一起时,也会变成0。
故需要单独统计这种情况下,有多少个子数组可以组合成0。

特殊情况3: k=4 & x=2, 当只有一个2,其他都是奇数时,答案是2。

四、求出数组的 X 值 II

题意:与第三题类似,不过子数组数组不再是任意删除前缀和后缀,而是删除固定前缀+任意后缀,问个数。
另外,还增加了修改操作,有多个询问。

思路:线段树

线段树需要储存两个值:
1)整个区间的乘积。
2)区间内所有前缀的乘积分布。

vector<int> allVal;
vector<vector<ll>> preVal;  // 记录最值的位置

有这两个值,左右子区间可以合并计算出父区间。

如下,整个区间直接相乘,前缀则分为两部分:左区间乘积分布继承不变,右区间前缀乘积分布乘以整个左区间,就是剩余的前缀乘积分布。

// 合并函数,按需进行合并
void PushUp(int rt, int l, int r) {
  allVal[rt] = (allVal[rt << 1] * allVal[rt << 1 | 1]) % k;
  for (int i = 0; i < k; i++) {
    preVal[rt][i] = preVal[rt << 1][i];
  }
  int leftAllVal = allVal[rt << 1];
  for (int i = 0; i < k; i++) {
    preVal[rt][(i * leftAllVal) % k] += preVal[rt << 1 | 1][i];
  }
}

变更操作与初始化类似,单点更新后,更新整个路径的值即可。

查询时,右区间的答案依赖于左区间的整个区间的乘积,即第三题提到的乘法逆元。

左区间无脑递归即可,递归的时候顺便计算下整个前缀的乘积,最后到具体位置时,通过乘法逆元来找到所有满足要求的位置。
逻辑比较复杂,属于高级线段树的用法,具体看代码会更容易理解。

ll QueryPre(int L, int R, int val, int& pre, int l = 1, int r = maxNM, int rt = 1) {
  if (L <= l && r <= R) {
    ll ans = 0;
    for (int i = 0; i < k; i++) {
      if (pre * i % k == val) { // 找到所有的前缀,满足模k等于 val
        ans += preVal[rt][i];
      }
    }
    pre = pre * allVal[rt] % k;
    return ans;
  }
  int m = (l + r) >> 1;
  ll ret = 0;
  if (L <= m) {
    ret += QueryPre(L, R, val, pre, lson);
  }
  if (m < R) {
    ret += QueryPre(L, R, val, pre, rson);
  }
  return ret;
}

四、最后

这次比赛第三题我想复杂了,乘法逆元存在反例,最终在写各种特殊判断去修复反例,浪费了很多时间。
最后一题则是稍微高级的线段树,查询右区间时依赖左区间的结果,理解难度稍微高一些。

《完》

-EOF-

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

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

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

tiankonguse +
穿越