很久以前,我自己想过这样一道题,记在洛谷里,没有公开。当时题面已经想好了,但我其实也不太会做,更没有确定应该把数据范围开到多少。
今天忽然想起来,现在 AI 已经这么厉害了,不如把这道题拿出来,看看能不能让 AI 解决一下。
所以先说明一下:题目是我以前想的;这篇 blog 的解题思路、推导、代码和文字整理,全部由 AI 生成。 我提出的问题很简单:能不能做到
这次得到的结果是:有一个可以直接实现的轻重分治加 bitset 解法,也可以借助快速矩阵乘法得到更好的理论上界。至于最想要的线性算法,目前还没有找到。不过,这个目标和 0/1 矩阵乘法之间有一个很直接的联系。
题目与记号
给定一个
也就是把第
例如:
第
下面记
这一步也需要计入复杂度:比较排序可以在
先把重复计算找出来
设第
行、列各有多少种数都很好算。但把两个数量直接相加时,两边共有的数会被算两次,因此每个共有的数都要减去一次:
例子中
现在只剩下一个问题:怎样同时算出所有
改为枚举一个数的贡献
固定一个数值
并令
只要
例如,矩阵里的数值
这也给出了一个直接的算法:
- 按数值分组,求出每个
的去重行表 和去重列表 。 - 把
初始化为零。 - 对每个
,枚举 ,把对应的 加一。 - 用容斥还原
。
这里必须先对行号、列号去重。某个数在一行出现五次,也只能在
这个算法到底多快?
设
于是算法的时间是
一方面
因此最坏复杂度是
这个界确实可以达到。对于
但如果全部元素互不相同,每个数只贡献一个位置,核心计算就只有
轻重分治与 bitset
瓶颈来自那些同时涉及很多行、很多列的数。一个自然的办法是:涉及范围小的数继续枚举,涉及范围大的数放在一起批量处理。
选一个整数阈值
- 如果
,称 为轻值。 - 如果
且 ,称 为重值。
这里按去重后的行数、列数划分。一个数即使出现很多次,只要集中在一行,它的贡献仍然容易枚举。
轻值:直接枚举
若
所以轻值部分是
重值:一次处理一个机器字
假设有
把这些重值编号,为每一行、每一列建立 bitset。一个位置为
因此,一对行列的重值贡献为
这里的 popcount 表示统计二进制中
设一个机器字有 popcount 视为
其实不需要同时存下全部 bitset。每次取至多
这样有
其中,建立掩码时写入的行号、列号总共只有
怎样选择阈值?
两部分合起来是
把枚举轻值的
还要照顾很窄的矩阵:如果这个阈值已经达到
因此可以选
得到核心复杂度
加上代码采用的排序预处理,就是
这也解释了为什么单独使用轻重分治还不够。如果将一个重值对全部
对于
在固定的 64 位机器上,它改善了最坏情况下的运算量,但没有改变三次方这个指数。更精细的实际开销是
通常比统一上界更有参考价值。
C++20 参考实现
输入第一行为
代码按“数值、行号、列号”排序。这样同一个数的行号天然有序,可以相邻去重;列号则用时间戳去重。轻值读完这一组就处理,只有重值需要保留行表和列表。
展开完整代码
#include <algorithm>
#include <bit>
#include <cmath>
#include <cstddef>
#include <cstdint>
#include <iostream>
#include <tuple>
#include <utility>
#include <vector>
struct Cell {
std::int64_t value;
int row;
int col;
};
struct Support {
std::vector<int> rows;
std::vector<int> cols;
};
std::vector<int> intersections(
int n, int m, std::vector<Cell> cells,
std::vector<int>& row_count, std::vector<int>& col_count
) {
const std::size_t total = std::size_t(n) * m;
constexpr int word_bits = 64;
const int threshold = std::min(
std::min(n, m),
std::max(1, int(std::ceil(std::sqrt(
static_cast<long double>(total) / word_bits
))))
);
std::sort(cells.begin(), cells.end(), [](const Cell& a, const Cell& b) {
return std::tie(a.value, a.row, a.col)
< std::tie(b.value, b.row, b.col);
});
row_count.assign(n, 0);
col_count.assign(m, 0);
std::vector<int> common(total, 0);
std::vector<std::size_t> last_column(m, total);
std::vector<Support> heavy;
for (std::size_t begin = 0; begin < total; ) {
std::size_t end = begin;
Support current;
while (end < total && cells[end].value == cells[begin].value) {
const int i = cells[end].row;
const int j = cells[end].col;
if (current.rows.empty() || current.rows.back() != i) {
current.rows.push_back(i);
++row_count[i];
}
if (last_column[j] != begin) {
last_column[j] = begin;
current.cols.push_back(j);
++col_count[j];
}
++end;
}
if (std::min(current.rows.size(), current.cols.size())
<= std::size_t(threshold)) {
for (int i : current.rows) {
const std::size_t offset = std::size_t(i) * m;
for (int j : current.cols) {
++common[offset + j];
}
}
} else {
heavy.push_back(std::move(current));
}
begin = end;
}
// The sorted input is no longer needed.
std::vector<Cell>().swap(cells);
std::vector<std::uint64_t> row_mask(n), col_mask(m);
for (std::size_t begin = 0; begin < heavy.size(); begin += word_bits) {
std::fill(row_mask.begin(), row_mask.end(), 0);
std::fill(col_mask.begin(), col_mask.end(), 0);
const std::size_t end = std::min(heavy.size(), begin + word_bits);
for (std::size_t k = begin; k < end; ++k) {
const std::uint64_t bit = std::uint64_t{1} << (k - begin);
for (int i : heavy[k].rows) row_mask[i] |= bit;
for (int j : heavy[k].cols) col_mask[j] |= bit;
}
for (int i = 0; i < n; ++i) {
const std::size_t offset = std::size_t(i) * m;
for (int j = 0; j < m; ++j) {
common[offset + j] += std::popcount(row_mask[i] & col_mask[j]);
}
}
}
return common;
}
int main() {
std::ios::sync_with_stdio(false);
std::cin.tie(nullptr);
int n, m;
if (!(std::cin >> n >> m) || n <= 0 || m <= 0) return 0;
std::vector<Cell> cells;
cells.reserve(std::size_t(n) * m);
for (int i = 0; i < n; ++i) {
for (int j = 0; j < m; ++j) {
std::int64_t value;
std::cin >> value;
cells.push_back({value, i, j});
}
}
std::vector<int> row_count, col_count;
const auto common = intersections(
n, m, std::move(cells), row_count, col_count
);
for (int i = 0; i < n; ++i) {
for (int j = 0; j < m; ++j) {
const std::int64_t answer = std::int64_t(row_count[i])
+ col_count[j] - common[std::size_t(i) * m + j];
std::cout << answer << (j + 1 == m ? '\n' : ' ');
}
}
}
理论上还能更快吗?
bitset 是把一批重值放在同一个机器字里一起算。还可以把所有重值的贡献放进一次矩阵乘法,使用代数算法来减少运算次数。
先看全部数值。构造两个 0/1 矩阵:
这里
这里用的是普通加法与乘法。Boolean matrix multiplication 只能告诉我们交集是否非空,无法直接得到交集大小。
把轻的部分直接枚举、重的部分交给快速矩阵乘法,是稀疏矩阵乘法中的经典思路,可以参见 Yuster 和 Zwick 的工作 (Yuster & Zwick, 2005)。对这道题,前面已经给出了轻值
其中
这个表达式也适用于长方形矩阵;如果
先用方阵乘法得到一个容易推导的界
设
重值部分要乘一个
配平第一项与第三项,取
得到
其中
文献中的方阵乘法指数已经小于
这里特意使用一个可达到、留有余量的指数
利用矩形乘法,可以得到
重值矩阵本来就是长方形的。使用针对矩形的乘法界,还能比上面的方阵分块更好。下面给出一个可以由文献中的指数界推出的结果,不声称它是这道题的最优上界。
展开矩形乘法的指数计算
记
Alman 等人的论文 Table 1 给出了比下面两个数更强的界 (Alman et al., 2024):
矩阵乘法算法可以通过张量积组合:维度的指数按比例相加,运算量的指数也按比例相加。也就是,可以在这两个形状之间使用凸组合上界。
选取
相应的乘法指数满足
现在在本题中取
因此,整个算法可以做到
以上是代数运算次数的界。要精确计算,可以选取素数
这些指数界主要回答“理论上能做到多快”。它们使用的快速矩阵乘法构造不适合直接照搬成普通竞赛代码;前面的 C++ 实现采用的仍然是轻重分治与 64 位掩码。
如果真的找到 ,会发生什么?
知道“这道题能写成矩阵乘法”,还不足以说明它难。也许这里的矩阵有特殊结构,刚好可以更快处理。
要说明线性算法的意义,需要反过来:把任意两个 0/1 矩阵的乘法,变成这道题。
给定两个
构造一个
- 若
,在右上块的 填入 。 - 若
,在左下块的 填入 。 - 其他位置保持为
。
其结构为
这里只用到了
第
因为左上块全部是零,
记
从而
构造输入、统计
例如,取
构造出的输入是
它的
这个归约说明,线性算法会带来一个很强的矩阵乘法结果。它没有证明线性算法不存在,也没有给本题建立超过
那么数据范围可以开多大?
这道题没有既定的数据范围,最好先看矩阵形状和数值分布,再决定规模。下面的复杂度均不含数值分组的预处理:
| 方法或条件 | 时间复杂度 | 说明 |
|---|---|---|
| 按数值枚举贡献 | 最坏 | |
| 轻重分治加 bitset | 参考代码采用此方法,空间 | |
| 方阵:轻重分治加快速矩形乘法 | ||
| 直接枚举贡献即可 | ||
| 每个数的 | 与轻值部分相同的求和证明 | |
| 总共只有常数种数 | 对每种数扫描一次所有位置 |
对于参考实现,估算工作量时可以直接计算轻值的 popcount。全部不同、全部相同、循环拉丁方,以及大量数值在多行多列中零散出现,都会产生很不一样的开销。只用随机小值域数据测试,很容易漏掉较难的分布。
这次也实际跑了上面的代码。在 Intel Xeon Platinum 8462Y+ 上,使用 GCC 9.3、-std=c++2a -O3 -march=native 编译,每组运行三次,得到下面的中位数。c++2a 是这个版本 GCC 使用的 C++20 模式名称。
| 矩阵大小 | 数值分布 | 排序、分组与计数用时 |
|---|---|---|
| 全部相同 | 0.026 s | |
| 全部不同 | 0.037 s | |
| 循环拉丁方 | 0.089 s | |
| 在 8192 种数中均匀随机取值 | 0.201 s | |
| 在 16384 种数中均匀随机取值 | 1.576 s | |
| 在 128 种数中均匀随机取值 | 0.138 s | |
| 在 128 种数中均匀随机取值 | 0.111 s |
这里不包含生成数据、读入和打印答案的时间,随机数据使用固定种子 20260913。这些是本次环境中的样本测量,不是最坏用时保证;不过至少说明,百万乃至数百万个格子的输入,是这个实现可以实际尝试的量级。
正确性方面,从文章中提取出的代码通过了 33,685 个矩阵的独立集合暴力校验。其中包括
这些结果可以作为设定数据范围的起点。真要出成一道题,还需要针对预期时限、内存和输出量继续测试。尤其要分别考虑方阵和很窄的长方形矩阵,不能只限制
这次还留下两个值得继续想的问题:有没有比这里的 bitset 实现更好的实用算法,以及能否利用输入矩阵的结构改进理论上界。最开始希望的