题目

半素数序列

解题思路(线性筛法优化生成)

半素数定义与筛选条件

半素数指两个素数相乘得到的数(含平方形式,如4=2×2)。其核心判定条件为:n是合数,且 n除以最小质因数后结果仍为素数。此条件等价于 n = p × q(其中 pqpq均为素数)。

线性筛法基础:素数生成与最小质因数记录

采用欧拉筛(线性筛)在O(n)时间复杂度内生成素数列表,同时记录每个数的最小质因数。筛法关键特性:每个合数仅被其最小质因数筛去一次,确保效率。筛法过程中同步维护 isPrime(素数标记)和 minPrime(最小质因数)数组。

两种高效生成半素数的策略对比

方法一通过双重循环遍历素数列表生成所有 p×qp≤q),生成后排序。时间复杂度O(π² + n log n)(π为素数个数,10⁶内约8万)。方法二利用 minPrime数组直接判断,仅需O(n)遍历,避免双重循环与排序,效率显著提升。

参考代码

方法一:双重循环生成后排序

import java.util.*;

public class Main {
    public static void main(String[] args) {
        Scanner sc = new Scanner(System.in);
        int q = sc.nextInt();
        List<Integer> halfPrimes = seive(1000000); // 生成所有 ≤100万的半素数
        while (q-- > 0) {
            int index = sc.nextInt();
            System.out.println(halfPrimes.get(index - 1)); // 1-indexed 索引
        }
    }

    private static List<Integer> seive(int limit) {
        // 初始化素数标记数组(true=素数)
        boolean[] isPrime = new boolean[limit + 1];
        Arrays.fill(isPrime, true);
        isPrime[0] = isPrime[1] = false; // 0和1非素数
      
        List<Integer> primes = new ArrayList<>(); // 存储素数列表
        // 线性筛法生成素数
        for (int i = 2; i <= limit; i++) {
            if (isPrime[i]) {
                primes.add(i); // 添加当前素数
            }
            // 用已知素数筛合数
            for (int prime : primes) {
                if (i * prime > limit) break; // 超出上限提前终止
                isPrime[i * prime] = false; // 标记为合数
                if (i % prime == 0) break; // 保证每个合数仅被最小质因数筛去
            }
        }
      
        // 生成半素数:p≤q避免重复(如2×3和3×2)
        List<Integer> halfPrimes = new ArrayList<>();
        for (int i = 0; i < primes.size(); i++) {
            int p = primes.get(i);
            for (int j = i; j < primes.size(); j++) {
                long product = (long) p * primes.get(j); // 防止int溢出
                if (product > limit) break; // 超出上限终止内层循环
                halfPrimes.add((int) product);
            }
        }
        Collections.sort(halfPrimes); // 生成顺序非升序需排序
        return halfPrimes;
    }
}

方法二:利用最小质因数直接判断

import java.util.*;

public class Main {
    public static void main(String[] args) {
        Scanner sc = new Scanner(System.in);
        int q = sc.nextInt();
        List<Integer> halfPrimes = seive(1000000); // 优化版筛法
        while (q-- > 0) {
            int index = sc.nextInt();
            System.out.println(halfPrimes.get(index - 1)); // 1-indexed 索引
        }
    }

    private static List<Integer> seive(int limit) {
        // minPrime[i] = i的最小质因数
        int[] minPrime = new int[limit + 1];
        boolean[] isPrime = new boolean[limit + 1];
        Arrays.fill(isPrime, true);
        isPrime[0] = isPrime[1] = false; // 0和1非素数
      
        List<Integer> primes = new ArrayList<>();
        // 线性筛法:同时生成素数列表和最小质因数
        for (int i = 2; i <= limit; i++) {
            if (isPrime[i]) {
                primes.add(i);
                minPrime[i] = i; // 质数的最小质因数是自身
            }
            for (int prime : primes) {
                if (i * prime > limit) break;
                isPrime[i * prime] = false; // 标记合数
                minPrime[i * prime] = prime; // 记录最小质因数
                if (i % prime == 0) break; // 保证最小质因数唯一性
            }
        }
      
        // 直接生成半素数:i是合数且i/minPrime[i]是素数
        List<Integer> halfPrimes = new ArrayList<>();
        for (int i = 4; i <= limit; i++) {
            // 条件解释:i是合数(!isPrime[i]),且i除以最小质因数后仍为素数(isPrime[i/minPrime[i]])
            if (!isPrime[i] && isPrime[i / minPrime[i]]) {
                halfPrimes.add(i);
            }
        }
        return halfPrimes;
    }
}

