Skip to content

K - Kindred Sums - 题解

标签与难度

标签: 格基规约 (Lattice Reduction), Meet-in-the-middle, 子集和, 数论, 构造 难度: 2900

题目大意喵~

主人你好呀,喵~ 这道题是这样的:

我们有 N = 1000 个非常大的正整数 a1,a2,,aNa_1, a_2, \ldots, a_N,还有一个同样非常大的模数 M=1018M = 10^{18}

我们的任务是,从这1000个数的下标 {1, 2, ..., N} 中,找出两个互不相交并且不相同的子集 SSTT。这两个子集需要满足一个特殊的条件:它们对应元素的和之差是 MM 的倍数。

用数学公式表达就是:

(iSaiiTai)(modM)=0\left( \sum_{i \in S} a_i - \sum_{i \in T} a_i \right) \pmod M = 0

题目保证一定有解,我们只需要输出任意一个解就好啦。

输出格式有点特别哦:我们要输出一个长度为 N 的字符串。

  • 如果第 i 个元素在集合 SS 中,字符串第 i 位就是 1
  • 如果第 i 个元素在集合 TT 中,字符串第 i 位就是 2
  • 如果第 i 个元素哪儿都不在,那一位就是 0

解题思路分析

这道题看起来就像一个巨大的背包或者子集和问题,但是 N 和数值都太大了,直接搜索肯定会超时的说!310003^{1000} 种可能性,就算是超级计算机也顶不住呀,喵~

所以,我们需要更聪明的办法!让我带你一步步揭开谜底吧!

换个角度看问题喵

首先,我们把题目要求的目标稍微变个形。寻找集合 SSTT,其实等价于为每个 aia_i 分配一个系数 ci{1,0,1}c_i \in \{-1, 0, 1\},使得:

i=1Nciai0(modM)\sum_{i=1}^{N} c_i \cdot a_i \equiv 0 \pmod M

这里的系数 cic_i 和集合 S,TS, T 的关系是:

  • ci=1    iSc_i = 1 \implies i \in S
  • ci=1    iTc_i = -1 \implies i \in T
  • ci=0    iS and iTc_i = 0 \implies i \notin S \text{ and } i \notin T

因为题目要求 SSTT 是不同的非空集合(至少不能同时为空),所以我们不能让所有的 cic_i 都为 0。我们需要一个非平凡解

鸽巢原理的启示

想一想,我们有 N=1000N=1000 个数,可以构成 210002^{1000} 个不同的子集。210002^{1000} 是一个天文数字,远远大于 M=1018M=10^{18}

根据鸽巢原理,如果我们计算所有 210002^{1000} 个子集的元素和并对 MM 取模,必然会产生碰撞!也就是说,一定存在两个不同的子集 S1S_1S2S_2,使得:

iS1aiiS2ai(modM)\sum_{i \in S_1} a_i \equiv \sum_{i \in S_2} a_i \pmod M

移项一下,就是:

iS1aiiS2ai0(modM)\sum_{i \in S_1} a_i - \sum_{i \in S_2} a_i \equiv 0 \pmod M

这不就是我们想要的形式吗?我们可以定义 S=S1S2S = S_1 \setminus S_2T=S2S1T = S_2 \setminus S_1。这样 SSTT 就互不相交,并且因为 S1S2S_1 \neq S_2,所以 SSTT 不会同时为空,找到了一个合法的解!

这个原理证明了解一定存在,但并没有告诉我们怎么高效地找到它。毕竟,我们不能真的去计算 210002^{1000} 个子集和呀!

格基规约 (Lattice Reduction) 的大智慧

这个问题的本质,其实是寻找一组整数系数 cic_i 使得一个线性组合为 0(modM)0 \pmod M。在数学上,这类问题被称为整数关系发现(Integer Relation Discovery),而解决它的有力工具就是格基规约(Lattice Reduction),其中最著名的算法是 LLL 算法。

