深挖26汕头零模T14:做个网页展示这道好题的风景~

24 阅读4分钟

引言

本文视频版传送门

视频封面:

260426-封面.png

今天我们来讲今年一月考的汕头零模的填空压轴题。这是一道小清新的脑筋急转弯题,难度不大,又能让人更好地体会像“正难则反”这样的数学思想。但如何构造出符合题意的数列呢?于是我开始写代码搜索这样的数列。这时,我忽然想到:为什么不vibe coding一个网页,让同学们更清楚地观赏这道好题的风景呢?

题干:

113737 排成数列 {an}\{a_n\} ,已知 a1=37a_1 = 37 ,且前 nn 项和 SnS_n 总能被下一项 an+1a_{n+1} 整除(即 Sn(modan+1)=0S_n \pmod{a_{n+1}} = 0 )。求 a37a_{37}

本文 52pojie: www.52pojie.cn/thread-2106…

本文 博客园: www.cnblogs.com/hans77/arti…

本文 juejin: juejin.cn/post/763659…

作者:hans774882968以及hans774882968以及hans774882968以及hans77

题解

首先,我们有一个朴素的想法:求出数列的前几项,看看有什么规律

a1=37a_{1} = 37S1(moda2)=0S_{1} \pmod{a_{2}} = 0 。37是素数,所以 a2=1 or 37a_{2} = 1\text{ or }37 ,但 a1=37a_{1}=37 ,所以 a2=1a_{2}=1

S2(moda3)=38(moda3)=0S_{2} \pmod{a_{3}} = 38 \pmod{a_{3}} = 0 。38的质因数分解为 2×192 \times 19 ,并且1已经被占用,所以 a3=2 or 19a_{3}=2\text{ or }19

我们在这卡住了!因为我们接下来需要分别假设 a3=2a_{3}=2a3=19a_{3}=19 ,后续求出的 a4, a5, a_{4},\ a_{5},\ \dots 的分支只会越来越多。

指望这样找到规律似乎很难!

怎么办呢?

正着推导很困难,我们就直接看最后一项!这就是正难则反的数学思想

根据题意, S36S_{36} 必须能被 a37a_{37} 整除(令 n+1=37n+1=37 )。 S36S_{36} 不是固定的,但 S37=(1+37)×372=19×37=703S_{37} = \frac{(1+37) \times 37}{2} = 19 \times 37 = 703 是定值,所以

S36=S37a37=703a37    a37(703a37)    a37(19×37)S_{36} = S_{37} - a_{37} = 703 - a_{37} \implies a_{37} \mid (703 - a_{37}) \implies a_{37} \mid (19 \times 37)

19、37都是素数,所以 a37=1 or 19 or 37a_{37}=1\text{ or }19\text{ or }37 。但之前已经推出 a1=37, a2=1a_{1}=37,\ a_{2}=1 ,所以 a37=19a_{37}=19

总结

题干中值得被反复观察、把玩的信息:

  1. 数列 {an}\{a_n\}113737排列。排列意味着什么呢?它意味着这个数列任取两项,这两项都不相等。比如说,我们知道a1=37a2=1,那么a3a37都不会是37,也不会是1了
  2. SnS_n 总能被下一项 an+1a_{n+1} 整除
    1. 观察开头:令 n=1n=1
    2. 观察结尾:令 n=36n=36

扩展

我们来回顾下题干:

113737 排成数列 {an}\{a_n\} ,已知 a1=37a_1 = 37 ,且前 nn 项和 SnS_n 总能被下一项 an+1a_{n+1} 整除。求 a37a_{37}

下面我们考虑怎么对这题进行扩展。首先,37是素数,不难想到:数列的项数n应为奇素数,设为p。

于是得到修改后的题干:

11pp 排成数列 {an}\{a_n\} ,已知 a1=pa_1 = p ,且前 nn 项和 SnS_n 总能被下一项 an+1a_{n+1} 整除。求 apa_{p}