蓝桥约数

解题思路

利用约数个数奇偶性的关键观察

一个正整数的约数总是成对出现,因此其约数个数通常为偶数。只有当该数是完全平方数时,其平方根无法配对,导致约数个数为奇数。由此可得:若两个正整数 ​a​b 的约数个数之差为 1(即一奇一偶),则其中必有一个是完全平方数。

这一性质直接限定了蓝桥约数 ​N = a \times b 的结构:在所有满足 ​a \cdot b = N​|d(a) - d(b)| = 1 的分解中,​a​b 中必有一个是完全平方数

枚举完全平方因数进行高效验证

基于上述结论,无需枚举 ​N 的所有因数对,只需遍历所有不超过 ​N 的完全平方数 ​s = k^2,检查 ​s 是否整除 ​N。若整除,则令 ​t = N / s,并验证 ​|d(s) - d(t)| = 1 是否成立。只要存在一组满足条件的 ​(s, t),即可判定 ​N 为蓝桥约数。

该策略将每数的验证复杂度控制在 ​O(\sqrt{N}),且常数更小、逻辑更贴合问题本质。

线性筛预处理约数个数

为快速获取任意数的约数个数 ​d(x),采用线性筛(欧拉筛)在 ​O(n) 时间内预处理出 ​d[1..n]。筛法过程中维护每个数的最小质因子的指数,利用公式:

  • ​p \mid i​p 为最小质因子):​​d[i \cdot p] = d[i] \div (e+1) \times (e+2),其中 ​e​i​p 的指数;
  • 否则(​p \nmid i):
    ​d[i \cdot p] = d[i] \times 2

从而实现高效递推。(原理见下方详解)

前缀和加速区间查询

题目要求回答多个区间 ​[l, r] 内蓝桥约数的个数。在预处理出每个数是否为蓝桥约数后,构建前缀和数组,使每次查询可在 ​O(1) 时间内完成。

线性筛约数个数

为什么这个筛法能正确算出每个数的约数个数?

核心思想:任何一个数都能拆成质因数相乘

比如:

  • 12 = 2² × 3¹
  • 18 = 2¹ × 3²
  • 7 = 7¹(质数)

而一个数的约数个数,只跟它的质因数指数有关。

公式(记住这个就行):

如果 n = p₁^a₁ × p₂^a₂ × … × pₖ^aₖ
那么 n 的约数个数 = (a₁ + 1) × (a₂ + 1) × … × (aₖ + 1)

例子

  • 12 = 2² × 3¹ → 约数个数 = (2+1) × (1+1) = 3×2 = 6​(约数:1,2,3,4,6,12 ✅)
  • 9 = 3²(2+1) = 3(1,3,9 ✅)

所以问题变成:怎么快速知道每个数的质因数指数?

线性筛的聪明之处:从小到大构造每个数,并记录“最小质因子的指数”

我们用两个数组:

  • divisorCount[i]:i 的约数个数(最终答案)
  • minExp[i]:i 的最小质因子在它质因数分解中出现的次数(指数)
情况一:i 是质数(没被筛过)
  • 比如 i = 5
  • 质因数分解:
  • 所以 divisorCount[5] = 1 + 1 = 2
  • minExp[5] = 1(最小质因子是 5,指数 1)

✅ 直接设:

divisorCount[i] = 2;
minExp[i] = 1;
情况二:用 i 去乘一个质数 p,生成新数 x = i * p