我们可以构造一个格(Lattice)。想象一个 N+1N+1 维空间,其中的向量是这样的:

  • v1=(Ca1,1,0,,0)v_1 = (C \cdot a_1, 1, 0, \ldots, 0)
  • v2=(Ca2,0,1,,0)v_2 = (C \cdot a_2, 0, 1, \ldots, 0)
  • ...
  • vN=(CaN,0,0,,1)v_N = (C \cdot a_N, 0, 0, \ldots, 1)
  • vN+1=(CM,0,0,,0)v_{N+1} = (C \cdot M, 0, 0, \ldots, 0) (这里的 CC 是一个缩放因子,用来平衡数值大小,可以暂时忽略)。

这个格里所有的向量都可以写成 civi\sum c_i v_i 的形式。我们想找一个短的、非零的向量。如果能找到一个向量 v=(L,c1,c2,,cN)v = (L, c_1, c_2, \ldots, c_N),并且它的长度很小,那么它的各个分量 cic_iLL 都会很小。如果我们能找到一个 L=0L=0 的向量,那就意味着:

i=1Nci(Cai)+cN+1(CM)=0\sum_{i=1}^{N} c_i \cdot (C \cdot a_i) + c_{N+1} \cdot (C \cdot M) = 0

i=1Nciai=cN+1M    i=1Nciai0(modM)\sum_{i=1}^{N} c_i \cdot a_i = -c_{N+1} \cdot M \implies \sum_{i=1}^{N} c_i \cdot a_i \equiv 0 \pmod M

这正是我们想要的!LLL算法就能帮我们找到这样的短向量。

不过,从头实现一个能处理1000维大数的LLL算法也太为难人了,喵~ 我们可以用一个更直观、更易于实现的简化版思想。

简化版的“规约” + Meet-in-the-middle

我们的核心策略分为两步:

  1. 规约 (Reduction): 我们有1000个初始“向量”,每个向量代表一个 aia_i 和它的来源。我们通过不断地两两相减,来制造出新的、数值上更“小”的向量。比如,如果我们有两个向量 viv_ivjv_j,它们的值分别是 aia_iaja_j。那么新向量 vk=vivjv_k = v_i - v_j 的值就是 aiaja_i - a_j,它的系数向量就是 eieje_i - e_jeie_i 是第i个单位向量)。通过不断重复这个过程,我们可以把1000个向量规约成一个更小的集合(比如40个),这些新向量的值都相对较小,但它们仍然是原始 aia_i 的线性组合。

  2. 中间相遇 (Meet-in-the-middle): 当我们把向量数量减少到可以处理的范围(比如 k=40k=40 个)时,就可以用经典的中间相遇攻击来寻找最终解了。

    • 把这40个向量分成两组,每组20个。
    • 第一组: 计算这20个向量所有 2202^{20} 个子集的和。对于每个子集,我们记录下它的 (sum % M, 对应的系数向量),存入一个哈希表 map 中。
    • 第二组: 同样计算这20个向量的所有 2202^{20} 个子集的和。对于每个子集的和 current_sum,我们去哈希表中查找是否存在一个 target_sum = (M - current_sum % M) % M
    • 如果找到了!那就意味着我们找到了两部分子集,它们的和加起来恰好是 MM 的倍数!把它们对应的系数向量合并起来,就得到了最终的答案 c 向量。
    • 我们也可以查找 current_sum 是否直接在哈希表中。如果找到,说明 sum1 % M == sum2 % M,那么 sum1 - sum2M 的倍数。这样构造出的解,系数是 {-1, 0, 1}

这个两步走的策略,既利用了格基规约的核心思想(通过线性组合降低向量范数),又使用了经典的搜索技巧,非常巧妙地解决了这个难题!

代码实现

好啦,理论讲完了,一起来看看代码怎么写吧,喵~

我将采用上面提到的“规约 + 中间相遇”的思路来写这份代码。

cpp
#include <iostream>
#include <vector>
#include <numeric>
#include <algorithm>
#include <map>