接下来回顾之前的解题过程,我们能够确定的是 a2=1a_{2} = 1Sp=pp+12S_{p}=p*\frac{p+1}{2} ,以及 ap(p×p+12)a_{p} \mid \left( p \times \frac{p+1}{2} \right) 。于是我们不难想到:限制 p+12\frac{p+1}{2} 为素数,必然能让 apa_{p} 固定为 p+12\frac{p+1}{2}

这表明“ p+12\frac{p+1}{2} 也为素数”是 ap=p+12a_{p}=\frac{p+1}{2} 的充分条件。但它是必要条件吗?

注:p为素数+ p+12\frac{p+1}{2} 为正整数等价于p为奇素数

我们不妨写代码来寻找符合条件的数列

回到最开始说的朴素的想法:

假设数列的前 idxidx 项(a[1~idx])均已求出。枚举所有还没用过的数u,只要符合题意,就令 a[idx+1]a[idx+1]u。于是,假设被增强为:数列的前 idx+1idx+1 项均已求出——这是一个递归的过程

a[1~n]都求出后,就得到了一个符合条件的数列。这样的数列可能很多

这个非常朴素的想法,就是所谓的dfs(深度优先搜索)。下面我们简单看下这个朴素的想法,在代码中是怎么体现的。

int P;
__int64 current_sum;         // 记录当前前缀和 S[idx]
vector<int> perm;            // 当前构造的序列
vector<bool> used;           // 标记数字是否已使用
__int64 solution_count = 0;  // 合法排列计数

void dfs(int idx) {
  if (idx == P) {
    solution_count++;
    // 输出答案
    return;
  }

  for (int cand = 1; cand <= P; ++cand) {
    if (used[cand]) continue;
    if (current_sum % cand != 0) continue;
    used[cand] = true;
    perm[idx] = cand;
    current_sum += cand;
    dfs(idx + 1);
    current_sum -= cand;
    used[cand] = false;
  }
}

完整代码:

#include <iostream>
#include <vector>
#include <cmath>
#include <chrono>
// Copyright (c) 2026 hans7

using namespace std;

bool isPrime(int n) {
  if (n < 2) return false;
  for (int i = 2; i <= static_cast<int>(sqrt(n)); ++i) {
    if (n % i == 0) return false;
  }
  return true;
}

int P;
__int64 current_sum;         // 记录当前前缀和 S[idx]
vector<int> perm;            // 当前构造的序列
vector<bool> used;           // 标记数字是否已使用
__int64 solution_count = 0;  // 合法排列计数

void dfs(int idx) {
  if (idx == P) {
    solution_count++;
    cout << "【解 " << solution_count << "】: ";
    for (int i = 0; i < P; ++i) {
      cout << perm[i] << (i == P - 1 ? "" : ", ");
    }
    cout << "\n";
    return;
  }

  for (int cand = 1; cand <= P; ++cand) {
    if (used[cand]) continue;
    if (current_sum % cand != 0) continue;
    used[cand] = true;
    perm[idx] = cand;
    current_sum += cand;

    dfs(idx + 1);

    current_sum -= cand;
    used[cand] = false;
  }
}

int main() {
  ios::sync_with_stdio(false);
  cin.tie(nullptr);

  cout << "请输入素数 p: " << flush;
  if (!(cin >> P)) return 0;

  if (!isPrime(P)) {
    cerr << "❌ 错误: " << P << " 不是素数。\n";
    return 114514;
  }
  if ((P + 1) % 2 != 0) {
    cerr << "❌ 错误: (p+1)/2 不是整数(p 必须为奇素数)。\n";
    return 114514;
  }

  if (P > 23) {
    cout << "⚠️  警告: P > 23 时搜索树极深。DFS "
            "剪枝可大幅加速,但仍可能耗时较长。\n";
  }

  perm.assign(P, 0);
  used.assign(P + 1, false);

  perm[0] = P;
  used[P] = true;
  current_sum = P;

  auto start = chrono::high_resolution_clock::now();
  cout << "🔍 开始搜索合法排列...\n";

  dfs(1);

  auto end = chrono::high_resolution_clock::now();
  chrono::duration<double> elapsed = end - start;

  cout << "\n✅ 搜索完成。\n";
  cout << "📊 共找到 " << solution_count << " 个合法排列。\n";
  cout << "⏱️  耗时: " << elapsed.count() << " 秒\n";

  return 0;
}

