QOJ.ac

QOJ

Type: Editorial

Status: Open

Posted by: palette

Posted at:

Last updated:

Back to Problem

精确三元变换与逐因子贪心恢复:O((n+k)3^k)

题目:QOJ #8690 Archaeological Recovery,The 2022 ICPC World Finals Luxor。

代码已通过 QOJ #3010917:1 ms,4068 KB,64 个正式测试和 2 个额外测试全部 Accepted。算法与实现由 ChatGPT 辅助推导及编写。

记 $N=3^k$。下面给出确定性的 $O((n+k)N)$ 时间、$O(N+n+k)$ 空间算法。所有计算均为整数运算,无浮点数或哈希碰撞。先恢复每个无向操作 $\{v,-v\}$ 的数量,再用逐因子反卷积贪心选择方向,无需方向子集和 DP。

与已有方法的关系

这不是首次对本题使用 Fourier 变换。需要区分以下公开基线:

公开方法 主要计算 时间
2022 World Finals solution sketches,第 9–10 页 枚举所有投影、恢复无向数量、枚举平移并恢复符号 $O(kN^2+nN)$
QOJ #2140384 精确整数 Fourier 恢复,但直接枚举两重频率/状态及逐维点积 $O(kN^2)$,另有输出成本
QOJ #1342828 已有快速三元变换;用模数下相关性找平移,再对 $n$ 个操作做逐维加法的方向 DP $O(kN+nkN)$,DP 空间 $O(nN)$
本文 两次快速三元变换;按三元循环逐因子贪心反卷积 $O(kN+nN)$,空间 $O(N+n+k)$

因此,相对赛事解题分析,本文消除了 $N^2$ 项;相对已检查的快速变换实现,方向恢复消除了时间中的 $k$ 因子和空间中的 $n$ 因子,并给出不依赖模数碰撞假设的构造证明。Fourier、无向类可识别性及翻转与平移的关系已有理论基础,见 Glaudo–Kravitz 和 Ciprietti–Glaudo。本文不主张这些基础事实或该复杂度在所有未公开解法中首次出现。

1. 用整数表示三元 Fourier 变换

将 A,E,I 编码为 $0,1,2$,将操作 0,+,- 编码为 $0,1,2$。所有状态构成加法群 $G=\mathbb F_3^k$。输入 $C[x]$ 是 $n$ 个未知操作向量 $u_1,\ldots,u_n$ 的子集和重数。

取 $\omega^2+\omega+1=0$。定义

$$F[t]=\sum_{x\in G}C[x]\omega^{t\cdot x}=\prod_{j=1}^n(1+\omega^{t\cdot u_j}).$$

每个数用整数对 $(a,b)$ 表示 $a+b\omega$。乘以 $\omega$ 和 $\omega^2$ 分别为

$$ (a,b)\mapsto(-b,a-b),\qquad(a,b)\mapsto(b-a,-a). $$

因此每一维的蝶形

$$ (X,Y,Z)\mapsto(X+Y+Z,\ X+\omega Y+\omega^2Z,\ X+\omega^2Y+\omega Z) $$

只需常数次整数加减。对 $k$ 个三元位各做一层,耗时 $O(kN)$。

设 $z[t]$ 是满足 $t\cdot u_j=0$ 的操作数,$S=\sum_j u_j$。由于 $1+\omega^d=-\omega^{-d}$ 对 $d=1,2$ 成立,

$$F[t]=2^{z[t]}(-1)^{n-z[t]}\omega^{-t\cdot S}.$$

故每个 $F[t]$ 都是 $2^{z[t]}$ 乘以六次单位根。它的整数对一定是 $2^{z[t]}$ 乘以 $(\pm1,0),(0,\pm1),(1,1),(-1,-1)$ 之一。

所以 $z[t]$ 恰好等于 abs(a) | abs(b) 的二进制尾零数。abs(a) | abs(b) 非零,因此尾零计数有定义;不需要计算复数模长或浮点对数。

2. 第二次变换恢复无向操作

再对 $z$ 做同一个变换:

$$H[v]=\sum_{t\in G}z[t]\omega^{t\cdot v}.$$

交换求和次序,考察一个操作 $u$ 的贡献:

$$\sum_{t:t\cdot u=0}\omega^{t\cdot v}.$$