// 为了处理大数,我们使用 __int128
using int128 = __int128_t;

const int N = 1000;
const int128 M = 1000000000000000000LL;

// 定义一个结构体来表示我们的“向量”
// 它包含一个数值 value 和一个表示其来源的系数向量 coeffs
struct Vector {
    int128 value;
    std::vector<int> coeffs;

    Vector() : value(0), coeffs(N, 0) {}

    // 定义向量减法
    Vector operator-(const Vector& other) const {
        Vector result;
        result.value = this->value - other.value;
        for (int i = 0; i < N; ++i) {
            result.coeffs[i] = this->coeffs[i] - other.coeffs[i];
        }
        return result;
    }

    // 定义向量加法
    Vector operator+(const Vector& other) const {
        Vector result;
        result.value = this->value + other.value;
        for (int i = 0; i < N; ++i) {
            result.coeffs[i] = this->coeffs[i] + other.coeffs[i];
        }
        return result;
    }
};

// 用于排序的比较函数
bool compareVectors(const Vector& a, const Vector& b) {
    return a.value < b.value;
}

// 用于从标准输入读取 __int128
std::istream& operator>>(std::istream& is, int128& val) {
    long long input_val;
    is >> input_val;
    val = input_val;
    return is;
}

// 用于向标准输出打印 __int128
std::ostream& operator<<(std::ostream& os, const int128& val) {
    if (val == 0) return os << "0";
    std::string s = "";
    int128 tmp = val;
    bool neg = false;
    if (tmp < 0) {
        neg = true;
        tmp = -tmp;
    }
    while (tmp > 0) {
        s += (tmp % 10) + '0';
        tmp /= 10;
    }
    if (neg) s += '-';
    std::reverse(s.begin(), s.end());
    return os << s;
}


int main() {
    // 加速输入输出
    std::ios_base::sync_with_stdio(false);
    std::cin.tie(NULL);

    std::vector<Vector> basis(N);
    for (int i = 0; i < N; ++i) {
        std::cin >> basis[i].value;
        basis[i].coeffs[i] = 1;
    }

    // --- 阶段一:规约 ---
    // 我们将向量数量规约到 REDUCED_BASIS_SIZE
    const int REDUCED_BASIS_SIZE = 40;
    while (basis.size() > REDUCED_BASIS_SIZE) {
        // 按 value 排序,让值相近的向量排在一起
        std::sort(basis.begin(), basis.end(), compareVectors);
        
        std::vector<Vector> next_basis;
        // 保留第一个向量
        next_basis.push_back(basis[0]);

        // 其他向量都通过与前一个向量相减来更新
        for (size_t i = 1; i < basis.size(); ++i) {
            next_basis.push_back(basis[i] - basis[i-1]);
        }
        
        basis = next_basis;

        // 为了避免系数过大,可以进行一些额外的规约步骤,但这个简化版也足够找到解
    }

    // --- 阶段二:中间相遇搜索 ---
    int k = basis.size();
    int k1 = k / 2;
    int k2 = k - k1;

    // 存储第一半向量组合的哈希表
    // key: sum % M, value: 对应的向量
    std::map<int128, Vector> first_half_sums;

    // 遍历第一半向量的所有子集
    for (int i = 0; i < (1 << k1); ++i) {
        Vector current_sum;
        for (int j = 0; j < k1; ++j) {
            if ((i >> j) & 1) {
                current_sum = current_sum + basis[j];
            }
        }
        int128 mod_val = current_sum.value % M;
        if (mod_val < 0) mod_val += M;

        // 如果已经有一个组合的和是0的倍数,直接就是答案了!
        if (mod_val == 0 && i != 0) {
            first_half_sums[mod_val] = current_sum;
            break;
        }
        
        if (first_half_sums.find(mod_val) == first_half_sums.end()) {
            first_half_sums[mod_val] = current_sum;
        }
    }

    Vector solution;
    bool found = false;

    // 遍历第二半向量的所有子集
    for (int i = 0; i < (1 << k2); ++i) {
        if (found) break;
        Vector current_sum;
        for (int j = 0; j < k2; ++j) {
            if ((i >> j) & 1) {
                current_sum = current_sum + basis[k1 + j];
            }
        }

        int128 mod_val = current_sum.value % M;
        if (mod_val < 0) mod_val += M;
        
        // 寻找 sum1 + sum2 = 0 (mod M)
        int128 target = (M - mod_val) % M;
        if (first_half_sums.count(target)) {
            Vector s1 = first_half_sums[target];
            solution = s1 + current_sum;
            if (solution.value != 0 || std::any_of(solution.coeffs.begin(), solution.coeffs.end(), [](int c){ return c != 0; })) {
                found = true;
                break;
            }
        }

        // 寻找 sum1 = sum2 (mod M)
        if (first_half_sums.count(mod_val)) {
             Vector s1 = first_half_sums[mod_val];
             // 确保是不同的组合
             if (s1.value != current_sum.value || s1.coeffs != current_sum.coeffs) {
                solution = s1 - current_sum;
                if (solution.value != 0 || std::any_of(solution.coeffs.begin(), solution.coeffs.end(), [](int c){ return c != 0; })) {
                    found = true;
                    break;
                }
             }
        }
    }
    
    // --- 输出结果 ---
    for (int i = 0; i < N; ++i) {
        if (solution.coeffs[i] == 1) {
            std::cout << '1';
        } else if (solution.coeffs[i] == -1) {
            std::cout << '2';
        } else {
            std::cout << '0';
        }
    }
    std::cout << std::endl;

    return 0;
}

