NOIP 2005 普及组算法题解
作者: | 更新日期:
涉及01背包与大整数逐位递推求循环节
本文首发于公众号:天空的代码世界,微信号:tiankonguse
零、背景
今天继续分享 2005 年 NOIP 普及组题解。

本场题型概览如下。
第一题:模拟
第二题:区间覆盖(暴力 / 区间合并 / 扫描线三种写法)
第三题:动态规划(01背包)
第四题:大整数 + 逐位递推(从 20 分到 100 分的演进)
一、陶陶摘苹果
题意:陶陶家的院子里有一棵苹果树,每到秋天树上就会结出 10 个苹果。
苹果成熟的时候,陶陶就会跑去摘苹果。
陶陶有个 30 厘米高的板凳,当她不能直接用手摘到苹果的时候,就会踩到板凳上再试试。
现在已知 10 个苹果到地面的高度,以及陶陶把手伸直的时候能够达到的最大高度,请帮陶陶算一下她能够摘到的苹果的数目。
假设她碰到苹果,苹果就会掉下来。
数据范围:10 个苹果的高度均在 100 到 200 厘米之间(包含 100 和 200);陶陶手伸直能达到的最大高度在 100 到 120 厘米之间(包含 100 和 120)。
思路:模拟
陶陶能达到的最大高度为 h + 30,枚举 10 个苹果,统计高度不超过该值的个数即可。
const int n = 10;
const int ext = 30;
int ans = 0;
for (int i = 0; i < n; i++) {
int v;
scanf("%d", &v);
if (v <= h + ext) {
ans++;
}
}
printf("%d\n", ans);
二、校门外的树
题意:某校大门外长度为 l 的马路上有一排树,每两棵相邻的树之间的间隔都是 1 米。
我们可以把马路看成一个数轴,马路的一端在数轴 0 的位置,另一端在 l 的位置;数轴上的每个整数点,即 0,1,2,…,l,都种有一棵树。
由于马路上有一些区域要用来建地铁,这些区域用它们在数轴上的起始点和终止点表示。
已知任一区域的起始点和终止点的坐标都是整数,区域之间可能有重合的部分。
现在要把这些区域中的树(包括区域端点处的两棵树)移走。
你的任务是计算将这些树都移走后,马路上还有多少棵树。
数据范围:1 ≤ l ≤ 10^4,1 ≤ m ≤ 100,0 ≤ u ≤ v ≤ l;其中 20% 的数据保证区域之间没有重合。
本题有三种写法,都能 AC,由浅入深如下。
解法一:暴力标记
开一个大小为 l + 1 的布尔数组,初始全部为 1(有树)。
对于每个区域 [u, v],遍历把 nums[u..v] 全部置为 0。
最后统计数组中 1 的个数。
复杂度 O(l * m),本题 l ≤ 10^4,m ≤ 100,完全够用。
vector<int> nums(l + 1, 1);
while (m--) {
int u, v;
scanf("%d%d", &u, &v);
for (int i = u; i <= v; i++) {
nums[i] = 0;
}
}
int ans = 0;
for (int i = 0; i <= l; i++) {
ans += nums[i];
}
printf("%d\n", ans);
解法二:区间合并
将所有区域按左端点排序,然后合并重叠或相邻的区间。
合并后,总树木数 l + 1 减去每个合并区间的长度,即为剩余树木数。
复杂度 O(m log m),不依赖 l 的大小。
sort(intervals.begin(), intervals.end());
vector<pair<int, int>> merged;
for (auto [u, v] : intervals) {
if (merged.empty() || merged.back().second < u) {
merged.push_back({u, v});
} else {
merged.back().second = max(merged.back().second, v);
}
}
int ans = l + 1;
for (auto [u, v] : merged) {
ans -= (v - u + 1);
}
printf("%d\n", ans);
解法三:扫描线(推荐)
和解法二类似,但不需要显式合并区间,直接在排序后一遍扫描统计剩余树木。
维护 pre 表示 [0, pre) 已经被覆盖(没有树)。
遍历每个区间 [u, v]:如果 pre < u,说明 [pre, u) 之间有树,计入答案;然后更新 pre = max(pre, v + 1)。
最后处理末尾 [pre, l] 的树。
这种写法直接统计答案,无需先算总数再减,逻辑更紧凑。
sort(intervals.begin(), intervals.end());
int ans = 0;
int pre = 0; // [0, pre) 已被覆盖
for (auto [u, v] : intervals) {
if (pre < u) {
ans += u - pre;
}
pre = max(pre, v + 1);
}
if (pre <= l) {
ans += l - pre + 1;
}
printf("%d\n", ans);
三、采药
题意:辰辰是个天资聪颖的孩子,他的梦想是成为世界上最伟大的医师。
为此,他想拜附近最有威望的医师为师。
医师把他带到一个到处都是草药的山洞里,对他说:”孩子,这个山洞里有一些不同的草药,采每一株都需要一些时间,每一株也有它自身的价值。我会给你一段时间,在这段时间里,你可以采到一些草药。如果你是一个聪明的孩子,你应该可以让采到的草药的总价值最大。”
如果你是辰辰,你能完成这个任务吗?
数据范围:1 ≤ T ≤ 1000,1 ≤ M ≤ 100;每株草药的采摘时间和价值均在 1 到 100 之间(包含 1 和 100);其中 30% 的数据满足 M ≤ 10。
思路:动态规划(01背包)
经典的 01 背包问题。
定义 dp[i] 表示用时不超过 i 时能获得的最大价值。
对于每株草药 (t, v),从大到小枚举时间,转移方程为 dp[i] = max(dp[i], dp[i - t] + v)。
从大到小枚举保证每株草药最多使用一次。
最终答案为 dp[T]。
复杂度:O(T * M)。
vector<int> dp(T + 1, 0);
while (M--) {
int t, v;
scanf("%d%d", &t, &v);
for (int i = T; i >= t; i--) {
dp[i] = max(dp[i], dp[i - t] + v);
}
}
printf("%d\n", dp[T]);
四、循环
题意:乐乐是一个聪明而又勤奋好学的孩子。他总喜欢探求事物的规律。
一天,他突然对数的正整数次幂产生了兴趣。
众所周知,2 的正整数次幂最后一位数总是不断地在重复 2,4,8,6,2,4,8,6…,我们说 2 的正整数次幂最后一位的循环长度是 4(实际上 4 的倍数都可以说是循环长度,但我们只考虑最小的循环长度)。
这时乐乐的问题就出来了:是不是只有最后一位才有这样的循环呢?
对于一个整数 n 的正整数次幂来说,它的后 k 位是否会发生循环?如果循环的话,循环长度是多少呢?
注意:1. 如果 n 的某个正整数次幂的位数不足 k,那么不足的高位看做是 0;2. 如果循环长度是 L,那么说明对于任意的正整数 a,n 的 a 次幂和 a+L 次幂的最后 k 位都相同。
数据范围:1 ≤ n < 10^100(n 最多 100 位),1 ≤ k ≤ 100;其中 30% 的数据满足 k ≤ 4。
本题是本场最难的题,下面按得分从低到高介绍各种解法,包括错误的尝试。
解法一:暴力枚举(20 分)
最直接的想法:用 long long 存储 n 和模数 10^k,不断计算 val = val * n % 10^k,用哈希表记录每个值首次出现的位置。当某个值重复时,循环长度为 当前次数 - 首次出现次数。
ll K = 1;
for (int i = 0; i < k; i++) K *= 10;
unordered_map<ll, int> mp;
ll val = n % K;
mp[val] = 1;
int times = 1;
while (1) {
val = val * n % K;
times++;
if (mp.count(val)) {
printf("%d\n", times - mp[val]);
break;
}
mp[val] = times;
}
为什么只有 20 分:
- n 用
int读入,题目中 n 可达 10^100,直接溢出。 - 模数
10^k用long long存储,k > 18 时溢出。 - 循环长度可能极大(如 n=3, k=100 时答案为 4×5^99),暴力枚举根本跑不完。
解法二:大整数暴力枚举(30 分)
针对解法一的前两个问题,引入大整数 BigNum,n 用字符串读入,乘法后截断到 k 位。
仍然是暴力枚举 n^1, n^2, n^3, …,用哈希表记录后 k 位的首次出现位置。
BigNum n(s);
n.Smp(k);
BigNum val = n;
unordered_map<string, int> mp;
mp[val.ToString()] = 1;
int times = 1;
while (1) {
val = val * n;
val.Smp(k);
times++;
string s = val.ToString();
if (mp.count(s)) {
if (mp[s] == 1) {
printf("%d\n", times - 1);
} else {
printf("-1\n");
}
break;
}
mp[s] = times;
}
为什么只有 30 分:
解决了大整数输入和存储问题,但核心瓶颈仍在——循环长度可能达到 4×5^99 量级,暴力枚举需要跑这么多步,完全不可行。
30% 的数据 k ≤ 4,此时循环长度最多几百,可以通过。
解法三:逐位递推 + ll 答案(30 分)
这是关键的算法突破。核心观察:后 k 位的循环长度,可以从低位到高位逐位确定。
假设已经求出后 k-1 位的循环长度为 L,即 n^L ≡ 1 (mod 10^(k-1))。
那么每次乘以 n^L,后 k-1 位保持不变,只有第 k 位可能变化。
第 k 位只有 0 到 9 十种取值,因此最多尝试 10 次乘法就能确定第 k 位的循环。
这个版本用 long long preLoop 存储当前累计的循环长度,用快速幂 Pow(n, preLoop, k) 计算 n^preLoop 的后 k 位。
ll TrySolver(const BigNum& nk, const int k, const ll preLoop) {
unordered_map<string, int> mp;
mp["1"] = 0;
const BigNum loopVal = Pow(nk, preLoop, k);
BigNum val(1);
for (ll times = 1;; times++) {
BigNum nextVal = val * loopVal;
nextVal.Smp(k);
const string s = nextVal.ToString();
if (mp.count(s)) {
if (mp[s] == 1) {
// 重复第一个值,循环长度为 (times-1) * preLoop
return (times - 1) * preLoop;
} else if (mp[s] == 0) {
// 变成了 1,循环长度为 times * preLoop
return times * preLoop;
}
return -1;
}
val = nextVal;
mp[s] = times;
}
}
为什么只有 30 分:
算法本身是正确的,但答案 preLoop 用 long long 存储。
当 k 较大时,循环长度可达 4×5^99 ≈ 10^70,远超 long long 范围(约 9×10^18),导致溢出得到错误答案。
k ≤ 4 时答案不超过几百,可以通过;k 再大就溢出了。
解法四:逐位递推 + BigNum 答案(100 分,AC)
在解法三的基础上,把循环长度也用大整数 BigNum 存储,彻底解决溢出问题。
同时不再需要快速幂:维护 preLoopVal = n^preLoopNum,每次递推时直接用它做乘法,避免了重复计算。
具体做法:维护两个值
preLoopNum:当前已确定的循环长度(BigNum,初始为 1)。preLoopVal:n^preLoopNum的后 K 位(BigNum,初始为 n)。
对每一位 k(从 1 到 K),依次计算 preLoopVal^1, preLoopVal^2, ... 的后 k 位,用哈希表记录首次出现位置:
- 如果某个值重复出现,且重复的是第一个值(位置 1),说明找到了新循环。设第 t 次重复,则新循环长度为
t - 1,总长度更新为preLoopNum * (t - 1),同时更新preLoopVal = preLoopVal^(t-1)。 - 重复时需要验证
n^(新长度) * n ≡ n (mod 10^k),确保循环从 a=1 开始;验证失败则循环不存在。 - 如果重复的不是第一个值,说明循环不包含起点,不存在纯循环,返回 -1。
- 10 次内未出现重复,也返回 -1。
每一位最多尝试 10 次大整数乘法,乘法复杂度 O(k^2),总复杂度 O(K^3),K ≤ 100 完全可行。
struct BigNum {
vector<ll> data; // 逆序存储
BigNum(ll x = 0) {
while (x) { data.push_back(x % 10); x /= 10; }
if (data.empty()) data.push_back(0);
}
BigNum(const string& s) {
for (int i = s.size() - 1; i >= 0; i--) data.push_back(s[i] - '0');
}
BigNum& Smp(int minBit = 0) {
while (minBit > 0 && data.size() > minBit) data.pop_back();
while (data.size() > 1 && data.back() == 0) data.pop_back();
return *this;
}
BigNum operator*(const BigNum& other) const {
BigNum res;
res.data.resize(data.size() + other.data.size() + 1, 0);
for (int i = 0; i < data.size(); i++) {
int carry = 0, pos = i;
for (int j = 0; j < other.data.size(); j++) {
res.data[pos] += data[i] * other.data[j] + carry;
carry = res.data[pos] / 10;
res.data[pos] %= 10;
pos++;
}
while (carry) {
res.data[pos] += carry;
carry = res.data[pos] / 10;
res.data[pos] %= 10;
pos++;
}
}
return res.Smp();
}
string ToString() const {
string res;
for (int i = data.size() - 1; i >= 0; i--) res.push_back(data[i] + '0');
return res;
}
};
string ToString(BigNum n, int k) { n.Smp(k); return n.ToString(); }
BigNum preLoopNum; // 当前循环长度(大整数)
BigNum preLoopVal; // n^preLoopNum 的后 K 位
int K;
ll TrySolver(const BigNum& nk, const int k) {
unordered_map<string, int> mp;
const string oneString = ToString(nk, k);
BigNum val(1);
for (ll times = 1; times <= 11; times++) { // 最多尝试 10 次
BigNum nextVal = val * preLoopVal;
nextVal.Smp(K);
const string s = ToString(nextVal, k);
if (mp.count(s)) {
if (mp[s] == 1) { // 重复的是第一个值
if (ToString(val * nk, k) == oneString) {
preLoopVal = val;
preLoopNum = preLoopNum * BigNum(times - 1);
return 0;
}
return -1;
}
return -1; // 循环不包含起点
}
val = nextVal;
mp[s] = times;
}
return -1;
}
void Solver() {
char buf[222];
scanf("%s%d", buf, &K);
BigNum n(string(buf));
n.Smp(K);
preLoopNum = BigNum(1);
preLoopVal = n;
for (int k = 1; k <= K; k++) {
if (TrySolver(n, k) == -1) {
printf("-1\n");
return;
}
}
printf("%s\n", preLoopNum.ToString().c_str());
}
五、最后
2005 年普及组的比赛整体难度适中。
第一题,纯签到题,统计即可。
第二题有三种写法:暴力标记最直观、区间合并是通用套路、扫描线最简洁;数据范围小,三种都能 AC。
第三题,经典 01 背包,一维数组倒序枚举是关键。
第四题是本场最难的题,介绍了四个解法:暴力枚举(20 分,n 和模数溢出)→ 大整数暴力(30 分,循环太长跑不完)→ 逐位递推 + ll 答案(30 分,答案溢出)→ 逐位递推 + BigNum 答案(100 分)。核心突破是逐位递推的思路:利用低位循环长度不变的性质,每新增一位只需最多尝试 10 次乘法,将指数级问题降为多项式级;最后的关键修复是把答案也用大整数存储。
《完》
-EOF-
本文公众号:天空的代码世界
个人微信号:tiankonguse
公众号 ID:tiankonguse-code
本文首发于公众号:天空的代码世界,微信号:tiankonguse
如果你想留言,可以在微信里面关注公众号进行留言。
