这是「笔试强训」系列的第 06 周。这一周我们把镜头拉得更高一点:从 Day31 一路走到 Day36,每天三道题,一共整整 18 道。你仔细扫一遍不难发现,本周所有的题都不追求"偏难怪",而是扎扎实实地考贪心思路的正确性、动态规划的建模功力、以及模拟题对细节的敏感度——堆取最小、01 背包凑和、区间贪心、线性 DP 双数组维护正负、滑动窗口、记忆化搜索、哈夫曼建树、编辑距离……每一道都是笔试现场的高频面孔。
我处理这篇文章的方式和之前一样,但对"真实"的要求更高了一格:下面每一道题,我都先用本机 g++(15.2.0,-std=c++17)把代码编译并通过实际输入验证过一遍,代码块里展示的就是我真实运行得到的结果,不是"我想当然应该输出什么"。这样你复制到本地跑,拿到的一定和文章里一模一样。
说明:个别题(合唱团、矩阵最长递增路径、字符串的排列)在 OJ 上给的是
Solution类原板子,我在文中会补一个演示用的main让你能独立编译运行,算法主体一字未改。带Solution类的题,你交题时只交类体即可。
Day31:堆与贪心 · 贪心分情况讨论 · 01 背包
第一天的三道题,恰好把"贪心"的两张国脸都露了出来:一种是用堆来每次取最值(口罩),一种是要你仔细分情况讨论(春游);而最后一道数位染色,则是把"01 背包可行性"套了一层数学外壳。别小看这套组合拳,笔试里"看出该用堆""分得清讨论分支""把背包包装成判断题"正是拉开差距的三处。
第 1 题:小红的口罩(贪心 + 堆 · 题号 2283787)
题干
小红有 n 种可以制作口罩的原料,第 i 种原料在当前位置每次生产一个口罩的成本为 x_i。每次用某种原料加工一次后,这种原料的成本会翻倍(即从 x 变成 2x)再放回去,下次再用就要按新成本算。小红手里的总预算为 k,请你帮她算算:在累计成本不超过 k 的前提下,最多能做多少个口罩。(每次当然都选当前成本最低的那种原料来做。)
思路
核心就一句话:每次都取当前成本最小的方案来做,并让它翻倍后重新参与竞争。这一"取最小、翻倍、回堆"的动作,恰好是小根堆(priority_queue<int, vector<int>, greater<>>)一天的工作量。
注意一个容易栽的细节:代码里是先 sum += t、count++,再检查 if(sum > k)。也就是说当累计成本刚突破 k 的那一次,其实"这一件"已经超预算做不成了,所以答案要输出 count - 1。题目要求"不超过 k",突破的那一次得退回去。
代码(C++,可直接编译运行)
#include <iostream>
#include <queue>
using namespace std;
int n, k;
int main()
{
cin >> n >> k; // n 种原料,总预算 k
priority_queue<int, vector<int>, greater<int>> heap; // 小根堆:每次取成本最低的方案
for(int i = 0; i < n; i++)
{
int x;
cin >> x; // 每种原料当前做 1 个口罩的成本
heap.push(x);
}
int sum = 0, count = 0; // 累计成本、已做口罩数
while(true)
{
int t = heap.top(); // 取当前成本最低的方案
heap.pop();
sum += t; // 花掉这份成本,做出 1 个口罩
heap.push(t * 2); // 该方案下次成本翻倍,重新入堆
count++; // 口罩计数 +1
if(sum > k) // 累计成本刚突破预算
{
cout << count - 1 << endl; // 突破的那一次没真正做成,故减 1
break;
}
}
return 0;
}运行结果(输入)
3 30
2 4 6
(输出)
5
详解
把过程完整走一遍:初始堆 {2, 4, 6}。
| 步 | 取出的最小值 | 累计成本 | 翻倍后回堆 | 口罩数 |
|---|---|---|---|---|
| 1 | 2 | 2 | {4, 4, 6} | 1 |
| 2 | 4 | 6 | {4, 6, 8} | 2 |
| 3 | 4 | 10 | {6, 8, 8} | 3 |
| 4 | 6 | 16 | {8, 8, 12} | 4 |
| 5 | 8 | 24 | {8, 12, 16} | 5 |
| 6 | 8 | 32 | —— | 6,但 32 > 30 |
第 6 次取出最小值 8,累计到达 32 已经突破 30,所以这次不算,真正做成了 5 个口罩。输出 5。
易错点:一是"突破后要减一"别漏;二是小根堆的声明要写 greater<int>,写成默认的大根堆(每次取最大)方向就反了。本题时间复杂度 O((n + len)logn),空间 O(n)。
第 2 题:春游(贪心 · 分情况讨论 · 题号 1389158)
题干
有 n 个人要去春游,河边停着两种船:双人船可以坐 2 人,租金 a 元;三人船可以坐 3 人,租金 b 元。小船可以坐不满(即允许空位)。请计算把这 n 个人全部送去划船的最小总租金。有 t 组数据,每组给出 n, a, b。
思路
这是经典的"性价比 + 分情况"贪心。先看单位容量:3 艘双人船载 6 人花 3a,2 艘三人船同样载 6 人花 2b。所以:
- 若
3a < 2b,说明双人船性价比高,就尽量用双人船:先坐n/2艘双人船,剩 0 或 1 人;只剩 1 人时再单独处理。 - 否则三人船更划算,就尽量用三人船:先坐
n/3艘,剩下0、1、2人再分别处理。
难点都在"剩下几个人的收尾"上,因为墨守成规可能亏。代码里聪明地用了 min(min(a,b), ...) 来同时考虑"补租一艘"与"倒换船型"两种方案。具体分支看注释。
代码(C++,可直接编译运行)
#include <iostream>
using namespace std;
typedef long long LL;
LL t;
LL n, a, b;
LL fun()
{
// 边界情况:人特别少(<=2),一条船就能全坐,取两种船便宜的那个
if(n <= 2) return min(a, b);
LL ret = 0;
if(a * 3 < b * 2) // 双人船性价比更高 -> 优先双人船
{
ret += n / 2 * a; // 装满的每对坐双人船
n %= 2; // 看还剩 0 还是 1 人
if(n) // 剩 1 人
{
// 两个选择取小:
// a -> 单独再租一艘双人船(空 1 座)
// b - a -> 把某人从"双人船"换成"三人船"(多 1 座),净多花 b - a
ret += min(min(a, b), b - a); // min(a,b) 包住 b-a 恰是因为 b 可能比 a 还贵/便宜
}
}
else // 三人船性价比更高 -> 优先三人船
{
ret += n / 3 * b; // 装满的每三个坐三人船
n %= 3; // 剩 0 / 1 / 2 人
if(n == 1)
{
// 剩 1 人:单独租船(取 a 或 b),或把一艘三人船换成两艘双人船(多 1 座净花 2a - b)
ret += min(min(a, b), 2 * a - b);
}
if(n == 2)
{
// 剩 2 人:单独租双人船 a,或把一艘三人船换成三艘双人船(净花 3a - b)
ret += min(min(a, b), 3 * a - b);
}
}
return ret;
}
int main()
{
cin >> t;
while(t--)
{
cin >> n >> a >> b;
cout << fun() << endl;
}
return 0;
}运行结果(输入)
5
3 5 4
4 7 5
2 5 7
3 3 10
4 6 9
(输出)
4
10
5
6
12
详解 逐组来算,对应上面五个输出:
n=3, a=5, b=4:3a=15、2b=8,15<8不成立,走三人船分支。3/3*4=4,剩 0 人,总4。n=4, a=7, b=5:走三人船分支。4/3*5=5,剩 1 人;min(min(7,5), 2*7-5)=min(5,9)=5。总5+5=10。n=2, a=5, b=7:n<=2,取min(5,7)=5(一艘双人船)。n=3, a=3, b=10:3a=9、2b=20,9<20成立,走双人船分支。3/2*3=3,剩 1 人;min(min(3,10), 10-3)=min(3,7)=3。总6(两艘双人船载 3 人,空 1 座)。n=4, a=6, b=9:3a=18、2b=18,18<18不成立,走三人船分支。4/3*9=9,剩 1 人;min(min(6,9), 2*6-9)=min(6,3)=3。总12。
易错点:别把 3a < 2b 笔误写成 a < b(那是排他性判断,不是性价比);n<=2 的边界要单独摘出来,否则 n=1 会在 n/2 分支里算错;所有中间结果用 long long,本题常给大到爆 int 的数据。
第 3 题:数位染色(动态规划 · 01 背包 · 题号 1815295)
题干
给定一个正整数 x(位数可能达到 19 位),你可以把它的每一位数字分别染成两种颜色(比如红色/蓝色)。染完之后,要求红色数字之和等于蓝色数字之和。问是否可行?可行输出 Yes,否则输出 No。
思路
把每一位数字看成一个独立的"物品",背包容量就是"某一种颜色需要凑出的和"。设各位数字总和为 S:
- 如果
S是奇数,不可能分成两个相等的整数和,直接No。 - 否则目标就是能否从这些数字中挑出若干个,使它们的和为
S/2(剩下的自然也等于S/2)。这就是01 背包可行性判断:dp[j]表示"能否用挑出的数字凑出和j",最后看dp[S/2]。
标准 01 背包要逆序遍历容量 j,保证每个数字只用一次。dp[0]=true 表示空集凑 0 恒可行。位数最多 19,数字和最大 19*9=171,数组开这点大小绰绰有余。
代码(C++,可直接编译运行)
#include <iostream>
using namespace std;
const int N = 20, M = N * 9; // 最多 20 位,每位最大 9,故数字和上限 M
long long x;
int n, sum; // n = 位数,sum = 所有位数字之和
int arr[N]; // 逐位存下来的数字
bool dp[M]; // dp[j]: 能否用已选数字凑出和 j
bool fun()
{
if(sum % 2 == 1) return false; // 奇数,必不可能均分,直接判 No
sum /= 2; // 目标:凑出总和的一半
dp[0] = true; // 空集:什么也不选,和恰为 0
for(int i = 0; i < n; i++) // 枚举每一位(一个物品)
{
for(int j = sum; j >= arr[i]; j--) // 01背包:容量必须倒序遍历
{
dp[j] = dp[j] || dp[j - arr[i]]; // 不选 或 选这一位构成转移
}
}
return dp[sum]; // 看目标值是否可达
}
int main()
{
cin >> x;
while(x) // 逐位拆解(倒着拆,因求和与顺序无关)
{
arr[n++] = x % 10; // 取出最低位
sum += x % 10; // 累加数字和
x /= 10; // 去掉最低位
}
if(fun()) cout << "Yes" << endl;
else cout << "No" << endl;
return 0;
}运行结果(输入一)
123
(输出一)
Yes
(输入二)
435
(输出二)
No
详解
- 输入
123:拆位得{3, 2, 1},sum=6,一半为3。能凑出 3 吗?能(直接选数字3,或1+2)。故Yes。 - 输入
435:拆位得{5, 3, 4},sum=12,一半为6。5只能配出 5;5+3=8、5+4=9、3+4=7,没有一个等于 6。故No。
易错点:01 背包的容量循环必须倒序,否则同一数字会被重复使用(退化成完全背包),甚至 123 都可能误判。数字和用 int 即可(最大 171),但读入的 x 必须用 long long,否则 19 位数直接溢出。
Day32:模拟 + 素数判断 · 区间贪心 · 线性 DP
第二天的三道题开始上强度:素数回文考"拼接 + 素数判定",活动安排是区间贪心最经典的入口,而合唱团则是笔试线性 DP 的一道硬骨头——难点在于乘积里出现负数时,正负要分开维护。
第 1 题:素数回文(模拟 + 数学 · 题号 140152)
题干
给定一个只含数字的字符串 s,请你把它变成一个"回文串":保留最后一个数字不变,把前面的所有数字(从倒数第二个开始)按顺序镜像地接到后面。例如 "16" 变成 "161","31" 变成 "313"。然后把这个回文串看成一个整数,判断它是不是素数。是输出 prime,否则输出 noprime。
思路
分两步,都是基础功:第一步拼接构造回文,第二步素数判定。拼回文时注意镜像的顺序——是把"除最后一位外的所有字符按原顺序"再贴一遍;判断素数用经典的 sqrt 范围内试除,x <= 1 不是素数。原始数字可能长达十位,拼接后可能超过 int,所以用 long long。
代码(C++,可直接编译运行)
#include <iostream>
#include <cmath>
#include <string>
using namespace std;
long long change(string s)
{
// i 从倒数第二位开始,一直镜像到第一位,逐个往后贴
for(int i = s.size() - 2; i >= 0; i--)
{
s += s[i]; // 把前面的字符接到末尾
}
return stol(s); // 字符串转 long long
}
bool isprim(long long x)
{
if(x <= 1) return false; // 1 及以下不是素数
for(long long i = 2; i <= sqrt(x); i++) // 只需试到 sqrt(x)
{
if(x % i == 0) return false;
}
return true;
}
int main()
{
string s;
cin >> s;
long long x = change(s); // 先拼回文
if(isprim(x)) cout << "prime" << endl;
else cout << "noprime" << endl;
return 0;
}运行结果(输入)
31
(输出)
prime
(附带验证:输入 16 得到 noprime,输入 23 得到 noprime。)
详解
以 "31" 为例:size() 为 2,i 从 0 开始。第一次循环把 s[0]='3' 贴到末尾,得 "313",转成整数 313。试除 2..sqrt(313)≈17.7,均不能整除,是素数,输出 prime。而 "16" -> "161",161 = 7 x 23,是合数。
易错点:拼接循环的初值是 s.size()-2(跳过最后一个),别写错方向;转出的数要用 long long,若第一个字符是较大数字拼接后可能逼近 int 上限;试除到 sqrt 即可,最多十位数,逐位扫要 TLE。
第 2 题:活动安排(贪心 · 区间 · 题号 2373697)
题干
有 n 场活动,第 i 场从 l_i 开始、到 r_i 结束。如果两场活动在时间上重叠(start < 对方 end),它们就安排不到同一个地方。请计算最多能举办多少场互不重叠的活动。
思路
这题给的贪心是:按开始时间排序,再维护一个"当前已选活动集合的右边界 r",一趟扫描:
- 若下一场
start < r,说明它和已选集合冲突,我们不想增加一场,那就把边界收紧为min(r, 它的end)(保留结束更早的,给后续留更多空间); - 若
start >= r,互不冲突,就多选一场,ret++,并更新r为它的结束。
最后答案是 ret + 1(第一场要用掉 1 的计数基准)。读题要分清:有些区间题要求"合并"(求并集),这里要求的是"最多不重叠数量"(求取舍),别混。
代码(C++,可直接编译运行)
#include <iostream>
#include <algorithm>
using namespace std;
typedef pair<int, int> PII; // first=开始,second=结束
const int N = 2e5 + 10;
int n;
PII arr[N];
int main()
{
cin >> n;
for(int i = 0; i < n; i++) cin >> arr[i].first >> arr[i].second;
sort(arr, arr + n); // 按开始时间升序排列
int ret = 0, r = arr[0].second; // 先默认第一场必选,r 为它的结束
for(int i = 1; i < n; i++)
{
if(arr[i].first < r)
// 与当前集合重叠 -> 不新增,缩紧右边界留机会
{
r = min(r, arr[i].second);
}
else
// 不重叠 -> 多选一场,移动到新的结束边界
{
ret++;
r = arr[i].second;
}
}
cout << ret + 1 << endl;
return 0;
}运行结果(输入)
5
1 3
2 5
4 6
6 8
7 9
(输出)
3
详解
排序后为 (1,3),(2,5),(4,6),(6,8),(7,9)。初始 r=3(第一场必选):
(2,5):2<3重叠,r=min(3,5)=3;(4,6):4>=3不重叠,ret=1,r=6;(6,8):6>=6不重叠,ret=2,r=8;(7,9):7<8重叠,r=min(8,9)=8。
得 ret+1=3。手算:可安排的一例是 (1,3),(4,6),(6,8)(首尾相接也算不重叠),正好 3 场。与程序输出一致。
易错点:判断重叠的分界条件是 start < r 而非 <=(端点相接不视为冲突);"不重叠则选"的分支里是 ret++,最后要 +1 补上第一场,不少同学在这里少算了 1。
第 3 题:合唱团(动态规划 · 线性 DP · 题号 2600642)
题干
有 n 个学生站成一排,第 i 个学生的能力值为 a_i。要从中选出 k 个学生组成合唱团,要求任意相邻两个被选中的学生,在原队列中的编号差不超过 d。请问这 k 个学生的能力值乘积最大可能是多少?(能力值可正可负,结果很大)
思路
设 f[i][j] 表示"以第 i 个学生为最后选中的学生、一共选了 j 个时,能力值的最大乘积",g[i][j] 表示同位置下的最小乘积。
为什么要开两个数组存最值?因为能力值可能是负数:当前乘到的最优(最大正数)也可能来自"上一个位置的最小负数 × 当前负数"(负负得正)。所以在状态转移时,对每个候选前驱 prev,都要拿 f[prev][j-1]*a[i] 和 g[prev][j-1]*a[i] 两个候选,同时去更新最大和最小。
前驱范围要满足两个约束:其一,前一个选中的人与 i 的编号差不超过 d,即 prev >= i - d;其二,prev 之前至少要能选出 j-1 个人,即 prev >= j-1。综合即 prev 从 max(i-d, j-1) 到 i-1 枚举。初始化放在填表中做:f[i][1]=g[i][1]=a[i]。
代码(C++,可直接编译运行)
#include <iostream>
using namespace std;
typedef long long LL;
const int N = 55, M = 15;
const LL INF = 0x3f3f3f3f3f3f3f3f; // 一个非常大的 long long
int n, k, d;
LL arr[N]; // 能力值
LL f[N][M]; // f[i][j]: 以 i 结尾选 j 个的最大乘积
LL g[N][M]; // g[i][j]: 以 i 结尾选 j 个的最小乘积
int main()
{
cin >> n;
for(int i = 1; i <= n; i++) cin >> arr[i];
cin >> k >> d;
for(int i = 1; i <= n; i++) // 枚举最后被选中的人 i
{
g[i][1] = f[i][1] = arr[i]; // 只选 1 个:最大=最小=它本身
for(int j = 2; j <= min(i, k); j++) // 一共选 j 个(不能超过 i 和 k)
{
f[i][j] = -INF; // 求最大先初始化为极小
g[i][j] = INF; // 求最小先初始化为极大
for(int prev = max(i - d, j - 1); prev <= i - 1; prev++)
// 上一个选中的人 prev:距离不超过 d,且前面能凑出 j-1 个
{
// 前面的最大/最小,分别乘上当前能力值,取最大更新 f
f[i][j] = max(max(f[prev][j - 1] * arr[i],
g[prev][j - 1] * arr[i]), f[i][j]);
// 同时用最大/最小乘当前值,取最小更新 g
g[i][j] = min(min(f[prev][j - 1] * arr[i],
g[prev][j - 1] * arr[i]), g[i][j]);
}
}
}
LL ret = -INF;
for(int i = k; i <= n; i++) ret = max(ret, f[i][k]); // 最后选中的人可以是 k..n
cout << ret << endl;
return 0;
}运行结果(输入)
6
7 5 9 8 1 9
3 2
(输出)
648
详解
手算校验一轮:选 3 人、相邻编号差不超过 2。穷举满足距离条件的三元组,最大是位置 3, 4, 6:a_3=9, a_4=8, a_6=9,两两距离 4-3=1<=2、6-4=2<=2,乘积 9*8*9=648。其余如 1,3,4 -> 7*9*8=504、1,4,6 -> 7*8*9=504 都不及 648。程序输出 648 一致。
易错点:这是本题最值得记的一课——乘积里有负数就必须同时维护最大和最小两个 DP 值,忘掉 g 表在数据含负数时会得到错误答案;INF 用 0x3f3f3f3f3f3f3f3f(8 字节长整型),别只写 0x3f3f3f3f(那是 int 的量级,赋给 LL 会偏小);枚举前驱时 prev 的下界 max(i-d, j-1) 是双约束,漏掉 j-1 会在 d 很大时把不合法状态卷进来,长度 8*9*9 用的时候注意。复杂度 O(n*k*d),本题 n<=50,轻松通过。
Day33:找规律 · 滑动窗口 · DFS 全排列去重
周三的题突然轻快起来:跳台阶是个数学规律题,包含不超过两种字符的最长子串是滑动窗口的教科书,字符串的排列则是深搜 + 剪枝去重的经典训练。
第 1 题:跳台阶扩展问题(规律 · 题号 2361300)
题干
一只青蛙一次可以跳上 1 级台阶,也可以跳上 2 级,……也可以跳上 n 级。求该青蛙跳上一个 n 级的台阶总共有多少种跳法。
思路
第一眼直觉是"这不就是加法和嘛,用 DP"。但千万别急——这题有个巧妙到离谱的结论。设 f(n) 为跳到第 n 级的方法数。跳到第 n 级可以从第 0, 1, ..., n-1 任意一级一步跳到(因为一次最多能跳 n 级)。于是:
f(n) = f(0) + f(1) + ... + f(n-1)
而 f(n-1) = f(0) + f(1) + ... + f(n-2),两式相减得:
f(n) - f(n-1) = f(n-1) => f(n) = 2 * f(n-1)
又有 f(1)=1,所以 f(n) = 2^(n-1)。一道"看似 DP 实为幂运算"的规律题,这就是它被标成"规律"的原因。
代码(C++,可直接编译运行)
#include <iostream>
using namespace std;
int main()
{
int n;
cin >> n;
cout << (1 << (n - 1)) << endl; // 结论:2^(n-1)
return 0;
}运行结果(输入)
5
(输出)
16
详解
n=5:1 << 4 = 16。手算验证:f(1)=1, f(2)=2, f(3)=4, f(4)=8, f(5)=16,逐项翻倍,正是 2^(n-1)。
易错点:题目数据范围较小时 int 足够,但若 n 很大(比如逼近 1e9),1 << (n-1) 会溢出,需要取模或用 long long;有些题把 n 从 0 编号,起点定义不同公式会差一个平移,先确认 n>=1 再套 2^(n-1)。
第 2 题:包含不超过两种字符的最长子串(滑动窗口 · 题号 2454794)
题干
给定一个只含小写字母的字符串 s,求它的一个最长连续子串,使得该子串中出现的不同字符种数不超过 2。输出这个最长长度。
思路
区间问题里"求满足某个约束的最长连续段",标准武器就是滑动窗口(双指针)。开两个左、右指针维护一个窗口,并保证窗口内不同字符种数 count <= 2:
right每前进一格,把新字符纳入窗口:若窗口里它是第一次出现(计数从 0 变 1),count++;- 只要
count > 2,就不断收缩left:被移出的字符如果计数从 1 变 0,说明这种字符彻底离开窗口,count--; - 每次窗口"合法"时用
right - left + 1更新答案,right继续右移。
用 hash[26] 记录窗口内每个字符出现次数,count 记不同字符种数,正好把"种数"这个约束压缩成一趟线性扫描。
代码(C++,可直接编译运行)
#include <iostream>
#include <string>
#include <algorithm>
using namespace std;
int main()
{
string s;
cin >> s;
int left = 0, right = 0, n = s.size();
int hash[26] = { 0 }; // 窗口内每种字母出现的次数
int count = 0; // 窗口内一共有多少种不同的字母
int ret = 0;
while(right < n)
{
// 新字符若第一次进入窗口(0 -> 1),窗口内的种数 +1
if(hash[s[right] - 'a']++ == 0) count++;
// 种数超了 2,收缩窗口
while(count > 2)
{
// 被移出的字符若从 1 -> 0,说明这种字母彻底离开,种数 -1
if(hash[s[left++] - 'a']-- == 1) count--;
}
// 此时窗口合法,尝试更新答案
ret = max(ret, right - left + 1);
right++;
}
cout << ret << endl;
return 0;
}运行结果(输入一)
abccd
(输出一)
3
(输入二)
cbba
(输出二)
3
详解
"abccd":能容纳最多 2 种字符的最长段,比如"bcc"(b、c 两种,长 3)或"ccd"(c、d 两种,长 3)。不存在更长的符合条件段,故最长 3。"cbba":"cbb"(c、b 两种,长 3)或"bba"(b、a 两种,长 3),最长 3。
易错点:判断"进入窗口"用"计数从 0 变 1",判断"离开窗口"用"计数从 1 变 0",这两个判断各自对应 count 的增减,方向别写反;收缩是 while 而非 if(可能连缩多个);更新答案要放在 right++ 之前,否则漏算包含最后一个字符的窗口。
第 3 题:字符串的排列(DFS 枚举 · 剪枝 · 题号 23291)
题干
输入一个字符串 str(可能含重复字符),按字典序返回它的所有不同排列(不含重复项)。例如 "aab" 返回 ["aab", "aba", "baa"]。
思路
深搜去填每一个位置,再用 vis 标记已用字符。难点在于去重:同一层的相同字符只能被填到同一位置一次,否则 "aab" 这类输入会产出大量重复排列。去重的经典剪枝是——在递归前先把字符排序好,然后在 for 里跳过"与前一个字符相同且前一个还没被用过"的候选:
if(i > 0 && s[i] == s[i - 1] && !vis[i - 1]) continue;这样能保证:相同字符内部,只有"最靠前的那个"开头的分支被展开,其余相同者不再独立成枝。配合 path 记录路径、vis 标记使用,叶子处收集结果即得去重后的全排列,而排序又保证了输出天然字典序。
代码(C++,可直接编译运行)
#include <iostream>
#include <vector>
#include <string>
#include <algorithm>
using namespace std;
class Solution {
public:
vector<string> ret; // 收集所有排列(叶子节点)
string path; // 记录当前递归路径
bool vis[11] = { 0 }; // 标记某个位置的字符是否用过
int n;
string s;
vector<string> Permutation(string str)
{
n = str.size();
sort(str.begin(), str.end()); // 先排序,便于相邻去重 + 保证字典序
s = str;
dfs(0);
return ret;
}
void dfs(int pos)
{
if(pos == n) // 所有位置填满,收集一个排列
{
ret.push_back(path);
return;
}
for(int i = 0; i < n; i++) // 枚举填 pos 位置用哪个字符
{
if(!vis[i])
{
// 去重剪枝:与前一位字符相同、而前一个还没用,说明是同一字符的首枝,跳过
if(i > 0 && s[i] == s[i - 1] && !vis[i - 1]) continue;
path.push_back(s[i]); // 选择
vis[i] = true;
dfs(pos + 1); // 填下一个位置
vis[i] = false; // 恢复现场(回溯)
path.pop_back();
}
}
}
};
int main() // 本地演示用 main,OJ 上交时只需提交 Solution 类
{
string str;
cin >> str;
Solution sol;
for(auto &e : sol.Permutation(str)) cout << e << endl;
return 0;
}运行结果(输入)
aab
(输出)
aab
aba
baa
详解
"aab" 排序后仍为 "aab"。深搜填第一个位置时,两个 'a' 中只有第一个 a 能作为该位置的候选(第二个 a 因 s[1]==s[0] 且 vis[0]==false 被剪掉),于是以 a 开头只产生"aab、aba"两组,再加上以 b 开头的 baa,共三组,且字典序递增。若去掉这个剪枝,aab 会出现两次,答案就错了。
易错点:去重剪枝的条件 !vis[i-1] 是灵魂——若写成 vis[i-1](前一个已用过时才剪),去重方向就反了,反而会漏掉合法排列;"先排序 + 同字符只取首枝"必须是递归前的预处理,排序还可顺带保证输出字典序;JZ 牛客题这里要求按字典序且不含重复,注意返回值是 vector<string>。
Day34:模拟 · BFS 求多出口 · 记忆化搜索
第四天的题把"搜索"的味道做足了:ISBN 是纯模拟,kotori 的迷宫是 BFS 的一个扩展(统计所有可到达的出口),矩阵最长递增路径则用记忆化搜索把一个朴素 DFS 提升到多项式复杂度。
第 1 题:ISBN 号码(模拟 · 题号 170470)
题干
每一本正式出版的图书都有一个 ISBN 号码。ISBN 码包括 9 位数字、1 位识别码和 3 个分隔符。识别码的计算方法:把前 9 位数字按顺序分别乘以权重 1,2,...,9,求和后再对 11 取模,得到的结果如果是 0..9,识别码就是对应数字;如果是 10,识别码用大写字母 X 表示。现给定一个 ISBN 串,若它的识别码正确,输出 Right;否则输出修正后的正确 ISBN 串。
思路
纯粹模拟,看清楚三点:第一,只把数字位捞出来乘权重,分隔符 - 要跳过;第二,识别码在串的最后一位,可能是一个数字也可能是字母 X;第三,比较时要把 X 对应成 10,而重新修正后若校验和是 10 也要写回 X。权重系数 count 从 1 递增到 9,遍历到数字位才累加,遇到 - 只跳过不加权。
代码(C++,可直接编译运行)
#include <iostream>
#include <string>
using namespace std;
int main()
{
string s;
cin >> s;
int sum = 0, count = 1, n = s.size(); // count 是权重,从 1 到 9
for(int i = 0; i < n - 1; i++) // 前 n-1 位参与计算(最后一位是识别码)
{
if(s[i] >= '0' && s[i] <= '9') // 只对数字位加权
{
sum += (s[i] - '0') * count;
count++; // 每遇到一个数字位,权重 +1
}
// 分隔符 '-' 跳过,不计权
}
sum %= 11; // 对 11 取模得校验值
// 识别码正确的情况:是数字且相等;或是 X(代表 10)且 sum 也为 10
if(sum == s[n - 1] - '0' || (sum == 10 && s[n - 1] == 'X'))
{
cout << "Right" << endl;
}
else
{
// 修正最后一位:sum==10 要写回 'X',否则写回数字字符
s[n - 1] = sum == 10 ? 'X' : sum + '0';
cout << s << endl;
}
return 0;
}运行结果(输入一)
0-670-82162-4
(输出一)
Right
(输入二)
0-670-82162-3
(输出二)
0-670-82162-4
详解
对 0-670-82162-4,可取的 9 个数字位是 0,6,7,0,8,2,1,6,2,分别乘权重 1..9:
0*1 + 6*2 + 7*3 + 0*4 + 8*5 + 2*6 + 1*7 + 6*8 + 2*9
= 0 + 12 + 21 + 0 + 40 + 12 + 7 + 48 + 18 = 158
158 % 11 = 4,正好等于识别码 4,输出 Right。而把识别码改成 3 时校验不匹配,程序把它改回正确的 4 并输出修正串 0-670-82162-4。
易错点:加权循环的范围是 n-1(别把识别码也加权进去);sum == 10 时要写回 X 而不是字符 '10',判断已有识别码是否正确时也要单独照顾 X 情形;分隔符 - 用 >= '0' && <= '9' 过滤掉最省心。
第 2 题:kotori 和迷宫(BFS · 题号 500543)
题干
给定一个 n x m 的迷宫:'k' 表示 kotori 的起点,'e' 表示出口(可能不止一个),'*' 表示墙(不可走),其余字符表示空地。kotori 每步可以上下左右移动一格,不能踩墙。请问:她最多能到达多少个出口?以及到最近的那个出口的最短步数是多少?如果一个出口都到不了,输出 -1。输出格式为"出口数 最短步数"(空格分隔)。
思路
标准的 BFS 扩散,但做了个小扩展:先把到每个格子的步数都算出来,最后再统一统计所有 'e' 格子的步数。一个坑是——当 BFS 碰到出口 e 时,不能把它当作普通点继续扩散(否则会从出口"穿"出去,走上不该走的新区域),所以代码里 if(arr[a][b] != 'e') 才入队。用 dist 数组按经典 BFS 记录最短步数,初始值为 -1(代表不可达),起点为 0。最后扫描全图:凡 dist != -1 的出口都计入,并顺手维护最小步数。
代码(C++,可直接编译运行)
#include <iostream>
#include <cstring>
#include <queue>
using namespace std;
const int N = 35;
int x1, y1; // 标记起点位置
int n, m;
char arr[N][N]; // 迷宫
int dist[N][N]; // 到每个格子的最短步数,-1 表示不可达
queue<pair<int, int>> q;
int dx[4] = {0, 0, 1, -1};
int dy[4] = {1, -1, 0, 0};
void bfs()
{
memset(dist, -1, sizeof dist); // 先全部标为不可达
dist[x1][y1] = 0;
q.push({x1, y1});
while(q.size())
{
auto [x2, y2] = q.front(); // C++17 结构化绑定取队首
q.pop();
for(int i = 0; i < 4; i++)
{
int a = x2 + dx[i], b = y2 + dy[i];
// 在界内、未访问过、且不是墙
if(a >= 1 && a <= n && b >= 1 && b <= m && dist[a][b] == -1 && arr[a][b] != '*')
{
dist[a][b] = dist[x2][y2] + 1;
if(arr[a][b] != 'e') // 出口拿到步数即可,不再向外扩散
{
q.push({a, b});
}
}
}
}
}
int main()
{
cin >> n >> m;
for(int i = 1; i <= n; i++)
{
for(int j = 1; j <= m; j++)
{
cin >> arr[i][j];
if(arr[i][j] == 'k') x1 = i, y1 = j; // 记录起点
}
}
bfs();
int count = 0, ret = 1e9; // count: 可达出口数,ret: 最近出口步数
for(int i = 1; i <= n; i++)
{
for(int j = 1; j <= m; j++)
{
if(arr[i][j] == 'e' && dist[i][j] != -1)
{
count++; // 这个出口可达
ret = min(ret, dist[i][j]);
}
}
}
if(count == 0) cout << -1 << endl;
else cout << count << " " << ret << endl;
return 0;
}运行结果(输入一)
3 3
k..
.*.
..e
(输出一)
1 4
(输入二)
3 3
k..
...
e.e
(输出二)
2 2
详解
- 图一:起点
(1,1),(2,2)是墙。绕行到出口(3,3)需要(1,1)->(1,2)->(1,3)->(2,3)->(3,3),共 4 步,只有一个出口,输出1 4。 - 图二:两个出口
(3,1)和(3,3),均可达。到(3,1)只需往下一路:(1,1)->(2,1)->(3,1),共 2 步;到(3,3)路径更长。故输出可达 2 个、最近 2 步:2 2。
易错点:出口要"到达即止、不再入队",否则 BFS 会从出口继续向外,把墙后或出口之后的格子也标上步数,导致统计或距离出错;统计出口用 dist != -1 过滤掉"被墙围住出不去"的出口;起点 k 可能出现在任何位置,记得记录。
第 3 题:矩阵最长递增路径(记忆化搜索 · 题号 1076860)
题干
给定一个 m x n 的整数矩阵,你可以从任意格子出发,每次向上、下、左、右移动到值比当前格子更大的相邻格子。求最长的"严格递增路径"的长度。
思路
裸 DFS 每个格子朝四个方向走会指数爆炸,但注意到:以某个格子为起点的最长路径长度是固定的、可复用的。于是用 memo[i][j] 记录"从 (i,j) 出发的最长递增路径长度",初始为 -1 表示尚未计算:
dfs(i,j)若已算过(memo != -1)直接返回;- 否则
len = 1(至少自身一格),向四周能到的更大值格子递归取max; - 把
len存进memo返回。
最后对每个格子调用并取最大值。因为"只向更大的值走"天然是 DAG,不存在环,记忆化后每个状态只算一次,复杂度降到 O(m*n)。注意 OJ 板子里的 memo[1010][1010] 有 4MB,本地演示时我把它定义在全局(作为成员放进栈上会爆栈)。
代码(C++,可直接编译运行)
#include <iostream>
#include <vector>
#include <cstring>
using namespace std;
class Solution
{
int m, n;
int dx[4] = {0, 0, 1, -1};
int dy[4] = {1, -1, 0, 0};
int memo[1010][1010]; // memo[i][j]: 从 (i,j) 出发的最长递增路径
int dfs(vector<vector<int> >& matrix, int i, int j)
{
if(memo[i][j] != -1) return memo[i][j]; // 算过就复用
int len = 1; // 至少包含自身一格
for(int k = 0; k < 4; k++)
{
int x = i + dx[k], y = j + dy[k];
// 只走向值更大的相邻格子
if(x >= 0 && x < m && y >= 0 && y < n && matrix[x][y] > matrix[i][j])
{
len = max(len, 1 + dfs(matrix, x, y));
}
}
memo[i][j] = len; // 记录,供后续状态复用
return len;
}
public:
int solve(vector<vector<int> >& matrix)
{
m = matrix.size(), n = matrix[0].size();
memset(memo, -1, sizeof memo); // 全部置为"未计算"
int ret = 1;
for(int i = 0; i < m; i++)
for(int j = 0; j < n; j++)
ret = max(ret, dfs(matrix, i, j)); // 从每个格子出发都试一遍
return ret;
}
};
Solution sol; // memo 数组较大(约4MB),置于全局避免栈溢出
int main() // 本地演示用 main,OJ 上交时只需 Solution 类
{
int r, c;
cin >> r >> c;
vector<vector<int> > mat(r, vector<int>(c));
for(int i = 0; i < r; i++)
for(int j = 0; j < c; j++)
cin >> mat[i][j];
cout << sol.solve(mat) << endl;
return 0;
}运行结果(输入)
3 3
9 9 4
6 6 8
2 1 1
(输出)
4
详解 观察矩阵:
9 9 4
6 6 8
2 1 1
一条长度 4 的严格递增链是 (3,2)=1 -> (3,1)=2 -> (2,1)=6 -> (1,1)=9(值:1,2,6,9)。程序输出 4、与著名例题(LeetCode 329)的答案一致。若想更长,任一方向都得走回已经更小的值,无法满足严格递增,故答案恰为 4。
易错点:判断用 >(严格递增),写成 >= 会把相等路径也计入导致误判;memo 初始值必须能区分"未计算",用 -1 是安全的;只有"向更大值走"才保证无环,若允许相等值则可能出现来回互走。
Day35:找规律模拟 · DFS 枚举组合 · 二维线性 DP
第五天的三道题又回到了"套路检验":奇数位丢弃考你能否从一个数学序列里提炼出"2 的幂"的规律,求和是深搜枚举所有组合,编辑距离则是几乎所有面试都会问到的经典二维 DP。
第 1 题:奇数位丢弃(模拟 · 规律 · 题号 26166)
题干
给定一个 n,把 1,2,...,n 这串数按顺序排成一排。每次操作:从这一排中把位于奇数位置的那个数删掉(位置从 1 开始计数,即每次都删掉当前序列的第 1、第 3、第 5……个),删完后剩下的数再重新按顺序排、继续删奇数位,直到只剩一个数为止。问最后剩下的那个数是多少?
思路
与其真的模拟,不如找规律。用小的 n 手算几轮会发现:每次被删掉的都是"奇数位置",而保留的起始位置总是 2 的整数次幂。推演得到的结论十分简洁:答案是「小于等于 n 的最大的 2^k - 1」。代码用 while(ret - 1 <= n) ret *= 2; 找到第一个让 ret-1 > n 的 ret,那么 ret/2 - 1 就是我们要的"不超过 n 的最大形如 2^k-1 的值"。
代码(C++,可直接编译运行)
#include <iostream>
using namespace std;
int main()
{
int n;
while(cin >> n) // 多组输入,读到文件结束符为止
{
int ret = 1;
// 让 ret 翻倍,直到 ret-1 > n,即 ret/2-1 是不超过 n 的最大(2^k-1)
while(ret - 1 <= n) ret *= 2;
cout << ret / 2 - 1 << endl;
}
return 0;
}运行结果(输入)
7
4
(输出)
7
3
详解
n=7:不超过 7 的最大2^k-1取2^3-1=7,输出7。(完整删一遍确实最后剩下 7。)n=4:不超过 4 的最大2^k-1是2^2-1=3,输出3。
易错点:别试图真的维护动态数组去模拟指数轮删除(n 大时既慢又易错);公式的边界是 ret-1 <= n 这个循环宏条件,写错一个符号得到的就是上一档或下一档的答案;多组输入用 while(cin>>n) 接收。
第 2 题:求和(DFS 枚举 · 题号 10055177)
题干
给定两个正整数 n, m。从 1,2,...,n 中选出若干个数(每个最多选一次),使得选出的数总和恰好等于 m。从小到大输出所有满足条件的选取方案,每种方案占一行,方案内的数按升序排列。没有方案则什么都不输出。
思路
典型的递归型枚举(选 / 不选)。维护一个全局 sum 记录当前已选数的和,choose[] 标记选了哪些数。从 x=1 开始,对每个数做"选"与"不选"两个分支:
- 若
sum == m,把已选且标记为 true 的数按下标顺序输出(因为x是递增枚举的,天然升序); - 若
sum > m或x > n,剪枝回退(已经超了或没数可选了)。
注意分支顺序:先"选",再"不选"。这两条路径用增减 sum、翻转 choose 的"恢复现场"手法切回来,就是回溯的骨架。
代码(C++,可直接编译运行)
#include <iostream>
using namespace std;
int n, m;
bool choose[11]; // choose[i]:路径里是否选了数字 i
int sum; // 当前已选数字之和
void dfs(int x)
{
if(sum == m) // 凑到了目标 m,输出当前方案
{
for(int i = 1; i <= n; i++)
if(choose[i]) cout << i << " ";
cout << endl;
return;
}
if(sum > m || x > n) return; // 剪枝:已超越目标,或无号可选
// 分支一:选 x
sum += x;
choose[x] = true;
dfs(x + 1);
sum -= x; // 恢复现场
choose[x] = false;
// 分支二:不选 x
dfs(x + 1);
}
int main()
{
cin >> n >> m;
dfs(1);
return 0;
}运行结果(输入)
5 5
(输出)
1 4
2 3
5
详解
从 1..5 选若干数凑 5,所有不重复组合(升序):1+4=5、2+3=5、5。程序依次输出三行。注意输出是每行末尾带一个空格(OJ 对这种行尾空格通常友好,若严格要求无尾随空格需自行调整打印),三种方案无遗漏、无重复,与程序输出一致。
易错点:"选"分支递归完必须恢复现场(sum 减回去、choose 置 false),否则会污染其它分支;输出前先判 sum == m 而不是在递归结尾判断,避免漏掉不满 m 才被剪枝的情形;choose 与打印都用 1-based 下标,dfs(1) 从 1 开始枚举。
第 3 题:计算字符串的编辑距离(动态规划 · 题号 36876)
题干
给定两个字符串 a 和 b,可以把 a 通过若干次操作变成 b。每次操作允许三种之一:插入一个字符、删除一个字符、替换一个字符(三者各计一次代价 1)。求把 a 变成 b 所需的最少操作次数(编辑距离)。
思路
二维线性 DP。dp[i][j] 表示把 a 的前 i 个字符变成 b 的前 j 个字符所需的最少操作数。转移看 a[i-1] 与 b[j-1]:
- 若相等,不动即可:
dp[i][j] = dp[i-1][j-1]; - 否则考虑三种操作里取最小再加 1:删除最后一位
dp[i-1][j]、在末尾插入dp[i][j-1]、替换成b[j-1]dp[i-1][j-1]。即dp[i][j] = min(三者) + 1。
边界初始化很关键:dp[0][j] = j(空串变成 b 前 j 个,只能插 j 次),dp[i][0] = i(a 前 i 个变空串,只能删 i 次)。
代码(C++,可直接编译运行)
#include <iostream>
#include <string>
using namespace std;
const int N = 1010;
string a, b;
int dp[N][N];
int main()
{
cin >> a >> b;
int n = a.size(), m = b.size();
// 边界:一个串变成空串
for(int j = 0; j <= m; j++) dp[0][j] = j; // a 空 -> 变 b 前 j 个:只需插入 j 次
for(int i = 0; i <= n; i++) dp[i][0] = i; // a 前 i 个 -> 空:只需删除 i 次
for(int i = 1; i <= n; i++)
{
for(int j = 1; j <= m; j++)
{
if(a[i - 1] == b[j - 1])
dp[i][j] = dp[i - 1][j - 1]; // 末尾相同,不用操作
else
// 删除 / 插入 / 替换 三种取最小,再加一次操作
dp[i][j] = min(min(dp[i - 1][j], dp[i][j - 1]), dp[i - 1][j - 1]) + 1;
}
}
cout << dp[n][m] << endl;
return 0;
}运行结果(输入)
horse
ros
(输出)
3
详解
把 horse 变成 ros 需要 3 次(这是编辑距离的公认经典结论):horse -> hors(删 e)-> orts? 更标准的推法可借助 DP 转移,最终 dp[5][3]=3。要读懂为什么是 3,可以直观想:r、o、s 三个目标字符分别需要一个动作去对齐 h、e 等的差异。程序输出 3 与公认答案一致。复杂度 O(n*m),n,m<=1000 可过。
易错点:下标对齐要小心——字符串下标从 0 起,但 DP 状态从 1 起,所以取字符时写 a[i-1]、b[j-1];三条转移分支的语义要记牢(删对应 dp[i-1][j],插对应 dp[i][j-1],替换对应 dp[i-1][j-1]),写错一个下标结果就受影响;边界两行(dp[0][j]、dp[i][0])必须显式初始化。
Day36:模拟 · 哈夫曼建树 · 巧妙的线性 DP
最后一天的三道题收官:提取不重复的整数是最朴素也容易漏细节的模拟,哈夫曼编码是堆应用的模板级经典,abb 则用一趟线性扫描加两个计数数组玩出了花——值得反复咀嚼。
第 1 题:提取不重复的整数(数学 · 模拟 · 题号 10055187)
题干
输入一个整数,按从右向左(即从个位向高位)的顺序依次读取每一位,并把这个整数的各位数字去重后按该顺序拼接成一个新整数输出(即后出现的重复数字不再输出)。例如输入 9876673,从右读得 3,7,6,6,7,8,9,去掉重复只保留第一次出现的,得到 37689。
思路
思路极简:把数字按字符串读入,从最后一个字符往前遍历每位,用一个 10 长度的 bool 数组记录某个数字是否输出过,没输出过就打出来并标记,出现过就跳过。注意输出顺序是"个位在前",所以直接从尾部往头部走,遇到没见过的就打印。
代码(C++,可直接编译运行)
#include <iostream>
#include <string>
using namespace std;
int main()
{
string s;
cin >> s;
bool hash[10] = { 0 }; // 标记 0~9 是否已经输出过
for(int i = s.size() - 1; i >= 0; i--) // 从个位往高位走
{
int x = s[i] - '0'; // 数字字符转成整数
if(!hash[x]) // 第一次出现
{
cout << x; // 直接输出该数字(不换行)
hash[x] = true; // 标记已输出
}
}
return 0;
}运行结果(输入)
9876673
(输出)
37689
详解
从右往左:3(输出)、7(输出)、6(输出)、6(重复,跳过)、7(重复,跳过)、8(输出)、9(输出)。拼接得到 37689,与程序输出一致。
易错点:循环要从 size()-1 开始向 0 走,别写反(否则得到的是从左去重,顺序完全不同);去重标记用 bool[10] 即可,值域只有 0..9;按字符逐位输出时它们自然拼接成新数,位间不可加空格。
第 2 题:【模板】哈夫曼编码(哈夫曼编码 · 题号 2371724)
题干
给定 n 个字符在一次文本中出现的频次,需要为它们设计一套二进制编码使总编码长度最短(即构造一棵最优二叉树 / 哈夫曼树),要求字符的编码互不为前缀。请你输出这棵哈夫曼树的带权路径长度(WPL),即所有叶子节点的"频次 x 编码长度"之和。
思路
哈夫曼建树的贪心——每次从当前集合中取出两个最小频次的结点合并为一个父结点(频次相加),把合并后的父结点放回集合,重复直到只剩一个结点。合并过程中累积的"频次和"就是整棵树的带权路径长度。用一个小根堆(priority_queue<LL, vector<LL>, greater<>>)维护"当前所有结点频次",每次 pop 两个最小的,push 它们的和,并把和加到答案上,直到堆里只剩一个。用 long long 防累加溢出。
代码(C++,可直接编译运行)
#include <iostream>
#include <queue>
#include <vector>
using namespace std;
typedef long long LL;
int main()
{
int n;
cin >> n;
priority_queue<LL, vector<LL>, greater<LL>> heap; // 小根堆,存各个结点频次
while(n--) // 读入全部频次
{
LL x;
cin >> x;
heap.push(x);
}
LL ret = 0;
while(heap.size() > 1) // 只剩一个结点时结束(那是根)
{
LL t1 = heap.top(); heap.pop(); // 最小
LL t2 = heap.top(); heap.pop(); // 次小
heap.push(t1 + t2); // 合并为新结点放回
ret += t1 + t2; // 累加即为带权路径长度增量
}
cout << ret << endl;
return 0;
}运行结果(输入)
4
1 2 5 8
(输出)
27
详解 合并过程:
- 取
1、2:和3入堆,ret=3;堆{3, 5, 8}; - 取
3、5:和8入堆,ret=3+8=11;堆{8, 8}; - 取
8、8:和16入堆,ret=11+16=27;堆{16}。
ret=27,即带权路径长度最小值为 27。与程序输出一致。
易错点:合并条件是 size()>1(叶子不足两个就无法再合并);heap.top() 后要立刻 pop() 再取第二个最小值,否则二次取到同一个结点;累加用 long long,n 大、权值大时 int 会溢出;greater<LL> 别落下,否则变成大根堆方向全反。
第 3 题:abb(动态规划 · 题号 1831946)
题干
给定一个长度为 n、仅含小写字母的字符串 s。请统计满足如下条件的"子序列"个数:存在下标 i < j < k,使得 s[i] 与 s[j]、s[k] 不同,而 s[j] == s[k]。也就是形如 a-b-b 的三元组(其中 a 与两个 b 不同),数一数总共有多少个。答案可能很大,用 64 位整数输出。
思路
这是道很漂亮的线性 DP。从左到右扫字符串,对每个字符 x(若它作为三元组中的第三个字符),需要知道"前面已经形成多少个 (a, x) 这样一对、且 a != x"的候选对。为此维护两个数组:
g[c]:到目前为止,字符c已经出现的次数;f[c]:到目前为止,以c作为"对中后一个(第二个)字符、且前导字符 != c"的对数。
扫描到字符 x = s[i] 时:
ret += f[x]:当前字符作为第三个b,与之前已有的所有(a, x)对(a != x)都组成一个(a, x, x)三元组;- 再更新
f[x] += i - g[x]:i - g[x]是当前位置之前不同于x的字符个数(i是当前下标,g[x]是此前已有的 x 个数),把它们与当前这个 x 配成新对; g[x]++:把当前字符计入出现次数。
一趟扫完,ret 就是答案。关键是 f 的更新要在贡献 ret 之后,保证"当前这次新配出的对"不会立刻又把自己当成第三个 b 用掉。
代码(C++,可直接编译运行)
#include <iostream>
using namespace std;
typedef long long LL;
const int N = 1e5 + 10;
int n;
char s[N];
LL f[26]; // 已形成的 (不同前导字符, x) 对数
LL g[26]; // 字符 x 已出现的次数
int main()
{
cin >> n >> s;
LL ret = 0;
for(int i = 0; i < n; i++)
{
int x = s[i] - 'a';
ret += f[x]; // 当前字符作为第三个 b:前面所有 (a, x) 对均可成三元组
f[x] = f[x] + i - g[x]; // 前面与 x 不同的字符个数为 i - g[x],都与当前 x 配成新对
g[x] = g[x] + 1; // 当前这个 x 计入出现次数
}
cout << ret << endl;
return 0;
}运行结果(输入)
4
abbb
(输出)
3
详解
输入 "abbb"(n=4):
i=0, x='a':ret+=0;f[a]+=0-0=0;g[a]=1。i=1, x='b':ret+=0;f[b]+=1-0=1(前面非 b 的字符只有 a,配成(a,b));g[b]=1。i=2, x='b':ret+=1(前面的(a,b)与当前第二个 b 组成(a,b,b),计 1);f[b]+=2-1=1;g[b]=2。i=3, x='b':ret+=2(此时f[b]=2,加 2);f[b]+=3-2=1;g[b]=3。
ret = 0 + 1 + 2 = 3。手算:从四个下标里选 i<j<k 且 s[i] != s[j] == s[k],i 必须为 0(字符 a),从后三个 b 里任选两个作为 j、k,C(3,2) = 3 个组合。与输出一致。
易错点:贡献 ret 必须发生在更新 f[x] 之前,顺序反过来会自我复用导致多数;下标 i 是 0-based,i - g[x] 恰等于当前字符之前非 x 的字符个数,别按 1-based 想导致差一;数组用 long long,n 到 1e5、全同字符时 ret 会达到组合数级,int 必溢出。
本周小结与查漏补缺
一路写到这里,18 道题的运行结果都是我在本机 g++(15.2.0,-std=c++17)上真实编译运行得到的,你可以放心照着验证。回头把这六天串起来看,能凝练出四条最值得带进考场的经验:
- 贪心先问"每次取哪个最划算":口罩是"最小堆取最小",春游是"性价比比较 + 收尾分情况",活动安排是"谁更早结束留空间"。先想清楚贪心选择,再谈排序或堆。
- DP 三问:状态、转移、边界:数位染色的 01 背包要倒序防重复;合唱团因负数而双数组维护最大/最小;编辑距离处理好两行边界;abb 用前缀计数把三维枚举线性化。状态定义对了,转移就是填表。
- 搜索题的扩展点:kotori 迷宫是"BFS 出多出口 + 统计个数",矩阵递增路径用记忆化去重复计算,字符串排列用"排序 + 同字符去重剪枝"。
- 模拟题考的是细心:ISBN 的 X、提取不重复整数的从右往左、素数回文的 long long、奇数位丢弃的 2 的幂边界,任何一个细节没拿捏稳都可能爆零。
下周我们会进入难度更高的综合题阶段,届时这几天的"贪心选对 + DP 建对 + 搜索剪好"会不断在更长的题干里反复出现。如果你在本地跑的时候有任何一道的结果和文章对不上,优先回去检查注释里提示的那几个易错点——往往答案就藏在其中一行。
来源信息:本周题目均来自公开笔试/刷题库(牛客网、洛谷等),题号已在各题题干处标注,可据此回到原题核对数据范围与输入输出格式。文中代码仅供学习交流使用。
还没有评论 — 第一条由你来留。