dfs剪枝优化

考虑dfs中可行的cand需要满足的条件:

  1. 1 <= cand <= P && used[cand] = false
  2. candcurrent_sum的因数

考虑从“2”入手进行优化。我们预处理出1到 PP+12P*\frac{P+1}{2} 的每个数的所有因数,并把大于P的去掉,形成一张因数表vector<vector<int>> factors,然后dfs枚举的时候可以直接遍历这张表,不再需要遍历1到P

原版代码剪枝版代码
p=190.023874 秒0.010446 秒
p=230.873968 秒0.342937 秒
p=29138.214 秒45.8159 秒

优化后的完整代码:

#include <iostream>
#include <vector>
#include <cmath>
#include <chrono>
#include <algorithm>
// Copyright (c) 2026 hans7

using namespace std;

bool isPrime(int n) {
  if (n < 2) return false;
  for (int i = 2; i <= static_cast<int>(sqrt(n)); ++i) {
    if (n % i == 0) return false;
  }
  return true;
}

vector<vector<int>> factors;

void precompute_factors(int max_sum, int P) {
  factors.resize(max_sum + 1);
  for (int s = 1; s <= max_sum; ++s) {
    for (int d = 1; d * d <= s; ++d) {
      if (s % d == 0) {
        if (d <= P) factors[s].push_back(d);
        int other = s / d;
        if (other != d && other <= P) factors[s].push_back(other);
      }
    }
    // 升序排序在此没必要,但排序后调试更方便
    sort(factors[s].begin(), factors[s].end());
  }
}

int P;
__int64 current_sum;         // 记录当前前缀和 S[idx]
vector<int> perm;            // 当前构造的序列
vector<bool> used;           // 标记数字是否已使用
__int64 solution_count = 0;  // 合法排列计数

void dfs(int idx) {
  if (idx == P) {
    solution_count++;
    cout << "【解 " << solution_count << "】: ";
    for (int i = 0; i < P; ++i) {
      cout << perm[i] << (i == P - 1 ? "" : ", ");
    }
    cout << "\n";
    return;
  }

  for (int cand : factors[current_sum]) {
    if (used[cand]) continue;
    used[cand] = true;
    perm[idx] = cand;
    current_sum += cand;

    dfs(idx + 1);

    current_sum -= cand;
    used[cand] = false;
  }
}

int main() {
  ios::sync_with_stdio(false);
  cin.tie(nullptr);

  cout << "请输入素数 p: " << flush;
  if (!(cin >> P)) return 0;

  if (!isPrime(P)) {
    cerr << "❌ 错误: " << P << " 不是素数。\n";
    return 114514;
  }
  if ((P + 1) % 2 != 0) {
    cerr << "❌ 错误: (p+1)/2 不是整数(p 必须为奇素数)。\n";
    return 114514;
  }

  if (P > 23) {
    cout << "⚠️  警告: P > 23 时搜索树极深。DFS "
            "剪枝可大幅加速,但仍可能耗时较长。\n";
  }

  const int max_sum = P * (P + 1) / 2;
  cout << "📦 预处理因数表 [1, " << max_sum << "] ...\n";
  auto pre_start = chrono::high_resolution_clock::now();

  precompute_factors(max_sum, P);

  auto pre_end = chrono::high_resolution_clock::now();
  chrono::duration<double> pre_elapsed = pre_end - pre_start;
  cout << "✅ 预处理完成,耗时: " << pre_elapsed.count() << " 秒\n\n";

  perm.assign(P, 0);
  used.assign(P + 1, false);

  perm[0] = P;
  used[P] = true;
  current_sum = P;

  auto start = chrono::high_resolution_clock::now();
  cout << "🔍 开始搜索合法排列...\n";

  dfs(1);

  auto end = chrono::high_resolution_clock::now();
  chrono::duration<double> elapsed = end - start;

  cout << "\n✅ 搜索完成。\n";
  cout << "📊 共找到 " << solution_count << " 个合法排列。\n";
  cout << "⏱️  搜索耗时: " << elapsed.count() << " 秒\n";
  cout << "📈 总耗时(含预处理): " << (pre_elapsed + elapsed).count() << " 秒\n";

  return 0;
}

