leetcode 第 438 场算法比赛

作者: | 更新日期:

乘法逆元取模不是质数,触发我的知识盲区了。

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

背景

2025 年 leetcode 比赛越来越难了。
第三题竟然是非质数的乘法逆元,第一次遇到,触发我的知识盲区了。

我的猜测是:leetcode 原先是面向公司招聘的,如今行业不景气,leetcode 从招聘上的收入越来越少了。
所以 leetcode 打算转型,面向中学的算法比赛,以后依靠会员来提高收入,由此提高了算法难度。

A: 模拟
B: 堆或排序
C: 组合数学与乘法逆元
D: 二分贪心

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

一、判断操作后字符串中的数字是否相等 I

题意:给一个整数数组,每次相邻整数相加由此得到新的长度减一的数组,不断重复操作,直到数组长度为二,问此时剩余的两个元素的个位是否相等。

思路:模拟

按题意不断模拟操作,每次数组长度减一,减到剩余两个元素时,判断个位是否相等即可。

二、提取至多 K 个元素的最大总和

题意:给一个矩阵,每一行有个可以选择数字的数量限制,现在共需要选择 k 个数组,问怎么选择才能使得选择的数字和最大。

思路:堆或排序

方法1:排序+堆

对每一行从大到小排序,然后把这一行数量限制之内的数字加入大小最多为K的小顶堆中。
小顶堆数量超过 K 时,就把最小元素删除。
最后堆中的元素求和即可。

复杂度:O(n * m * (log(m) + log(k))))

方法2:排序。

矩阵转化为一维数组,元素值是 {val, rowIndex},之后逆序排序。
从大到小加入元素,并统计每一行已经加入的个数,到达限制时这一行就不再加入。
复杂度:O(n*m*log(n*m))

三、判断操作后字符串中的数字是否相等 II

题意:给一个整数数组,每次相邻整数相加由此得到新的长度减一的数组,不断重复操作,直到数组长度为二,问此时剩余的两个元素的个位是否相等。

思路:组合数学与乘法逆元

由于下一行的元素是上一行相邻两个数组的和,很容易发现,最终的答案是原始数组各元素乘以系数后的和。
第一个数字对应的元素区间是 [0, n-2],第二个数字对应的元素区间是 [1,n-1]
各系数对应组合数 C(n-2, i)
所以问题转化为了 sum(ai * C(n-2, i)) % 10

在计算这个公式之前,需要先了解几个基础知识。

1)乘法逆元
如果 gcd(a,b)=1x/a 可以整除,则x/a%p 等价与 x * a^-1 % p
其中 a^-1a 关于 p 的逆元。

这道题 p 为 0,不满足情况,所以我们需要做一下因式分解,使得除法满足乘法逆元。
推导公示如下

有了上面的公式,我们就可以预处理出所有剔除掉2和5的阶乘与阶乘逆元,然后套公式即可。

阶乘的预处理伪代码如下:

A[0] = 1;
for (int i = 1; i <= n; i++) {
  ll x = i;
  int x2 = 0, x5 = 0;
  while (x % 2 == 0) {
    x2++;
    x /= 2;
  }
  while (x % 5 == 0) {
    x5++;
    x /= 5;
  }
  A[i] = (A[i - 1] * x) % mod;
  A2[i] = A2[i - 1] + x2;
  A5[i] = A5[i - 1] + x5;
}

阶乘逆元的伪代码如下:

阶乘逆元也可以递推计算, 由于 n! = n * (n-1)!, 因此可以推导出 inv(n-1)= n * inv(n)
利用这个推导公式,我们就可以从后到前推导出所有阶乘逆元。

RA.resize(n + 1);
RA[n] = inv(A[n], mod);
for (int i = n; i > 0; i--) {
  ll x = i;
  int x2 = 0, x5 = 0;
  while (x % 2 == 0) {
    x2++;
    x /= 2;
  }
  while (x % 5 == 0) {
    x5++;
    x /= 5;
  }
  RA[i - 1] = RA[i] * x % mod;
}

四、正方形上的点之间的最大距离

题意:给一个正方形,问正方形边长上选择 k 个点,任意两点之间最短距离的最大值是多少。

思路:最小的最大,二分。

二分后,问题转化为了是否存在 k 个点,使得任意两点的距离都不小于 mid。

ll l = 1, r = side + 1;
while (l < r) {
  ll mid = (l + r) / 2;
  if (checkAll(mid)) {
    l = mid + 1;
  } else {
    r = mid;
  }
}
return r - 1;

由于点都在正方向上,很容易发现同一个边上的点可以二分查找。
相邻边上的点,也可以二分查找。
而对面边上的点,就不满足二分了。

另外,由于某条边上可能没点,我们需要判断下个点属于哪种情况,这样做的话,就需要写 4*4=16 中 if/else 与二分查找,代码非常复杂。

还好,题目说 k 至少为 4。
如果 k 大于 4 时,根据容斥原理,最短距离不会超过边长。
可以证明,k 等于 4 时,最短距离也不会超过边长。

有了这个结论,边对面的点就一定满足要求了。

我们可以把四条边的坐标顺时针转化为一维坐标,之后可以枚举起始位置,然后去贪心二分查找 k 个点。

如果枚举的位置在后面了,就需要转回到前面,为了避免处理环,我们可以把整个一维数组再复制一遍。

等着,假设枚举的是第一个点,如果找到最后一个点,最后一个点与第一个点可能不满足答案。
所以我们找到 k 个点后,需要判断最后一个点与第一个点是否满足条件即可。

auto checkOne = [n, side, k, &rows](const int p, const ll mid) -> bool {
  int num = 1;
  const ll firstVal = rows[p];
  ll preVal = firstVal;
  int offset = p;
  while (num < k) {
    auto it = lower_bound(rows.begin() + offset + 1, rows.end(), preVal + mid);
    if (it == rows.end()) return false;
    offset = it - rows.begin();
    if (p + n <= offset) return false; // 剪枝,转了一圈了,没答案
    preVal = *it;
    num++;
  }
  if (firstVal + 4 * side - preVal < mid) { //首位判断
    return false;
  }
  return true;
};

另外,枚举的时候,也有两个剪枝策略。

1)假设有答案,则答案肯定会尽量平均的分布在四个边上,所以我们只需要枚举一个边即可。
2)四条边要分k分,想要间隔最大,就要分配的尽量平均,间隔上限是周长分k分。

// 优化前 172ms
auto checkAll = [side, k, n, &rows, &checkOne](const ll mid) -> bool {
  // 优化1 容器原理, 4*side 的位置,分成 k 分,间隔至少为 (4*side - k) / (k - 1)
  if (1 + mid * (k - 1) > 4 * side) return false;  // 142ms
  // 第一个点肯定在第一个边上
  const ll firstVal = rows[0];
  for (int i = 0; i < n; i++) {
    if (rows[i] - firstVal > side) return false;  // 剪枝,只需要枚举一个边,63ms
    if (checkOne(i, mid)) {
      return true;
    }
  }
  return false;
};

优化前复杂度:O(log(S) * n * k * log(n))
优化后复杂度:O(log(S) * n * log(n))

可以发现,优化后,把 k 优化没了,所以 k 即使很大,我们这个算法也不会超时。

大家可以思考下,如果 k 是 2 或者 3 时,有什么简单的方法来做呢?

五、最后

这次比赛第三题非常难,之前没做过,我是不会做。
第四题不好想,我推导出答案不超过边长,所以最终没做出来。

第一次只做出两道题,过年后真的变菜了。

《完》

-EOF-

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

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

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

tiankonguse +
穿越