复杂度分析

  • 时间复杂度: O(logNNlogN+2k/2kN)O(\log N \cdot N \log N + 2^{k/2} \cdot k \cdot N)

    • 规约阶段: 我们大概需要 O(logN)O(\log N) 轮来把向量数量从 N 降到 k。每一轮都需要排序,复杂度是 O(NlogN)O(N \log N)。向量操作是 O(N)O(N)。所以规约阶段总的来说比较快。
    • 搜索阶段: 中间相遇是主要瓶颈。我们将 k 个向量分成两半。对每一半,我们都需要生成 2k/22^{k/2} 个子集和。每次生成和合并系数向量需要 O(kN)O(k \cdot N) 的时间。哈希表操作接近 O(1)O(1)。所以搜索阶段的复杂度是 O(2k/2kN)O(2^{k/2} \cdot k \cdot N)。在我们的代码中,k 大约是40,所以 2202^{20} 约一百万,这是可以接受的。
  • 空间复杂度: O(2k/2N)O(2^{k/2} \cdot N)

    • 主要开销是中间相遇搜索中存储第一半结果的哈希表。它最多会存储 2k/22^{k/2} 个项,每个项包含一个长度为 N 的系数向量。

知识点总结

这道题真是一次奇妙的冒险呢,喵~ 我们用到的知识点有:

  1. 问题转化: 把寻找集合 S,TS, T 的问题,转化为寻找一组系数 ci{1,0,1}c_i \in \{-1, 0, 1\} 的线性组合问题。这是解决组合问题时常用的思路。
  2. 鸽巢原理: 它是我们信念的基石!它告诉我们解一定存在,所以我们的方向是对的。
  3. 格基规约 (Lattice Reduction): 这是解决此类问题的标准数学工具。虽然我们没有直接用复杂的 LLL 算法,但我们通过排序和相减来模拟其核心思想——通过线性组合找到“更短”的向量,这大大降低了问题的规模。
  4. 中间相遇 (Meet-in-the-middle): 当问题规模被缩减到可以处理的范围时(比如40个元素),中间相遇就成了我们的终极武器。它通过空间换时间,将指数级的搜索复杂度 O(2k)O(2^k) 降低到 O(2k/2)O(2^{k/2}),非常有效!

希望我的讲解对你有帮助哦!下次遇到难题,也请不要放弃,多动动脑筋,一定能找到思路的!喵~