推广问题的结论

p+12\frac{p+1}{2} 也为素数”是 ap=p+12a_{p}=\frac{p+1}{2} 的充分不必要条件。但“p为奇素数”是 ap=p+12a_{p}=\frac{p+1}{2} 的必要不充分条件

比如 p=11p=11 的两个排列中,出现了末项不是 p+12=6\frac{p + 1}{2} = 6 的情况

11, 1, 2, 7, 3, 8, 4, 9, 5, 10, 6
11, 1, 4, 8, 6, 10, 5, 9, 2, 7, 3

p=19p=19 的6个排列的末项恰好都是 p+12=10\frac{p + 1}{2} = 10

19, 1, 2, 11, 3, 12, 4, 13, 5, 14, 6, 15, 7, 16, 8, 17, 9, 18, 10
19, 1, 2, 11, 3, 4, 5, 15, 12, 6, 13, 7, 14, 16, 8, 17, 9, 18, 10
19, 1, 4, 12, 3, 13, 2, 18, 8, 16, 6, 17, 7, 9, 5, 14, 11, 15, 10
19, 1, 4, 12, 6, 2, 11, 5, 15, 3, 13, 7, 14, 16, 8, 17, 9, 18, 10
19, 1, 4, 12, 6, 14, 8, 16, 5, 17, 2, 13, 9, 18, 3, 7, 11, 15, 10
19, 1, 4, 6, 15, 9, 18, 8, 5, 17, 3, 7, 16, 2, 13, 11, 14, 12, 10

26汕头零模T14-可视化网页

还能继续提问:

  1. 能发现一些比较有规律的数列吗?
  2. 除了 p+12\frac{p+1}{2} ,末项还可以是哪些数?
  3. 每种可能的末项的占比如何(这里称为“末项分布”)?

为了更加清晰地展示对这些问题的回答,我vibe coding了一个网页。

网页传送门,示意图:

网页示意图.png

开发网页的提示词

大佬,请完整复述这题的题干。然后我们不难容易知道这题a37=19。可以写dfs代码求出所有可能的解。请写一个html文件。网页有一个输入框,输入框支持输入一个素数p(表示a1=p,以及数列共有p项)然后要判定(p+1)/2也是素数。两个都满足,就能确定头和尾。就跑dfs算法,在网页展示10组满足条件的数列。这时出现一个“继续生成”按钮,每点击一次就再生成10组数据。这里“继续生成”的能力可以靠js的yield实现。

技术栈:Tailwind CSS、React。

大佬,下面这个函数报错:Uncaught SyntaxError: /Inline Babel script: Unexpected reserved word 'yield'. (40:10)
  38 |         // 终止条件:找到了长度为 p 的序列
  39 |         if (depth === p) {
> 40 |           yield[...path];
     |           ^
  41 |           return;
  42 |         }

// solveSequence 所有代码

谢谢大佬!网页能够运行,接下来请你帮我做以下修改:

  1. “验证条件”按钮改成“验证条件并搜索”,点击后会验证条件。如果不通过,行为和目前一致。如果通过,就预先生成前10个数列。
  2. 对于p=37,第一次点击“继续生成10组”按钮后,我看到有10组数据出来,但是按钮一直都是“搜索中”,无法继续点击。请帮我查找原因并修复这个bug
  3. 复制按钮改为总是显示
  4. 复制按钮的左边新增一个“验证”按钮,点击后弹出一个对话框,对话框显示每一个 SnS_nan+1a_{n+1} ,以表明它确实符合题意

请在我给你的代码的基础上修改:

谢谢大佬!接下来请帮我在“已找到XX组解”的上方新增一个组件。这个组件有一个输入框,可以输入一个逗号分隔的数组。首先做输入校验,
比如验证数列是否是1p的排列。然后验证数列是否符合题意。这里可以考虑复用前面开发过的“验证”的对话框的组件,
但是这个组件不需要弹出对话框。

请在我给你的代码的基础上修改:

谢谢大佬!接下来请帮我在validationMsg的上方新增一行文本,展示1到200的正整数中,满足p和(p+1)/2都是素数的所有p。这次你只需要输出需要改动的代码

谢谢大佬,接下来请帮我在“已找到X组解”的下方新增一行文本,展示目前搜索到的每种末项,及它们各出现了几次。

1. 请在我给你的代码的基础上修改,给出修改后的完整代码
2. 代码要符合React最佳实践

谢谢大佬,接下来请帮我在“继续生成10组”按钮的左边加一个“搜索所有数列”按钮,点击后继续跑dfs,直到dfs运行完毕,并把所有数列都展示在页面上。

请在我给你的代码的基础上修改:

// 完整代码

UI 升级:变成 quantum-rose 主题

大佬,你是一名专家前端工程师,精通前端工程化。我有一个html文件,技术栈是Tailwind CSS、React,目前它的UI比较朴素。希望你帮我把这个网页的颜色主题变为 quantum-rose (来自 tweakcn 网站)

要求:符合最小改动原则,只改变颜色主题,其他都不改变。

quantum-rose 主题的 CSS 代码:

完整HTML代码:

吸收点JS知识

为了理解前面给出的提示词,除了要知道这题可以用dfs求出所有解,还要知道在JS中如何优雅地拿到dfs给出的前若干个解:Generator函数和yield语句(ES6推出)

下面我们再对比下“继续生成10组”和“搜索所有数列”的代码:

“继续生成10组”按钮:

const generateBatch = (count) => {
  let gen = generatorRef.current;

  if (!gen) {
    const p = parseInt(inputP);
    gen = solveSequence(p);
    generatorRef.current = gen;
  }

  const newSolutions = [];
  let done = false;

  for (let i = 0; i < count; i++) {
    const result = gen.next();
    if (result.done) {
      done = true;
      break;
    }
    newSolutions.push(result.value);
  }

  if (newSolutions.length > 0) {
    setSolutions(prev => [...prev, ...newSolutions]);
  }

  if (done) {
    setIsFinished(true);
  }
};

“搜索所有数列”按钮:

const searchAll = async () => {
  if (isSearchingAll) return;

  setIsSearchingAll(true);
  let gen = generatorRef.current;

  if (!gen) {
    const p = parseInt(inputP);
    gen = solveSequence(p);
    generatorRef.current = gen;
  }

  const BATCH_SIZE = 50;

  while (true) {
    const batch = [];
    let done = false;

    for (let i = 0; i < BATCH_SIZE; i++) {
      const result = gen.next();
      if (result.done) {
        done = true;
        break;
      }
      batch.push(result.value);
    }

    if (batch.length > 0) {
      setSolutions(prev => [...prev, ...batch]);
    }

    if (done) {
      setIsFinished(true);
      break;
    }

    // 让出主线程, UI 才能更新,用户才能看到每次增加 50 个解的效果
    await new Promise(resolve => setTimeout(resolve, 0));
  }

  setIsSearchingAll(false);
};

不难发现,两段代码的前面部分是类似的,都调用了solveSequence方法。solveSequence方法基本就是把dfs封装了一下,但它是一个Generator函数,所以这句话并不会真的执行dfs,只是想要拿到一个gen对象。只有在后续gen对象调用next方法时,才会真正执行dfs。

“搜索所有数列”的循环虽然有两层,但内层循环和“继续生成10组”单层循环的也类似。最大的区别是,“搜索所有数列”有一句await new Promise。这句话很重要。只有添加了这句话,才能让出主线程,UI才能更新,用户才能看到每次增加50个解的效果。如果没有这句话,在求出所有数列前,UI会完全卡住,用户体验会很糟糕。