若 $u=0$,贡献仅在 $v=0$ 时为 $N$。若 $u\ne0$,由子空间上的特征标正交性,贡献在 $v\in\{0,u,-u\}$ 时为 $N/3$,其余为零。

设零操作数为 $m_0$,于是

$$m_0=\frac{H[0]/(N/3)-n}{2},$$

$$m(v)+m(-v)=\frac{H[v]}{N/3}\quad(v\ne0).$$

这些 $H[v]$ 都是普通整数,其 $\omega$ 系数为零。每对 $\{v,-v\}$ 只枚举一次,先任选一个方向,按恢复出的数量列出 $n$ 个操作。这一步只确定无向多重集。

3. 逐因子反卷积选择方向

定义平移 $(T_vD)[x]=D[x-v]$。增加操作 $v$ 等价于将剩余分布 $D$ 变成 $(I+T_v)D$。

因为 $3v=0$,有 $T_v^3=I$,从而

$$ (I+T_v)^{-1}=\frac{I-T_v+T_v^2}{2}. $$

对下一个无向操作 $\{v,-v\}$,直接计算

$$ D[x]=\frac{C[x]-C[x-v]+C[x+v]}2. $$

这是精确除法。假设当前某个合法表示包含 $v$,则 $D$ 是删除该操作后的分布;若它包含 $-v$,则 $D$ 是剩余分布的平移,因为

$$I+T_{-v}=T_{-v}(I+T_v).$$

因此 $D$ 始终是非负整数分布,但零点可能不在其支撑中。下面的引理解决这个问题。

引理: 一个合法子集和分布的平移,只要零点重数为正,就仍是同一无向操作多重集的某种合法定向产生的分布。

证明: 若 $(T_aF)[0]=F[-a]>0$,则存在操作子集 $B$ 的和为 $-a$。翻转 $B$ 中全部操作的方向,会使整个子集和多重集平移 $-\sum B=a$。这正好产生 $T_aF$。证毕。

每个合法分布都包含空集,所以当前 $C[0]>0$。又因

$$C[0]=D[0]+D[-v],$$

可作如下贪心选择:

  • 若 $D[0]>0$,输出 $v$,令剩余分布为 $D$。
  • 否则 $D[-v]>0$,输出 $-v$,令剩余分布为 $T_vD$。

两种选择都使新分布的零点重数为正。引理保证剩余无向操作仍存在合法定向,而且第二种选择满足

$$ (I+T_{-v})(T_vD)=(I+T_v)D=C. $$

由归纳法,每一步都精确去掉一个最终输出的因子。去掉全部 $n$ 个操作后,重数总和从 $2^n$ 变为 $1$,且零点重数为正,故剩下的正好是空集分布 $\delta_0$。把所有输出因子乘回去,就得到输入的完整分布,证明输出正确。零操作也适用:此时公式直接变成 $D=C/2$。

4. 实现与复杂度

不能在每个状态转移时重新拆 $k$ 个三元位,否则会多出一个 $k$ 因子。对固定 $v$,预先在 $O(N)$ 时间构造排列 $p[x]=x+v$:先令 $p[0]=v$,按位扩展已构造的前缀;若本位权重为 $h$、$v$ 的本位为 $d$,则

$$p[x+h]=p[x]+((d+1)\bmod3-d)h,$$ $$p[x+2h]=p[x]+((d+2)\bmod3-d)h.$$

扩展的总规模为 $1+3+\cdots+3^{k-1}=O(N)$。于是 $p[x]=x+v$,$p[p[x]]=x-v$;一轮反卷积、检查零点及必要平移共 $O(N)$。

两次变换为 $O(kN)$,全部 $n$ 轮反卷积为 $O(nN)$。输入、负向量计算与输出的 $O(kN+nk)$ 已被包含,故总时间为

$$\boxed{O((n+k)3^k)},\qquad\boxed{\text{空间 }O(3^k+n+k)}.$$

复杂度按能存储输入频数的机器字上的整数运算计。原题 $n\le40,k\le5$:第一次变换的每个部分和是互斥原始项的加权和,两个整数系数绝对值均不超过 $2^n$;第二次不超过 $nN$;反卷积临时分子绝对值不超过 $3\cdot2^n$。所有值都远小于有符号 64 位范围。

C++17 实现

以下代码与对应的 QOJ 提交一致。