这时候分两种子情况:

✅ 子情况 A:p 整除 i(即 i % p == 0

这意味着:p 就是 i 的最小质因子(因为我们按从小到大遍历质数)

举个例子:

  • i = 12 = 2² × 3,最小质因子是 2
  • p = 2(当前质数),x = 12 × 2 = 24 = 2³ × 3

原来 i=12 的最小质因子指数是 minExp[12] = 2(因为 2²)
现在 x=24 的最小质因子指数变成 2 + 1 = 3

那约数个数怎么变?

  • divisorCount[12] = (2+1)(1+1) = 6
  • divisorCount[24] = (3+1)(1+1) = 8

注意到:只有最小质因子的指数变了,其他不变​所以我们可以从 divisorCount[i] 推出来:

新约数个数 = 旧约数个数 ÷ (旧指数 + 1) × (新指数 + 1)

也就是:

minExp[x] = minExp[i] + 1;
divisorCount[x] = divisorCount[i] / (minExp[i] + 1) * (minExp[i] + 2);

然后 break —— 因为之后更大的质数不是 x 的最小质因子,不能这么算了。

✅ 子情况 B:p 不整除 i(即 i % p != 0

说明:p 是一个全新的质因子,而且比 i 的所有质因子都小(因为 primes 从小到大),所以 p 就是 x = i * p最小质因子

例子:

  • i = 9 = 3²p = 2(2 不整除 9)
  • x = 18 = 2¹ × 3²

此时:

  • x 的最小质因子是 p=2,指数是 1 → minExp[x] = 1
  • 因为 ip 没有公共质因子(互质),所以:
    • divisorCount[x] = divisorCount[i] × divisorCount[p]
    • divisorCount[p] = 2(质数)
    • 所以 divisorCount[x] = divisorCount[i] × 2

✅ 直接写:

minExp[x] = 1;
divisorCount[x] = divisorCount[i] * 2;

总结一句话

这个算法之所以能算对约数个数,是因为:

  • 从小到大构造每个合数
  • 对每个数,只通过它的“最小质因子”来更新
  • 利用“约数个数 = 各质因子指数+1 的乘积”这个公式;
  • 分两种情况处理:最小质因子已存在 vs 最小质因子是新的

只要明白:

“约数个数由质因数指数决定”,以及“线性筛保证每个数只被最小质因子筛一次”。

举个完整流程(n=6)

num primes so far 处理过程
2 [] → [2] 质数 → d[2]=2, minExp[2]=1
3 [2] → [2,3] 质数 → d[3]=2, minExp[3]=1
4 [2,3] 2: 2%2==0 → d[4] = d[2]/(1+1)(1+2)=2/23=3, minExp[4]=2 → break
5 [2,3] → [...,5] 质数 → d[5]=2
6 [...] 2: 6=3×2, 3%2!=0 → d[6]=d[3]*2=4, minExp[6]=1
3: 3%3==0 → d[9]=...(但 9>6 跳过)

结果:d[6]=4 ✅(1,2,3,6)


本质就是:利用质因数分解 + 动态更新指数信息。

参考代码

import java.util.*;

public class Main {
    public static void main(String[] args) {
        Scanner sc = new Scanner(System.in);
        int q = sc.nextInt();
        final int MAX_N = 100000;
        // 线性筛预处理 1~MAX_N 的约数个数 d[i]
        int[] d = sieveDivisorCount(MAX_N);
        // 标记每个数是否为蓝桥约数(利用完全平方数性质)
        boolean[] isLanQiao = new boolean[MAX_N + 1];
        for (int x = 1; x <= MAX_N; x++) {
            isLanQiao[x] = isLanQiao(x, d);
        }

        // 前缀和,支持区间查询
        int[] prefix = new int[MAX_N + 1];
        for (int i = 1; i <= MAX_N; i++) {
            prefix[i] = prefix[i - 1] + (isLanQiao[i] ? 1 : 0);
        }

        // 处理查询
        while (q-- > 0) {
            int l = sc.nextInt();
            int r = sc.nextInt();
            System.out.println(prefix[r] - prefix[l - 1]);
        }
    }

    private static boolean isLanQiao(int x, int[] d) {
        // 枚举所有完全平方数 s = k*k <= x
        for (int k = 1; (long) k * k <= x; k++) {
            int sq = k * k;
            if (x % sq == 0) {
                int other = x / sq;
                if (Math.abs(d[sq] - d[other]) == 1) {
                    return true;
                }
            }
        }
        return false;
    }

    /**
     * 线性筛预处理 1~n 每个数的约数个数
     * 返回数组 divisorCount,divisorCount[i] = i 的约数个数
     */
    public static int[] sieveDivisorCount(int n) {
        // 状态数组
        boolean[] isPrime = new boolean[n + 1];
        Arrays.fill(isPrime, true);
        isPrime[0] = isPrime[1] = false;
        List<Integer> primes = new ArrayList<>();

        // 结果数组
        int[] divisorCount = new int[n + 1];     // divisorCount[i] = i 的约数个数
        int[] minPrimeExp = new int[n + 1];      // minPrimeExp[i] = i 的最小质因子的指数

        divisorCount[1] = 1;

        for (int i = 2; i <= n; i++) {
            if (isPrime[i]) {
                primes.add(i);
                divisorCount[i] = 2;
                minPrimeExp[i] = 1;
            }

            for (int p : primes) {
                if (i * p > n) break;

                isPrime[i * p] = false;

                if (i % p == 0) {
                    // p 是 i 的最小质因子(因为 primes 是从小到大)
                    minPrimeExp[i * p] = minPrimeExp[i] + 1;
                    // 更新公式:原指数是 e,贡献 (e+1),新是 (e+2)
                    divisorCount[i * p] = divisorCount[i] / (minPrimeExp[i] + 1) * (minPrimeExp[i] + 2);
                    break; // 关键:只被最小质因子筛一次
                }

                // 此时 p 不整除 i → p 是 nextNum 的最小质因子,且是第一次出现
                minPrimeExp[i * p] = 1;
                divisorCount[i * p] = divisorCount[i] * 2; // 因为 d(p)=2,且互质
            }
        }

        return divisorCount;
    }
}

九面数字墙

解题思路

这道题的本质是求所有排列组成的数字之和。如果我们把成千上万个数字列出来硬算,肯定是不行的。我们需要换个角度,像小学生做加法一样,“竖着”来看这个问题。

核心直觉:竖式加法的奥秘

假设我们要计算几个数的和,比如由数字 1, 1, 2 组成的所有排列:​112 + 121 + 211

我们不要横着加,而是列成竖式看:

  1 1 2
  1 2 1
+ 2 1 1
-------

你会发现一个惊人的规律:

  1. 个位这列​2+1+1=4
  2. 十位这列​1+2+1=4
  3. 百位这列​1+1+2=4

每一列的数字加起来其实都是一样的!我们记这个和为 “单列和”。

最终的总结果,其实就是:

总结果 = \text{单列和} \times 1 + \text{单列和} \times 10 + \text{单列和} \times 100 \dots

提取公因数后:

总结果 = \text{单列和} \times (1 + 10 + 100 \dots) = \text{单列和} \times \underbrace{11\dots1}_{N个}

所以,解题只需要两步:

  1. 算出单列和是多少。
  2. 算出 ​N 个 1 组成的全1数是多少。

利用“概率”直觉计算单列和

单列和,就是把所有排列中,出现在某一位(比如个位)上的数字全部加起来。

我们可以利用概率来思考,这比背复杂的组合公式要简单得多。

假设一共有 ​N 个数字。

  1. 总共有多少种排列?

    这是一个经典的多重集排列问题。如果所有数字都不同,排列数是 ​N!。但因为有重复的数字(比如 ​c_1 个 1),我们要除以重复部分的阶乘。

    \text{总排列数} = \frac{N!}{c_1! \times c_2! \times \dots \times c_9!}
  2. 数字 ​i 在个位出现了几次?

    想象你闭着眼睛从 ​N 个数字里随便抽一个放在个位。

    • 抽到数字 ​i概率是:​\frac{\text{数字 } i \text{ 的个数}}{N}

    • 那么在所有排列中,数字 ​i 在个位出现的总次数就是:

      \text{出现次数} = \text{总排列数} \times \text{抽到 } i \text{ 的概率}
      \text{出现次数} = \text{总排列数} \times \frac{c_i}{N}
  3. 计算单列和

    算出每个数字 ​1 \sim 9 的出现次数后,乘上它们自己的数值,再加在一起:

    \text{单列和} = \sum_{i=1}^9 (\text{数字 } i \text{ 的出现次数} \times i)

构造全1数

我们需要一个由 ​N 个 1 组成的数(​11\dots1)。

这是一个等比数列求和的结果,公式为:

\underbrace{11\dots1}_{N个} = \frac{10^N - 1}{9}

关于取模运算的注意事项

题目要求对 998244353 取模。

  • 加法、乘法可以直接取模。
  • 除法不能直接做:公式里涉及到的除法(比如除以 ​N,除以 ​c_i!,除以 9),在模运算里需要转化为乘以逆元(可以理解为该数在模意义下的倒数)。代码中会使用费马小定理 power(a, MOD-2) 来计算逆元。

参考代码

import java.io.*;
import java.util.*;

public class Main {
    // 题目要求的模数
    static final long MOD = 998244353;
    // 数据范围限制,总位数最多 10^5
    static final int MAX_N = 100005;

    // 预处理数组:fac存储阶乘,invFac存储阶乘的逆元
    static long[] fac = new long[MAX_N];
    static long[] invFac = new long[MAX_N];

    public static void main(String[] args) {
        Scanner sc = new Scanner(System.in);
      
        // 1. 读入数据
        int[] count = new int[10]; // count[i] 表示数字 i 有多少个
        int N = 0; // N 表示总位数 (所有 c_i 的和)
      
        for (int i = 1; i <= 9; i++) {
            if (sc.hasNextInt()) {
                count[i] = sc.nextInt();
                N += count[i];
            }
        }

        // 特殊情况处理
        if (N == 0) {
            System.out.println(0);
            return;
        }

        // 2. 预处理阶乘和逆元,为计算排列数做准备
        precomputeFactorials();

        // 3. 第一步:计算“总排列数”
        // 公式:Total = N! / (c1! * c2! * ... * c9!)
        // 在代码中,"除以 c_i!" 变成了 "乘以 c_i! 的逆元"
        long denominatorInv = 1;
        for (int i = 1; i <= 9; i++) {
            denominatorInv = (denominatorInv * invFac[count[i]]) % MOD;
        }
        // 总排列数 = N! * 所有分母阶乘的逆元积
        long totalPermutations = (fac[N] * denominatorInv) % MOD;

        // 4. 第二步:计算“单列和”
        // 即计算任意一列(比如个位)所有数字加起来是多少
        long singleColumnSum = 0;
      
        // 计算 N 的逆元,用于概率计算 (概率 = 个数 / N)
        long invN = modInverse(N);

        for (int i = 1; i <= 9; i++) {
            if (count[i] == 0) continue;

            // 核心逻辑:利用概率计算数字 i 出现的次数
            // 次数 = 总排列数 * (数字 i 的个数 / 总数 N)
            // 出现次数 = 总排列数 * (数字 i 个数 / N)
            // 写成代码运算顺序:(Total * Count * invN)
            long times = totalPermutations;
            times = (times * count[i]) % MOD;
            times = (times * invN) % MOD;

            // 该数字对单列和的贡献 = 次数 * 数字本身的值
            long contribution = (times * i) % MOD;
          
            // 累加到单列和
            singleColumnSum = (singleColumnSum + contribution) % MOD;
        }

        // 5. 第三步:计算“全1数”
        // 我们需要构造 N 个 1 组成的数:11...1
        // 数学公式为 (10^N - 1) / 9
        long tenPowN = power(10, N); // 计算 10^N
        long allOnes = (tenPowN - 1 + MOD) % MOD; // 分子:10^N - 1 (注意防负数)
        long inv9 = modInverse(9); // 分母:9 的逆元
        allOnes = (allOnes * inv9) % MOD; // 相当于除以 9

        // 6. 最终答案 = 单列和 * 全1数
        long ans = (singleColumnSum * allOnes) % MOD;
      
        System.out.println(ans);
    }

    // --- 下面是数学工具函数 ---

    // 预处理阶乘及其逆元
    static void precomputeFactorials() {
        fac[0] = 1;
        for (int i = 1; i < MAX_N; i++) {
            fac[i] = (fac[i - 1] * i) % MOD;
        }
        // 计算 N! 的逆元
        invFac[MAX_N - 1] = modInverse(fac[MAX_N - 1]);
        // 逆向递推计算前面所有阶乘的逆元
        for (int i = MAX_N - 2; i >= 0; i--) {
            invFac[i] = (invFac[i + 1] * (i + 1)) % MOD;
        }
    }

    // 快速幂算法:计算 (base^exp) % MOD
    static long power(long base, long exp) {
        long res = 1;
        base %= MOD;
        while (exp > 0) {
            if ((exp & 1) == 1) res = (res * base) % MOD;
            base = (base * base) % MOD;
            exp >>= 1;
        }
        return res;
    }

    // 求逆元 (相当于求模意义下的倒数)
    // 根据费马小定理,a^(MOD-2) 就是 a 的逆元
    static long modInverse(long n) {
        return power(n, MOD - 2);
    }
}

额外知识点:模运算中的“除法”——逆元

在解决取模运算(​A \pmod M)的问题中,加法和乘法都很容易处理:

(A + B) \pmod M = ((A \pmod M) + (B \pmod M)) \pmod M
(A \times B) \pmod M = ((A \pmod M) \times (B \pmod M)) \pmod M

但是,除法却不能直接进行取模操作:

\frac{A}{B} \pmod M \ne \frac{A \pmod M}{B \pmod M}

例如:​\frac{10}{2} = 5。如果对 8 取模:

\frac{10}{2} \pmod 8 = 5

但如果先取模再除:

\frac{10 \pmod 8}{2 \pmod 8} = \frac{2}{2} = 1

结果错误!

为了在模 ​M 的世界里实现“除以 ​B”的操作,我们引入了逆元的概念。

逆元的定义

逆元 ​\text{inv}(B),就是指一个数 ​X,使得它乘以 ​B 之后,对 ​M 取模的结果是 ​1

B \times X \equiv 1 \pmod M

如果我们找到了 ​B 的逆元 ​\text{inv}(B),那么在模 ​M 意义下,“除以 ​B”就等价于“乘以 ​\text{inv}(B)”:

\frac{A}{B} \pmod M \equiv A \times \text{inv}(B) \pmod M

如何计算逆元?(费马小定理)

在我们的题目中,模数 ​M=998244353 是一个质数。当模数 ​M 是质数时,我们可以使用最简单高效的方法——费马小定理来计算逆元。

费马小定理指出:如果 ​M 是一个质数,且 ​B 不是 ​M 的倍数,那么:

B^{M-1} \equiv 1 \pmod M

将上式拆开:

B \times B^{M-2} \equiv 1 \pmod M

对比逆元的定义 ​B \times X \equiv 1 \pmod M,我们可以得出结论:

​B 的逆元 ​X 就是 ​B​M-2 次方。

\text{inv}(B) \equiv B^{M-2} \pmod M

在代码中,我们通过 快速幂 函数 power(n, MOD - 2) 来高效地计算这个逆元。

总结:

无论是计算总排列数时需要除以阶乘 (​c_i!),还是计算全1数时需要除以 9,我们都没有进行真正的除法,而是通过乘以它们的逆元来巧妙地完成了模意义下的运算。

一个敲代码的