NOIP 2006 普及组算法题解
作者: | 更新日期:
涉及去重排序、01背包、递增字母计数与二进制转k进制求和
本文首发于公众号:天空的代码世界,微信号:tiankonguse
零、背景
今天继续分享 2006 年 NOIP 普及组题解。

本场题型概览如下。
第一题:排序去重
第二题:动态规划(01背包)
第三题:贪心求下一个递增排列
第四题:二进制转 k 进制(__int128 / 大整数两种写法)
一、明明的随机数
题意:明明想在学校中请一些同学一起做一项问卷调查,为了实验的客观性,他先用计算机生成了 N 个 1 到 1000 之间的随机整数。
对于其中重复的数字,只保留一个,把其余相同的数去掉,不同的数对应着不同的学生的学号。
然后再把这些数从小到大排序,按照排好的顺序去找同学做调查。
请你协助明明完成”去重”与”排序”的工作。
数据范围:N ≤ 100,生成的随机整数均在 1 到 1000 之间(包含 1 和 1000)。
思路:模拟(排序 + 去重)
先对 N 个数排序,使相同的数相邻;再用 unique 把相邻重复元素移到末尾并返回新结尾,erase 删掉即可。
排序后重复数字只保留一个,天然完成去重。
输出先去重后的个数 M,再按序输出 M 个数。
复杂度:O(N log N)。
scanf("%d", &n);
nums.resize(n);
for (int i = 0; i < n; i++) {
scanf("%d", &nums[i]);
}
sort(nums.begin(), nums.end());
nums.erase(unique(nums.begin(), nums.end()), nums.end());
printf("%d\n", (int)nums.size());
for (int i = 0; i < (int)nums.size(); i++) {
printf("%d%c", nums[i], i == (int)nums.size() - 1 ? '\n' : ' ');
}
二、开心的金明
题意:金明今天很开心,家里购置的新房就要领钥匙了,新房里有一间他自己专用的很宽敞的房间。
更让他高兴的是,妈妈昨天对他说:”你的房间需要购买哪些物品,怎么布置,你说了算,只要不超过 N 元钱就行”。
今天一早金明就开始做预算,但是他想买的东西太多了,肯定会超过妈妈限定的 N 元。
于是,他把每件物品规定了一个重要度,分为 5 等:用整数 1-5 表示,第 5 等最重要。
他还从因特网上查到了每件物品的价格(都是整数元)。
他希望在不超过 N 元(可以等于 N 元)的前提下,使每件物品的价格与重要度的乘积的总和最大。
数据范围:
总钱数 n < 3×10^4,
物品个数 m < 25;
每件物品价格 v ≤ 10^4,重要度 p 为 1 到 5;
答案小于 10^8。
思路:动态规划(01背包)
把每件物品看成”重量为价格 v、价值为价格×重要度 v×p”的物品,总钱数 n 就是背包容量。
问题转化为经典的 01 背包:选一些物品,在总价不超过 n 的前提下使总价值最大。
定义 dp[i] 表示花费不超过 i 元时能获得的最大价值总和。
对每件物品,从大到小枚举容量,转移方程为 dp[i] = max(dp[i], dp[i - v] + v * p)。
从大到小枚举保证每件物品最多被选一次。
最终答案为 dp[n]。
复杂度:O(n * m)。
scanf("%d%d", &n, &m);
vector<pair<int, int>> nums(m);
for (int i = 0; i < m; i++) {
scanf("%d%d", &nums[i].first, &nums[i].second);
nums[i].second *= nums[i].first; // 价值 = 价格 × 重要度
}
vector<int> dp(n + 1, 0);
for (int i = 0; i < m; i++) {
for (int j = n; j >= nums[i].first; j--) {
dp[j] = max(dp[j], dp[j - nums[i].first] + nums[i].second);
}
}
printf("%d\n", dp[n]);
三、Jam 的计数法
题意:Jam 是个喜欢标新立异的科学怪人,他不使用阿拉伯数字计数,而是使用小写英文字母计数。
在他的计数法中,每个数字的位数都是相同的(使用相同个数的字母),英文字母按原先的顺序,排在前面的字母小于排在它后面的字母。
我们把这样的”数字”称为 Jam 数字:每个字母互不相同,而且从左到右是严格递增的。
每次,Jam 还指定使用字母的范围,例如从 2 到 10,表示只能使用 b,c,d,e,f,g,h,i,j 这些字母;
如果再规定位数为 5,那么紧接在 Jam 数字 bdfij 之后的数字应该是 bdghi。
你的任务是:对于给定的一个 Jam 数字,按顺序输出紧接在后面的 5 个 Jam 数字,如果后面没有那么多,有几个就输出几个。
数据范围:
1 ≤ s < t ≤ 26,
2 ≤ w ≤ t - s。
思路:贪心求下一个递增排列
1)普通全排列的下一个
先来看普通的全排列。
全排列是所有 N 个元素全部用上、顺序任意,共 N! 种。
按字典序求下一个的经典算法:
- 从右往左找第一个“升序对”位置 i,满足
a[i] < a[i+1]; - 在 i 右侧找大于
a[i]的最小元素,与a[i]交换; - 把 i 右侧整体反转成升序。
例如 1 3 2:
从右往左看,1 < 3 是升序对;
右侧大于 1 的最小元素是 2,交换得 2 3 1;
右侧 3 1 反转成 1 3;
最终得到 2 1 3。
这就是 std::next_permutation 的实现。
2)N 个不同字母选 M 个的下一个排列
再来看 N 个不同字母,选择 M 个字母,求下个排列。
从 N 个不同字母中选出 M 个的排列,共有 A(N, M) = N! / (N - M)! 种。
求字典序下一个的经典算法:
- 从右往左扫描,维护候选集合:初始是“整个排列没选中的字母”,每扫过一个位置就把该位置的字母并入;
到位置 i 时,集合 = 没选中的后备字母 ∪ 已扫描过的字母,即去掉已确定前缀a[0..i-1]后剩下的所有字母; - 在位置 i,若集合中存在比
a[i]大的字母,取最小者替换a[i],其后位置依次填入集合剩余字母中最小的若干个(升序),即为下一个排列; - 若所有位置都不满足,说明已经是最后一个排列。
例如从 1..4 中选 3 个:
1 2 3
→ 1 2 4
→ 1 3 2
→ 1 3 4
→ 1 4 2
→ 1 4 3
→ 2 1 3
→ ...
当 N = M 时,这一步就退化为第 1 步的全排列。
3)本题:选 w 个且严格递增
最后来看这道题,选择的 M 个字母需要严格递增,求下个排列。
本题与第 2 步不同:从 [s, t](共 N=t-s+1 个字母)中选 M=w 个时,要求从左到右严格递增。
也就是说,每个组合只保留升序这一种表示,总数从 A(N, M) 变成 C(N, M)。
Jam 数字是长度为 w 的严格递增字母串,求字典序中下一个的算法如下。
- 从右往左找第一个“还能增大”的位置 w:
设当前位置字母序号为 c,严格递增约束下,候选字母只能是比 c 大的字母,即(c, T]区间,共leftChar = T - c个;
候选数量必须足够填满剩余位置:剩余位置数leftPos = W - w≤ 候选字母数leftChar; - 取候选中最小的字母(即
c + 1)替换str[w],其后位置依次填入剩余候选中最小的那些,即str[i] = str[i - 1] + 1,得到下一个 Jam 数字; - 若所有位置都不满足,说明已经是最后一个 Jam 数字。
重复这个过程 5 次即可。
例如样例:
bdfij
→ bdghi
→ bdghj
→ bdgij
→ bdhij
→ befgh
复杂度:每次 O(W)。
int S, T, W;
char str[30];
bool Next(int w) {
int leftPos = W - w; // [w, W) 还需填的位置数
int c = str[w] - 'a' + 1; // 当前位置字母序号
int leftChar = T - c; // (c, T] 可用的字母数
return leftPos <= leftChar;
}
bool Next() { // 求下一个 Jam 数字
for (int w = W - 1; w >= 0; w--) {
if (Next(w)) {
str[w]++;
for (int i = w + 1; i < W; i++) {
str[i] = str[i - 1] + 1;
}
return true;
}
}
return false;
}
void Solver() {
scanf("%d%d%d", &S, &T, &W);
scanf("%s", str);
int cnt = 5;
while (cnt && Next()) {
printf("%s\n", str);
cnt--;
}
}
四、数列
题意:给定一个正整数 k,把所有 k 的方幂及所有有限个互不相等的 k 的方幂之和构成一个递增的序列。
例如当 k = 3 时,这个序列是 1, 3, 4, 9, 10, 12, 13, …(即 3^0, 3^1, 3^0+3^1, 3^2, 3^0+3^2, 3^1+3^2, 3^0+3^1+3^2, …)。
请你求出这个序列的第 N 项的值,用 10 进制数表示。
数据范围:3 ≤ k ≤ 15,10 ≤ N ≤ 1000。
思路:二进制转 k 进制
规律:序列的每一项都对应一个”子集”——选择哪些 k 的方幂求和。
下面从 k = 2 开始,一步步推出:第 N 项就是把 N 写成二进制,按位累加 k 的对应次幂。
1)先看 k = 2:第 N 项就是 N
k = 2 时,方幂是 2^0, 2^1, 2^2, …,即 1, 2, 4, 8, …。
序列就是:1, 2, 3, 4, 5, 6, 7, …(所有正整数)。
为什么?每个方幂只有”选”与”不选”两种状态:第 i 个方幂选不选,恰好对应二进制第 i 位是 1 还是 0。
所以任意”互不相等的 2 的方幂之和” = 某个二进制数的展开值,反过来也成立,两者一一对应。
按和从小到大排列,就是按二进制数从小到大排列,因此第 N 项就是第 N 个二进制数,即 N 本身。
2)再看 k = 3:还是用 N 的二进制
k = 3 时,方幂是 3^0, 3^1, 3^2, …,即 1, 3, 9, 27, …。
每个方幂依然是”选”与”不选”两种状态,所以依然与二进制位一一对应;
唯一区别:选中的第 i 位,贡献从 2^i 变成 3^i。
因此第 N 项 = N 的二进制展开,按位乘 3^i 求和:
ans = Σ bit_i(N) * 3^i。
为什么序列递增?
对两个下标 A < B,设最高的不同二进制位为 i(A 的第 i 位为 0、B 的第 i 位为 1)。
A 的低 i 位全选时和最大,为 1 + 3 + ... + 3^(i-1) = (3^i - 1) / 2,仍小于 3^i;
而 B 的第 i 位就贡献 3^i,故 A 的和 < B 的和。
3)任意 k 都可以用 N 的二进制
一般化:任意正整数 k,方幂 k^0, k^1, k^2, … 互不相等,每个方幂依然只有”选”与”不选”两种状态,与二进制位一一对应。
所以第 N 项 = N 的二进制展开,按位乘 k^i 求和:
ans = Σ bit_i(N) * k^i。
递增性同理:对 A < B,设最高的不同二进制位为 i,则 A 的和 ≤ 1 + k + ... + k^(i-1) = (k^i - 1) / (k - 1) < k^i,
而 B 的和 ≥ k^i,故 A 的和 < B 的和。
因此按下标从小到大排列等价于按和的值从小到大排列,任意 k 都成立。
这道题有两个正确解法,都介绍如下。
解法一:__int128(推荐)
N ≤ 1000 < 2^10,所以二进制最多 10 位,只用得到 k^9。
k ≤ 15 时 k^9 = 15^9 ≈ 3.8×10^10,总和不超过 (15^10 - 1) / 14 ≈ 4.1×10^10,__int128 不会越界。
实现:base 从 1 开始表示 k^i,每次看 N 的最低位,为 1 就把 base 累加到答案,然后 base 乘 k、N 右移一位。
复杂度:O(log N)。
typedef __int128_t BigNum;
string ToString(BigNum num) {
if (num == 0) return "0";
string res;
while (num > 0) {
res += (char)('0' + (num % 10));
num /= 10;
}
reverse(res.begin(), res.end());
return res;
}
void Solver() {
int K, N;
scanf("%d%d", &K, &N);
BigNum ans(0);
BigNum base(1);
while (N) {
if (N & 1) {
ans += base;
}
base = base * K;
N >>= 1;
}
printf("%s\n", ToString(ans).c_str());
}
解法二:BigNum 大整数
数据范围如果更大一些,就只能使用大整数来做了。
核心逻辑与解法一完全一致,只是把 ans 与 base 换成大整数类型即可。
复杂度同样是 O(B log N),其中 B 是大整数的位数。
void Solver() {
int K, N;
scanf("%d%d", &K, &N);
BigNum ans(0);
BigNum base(1);
while (N) {
if (N & 1) {
ans += base;
}
base = base * K;
N >>= 1;
}
printf("%s\n", ans.ToString().c_str());
}
大整数模板如下,只需要实现加法与乘法:
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& Smp() { // 去掉高位前导 0
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 < (int)data.size(); i++) {
int carry = 0, pos = i;
for (int j = 0; j < (int)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();
}
BigNum& operator+=(const BigNum& other) {
data.resize(max(data.size(), other.data.size()) + 1, 0);
int carry = 0;
for (size_t i = 0; i < data.size(); i++) {
carry += data[i];
if (i < other.data.size()) carry += other.data[i];
data[i] = carry % 10;
carry /= 10;
}
return Smp();
}
string ToString() const {
string res;
for (int i = (int)data.size() - 1; i >= 0; i--) {
res.push_back(data[i] + '0');
}
return res;
}
};
五、最后
2006 年普及组的比赛整体难度适中。
第一题,签到题,排序加去重即可。
第二题,经典 01 背包,把”价格×重要度”直接当成价值是关键,一维数组倒序枚举容量。
第三题,贪心求下一个严格递增排列,从右往左找第一个能增大的位置,剩余位置依次递增填充即可。
第四题,识别出”二进制下标转 k 进制求和”是关键:第 N 项就是把 N 的二进制位展开,逐位乘 k 的对应次幂再求和;这道题的数据范围不大,可以使用 __int128 水过去。
《完》
-EOF-
本文公众号:天空的代码世界
个人微信号:tiankonguse
公众号 ID:tiankonguse-code
本文首发于公众号:天空的代码世界,微信号:tiankonguse
如果你想留言,可以在微信里面关注公众号进行留言。