#include <cassert>
#include <cstdlib>
#include <iostream>
#include <string>
#include <vector>
using namespace std;
using i64 = long long;

// Exact arithmetic in Z[w], w^2+w+1=0.
struct E {
    i64 a = 0, b = 0;
    E operator+(const E &o) const { return {a + o.a, b + o.b}; }
    E w() const { return {-b, a - b}; }
    E w2() const { return {b - a, -a}; }
};

void transform(vector<E> &a) {
    int n = (int)a.size();
    for (int h = 1; h < n; h *= 3)
        for (int b = 0; b < n; b += 3 * h)
            for (int i = b; i < b + h; ++i) {
                E x = a[i], y = a[i + h], z = a[i + 2 * h];
                a[i] = x + y + z;
                a[i + h] = x + y.w() + z.w2();
                a[i + 2 * h] = x + y.w2() + z.w();
            }
}

// All digitwise additions x+v in F_3^k, in O(3^k) time.
vector<int> translations(int v, int N) {
    vector<int> p(N);
    p[0] = v;
    for (int h = 1; h < N; h *= 3) {
        int d = v / h % 3;
        for (int x = 0; x < h; ++x) {
            p[x + h] = p[x] + ((d + 1) % 3 - d) * h;
            p[x + 2 * h] = p[x] + ((d + 2) % 3 - d) * h;
        }
    }
    return p;
}

vector<int> reconstruct(int n, int k, const vector<i64>& counts) {
    int N = 1;
    for (int i = 0; i < k; ++i) N *= 3;
    vector<E> f(N);
    for (int x = 0; x < N; ++x)
        f[x].a = counts[x];
    transform(f);
    for (E &x : f) {
        unsigned long long bits = (unsigned long long)llabs(x.a)
                                | (unsigned long long)llabs(x.b);
        assert(bits);
        x = {(i64)__builtin_ctzll(bits), 0};
    }
    transform(f);

    int zero = (f[0].a / (N / 3) - n) / 2;
    vector<int> levers(zero, 0), neg(N);
    for (int x = 1; x < N; ++x) {
        int y = x, p = 1;
        for (int j = 0; j < k; ++j, y /= 3, p *= 3)
            neg[x] += (3 - y % 3) % 3 * p;
        if (x < neg[x]) {
            assert(f[x].b == 0 && f[x].a % (N / 3) == 0);
            int count = f[x].a / (N / 3);
            for (int j = 0; j < count; ++j) levers.push_back(x);
        }
    }
    assert((int)levers.size() == n);

    // Divide out each unsigned factor and choose the sign that keeps zero
    // in the residual support. A supported translation of subset sums can
    // always be realized by flipping signs of a subset of the remaining levers.
    vector<i64> current = counts, quotient(N);
    for (int &v : levers) {
        vector<int> plus = translations(v, N);
        for (int x = 0; x < N; ++x) {
            int minus = plus[plus[x]];
            i64 twice = current[x] - current[minus] + current[plus[x]];
            assert(twice >= 0 && twice % 2 == 0);
            quotient[x] = twice / 2;
        }
        if (quotient[0] > 0) {
            current.swap(quotient);
        } else {
            // C=(1+T_v)D=(1+T_{-v})(T_v D), T_v D[x]=D[x-v].
            v = neg[v];
            for (int x = 0; x < N; ++x)
                current[x] = quotient[plus[plus[x]]];
        }
        assert(current[0] > 0);
    }
    assert(current[0] == 1);
    for (int x = 1; x < N; ++x) assert(current[x] == 0);
    return levers;
}

#ifndef ARCHAEOLOGY_LIBRARY
int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);
    int n, k, t;
    if (!(cin >> n >> k >> t)) return 0;
    int N = 1;
    for (int i = 0; i < k; ++i) N *= 3;
    vector<i64> counts(N);
    for (int i = 0; i < t; ++i) {
        string s;
        i64 count;
        cin >> s >> count;
        int x = 0, p = 1;
        for (int j = 0; j < k; ++j, p *= 3)
            x += (s[j] == 'A' ? 0 : s[j] == 'E' ? 1 : 2) * p;
        counts[x] = count;
    }
    for (int v : reconstruct(n, k, counts)) {
        for (int j = 0; j < k; ++j, v /= 3)
            cout << "0+-"[v % 3];
        cout << '\n';
    }
}
#endif

Comments

No comments yet.