阅读视图

发现新文章,点击刷新页面。

GESP 202506 5级真题「奖品兑换」题解

题目描述

分析

此题首先是不能暴力枚举的,因为 n 和 m 最大情况下是 10^9,这个数据规模,暴力枚举肯定会超时。

然后我们可能想到贪心,但实际可落地的贪心的策略总是有特殊情况。

最后,假如我们可以检查一个答案是否可行,我们就可以用二分答案+判定的方法求解。

二分还有一个要求,就是答案是单调递增的。我们可以想像,随着兑换券的递增,如果限定 n 的值不变,那 m 的值肯定是递增的。所以此题符合单调递增的条件。

解法

那么,对于一个可能的答案 k,我们怎么检查答案是否可行呢?

  • 我们先把 n 和 m 排序,让 n 是较大者,a 和 b 排序,让 a 是较大者
  • 对于一份奖品,可以是 n-a, m-b 来获得,也可以是 n-b, m-a 来获得,我们让 d=a-b
  • 因为 a 是较大者,所以当更换兑换方式的时候,n 的值从n-a变成了n-b,相对来说,增加了 d,m 的值减少了 d

所以:

  • 我们可以先用第一个兑换方法,把 k 个奖品换成 c1=a*k 张课堂优秀券, c2=b*k 张作业优秀券
  • 如果 c1 <=n, c2 <= m 那这个答案 k 显然就是可以的。
  • 但如果 c1 > n,我们可以想到,把超额出来的兑换换成第二个兑换方法

具体如何换呢?

  • 我们先计算超额的值,为 c1-n
  • 每次兑换可以让这个值少 d,所以需要换 r=(c1-n)/d (向上取整)r=(c1-n+d-1)/d
  • 经过如上的兑换,c1 的值减少了 d*r,c2 的值增加了 d*r

最后需要注意,因为 a*k 的范围可能超过 int,所以需要把计算过程都用 long long 来保存。

总结

此题考查了:

  • 二分+判定的解法
  • 向上取整的写法
  • 数据范围的预估
  • 时间复杂度的预估

这还是非常综合的一道题。对于没想到二分的学生,也可以用贪心或者暴力枚举骗到不少分(估计 10-15 分),所以此题也有相当的区分度,各种能力的学生都可以拿到部分分数。

详细代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
/*
* Author: Tang Qiao
*/
#include <bits/stdc++.h>
using namespace std;

long long n, m, a, b, d, ans;

bool test(long long k) {
long long c1 = a*k;
long long c2 = b*k;
if (c1 > n) {
long long r = (c1 - n + d - 1) / d;
c1 -= r*d;
c2 += r*d;
}
if (c1 <= n && c2 <=m) return true;
else return false;
}

int main() {
ios::sync_with_stdio(0);
cin >> n >> m >> a >> b;
if (n < m) swap(n, m);
if (a < b) swap(a, b);
d = a - b;
long long l = 0;
long long r = n;
while (l <= r) {
long long m = (l+r)/2;
if (test(m)) {
ans = max(ans, m);
l = m+1;
} else {
r = m-1;
}
}
cout << ans << endl;
return 0;
}

构建你的“多巴胺”系统

什么是“多巴胺”系统

“多巴胺”系统是一种隐喻,是指能够给你带来持续正反馈/正向情绪的事情。之所以用这个隐喻,一方面是想让大家更容易理解、记忆和传播这个系统。

这个系统对我来说非常重要,它就相当于我人生的“第一性原理”一样。人类看起来是自己的主人,但人类对自身行为动机的理解很多时候并不清楚。

马斯洛把人类的需求按层次来分,在他的理论中提到的各种需求:性,安全,食物,社交,自我实现等等。但是其实,这些其实本质上,都是在为人类提供“多巴胺”。

当人类失去了“多巴胺”系统,很多时候就宁愿放弃生命:比如在战争中,很多人为了信仰而牺牲自己。这是因为他内心的目标大于活着的意义。

在实际生活中,虽然不至于放弃生命,但冒着生命危险做的事情,也不鲜见。比如消防队员救人、警察和歹徒搏斗、或者体育健儿在赛场上带伤为荣誉而战。

这些行为虽然有可能失去生命,但是换来的荣誉与成就是非常让人自豪的,可以为自己提供终身的多巴胺来源。

有人说,这个世界上只有两种生意:让人爽的生意和让人牛逼(学习、健身等)的生意。但我觉得,这都是多巴胺的生意,差别只是一个是提供短期多巴胺,一个是提供长期多巴胺。学习这种事情虽然短期很辛苦,但是收获的成就是可以提供长期的回报,从而提供长期的多巴胺。

为什么“多巴胺”系统很重要

1、人对生活的意义有需求

看看全世界有多少人信教就明白了。大部分人都需要精神上为生命的存在赋予意义。意义感会驱使人们面对挑战和困难、提供情绪支撑、获得幸福感。

在中国,很少有人信教,但是我们每一个普通人也有自己对生命的追求,哪怕是更好一点的生活,或者一个遥不可及的理想,又或者是简单地照顾好家人和孩子。

人生的目标带动着每一个人在各种重大决策的十字路口上做选择。韩寒为了赛车辍学;赵心童为了台球远赴英国;崔永远为了自由表达离开了央视;而我身边,一个亲人为了更好的照顾孩子而放弃了工作上的晋升机会。

“多巴胺”系统就是为人生的意义提供基础能量的仓库,守护好多巴胺系统,人生之路就会走得更加从容。

2、“多巴胺”系统不容易构建

我们随便看看身边,就会发现无论是学习、工作,还是退休安排和日常生活。“多巴胺”系统的构建都是非常不容易的。

2.1 学习

拿学习来说,如果将孩子的“多巴胺”系统和学校排名、升学挂钩,那么很多孩子是无法构建学习的“多巴胺”系统的。因为每个班几十个孩子,必然有排在后面 50% 的孩子。这些孩子从排名上是无法获得正向激励的。

另外,整个学习是一个不断淘汰对手的游戏。中考会淘汰 50% 的学生分流到中专,高考又会分流 50% 的人到职高,大学又会分流 90% 的学生到非重点大学。研究生考试又会分流 2/3 的本科生,只剩下 1/3。

按上面的通过率,就算你是全中国前 1% 的学生,那大概也会止步于 985/211 的研究生入学考试。

所以,在学习上,你总会有一天会遇上身边的对手都比你强,你在这个小圈子里面排在后面,如果你和同学比的话,你能收获的只有负面的情绪,感觉自己像个废物。

后面我会提到如何构建学习的多巴胺系统。

2.2 工作

也许你是一个优秀的员工,不断获得奖励和提拔,但是随着环境和年龄变化,工作中持续获得正反馈是困难的。原因如下:

第一个原因:正向激励的比例太低。只有前 20% 的员工才能获得超过其他人的回报,大部分人只能拿到普通的绩效和待遇。

第二个原因:很多工作的经验积累并不是线性的。在积累 3-5 年后,新增加的经验不足以带来相应比例产出提升,这就造成老员工工资过高,性价比不足。拿 iOS 开发来说,工作 10 年和工作 30 年的开发者的经验差异在大部分情况下表现得并不明显,这就可能造成某些工作 10 年以上的老员工薪资涨幅变慢。

第三个原因:人在 30 岁以后,体力和学习速度逐渐下降。我今年 41 岁,熬夜的能力明显变差。而我在 30 岁的时候,经常熬夜加班。工作中的一些内容如果需要的是堆时间才能完成,老员工的完成速度就不及年轻的员工。

第四个原因:岗位架构是金字塔形的。越往上需要的人越少,所以一个员工很容易最终就停在某一个岗位无法获得上升机会,背后的原因可能仅仅是因为上面已经有人了,不需要更多管理者。

2.3 退休

退休是每个人必须面对的事情,如果不做好准备,“多巴胺”系统根本就不会自己产生。因为每个人退休后,日常生活的节奏就会有巨大变化。而人的时间是需要被填满的,否则就会因为意义感缺失而产生各种问题。

2.4 其它

其它的部分还包括,生活、家庭、理财等等:

  • 对于生活:兴趣能否持续,影响“多巴胺”系统的稳定。
  • 对于家庭:如何处理夫妻关系,亲子关系,婆媳关系,都关系到多巴胺系统的稳定。
  • 对于理财:如果你买在顶峰,不但需要很长时间回本,也会承受巨大的账面亏损压力,给自己的多巴胺系统带来巨大冲击
  • 对于伤痛:个人对伤痛,特别是心理层面上的伤痛处理也很重要,心理上的伤痛如果处理不好,就像应激的小猫一样,会给身体带来严重的伤害。

如何构建“多巴胺”系统

接下来,我就讲讲我对各种情况下构建“多巴胺”系统的心得。

1、对于学习

对于学习,我们需要刻意设计“多巴胺时刻”。让原来可能没有的多巴胺变得有,让原来分泌得少的多巴胺,变得分泌多。具体来说,我们可以:

一、定期回顾,肯定自己的进步。我每年都会写年度总结,之前觉得每年没有什么变化,但是总结的时候,发现还是有挺多进步的,这样就让自己更有成就感。

二、设立奖励,自我颁奖。不管是小的学习还是大的学习,都可以设立奖励。我在做竞赛题的时候,之前做完我就继续做下一题。但后来我发现,如果我每次做对,都挥舞一下手臂小小庆祝一下,就会开心很多。所以,即便是很小的自我肯定,都可以让多巴胺给我们更多激励。

三、适当分享,获得亲朋鼓励。人是社会动物,自己的成就还是要适当分享出来。但是对自己友谊不深的朋友就没太有必要,有可能会造成人家妒忌,或者人家会认为你是一个喜欢炫耀的人,没必要。

四、构建无限游戏,不要设置终点和上限。学习无止境,如果我们可以一直设立目标,就可以无限玩下去。对于生命来说,能够无限玩的游戏不多,学习算是一个。

2、对于工作

刚刚说过,随着环境和年龄变化,工作中持续获得正反馈是困难的。所以,对于工作,我们首先需要做的是降低预期。工作首先你是获得持续现金流的谋生手段;它如果能够给你持续的正向激励,当然很好,但是如果有一天,工作无法给你带来正反馈,那么你也可以就把它当作一份工作即可。

在工作上不要讲太多回报,公平。很多事情做了没有结果,但是公司付你钱了,所以你认真把事情做好,就很好,也很专业。

另外,在工作上,我们也需要尊重规律,做累进的事情。坚持在自己的专业领域积累经验,如果自己的年龄大了或者行业发展不好,也要接受工资不再上涨这些现实。

在工作上,我们还可以尝试杠铃策略,即:同时拥有两个不太相关的专业技能。通过在业余时间利用自己的爱好或者特长来发展副业,如果万一出现什么变动,自己的副业就可以成为主业,保证自己不至于失业。

3、对于退休

退休是人一辈子重要的规划之一,也是人生活法的重大转换。

对于退休,最重要的事情就是让提前规划好兴趣,让兴趣填满自己的时间。否则,人生一下子多了那么多时间,很容易觉得无聊。

这个兴趣最好是无限挑战游戏。这样可以几十年也做不完。

这个兴趣也最好可以锻炼到身体(例如:广场舞、摄影、骑行之类)。

最后,退休还有一个很重要的事情:要管好自己的钱,不冒大的风险,不折腾高风险的投资。因为挣太多钱自己也不一定能花完,但是如果亏很多就会影响自己的退休生活。

4、日常生活

日常生活中,有这些技巧可以带来更多的多巴胺:

一、主动给生活带来变化

我自己的经验是,主动做一些以前没做过的事情,会给生活带来新鲜感。比如:

  • 我家每过几年就会尝试换个房子租,每次都有不同的体验。
  • 每年出游几次,每次去不同的地方,让自己开眼界。
  • 购物,看上什么东西就买买买。
  • 庆祝。为自己的成绩庆祝,为朋友的成绩庆祝,为家人的成绩庆祝。

二、自立

不要太依赖别人,或者太依赖于某个工作,或者将自己放到一个困境,或者太陷入一个目标。这不是说我们应该不努力。对于生活,我们应该全情投入,把过程做好;但是对于结果,我们应该顺其自然。

三、终身学习

学习是少有的,可以持续给人带来获得感的事情。而且这个事情是没有终点的,属于一种“无限游戏”,这就让我们永远不会觉得无聊。

我最近因为兴趣又开始学习编程,遇到一个算法没看懂,我就慢慢想,可能想个一周,甚至两周,我感觉这才是一个学习的状态,就是慢慢的,不紧不慢的,学完一个再学下一个。

相对来说,学校的学习更像是一个工业化的人才产出器,每个人需要像机器一样在指定的时间学习完指定的内容,但是每个人的学习能力是不一样的,其实对每个人来说,匹配自己的学习速度才是最佳的学习方案。

四、关注过程,弱化结果

人生是一场体验,并非要留下什么,也留不下什么。

如果我们想想 100 年后谁能记得我们,我们会发现结论是:没有人。即使是自己的亲人,过了三代你可能也不会记得。大家可以想想,你知道你的爷爷的爷爷叫什么名字,长什么样,做过什么成绩吗?就算你记得,你的孩子以后会记得吗?

所以,如果人生到最后不会有任何人记得我们,那么我们人生的意义是什么?我认为核心的意义就是人生本身。就像《活着》中写道:活着就是最大的意义。

对于人生这种重过程,无结果的“游戏”,我们活在当下,关注过程,把自己的人生过好,就是一个非常棒的事情了。别的更多的结果,我们做不到,也没有什么意义。

5、对于家庭

对于家庭,最简单的获得多巴胺的方式是:低预期。比如:

对于家人,不要指望家人一定要为自己付出。家人能够不让你付出,就是超预期。有这样的心态,你每天都是超预期。

对于孩子也一样,低预期,不鸡娃。

  • 孩子小的时候,我们只需要尽量培养孩子兴趣,兴趣是最大的老师,对于结果,则需要看孩子的天赋和运气,所以我们只能静待花开。
  • 当孩子成年后,她会有自己的生活,作为父母也应该降低预期,孩子能活成什么样,最主要的还是靠孩子自己。
  • 当我们老了后,也别指望孩子给自己养老,不啃老就不错了。有这样的低预期,也容易每天获得超预期的结果。

6、对于朋友

我认为有三种朋友,可以给我们提供持续的多巴胺。

  • 一种朋友是相互帮助、支持的人。显然你们相互会收获很多。
  • 一种是可以给你提供指导的前辈,牛人。你可以收获到成长。
  • 一种是你可以给别人提供指导的后辈。你可以收获到成就感。

那哪些是消耗你多巴胺的朋友呢?

  • 每次需要你的时候找你,但你需要他的时候总逃避的人。
  • 和你话不投机,没有共同语言的人。
  • 无法平等对话的人,有可能是对方太过强于你,懒得和你对话;也可能是对方太弱于你,你懒得和他对话。
  • 让你感觉到有压力,但是除了消耗你多巴胺外,并不能给你带来任何其他好处的人。
  • 你讨厌的人。
  • 你嫉妒的人。

我有些时候,有点讨好型人格,就是不喜欢一个人,也不愿意和人家起冲突,很多时候碍于面子还是淡淡地交往。后来我发现这样不对,这完全是一种对多巴胺系统的伤害,想到这些我就主动断开了一些不喜欢的朋友的来往。其实有一些人是很优秀的,但是多巴胺系统为先的决策,让我还是会坚决断开联系。

7、对于伤痛

小孩子如果反复盯着糖果看,最后就会忍不住吃掉糖果。如果有人伤害了你,你反复回忆这个伤害的过程,你就会受到更多的内心部分的伤害。

著名作家蔡澜最近去世了,别人问他,他的爱人离他而去了,他是如何克服下来的。蔡澜说:你如果老去想这件事情,你就会发疯,所以我尽量让自己不去想这件事情。

芒格和巴菲特的公司之前特别看好一个接班人,后来这个接班人做了一些违背公司原则的事情,在收购一家公司前,自己私下提前买了这家公司的股票,自己获利了几百万美元。事情暴露之后,这个接班人辞职了。别人问芒格怎么看这个事情。

面对欺骗与背叛,芒格说:永远不要责备自己,永远不要有受害者心态。当你产生这种心态的时候,只会让你自己难受,不会带来任何其它正面的影响,因此你不应该花时间去感受它,哪怕是一秒钟。所以,更应该的心态是应对这种情况,为未来的不确定性做好准备。

芒格最后总结道:“I am not a victim. I am a survivor.”

所以,站在建立“多巴胺”系统的角度,任何只有负面效果的情绪都是不值得去强化和感受的。如果你忍不住,你可以尽量不去想它。更好的办法是像芒格那样,有一个更加强大的幸存者视角来看待所有的坏运气、灾难、欺骗与背叛。让这些负面情绪不影响自己的多巴胺系统。

8、不内耗和自恰

我后来发现,其他人讲的一些行事原则,在表达角度上虽然不一样,其实也是一样的道理。比如我们讲的“不内耗”原则。

内耗就是一种持续消耗“多巴胺”的心理行为。如果以构建“多巴胺”系统作为人生准则的话,我们会发现内耗没有任何效果。当我们面对不如意的时候,要么改变,要么适应,要么淡化,而内耗是一种既不改变,又不适应,又反复强化负反馈的行为。百害而无一利。

自恰的底层含义是:所有事情能够自圆其说,不矛盾,不冲突,自然也就不内耗了,不消耗多巴胺。

所以,人需要活得“自恰”,只有自恰才能睡好觉,持续获得多巴胺。

主观与客观

“多巴胺”系统有主观的部分,也有客观的部分。

一、主观部分

“多巴胺”系统对于个人内心是一种主观行为和感受,而不是一种客观描述和标准。所以,对于芒格来说,一个重要朋友的背叛不是对“多巴胺”系统的冲击;但换一个人,可能觉得天塌了,一辈子再难信任他人。

因此,我们更应该调整的是自我的行事方式和思考问题的角度,而不是改变其他人。我们可以远离那些影响我们“多巴胺”系统的人和事,但是当坏运气到来的时候,我们只能接受。

二、客观部分

当然,“多巴胺”系统在指导我们行为的时候,是让我们客观上在做具体的行为选择。通过行为选择让我们尽可能构建有利于我们产生多巴胺的外界环境。比如我刚刚提到的:提前规划退休生活、选择终身学习、多搞庆祝活动等。这些有利的环境不但不会消耗我们主观意志来维护多巴胺,还会给我们提供愉悦,贡献多巴胺。

小结

“多巴胺”系统是一种隐喻,是指能够给你带来持续正反馈/正向情绪的事情。我们通过:

  • 主观上,调整自己的思考和看待事情的方式
  • 客观上,搭建好能够持续供养自己多巴胺的外部环境

利用“多巴胺”系统,让自己的人生少一点内耗,少一点纠结,多一点平静,多一点快乐。

愿每个读者都能过好当下的每一天,谢谢!

GESP 大题核心考点

GESP 1 级

1 级主要考查分支和循环结构,所以大题的解法一般都是一个 for 循环,然后循环里面用 if 之类的条件判断做一些事情,最后再输出结果。其代码框架为:

1
2
3
// 循环结构, 例如 for ...
// 判断条件
// 输出结果

拿 GESP202309 一级题目:小明的幸运数 来说,其核心代码是:

1
2
3
4
5
6
7
8
9
10
// 循环
for (int i = l; i <= r; ++i) {
// 判断条件
if (isLucky(i)) {
// 累加
ans += i;
}
}
// 输出结果
cout << ans << endl;

另外一个例子,GESP202503 一级题目:四舍五入,核心代码:

1
2
3
4
5
6
7
8
9
10
11
// 循环
for (int i = 1; i <= n; ++i) {
cin >> a;
b = a%10;
a = a/10;
// 判断条件
if (b <= 4) a = a*10;
else a = a*10 + 10;
// 输出结果
cout << a << endl;
}

GESP 2 级

考点一:双重循环

GESP 2 级相对 1 级,对循环结构的考查进行了加强,一般需要用双层嵌套的循环才能完成大题。有一类双层嵌套循环需要特别关注,就是模拟输出类,这一类题过去考过多次,包括:

等差矩阵为例,其关键代码为嵌套的 for 循环,参考如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/**
* Author: Tang Qiao
*/
#include <bits/stdc++.h>
using namespace std;

int n, m;
int tu[55][55];
int main() {
cin >> n >> m;
// 嵌套的 for 循环
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= m; j++) {
cout << i*j << " ";
}
cout << endl;
}
return 0;
}

如果学生还是不熟悉,可以考虑如下更多的练习:

  • 模仿 小杨的 X 字矩阵,输出 “又” 字,倒 “N” 字,“工” 字矩阵,“口”字矩阵
  • 模仿 画三角形,输出 左对齐、右对齐的正三角形,倒三角形
  • 模仿 等差矩阵,输出求和的矩阵,输出只有偶数的等差矩阵(奇数位填 *

考点二:常用函数

2 级还会考一些我们经常会实现的函数。包括:

求素质函数

参考题目:GESP202306 找素数

1
2
3
4
5
6
7
8
bool isPrime(int a) {
for (int i = 2; i*i <=a; i++) {
if (a%i == 0) {
return false;
}
}
return true;
}

求闰年函数

参考题目:GESP202503 时间跨越

关键代码:

1
2
3
4

bool isLeapYear(int year) {
return (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0);
}

把一个数的每一位数字拆分的写法

参考题目:GESP202406 计数

关键代码:

1
2
3
4
5
6
7
8
int count(int a, int k) {
int ret = 0;
while (a) {
if (a%10 == k) ret++;
a/=10;
}
return ret;
}

练习题目:GESP202409 数位之和

GESP 3 级

考点一:字符串操作

3 级对字符串的操作要求非常高,需要考生灵活掌握字符串的变换、拼接、求子串、判断回文等操作。

求子串可以用 string 类的 substr(int pos, int len) 函数。需要注意该函数的两个参数分别是起始下标和长度。

其中,判断回文的写法如下:

1
2
3
4
5
6
7
8
9
bool isReverse(string &s) {
int len = s.length();
for (int i = 0; i < len/2; ++i) {
if (s[i] != s[len-i-1]) {
return false;
}
}
return true;
}

以真题 GESP202409 回文拼接 为例,考生需要对字符串进行切分,然后分别判断是否是回文串。

参考代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
/**
* Author: Tang Qiao
*/
#include <bits/stdc++.h>
using namespace std;

int n;
string s;

bool isReverse(string &s) {
int len = s.length();
for (int i = 0; i < len/2; ++i) {
if (s[i] != s[len-i-1]) {
return false;
}
}
return true;
}

int main() {
ios::sync_with_stdio(false);
cin >> n;
while (n--) {
cin >> s;
bool ans = false;
if (s.length() >= 4) {
for (int i = 2; i < s.length() - 1; i++) {
string s1 = s.substr(0, i);
string s2 = s.substr(i);
if (isReverse(s1) && isReverse(s2)) {
ans = true;
break;
}
}
}
if (ans) cout << "Yes" << endl;
else cout << "No" << endl;
}
return 0;
}

该考点的相关真题:

其中 GESP202309 进制判断 看起来是考进制的规则,实际上也是考字符串的查找。参考代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
/**
* Author: Tang Qiao
*/
#include <bits/stdc++.h>
using namespace std;

int isRange(string s, string range) {
for (int i = 0; i < s.length(); ++i) {
char ch = s[i];
int j = 0;
for (j=0; j<range.length(); ++j) {
if (ch == range[j]) {
break;
}
}
if (j == range.length()) return 0;
}
return 1;
}

int main() {
int n;
string s;
cin >> n;
while (n--) {
cin >> s;
cout << isRange(s, "01") << " "
<< isRange(s, "01234567") << " "
<< isRange(s, "0123456789") << " "
<< isRange(s, "0123456789ABCDEF") << endl;
}
return 0;
}

考点二:前缀和

前缀和的计算技巧是:用一个累加变量来不停地更新前 N 个数的和,这样我们只需要用 O(N)的时间复杂度,就可以把所有的前缀和求出来。

参考题目:GESP202409 平衡序列

此题解法是:暴力测试,先计算出总和 tot ,然后看前缀和的两倍有没有可能等于 tot。

参考代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
/**
* Author: Tang Qiao
*/
#include <bits/stdc++.h>
using namespace std;

int t, n, v[10010], tot;
int main() {
ios::sync_with_stdio(false);
cin >> t;
while (t--) {
cin >> n;
tot = 0;
for (int i = 0; i < n; ++i) {
cin >> v[i];
tot += v[i];
}
int cnt = 0;
bool ans = false;
for (int i = 0; i < n && cnt*2<tot; ++i) {
cnt += v[i];
if (cnt*2 == tot) {
ans = true;
}
}
if (ans) cout << "Yes" << endl;
else cout << "No" << endl;
}
return 0;
}

考点三:位运算

考生需要熟悉二进制,以及数的位运算操作。

典型考题为:GESP202503 2025

此题的思路如下:因为 x 最大是 2025,而如果 y 需要影响 x 的运算,只能与 x 的 bit 位是 1 的位进行操作。所以 y 如果存在,则必定小于 2048。因为 2048 的二进制 1 的 bit 位已经超过 2025 的最高位了。所以直接枚举 1~2048 之间的答案即可。

参考代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <bits/stdc++.h>
using namespace std;

int ans = -1;
int x;
int main() {
cin >> x;
for (int i = 1; i < 2048; ++i) {
if ((x & i) + (x | i) == 2025) {
ans = i;
break;
}
}
cout << ans << endl;
return 0;
}

GESP 4 级

考点比较散,以下是历次考题的考点。

  • GESP-202306 幸运数:模拟
  • GESP-202309 进制转换:进制转换
  • GESP-202309 变长编码:位操作
  • GESP-202312 小杨的字典:字符串操作
  • GESP-202312 田忌赛马:排序,模拟
  • GESP-202403 相似字符串:字符串操作
  • GESP-202403 做题:贪心
  • GESP-202406 黑白方块:枚举
  • GESP-202406 宝箱:枚举,二分
  • GESP-202409 黑白方块:枚举
  • GESP-202409 区间排序:排序
  • GESP-202412 Recamán:枚举
  • GESP-202412 字符排序:排序
  • GESP-202503 荒地开垦:枚举
  • GESP-202503 二阶矩阵:枚举

其中,比较常考的考点:

  • 枚举:考了 6 次。
  • 排序:考了 3 次。
  • 字符串操作:考了 2 次。

GESP 5 级

待补充

GESP 6 级

最近公共祖先

动态规划

包括 01 背包和完全背包:

基础动态规划:

记忆化搜索:

其它

树状数组:

暴力枚举:

模拟+高精度:

CSPJ 教学总结:树状数组

引言

有些时候,题目给我们 N 个元素的序列,然后让我们求前缀和或者区间和。并且,题目还会动态地修改这个序列的值。如果我们每次暴力求解前缀和,时间复杂度会是 O(N),而使用树状数组,可以将查询前缀和的复杂度降低到 O(LogN)。

树状数组是挺不好教学的一个知识点。它需要以下前置知识:

  • 二进制表示法及熟练的位操作
  • 前缀和的知识
  • 树的基础知识
  • 时间复杂度的估算

在教学的时候,我们的教学顺序如下:

  • 先引入问题
  • lowbit 函数讲解
  • 树状数组的结构特点
  • 利用树状数组求前缀和的方法
  • 怎么修改树状数组的值
  • 如何初始化树状数组
  • 增加值或替换值
  • 二维的树状数组

那么让我们来开始。

问题的引入

P3374 树状数组 1 是一道标准的树状数组问题:该题目给我们了一个数列,我们需要解决以下两个问题:

  • 数列的区间求和
  • 更新某一个数(加上 x)

我们很容易想到用暴力的方法来做此题。于是我们可以估计一下暴力的时间复杂度:

  • 数列的区间求和,时间复杂度 O(N)
  • 更新某一个数,时间复杂度 O(1)

题目中提到,求和的次数最多为 M 次,所以最坏情况下,时间复杂度为 O(M*N)。而由于 M 和 N 的最大范围为 5*10^5,所以最大运算次数高达 (5*10^5) * (5*10^5) = 2500亿次,而竞赛中估算 1000 万次的运算时间就接近 1 秒了,这个时间肯定会超时。

数列的区间求和有一个 O(1)的办法,就是提前求出前缀和。假如 Sum(i) 表示前 i 个数的和,那么区间 (i,j] 的和就可以通过 Sum(j) - Sum(i) 来得出。可惜的是,本题还有一个操作是更新某一个数。如果更新的是第一个数,那么整个前缀和数组 Sum 都需要更新,这样更新的时间复杂度会变成 O(N),最坏情况下会有 O(M*N)次更新,造成运算同样超时。

由此,我们需要一个更优秀的数据结构来解决这类问题,这就是树状数组。

lowbit 函数

在讲解树状数组前,我们先学习一下 lowbit 函数。

lowbit 函数实现的功能是:求 x 的二进制最低位 1 以及后面的 0 组成的数。例如:

  • 8 (10 进制) = 1000 (2 进制) ,则 lowbit(8) = 8
  • 9 (10进制)= 1001(2 进制),则 lowbit(9) = 1
  • 10(10 进制)= 1010(2 进制),则 lowbit(10) = 2

所以,我们需要找到目标数的二进制中的最后那个 1 的位置。有两种实现方式:

方法一:x^(x-1) & x

方法一相对比较好理解,我拿二进制数 1100 举例解释如下:

  • (x-1)的效果,相当于把二进制的最后一个1变成 0,比如某数 11001之后,就变成了 1011
  • 这个时候,如果我用 x^(x-1),就会得到 1100^1011=0111
  • 最后,用 x& 刚刚的 x^(x-1),就相当于把x的最后一个1留下来了,前面的1都抹掉了:1100 & 0111 = 0100

方法二:x&-x

我们还是拿二进制数 1100 举例,由于负数是用补码表示,所以对于 1100,它的负数:

  • 原码为:11100(最高为 1 为符号位)
  • 反码为:10011(反码符号位不变,其余位取反)
  • 补码为:10100(补码=反码+1)

这样一操作,x&-x 就等于 01100 & 10100 = 0100,同样把最后的 1 取出来了。

在实现中,我们用方法二的更多,因为更短。参考代码如下:

1
2
3
int lowbit(int x) {
return x & -x;
}

树状数组的定义

对于一个长度为 N 的序列,为了满足上面提到的更快的区间求和和更新的需求,我们可以构造一个树状数组。

树状数组(Binary Index Tree,简称 BIT)通过构造另一个长度为 N 的数组,来做到:

  • 区间求和,时间复杂度 O(log N)
  • 更新某一个数,时间复杂度 O(log N)

因为树状数组需要另外创建一个长度为 N 的数组,所以它的空间复杂度为O(N)

我们先创建出这个数组 b ,然后再引入它的元素间的树状逻辑关系。

我们有了数组 b,我们让数组 b 相对于原始序列 a,按如下的关系来保存范围和:

  • b[1] 保存 a[1]的值
  • b[2] 保存区间 [a[1], a[2]] 的和
  • b[3] 保存 a[3]的值
  • ….省略若干行
  • b[8] 保存区间 [a[1], a[8]] 的和

我们先不管如何做到的,先假设我们按上面的逻辑,初始化好了这个数组,那么它怎么能快速求出前缀和呢?

树状数组求和

我们假设要求 a[1] ~ a[7]的和,如下图所示,我们知道这段和满足:Sum(7) = b[4] + b[6] + b[7]

那么,我们观察一下 b[4],b[6],b[7] 这几个下标有什么特点:

  • 4 的二进制:0100
  • 6 的二进制:0110
  • 7 的二进制:0111

如果结合上我们刚刚教的 lowbit 函数,我们就可以发现如下规律:

  • 4 的二进制:0100,4 = 6 - lowbit(6)
  • 6 的二进制:0110,6 = 7 - lowbit(7)
  • 7 的二进制:0111

于是,如果我们要求 Sum(7),就可以用 b[7] 开始累加,然后用 7 - lowbit(7) 得到 6,再用 6 - lowbit(6) 得到 4,最后 4 - lowbit(4) = 0,就结束整个求和累加过程。

把以上逻辑转换成代码,是这样的:

1
2
3
4
5
6
7
8
int query(int range) {
int ret = 0;
while (range > 0) {
ret += b[range];
range -= lowbit(range);
}
return ret;
}

有人可能要问了,这个求和都是从序列开头开始的,如果我们想求序列中间一段,比如从 x 到 y 的区间和,应该怎么办呢?这种情况,我们可以:

  • 用 query(y) 把从头到 y 位置的和求出来
  • 用 query(x-1) 把从头到 x-1 位置的和求出来
  • 然后相减 query(y) - query(x-1) 得到区间 [x,y] 的和

更新数据

树状数组也支持更新数据,像P3374 树状数组 1题目中要求的那样,我们可以将某个数加上 x,这种情况应该如何更新数组呢?

我们以更新 a[1]为例,通过观察,我们发现涉及 a[1] 的数组有:b[1],b[2],b[4],b[8],如下图所示:

你有观察出来规律吗?这刚好是我们构建的这个树从叶子结点到根结点的一条路径。

那同样的问题来了,我们如何求解出b[1],b[2],b[4],b[8]这个路径呢?我们来观察一下:

  • 1 的二进制是:0001
  • 2 的二进制是:0010, 2 = 1 + lowbit(1)
  • 4 的二进制是:0100, 4 = 2 + lowbit(2)
  • 8 的二进制是:1000, 8 = 4 + lowbit(4)

我们再验证一个中间结点的更新,比如更新 a[5],如下图所示:

我们看看规则是不是一样:

  • 5 的二进制是 0101,
  • 6 的二进制是 0110,6 = 5 + lowbit(5)
  • 8 的二进制是 1000,8 = 6 + lowbit(6)

至此,我们总结出更新方法:从数列的下标 idx 开始,不停地更新,并且用 idx += lowbit(idx) 获得下一个更新的下标,直到更新到下标超过上界(N)为止。

1
2
3
4
5
6
void add(int idx, int val) {
while (idx <= n) {
b[idx] += val;
idx += lowbit(idx);
}
}

初始化

最暴力的初始化方法是:我们假设原序列全是 0,这样树状数组的初始状态也全是 0 即可正常表达上面的树型关系。然后,我们把每一个 a 序列中的数用更新的方式来放入树状数组中。

至此,我们完成了例题P3374 树状数组 1中的所有细节讨论,完整的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
/**
* Author: Tang Qiao
*/
#include <bits/stdc++.h>
using namespace std;
#define MAXN (int)(500000+10)

int n, m;
int a[MAXN], b[MAXN];

int lowbit(int x) {
return x & -x;
}

void add(int idx, int val) {
while (idx <= n) {
b[idx] += val;
idx += lowbit(idx);
}
}

int query(int range) {
int ret = 0;
while (range > 0) {
ret += b[range];
range -= lowbit(range);
}
return ret;
}

int main() {
ios::sync_with_stdio(false);
cin >> n >> m;
for (int i = 1; i <=n; ++i) {
cin >> a[i];
add(i, a[i]);
}
for (int i = 1; i <= m; ++i) {
int op, x, y;
cin >> op >> x >> y;
if (op == 1) {
add(x, y);
} else {
cout << query(y) - query(x-1) << endl;
}
}
return 0;
}

但是,以上的这种初使化方法,时间复杂度为 O(N*logN),如果数据刚好卡在初始化中,我们可以用以下这种方法来将初始化时间复杂度优化到 O(N)

初始化(优化)

为了讲明白这种初始化,我们需要观察树状数组 b 中的每个元素代表的数据范围有什么规律。为什么 b[5] 只代表 a[5] 一个元素,但是 b[8]代表的是[a[1],a[8]] 区间的 8 个元素的和 ?

最终我们可以发现,一个数组元素代表的区间范围大小就是它的 lowbit 函数求出来的值。

例如:

  • lowbit(5) = 1,所以它只代表 a[5] 一个元素
  • lowbit(8) = 8,所以它代表 [a[1],a[8]] 共 8 个元素
  • 一个十进制数 88,其二进制为 01011000lowbit(88)=8,所以它代表的区间为 8 个元素。

进一步的,我们可以观察出,对于一个 b[x],它代表的区间为[x-lowbit(x)+1, x]

这对初始化有什么用呢?

  • 我们如果构建了数组 a 的前缀和数组 s,s[i]表示前 i 个数的和。
  • 那么,我们就可以用前缀和数组 s 来初始化 b[x]。

因为 b[x] 代表的区间和是[x-lowbit(x)+1, x],所以:b[i] = s[i] - s[i-lowbit(i)]

至此,我们可以将例题P3374 树状数组 1的代码更新如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
/**
* Author: Tang Qiao
*/
#include <bits/stdc++.h>
using namespace std;
#define MAXN (int)(500000+10)

int n, m;
int a[MAXN], b[MAXN], s[MAXN];

int lowbit(int x) {
return x & -x;
}

void add(int idx, int val) {
while (idx <= n) {
b[idx] += val;
idx += lowbit(idx);
}
}

int query(int range) {
int ret = 0;
while (range > 0) {
ret += b[range];
range -= lowbit(range);
}
return ret;
}

int main() {
ios::sync_with_stdio(false);
cin >> n >> m;
for (int i = 1; i <=n; ++i) {
cin >> a[i];
s[i] = s[i-1] + a[i];
}
// 初始化
for (int i = 1; i<=n; ++i) {
b[i] = s[i] - s[i-lowbit(i)];
}
for (int i = 1; i <= m; ++i) {
int op, x, y;
cin >> op >> x >> y;
if (op == 1) {
add(x, y);
} else {
cout << query(y) - query(x-1) << endl;
}
}
return 0;
}

管辖区间

上面讲到,树状数组中的元素 b[x] 管辖的区间和是[x-lowbit(x)+1, x],因此,我们更能理解树状数组的更新逻辑:

  • 所谓的更新a[x],就是把管辖区间涵盖 a[x] 的所有 b[x]都更新一遍。
  • 那哪些 b[x]的管辖区间涵盖 a[x]呢?就是从二进制看,就是范围中有 lowbit(x) 的数。

举例来说,如果我们要更新 a[2] 的值,lowbit(2) 的值是 0010,所以,我们要更新:

  • b[2], 因为 2 的二进制是 0010,管辖区间是 [1, 2],宽度是 2
  • b[4], 因为 4 的二进制是 0100,管辖区间是 [1, 4],宽度是 4
  • b[8], 因为 8 的二进制是 1000,管辖区间是 [1, 8],宽度是 8

再举一个例子,如果我们要更新 a[5] 的值,lowbit(5) 的值是 0001,所以我们要更新:

  • b[5],因为 5 的二进制是 0101,管辖区间是 [5, 5],宽度是 1
  • b[6],因为 6 的二进制是 0110,管辖区间是 [5, 6],宽度是 2
  • b[8],因为 8 的二进制是 1000,管辖区间是 [1, 8],宽度是 8

再举一个例子,如果我们要更新 a[7] 的值,lowbit(7) 的值是 0001,所以我们要更新:

  • b[7],因为 7 的二进制是 0111,管辖区间是 [7, 7],宽度是 1
  • b[8],因为 8 的二进制是 1000,管辖区间是 [1, 8],宽度是 8

通过上面的例子,我们可以看到,管辖区间在更新的过程中宽度是不断扩大的。不同的数,宽度扩大的倍数不同。但至少是每次翻倍的方式来扩大。

我们再从另一个角度来看管辖区间:我们把数状数组的第 1 个到第 56 个元素的二进制列出来,如下所示:

我们可以观察到:bit 为 1 的位置越低,管辖的区域越小,所以:

  • 有一半管辖区域大小为 1 的数(图中为粉色)
  • 剩下的一半,有一半管辖区域大小为 2 的数(图中为绿色)
  • 再剩的一半,有一半管辖区域大小为 4 的数(图中为紫色)
  • 再剩的一半,有一半管辖区域大小为 8 的数(图中为黄色)

再看这些数的间隔:

  • 粉色的间隔是 2-1,每 2 个出现一次
  • 绿色的间隔是 4-1,每 4 个出现一次
  • 紫色的间隔是 8-1,每 8 个出现一次
  • 黄色的间隔是 16-1,每 16 个出现一次

所以,其实树状数组是把区间和数据按分治的思想进行了切分,这样可以快速求和。

另外,从管辖区域的角度考虑,每一个数在进行 lowbit 减运算的时候,得到的新数,一定和之前的区间不是重叠的。我们可以这样证明:

  • 每个元素 b[x] 管辖的区间和是[x-lowbit(x)+1, x]
  • 我们令 y = x - lowbit(x), 则 b[y] 的管辖区间就是:[y-lowbit(y)+1, y],即:[y-lowbit(y)+1, x - lowbit(x)]
  • 可以看到,这两个区间 [y-lowbit(y)+1, x - lowbit(x)][x-lowbit(x)+1, x]其实是相邻的。

所以,每次减 lowbit(x) 的运算,其实是获得了其左侧相邻的一块区间的和。

我们来看一个查询和的例子,如果我们要求前缀和 sum(7):

  • 我们先计算 b[7],7 的二进制是 0111,管辖区间是 [7, 7],宽度是 1
  • 我们再计算 b[6],6 的二进制是 0110,管辖区间是 [5, 6],宽度是 2
  • 我们再计算 b[4],4 的二进制是 0100,管辖区间是 [1, 4],宽度是 4

我们从上面的例子可以看到:由于每次减掉的都是最小的一个 lowbit 位,所以左侧相邻的新区间一定更宽。所以求和过程中, b[7],b[6],b[4] 对应的管辖宽度从 1 到 2 再到 4.

我们再看一个前缀和 sum(9) 的例子:

  • 我们先计算 b[9], 9 的二进制是 1001,管辖区间是 [9, 9],宽度是 1
  • 我们再计算 b[8], 9 的二进制是 1000,管辖区间是 [1, 8],宽度是 8

和我们刚刚得到的结论相同:求和过程中,随着不断地减 lowbit(x),获得的新区间更宽。

小结:

  • 树状数组中的元素 b[x] 管辖的区间和是[x-lowbit(x)+1, x]
  • 每次加 lowbit(x) 的过程,相当于在不断扩展管辖区间。不同的数,宽度扩大的倍数不同。但至少是每次翻倍的方式来扩大。
  • 每次减 lowbit(x) 的过程,相当于在查找紧临 b[x] 管辖区间的一块新区间。这个新区间,宽度也是不断扩大的。不同的数,宽度扩大的倍数不同。但至少是每次翻倍的方式来扩大。

差分数组

有些时候,题目会让我们一次更新一段区间,这个时候,我们可以引入差分数组来替代原数组。

差分数组中的每一个元素,是原数组相邻两个数的差。

例如:

  • 原数组: 1,2,3,4,5,6
  • 差分数组:1,1,1,1,1,1

我们对差分数组求前缀和,就可以还原出原数组。

这个时候,如果我们把原数组的第 3 个数到第 5 个数都加上 2,我们看看效果:

  • 原数组: 1,2,5,6,7,6
  • 差分数组:1,1,3,1,1,-1

我们观察到,原数组的一个区间都加上 2 之后,在差分数组那里,只有第 3 个数和第 6 个数有变化,其它都没有变化。所以,如果我们用差分数组来代替原数组,就可以只更新两个数值来代表原来的范围更新。

P3368 【模板】树状数组 2此题可以很好地练习差分数组与数状数组的结合运用,相关代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
/**
* 差分:
* - 假设 A 序列为原序列
* - 差分数列 C 为原序列每两个数之间的差
* - 即:c[i] = a[i] - a[i-1]
* c[1] = a[1]
* c[2] = a[2] - a[1]
* c[3] = a[3] - a[2]
* - 所以:
* - a[i] = sum(c[1]+c[2]+...c[i])
*
* 对于本题,如果把数组变成差分数组:
* - [x,y] 每个数加上 k,等价于:
* - c[x] += k
* - c[y+1] -= k
* - 求第 a[x] 的值,等价于:
* - sum(c[1]+c[2]+...c[x])
* - 即求前缀和
*
* Author: Tang Qiao
*/
#include <bits/stdc++.h>
using namespace std;
#define MAXN (int)(500000+10)

int n, m;
int a[MAXN], c[MAXN], b[MAXN];

int lowbit(int x) {
return x&-x;
}

void add(int idx, int v) {
while (idx <= n) {
b[idx] += v;
idx += lowbit(idx);
}
}

int query(int range) {
int ret = 0;
while (range) {
ret += b[range];
range -= lowbit(range);
}
return ret;
}

int main() {
ios::sync_with_stdio(false);
cin >> n >> m;
for (int i = 1; i <= n; ++i) {
cin >> a[i];
c[i] = a[i] - a[i-1];
add(i, c[i]);
}
while (m--) {
int op, x, y, k;
cin >> op;
if (op == 1) {
cin >> x >> y >> k;
add(x, k);
add(y+1, -k);
} else {
cin >> x;
cout << query(x) << endl;
}
}
return 0;
}

二维的树状数组

刚刚讲到,对于一个 b[x],它代表的区间为[x-lowbit(x)+1, x]

那么对于一个二维的树状数组 b[x, y],它代表的区间就是 a(x-lowbit(x)+1, y-lowbit(y)+1) - a(x, y) 形成的矩阵的总和。如下图所示:

对于二维的树状数组,更新就需要用两层的循环了。示例代码如下:

1
2
3
4
5
6
7
void add(int x, int y, int v) {
for (int i = x; i <= n; i += lowbit(i)) {
for (int j = y; j <= m; j += lowbit(j)) {
c[i][j] += v;
}
}
}

查询前缀和同样需要用循环,示例代码如下:

1
2
3
4
5
6
7
8
9
int query(int x, int y) {
int res = 0;
for (int i = x; i > 0; i -= lowbit(i)) {
for (int j = y; j > 0; j -= lowbit(j)) {
res += c[i][j];
}
}
return res;
}

如果题目要求区间和,则需要用容斥原理来求解,这里不再展开介绍。

用树状数组求逆序对

什么是逆序对?逆序对是指一个序列中,a[i] > a[j]i < j 的有序对。

比如一个序列是 3 2 1,它的逆序对就有:3 2,3 1,2 1 三组。

树状数组如何和逆序对的数量扯上关系呢?

拿序列 3 2 1 举例,我们知道,树状数组是可以用前缀和的。如果我们:

  • 假设序列初始情况下为全 0
  • 当处理第一个数 3 的时候,我们让树状数组的下标 3 加 1:update(3, 1),同时记录插入了 1 个数
  • 当处理第二个数 2 的时候,我们统计小于等于 2 的前缀和:query(2),然后拿总数减 query(2),得到大于 2 的数字数量
  • 这个数量,就是当 2 被处理的时候,前面有一共多少个数大于 2,即与 2 能够组成逆序对的数量

例题:P1908 逆序对

在此题中,我们先要解决两个问题,才能借用上面的思想:

问题1、题中的数据范围太大,我们如何解决?

答案:我们可以用离散化的思想,把 2 10000 1 变成 2 3 1,因为逆序对是统计相对大小,所以这样更改之后,逆序对的数量是不变的。

具体如何离散化呢?我们可以将数据依次标记上编号,然后排序。例如:

  • 原始序列为 100 200 50, 我们把它分别标上编号 (100,1), (200,2), (50,3)
  • 然后我们将数值排序,得到:(50,3), (100,1), (200,2)
  • 然后,我们再将新的序列赋上从 1 开始的编号:(50,3,1), (100,1,2), (200,2,3)
  • 然后,我们再将序列按原来的编号(第 2 个数字)排序,得到 (100,1,2), (200,2,3), (50, 3, 1)
  • 至此,我们转换得到了新的编号 2,3,1

因为 N 最多是 5*10^5,所以离散化之后,树状数组的大小也缩减到了 5*10^5

在实现的时候,我们可以用结构体来保存上面的三元组。

1
2
3
4
5
struct Node {
int v;
int origin_idx;
int next_idx;
};

问题2、如果有两个相等的元素,会不会计算错误?

我们假设元素是 200 300 200,按我们刚刚的操作:

  • 先标号,得到 (200,1) (300,2) (200,3)
  • 再排序,得到 (200,1) (200,3) (300,2)
  • 再标号,得到 (200,1,1) (200,3,2) (300,2,3)
  • 再排序,得到 (200,1,1) (300,2,3) (200,3,2)
  • 最后序列是 1,3,2

这种是没问题的,但是,如果我们排序的时候不是用的稳定排序,把第二个 200 排到了前面,就会得到 2,3,1,这样逆序对就会多一个 2 1,而这本来是不存在的。

所以,为了解决这个问题,我们可以用稳定排序stable_sort,或者保证排序的时候,值相同的情况下,标号大的在后面。

以下是完整的参考程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
/**
* Author: Tang Qiao
*/
#include <bits/stdc++.h>
using namespace std;

#define MAXN (int)(5*1e5+10)

struct Node {
int v;
int origin_idx;
int next_idx;
};
Node a[MAXN];
int n,c[MAXN];
long long ans;

bool comp1(const Node &a, const Node &b) {
return a.v < b.v;
}

bool comp2(const Node &a, const Node &b) {
return a.origin_idx < b.origin_idx;
}

int lowbit(int x) { return x&-x; }

void add(int a, int v) {
while (a<=n) {
c[a]+=v;
a+=lowbit(a);
}
}

int query(int a) {
int ret = 0;
while(a) {
ret += c[a];
a -= lowbit(a);
}
return ret;
}


int main() {
cin >> n;
for (int i = 1; i <=n; ++i) {
cin >> a[i].v;
a[i].origin_idx = i;
}
stable_sort(a+1, a+1+n, comp1);
for (int i = 1; i<=n; ++i)
a[i].next_idx = i;
stable_sort(a+1, a+1+n, comp2);

for (int i = 1; i <=n; ++i) {
add(a[i].next_idx, 1);
ans += i - query(a[i].next_idx);
}
cout << ans << endl;

return 0;
}

相关练习题目

文章中涉及的例题:

练习题:

题目 描述
B3874 小杨的握手问题 GESP 202309 六级真题
- -

B3874 小杨的握手问题

解题思路:

  • 把学号为 a 的学生进入教室的行为,转化为第 a 个序列元素的值加 1。
  • 这样,找出小于 a 的学生数量,就等价于求序列前 a-1 个元素的前缀和。
  • 利用数状数组,就可以快速求前缀和了。

参考代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
/**
* 数状数组求逆序对。
*
* Author: Tang Qiao
*/
#include <bits/stdc++.h>
using namespace std;
#define MAXN int(3e5+10)

int n, b[MAXN];
long long ans;

int lowbit(int a) {
return a&-a;
}

void add(int idx, int v) {
while (idx <= n) {
b[idx] += v;
idx += lowbit(idx);
}
}

int query(int range) {
int ret = 0;
while (range) {
ret += b[range];
range -= lowbit(range);
}
return ret;
}

int main() {
ios::sync_with_stdio(0);
cin >> n;
for (int i = 0; i < n; ++i) {
int a;
cin >> a;
// 将学号下标从 0 开始改到 1 开始
a = a + 1;
ans += query(a - 1);
add(a, 1);
}
cout << ans << endl;
return 0;
}

CSPJ 教学总结:深度优先搜索(DFS)

深度优先搜索(DFS)是学生学习算法的第一道门槛,因为它的主要形式是递归。而递归中需要将搜索的相关信息通过参数传递,这一点需要学生深刻理解 DFS。

模版

DFS 有比较标准的模版,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void dfs(int pt) // pt 表示层数
{
if (终止条件) {
// 处理
return;
}
for (枚举这个层的所有可选项) {
if(这个选项是合法的){
标记这个选项(保存现场)
dfs(pt+1);
取消标记(恢复现场)
}
}
}

我们将运用该模版,完成后面的题目。

递归的深度

递归的时候,程序会占用栈空间来保存函数的环境变量。根据编译器以及编辑参数的不同,栈空间的大小也不同。通常情况下,竞赛中的编译器设定的栈空间为 8M 或者 16M。

假如,我们在一个递归函数中使用了 10 个 int 变量,那么每个递归函数就需要 4*10=40字节的栈空间。8M 一共可以支持 8*1000*1000/40=200000层调用。考虑到栈空间还需要保存当前函数执行的地址等变量,可供支持的调用层数会更小一点。

同学们也可以做如下的递归函数栈空间的测试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/**
* Author: Tang Qiao
*/
#include <bits/stdc++.h>
using namespace std;

int dfs(int x) {
int test[9] = {0};
cout << x << endl;
dfs(x + 1);
return 0;
}

int main() {
dfs(0);
return 0;
}

在我的本地,以上程序调用了约 13 万次后栈溢出。为了保险,我们在比赛中如果调用深度小于 1 万层,那应该是稳妥的;否则我们需要考虑是否用别的解法来解题。

教学和练习题目

题目名 说明
P1036 选数 NOIP 2002 普及组
P1219 八皇后 Checker Challenge USACO 1.5
P1596 Lake Counting S USACO10OCT
P2036 PERKET COCI 2008/2009 #2
P12139 黑白棋 蓝桥杯 2025 省 A,写起来较繁琐
P1605 迷宫 标准的 DFS
P2404 自然数的拆分问题
P1019 单词接龙 NOIP 2000 提高组

P7200
P10483

P1219 八皇后 Checker Challenge

这是八皇后的变种,N 皇后问题。可以作为基础练习。具体解法是:

  • 我们用变量 v[15] 表示每个皇后的列值。
  • 对于新放入的皇后,我们依次检查它与前面的皇后是否在一条斜线上。检查方法是看其“横坐标差”与“纵坐标差”是否相同。检查函数如下:
1
2
3
4
5
6
bool check(int pt) {
for (int i = 0; i < pt; i++) {
if (abs(v[i] - v[pt]) == abs(i - pt)) return false;
}
return true;
}

完整的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
/**
* Author: Tang Qiao
*/
#include <bits/stdc++.h>
using namespace std;

int n;
int v[15], ans;
bool flag[15];

bool check(int pt) {
for (int i = 0; i < pt; i++) {
if (abs(v[i] - v[pt]) == abs(i - pt)) return false;
}
return true;
}

void dfs(int pt) {
if (pt == n) {
ans++;
if (ans <= 3) {
for (int i = 0; i < n; i++) {
cout << v[i] << " ";
}
cout << endl;
}
return;
}
for (int i = 1; i <= n; i++) {
if (flag[i]==false) {
flag[i] = true;
v[pt] = i;
if (check(pt)) dfs(pt + 1);
flag[i] = false;
}
}

}

int main() {
cin >> n;
dfs(0);
cout << ans << endl;
return 0;
}

P1036 选数

此题需要从小到大取数求和,然后再判断是否是素数。用递归的方式来进行枚举。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
/**
* Author: Tang Qiao
*/
#include <bits/stdc++.h>
using namespace std;

int n, k, tot, ans;
int a[22], p[22];

bool isPrime(int v) {
for (int i = 2; i*i <= v; ++i) {
if (v%i == 0) {
return false;
}
}
return true;
}

void dfs(int pt) {
if (pt == k+1) {
if (isPrime(tot)) {
ans++;
}
} else {
// 每一层都必须取比前一层更大的下标,防止重复取
for (int i = p[pt-1]+1; i <= n; ++i) {
p[pt] = i;
tot += a[i];
dfs(pt+1);
tot -= a[i];
}
}
}

int main() {
cin >> n >> k;
for (int i = 1; i <= n; ++i) {
cin >> a[i];
}
dfs(1);
cout << ans << endl;
return 0;
}

P1596 Lake Counting S

此题既可以用 DFS,也可以用 BFS。考虑到 N 和 M 最大值为 100,所以递归的层次最多为 1 万层,所以我们可以试试 DFS。

以下是参考代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
/**
* Author: Tang Qiao
*/
#include <bits/stdc++.h>
using namespace std;
int n, m;
char tu[105][105];
int ans;
int movex[8] = {0, 0, 1, -1, 1, 1, -1, -1};
int movey[8] = {1, -1, 0, 0, 1, -1, 1, -1};

void dfs(int x, int y) {
tu[x][y] = '.';
for (int i = 0; i < 8; i++) {
int nx = x + movex[i];
int ny = y + movey[i];
if (nx < 0 || nx >= n || ny < 0 || ny >= m
|| tu[nx][ny] != 'W') continue;
dfs(nx, ny);
}
}

int main() {
cin >> n >> m;
for (int i = 0; i < n; i++) {
for (int j = 0; j < m; j++) {
cin >> tu[i][j];
}
}
for (int i = 0; i < n; i++) {
for (int j = 0; j < m; j++) {
if (tu[i][j] == 'W') {
dfs(i, j);
ans++;
}
}
}
cout << ans << endl;
return 0;
}

P2036 PERKET

因为 N 最多为 10,每种食材可以选或者不选两种情况,所以最多情况数为 2^10=1024 种。搜索时间满足要求。

所以,此题用 DFS 可以非常方便解决。在搜索的时候,我们可以将食材的相关信息带到 DFS 函数的参数中,方便最后答案的求解。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
/**
* Author: Tang Qiao
*/
#include <bits/stdc++.h>
using namespace std;

int n;
int s[11], b[11], v[11];
int ans = INT_MAX;

/**
* pt: 当前处理到的食材
* cnt: 当前选中的食材数量
* ss: 当前选中的食材的总酸度
* bb: 当前选中的食材的总甜度
*/
void dfs(int pt, int cnt, int ss, int bb) {
if (pt == n) {
if (cnt > 0) {
ans = min(ans, abs(ss - bb));
}
return;
}
v[pt] = 1;
dfs(pt + 1, cnt + 1, ss * s[pt], bb + b[pt]);
v[pt] = 0;
dfs(pt + 1, cnt, ss, bb);
}
int main() {
cin >> n;
for (int i = 0; i < n; i++) {
cin >> s[i] >> b[i];
}
dfs(0, 0, 1, 0);
cout << ans << endl;
return 0;
}

P12139 黑白棋

此题是搜索题,需要在中间尽可能检查状态来剪枝,以节省搜索次数。

题目有三类限制,分别可以用在不同的剪枝环节。

限制一:在每一行和每一列中,黑色棋子和白色棋子的数量必须相等(即为 3)。

  • 我们可以对每一行记录黑子和白子的数量,如果某一行或某一列的一种颜色达到 3,后面则不能用这个颜色。

限制二:不能有超过两个相同颜色的棋子连续排列。

  • 我们可以在当前落子的时候,检查它的左边和上面连续的几个格子,看是否有 3 个相同的子。

限制三:行列唯一性

  • 可以放到最后检查。

另外,这个棋盘有几个位置已经设定了值,我们需要标记下来,搜索的时候跳过对这些位置的尝试,但需要在这些位置做合法性检查。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
/**
* Author: Tang Qiao
*/
#include <bits/stdc++.h>
using namespace std;

int row_cnt[6][2], col_cnt[6][2];
char tu[7][7], mark[7][7];

bool check(int r, int c) {
// 在每一行和每一列中,黑色棋子和白色棋子的数量必须相等(即为 3)
if (row_cnt[r][1] > 3 || row_cnt[r][0] > 3 || col_cnt[c][1] > 3 || col_cnt[c][0] > 3) return false;

// 不能有超过两个相同颜色的棋子连续排列
if (r >= 2) {
if (tu[r][c] == '1' && tu[r-1][c] == '1' && tu[r-2][c] == '1') return false;
if (tu[r][c] == '0' && tu[r-1][c] == '0' && tu[r-2][c] == '0') return false;
}
if (c >= 2) {
if (tu[r][c] == '1' && tu[r][c-1] == '1' && tu[r][c-2] == '1') return false;
if (tu[r][c] == '0' && tu[r][c-1] == '0' && tu[r][c-2] == '0') return false;
}
return true;
}

// 行列唯一性检查
bool final_check() {
set<int> row_set, col_set;
for (int i = 0; i < 6; i++) {
int v = 0;
for (int j = 0; j < 6; ++j) {
v = v * 10 + (tu[i][j] - '0');
}
row_set.insert(v);
}
if (row_set.size() != 6) return false;
for (int j = 0; j < 6; ++j) {
int v = 0;
for (int i = 0; i < 6; ++i) {
v = v * 10 + (tu[i][j] - '0');
}
col_set.insert(v);
}
if (col_set.size() != 6) return false;
return true;
}

void dfs(int r, int c);
void try_dfs(int r, int c) {
char ch = tu[r][c];
row_cnt[r][ch - '0']++;
col_cnt[c][ch - '0']++;
if (check(r, c)) {
int nr = r;
int nc = c + 1;
if (nc == 6) {
nr++;
nc = 0;
}
dfs(nr, nc);
}
row_cnt[r][ch - '0']--;
col_cnt[c][ch - '0']--;
}

void dfs(int r, int c) {
if (r == 6) {
if (final_check()) {
for (int i = 0; i < 6; i++) {
for (int j = 0; j < 6; j++) {
cout << tu[i][j];
}
}
cout << endl;
// 因为只有一个合法解,所以找到答案就退出
exit(0);
}
return;
}

if (mark[r][c] == 0) {
tu[r][c] = '1';
try_dfs(r, c);
tu[r][c] = '0';
try_dfs(r, c);
} else {
tu[r][c] = mark[r][c];
try_dfs(r, c);
}
}

void init() {
memset(mark, 0, sizeof(mark));
mark[0][0] = '1';
mark[0][1] = '0';
mark[0][3] = '0';
mark[1][3] = '0';
mark[2][4] = '0';
mark[2][5] = '0';
mark[4][2] = '1';
mark[4][5] = '1';
mark[5][1] = '0';
mark[5][4] = '1';
}

int main() {
init();
dfs(0, 0);
return 0;
}

P1605 迷宫

用 DFS 来枚举,但需要标记走过的路。

  • 因为最多只有 5x5=25 个格子,所以递归的深度最大只有 25,不存在溢出情况。
  • 因为有陷阱(不能走)和起点终点(不能重复走),所以我们假设平均每次有 2 条支路,
    整个的最坏情况估计只有 2^23=8388608 次,所以也不会超时。

一些陷阱:

  • 终点可能也有障碍物,这个时候始终就到不了。
  • 起点在走之前需要标记,否则会重复走。

参考代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
#include <bits/stdc++.h>
using namespace std;

// 0 - 空地
// 1 - 障碍物
int tu[6][6], n, m, t, sx, sy, ex, ey, ans;

int movex[]={1,-1,0,0};
int movey[]={0,0,-1,1};

void dfs(int x, int y) {
if (x == ex && y == ey) {
ans++;
return;
}
for (int i = 0; i < 4; ++i) {
int tox = x + movex[i];
int toy = y + movey[i];
if (tox >=1 && tox<=n && toy>=1 && toy<=m && tu[tox][toy]!=1){
tu[tox][toy]=1;
dfs(tox, toy);
tu[tox][toy]=0;
}
}
}

int main() {
ios::sync_with_stdio(false);
cin >> n >> m >> t;
cin >> sx >> sy >> ex >> ey;
while (t--) {
int x, y;
cin >> x >> y;
tu[x][y] = 1;
}
tu[sx][sy] = 1;
dfs(sx, sy);
cout << ans << endl;
return 0;
}

P2404 自然数的拆分问题

DFS,有两个技巧:

  • 保证后面的数 >= 前面的数。
  • 让每个数必须小于 n,这样不会出现 n=n 这种等式。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
/**
* Author: Tang Qiao
*/
#include <bits/stdc++.h>
using namespace std;

int n, tot, v[10];

void dfs(int pt) {
if (tot == n) {
cout << v[1];
for (int i = 2; i < pt; ++i) {
cout << "+" << v[i];
}
cout << endl;
return;
}
for (int i = v[pt-1]; tot + i <=n && i < n ; ++i) {
tot += i;
v[pt] = i;
dfs(pt+1);
tot -= i;
}
}

int main() {
ios::sync_with_stdio(false);
cin >> n;
v[0] = 1;
dfs(1);
return 0;
}

CSPJ 教学总结:STL

STL 库是 C++ 语言的标准库,我们在比赛中主要用到的有如下内容。

string 类

  • substr
  • find
  • replace
  • insert
  • erase
  • c_str

容器

  • pair
  • vector
  • deque
  • list
  • stack
  • queue
  • priority_queue
  • map
  • unordered_map
  • set
  • unordered_set

算法库

函数 调用示意 说明
sort sort(v.begin(), v.end()) 快速排序
stable_sort stable_sort(v.begin(), v.end()) 稳定排序
unique unique(v.begin(), v.end()) 去重,返回的是去重后的元素末地址。可以结合 erase 函数来把多余数据删除。参考代码:v.erase(unique(v.begin(), v.end()), v.end());
next_permutation next_permutation(v, v+n) 返回全排列的下一个值,当没有下一个排列时,函数返回 false
prev_permutation prev_permutation(v, v+n) 返回全排列的上一个值,当没有上一个排列时,函数返回 false
nth_element nth_element(v.begin(), v.begin() + k, v.end()), 函数执行后,v.begin()+k 位置的数为排序后的最终位置,即左边的数都小于它,后面的数都大于它
lower_bounds lower_bounds(v, v+n, a) 查找大于或等于 a 的第一个位置,如果没找到则返回 end()
upper_bounds upper_bounds(v, v+n, a) 查找大于 a 第一个位置,如果没找到则返回 end()
equal_range equal_range(v, v+n, a) equal_range 返回一个 pair,first 元素是查找到的匹配 a 值的左边界,second 元素是匹配到的 a 值的右边界,边界为左闭右开原则。当 first == second 的时候,相当于没找到目标值
__gcd __gcd(a, b) 返回 a 和 b 的最大公约数
reverse reverse(v.begin(), v.end()) 将原序列逆序
min_element min_element(v.begin(), v.end()) 返回的是地址,如果想要值,可以用 * 获得对应下标的值,如果想获得下标,可以让它减去 v.begin()
max_element max_element(v.begin(), v.end()) 返回的是地址,如果想要值,可以用 * 获得对应下标的值,如果想获得下标,可以让它减去 v.begin()
accumulate accumulate(v.begin(), v.end(), 0); 第三个参数是初始值

练习

题号 说明
P1996 约瑟夫问题 适合用 list
P3613 寄包柜 适合用 map 和 pair
P4387 验证栈序列 适合用 stack
P1540 机器翻译 NOIP 2010 提高组,适合用 vector 以及 STL 的 find 算法
P1449 后缀表达式 适合练习 stack
P2058 海港 NOIP 2016 普及组,练习桶和队列
P2234 营业额统计 练习 set 和 lower_bound 函数
P4305 不重复数字 可以练习 unordered_map 以及对比 cin 和 scanf 的速度差别

P4387 验证栈序列

解法:把 A 数组中的元素住栈里面 push,然后如果栈顶元素和 B 数组的当前元素相同,就 pop,同时 B 数组的当前元素后移。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
/**
* Author: Tang Qiao
*/
#include <bits/stdc++.h>
using namespace std;

int t, n, a[100010], b[100010];

int main() {
ios::sync_with_stdio(false);
cin >> t;
while (t--) {
cin >> n;
for (int i = 0; i < n; ++i)
cin >> a[i];
for (int i = 0; i < n; ++i)
cin >> b[i];
stack<int> q;
int idx = 0;
for (int i = 0; i < n; ++i) {
q.push(a[i]);
while (!q.empty() && q.top() == b[idx]) {
q.pop();
idx++;
}
}
if (q.empty()) cout << "Yes" << endl;
else cout << "No" << endl;
}
return 0;
}

P1540 机器翻译

参考代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <bits/stdc++.h>
using namespace std;

int main() {
ios::sync_with_stdio(false);
int m, n, t, ans = 0;
cin >> m >> n;
vector<int> v;
while (cin >> t) {
if (find(v.begin(), v.end(), t) == v.end()) { // 如果不在内存中
v.push_back(t);
++ans;
}
if (v.size() > m)
v.erase(v.begin());
}
cout << ans << endl;
}

P1449 后缀表达式

表达式计算:

  • 不停读入。
  • 如果读到数字,就和之前的数字拼接:a = a * 10 + ch - '0'
  • 如果读到 . 就压栈
  • 如果读到运算符,就出栈两个数进行运算,结果再压栈
  • 如果读到 @ 结束
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
/**
* Author: Tang Qiao
*/
#include <bits/stdc++.h>
using namespace std;

stack<int> s;
int a, v1, v2;

int main() {
char ch;
while (cin >> ch) {
if (ch == '@') break;
if (ch >= '0' && ch <='9') {
a = a*10 + ch - '0';
} else if (ch == '.') {
s.push(a);
a = 0;
} else if (ch == '+') {
v1 = s.top(); s.pop(); v2 = s.top(); s.pop();
s.push(v1 + v2);
} else if (ch == '-') {
v1 = s.top(); s.pop(); v2 = s.top(); s.pop();
s.push(v2 - v1);
} else if (ch == '*') {
v1 = s.top(); s.pop(); v2 = s.top(); s.pop();
s.push(v1 * v2);
} else if (ch == '/') {
v1 = s.top(); s.pop(); v2 = s.top(); s.pop();
s.push(v2 / v1);
}
}
cout << s.top() << endl;
return 0;
}

P2058 海港

解法:用一个队列记录所有 24 小时内的船。用一个桶记录每个国家的乘客数量。

  • 每次有新船入队列的时候,更新桶。如果桶更新前是 0,则 ans++
  • 每次新船入队列后,检查最早的队列,如果超24 小时,则出队
  • 出队的时候,更新桶,如果桶的数量减为 0,则 ans--
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
/**
* Author: Tang Qiao
*/
#include <bits/stdc++.h>
using namespace std;

struct Node {
int t;
int len;
vector<int> v;
};

// 桶,记录每个国家的乘客数量
int cnt[100010], n, t, ans;
// 队列
queue<Node> q;

int main() {
ios::sync_with_stdio(false);
cin >> n;
for (int i = 0; i < n; ++i) {
Node a;
cin >> a.t >> a.len;
a.v.resize(a.len);
for (int j = 0; j < a.len; ++j) {
cin >> a.v[j];
if (cnt[a.v[j]] == 0) ans++;
cnt[a.v[j]]++;
}
q.push(a);
int min_t = a.t - 86400;
// 检查出列
a = q.front();
while (a.t <= min_t) {
for (int j = 0; j < a.len; ++j) {
cnt[a.v[j]]--;
if (cnt[a.v[j]] == 0) ans--;
}
q.pop();
a = q.front();
}
cout << ans << endl;
}
return 0;
}

P2234 营业额统计

把营业额往 set 里面放,这样数据就是有序的。然后用 lower_bound 查找大于等于 x 的值。

  • 如果找到了,那么波动就是 0
  • 如果没找到,比较当前位置和上一个位置与 x 的差,取较小那个;同时插入 x

取上一个位置的时候要处理一下边界,如果是在 s.begin()位置的话就不用处理了。

取当前位置的时候要处理一下,看看是不是在 s.end()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
/**
* Author: Tang Qiao
*/
#include <bits/stdc++.h>
using namespace std;

set<int> s;
int n, x, ans;
bool debug = false;

int main() {
ios::sync_with_stdio(false);
cin >> n;
cin >> x;
ans = x;
s.insert(x);
for (int i = 1; i < n; ++i) {
cin >> x;
set<int>::iterator it;
it = s.lower_bound(x);
if (it != s.end() && *it == x) {
continue;
} else {
int diff = INT_MAX;
if (it != s.end()) {
diff = min(diff, abs(*it-x));
}
if (it != s.begin()) {
it--;
diff = min(diff, abs(*it-x));
}
ans += diff;
s.insert(x);
}
}
cout << ans << endl;
return 0;
}

CSPJ 教学思考:数学题

数学题是信息学竞赛中重要的一类题目,通常包括几何、数论、容斥原理等。

本文将相关的题目归纳整理,用于教学。

质数相关

判断一个数是否为质数

此算法是很多数学相关题目的基础,在 GESP 二级中也有涉及。例如:B3840 找素数

其核心代码是:

1
2
3
4
5
6
bool isPrime(int a) {
for (int i = 2; i*i <=a; i++) {
if (a%i == 0) return false;
}
return true;
}

初学者在写的时候,要注意 i*ia 的比较是小于等于。

质因数分解

质因数分解的方法是从 2 开始试商,如果发现能整除,就把被除数中该因数去掉,关键代码是:

1
while (N % i == 0) N /= i;

这样经过几轮下来,N 的值会变得很小,最后 N 如果不为 1,N 就是最后一个质因数。

完整代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
vector<int> prime_facs(int N) {
vector<int> result;
for (int i = 2; i * i <= N; i++) {
if (N % i == 0) {
while (N % i == 0) N /= i;
result.push_back(i);
}
}
if (N != 1) { // 说明再经过操作之后 N 留下了一个素数
result.push_back(N);
}
return result;
}

练习题:

B3969 GESP202403 五级 B-smooth 数 的参考代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#include <bits/stdc++.h>
using namespace std;

int n, b, ans;

int getMaxPrime(int v) {
int ret = 0;
for (int i = 2; i*i <= v; i++) {
if (v%i == 0){
ret = max(ret, i);
while (v%i == 0) v/=i; // 把 v 的值缩小
}
}
ret = max(ret, v);
return ret;
}

int main() {
cin >> n >> b;
for (int i = 1; i <=n; ++i) {
int t = getMaxPrime(i);
if (t <= b) ans++;
}
cout << ans << endl;
return 0;
}

几何

P2241 统计方形

本题解法:每个矩形(包括正方形)都可以由一段左边线段和一段上边线段确定。因此,我们只需要枚举所有可能的线段。

对于一个长是 N 宽是 M 的棋盘。

  • 左边的线段长度为 1 的有 N 个,长度为 2 的有 N-1 个,…长度为 N 的有 1 个。
  • 上边的线段长度为 1 的有 M 个,长度为 2 的有 M-1 个,…长度为 M 的有 1 个。

所以:

  • 左边的线段一共有 (1+2+3+...+N)= N*(N+1)/2 个。
  • 上边的线段一共有 (1+2+3+...+M)= M*(M+1)/2 个。
  • 因此,总共有 N*(N+1)/2 * M*(M+1)/2 个矩形。

用相同的办法可以推导正方形的数量,方法如下:

  • 对于左边长度为 1 的线段有 N 个,相应的上边长度为 1 的线段有 M 个。
  • 所以可以构造出 N*M 个边长为 1 的正方形。

同理:

  • 对于左边长度为 2 的线段有 N-1 个,相应的上边长度为 2 的线段有 M-1 个。
  • 所以可以构造出 (N-1)*(M-1) 个边长为 2 的正方形。

以此类推,可以构造出 N*M + (N-1)*(M-1) + (N-2)*(M-2) + (N-M+1)*1 个正方形(N>M)。

另外,需要注意使用 long long 来保存结果。完整的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <bits/stdc++.h>
using namespace std;
unsigned long long n, m, ans1, ans2;
int main() {
cin >> n >> m;
ans1 = n*(n+1)/2 * m*(m+1)/2;
while (n > 0 && m > 0) {
ans2 += n*m;
n--; m--;
}
cout << ans2 << " " << ans1 - ans2 << endl;
return 0;
}

数论

P1044 栈

这道题可以先用暴力的办法把前面几个数打出来,然后我们能发现数的规律是:1,1,2,5,14,42,132,429,1430,….

这是计算组合中很常见的卡特兰数,卡特兰数有两种公式,第一种公式是:

  • f(n) = f(n-1) * (4 * n - 2) / (n + 1)

我个人觉得这个公式不太好记。另一个公式是:

这个递推式相对好记一点:即C(n) = C(0)*C(n-1) + C(1)*C(n-2) ... C(n-1)*C(0)

以下是用该递推式实现的答案:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/**
* Author: Tang Qiao
*/
#include <bits/stdc++.h>
using namespace std;

long long ans[19];
int main() {
int n;
cin >> n;
ans[0] = 1;
for (int i = 1; i <= n; i++) {
for (int j = 0; j < i; ++j) {
ans[i] += ans[j] * ans[i-1-j];
}
}
cout << ans[n] << endl;
return 0;
}

P3612 USACO17JAN Secret Cow Code S

这是一道 USACO 的题目,需要我们先找出规律,然后再试图求解。

此题找规律的技巧是分析坐标每次在折半还原时的变化规律。
为了分析规律,我们可以看每次翻倍时,坐标的关系变化。

对于一个长度为 N 的字符串S,每次其长度变为 2*N。所以,我们对每一位进行标号:

1 2 3 4... N N+1 N+2 N+N

其中,除 S[N] == S[N+1] 外(按题意,此项为特殊项),其它位置都符合如下规律:

  • S[1] == S[N+2]
  • S[N-1] == S[N+N]

所以,将右边的坐标减去 N 再减 1,就得到左边的坐标。

所以,设总长为 L, 如果 a 的位置在右半侧,则对应到左半侧的坐标关系是:

  • if (a == L/2+1) a = 1;
  • else a = a - L/2 - 1;

如此递归下去,直到位置落在最初的长度上。
因为字符下标是从 0 开始,所以下标最后要减 1.

最后注意用 long long 来转换坐标。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38

#include <bits/stdc++.h>
using namespace std;

string s;
long long a, n;
bool debug = false;

long long di(long long a, long long L) {
if (debug) {
// 可用 debug 查看坐标变化过程
cout << "test a = " << a << ", L = " << L << endl;
}
if (a <= n) {
return a;
} else {
// 如果 a 的位置在右半侧,则调整到左半侧
if (a > L/2) {
if (a == L/2 + 1) a = L/2;
else a = a - L/2 - 1;
}
return di(a, L/2);
}
}

int main() {
cin >> s >> a;
n = s.length();

// 求出开始往回递归时,字符串拼起来的长度 L
long long L = n;
while (L < a) L *= 2;

// 寻找 L 这个长度下,第 a 个字符相当于哪个位置
int ans = di(a, L);
cout << s[ans-1] << endl;
return 0;
}

CSPJ 教学思考:枚举

枚举就是把所有情况都尝试一遍。比较简单的用 for 循环即可,较复杂的枚举,需要用到递归。

P1304 哥德巴赫猜想

此题直接枚举每个合数拆解成两个质数和的所有可能性。为了避免重复计算质数,我们用一个 map 将其运算结果保存下来。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
/**
* Author: Tang Qiao
*/
#include <bits/stdc++.h>
using namespace std;

map<int, bool> rec;
bool isPrime(int n) {
if (rec.find(n) != rec.end()) {
return rec[n];
}
for (int i = 2; i*i <= n; ++i) {
if (n % i == 0) return rec[n] = false;
}
return rec[n] = true;
}

int main() {
int n;
cin >> n;
for (int i = 4; i <= n; i+=2) {
for (int j = 2; j <= i; ++j) {
if (isPrime(j) && isPrime(i-j)) {
printf("%d=%d+%d\n", i, j, i-j);
break;
}
}
}
return 0;
}

P2089 烤鸡

此题初看起来 N 很大,但是每种配料最多 3 克,一共 10 种,总克数最多为 30 克。所以超过 30 克的情况答案都为 0。

每种配料 3 种情况,一共 10 种配料,所以暴力枚举的时间复杂度 3^10 约为 59000,不会超时。

枚举的参考代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
/**
* Author: Tang Qiao
*/
#include <bits/stdc++.h>
using namespace std;

vector<vector<int> > ans;
vector<int> a(10);
int n;
void dfs(int pt, int tot) {
if (pt == 10) {
if (tot == n)ans.push_back(a);
return;
}
if (tot >= n) return;
for (int i = 1; i<=3; i++) {
a[pt] = i;
dfs(pt+1, tot+i);
}
}
int main() {
cin >> n;
dfs(0, 0);
cout << ans.size() << endl;
for (int i = 0; i < ans.size(); i++) {
for (int j = 0; j < ans[i].size(); j++) {
cout << ans[i][j] << " ";
}
cout << endl;
}
return 0;
}

P1706 全排列问题

全排列的问题有多种写法,此题可以直接用 STL 中的 next_permutation 函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/**
* P1706 全排列问题
*/
#include <bits/stdc++.h>
using namespace std;

int n, v[11];

int main() {
cin >> n;
for (int i = 0; i < n; ++i) {
v[i] = i+1;
}
do {
for (int i = 0; i < n; ++i) {
printf("%5d", v[i]);
}
printf("\n");
} while (next_permutation(v, v+n));
return 0;
}

P1157 组合的输出

其实组合也可以用 next_permutation 来实现。以 n=5,r=3 为例,具体方法是:

  • 构造一个只有 0 和 1 的数组,0 表示选中,1 表示未选中。
  • 数组初始状态:0 0 0 1 1,这样对应输出的是 1, 2, 3
  • 下一个状态: 0 0 1 0 1, 输出 1, 2, 4
  • 结束状态: 1 1 0 0 0,输出 3, 4, 5

以下是实现代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/**
* Author: Tang Qiao
*/
#include <bits/stdc++.h>
using namespace std;

int n, r;
int v[25]={0};

int main() {
cin >> n >> r;
for (int i = r; i < n; ++i) {
v[i] = 1;
}
do {
for (int i = 0; i < n; ++i) {
if (v[i] == 0) printf("%3d", i+1);
}
printf("\n");
} while (next_permutation(v, v+n));
return 0;
}

更多全排列的练习:

P3392 涂条纹

  • 这道题可以枚举蓝色色块开始的行号和结束的行号,时间复杂度为 O(N^2)。
  • 对于每一种情况,我们需要 N 的时间复杂度来检查。
  • 所以一共的时间复杂度是 N^3。

我们先预保存下来每行的各种颜色的色块数量,这样检查的时候就可以快速求解。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
/**
* Author: Tang Qiao
*/
#include <bits/stdc++.h>
using namespace std;

int cnt[55][128];

int main() {
int n, m;
cin >> n >> m;
for (int i = 0; i < n; i++) {
for (int j = 0; j < m; j++) {
char ch;
cin >> ch;
cnt[i][ch]++;
}
}
int ans = INT_MAX;
// 枚举蓝色行的起止
for (int i = 1; i < n; ++i) {
for (int j = i; j < n-1; ++j) {
int cost = 0;
for (int k = 0; k < i; ++k)
cost += m - cnt[k]['W'];
for (int k = i; k <= j; ++k)
cost += m - cnt[k]['B'];
for (int k = j+1; k < n; ++k)
cost += m - cnt[k]['R'];
ans = min(ans, cost);
}
}
cout << ans << endl;
return 0;
}

P3654 First Step

直接枚举每个起点。但是 k==1 时需要特判,因为 k==1 意味着向下和向右重复计算,需要除以 2。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
/**
*
* 陷阱:
* k=1时需要特判,因为k=1意味着向下和向右重复计算,需要除以2。
*
* Author: Tang Qiao
*/
#include <bits/stdc++.h>
using namespace std;

int n, m, k, ans;
char tu[110][110];

bool check(int x, int y, int dx, int dy) {
int nx = x, ny = y;
for (int i = 0; i < k; i++) {
if (nx >= n || ny >= m) return false;
if (tu[nx][ny] == '#') return false;
nx += dx;
ny += dy;
}
return true;
}

int main() {
ios::sync_with_stdio(false);
cin >> n >> m >> k;
for (int i = 0; i < n; i++) {
for (int j = 0; j < m; j++) {
cin >> tu[i][j];
}
}
for (int i = 0; i < n; i++) {
for (int j = 0; j < m; j++) {
if (check(i, j, 1, 0)) ans++;
if (check(i, j, 0, 1)) ans++;
}
}
if (k == 1) ans /= 2;
cout << ans << endl;
return 0;
}

P1149 火柴棒等式

NOIP 2008 提高组第二题。推导如下:

  • n 最大为 24。
  • 24 减去加号(2根火柴)和等号(2 根火柴),还剩 20 根。
  • 20 根分配到 3 个数字(A+B=C)上,平均每个数字 7 根,但也可能一个数特别大(10 根),另一个数特别小(2 根)。
  • 每个数字最少用量为 2 根火柴(数字 1)。

枚举办法:

  • 第 1 个数字 A 从 0 - 10000,计算出 A 用的火柴数 t1
  • 第 2 个数字 B 从 A - 10000,计算出 B 用的火柴数 t2
  • 算出来 A+B 的和 C,检查 C 用的火柴数是不是刚好是 n-t1-t2-4
  • 每找到一种,如果 A!=B,则计算两次答案,因为 B+A=C 是另一个对称的答案。

用以上的枚举之后,我们将所有答案输出,发现 A 其实在 N 最大(N=24)的时候也不会超过 1000,测试如下(只输出了 A<=B 的情况)。所以我们就可以将 A 的范围改小,或者直接打表输出答案。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
0+8=8
0+12=12
0+13=13
0+15=15
0+21=21
0+31=31
0+47=47
0+51=51
0+74=74
0+117=117
0+171=171
0+711=711
1+20=21
1+30=31
1+42=43
1+47=48
1+50=51
1+112=113
1+117=118
1+170=171
1+710=711
2+8=10
2+10=12
2+19=21
2+41=43
2+72=74
2+77=79
2+111=113
3+10=13
3+13=16
3+44=47
3+114=117
4+43=47
4+57=61
4+70=74
4+113=117
4+117=121
5+10=15
5+16=21
5+17=22
6+15=21
7+15=22
7+27=34
7+40=47
7+41=48
7+54=61
7+72=79
7+77=84
7+110=117
7+111=118
7+114=121
9+12=21
11+13=24
11+14=25
11+16=27
11+31=42
11+41=52
11+61=72
14+27=41
14+77=91
17+24=41
17+57=74
17+74=91
41+71=112

完成的程序如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
/**
* 把 A 和 B 的范围改成 10000,同时把 debug 改成 true 可以输出所有可能的组合。
* 经过测试发现 A和 B的答案范围小于 1000,所以可以改成 1000。
*
* Author: Tang Qiao
*/
#include <bits/stdc++.h>
using namespace std;

int num[] = {6,2,5,5,4,5,6,3,7,6};
unordered_map<int, int> record;
int n, ans;
bool debug = false;

int main() {
cin >> n;
// 初始化
for (int i = 0; i < 20000; i++) {
int tmp = i;
int cnt = 0;
do {
cnt += num[tmp % 10];
tmp /= 10;
}while (tmp > 0);;
record[i] = cnt;
}

n -= 4;
for (int i = 0; i < 1000; i++) {
for (int j = i; j < 1000; j++) {
int c = i + j;
if (record[i] + record[j] + record[c] == n) {
if (i != j) ans+=2;
else ans++;
if (debug) {
cout << i << "+" << j << "=" << c << endl;
}
}

}
}
cout << ans << endl;
return 0;
}

P3799 小 Y 拼木棒

思路如下:

  • 4 根木棒,先选出三根。肯定是有两根的和等于第三根。
  • 最后一根显然是和第三根一样长。
  • 所以,问题转换成:选两根木棒,同时再选两根他们的和,一共有多少种。

在选两根木棒的时候,我们又可以转化为:选一根木棒,然后选另一根大于等于它的木棒。

因为 a 的值在 5000 以内,而 N 最大是 10 万,所以可以把值放到一个计数的桶里面。这样枚举的时候效率更高。

解法:

  • 拿一个 cnt[] 数组保存每个数字出现的次数,同时记录最大值 maxv。
  • 从 1 到 maxv 枚举 a 和 b(其中保证 b 大于等于 a)
  • 计算两个数字的和 c,然后取 c 的次数。
  • 计算一共的组合数,结果对 10^9+7 取模。

参考代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
/**
* Author: Tang Qiao
*/
#include <bits/stdc++.h>
using namespace std;
#define MOD (int)(1e9 + 7)

unordered_map<int, int> cnt;
int n, x, maxv;
long long ans;

// 从 a 个数中选 2 个数的组合数
long long C(long long a) {
return a * (a - 1) / 2;
}

int main() {
ios::sync_with_stdio(false);
cin >> n;
for (int i = 0; i < n; i++) {
cin >> x;
cnt[x]++;
maxv = max(maxv, x);
}
for (int a = 1; a <= maxv; a++) {
for (int b = a; b <= maxv; b++) {
if (a == b && cnt[a] < 2) continue;
int c = a + b;
if (cnt[c] >= 2) {
long long base = C(cnt[c]) % MOD;
if (a == b)
base = base * C(cnt[a]) % MOD;
else
base = base * ((cnt[a] * cnt[b]) % MOD) % MOD;
ans = (ans + base) % MOD;
}
}
}
cout << ans << endl;
return 0;
}

P1028 数的计算

NOIP 2001 普及组 题目。在暴力枚举的时候,需要记住重复的计算。

参考代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/**
* Author: Tang Qiao
*/
#include <bits/stdc++.h>
using namespace std;

int n, ans, record[1010];

int dfs(int a) {
if (record[a] != 0) return record[a];
int ret = 1;
for (int i = 1; i <= a/2; ++i) {
ret += dfs(i);
}
record[a] = ret;
return ret;
}

int main() {
cin >> n;
ans = dfs(n);
cout << ans << endl;
return 0;
}

更多练习

P2437 蜜蜂路线

需要用到高精度。

参考代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
/**
* Author: Tang Qiao
*/
#include <bits/stdc++.h>
using namespace std;

string record[1001][1001];

string add(string a, string b) {
int len_a = a.length();
int len_b = b.length();
int len_max = max(len_a, len_b);
int carry = 0;
string ret = "";
for (int i = 0; i < len_max; i++) {
int num_a = i < len_a ? a[len_a - i - 1] - '0' : 0;
int num_b = i < len_b ? b[len_b - i - 1] - '0' : 0;
int sum = num_a + num_b + carry;
ret = to_string(sum % 10) + ret;
carry = sum / 10;
}
if (carry > 0) ret = to_string(carry) + ret;
return ret;
}

string dfs(int n, int m) {
if (n > m) return "0";
if (n == m) return "1";
if (record[n][m] != "") return record[n][m];
return record[n][m] = add(dfs(n+1, m), dfs(n+2, m));
}

int main() {
int n, m;
cin >> n >> m;
cout << dfs(n, m) << endl;
return 0;
}

CSPJ 教学思考:模拟

模拟是最有效的练习编程熟练度的基础算法,也是有效的掌握各种编程技巧的练习方式。

本文将把各种模拟技巧与题目结合,用题目带着学生掌握这些模拟技巧。

二维数组包边

有些时候,我们在处理二维数组的时候,需要处理 x,y 坐标的边界。这样写起来会比较麻烦,但是,如果我们将数据从下标 1 开始保存,那么就人为在数据外面留了一圈缓冲带。这个时候,在处理 x,y 周围坐标的时候,就不会出现数据下标越界的情况了。

例题:P2670 NOIP 2015 普及组 扫雷游戏

该题如果正常写,需要判断每个格子周围 8 个格子的状态。如果我们把数据从 1 开始读入,在判断的时候就容易很多。以下是参考代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
/**
* P2670 [NOIP 2015 普及组] 扫雷游戏
*
* Author: Tang Qiao
*/
#include <bits/stdc++.h>
using namespace std;

int n, m;
char tu[110][110];
int movex[] = {-1, -1, -1, 0, 0, 1, 1, 1};
int movey[] = {-1, 0, 1, -1, 1, -1, 0, 1};

int main() {
cin >> n >> m;
for (int i = 1; i <= n; ++i) {
for (int j = 1; j <= m; ++j) {
cin >> tu[i][j];
}
}

for (int i = 1; i <= n; ++i) {
for (int j = 1; j <= m; ++j) {
if (tu[i][j] == '*') continue;
int cnt = 0;
for (int k = 0; k < 8; ++k) {
int x = i + movex[k];
int y = j + movey[k];
if (tu[x][y] == '*') cnt++;
}
tu[i][j] = cnt + '0';
}
}

for (int i = 1; i <= n; ++i) {
for (int j = 1; j <= m; ++j) {
cout << tu[i][j];
}
cout << endl;
}
return 0;
}

例题:B4248 语言月赛 202503 数字棋盘

本题也可以用包边的技巧,保证数据在检查的时候不会越界。参考代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
#include <bits/stdc++.h>
using namespace std;

int n, m;
int a[1001][1001];
int x, y;

bool check(int i, int j) {
// 检查上方格子
if (i > 1 && a[i-1][j] == y) return true;
// 检查下方格子
if (i < n && a[i+1][j] == y) return true;
// 检查左侧格子
if (j > 1 && a[i][j-1] == y) return true;
// 检查右侧格子
if (j < m && a[i][j+1] == y) return true;
return false;
}

int main() {
ios::sync_with_stdio(false);
cin >> n >> m;
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= m; j++) {
cin >> a[i][j];
}
}
cin >> x >> y;
int count = 0;
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= m; j++) {
if (a[i][j] == x && check(i, j)) {
count++;
}
}
}
cout << count << endl;
return 0;
}

P10719 GESP202406 五级 黑白格

此题需要求枚举从坐标(x,y)到坐标(a,b)的 1 的个数。我们先用将从(0,0)到(a,b)的 1 的个数保存在一个数组 s[110][110]中,然后再通过容斥原理来进行快速求(i,j)到(a,b)中 1 的个数。具体方法如下:

第一步:对于每一个 s[i][j],满足:s[i][j] = s[i-1][j] + cnt,其中 cnt 为第 i 行前 j 个数中 1 的个数。于是,我们就可以递推求出所有的 s[i][j],代码如下:

1
2
3
4
5
6
7
for (int i = 1; i <= n; i++) {
int cnt = 0;
for (int j = 1; j <= m; j++) {
cnt += (tu[i][j] == '1');
s[i][j] = s[i-1][j] + cnt;
}
}

以上代码使用了“包边”的技巧,因为我们下标是从 1 开始的,所以下标 i-1 不会越界。

第二步:根据容斥原理。从坐标(i,j)到坐标(a,b)的 1 的个数为:s[a][b] - s[i-1][b] - s[a][j-1] + s[i-1][j-1]。如下图所示:

以上公式如果使用“包边”技巧,让有效坐标从 1 开始,也会帮助 i-1 的值不会越界。

完整代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
/**
* Author: Tang Qiao
*/
#include <bits/stdc++.h>
using namespace std;

int n, m, k, ans;
int s[110][110]; // 表示从(0,0)到(a,b)的 1 的个数
char tu[110][110];

int main() {
cin >> n >> m >> k;
for (int i = 1; i <= n; i++) {
cin >> tu[i]+1;
}
// 从第二行递推
for (int i = 1; i <= n; i++) {
int cnt = 0;
for (int j = 1; j <= m; j++) {
cnt += (tu[i][j] == '1');
s[i][j] = s[i-1][j] + cnt;
}
}
ans = INT_MAX;
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= m; j++) {
for (int a = i; a <= n; a++) {
for (int b = j; b <= m; b++) {
int cnt = s[a][b] - s[i-1][b] - s[a][j-1] + s[i-1][j-1];
if (cnt >= k) {
int tmp = (a-i+1) * (b-j+1);
if (tmp < ans) ans = tmp;
}
}
}
}
}
if (ans == INT_MAX) cout << 0 << endl;
else cout << ans << endl;
return 0;
}

围圈数数

有一种模拟题,要求我们把人围成一个圈,在圈上数数,然后问你数到的是谁。类似于小时候玩的“点兵点将”游戏,可能是顺时针数,也可能是逆时针数。

对于这种数数题目,最简单的做法是:直接用加减来进行目标的选择。加减之后,下标可能变负数或者超过总数,这个时候进行简单的取模调整,就可以将下标调整正常。

例题:P1563 玩具谜题

此题我们:

  • idx = (idx + b) % n; 来完成顺时针数
  • idx = (idx - b + n) % n; 来完成逆时针数

通过这样的简单的加减和取模,保证能够快速跳到目标位置,完成模拟操作。完整代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
/**
* Author: Tang Qiao
*/
#include <bits/stdc++.h>
using namespace std;
#define MAXN int(1e5 + 10)

int n, m;
int face[MAXN];
string name[MAXN];

int main() {
cin >> n >> m;
for (int i = 0; i < n; ++i) {
cin >> face[i] >> name[i] ;
}

int idx = 0;
for (int i = 0; i < m; ++i) {
int a, b;
cin >> a >> b;
// 圈内向左 == 圈外向右
if ((face[idx] == 0 && a == 0)
|| (face[idx] == 1 && a == 1)) {
idx = (idx - b + n) % n;
} else {
idx = (idx + b) % n;
}
}
cout << name[idx] << endl;
return 0;
}

例题:B4246 环形游走

此题有个技巧:就是走的时候可能绕多圈,这个时候我们先把要走的步数模 n: step % n, 这样就把前面的多圈跳过了,也不会把坐标减成特别特别小的负数。

参考代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/**
* Author: Tang Qiao
*/
#include <bits/stdc++.h>
using namespace std;

int main() {
int n, m;
cin >> n >> m;
vector<int> a(n);
for (int i = 0; i < n; i++) {
cin >> a[i];
}
int current = 0;
for (int i = 0; i < m; i++) {
int step = a[current] % n;
current = (current - step + n) % n;
}
cout << current + 1 << endl;
return 0;
}

更多练习:

例题:B3921 小杨的考试

绕圈一类的问题不止是以上那种真实的圈,也可能是像星期几这样逻辑上的圈(日期就像是一个圈,从星期一到星期日,然后又回到星期一)。

B3921 GESP202312 一级 小杨的考试这道题让我们计算日期。最简单的办法,是让星期几先落到 0-6 的表示法(0 表示星期一,6 表示星期日)。然后我们就可以用简单的加 N 天,然后模 7,快速定位到未来是星期几。对于过去,我们也可以简单通过减 N%7 天,然后减掉差的天数后 +7 再模 7,让结果落到 0-6 上。

参考代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
/**
* Author: Tang Qiao
*/
#include <bits/stdc++.h>
using namespace std;

int x, n;
int main() {
cin >> x >> n;
x = (x - 1 + n) % 7 + 1;
cout << x << endl;
return 0;
}

矩阵操作

矩阵操作这类模拟题,会要求我们在一个二维(或三维)的数组上进行各种操作,包括填充,旋转,查找,合并等。需要我们熟悉各种矩阵操作的技巧。

例题:P5725 求三角形

此题是一道基础的填充题。

  • 对于第一种要求,我们用在二维数组上填充实现。
  • 对于第二种要求,我们直接输出结果,在合适的位置加上一些空格。

示例代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
/**
* Author: Tang Qiao
*/
#include <bits/stdc++.h>
using namespace std;

int tu[11][11];
int n;
int main() {
cin >> n;

// 处理第一种要求
int cnt = 1;
for (int i = 1; i <= n; ++i) {
for (int j = 1; j <= n; ++j) {
tu[i][j] = cnt++;
}
}
for (int i = 1; i <= n; ++i) {
for (int j = 1; j <= n; ++j) {
printf("%02d", tu[i][j]);
}
printf("\n");
}
printf("\n");
// 处理第二种要求
cnt = 1;
int bk = n-1;
for (int i = 1; i <= n; ++i, bk--) {
for (int j = 1; j <= bk; ++j) printf(" ");
for (int j = 1; j <= i; ++j) {
printf("%02d", cnt++);
}
printf("\n");
}

return 0;
}

例题:P5461 赦免战俘

此题我们需要熟练使用递归来进行标记。参考代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
/**
* Author: Tang Qiao
*/
#include <bits/stdc++.h>
using namespace std;

int n, m;
char v[1100][1100];

void mark(int x, int y, int size) {
if (size == 1) return;
int half = size/2;
for (int i = x; i < x+half; ++i) {
for (int j = y; j < y+half; ++j) {
v[i][j] = '0';
}
}
mark(x, y+half, half);
mark(x+half, y, half);
mark(x+half, y+half, half);
}

int main() {
cin >> n;
m = 1<<n;
memset(v, '1', sizeof(v));
mark(0, 0, m);
for (int i = 0; i < m; ++i) {
for (int j = 0; j < m; ++j) {
cout << v[i][j] << " ";
}
cout << endl;
}
return 0;
}

例题:P5731 蛇形方阵

蛇形方阵是一道基础题,用于练习二维数组上的操作。我使用的模拟技巧是:

  • 定义一个 order 变量,表示当前方向
  • 与 order 变量配合,定义一个 movex 和 movey 数组,表示当前方向的移动

相关代码是:

1
2
3
int order;
int movex[] = {0, 1, 0, -1};
int movey[] = {1, 0, -1, 0};

每次移动,先判断是否越界或者已经填充过值:

  • 如果越界或已经填充过值,则改变方向再移动
  • 如果没越界,则移动

关键代码如下:

1
2
3
4
5
if (nx < 1 || nx > n || ny < 1 || ny > n || tu[nx][ny] != 0) {
order = (order + 1) % 4;
nx = x + movex[order];
ny = y + movey[order];
}

因为要填充 n*n 个数,所以循环一共执行 n*n 次。

完整的参考代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
/**
* Author: Tang Qiao
*/
#include <bits/stdc++.h>
using namespace std;

int n, x, y, order;
int tu[15][15];
int movex[] = {0, 1, 0, -1};
int movey[] = {1, 0, -1, 0};
int main() {
cin >> n;
memset(tu, 0, sizeof(tu));
x = 1;
y = 0;
order = 0;
for (int i = 1; i <= n*n; i++) {
int nx = x + movex[order];
int ny = y + movey[order];
if (nx < 1 || nx > n || ny < 1 || ny > n || tu[nx][ny] != 0) {
order = (order + 1) % 4;
nx = x + movex[order];
ny = y + movey[order];
}
x = nx;
y = ny;
tu[x][y] = i;
}
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= n; j++) {
printf("%3d", tu[i][j]);
}
printf("\n");
}
return 0;
}

例题:P4924 魔法少女小Scarlet

本题涉及矩阵的旋转,实际操作起来还是有点麻烦。这里我们按旋转的中心来重建坐标系的话,可以观察到如下规律:

  • 顺时针旋转:(a, b) -> (b, -a)
  • 逆时针旋转:(a, b) -> (-b, a)

这样,我们就可以构建关键的旋转代码了,假如我们是基于中心点 (x, y) 半径是 r 的顺时针旋转的话,那么,对于坐标 (a, b),我们:

  • 首先:把它移动到以 (x, y) 为中心:(a-x, b-y)
  • 然后:我们把坐标按上面的规则变换成 (b-y, x-a)
  • 最后:我们把坐标加上 (x, y) 的偏移,还原成原始坐标:(b-y+x, x-a+y)

以上逻辑写成代码是:g[b-y+x][x-a+y]=f[a][b]

同理,如果是逆时针旋转:

  • 首先:把它移动到以 (x, y) 为中心:(a-x, b-y)
  • 然后:我们把坐标按上面的规则变换成 (y-b, a-x)
  • 最后:我们把坐标加上 (x, y) 的偏移,还原成原始坐标:(y-b+x, a-x+y)

以上逻辑写成代码是:g[y-b+x][a-x+y]=f[a][b]

本题保证了数据不会在旋转时越界,整体的参考代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
/**
* Author: Tang Qiao
*/
#include <bits/stdc++.h>
using namespace std;

#define MAXN 510
int f[MAXN][MAXN], g[MAXN][MAXN];
int n, m;
int main() {
cin >> n >> m;
int cnt = 1;
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= n; j++) {
f[i][j] = cnt++;
}
}
for (int i = 1; i <=m; ++i) {
int x, y, r, z;
cin >> x >> y >> r >> z;
if (z == 0) {
for (int a = x-r; a <= x+r; ++a)
for (int b = y-r; b <= y+r; ++b)
g[b-y+x][x-a+y]=f[a][b];

} else {
for (int a = x-r; a <= x+r; ++a)
for (int b = y-r; b <= y+r; ++b)
g[y-b+x][a-x+y]=f[a][b];
}

for (int a = x-r; a <= x+r; ++a)
for (int b = y-r; b <= y+r; ++b)
f[a][b] = g[a][b];
} // end of m
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= n; j++) {
cout << f[i][j] << " ";
}
cout << endl;
}
return 0;
}

例题:P1205 USACO1.2 方块转换 Transformations

此题需要推导矩阵旋转的规律。我们可以把原坐标和新坐标写下来,做成一个表格。

然后,我们把坐标的变化写成下面的表格形式:

通过观察,我们发现:

  • 黄色和红色的坐标在变换前后刚好相等,即: 新 x = 原 y
  • 两侧的白色的坐标加和刚好等于 n-1,即:原 x + 新 y = n - 1 => 新 y = n - 原 x - 1

综上,坐标变换公式为:新(y, n-x-1)=原(x, y)

所以,坐标变换相关代码为:

1
2
3
4
5
for (int x = 0; x < n; ++x) {
for (int y = 0; y < n; ++y) {
tmp[y][n-x-1] = ori[x][y];
}
}

与此类似,我们可以推出“反射”的代码关系是 新(x,n-y-1)=原(x,y),相关变换代码为:

1
2
3
4
5
for (int x = 0; x < n; ++x) {
for (int y = 0; y < n; ++y) {
tmp[x][n-y-1] = ori[x][y];
}
}

完整的参考代码如下(可以把 debug 变量设置成 true 来查看执行过程):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
/**
* Author: Tang Qiao
*/
#include <bits/stdc++.h>
using namespace std;

char ori[12][12], dest[12][12];
char tmp[12][12], tmp2[12][12];
int n;
bool debug = false;

bool match(char a[12][12], char b[12][12]) {
for (int x = 0; x < n; ++x) {
for (int y = 0; y < n; ++y) {
if (a[x][y] != b[x][y]) return false;
}
}
return true;
}

void print(char a[12][12]) {
for (int i = 0; i < n; ++i) {
cout << a[i] << endl;
}
}

int main() {
cin >> n;
for (int i = 0; i < n; ++i) {
cin >> ori[i];
}
for (int i = 0; i < n; ++i) {
cin >> dest[i];
}
// 方案一
for (int x = 0; x < n; ++x) {
for (int y = 0; y < n; ++y) {
tmp[y][n-x-1] = ori[x][y];
}
}
if (debug) {
cout << "debug 1: " << endl;
print(tmp);
}
if (match(tmp, dest)) {
cout << "1" << endl;
return 0;
}
// 方案二
for (int x = 0; x < n; ++x) {
for (int y = 0; y < n; ++y) {
tmp2[y][n-x-1] = tmp[x][y];
}
}
if (match(tmp2, dest)) {
cout << "2" << endl;
return 0;
}
// 方案三
for (int x = 0; x < n; ++x) {
for (int y = 0; y < n; ++y) {
tmp[y][n-x-1] = tmp2[x][y];
}
}
if (match(tmp, dest)) {
cout << "3" << endl;
return 0;
}
// 反射
for (int x = 0; x < n; ++x) {
for (int y = 0; y < n; ++y) {
tmp[x][n-y-1] = ori[x][y];
}
}
if (match(tmp, dest)) {
cout << "4" << endl;
return 0;
}
// 反射+旋转90
for (int x = 0; x < n; ++x) {
for (int y = 0; y < n; ++y) {
tmp2[y][n-x-1] = tmp[x][y];
}
}
if (debug) {
cout << "debug 5-1: " << endl;
print(tmp2);
}
if (match(tmp2, dest)) {
cout << "5" << endl;
return 0;
}
// 反射+旋转180
for (int x = 0; x < n; ++x) {
for (int y = 0; y < n; ++y) {
tmp[y][n-x-1] = tmp2[x][y];
}
}
if (debug) {
cout << "debug 5-2: " << endl;
print(tmp);
}
if (match(tmp, dest)) {
cout << "5" << endl;
return 0;
}
// 反射+旋转270
for (int x = 0; x < n; ++x) {
for (int y = 0; y < n; ++y) {
tmp2[y][n-x-1] = tmp[x][y];
}
}
if (debug) {
cout << "debug 5-3: " << endl;
print(tmp2);
}
if (match(tmp2, dest)) {
cout << "5" << endl;
return 0;
}
// 不改变
if (match(ori, dest)) {
cout << "6" << endl;
return 0;
}
cout << "7" << endl;
return 0;
}

更多练习:

游戏模拟

游戏模拟类的题目通常会告诉你一个相对复杂一点的游戏规则,然后让你用程序将这个游戏规律实现,最终将游戏的结果输出出来。

这种题目一方面考查了读题能力,需要对游戏规则的理解清楚,另一方面则是要对游戏规则进行建模,用合适的数据结构实现游戏中的模拟。

以下是一些相关的题目。

题号 描述
P1042 NOIP 2003 普及组 乒乓球
P1328 NOIP 2014 提高组 生活大爆炸版石头剪刀布
P1518 USACO2.4 两只塔姆沃斯牛 The Tamworth Two
P1089 NOIP 2004 提高组 津津的储蓄计划
P1161 数组标记

滑动窗口

例题:P1614 爱与愁的心痛

此题的解法是:构造一个“滑动的窗口”。先求出前 m 个数的和,这相当于窗口的原始位置。之后每次让窗口往右移动一格。每次移动的时候,会将最左侧的数字剔除,同时纳入一个新数字。如下图所示:

我们在滑动窗口的时候,需要用这个变量,分别指向:

  • 当前窗口最左的数字 p1
  • 当前窗口下一个要加入的数字 p2
  • 在滑动的时候,不断更新当前窗口的值 tot

以下是关键代码:

1
2
3
4
5
6
7
8
p1 = 0;
p2 = m;
while (p2 < n) {
tot -= v[p1];
tot += v[p2];
ans = min(ans, tot);
p1++; p2++;
}

完整代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
/**
* Author: Tang Qiao
*/
#include <bits/stdc++.h>
using namespace std;

int n, m, tot, ans;
int p1, p2;
int v[3300];
int main() {
cin >> n >> m;
for (int i = 0; i < n; ++i) {
cin >> v[i];
}
// 初使化滑动窗口
tot = 0;
for (int i = 0; i < m; ++i) {
tot += v[i];
}
ans = tot;
p1 = 0;
p2 = m;
// 滑动窗口,更新值
while (p2 < n) {
tot -= v[p1];
tot += v[p2];
ans = min(ans, tot);
p1++;
p2++;
}
cout << ans << endl;
return 0;
}

模拟输入输出

有一些模拟需要我们有比较复杂的输入和输出操作技巧。在模拟输入和输出的时候,常用的两个函数是 sscanfsnprintf,其中:

  • sscanf 允许我们从一个字符串中读入内容。
  • snprintf 允许我们将输出内容先输出到一个字符串中。

以下我们用例题来演示其用法。

例题:P1957 口算练习题

此题的输入长度不固定,我们需要先判断输入的长度。同时,输出的时候,我们还需要输出“输出内容”的长度。这对我们处理输入和输出都带来了挑战。

我们可以把表达式整行整行读入,再用 sscanfsnprintf 来进行分析处理。以下是相关的示意:

1
2
3
4
int a, b;
char s[100], out[100];
sscanf(s, "%d%d", &a, &b);
snprintf(out, sizeof(out), "%d+%d=%d", a, b, a + b);

另外,我们还需要一次读入一整行,我用的方法是用 scanf, 代码如下:

1
2
scanf("%[^\n]", s);
getchar();

需要注意,以上代码每读入一行,需要用 getchar() 将行末的换行给读掉。

我们也可以用 cin.getline(s, sizeof(s)); 来读取数据。

以下是完整的示意代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
/**
* Author: Tang Qiao
*/
#include <bits/stdc++.h>
using namespace std;

int T, a, b;
char ch, s[100], out[100];

int main() {
scanf("%d", &T); getchar();
while (T--) {
scanf("%[^\n]", s); getchar();
if (s[0] >='0' && s[0] <= '9') { // 也可使用函数: isdigit(s[0])
sscanf(s, "%d%d", &a, &b);
} else {
sscanf(s, "%c%d%d", &ch, &a, &b);
}
memset(out, 0, sizeof(out));
if (ch == 'a') {
snprintf(out, sizeof(out), "%d+%d=%d", a, b, a + b);
} else if (ch == 'b') {
snprintf(out, sizeof(out), "%d-%d=%d", a, b, a - b);
} else if (ch == 'c') {
snprintf(out, sizeof(out), "%d*%d=%d", a, b, a * b);
}
printf("%s\n", out);
printf("%lu\n", strlen(out));
}
return 0;
}

以上的 scanf 部分如果替换成 cin,示意代码如下:

1
2
3
4
5
6
7
8
9
int main() {
cin >> T;
cin.getline(s, sizeof(s));
while (T--) {
cin.getline(s, sizeof(s));
// 省略
}
return 0;
}

例题:P1067 多项式输出

此题是 NOIP 2009 普及组的题目。此题练习了 snprintf 的使用。同时,此题用 printf 的 %+d 可以保证正数输出带+号。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
/**
* Author: Tang Qiao
*/
#include <bits/stdc++.h>
using namespace std;

int n;
string ans;
char outs[100];

int main() {
ans = "";
cin >> n;
for (int i = n; i>=0; i--) {
int a;
cin >> a;
// 系数为0,跳过
if (a == 0) continue;
// 指数为0,单独处理
if (i == 0) {
memset(outs, 0, sizeof(outs));
snprintf(outs, sizeof(outs), "%+d", a);
ans += outs;
} else {
// 先处理系数
if (a == 1) {
snprintf(outs, sizeof(outs), "+x");
} else if (a == -1) {
snprintf(outs, sizeof(outs), "-x");
} else {
snprintf(outs, sizeof(outs), "%+dx", a);
}
ans += outs;
// 再处理指数
memset(outs, 0, sizeof(outs));
if (i == 1) {
snprintf(outs, sizeof(outs), "");
} else {
snprintf(outs, sizeof(outs), "^%d", i);
}
ans += outs;
}
}
if (ans[0] == '+') {
ans = ans.substr(1);
}
cout << ans << endl;
return 0;
}

P1010 NOIP 1998 普及组 幂次方

此题的技巧是利用递归来循环处理。特殊情况是 2^1 写作 2,而不是 2(0)。参考代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
/* 
* Author: Tang Qiao
*/
#include <bits/stdc++.h>
using namespace std;

string conv(int n) {
if (n == 0) return "0";
else if (n == 1) return "2(0)";
else if (n == 2) return "2";
else {
string ret = "";
int base = 1;
int cnt = 0;
while (n >= base) {
if (n & base) {
string sub = "";
if (base == 2) sub = "2";
else sub = "2("+conv(cnt)+")";

if (ret == "") ret = sub;
else ret = sub + "+" + ret;
}
base <<= 1;
cnt++;
}
return ret;
}
}


int main() {
int n;
cin >> n;
cout << conv(n) << endl;
return 0;
}

更多练习:

字符串操作

B3927 GESP202312 四级 小杨的字典

此题需要操作字符进行替换操作,是比较复杂的字符串模拟。此题我们可以用 map 来简化字符串的映射关系管理。map 的 find 函数可以返回一个迭代器,该迭代器的值:

  • 当查找失败时,值为 end()
  • 当查找成功时,值为一个 pair,分别是对应查询的 key 和 value。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
/**
* Author: Tang Qiao
*/
#include <bits/stdc++.h>
using namespace std;

int n;
map<string, string> m;
map<string, string>::iterator it;
string a, b, s;

void process(string& s) {
if (s.length() != 0) {
it = m.find(s);
if (it!=m.end()) cout << it->second;
else cout << "UNK";
s = "";
}
}

int main() {
ios::sync_with_stdio(false);
cin >> n;
while (n--) {
cin >> a >> b;
m[a] = b;
}
char ch;
s = "";
while (cin >> ch) {
if (ch>='a' && ch <='z') s = s + ch;
else {
process(s);
cout << ch;
}
}
process(s);
return 0;
}

其它模拟题目

题号 描述
P1241 括号序列 考查语文能力,容易读错题意

P1241 括号序列

此题纯考读题。在找小括号对应的左括号的时候,找到 ([都算找到。只是找到后,如果不匹配,就算匹配失败。否则算匹配成功。

参考代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
/**
* Author: Tang Qiao
*/
#include <bits/stdc++.h>
using namespace std;

// 原串
string s;
// 匹配成功标记
bool flag[110];

bool match(char a, char b) {
return (a == '(' && b == ')') || (a == '[' && b == ']');
}

string change(char a) {
if (a == '(' || a == ')') return "()";
else return "[]";
}

int main() {
cin >> s;
for (int i = 0; i < s.length(); ++i) {
// 如果它是一个右括号
if (s[i] == ']' || s[i] == ')') {
// 考察它与它左侧离它最近的未匹配的的左括号
for (int j = i-1; j >=0; --j) {
// 如果该括号与之对应(即小括号匹配小括号,中括号匹配中括号),
// 则将二者配对。如果左侧未匹配的左括号不存在或与之不对应,则其配对失败。
if (!flag[j] && (s[j] == '(' || s[j] == '[')) {
if (match(s[j], s[i])) {
flag[i] = flag[j] = true;
}
break;
}
}
}
}
for (int i = 0; i<s.length(); ++i) {
if (flag[i]) cout << s[i];
else cout << change(s[i]);
}
return 0;
}

斑马思维机的详细调研

一、产品介绍

斑马思维机是针对 2-8 岁儿童的全科启蒙学习机。由在线教育集团“猿辅导”旗下的斑马品牌在 2022 年 11 月推向市场,并在 2023 年 8 月升级为二代产品:斑马思维机 G2。

它包含语文、思维、英语、音乐等学科内容,通过纸质的题卡结合点触交互的形式,让孩子在不同情景主题场景下互动,通过互动答题的形式,完成内容的教学。插卡自动出题,孩子通过点触答题。答对有鼓励,答错会有提醒,孩子可以自主完成从插卡到答题的整个过程。

相比别的早教学习机,斑马思维机的核心特点是没有传统的屏幕。它用纸质题卡来完成学习交互,在完成学习的同时可以有效保护低幼孩子的眼睛,防止过早接触电子屏幕产生沉迷。

产品上线后累计销量突破 100 万台,2023 年和 2024 年连续两年全国销量第一

斑马思维机主要具备如下产品优势:

1、专业教研

团队邀请了三位行业专家共同参与内容研发,分别是:

  • 曹立宏教授:中国传媒大学的脑科学专家。
  • 刘嘉教授:清华大学心理学专家。同时也是“最强大脑”节目的科学总顾问。
  • 蔡可教授:首都师范大学教育学专家。同时也是语文新课标的制定者。

在以上专家参与的同时,斑马结合自己斑马 AI 学产品的 3000 万用户的 100 亿次线上作答数据,为题卡的编制提供大数据支撑。

斑马思维机题卡构建了科学合理的分级进阶体系,分设 S0、S1、S2、S3 4 个难度级别。这种设置充分考虑了 2-8 岁儿童不同阶段的认知水平和思维发展能力。题卡难度逐阶递增、螺旋上升,能够循序渐进地开发儿童大脑潜能。

2、纸屏护眼

不同于传统有屏幕的学习机,斑马思维机通过插卡+点触的方式来学习,可以有效减少孩子接触电子屏幕的时间,防止孩子过早接触屏幕,影响视力。

每张题卡上都有丰富的主题元素,帮助孩子建立起学习的兴趣。

每张纸质题卡都用了食品级白卡和大豆油墨印刷,保证对孩子安全。

3、全科启蒙

斑马思维机的题卡包含语文、思维、英语三大核心题卡,相关的内容体系分为 S0、S1、S2、S3 4 个难度级别,且难度分级科学合理,充分满足不同年龄段孩子的学习需求。其中:

级别 针对年龄 培养重点
S0 2-3 习惯养成
S1 3-4 兴趣培养
S2 4-5 知识积累
S3 5+ 应用拓展

4、无限扩展

斑马思维机的题卡支持无限扩展,随着产品研发不断的持续,斑马思维机在语文、思维、英语题卡的基础上,又逐步上新了迪士尼、鲨鱼宝宝、音乐、专注力、故官等主题题卡。其中:

  • 2023 年 12 月,与迪士尼官方合作上新迪士尼题卡。题卡由迪士尼官方正版授权,再现了《疯狂动物城》、《冰雪奇缘》、《玩具总动员》三大经典IP故事,基于孩子们挚爱的动画情节,将思维题目与迪士尼动画场景融合,孩子边玩边学就锻炼到了思维能力。

  • 2024 年 7 月,与“打开故宫”合作上新故宫题卡。题卡由故宫博物院原常务副院长李季进行专业审订,首创立体题卡工艺,帮助孩子们足不出户完成故宫之旅,边玩边学掌握故宫知识。

  • 2024 年 10 月,与 Pinkfong 联名推出鲨鱼宝宝题卡。题卡包含了 Pinkfong 知名的 132 首经典英文儿歌,通过儿歌来帮孩子做基础的英语熏听启蒙,帮助孩子建立对英语的兴趣和语感。其中的儿歌 《Baby shark》为全球播放量第一的儿歌(吉尼斯世界记录认证)。

  • 2024 年 12 月,推出音乐题卡。内容包括 38 组乐理知识、52 种乐器探索、16 种音乐文化和 48 首儿歌鉴赏,帮助孩子完成音乐启蒙。

  • 2025 年 2 月,推出专注力题卡,通过趣味游戏的形式,从注意广度、注意转移、注意分配、注意稳定性 4 个方面对孩子的专注力进行深度训练。

二、内容体系

语文

斑马思维机语文题卡共 265 张,包括 6 个知识模块:汉字、词语、成语常言、古诗歌谣、表达结构、国学常识。另外在 S3 级别中,额外增加了拼音专题。

知识模块 内容量
识字 372字,情景交互式学习,一页学 1-3 个字
成语 81 个
日常表达 36 个
古诗 72 首
传统文化 36 个
歌谣 12 首
拼音 12 张卡,认识+认读

思维

斑马思维机思维题卡共 241 张,包括 6 个知识模块:视听与记忆、数感与模型、图形与空间、逻辑与规律、实践与规划、动手与益智。

英语

斑马思维机英语题卡共 265 张,包括 5 个知识模块:字母与发音、单词、句型、儿歌、拓展应用。

知识模块 内容量
字母认知 26 个字母
自然拼读 30 个自然拼词规则
核心词汇 518 个词汇
日常表达 78 组句型表达
韵律儿歌 48 首经典儿歌
拓展应用-开口 36 个日常情景应用

音乐

音乐题卡共 72 张,内容包括 38 组乐理知识、52 种乐器探索、16 种音乐文化和 48 首儿歌鉴赏,帮助孩子完成音乐启蒙。

专注力

专注力题卡共 72 张,内容从注意广度、注意转移、注意分配、注意稳定性 4 个方面对孩子的专注力进行深度训练。

鲨鱼宝宝题卡

鲨鱼宝宝共 36 张,题卡包含了 Pinkfong 知名的 132 首经典英文儿歌。通过儿歌共熏听了 1400+ 单词,包含了 81% 的小学新课标二级核心词汇。

市场表现与竞争分析

竞争壁垒

斑马思维机为思维机品类开创者,拥有 6 项思维机专利和 8 项国际大奖。

斑马思维机专利情况:

专利名称 专利公告
机器专利1 http://epub.cnipa.gov.cn/cred/CN219533902U
机器专利2 http://epub.cnipa.gov.cn/cred/CN219609810U
结构专利 http://epub.cnipa.gov.cn/cred/CN219831980U
外观专利 http://epub.cnipa.gov.cn/cred/CN307609057S
立体题卡专利 http://epub.cnipa.gov.cn/cred/CN221766203U
滑动交互专利 http://epub.cnipa.gov.cn/cred/CN221613415U

斑马思维机获奖情况:

  • Tillywig Toy Awards(堤利威格玩具奖),美国玩具行业最顶级的奖项之一
  • Creative Child Awards(儿童创意大奖),儿童创造力培养领域享有盛誉的国际大奖
  • K Design Award(K设计大奖),享誉全球的国际专业设计大奖
  • Mom’s Choice Awards(妈妈之选),国际母婴产品领域标杆奖项
  • The National Parenting Center Seal of Approval,美国国家育儿中心专业认证
  • Contemporary Good Design Award,当代好设计奖
  • TOY AWARD,中外玩具大奖
  • IDEA,国际卓越设计奖

以上专利和奖项为斑马思维机提供了不少竞争优势,帮助它持续提升产品端的用户体验。

市场销量

上市以来,斑马思维机市场销量表现出色,受到众多家长青睐。在各大电商平台,其销售数据持续增长,斑马思维机连续两年稳居思维机品类的销量和销售额第一。

由以上数据可知,斑马思维机的市场占有率进一步扩大,从 2024 年初的 52.8% 上升到 2025 年初的 66.6%,进一步巩固了市场第一的地位。

在京东平台提供的 2025 年思维机热卖榜上,斑马思维机已连续占据榜首 131 天(数据截至 2025.03.09 )。

在天猫平台提供的 2025 年学习机热卖榜上,斑马思维机占据 2000 元以下学习机热卖榜第一(数据截至 2025.03.09 )。

同类思维机产品比较

斑马思维机的主要竞争产品为学而思旗下的摩比思维机(又名:学而思思维机)。斑马思维机和摩比思维机哪个好呢?以下是一些多维度的比较数据。

1、发布时间

从发布时间上看,斑马思维机较早,具有较大的先发优势:

  • 斑马思维机 G1 在 2022 年 11 月正式发布,而摩比思维机正式发布的时间为 2023 年 5 月,落后斑马思维机 6 个月。
  • 斑马思维机随后在 2023 年 8 月发布二代机型,而摩比思维机的二代机型同样落后半年多,在 2024 年 4 月发布

较早的发布使斑马获得了更多的销量,并从销量中获得了更多的用户反馈,也积累了更多的用户迭代数据。这些数据和反馈帮助斑马思维机做到了更好的产品体验。用户普遍反馈斑马思维机点触灵敏;而摩比思维机点触通常不太灵敏,孩子点不准容易受到挫折,从而打击学习积极性。所以,从机器点触灵敏度角度,更推荐大家使用斑马思维机。

2、题卡设置

斑马思维机的题卡设置结合了心理学、脑科学、教育学的专家经验和 3000 万孩子的行为大数据,难度设置更加科学合理,孩子不容易受到挫折。

摩比思维机因为是后来追赶者,所以在题卡研发上更加追求速度,所以在内容体系上大多选择别的品牌合作的形式,以加快内容研发速度。摩比在语文题卡上与“四五快读”合作,在英语题卡上与“剑桥英语”及“RAZ”合作,低龄题卡与小猪佩奇合作。

但是合作的形式使得摩比的题卡体系性和衔接性较差。例如:

  • 斑马的语文分为 S0-S4 4 个级别,难度螺旋上升,对各个年龄段的孩子都很适配。摩比的语文因为“四五快读”只有识字,所以无法分级,只能提供识字包、古词包、拼音包这种专题形式。同时“四五快读”的趣味性较低,不太适合 2-4 岁的孩子启蒙,降低了低龄孩子家长的好感度和选购意愿。

  • 斑马的英语为全美语体系。但是摩比的英语题卡分为英式英语的“剑桥英语”系列和美式英语的“RAZ”系列。两个系列混合提供不利于孩子建立标准的英语发音环境,家长会担心孩子练成既不英式也不美式的奇怪发音。

  • 小猪佩奇题卡依赖于小猪佩奇的 IP,但近年来小猪佩奇的热度降低,进一步影响了摩比思维机的售卖。

所以,斑马思维机的题卡更受大部分的家长和孩子的喜爱。

3、硬件配置

两者都是 Type-C 口的充电款机器。

  • 斑马思维机的机器重量为 400g,较为轻便,方便携带,无需联网即可使用。
  • 摩比思维机的机器重量为 500g,较为厚实,需要下载 App 连接 Wifi 才可激活使用。

在升级时,斑马思维机通过 U 盘升级,摩比思维机通过连接 Wifi 升级。

4、销量排名

公开数据,斑马思维机销量排名第一。其它思维机销量排名未知。

CSPJ 教学思考:并查集

并查集在引入之前,需要先教会学生集合的概念。

集合

集合是数学中的一个基本概念,它是由一些确定的、彼此不同的对象所组成的整体。集合有两个特点:

  • 集合中的元素是互不相同的。
  • 集合中的元素没有顺序之分。比如集合 {1, 2, 3} 和 {3, 2, 1} 是同一个集合。

生活中的集合有很多,比如:班级,家庭成员,朋友等等。所以,学生还是比较容易理解的。

并查集

并查集是一种用于管理元素所属集合的数据结构,实现为一个森林,其中每棵树表示一个集合,树中的节点表示对应集合中的元素。

并查集支持两种操作:

  • 查询(Find):查询某个元素所属集合(查询对应的树的根节点),这可以用于判断两个元素是否属于同一集合
  • 合并(Merge):合并两个元素所属集合(合并对应的树)

在教学并查集的时候,画示意图可以很好地让学生理解并查集的操作。

并查集的初始化

我们用数组来表示并查集,用数组的值表示当前结点的父亲。如下图所示:

所以,初始化的代码如下:

1
2
3
4
5
6
7
8
#define MAXN 1010

int p[MAXN], n;
void init() {
for (int i = 0; i <= n ; ++i) {
p[i] = i;
}
}

并查集的查询操作

并查集在查询时,从初始结点开始,判断自己是不是根结点。根结点的特征是自己是自己的父亲。如果自己不是根结点,则继续递归往上找。示例代码如下:

1
2
3
4
int find(int a) {
if (p[a] == a) return a;
return find(p[a]);
}

我们在这儿,也顺便引入路径压缩的优化,告诉学生在返回值的时候,如果更新结点,就可以把下图中的长路径“拍扁”,使得下次查询的时候速度更快。

那么如何更新呢?只需要在上面的代码基础上做一点点改动,如下:

1
2
3
4
int find(int a) {
if (p[a] == a) return a;
return p[a] = find(p[a]); // 在返回值之前,更新结点值
}

以上代码可以简化成一行:return p[a]==a ? a : (p[a] = find(p[a]));。但是教学的时候,还是展开让学生理解清楚后,再提供简化的写法比较好。

并查集的合并操作

合并的时候,像上图那样,我们把一个结点的根结点的父亲,指向另外一个根结点即可。

1
2
3
4
5
void merge(int a, int b) {
int pa = find(a);
int pb = find(b);
p[pa] = pb;
}

以上代码可以简化成一行:p[find(a)]=find(b);。但是教学的时候,还是展开让学生理解清楚后,再提供简化的写法比较好。

判断并查集中集合的个数

因为有一个根结点,就代表有一个集合,所以我们可以数根结点的个数来得到集合的个数。

根结点的特点是:它的父结点就是自己。相关代码如下:

1
2
3
4
5
6
int cnt = 0;
for (int i = 1; i <=n; ++i) {
if (p[i] == i) {
cnt++;
}
}

并查集的练习题

完成以上的基础教学,就可以练习了。并查集的考查主要就是两个:

  • 判断两点是否联通
  • 计算连通块(集合)的个数

以下是基础的练习题目。

题目 说明
P1551 亲戚 基础题
P1536 村村通 基础题
P1892 团伙 提高题,需要用反集
P3144 Closing the Farm S USACO 16 OPEN
P1197 星球大战 JSOI 2008
P2024 食物链 NOI 2001
P1196 银河英雄传说 NOI 2002

反集

当题目中引入了敌人关系,并且定义:“敌人的敌人是朋友”的时候,就可以用反集来求解了。

反集专门用于表示敌对关系,并且敌人的敌人是朋友。反集的思路是再构造一个集合(称之为反集),然后将“敌人”关系通过原集和反集表示出来。

我们看个例子:

比如假设有 3 个元素,1, 2, 3。我们称他们的反集元素分别为 1' , 2', 3'; 分别表示 1, 2, 3 的敌人。

这个时候,如果 1 和 2 是敌人,则:

  • 因为 1' 也是 1 的敌人, 所以 1' 和 2 是朋友
  • 因为 2' 也是 2 的敌人, 所以 2' 也是 1 的朋友

结果表示如下:

这个时候,如果 2 和 3 是敌人,则

  • 2 和 3` 是朋友
  • 3 和 2` 是朋友

结果表示如下:

我们可以看到,在这种操作下,1 和 3 自然就在一个集合中了(成为朋友了)。

以上逻辑在并查集中如何实现呢?我们将并查集的下标扩展一倍,用 n+1 ~ 2n 来表示反集元素。其中,元素 a 的反集是 a+n。

这个时候,如果 a 与 b 是敌人,则需要在并查集中做如下操作:

  • 因为 a 与 b 是敌人,所以 a 与 b+n 就是朋友,需要 merge(a, b+n);
  • 因为 a 与 b 是敌人,所以 b 与 a+n 就是朋友,需要 merge(b, a+n);

P1892 团伙 是反集的典型例题,可以拿此题练习。

需要特别注意的是,因为此题需要判断集合数量,所以需要让 1~n 的元素当根结点,涉及合并操作的时候,不要让 1~n 的元素当反集元素的孩子。关健代码如下:

1
2
3
4
5
6
void merge(int a, int b) {
int fa = find(a);
int fb = find(b);
// b 有可能是反集,所以始终让 fb 在合并的时候当子结点
p[fb] = fa;
}

P1892 团伙 的完整参考代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
#include <bits/stdc++.h>
using namespace std;

int p[2010], n, m;

int find(int a) {
if (p[a] == a) return a;
return p[a] = find(p[a]);
}

void merge(int a, int b) {
int fa = find(a);
int fb = find(b);
// b 有可能是反集,所以始终让 fb 在合并的时候当子结点
p[fb] = fa;
}

int main() {
for (int i = 0; i < 2010; ++i) {
p[i] = i;
}
scanf("%d%d", &n, &m);
for (int i = 0; i < m; ++i) {
char ch[3];
int a, b;
scanf("%s%d%d", ch, &a, &b);
if (ch[0] == 'F') {
merge(a, b);
} else {
merge(a, b+n);
merge(b, a+n);
}
}
int cnt = 0;
for (int i = 1; i <=n; ++i) {
if (p[i] == i) {
cnt++;
}
}
printf("%d\n", cnt);

return 0;
}

练习题参考代码

P1551 亲戚

标准的并查集,没有陷阱。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
/**
* Author: Tang Qiao
*/
#include <bits/stdc++.h>
using namespace std;

int n,m,q;
int p[5010];

int find(int a) {
if (p[a] == a) return a;
return p[a] = find(p[a]);
}

void merge(int a, int b) {
int pa = find(a);
int pb = find(b);
p[pa] = pb;
}

int main() {
int a, b;
// 初始化
for (int i = 0; i < 5010; ++i) {
p[i] = i;
}
scanf("%d%d%d", &n, &m, &q);
for (int i = 0; i < m; ++i) {
scanf("%d%d", &a, &b);
merge(a, b);
}
for (int i = 0; i < q; ++i) {
scanf("%d%d", &a, &b);
if (find(a) == find(b)) printf("Yes\n");
else printf("No\n");
}
return 0;
}

P1536 村村通

用并查集操作,然后数一下一共有多少个不同的集合,答案就是 集合数-1

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
/**
* Author: Tang Qiao
*/
#include <bits/stdc++.h>
using namespace std;

int p[1010], n, m;

int find(int a) {
if (p[a] == a) return a;
return p[a] = find(p[a]);
}

void merge(int a, int b) {
int pa = find(a);
int pb = find(b);
p[pa] = pb;
}

void init() {
for (int i = 0; i <= n ; ++i) {
p[i] = i;
}
}

int main() {
while (1) {
scanf("%d", &n);
if (n == 0) break;
init();
scanf("%d", &m);
for (int i = 0; i < m; ++i) {
int a, b;
scanf("%d%d", &a, &b);
merge(a, b);
}
int cnt = 0;
for (int i = 1; i <=n ; ++i) {
int pa = find(i);
if (pa == i) {
cnt++;
}
}
printf("%d\n", cnt-1);
}
return 0;
}

更多

并查集还有更多的优化,比如在合并的时候,把高度小的树往高度大的树上合并,以尽可能减少树的高度,这样可以使得未来查询的时候效率更高。因为大多时候用不上,所以这些知识可以放在课后阅读中让学生自行掌握。

参考文档

CSPJ 教学思考:二分查找

概述

二分查找的基础逻辑很简单:我们小时候都玩过猜数字游戏,心里想一个数字( 数字范围是 1-100),让对方猜,如果没猜对,就只告诉对方猜大了还是小了,看看最快几次能猜到。

这个游戏的最佳策略就是二分。先猜 50,如果大了,就猜 25。这样最多 7 次就可以猜到答案。

基础模版

对于猜数字这个游戏来说,二分的模版最简单的就是如下形式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 二分查找
int left, right, mid, ans;
left = 1;
right = n;
ans = -1;
while (left <= right) {
mid = left + (right-left) / 2;
if (v[mid] > a) {
right = mid - 1;
} else if (v[mid] < a) {
left = mid + 1;
} else {
ans = mid;
break;
}
}
cout << ans << " ";

以上代码需要注意的有以下几点:

  • 查徇范围是 [left, right],即 left 和 right 都是闭区间。
  • 循环条件是left <= right,即当 left == right时,还需要进行一次测试。
  • mid = left + (right-left) / 2其实等价于 mid = (left + right) / 2只是后者可能超界,用前者可以避免。

这种思路其实比较简单,写起来基本上不会犯错。但是,如果有多个目标值时,我们可能要多次更新 ans 变量。

P2249 查找就是一道例题,此题需要找到目标值第一次出现的位置,如果用上面的模版,我们需要多次更新 ans,参考代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
#include <bits/stdc++.h>
using namespace std;

int v[1000010];
int n, m, a;
int main() {
scanf("%d%d", &n, &m);
for (int i = 1; i <= n; ++i)
scanf("%d", v+i);
while (m--) {
scanf("%d", &a);
int left, right, mid, ans;
left = 1;
right = n;
ans = -1;
while (left <= right) {
mid = left + (right-left)/2;
if (v[mid] > a) {
right = mid - 1;
} else if (v[mid] < a) {
left = mid + 1;
} else {
// 如果找到,则比较 ans 的值,更新它
if (ans == -1 || ans > mid) ans = mid;
right = mid - 1;
}
}
cout << ans << " ";
}
cout << endl;
return 0;
}

另一种模版

除了刚刚的模版外,我们还可以用另外一种写法来写二分:我们用 [l,r)来表示目标查找区间,注意这里是左闭右开的区间。然后,我们不停地尝试缩小这个区间:

  • 情况 1:当目标值比 mid 值大的时候,新区间在 [mid+1, r)
  • 情况 2:当目标值比 mid 值小的时候,新区间在 [l, mid)
  • 情况 3:当目标值与 mid 值相等的时候,因为我们要找最小值,所以新区间在 [l, mid)

以上的情况 2 和情况 3 是可以合并的。结果就是只需要写一个 if 就可以了,核心代码如下:

1
2
3
4
5
while (l < r) {
mid = l + (r-l)/2;
if (a > v[mid]) l = mid + 1;
else r = mid;
}

有同学可能会问:如果只有一个值相等,并且在 mid 位置,那以上做法不是把结果就跳出区间了?其实这种情况下,l 的值会一步步右移,最后的循环结束的结果会是 [mid,mid)。所以我们还是可以从循环结束的 l 值中读到目标值。

对于这种写法,我们的二分判断会少很多,只需要最后判断一下 l 的值是否是目标值,即可知道是否查找成功。

以下是参考代码(从以前的 32 行缩短为 24 行):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#include <bits/stdc++.h>
using namespace std;

int v[1000010];
int n, m, a;
int main() {
scanf("%d%d", &n, &m);
for (int i = 1; i <= n; ++i)
scanf("%d", v+i);
while (m--) {
scanf("%d", &a);
int l, r, mid;
l = 1; r = n+1;
while (l < r) {
mid = l + (r-l)/2;
if (a > v[mid]) l = mid + 1;
else r = mid;
}
if (l < n+1 && v[l] == a) cout << l << " ";
else cout << -1 << " ";
}
cout << endl;
return 0;
}

如果记不清楚,就分开写:

  • 如果猜对了但要找最小值,就更新 r
  • 如果 mid 大了,则答案在 mid 左侧,就更新 r
  • 如果 mid 小了,则答案在 mid 右侧,就更新 l

另外,以上这种代码其实是不停在[l,mid)[mid+1, r)之间做选择,所以:

  • l 只会更新成 mid+1
  • r 只会更新成 mid

最后答案如果有,则在 l 位置,当然 l 位置也可能不是答案:

  • 如果目标极小,没找到,则 l 位置为查找的范围最左侧下标
  • 如果目标极大,没找到,则 l 位置为最初的 r 的位置(那个位置是最后一个元素的下一个位置,直接读取会数组越界)

lower_bound

其实上面那个写法就是 C++ STL 里面的 lower_bound 函数,所以我们可以直接用 lower_bound 函数来实现 P2249 题

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <bits/stdc++.h>
using namespace std;

int v[1000010];
int n, m, a;
int main() {
scanf("%d%d", &n, &m);
for (int i = 1; i <= n; ++i)
scanf("%d", v+i);
while (m--) {
scanf("%d", &a);
int l = lower_bound(v+1, v+n+1, a) - v;
if (l < n+1 && v[l] == a) cout << l << " ";
else cout << -1 << " ";
}
cout << endl;
return 0;
}

函数 lower_bound[first,last) 中的前闭后开区间进行二分查找,返回大于或等于目标值的第一个元素位置。如果所有元素都小于目标值,则返回 last 的位置。

这种函数行为初看很奇怪,因为它:

  • 当找到目标值时,它返回达找到的值的第一个位置
  • 当没有目标值时,它返回第一个大于目标值的位置
  • 当所有元素都小于目标值时,它返回 last 的位置

这实际上就是它的内部实现所致(可以理解为这种写法的side effect),它内部实现就是我们刚刚提到的写法,所以才会这么返回目标值。

如果我们想把查找结果转换成数组下标,只需要让它减去数组首地址即可,像这样:

1
int idx = lower_bound(v, v+n, a) - v;

upper_bound

除了 lower_bound 函数之外,C++还提供了 upper_bound 函数。lower_bound[first, last) 中的前闭后开区间进行二分查找,返回第一个比目标值大的位置。如果没找到,则返回 last 的位置。

upper_bound 的内部实现逻辑是:

  • 如果猜对了但要找最大值,就更新 l
  • 如果 mid 大了,则答案在 mid 左侧,就更新 r
  • 如果 mid 小了,则答案在 mid 右侧,就更新 l

为了方便对比,我把 lower_bound 的逻辑再写一下:

  • 如果猜对了但要找最小值,就更新 r
  • 如果 mid 大了,则答案在 mid 左侧,就更新 r
  • 如果 mid 小了,则答案在 mid 右侧,就更新 l

你看出来了吗?只是第一个更新的逻辑不一样。所以,其实两者的代码很像,我自己分别写了二者的一个实现,大家可以对比看一下,实际上二者实现部分只差了一个字符:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 如果目标值等于或者小于 mid,则 r = m
// 如果目标值大于 mid,则 l = m+1
int lower_bound(int a) {
int l, r;
l = 0; r = n;
while (l < r) {
int m = l + (r-l)/2;
if (a > v[m]) l = m+1;
else r = m;
}
return l;
}

// 如果 mid 值小于等于目标,就 l=m+1
// 如果 mid 值大于目标,就 r=m
int upper_bound(int a) {
int l, r;
l = 0; r = n;
while (l < r) {
int m = l + (r-l)/2;
if (a >= v[m]) l = m+1;
else r = m;
}
return l;
}

我们 upper_bound 考虑几种情况:

  • 如果目标值极小,那么一直就更新 r,结果返回的就是首地址,为正确值。
  • 如果目标值极大,那么一直就更新 l,结果返回的就是 last。

所以 upper_bound 如果没找到,会返回 last。

我们再看 lower_bound

  • 如果目标值极小,那么一直就更新 r,结果返回的就是首地址,为第一个大于目标值的地址。
  • 如果目标值极大,那么一直就更新 l,结果返回的就是 last。

所以,其实这两个函数在没找到目标值的情况下,都有可能返回首地址或末地址的。只是对于 upper_bound 函数来说,首地址是有意义的。

lower_bound 函数返回的首地址怎么说呢?有点像 side effect。很少有需求是求这个地址,所以很多时候要特殊处理一下,就像我们刚刚例题里面又判断了一下一样(如下所示)

1
if (l < n+1 && v[l] == a) cout << l << " ";

二分答案

二分不但能用于查找数值,还可以用来暴力尝试答案。因为即便是 0-20 亿这么大的范围的猜数字游戏,也只需要 30 多次就可以猜到,所以如果某个问题可以像猜大小一样,每次获得答案是大了还是小了,就可以用二分的办法来“二分答案”。

对于二分答案一类的题目,最常见的题目描述特征是求某某值的最大值最小,或者最小值最大。这个特征可以作为我们选择二分解题的小提示。我们在练习题目 P2678 跳石头P1182 数列分段 Section II 中就可以看到这种提示。

教学和练习题目

题目 说明
P2249 查找 可用 lower_bound 函数
P1102 A-B 数对 也可使用 STL map
P1873 砍树 二分答案
P3853 路标设置 天津省选,二分答案
P1678 烦恼的高考志愿 二分查找,可用 upper_bound 函数
P2440 木材加工 二分答案
P2678 跳石头 二分答案,NOIP2015 提高组
P1182 数列分段 Section II

P3853 路标设置

二分答案+判定。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
#include <bits/stdc++.h>
using namespace std;

int L, N, K;
int v[100010];

bool check(int mid) {
int ans = 0;
for(int i=1; i<N; i++){
if(v[i]-v[i-1] > mid){
ans += (v[i]-v[i-1]-1)/mid;
}
}
if(ans<=K){
return true;
}
return false;
}

int main() {
scanf("%d%d%d", &L, &N, &K);
for (int i = 0; i < N; ++i) {
scanf("%d", v+i);
}
int left, right, mid, ans = INT_MAX;
left = 1;
right = L;
while (left <= right) {
mid = (left + right) / 2;
if (check(mid)) {
right = mid - 1;
ans = min(ans, mid);
} else {
left = mid + 1;
}
}
cout << ans << endl;
return 0;
}

P1678 烦恼的高考志愿

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
/**
* 二分查找。
* 用 upper_bound 找到第一个大的位置 idx,然后取 idx 和 idx - 1, 分别试一下。
* idx 可能是 0 或者末尾(idx == m),要特殊处理一下。
*/
#include <bits/stdc++.h>
using namespace std;

int m, n, vm[100010], a;
long long ans = 0;

int main() {
scanf("%d%d", &m, &n);
for (int i = 0; i < m; ++i)
scanf("%d", vm+i);
sort(vm, vm+m);
for (int i = 0; i < n; ++i) {
scanf("%d", &a);
int diff = INT_MAX;
int idx = upper_bound(vm, vm+m, a)-vm;
if (idx != m) diff = min(diff, abs(vm[idx]-a));
if (idx - 1 >=0 ) diff = min(diff, abs(vm[idx-1]-a));
ans += diff;
}
cout << ans << endl;
return 0;
}

P2440 木材加工

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
/**
* 二分答案
*/
#include <bits/stdc++.h>
using namespace std;

int n, k;
int v[100010];
bool check(int mid) {
int cnt = 0;
for (int i = 0; i < n; ++i) {
cnt += v[i]/mid;
if (cnt >= k) return true;
}
return false;
}
int main() {
scanf("%d%d", &n, &k);
for (int i = 0; i < n; ++i) {
scanf("%d", v+i);
}
int left = 1;
int right = (int)1e8;
int ans = 0;
while (left <= right) {
int mid = (left + right) / 2;
if (check(mid)) {
left = mid + 1;
ans = max(ans, mid);
} else {
right = mid - 1;
}
}
cout << ans << endl;
return 0;
}

P2678 跳石头

二分答案:用 mid 去试跳,如果间距小于 mid,则去掉那个石头,如果去掉个数超过 k 个,则失败。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
#include <bits/stdc++.h>
using namespace std;

int ed, n, k;
int v[50010];
// 用 mid 去试跳,如果间距小于 mid,则去掉那个石头,如果去掉个数超过 k 个,则失败。
bool check(int mid) {
int cnt = 0;
int diff = 0;
for (int i = 1; i <= n+1; ++i) {
int dis = v[i] - v[i-1] + diff;
if (dis < mid) {
cnt++;
diff = dis;
if (cnt > k) return false;
} else {
diff = 0;
}
}
return true;
}
int main() {
scanf("%d%d%d", &ed, &n, &k);
for (int i = 1; i <= n; ++i) {
scanf("%d", v+i);
}
v[0] = 0; // 起点
v[n+1] = ed; // 终点
int left = 1;
int right = ed;
int ans = 0;
while (left <= right) {
int mid = left + (right-left)/2;
if (check(mid)) {
ans = max(ans, mid);
left = mid + 1;
} else {
right = mid - 1;
}
}
printf("%d\n", ans);
return 0;
}

P1182 数列分段 Section II

二分答案。对目标答案每 mid 分一段,如果分出来的段数 <= m 即为真。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
#include <bits/stdc++.h>
using namespace std;

int n, m, v[100010];
bool check(int mid) {
int tot = 0;
int cnt = 0;
for (int i = 0; i < n; ++i) {
cnt += v[i];
if (v[i] > mid) return false;
if (cnt > mid) {
tot++;
cnt = 0;
i--;
}
}
if (cnt != 0) tot++;
if (tot <= m) return true;
else return false;
}

int main() {
scanf("%d%d", &n, &m);
for (int i = 0; i < n; ++i) {
scanf("%d", v+i);
}
int left = 1;
int right = (int)(1e9 + 1);
int ans = INT_MAX;
while (left <= right) {
int mid = (left+right)/2;
if (check(mid)) {
ans = min(ans, mid);
right = mid - 1;
} else {
left = mid + 1;
}
}
cout << ans << endl;
return 0;
}

教学思考

因为lower_boundupper_bound的写法相比传统写法还是有点复杂,在教学中还是适合用最初的那个易懂的版本。易懂的版本虽然执行起来多几次判断,但是在比赛中这一点多的时间并不影响整体的时间复杂度,所以不会因此扣分。同时,简单易于理解的代码,在学习和解题时,也更加不容易犯错。

待学生理解基础二分的写法后,再把系统的实现拿出来,作为增强的补充练习题目。这么补充练习并不是要学生一定掌握,而是借由实现系统的函数,学会在比赛中调用 C++ 的 lower_boundupper_bound 库函数,这样可以加速解题的速度。

二分答案的思路很好理解,但是实际写起来还是很容易晕,所以需要多加练习。另外利用题目特征来获得提示,帮助自己快速解题。

小结

  • lower_boundupper_bound 都是极简二分查找的 C++ 内部实现。
  • 因为它们都有 side effect,所以在查找目标不存在时,均可能返回首地址和末地址(取决于目标是极小还是极大)。
    • 因为以上的 side effect,所以我们给 lower_bound 赋予了额外的功能:返回第一个大于或等于目标值的位置;如果不存在返回 last。
    • upper_bound 在目标值极小的时候,返回首地址(正好符合要求);在目标值极大的时候,返回 last。
  • 因为 lower_bound 有可能返回的不是目标值,所以最后要判断一下。

CSPJ 教学思考:动态规划

引言

动态规划是 CSPJ 拉分的关键知识点。

之所以这样,是因为动态规划不像 DFS、BFS、二分那样有固定的模版格式。学生要在动态规划问题上融汇贯通,需要花费大量的练习,也需要足够的聪明。

笔者自己在高中阶段,也是在动态规划问题上困扰许久。我自己的学习经验是:动态规划还是需要多练,练够 100 道题目,才能够熟悉动态规划的各种变型。之后在比赛中看到新的题目,才会有点似曾相识的感觉,进一步思考出状态转移方程。

所以,我打算写 100 道动态规划方程的题解,希望有志攻破此难关的学生和家长一起加油!

动态规划解题的核心问题

虽然动态规划没有模版可以套,但是动态规划有三个核心问题:

  • 状态的定义
  • 状态转移方程
  • 初始状态的设置

一般思考动态规划就是思考以上三个问题,这三个问题解决了,动态规划的程序也可以写出来了。

教学题目

推荐的教学题目如下:

题目名 说明
P2842 纸币问题 1 基础 DP,记忆化搜索
P1216 数字三角形 基础 DP,记忆化搜索 【经典 DP】
P2840 纸币问题 2 基础 DP
P2834 纸币问题 3 基础 DP,有多处可优化的点
P1048 采药 NOIP2005 普及组第三题。01 背包问题。【经典 DP】
P1616 疯狂的采药 完全背包问题。【经典 DP】
P2196 挖地雷 NOIP1996 提高组第三题。涉及输出路径技巧。
P1434 滑雪 上海市省队选拔 2002
P1115 最大子段和 最大子段和。【经典 DP】

适合的作业:

题目名 说明
P4017 最大食物链计数 记忆化搜索
P2871 Charm Bracelet S USACO 07 DEC,01 背包
P1802 5 倍经验日 01 背包
P1002 过河卒 NOIP2002 普及组,记忆化搜索
P1049 装箱问题 NOIP2001 普及组,01 背包
P1064 金明的预算方案 01 背包变型,NOIP2006 提高组第二题
P1077 摆花 NOIP2012 普及组
P1164 小A点菜 与摆花一题类似
P2392 考前临时抱佛脚 01 背包变型
B3873 小杨买饮料 01 背包变型, GESP202309 六级
P13015 学习小组 无穷背包,GESP 202506 六级
P10721 计算得分 | 背包问题变种,GESP 202406 六级 |

更多的题单:

例题代码

P2842 纸币问题 1

此题可以带着孩子一步步推导和演进。具体步骤如下。

先引导孩子用最暴力的 DFS 的方式来做此题,建立基础的解题框架,虽然会超时,但是也帮助我们后面引导孩子学会记忆化搜索。代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
/**
* DFS,超时
*/
#include <bits/stdc++.h>
using namespace std;

int n, w;
int v[1100];

int dfs(int pt) {
if (pt == 0) return 0;
int ret = 1e9;
for (int i = 0; i < n; ++i) {
if (pt>=v[i]) {
ret = min(ret, dfs(pt-v[i]) + 1);
}
}
return ret;
}

int main() {
scanf("%d%d", &n, &w);
for (int i = 0; i < n; ++i) {
scanf("%d", v+i);
}
int ans = dfs(w);
printf("%d\n", ans);
return 0;
}

有了上面的代码,通过分析,发现大部分的超时是因为有重复的计算过程。以下是一个以 10,5,1 为例的示意:

所以,我们可以将重复计算的过程保存下来,以后再次需要计算的时候,直接读取保存的结果即可。在此思想下,我们只需要在上面改动三行,即可将超时的程序改为通过。具体代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
/**
* DFS,记忆化搜索
*/
#include <bits/stdc++.h>
using namespace std;

int n, w;
int v[1100];
int r[10010]; // 改动 1

int dfs(int pt) {
if (pt == 0) return 0;
if (r[pt] != 0) return r[pt]; // 改动 2

int ret = 1e9;
for (int i = 0; i < n; ++i) {
if (pt>=v[i]) {
ret = min(ret, dfs(pt-v[i]) + 1);
}
}
return (r[pt]=ret); // 改动 3
}

int main() {
scanf("%d%d", &n, &w);
for (int i = 0; i < n; ++i) {
scanf("%d", v+i);
}
int ans = dfs(w);
printf("%d\n", ans);
return 0;
}

有了以上两段代码的尝试,我们能够发现:

  • dfs(pt) 只与 dfs( 0 ~ pt-1) 有关,与 dfs(pt+1~w)无关。
  • 如果我们知道了 dfs(0~pt),就可以推出 dfs(pt+1)

那么,我们就可以思考,如果我们用 dp[i] 来表示钱币总额为 i 的结果数。那么,dp[i] 的计算过程(即:状态转移方程)为:dp[i] = min( dp[i-v[j]] )+1,其中j=0~N

这样,我们就可以引导学生写出第一个动态规划程序。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
/**
* dp[i] = min( dp[i-v[j]] ) + 1
*/
#include <bits/stdc++.h>
using namespace std;

int n, w;
int v[1100], dp[11000];

int main() {
scanf("%d%d", &n, &w);
for (int i = 0; i < n; ++i) {
scanf("%d", v+i);
}
memset(dp, 0x3f, sizeof(dp));
dp[0] = 0;
for (int i = 1; i <=w ; ++i) {
for (int j = 0; j < n; ++j) {
if (i-v[j]>=0) {
dp[i] = min(dp[i], dp[i-v[j]]+1);
}
}
}
printf("%d\n", dp[w]);
return 0;
}

P1216 数字三角形

P1216 数字三角形同样可以用记忆化搜索引入。先写记忆化搜索的代码有助于我们理解动态规划的状态转移方程。

搜索的代码为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
/**
* DFS,记忆化
*/
#include <bits/stdc++.h>
using namespace std;

int n;
int v[1010][1010];
int r[1010][1010];

int dfs(int x, int y) {
if (r[x][y] != -1) return r[x][y];
if (x == n-1) return
r[x][y] = v[x][y];
else return
r[x][y] = v[x][y]+max(dfs(x+1,y), dfs(x+1,y+1));
}

int main() {
scanf("%d", &n);
for (int i = 0; i < n; ++i) {
for (int j = 0; j <= i; ++j) {
scanf("%d", &v[i][j]);
}
}
memset(r, -1, sizeof(r));
printf("%d\n", dfs(0, 0));
return 0;
}

由搜索代码可知,每一个位置的最价结果由它下面两个结点的最价结果构成。于是,我们可以构造出状态转移方程:dp[i][j] = v[i][j] + max(dp[i+1][j], dp[i+1][j+1])

另外,我们可以引导学生:上层的依赖于下层的数据,那应该怎么推导呢?让学生想到用倒着 for 循环的方式来从下往上推导。

最后,我们再引导学生构建一下初始值。由此,我们建立起动态规划解题的三个核心问题:

  • 状态的定义
  • 状态转移方程
  • 初始状态的设置
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
/**
* 动态规划:
* dp[i][j] = v[i][j] + max(dp[i+1][j], dp[i+1][j+1])
*/
#include <bits/stdc++.h>
using namespace std;

int n;
int v[1010][1010];
int dp[1010][1010];

int main() {
scanf("%d", &n);
for (int i = 0; i < n; ++i) {
for (int j = 0; j <= i; ++j) {
scanf("%d", &v[i][j]);
}
}
// 初始状态
for (int j = 0; j < n; ++j) {
dp[n-1][j] = v[n-1][j];
}
// dp
for (int i = n-2; i>=0; --i) {
for (int j = 0; j <= i; ++j) {
dp[i][j] = v[i][j] + max(dp[i+1][j], dp[i+1][j+1]);
}
}
printf("%d\n", dp[0][0]);
return 0;
}

P2840 纸币问题 2

状态转移方程为:dp[i] = sum(dp[i- v[j]]), j = 0~N,结果需要每次模 1000000007。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <bits/stdc++.h>
using namespace std;

int n, w;
int v[1010], dp[10010];

int main() {
scanf("%d%d", &n, &w);
for (int i = 0; i < n; ++i) {
scanf("%d", v+i);
}
dp[0] = 1;
for (int i = 1; i <= w ; ++i) {
dp[i] = 0;
for (int j = 0; j < n; ++j) {
if (i >= v[j]) {
dp[i] = (dp[i] + dp[i-v[j]])%1000000007;
}
}
}
printf("%d\n", dp[w]);
return 0;
}

P2834 纸币问题 3

此题不能像之前的题目那样,用金钱数为阶段。因为此题是计算的组合数,所以 1,5 和 5,1 是一种答案。如果以金钱数为阶段,就无法方便将这种重复计算的排除掉。

那么,以什么为阶段,可以保证每个阶段可以基于过去的阶段推导出来?可以用不同的钱币种类为阶段!

接下来就是思考这种情况下的状态转移方程。可以得出,状态转移方程如下:

  • dp[i][j] 表示用前 i 种钱币组成金额 j 的组合数
  • dp[i][j] = dp[i-1][j-v[i]] + dp[i-1][j - v[i]*2] + …. dp[i-1][j-v[i]*n]; (j >= v[i]*n)
  • 初始状态:dp[1][0] = 1; dp[1][v[1]] = 1; dp[1][v[1]*2] = 1;

参考程序如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
#include <bits/stdc++.h>
using namespace std;

const int MOD = 1000000007;
int n, w;
int v[1010], dp[1010][10010];

int main() {
scanf("%d%d", &n, &w);
for (int i = 0; i < n; ++i) {
scanf("%d", v+i);
}
memset(dp, 0, sizeof(dp));
// dp[0][0] = 1; dp[0][v[0]] = 1;dp[0][v[0]*2] = 1;….
int cnt = 0;
while (cnt <= w) {
dp[0][cnt] = 1;
cnt += v[0];
}
for (int i=1; i<n; ++i) {
for (int j=0; j<=w; ++j) {
cnt = 0;
while (j - cnt >= 0) {
dp[i][j] = (dp[i][j]+dp[i-1][j-cnt]) % MOD;
cnt += v[i];
}
}
}
printf("%d\n", dp[n-1][w]);
return 0;
}

此题还有另外一种状态转移方程,把阶段分为没有用过 a,和至少用过一张 a。

这样的话,状态转移方程优化为:dp[i][j] = dp[i-1][j] + dp[i][j-v[i]]

这样,代码的复杂度进一步降低,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
#include <bits/stdc++.h>
using namespace std;

const int MOD = 1000000007;
int n, w;
int v[1010], dp[1010][10010];

int main() {
scanf("%d%d", &n, &w);
for (int i = 0; i < n; ++i) {
scanf("%d", v+i);
}
memset(dp, 0, sizeof(dp));
int cnt = 0;
while (cnt <= w) {
dp[0][cnt] = 1;
cnt += v[0];
}
for (int i=1; i<n; ++i) {
for (int j=0; j<=w; ++j) {
if (j-v[i]>=0) {
dp[i][j] = (dp[i-1][j]+dp[i][j-v[i]])% MOD;
} else {
dp[i][j] = dp[i-1][j];
}
}
}
printf("%d\n", dp[n-1][w]);
return 0;
}

此题还可以进一步简化,因为 dp[i] 那一层算完之后 dp[i-1] 层就没有用了。有没有可能我们将 dp[i]层和 dp[i-1]都合并在一起呢?

答案是可以的。我们可以将关键代码进一步简化如下,把 dp 改成一个一维数组。状态转移方程变为了:dp[j] = dp[j] + dp[j-v[i]]

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
#include <bits/stdc++.h>
using namespace std;

const int MOD = 1000000007;
int n, w;
int v[1010], dp[10010];

int main() {
scanf("%d%d", &n, &w);
for (int i = 0; i < n; ++i) {
scanf("%d", v+i);
}
memset(dp, 0, sizeof(dp));
int cnt = 0;
while (cnt <= w) {
dp[cnt] = 1;
cnt += v[0];
}
for (int i=1; i<n; ++i) {
for (int j=0; j<=w; ++j) {
if (j-v[i]>=0) {
dp[j] = (dp[j]+dp[j-v[i]]) % MOD;
} else {
dp[j] = dp[j]; //此行可以删除,但为了教学示意保留
}
}
}
printf("%d\n", dp[w]);
return 0;
}

P1048 采药

P1048 采药这题是经典的 01 背包问题。为了方便教学,我们还是从最简单的动态规划思路开始推导。

我们把每个草药是一个阶段,这样:

  • dp[i][j] 表示前 i 个草药,花费 j 时间可以得到的最大价值
  • 状态转移方程为:dp[i][j] = max(dp[i-1][j], dp[i-1][j-v[i]])

这样写出来的参考程序如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
/**
dp[i][j] 表示前 i 个草药,花费 j 时间可以得到的最大价值
dp[i][j] = max(dp[i-1][j], dp[i-1][j-t[i]]
*/
#include <bits/stdc++.h>
using namespace std;

int T, M;
int t[110], v[110];
int dp[110][1010];

int main() {
scanf("%d%d", &T, &M);
for (int i = 1; i <= M; ++i) {
scanf("%d%d", t+i, v+i);
}
// 下标从 1 开始,这样不用考虑 i-1 越界了
for (int i = 1; i <= M; ++i) {
for (int j = 1; j <= T; ++j) {
dp[i][j] = dp[i-1][j];
if (j - t[i] >= 0) {
dp[i][j] = max(dp[i][j], dp[i-1][j - t[i]]+v[i]);
}
}
}
printf("%d\n", dp[M][T]);
return 0;
}

与上一题一样,通过分析,我们发现 dp[i][j] 中的 i 一层可以优化掉,变成只有 dp[j]。

这样,状态转移方程被优化成:dp[j]=max(dp[j],dp[j-t[i]]+v[i])

但是,因为每一个草药只能用一次,如果我们正着循环 j 的话,会出现多次使用第 i 个草药的情况。所以,我们倒着进行递推,就可以避免这种情况。

最终实现的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/**
dp[j] 花费 j 时间可以得到的最大价值
dp[j] = max(dp[j], dp[j-t[i]])
*/
#include <bits/stdc++.h>
using namespace std;

int T, M;
int t[110], v[110];
int dp[1010];

int main() {
scanf("%d%d", &T, &M);
for (int i = 1; i <= M; ++i) {
scanf("%d%d", t+i, v+i);
}
for (int i = 1; i <= M; ++i) {
for (int j = T; j >= t[i]; --j) {
dp[j] = max(dp[j], dp[j - t[i]]+v[i]);
}
}
printf("%d\n", dp[T]);
return 0;
}

P2196 挖地雷

P2196 挖地雷 是 NOIP1996 提高组第三题。这道题的解法有点类似于P1216 数字三角形

但是,这道题更难的是:它需要我们输出路径。

我们先说状态转移方程:

  • dp[i] 表示第 i 个地窖能够挖到的最多地雷数。
  • w[i] 表示第 i 个地窖的地雷数。
  • 转移方程:dp[i] = max(dp[i+1~N]中能够与 dp[i] 连通的地窖) + w[i]dp[i] = w[i]中的较大者。

我们再说说如何输出路径。因为计算之后 dp 数组中保存了每个结点能够挖的最大地雷数。所以,我们从答案 dp[ans]开始,找哪一个地窖与当前相连,同时值又等于 dp[ans] - w[ans],则表示那个地窖是下一个点。

参数代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
#include <bits/stdc++.h>
using namespace std;

int n;
int w[30];
int v[30][30];
int dp[30];

int main() {
scanf("%d", &n);
for (int i = 1; i <= n; ++i) {
scanf("%d", w+i);
}
for (int i = 1; i <=n ; ++i) {
for (int j = i+1; j<=n; ++j) {
scanf("%d", &v[i][j]);
}
}
int ans = 0;
for (int i = n; i>=1; --i) {
dp[i] = w[i];
for (int j = i+1; j<=n; ++j) {
if (v[i][j]) {
dp[i] = max(dp[i], dp[j]+w[i]);
}
}
if (dp[ans] < dp[i]) ans = i;
}
int cnt = dp[ans];
int idx = ans;
while (cnt) {
printf("%d ", idx);
cnt -= w[idx];
for (int i = idx + 1; i<=n; ++i) {
if (v[idx][i] && cnt == dp[i]) {
idx = i;
break;
}
}
}
printf("\n%d\n", dp[ans]);
return 0;
}

P1434 滑雪

这道题的麻烦点是如何定义状态转移的阶段,因为没有明显的阶段。

可以考虑的办法是:将点按高度排序,这样从高度低的点开始,往高的点做状态转移。

所以:

  • 定义:dp[i][j] 表示从 (i,j) 这个位置开始滑的最长坡。
  • 转移方程:
    • dp[x][y] = max(dp[x'][y'])+1
    • dp[x'][y'] 为上下左右相邻并且高度更低的点
  • 初始化:无
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
#include <bits/stdc++.h>
using namespace std;

int r, c;
int tu[110][110];
int dp[110][110];
int movex[]={-1,1,0,0};
int movey[]={0,0,-1,1};
bool debug = false;

struct Node {
int x, y, h;
Node(int _x, int _y, int _h) {
x = _x; y = _y; h = _h;
}
};
bool operator<(Node a, Node b) {
return a.h < b.h;
}
vector<Node> v;

int main() {
scanf("%d%d", &r, &c);
v.reserve(r*c);
for (int i = 0; i < r; ++i) {
for (int j = 0; j < c; ++j) {
scanf("%d", &tu[i][j]);
v.push_back(Node(i, j, tu[i][j]));
}
}
sort(v.begin(), v.end());
memset(dp, 0, sizeof(dp));
int ans = 0;
for (int i = 0; i < r*c; ++i) {
Node node = v[i];
int x = node.x;
int y = node.y;
for (int j = 0; j < 4; ++j) {
int tox = x + movex[j];
int toy = y + movey[j];
if (tox >=0 && tox <r && toy >=0 && toy<c &&
node.h > tu[tox][toy]) {
dp[x][y] = max(dp[x][y], dp[tox][toy]);
}
}
dp[x][y] += 1;
ans = max(ans, dp[x][y]);
if (debug) {
printf("dp[%d][%d]=%d\n", x, y, dp[x][y]);
}
}
printf("%d\n", ans);
return 0;
}

此题更容易想到的写法还是记忆化搜索:对每一个点作为开始点进行一次 DFS,同时在进行递归调用的时候,如果当前点处理过,则返回上次的结果。

参考代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
/**
* DFS, 记忆化
*/
#include <bits/stdc++.h>
using namespace std;

int r, c;
int tu[110][110];
int rem[110][110];

int movex[]={-1,1,0,0};
int movey[]={0,0,-1,1};

int dfs(int x, int y) {
if (rem[x][y] != 0) return rem[x][y];
int mm = 0;
for (int i = 0; i < 4; ++i) {
int tox = x + movex[i];
int toy = y + movey[i];
if (tox >=0 && tox <r && toy >=0 && toy<c &&
tu[x][y] > tu[tox][toy]) {
mm = max(mm, dfs(tox, toy));
}
}
return (rem[x][y] = mm + 1);
}

int main() {
scanf("%d%d", &r, &c);
for (int i = 0; i < r; ++i) {
for (int j = 0; j < c; ++j) {
scanf("%d", &tu[i][j]);
}
}
int ans = 0;
for (int i = 0; i < r; ++i) {
for (int j = 0; j < c; ++j) {
ans = max(ans, dfs(i, j));
}
}
printf("%d\n", ans);
return 0;
}

P1115 最大子段和

P1115 最大子段和 是最经典的一类动态规划问题。思路如下:

  • dp[i] 表示包含 i 这个数,并且以 i 结尾的最大子段和。
  • 状态转移方程:
    • 如果 dp[i-1] 为负数,那么 dp[i] = v[i]
    • 如果 dp[i-1] 为正数,那么 dp[i] = dp[i-1]+v[i]

因为 dp[i] 在转移方程上只与 dp[i-1]相关,所以它最终结构上被可以被化简成类似贪心的策略,即:

  • 用一个变量记录当前的累加值,如果当前累加值为负数,则重新计算。
  • 在累加过程中随时判断,记录最大的累加值为最终答案。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <bits/stdc++.h>
using namespace std;

int n;
int v[200100];

int main() {
scanf("%d", &n);
for (int i = 0; i < n; ++i) {
scanf("%d", v+i);
}
int cnt = 0;
int ans = -1e9;
for (int i = 0; i < n; ++i) {
cnt += v[i];
ans = max(ans, cnt);
if (cnt < 0) cnt = 0;
}
printf("%d\n", ans);
return 0;
}

作业代码

P4017 最大食物链计数

P4017 最大食物链计数最佳的做法是做记忆化的搜索。

记录下出度为 0 的结点,从这些结点开始去寻找,把各种可能的路径加总。同时在 DFS 的时候,记录下搜索的结果。

参考代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
/**
* 记忆化搜索
*/
#include <bits/stdc++.h>
using namespace std;

#define MOD 80112002

int n, m;
vector<vector<int> > v;
int r[5010], out[5010];

int dfs(int a) {
if (r[a] != -1) return r[a];
// 如果是头部,算一种情况
if (v[a].size() == 0) return (r[a]=1);
// 如果不是头部,则求和
int cnt = 0;
for (int i = 0; i < v[a].size(); ++i) {
cnt = (cnt + dfs(v[a][i])) % MOD;
}
return r[a] = cnt;
}

int main() {
memset(r, -1, sizeof(r));
scanf("%d%d", &n, &m);
v.resize(n+1);
for (int i = 0; i < m; ++i) {
int a, b;
scanf("%d%d", &a, &b);
v[a].push_back(b); // a 被 b 吃
out[b]++; // b 的出度+1
}
int ans = 0;
for (int i = 1; i <=n ; ++i) {
// 如果 i 出度为 0,就表示只能被吃,为底部
if (out[i] == 0) {
ans += dfs(i);
ans %= MOD;
}
}
printf("%d\n", ans);
return 0;
}

P2871 Charm Bracelet S

P2871 Charm Bracelet S 是最最标准的 01 背包问题。可以作为基础练习。

参考代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <bits/stdc++.h>
using namespace std;

int n, m;
int w[3500], v[3500], dp[14000];
int main() {
scanf("%d%d", &n, &m);
for (int i = 0; i < n; ++i) {
scanf("%d%d", w+i, v+i);
}
memset(dp, 0, sizeof(dp));
for (int i = 0; i < n; ++i) {
for (int j = m; j>=w[i]; --j) {
dp[j] = max(dp[j], dp[j-w[i]] + v[i]);
}
}
printf("%d\n", dp[m]);
return 0;
}

P1802 5 倍经验日

经典的 01 背包问题:

  • dp[i] 表示 i 容量可以获得的最大的经验值增量。
  • w[i] 表示第 i 个药的数量。
  • t[i] 表示第 i 个药贡献的经验值增量。

状态转移方程:dp[j] = max(dp[j], dp[j-w[i]]+t[i])

需要注意答案最大超过了 int,需要用 long long。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#include <bits/stdc++.h>
using namespace std;

int dp[1010], w[1010], t[1010];
int base = 0, n, x;

int main() {
scanf("%d%d", &n, &x);
for (int i = 0; i < n; ++i) {
int a, b, c;
scanf("%d%d%d", &a, &b, &c);
base += a;
t[i] = b-a;
w[i] = c;
}
for (int i=0; i<n; ++i) {
for (int j=x; j>=0; --j) {
if (j-w[i]>=0) {
dp[j] = max(dp[j], dp[j-w[i]]+t[i]);
}
}
}
//最大结果为 5*1e9,需要用 long long
printf("%lld\n", 5LL*(dp[x] + base));
return 0;
}

P1002 过河卒

P1002 过河卒此题是标准的记忆化搜索。有两个陷阱:

  • 马所在的位置也不能走。
  • long long。

相关代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
/**
* 记忆化搜索。
*/
#include <bits/stdc++.h>
using namespace std;

int bx, by, hx, hy;
long long r[22][22];

bool block(int x, int y) {
int v = abs(x-hx)*abs(y-hy);
return (v == 2 || x==hx && y == hy);
}

long long dfs(int x, int y) {
if (x>bx || y>by) return 0;
if (x == bx && y == by) return 1;
if (r[x][y]!=-1) return r[x][y];
if (block(x,y)) return r[x][y] = 0;
long long ans = dfs(x+1,y) + dfs(x,y+1);
return r[x][y] = ans;
}

int main() {
memset(r, -1, sizeof(r));
cin >> bx >> by >> hx >> hy;
printf("%lld\n",dfs(0, 0));
return 0;
}

P1064 金明的预算方案

P1064 金明的预算方案 是一道 01 背包的变型题。题目增加了附件的概念,初看起来没法下手,但是题目增加了一个限制条件:附件最多只有 2 个。

所以,我们可以将 01 背包的“选或不选”两种情况扩充成以下 5 种情况:

  • 不选
  • 选主件,不选附件
  • 选主件 + 附件 1
  • 选主件 + 附件 2
  • 选主件 + 附件 1 + 附件 2

然后就可以用 01 背包来实现该动态规划了。我们把每种物品的费用当作背包的体积,把每种物品的价格*权重当作价值。

转移方程是:dp[i]=max(dp[i], 5 种物品选择情况),每种选择情况下,dp[i]=max(dp[i], dp[i-该选择下的花费]+该选择下的收益)

另外,需要注意,输入数据的编号可能不按顺序提供,有以下这种情况:

1
2
3
4
100 3
1000 5 3
10 5 3
50 2 0

以下是参考程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
#include <bits/stdc++.h>
using namespace std;

struct Node {
int m;
int w;
int t;
};

int n, m;
vector<Node> va;
vector<vector<Node> > vb;
int dp[40000];

void updateDP(int i, int m, int w) {
if (i-m >= 0) {
dp[i] = max(dp[i], dp[i-m] + w);
}
}

int main() {
scanf("%d%d", &n, &m);
va.resize(m);
vb.resize(m);
for (int i = 0; i < m; ++i) {
Node node;
scanf("%d%d%d", &node.m, &node.w, &node.t);
node.w = node.w*node.m;
va[i] = node;
if (node.t != 0) {
vb[node.t - 1].push_back(node);
}
}
memset(dp, 0, sizeof(dp));
for (int i = 0; i < m; ++i) {
// 只处理主件,附件与主体一并处理
if (va[i].t == 0) {
for (int j = n; j > 0; j--) {
// 选主件,不选附件
updateDP(j, va[i].m,va[i].w);
// 选主件+附件 1
if (vb[i].size() > 0) {
int money = va[i].m + vb[i][0].m;
int weight = va[i].w + vb[i][0].w;
updateDP(j, money, weight);
}
// 选主件+附件 2
if (vb[i].size() == 2) {
int money = va[i].m + vb[i][1].m;
int weight = va[i].w + vb[i][1].w;
updateDP(j , money, weight);
}
// 选主件+附件 1+附件 2
if (vb[i].size() == 2) {
int money = va[i].m + vb[i][0].m + vb[i][1].m;
int weight = va[i].w + vb[i][0].w + vb[i][1].w;
updateDP(j, money, weight);
}
}
}
}
cout << dp[n] << endl;
return 0;
}

P1077 摆花

P1077 摆花 一题是 NOIP2012 普及组的第三题。

  • dp[i][j] 表示前 i 种花,摆在前 j 个位置上的种数。

状态转移方程:

1
2
3
4
dp[i][j] = dp[i-1][j] 不放第 i 种花
+ dp[i-1][j-1] 放 1 个第 i 种花
+ dp[i-1][j-2] 放 2 个第 i 种花
...

这道题的难点:没有想到 dp[0][0]=1。因为后面推导的时候,
dp[i-1][j-k]j==k 的时候,也是一种可能的情况,要统计进来。

参考代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
#include <bits/stdc++.h>
using namespace std;

int n, m;
int a[110];
int dp[110][110];

int main() {
scanf("%d%d", &n, &m);
for (int i = 1; i <= n; ++i) {
scanf("%d", a+i);
}
memset(dp, 0, sizeof(dp));
dp[0][0] = 1;
for (int i = 1; i <= n; ++i) {
for (int j = 0; j <= m; ++j) {
for (int k = 0; k <= a[i]; ++k) {
if (j - k >= 0) {
dp[i][j] += dp[i-1][j-k];
dp[i][j] %= 1000007;
}
}
}
}
printf("%d\n", dp[n][m]);
return 0;
}

P1164 小A点菜

P1164 小A点菜一题阶段比较明显。每一道菜点不点是一个明显阶段。所以:

  • dp[i][j]表示前 i 道菜,用 j 的价格,能够点的方案数

对于每道菜,有点或不点两种方案,所以:

  • 转移方程:dp[i][j] = dp[i-1][j]+dp[i-1][j-a[i]]

由于 i 阶段只与 i-1 阶段相关,所以可以把阶段压缩掉,只留一维。最后压缩后的方案是:

  • dp[j] 表示用 j 的价格可以点到的点的种数
  • 初始条件 dp[0] = 1,因为这样才可以把后面的结果递推出来
  • dp[j] = dp[j] + dp[j-a[i]]

因为和 01 背包类似的原因,压缩后需要倒着用 for 循环,否则每道菜就用了不止一次了。

参考代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <bits/stdc++.h>
using namespace std;

int n, m;
int a[110];
int dp[10010];

int main() {
scanf("%d%d", &n, &m);
for (int i = 1; i <= n; ++i) {
scanf("%d", a+i);
}
memset(dp, 0, sizeof(dp));
dp[0] = 1;
for (int i = 1; i <= n; ++i) {
for (int j = m; j>=a[i]; --j) {
dp[j] += dp[j-a[i]];
}
}
printf("%d\n", dp[m]);
return 0;
}

P2392 考前临时抱佛脚

P2392 考前临时抱佛脚 此题可以用动态规划,也可以用搜索,因为每科只有最多 20 个题目,所以搜索空间最大是 2^20 等于约 100 万。

以下是搜索的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
/**
* 搜索
*/
#include <bits/stdc++.h>
using namespace std;

int s[4], v[25];
int ans, tot, ret;

void dfsAns(int pt, int n, int cnt) {
if (pt == n) {
int tmp = max(cnt, tot-cnt);
ret = min(ret, tmp);
return;
}
dfsAns(pt+1, n, cnt);
dfsAns(pt+1, n, cnt+v[pt]);
}

int main() {
scanf("%d%d%d%d", s, s+1, s+2, s+3);
for (int i = 0; i < 4; ++i) {
memset(v, 0, sizeof(v));
tot = 0;
for (int j = 0; j < s[i]; ++j) {
scanf("%d", v+j);
tot += v[j];
}
ret = tot;
dfsAns(0, s[i], 0);
ans += ret;
}
printf("%d\n", ans);
return 0;
}

用动态规划解题时,此题可以把每次复习看作一次 01 背包的选择。每道题的价值和成本相同。背包的目标是尽可能接近 sum/2,因为sum 最大值为 20*60 = 1200,所以背包大小最大是 600。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
#include <bits/stdc++.h>
using namespace std;

int s[4];
int v[25];
int ans = 0;
int dp[610];

int dpAns(int n) {
int cnt = 0;
for (int i = 0; i < n; ++i) {
cnt += v[i];
}
int m = cnt / 2;
memset(dp, 0, sizeof(dp));
for (int i = 0; i < n; ++i) {
for (int j = m; j>=v[i]; --j) {
dp[j] = max(dp[j], dp[j-v[i]] + v[i]);
}
}
int ret = max(dp[m], cnt - dp[m]);
return ret;
}

int main() {
scanf("%d%d%d%d", s, s+1, s+2, s+3);
for (int i = 0; i < 4; ++i) {
memset(v, 0, sizeof(v));
for (int j = 0; j < s[i]; ++j) {
scanf("%d", v+j);
}
ans += dpAns(s[i]);
}
printf("%d\n", ans);
return 0;
}

B3873 小杨买饮料

假设第 i 种饮料的费用是 c[i], 容量是 l[i]dp[i][j] 表示用前 i 种饮料,凑成 j 升的最小费用。则,转移方程为:

  • dp[i][j] = min( dp[i-1][j-l[i]] + c[i] , dp[i-1][j] )

因为 i 只与 i-1 相关,所以这一层可以取消。转移方式优化为:

  • dp[j] = min(dp[j- l[i]] + c[i], dp[j])

其它注意事项:

  • 倒着 dp,因为每种饮料只能用一次
  • 最大值检查了一下,不会超 int,就不用 long long 了
  • 因为答案不一定是刚好 L 升,所以要取 L ~ L+max(l[i]) 这一段范围
  • 因为是取最小值,所以初使化设置成 0x7f7f7f7f(接近 21 亿,但是又没到 INT_MAX),这样运算不会超 int,又可以是较大值

参考代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
/* 
* Author: Tang Qiao
*/
#include <bits/stdc++.h>
using namespace std;

int dp[1010000], c[550], l[550], N, L, maxL;

int main() {
ios::sync_with_stdio(0);
cin >> N >> L;
for (int i = 0; i < N; ++i) {
cin >> c[i] >> l[i];
maxL = max(maxL, l[i]);
}
maxL += L;
memset(dp, 0x7f, sizeof dp);
dp[0] = 0;
for (int i = 0; i < N; ++i) {
for (int j = maxL; j - l[i] >= 0; --j) {
dp[j] = min(dp[j], dp[j - l[i]] + c[i]);
}
}
int ans = *min_element(dp+L, dp+maxL+1);
if (ans == 0x7f7f7f7f) cout << "no solution" << endl;
else cout << ans << endl;

return 0;
}

上面的代码有一个小缺点就是 dp 数据开得很大。因为虽然题目的 L 很小(最大值为 2000),但饮料的容量最大为 10^6

所以我们还有一种办法就是对这种容量很大的饮料单独判断,这样 L 的范围就可以只设置到 4000 即可。之所以是 4000 而不是 2000,是因为还是有刚刚超过 2000 一点点,而凑出最小值的情况。

参考代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
/* 
* Author: Tang Qiao
*/
#include <bits/stdc++.h>
using namespace std;

int dp[4100], c[550], l[550], N, L;

int main() {
ios::sync_with_stdio(0);
cin >> N >> L;
for (int i = 0; i < N; ++i) {
cin >> c[i] >> l[i];
}
memset(dp, 0x7f, sizeof dp);
dp[0] = 0;
for (int i = 0; i < N; ++i) {
for (int j = 4000; j - l[i] >= 0; --j) {
dp[j] = min(dp[j], dp[j - l[i]] + c[i]);
}
}
int ans = *min_element(dp+L, dp+4000);
// 如果单个饮料就可以超 L,则判断一下
for (int i = 0; i < N; ++i)
if (l[i] >= L)
ans = min(ans, c[i]);

if (ans == 0x7f7f7f7f) cout << "no solution" << endl;
else cout << ans << endl;

return 0;
}

P13015 学习小组

无穷背包问题:

  • 人数相当于物品的重量,积极度相当于物品的价值
  • 背包的总重量就是人数

设:人数限制是 a[i], 兴趣度是 b[i]

dp[i][j] 表示前 i 个物品,放 j 个人数的最大价格,则转移方程为:

  • dp[i][j] = max(dp[i-1][j], dp[i-1][j-a[i]] + b[i])

简化 dp,去掉第一个维度后,转移方程为:

  • dp[j] = max(dp[j], dp[j-a[i]] + b[i])
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
/* 
* Author: Tang Qiao
*/
#include <bits/stdc++.h>
using namespace std;

int n, a[1100], b[1100], dp[1100];

int main() {
ios::sync_with_stdio(0);
cin >> n;
for (int i = 1; i <=n ; ++i) {
a[i] = i;
cin >> b[i];
}
for (int i = 1; i <=n; ++i) {
for (int j = 1; j<=n; ++j) {
if (j - a[i] >= 0) {
dp[j] = max(dp[j], dp[j-a[i]] + b[i]);
}
}
}
cout << dp[n] << endl;
return 0;
}

CSPJ 教学思考:贪心算法

1、概述

贪心算法讲起来容易,就是问题求解的每一步,都用一个局部最佳的策略,如果能够证明局部最佳的策略最终能够保证全局最佳,则可以用贪心算法。

在实际 CSPJ 比赛中,我们不用严谨的求解和证明,只需要尝试做一些反例,如果反例中找不到问题,就可以先用贪心求解。毕竟比赛中时间的权重因素比较高。

在教学中,我们先通过简单的题目让学生理解贪心的概念。之后就可以逐步增加难度,让学生意识到,写出贪心可能容易,但是想到贪心这种解法在比赛中并不那么显而易见。

贪心通常伴随着排序,所以对 STL 的 sort 以及 priority_queue 的熟练应用也是快速解决贪心题目的必备基础,在学习相关题目的时候,可以重点加强巩固相关知识点。

2、sort 函数

sort 函数内部使用快速排序实现,时间复杂度为 O(N*log(N))。对于数据规模为 10 万左右的题目,出题人有可能是希望你用这个时间复杂度来解题的,所以可以留意一下是否需要排序。

对于普通类型,STL 自带了 greater<T>less<T> 两个比较器,以下是相关代码:

1
2
int v[100];
sort(v, v+n, greater<int>);

sort 函数通常和自定义的结构体排序搭配使用,以下是相关代码:

1
2
3
4
5
6
7
8
9
10
11
struct Person {
int idx;
int v;
};
bool operator < (Person a, Person b) {
return a.v < b.v;
}

Person v[1100];
// 使用时直接用 sort
sort(v, v+n);

sort 函数除了可以像上面这样通过调用 < 符号来比较大小,也可以传入一个比较函数。如下所示:

1
2
3
4
5
6
7
8
9
10
11
struct Person {
int idx;
int v;
};
bool comp(Person a, Person b) {
return a.v < b.v;
}

Person v[1100];
// 使用时直接用 sort
sort(v, v+n, comp);

以下是练习结构体排序的题目:

题目名 说明
P5143 攀爬者 按 z 坐标排序,然后求和
P1104 生日 生日的排序

3、教学题目

推荐的教学题目如下:

题目名 说明
P2240 部分背包问题 较简单的一道贪心题
P1223 排队接水 贪心进阶
P1803 凌乱的yyy 贪心进阶
P5019 铺设道路 NOIP 2018 提高组真题
B3872 巧夺大奖 GESP202309 五级
P1012 拼数 NOIP 1998 提高组

4、例题代码

P2240 部分背包问题

P2240 部分背包问题 是较简单的一道贪心题。唯一的陷阱是,学过动态规划的同学可能误以为这个是背包问题。但是在教学中,贪心算法的学习比动态规划更早,所以不会有这个误解。

此题的解题思路是:将金币按单位重量的价值排序,如果能放则放;放不了,则分割放部分。

我们定义了一个结构体,结构体中的 double p用于保存单位重量的价值。在排序的时候,按 p 的大小来由大到小排序。

参考代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
#include <bits/stdc++.h>
using namespace std;

struct Gold {
int w, v;
double p;
};
bool operator<(Gold a, Gold b) {
return a.p > b.p;
}

int n, t;
Gold v[110];

int main() {
scanf("%d%d", &n, &t);
for (int i = 0; i < n; ++i) {
scanf("%d%d", &v[i].w, &v[i].v);
v[i].p = v[i].v*1.0 / v[i].w;
}
sort(v, v+n);
double ans = 0;
for (int i = 0; i < n; ++i) {
if (t>=v[i].w) {
ans += v[i].v;
t -= v[i].w;
} else {
ans += v[i].p * t;
break;
}
}
printf("%.2f\n", ans);
return 0;
}

P1223 排队接水

此题的难度是需要推导出贪心的策略。具体推导过程如下:

由以上推导,我们只需要将打水时间按从小到大排序,然后加总时间即可。参考代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#include <bits/stdc++.h>
using namespace std;

struct Person {
int idx;
int v;
};
bool operator <(Person a, Person b) {
return a.v < b.v;
}

int n;
Person v[1100];
int main() {
cin >> n;
for (int i = 0; i < n; ++i) {
v[i].idx = i+1;
cin >> v[i].v;
}
sort(v, v+n);
long long cnt = 0;
for (int i = 0; i < n; ++i) {
printf("%d ", v[i].idx);
cnt += v[i].v * (n-i-1);
}
printf("\n%.2f\n", cnt*1.0/n);

return 0;
}

P1803 凌乱的yyy

此题有两种贪心的思路,分别是:

  • 按开始时间排序贪心
  • 按结束时间排序贪心

按开始时间排序贪心

此贪心的方法如下:

  • 左端点排序(小的在前),左端点相同的,按右端点排序(小的在前)

  • 比较当前区间和下一区间,如果下一区间与当前区间没有相交,则由于我们是按左端点排序的,后面的都不会相交,直接选择当前区间;否则这两个区间显然必须抛弃一个,由于我们是按左端点排序的,后面的区间左端点都是大于它们的,因此这两个的左端点已经没有意义了,为了留出更多的空间,留下右端点靠左的那一个即可。

参考代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
/**
* 按开始时间排序
*/
#include <bits/stdc++.h>
using namespace std;

struct Line{
int left, right;
};
bool operator<(Line a, Line b) {
if (a.left != b.left) return a.left < b.left;
return a.right < b.right;
}
int n, ans;
Line v[1000010];

int main() {
scanf("%d", &n);
for (int i = 0; i < n; ++i) {
scanf("%d%d", &v[i].left, &v[i].right);
}
sort(v, v+n);
ans = 0;
int border = 0;
for (int i = 0; i < n; ++i) {
if (v[i].left >= border) {
ans++;
border = v[i].right;
} else {
border = min(border, v[i].right);
}
}
printf("%d\n", ans);
return 0;
}

按结束时间排序贪心

此贪心的方法如下:

  • 右端点排序(小的在前),右端点相同的,按左端点排序(大的在前)

这种贪心的思路是:对于每一个结束时间,如果能排(开始时间在上一个结束时间之后),就尽量安排。如果不能排,则尝试下一个结束时间。

参考代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
/**
* 按结束时间排序
*/
#include <bits/stdc++.h>
using namespace std;

struct Line{
int left, right;
};
bool operator<(Line a, Line b) {
if (a.right != b.right) return a.right < b.right;
return a.left < b.left;
}
int n, ans;
Line v[1000010];

int main() {
scanf("%d", &n);
for (int i = 0; i < n; ++i) {
scanf("%d%d", &v[i].left, &v[i].right);
}
sort(v, v+n);
ans = 0;
int border = -1;
for (int i = 0; i < n; ++i) {
if (border <= v[i].left) {
ans++;
border = v[i].right;
}
}
printf("%d\n", ans);
return 0;
}

P5019 铺设道路

P5019 铺设道路是 NOIP2018 提高组真题。之所以作为提高组题目,是因为很难想到这种贪心策略,不过一旦想清楚,写起来是很简单的。

贪心策略是:

  • 第一个坑直接填满
  • 从第二坑开始,考虑能不能被左边顺带给填上。
  • 如果第二个坑比第一个坑小,肯定就顺带填上了。不需要任何成本。
  • 如果第二个坑比第一个坑大,那么就只能顺带填一部分,多出来的差额,需要额外的填补。

参考代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <bits/stdc++.h>
using namespace std;

int n;
int v[100010];
long long ans = 0;

int main() {
scanf("%d", &n);
for (int i = 0; i < n; ++i) {
scanf("%d", v+i);
}
ans = v[0];
for (int i = 1; i < n; ++i) {
if (v[i]>v[i-1]) {
ans += v[i] - v[i-1];
}
}
cout << ans << endl;
return 0;
}

以上。

B3872 巧夺大奖

对于本题,我们先来看一个样例,如果输入是:

1
2
3
2
2 1
20 10

以上样例最佳的贪心策略是:

  • 安排在第 2 个时间段做任务 1,得到 20 的奖励
  • 安排在第 1 个时间段做任务 2,得到 10 的奖励

由此,我们可以得出这道题的贪心策略是:

  • 按奖金的大小从大到小排序,每次取最大的奖金。
  • 对于某一个具体的奖金,其对应的时限如果为 a 的话,我们应该尽可能把它安排在 [1, a] 这个区间的较大的时间段。因为越大的时间段相对来说越宽裕,如果我们把它安排在较小的时间段,如果有另外一个游戏的时间段要求更小,那就会造成冲突。

完成的参考代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
/**
* Author: Tang Qiao
*/
#include <bits/stdc++.h>
using namespace std;

struct Game {
int time;
int reward;
};
Game games[510];
int mark[510];

bool operator<(const Game &a, const Game &b) {
return a.reward > b.reward;
}

int main() {
int n;
cin >> n;
for (int i = 0; i < n; i++) {
cin >> games[i].time;
}
for (int i = 0; i < n; i++) {
cin >> games[i].reward;
}
sort(games, games + n);
int ans = 0;
for (int i = 0; i < n; i++) {
for (int j = games[i].time; j >= 1; j--) {
if (!mark[j]) {
mark[j] = 1;
ans += games[i].reward;
break;
}
}
}
cout << ans << endl;
return 0;
}

P1012 拼数

此题容易想到的贪心策略是:每次用高位尽量大的数字。如果高位相同,则比较次高位。

但是如果两个涉及一个数是另一个数的前缀,则更复杂,让我们看看下面的例子:

  • 那如果是 321 和 32 比较呢?32132 和 32321 哪个大?看起来先用短的更好。
  • 再试一个 329 和 32 比较。32932 和 32329 哪个大?看起来先用长的更好。

所以,本题的正确解法是比较的时候将两个串“连接”起来比大小。以下是关键的比较函数:

1
2
3
bool comp(const string& a, const string& b) {
return a + b > b + a;
}

完整代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
/**
* Author: Tang Qiao
*/
#include <bits/stdc++.h>
using namespace std;

bool comp(const string& a, const string& b) {
return a + b > b + a;
}

int main() {
int n;
vector<string> a;
cin >> n;
a.resize(n);
for (int i = 0; i < n; i++) {
cin >> a[i];
}
sort(a.begin(), a.end(), comp);
for (int i = 0; i < n; i++) {
cout << a[i];
}
cout << endl;
return 0;
}

读《蹒跚前行 1870~2010 经济史》

本书的作者德龙教授是经济史学家,加州伯克利分校经济学教授,曾担任克林顿政府财政部副助理部长。

这本书以历史发展的时间线,介绍全球经济发展的过程。战争(包括一战和二战)、通胀、通缩、黄金发展期伴随着这 100 多年的历史进程,读完让人感叹发展的不易,就如本书书名:蹒跚前行。

以下是一些笔记。

1、关于通胀

通胀类似于一种税收和财富调节手段。它将财富从拥有现金的人手里转移到了拥有非现金财富的手里。

如果你是借款人,因为借款人是用贬值后的货币还款,而贷款人不得不接受已经贬值的货币。所以通胀对于欠债方是利好的。

政府发行的货币最好是和 GDP 的增长匹配。如果政府因为各种原因印制了更多货币来满足一些特定需求的时候,就会推动通胀。

通胀是一个零和博弈。受益者和损失者的损益完全匹配。

2、失业率对社会的影响

书中介绍了大萧条时期,就业环境如何给大家带来了巨大的伤害。乔治·奥威尔的描述是:“让我害怕并震惊的事情是,看到许多人因为失业而感到羞愧。”

在一个合理的社会中,每个人应该能够通过劳动自食其力。这让社会处于一个体面的状态。如果社会和经济制度让大量民众失去工作,那么一方面给个体会带来巨大的心理打击,另一方面也会给社会带来不稳定因素。

3、日本的终身雇佣制

日本的终身雇佣制来源于 20 世纪上半叶。当时日本的制造业很依赖未婚年轻女性,但是这一劳动队伍缺乏经验,同时流失率高。为了平衡流失率,日本慢慢发展出了终身雇佣制。

同时,1930 年,日本通过放弃金本位和对外扩张,避免了欧洲传过来的大萧条,从而让企业无需解雇员工,强化了终身雇佣制的文化。

4、二战德国为什么会失败

二战核心打的是经济战,虽然德国战术很强,但是从经济产出看,在 1944 年, 盟军的生产效率与德日对比为 150:24。即:德国和日本每生产出 1 架飞机,对方可以生产 6 架。

5、社会主义国家选择企业国有化的理由

作者提供了 3 个主要原因:

  • 担心垄断。领导人认为垄断企业会对社会公众进行剥削,除非国有化。
  • 担心腐败。垄断企业可能直接收买负责监管的机构。
  • 马克思主义信仰。认为资本市场具备剥削性质,只有公有制才能避免这种剥削。

以上理由多少可以用来理解我们的社会。

6、安全资产短缺问题

当经济危机发生的时候,大家会抛售之前被认为安全的资产。这个时候就会出现安全资产短缺的问题。这个时候怎么办呢?

作者介绍了白芝浩-明斯基策略:为应对安全资产短缺现象,政府的最优选择是立即基于平时被视为优质资产的抵押品提供充足的贷款,但收取惩罚性利率。

提供充足贷款意味着创造足够多的安全资产,使供应不再短缺。收取惩罚性利率则意味着防范投机性金融机构利用这种混乱局面来渔利。

以上。

2024 年个人总结

一、工作

财务视角

2024 年从财务视角,业务整体有不小的进步。

23 年虽然业务增长不错,但是整体有将将近千万的亏损,而 24 年整体的赢利是上千万的。所以业务整体健康度更高。当然,因为我们严卡利润率,我们的营收规模在 2024 年基本上没有什么增长,还是在 2 个亿左右。希望 2025 年有所增长。

海外业务在收缩为一人之后,也有不小的起色。我们在韩国还是找到了一条基于 coupang 全拖管的立足之地,可以基于这个基本盘开始做增长。虽然小,但是不至于每个月担心巨大的亏损,所以能睡得着觉。

产品视角

分产品来说,2024 年我们没有交付什么成功的新产品。虽然我们在年初上线了英语闪卡机,下半年上线了斑马拼音机 G2,但是这两款产品都没能上规模。不管是达人还是直播间,这两款产品都运营得比较艰难。

斑马童书

年底还有一个大的变化,就是我开始负责斑马童书。

童书是一个市场比玩教具小,同时竞争更加激烈的品类。但是对我来说,能够学习一个新的品类的玩法,也是一种成长,所以我还是很愿意投入精力在里面,看看能不能深耕出一些结果。

二、读书和写作

24 年一共读了 10 本书,以下是读书笔记:

写作方面,整理了以下文章:

今年还写了一篇涉及农夫山泉的文章《替农夫山泉说句话》,整个过程对我的帮助也很大,让我理解了情绪的力量。虽然当时争议很大,但事后看来,我的观点是对的,这也让我很开心。

三、爱好

今年开始系统性将 CSPJ 培训作为自己的爱好,我打算把这作为自己退休后的生活内容。因为目标在 20 年之后,所以我也开始慢慢总结自己在信息学竞赛上的经验,共分享了以下几篇文章:

除了爱好外,今年还做了一些事情来悦己:

  • 买了一台极米 Z7X 高亮版投影仪,在床上看投屏的感受很好。
  • 买了一台 M3 的 MacBook Air,在家用电脑的幸福感直线上升。
  • 买了一部荣耀 Magic V3 折叠屏。在工作中看文档效率,以及读书的体验提升明显。
  • 买了一台 Insta 360 拇指相机。发现拍的时候很爽,但剪辑视频累死人。
  • 双 11 给家里的猫买了自动喂食器和自动喂水器。再也不用每天惦记着毛孩子的吃喝问题了。不过喂食器买完有点后悔,应该买带摄像头的,这样就可以知道有没有吃完。

今年也买了一些软件:

  • Sublime Text,花了 99 美元。平时写博客和 CSPJ 代码都用它。
  • Longshot,花了大概 100 RMB。可以支持长截图。
  • Bartender 5,MacBook 的刘海屏下,没这个显示不了太多状态栏的东西。

四、理财

今年理财在贯彻自己年初目标上执行得还可以。

  • 年初定下来的定投目标,执行比较顺利。513500 算是一个很不错的 QDII 标的,唯一的缺点就是综合管理费是 0.91%

  • 年初还想在合适的时候赎回指数增强产品,这个也在年底做了。之前持有了三年的金锝和九坤的 500 指数增强,发现不同的产品增强的成绩差很多,能差 10% 以上。

  • 赎回了元盛 CTA。元盛给我的理解是:它能够在经济上行和下行的时候,都能捕捉到套利机会。但是元盛近两年的收益都是负的,我无法理解为什么这两年都没有机会。和管理团队的沟通机会也不是很好,所以赎回了。

今年整体港股和 A 股都有不错的收益。A 股整体有 19.05% 的收益。

港股里面:

  • 腾讯 417,+11%
  • 恒生高股息 23.9,+4%
  • 波司登 3.88,+18%
  • 海底捞 15.9,+27%
  • 伟易达 52.8,-2.9%

今年在理财上也有更多的思考和成长。比如:

  • 不懂不碰。以前是没那么遵守的,今年会更加严格。我也因此卖出了茅台。
  • 再平衡。以前没有严格做,在建平上吃了大亏,建平曾经有 100% 的收益,那个时候没有做再平衡,心理上贪多,还是自己能力不够,今年开始认真做这个事情。

五、24 年的目标回顾

  • 工作:
    • 销售:搭建好销售团队,带好团队的核心成员。培养有共同价值观和长久共事意愿的同事。这一点有一些进展,团队成员今年有一些流动,我觉得是好的。
    • 产品:推进硬件产品的创新尝试。今年没什么有效的落地,不算很好。
  • 理财:
    • 定投少量标普 500,建立初始仓位。今年做得不错。
    • 在合适的时候减少 A 股的指数增强仓位。今年做得不错。
  • 个人:
    • 读 6 本书。完成了,最后读了 9 本。
    • 很久没出国了,想抽空去一趟加拿大。没能完成。
    • 每月游泳一次。完成了。
    • 积极乐观。今年马马虎虎吧。

六、25 年的目标

  • 工作:硬件稳中有增,童书赢亏打正。带好童书业务。
  • 理财:做好配置,找到能拿 10 年的标的,并能坚定持有。
  • 个人:读 6 本书。CSPJ 教学继续累进。

七、个人 Milestone

  • 硬件业务利润过千万
  • 开始负责童书售卖业务

极致性价比 - 读《小米创业思考》

其实我以前一直不理解雷军。

原因一是我在猿辅导工作,我们做的产品都是追求创新和高品质。因为成本不低,所以我们的产品定价不那么便宜。像我们公司的学练机、月子中心、咖啡、月龄盒,以及我负责的斑马玩教具,说实话定价在行业都是比较高的。

原因二是我比较欣赏的人,不管是公司内部的同事,还是公司外部的一些人,都对 “性价比” 这个词表现出不喜欢。这种不喜欢主要是站在商业角度,这种模式做起来太辛苦,太容易失败。

原因三是我自己曾负责过一款基于微信传播的英语学习产品。在这个产品失败前,我们尝试过极致的低价,但是最后并没有带来同等回报的增长,所以我知道,低价并不好做。

最近读了根据雷军口述整理出来的《小米创业思考》,终于有那么一点点理解雷军要做什么了。

以下是一些感悟。

雷军的 “极致性价比” 逻辑

雷军的 “极致性价比” 的想法来自 Costco,他在采访中说,一个在中国国内卖几千块钱的新秀丽的行李箱,在 Costco 只需要几百块钱。同时,雷军是一个有比较多社会责任感的企业家,他希望在互联网时代,大家可以用厚道的价格买到极致体验的东西,于是,小米成了他这个理想的实践地。

企业的存在,首先是因为有社会价值,即用户需求。首先因为用户需要某种服务,才会有相应的企业存在。在用户需求的基础下,企业才会有自己的经营使命和战略,战略应该围绕着自己的社会价值,去更好地满足自己的社会价值,这样的企业才能活得更久。

小米运用 “极致性价比” 逻辑,选择了一个极度差异化的经营模式,这种模式下:

  • 小米的产品具备独特的价值:性价比高。
  • 小米的产品总成本领先:因为量大。
  • 小米的竞争对手难以模仿。因为这种模式太难生存了。

所以,小米其实是选了一条几乎没有人,也几乎没有人走成功的路。

所以,了解完小米的逻辑之后,我理解了雷军。其实常见的经营模式雷军都知道,也都理解,但是雷军就是想走一条不一样的路。同时他也认为这条路虽然难,但是对于开创者的回报巨大。

小米如何完成 “不可能三角”

小米这种模式,需要同时做到三点:产品好、价格低,以及要有合理的利润(也就是股东回报),雷军称之为 “不可能三角”。那他是如何完成的呢?

  • 产品好。雷军要求团队只做高端和创新的产品,即便是做充电宝,也是将原本用在笔记本电脑上的铝外壳做到了充电宝中。除了产品好外,小米在打造新品时,首先考量的第一要素是,产品是否具备“明天属性”。“明天属性”是指代表先进趋势的体验,而且这种体验是用户一旦用过就不想放手的。比如用户一旦用了智能手机,就再也不想用非智能手机了。

  • 价格低。雷军相信厚道的定价会带来规模效应,所以,他的很多产品是贴着成本价来定的。首款小米手机,成本 2000 块,他就定价 1999。这充分诠释了他对于价格的理解。

  • 合理利润。这么低的价格还能有利润吗?只有向制造环节要规模效应和生产效率,同时向流通环节要效率。

在合理利润这个点上,雷军做了很多事情。比如在制造环节:

  • 他们投资机械臂算法的公司,希望将机械臂的价格打下来,这样就可以在生产中尽可能使用机械臂。
  • 他们将生产线做改造,将不同产线的差异装配点模块化,使得换线成本显著降低。

在向流程环节要效率这个点上,雷军遇到了很大的挑战,没有线下的渠道愿意与他合作。于是在初期,他只能和自己的售后合作伙伴来合作开店,最终把线下渠道的成本压到了 10% 左右。而传统的渠道,成本是 20% 左右。

但是,即使到了现在,小米在合理利润这个点上,也没有完全通过市场检验。在手机端,小米因为有大量应用市场广告和 App 预装等服务性收费,才使得他有足够的利润。但是在硬件端,不是每款硬件都可以靠服务收费的,比如大部分小米生态链产品就不太需要服务,小米还需要在未来回答这些问题。

小米如何做第一辆车

小米切入造车行业,刚开始下属的提案有很多创新。雷军觉得不好,他觉得大公司做新业务的三个大坑:认知错位、惯性思维、偶像包袱。总觉得自己牛逼,做新业务要干件大事,但是自己在新领域很可能就是一个小学生,有很多该交的学费都还没交。

所以,雷军要求团队 “先确保做一款好车,一款能够与当下同级所有产品比拼的好车,在确保这个目标的基础上,再考虑颠覆的部分。”

当目标变成 “一款好车” 时,颠覆不颠覆就不那么重要了,什么东西好拿过来借鉴就好了。于是,小米的第一款车显得很熟悉,很多保时捷上的设计被借鉴来了,大家也被一款好的设计所吸引。

虽然入局晚了几年,但小米汽车还是获得了一个梦幻开局。

终局思维

雷军在书中提到了消费电子行业的规律:当 15-20 年后行业进入成熟期,全球前 5 的品牌必将手握 80% 以上的份额。也就是说,只有最终进入全球行业前 5,做到年出货 1000 万台以上才有意义。

雷军在进入这个行业的最初,就想好了 20 年后的终局。这种终局思维才让他能够做长期主义的事情,包括投入三电等基础能力的研发,包括为造车留够足够的资金,也包括他自己的 All in 行为。

小米曾经犯的错误

芒格说:如果你知道自己可能死在哪里,就永远不要去那个地方。雷军在书中提了很多小米犯的错误,这些错误让我记忆犹新。以下是一些记录:

性价比应该作用在用户价值上

小米早期连 SIM 卡的卡针都要用 10 倍于同行的材质和工艺,这事后来被雷军叫停了。雷军认为,所有的产品体验成本,应该用在用户价值上,如果用户用不到,就是自嗨。SIM 卡的卡针大部分用户只会用一次,这个卡针上就没必要用 10 倍于同行的成本。

在消费品行业,一些产品包装也会有同样的问题。如果消费者收到的产品过度包装,消费者就会认为 “羊毛出在羊身上”,这反倒是一种浪费。

品牌

雷军认为,自己在红米品牌上犯了错,以及之前用了很多 X 米的生态链品牌都是不对的,这些品牌模糊了小米品牌。所以,后来红米改名成为了 Redmi。

小米品牌,最终只用在了非常核心的产品上,包括:手机、电视、路由、音箱、笔记本电脑,以及后来做的汽车。

以上。

CSPJ 教学思考:宽度优先搜索

在学习完数据结构队列(queue)后,就可以让学生学习宽度优先搜索了。

宽度优先搜索(BFS)的形式相对固定,但是写起来代码偏长,学生在学习的时候,老是容易忘掉一些环节,所以需要加强练习。

1、模版记忆

我整理了一个 BFS 的模版,每次教学前让孩子复述这个环节,通过这种方式来强化模版的记忆,帮助学生掌握这个算法。

模版如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void bfs() {
queue< ? > q;

q.push( ? );
标记 ? 已经处理

while (!q.empty()) {
? = q.front(); q.pop();

for(各种情况) {
if (可入队) {
q.push( ? )
标记 ? 已经处理
}
}
}
}

2、关于结构体的使用

在教学宽度优先搜索的初期,其实并不需要将入队的数据整合成结构体。这样反而会让代码变得更复杂。可以直接将需要入队的数据成组地 push 和 pop,这样就实现了简易的类似结构体的效果。

3、教学题目

推荐的教学题目如下:

题目名 说明
B3625 迷宫寻路 新手入门,没有陷阱,学习方向数组写法
P1443 马的遍历 需要求步数,需要写 8 个方向
P1135 奇怪的电梯 BFS 不仅仅可以是在地图上,也可以是另外的搜索形式
P1162 填涂颜色 学习标记技巧:将地图往外扩一圈 0 ,减少标记难度
P1825 Corn Maze S 变种的地图,可以传送
P1451 求细胞数量 多次的 BFS 标记

推荐更多练习的题目如下,可作为基础训练之用:

题目名 说明
P1746 离开中山路 比较标准的练习,无坑
P1506 拯救oibh总部 强化P1162 填涂颜色 中学到的标记技巧
P1331 海战 多次 BFS 标记的同时,如何判断标记物是矩行

以下题目难度更高一些,可以作为强化训练之用:

题目名 说明
P2895 Meteor Shower S USACO 08 FEB
P1141 01迷宫 数据量很大,需要提前保存查询结果
P2802 回家 状态变为走过时的血量有没有变高
P8604 危险系数 [蓝桥杯 2013 国 C]题目,用 BFS 暴力尝试
Takahashi is Slime 2 变种的 BFS,需要用优先队列

4、例题代码

以下是详细的例题代码说明。

B3625 迷宫寻路

B3625 迷宫寻路 是一道非常基础的宽度优先搜索,只需要输出 YES 或者 NO,对输出的要求也较小,适合拿来入门教学。

在本例题中,我们也要开始教会学生定义 movex、movey 数组,后续在迷宫一类的宽度搜索题目中,这种技巧非常见。movex、movey 的快速定义技巧是:movex 和 movey 的结构交替,每一组都是一个 1 和一个 0,同时变换 1 的正负号。记住这样的技巧就可以快速定义出这两个数组。代码如下:

1
2
int movex[]={-1,1,0,0};
int movey[]={0,0,-1,1};

本例还需要一个数组标记是否走过,我们使用 flag 数组。参考代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
/**
* B3625 迷宫寻路,宽度优先搜索。
*/
#include <bits/stdc++.h>
using namespace std;

int n, m;
int movex[]={-1,1,0,0};
int movey[]={0,0,-1,1};
char tu[110][110];
bool flag[110][110] = {false};

void bfs(int x, int y) {
bool result = false;
int tox, toy;
queue<int> q;
q.push(x); q.push(y);
flag[x][y] = true;
while (!q.empty()) {
x = q.front(); q.pop();
y = q.front(); q.pop();
if (x == n-1 && y == m-1) {
result = true;
break;
}
for (int i = 0; i < 4; ++i) {
tox = x + movex[i];
toy = y + movey[i];
if (tox >= 0 && tox <n && toy >=0 && toy<m
&& tu[tox][toy] == '.'
&& flag[tox][toy]== false) {
flag[tox][toy] = true;
q.push(tox); q.push(toy);
}
}
}
if (result) cout << "Yes" << endl;
else cout << "No" << endl;
}

int main() {
cin >> n >> m;
for (int i = 0; i < n; ++i) {
cin >> tu[i];
}
bfs(0, 0);
return 0;
}

迷宫寻路加强:求步数

有了上面的代码,我们可以在题目上做变动,比如把输出的要求改为:
如果能到达,则输出到达终点的最短步数 ,引导学生思考,现有的代码要做怎样的改造,才能实现新的要求。

于是,我们讨论得出,需要将”步数”引入到代码中,于是,原来的代码增加了两处修改:

  • 每次入队的时候,将当前位置到达的步数也入队
  • 如果到达终点,记录下来当时的步数

改动的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
/**
* B3625 迷宫寻路,宽度优先搜索。
*/
#include <bits/stdc++.h>
using namespace std;

int n, m, ans;
int movex[]={-1,1,0,0};
int movey[]={0,0,-1,1};
char tu[110][110];
bool flag[110][110] = {false};

void bfs(int x, int y) {
bool result = false;
int tox, toy, step;
queue<int> q;
q.push(x); q.push(y);
q.push(1); // 改动 1
flag[x][y] = true;
while (!q.empty()) {
x = q.front(); q.pop();
y = q.front(); q.pop();
step = q.front(); q.pop(); // 改动 2
if (x == n-1 && y == m-1) {
result = true;
ans = step; // 改动 3
break;
}
for (int i = 0; i < 4; ++i) {
tox = x + movex[i];
toy = y + movey[i];
if (tox >= 0 && tox <n && toy >=0 && toy<m
&& tu[tox][toy] == '.'
&& flag[tox][toy]== false) {
flag[tox][toy] = true;
q.push(tox); q.push(toy);
q.push(step+1); // 改动 4
}
}
}
if (result) cout << "Yes, step = " << ans << endl;
else cout << "No" << endl;
}

int main() {
cin >> n >> m;
for (int i = 0; i < n; ++i) {
cin >> tu[i];
}
bfs(0, 0);
return 0;
}

迷宫寻路加强:求路径

当我们需要输出路径的时候,我们需要做两件事情:

1、把 BFS 经过的数据全部保存下来。这个时候我们就不能用队列了,只能用 vector,然后另外用一个变量 idx 来记录处理过的元素下标。于是,判断是否处理完的条件变成了如下的形式:

1
while (idx != q.size())

2、我们需要对每个元素中增加一个 parent 变量,记录它是来自哪一个下标。这样就可以把整个路径串起来。如下的形式:

1
2
3
4
5
6
struct Node {
int x, y, step, parent;
Node(int _x, int _y, int _step, int _parent) {
x = _x; y = _y; step = _step; parent=_parent;
}
};

最终,整体的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
/**
* B3625 迷宫寻路,宽度优先搜索。
*/
#include <bits/stdc++.h>
using namespace std;

int n, m, ans;
int movex[]={-1,1,0,0};
int movey[]={0,0,-1,1};
char tu[110][110];
bool flag[110][110] = {false};

struct Node {
int x, y, step, parent;
Node(int _x, int _y, int _step, int _parent) {
x = _x; y = _y; step = _step; parent=_parent;
}
};

void bfs(int x, int y) {
bool result = false;
int tox, toy, step;
vector<Node> q;
int idx = 0;
q.push_back(Node(x, y, 1, -1));
flag[x][y] = true;
while (idx != q.size()) {
Node node = q[idx];
if (node.x == n-1 && node.y == m-1) {
result = true;
// output
stack<Node> s;
s.push(node);
while (node.parent != -1) {
node = q[node.parent];
s.push(node);
}
while (!s.empty()) {
node = s.top(); s.pop();
printf("(%d, %d) ->\n", node.x+1, node.y+1);
}
break;
}
for (int i = 0; i < 4; ++i) {
tox = node.x + movex[i];
toy = node.y + movey[i];
if (tox >= 0 && tox <n && toy >=0 && toy<m
&& tu[tox][toy] == '.'
&& flag[tox][toy]== false) {
flag[tox][toy] = true;
q.push_back(Node(tox, toy, step+1, idx));
}
}
idx++;
}
if (!result) printf("No\n");
}

int main() {
cin >> n >> m;
for (int i = 0; i < n; ++i) {
cin >> tu[i];
}
bfs(0, 0);
return 0;
}
/*
3 5
.##.#
.#...
...#.
*/

P1443 马的遍历

有了迷宫寻路的变种练习基础,我们就可以正式练习用 BFS 来求最近的步数一类的题目了。这其中比较适合的题目是: P1443 马的遍历

《马的遍历》一题要求我们把所有位置的最近距离都求出来,我们可以用一个数组来保存结果。

同时,马可以跳 8 个方向,有了之前的建 movex, movey 的经验,我们知道,每组数是 1 与 2 的各种组合。于是可以快速写出来这两个方向数组。

具体写法是:

  • 先写 x 数组,把所有的负数写出来,再写所有的正数。
  • 考虑到每个数会有正负两个 y 与此搭档,所以每个数我们写两遍。这样就写出来了 -2,-2,-1,-1,1,1,2,2
  • 然后我们对着 movex 写 movey,凡是对应的 movex 是 2 的,我们就写 1,凡是 movex 是 1的,我们就写 2,同样的我们需要写正数和负数两遍。
  • 写完后两个数组的字符串也应该是刚好一样的,可以帮我们作为一个检查手段。

具体如下所示:

1
2
int movex[]={-2,-2,-1,-1,1,1,2,2};
int movey[]={-1,1,2,-2,2,-2,1,-1};

完整的《马的遍历》的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
/**
* P1443 马的遍历, 宽度优先搜索
*/
#include <bits/stdc++.h>
using namespace std;

// 坐标是从 1,1 开始算的
int n, m, x, y;
int tu[410][410];
bool flag[410][410]={false};
int movex[]={-2,-2,-1,-1,1,1,2,2};
int movey[]={-1,1,2,-2,2,-2,1,-1};

void bfs(int x, int y) {
queue<int> q;

q.push(x); q.push(y); q.push(0);
tu[x][y] = 0;
flag[x][y] = true;

while (!q.empty()) {
x = q.front(); q.pop();
y = q.front(); q.pop();
int step = q.front(); q.pop();
for (int i = 0; i < 8; ++i) {
int tox = x + movex[i];
int toy = y + movey[i];
if (tox>=1 && tox<=n && toy>=1 && toy<=m &&
!flag[tox][toy]){
flag[tox][toy] = true;
q.push(tox); q.push(toy); q.push(step+1);
tu[tox][toy] = step+1;
}
}
}
}

int main() {
memset(tu, -1, sizeof(tu));
cin>>n>>m>>x>>y;
bfs(x, y);
for (int i = 1; i <=n ; ++i) {
for (int j = 1; j<=m; ++j) {
printf("%d ", tu[i][j]);
}
printf("\n");
}
return 0;
}

本题还有一个小的教学点,就是用 memset 来初始化值为 -1。可以顺便教学 memset 可以初使化的值,告诉学生不是每种值都可以用 memset 来初始化。

P1135 奇怪的电梯

P1135 奇怪的电梯 一题的意义在于,用非地图的形式来教学 BFS,让学生知道 BFS 不仅仅可以是在地图上。

但从实现来说,此题的难度相对较小。此题的参考代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
/**
* P1135 奇怪的电梯
*
* 宽度优先搜索
*/
#include <bits/stdc++.h>
using namespace std;

int N, A, B;
int jump[210];
char flag[210]={0};
int ans = -1;

struct Node {
int v;
int step;
};

void bfs() {
Node node, up, down;
queue<Node> q;

if (A == B) {
ans = 0;
return ;
}
node.v = A;
node.step = 0;
q.push(node);
flag[node.v] = 1;
while (!q.empty()) {
up = down = node = q.front(); q.pop();

up.v += jump[node.v];
down.v -= jump[node.v];
up.step = down.step = node.step + 1;
if (up.v <= N && flag[up.v] == 0) {
q.push(up);
flag[up.v] = 1;
}
if (down.v >=1 && flag[down.v] ==0 ) {
q.push( down );
flag[down.v] = 1;
}
if (up.v == B || down.v == B) {
ans = node.step + 1;
break;
}
}
}


int main() {
scanf("%d%d%d", &N, &A, &B);
for (int i = 0; i < N; ++i) {
scanf("%d", jump+i+1);
}
bfs();
printf("%d\n", ans);
return 0;
}

P1162 填涂颜色

P1162 填涂颜色 可以用来学习地图标记的一个技巧:将地图往外扩一圈 0 ,减少标记难度。实际在写的时候,只需要从下标 1 开始读数据即可。

此题的参考代码如下,代码的最后用注释带了一个测试用例。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
/**
* P1162 填涂颜色
*/
#include <bits/stdc++.h>
using namespace std;

int n;
int tu[40][40] = {0};
bool flag[40][40] = {false};
int movex[]={-1,1,0,0};
int movey[]={0,0,-1,1};

void bfs(int x, int y) {
queue<int> q;
q.push(x);
q.push(y);
flag[x][y] = true;

while (!q.empty()) {
x = q.front(); q.pop();
y = q.front(); q.pop();
for (int i = 0; i < 4; ++i) {
int tox = x+movex[i];
int toy = y+movey[i];
if (tox>=0 && tox<=n+1 && toy >=0 && toy<=n+1
&& tu[tox][toy] == 0 && flag[tox][toy]==false) {
q.push(tox);
q.push(toy);
flag[tox][toy] = true;
}
}
}
}

int main() {
scanf("%d", &n);
for (int i = 1; i <= n; ++i) {
for (int j = 1; j <=n; ++j) {
scanf("%d", &tu[i][j]);
}
}

bfs(0, 0);

for (int i = 1; i <= n; ++i) {
for (int j = 1; j <=n; ++j) {
if (tu[i][j] == 0 && flag[i][j] == false) {
printf("%d ", 2);
} else {
printf("%d ", tu[i][j]);
}
}
printf("\n");
}

return 0;
}

/*

6
0 0 0 0 0 0
0 0 1 1 1 1
0 1 1 0 1 0
1 1 0 0 1 1
0 1 0 0 1 1
1 1 1 1 1 0
*/

P1506 拯救oibh总部

P1506 拯救oibh总部 强化上一题学到的技巧。

同时我们此题学习用 memset 将 char 数组统一设置成字符’0’:

1
memset(tu, '0', sizeof(tu));

参考代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
/**
* P1506 拯救oibh总部
*/
#include <bits/stdc++.h>
using namespace std;

int n,m;
char tu[510][510]={0};
bool flag[510][510]={false};
int movex[]={-1,1,0,0};
int movey[]={0,0,-1,1};

void bfs(int x, int y) {
queue<int> q;
q.push(x);
q.push(y);
flag[x][y] = 1;
while (!q.empty()) {
x = q.front(); q.pop();
y = q.front(); q.pop();
for (int i = 0; i < 4; ++i) {
int tox = x + movex[i];
int toy = y + movey[i];
if (tox>=0 && tox <=n+1 &&
toy>=0 && toy <=m+1 &&
tu[tox][toy] == '0' &&
flag[tox][toy] == false) {
flag[tox][toy] = true;
q.push(tox);
q.push(toy);
}
}
}
}

int main() {
memset(tu, '0', sizeof(tu));
scanf("%d%d", &n, &m);
for (int i = 1; i <= n; ++i) {
char ss[510];
scanf("%s", ss);
for (int j = 1; j <= m; ++j) {
tu[i][j] = ss[j-1];
}
}
bfs(0, 0);

int ans = 0;
for (int i = 1; i <= n; ++i) {
for (int j = 1; j <=m; ++j) {
if (tu[i][j] == '0' && flag[i][j]==false)
ans++;
}
}
printf("%d\n", ans);

return 0;
}

P1825 Corn Maze S

P1825 Corn Maze S 增加了“地图传送”这种新的玩法,使得 BFS 代码写起来会更加复杂一点。

像这种更复杂的 BFS,我们就可以引入结构体,来让代码更整洁一点。结构体定义如下:

1
2
3
4
5
struct Node {
int x, y;
Node() {x=y=0;}
Node(int _x, int _y) {x = _x; y=_y;}
};

因为在 BFS 的过程中,我们还需要记录步数,所以我们用 STL 的 pair 来存储队列元素。借此题,我们完成了 pair 的教学。

pair 的关键用法如下:

1
2
3
4
5
6
7
8
9
// 定义
queue<pair<Node, int> > q;
// 入队
q.push(make_pair(a, 0));
// 出队
pair<Node, int> one = q.front(); q.pop();
// 使用
Node a = one.first;
int step = one.second;

完整的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
/**
* P1825 [USACO11OPEN] Corn Maze S
* 宽度优先搜索
*
* 遇到传送的时候,把位置更新到另一个传送点。
*/
#include <bits/stdc++.h>
using namespace std;

int N,M;
char tu[310][310]={0};
bool flag[310][310]={0};
struct Node {
int x, y;
Node() {x=y=0;}
Node(int _x, int _y) {x = _x; y=_y;}
};
Node st;
int movex[]={-1,1,0,0};
int movey[]={0,0,-1,1};

bool operator==(Node a, Node b) {
return a.x == b.x && a.y == b.y;
}

Node getNode(char ch) {
for (int i = 0; i < N; ++i) {
for (int j = 0; j < M; ++j) {
if (tu[i][j] == ch) {
return Node(i,j);
}
}
}
return Node(0, 0);
}

Node getOtherNode(char ch, int x, int y) {
for (int i = 0; i < N; ++i) {
for (int j = 0; j < M; ++j) {
if (x == i && y == j) continue;
if (tu[i][j] == ch) {
return Node(i,j);
}
}
}
return Node(0, 0);
}

int bfs(Node a) {
queue<pair<Node, int> > q;
q.push(make_pair(a, 0));
flag[a.x][a.y] = true;
while (!q.empty()) {
pair<Node, int> one = q.front(); q.pop();
a = one.first;
int step = one.second;
char ch = tu[a.x][a.y];
if (ch >= 'A' && ch <='Z') {
a = getOtherNode(ch, a.x, a.y);
} else if (ch == '=') {
return step;
}
for (int i = 0; i < 4; ++i) {
int tox = a.x + movex[i];
int toy = a.y + movey[i];
if (tox>=0 && tox<N && toy>=0 && toy<M &&
tu[tox][toy] != '#' && !flag[tox][toy]) {
q.push(make_pair(Node(tox, toy), step+1));
flag[tox][toy] = true;
}
}
}
return 0;
}

int main() {
scanf("%d%d", &N, &M);
for (int i = 0; i < N; ++i) {
scanf("%s", tu[i]);
}
Node st = getNode('@');
printf("%d\n", bfs(st));
return 0;
}

P1451 求细胞数量

P1451 求细胞数量 是一道非常基础的 BFS 题目。此题需要多次调用 BFS,参考代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
/**
* P1451 求细胞数量
*/
#include <bits/stdc++.h>
using namespace std;

int n, m, ans = 0;
char tu[110][110]={0};
bool flag[110][110]={false};
int movex[]={-1,1,0,0};
int movey[]={0,0,-1,1};

void bfs(int x, int y) {
queue<int> q;

q.push(x);
q.push(y);
flag[x][y] = true;

while (!q.empty()) {
x = q.front(); q.pop();
y = q.front(); q.pop();

for (int i = 0; i < 4; ++i) {
int tox = x + movex[i];
int toy = y + movey[i];
if (tox >= 0 && tox < n &&
toy >= 0 && toy < m &&
tu[tox][toy]!='0' &&
flag[tox][toy]==false) {
flag[tox][toy] = true;
q.push(tox);
q.push(toy);
}
}
}
}

int main() {
scanf("%d%d", &n, &m);
for (int i = 0; i < n; ++i) {
scanf("%s", tu[i]);
}
for (int i = 0; i < n; ++i) {
for (int j = 0; j < m; ++j) {
if (tu[i][j] != '0' && flag[i][j] == false) {
bfs(i, j);
ans++;
}
}
}
printf("%d\n", ans);
return 0;
}

P1331 海战

P1331 海战 一题的标记矩形的形式比较难想到,我个人用的是另外一个判断方法:看看所填充的坐标最小和最大值计算出来的矩形面积与标记的数量是否刚好匹配。

参考代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
/**
* 宽度优先搜索。
*
* 先用 floodfill 把每组船支标记。标记的时候,记录:
* - 最小 minx, miny 和最大 maxx, maxy
* 然后判断是否标记的船只数量是否是正方形:
* - cnt == (maxx-minx+1)*(maxy-miny+1)
*
*/
#include <bits/stdc++.h>
using namespace std;

int R, C;
char tu[1100][1100] = {0};
bool flag[1100][1100] = {false};
int shipCnt = 0;
int movex[]={-1,1,0,0};
int movey[]={0,0,-1,1};
bool debug = false;

bool mark(int x, int y) {
int ans = 0;
int minx, miny, maxx, maxy;
queue<int> q;

q.push(x);
q.push(y);
minx = maxx = x;
miny = maxy = y;
flag[x][y] = true;

while (!q.empty()) {
x = q.front(); q.pop();
y = q.front(); q.pop();
ans++;
minx = min(minx, x);
miny = min(miny, y);
maxx = max(maxx, x);
maxy = max(maxy, y);
for (int i = 0; i < 4; ++i) {
int tox = x + movex[i];
int toy = y + movey[i];
if (tox >=0 && tox < R && toy>=0 && toy<C
&& tu[tox][toy] == '#' && !flag[tox][toy]) {
q.push(tox);
q.push(toy);
flag[tox][toy] = true;
}
}
}
int cnt = (maxx-minx+1)*(maxy-miny+1);
if (ans == cnt) {
shipCnt++;
return true;
} else {
return false;
}
}

void init() {
scanf("%d%d", &R, &C);
for (int i = 0; i < R; ++i) {
scanf("%s", tu[i]);
}
}

void process() {
for (int i = 0; i < R; ++i) {
for (int j = 0; j < C; ++j) {
if (tu[i][j] == '#' && flag[i][j] == false) {
if (!mark(i, j)) {
shipCnt = -1;
return;
}
}
}
}
}

int main() {
init();
process();
if (shipCnt == -1) printf("Bad placement.\n");
else printf("There are %d ships.\n", shipCnt);
return 0;
}
/*
6 8
.....#.#
##.....#
##.....#
.......#
##.....#
#..#...#
*/

P2895 Meteor Shower S

此题解法:

  • 标记下地图每个不能行走的位置,以及它变成焦土的时间。
  • 在 BFS 的时候,如果当前时间位置还没变成焦土,就可以继续走。

陷阱:有第 0 时刻就落下来的流星。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
/**
* Author: Tang Qiao
*/
#include <bits/stdc++.h>
using namespace std;

int m;
// -1 表示可以行走,非 -1 表示在第 i 时刻变成焦土
int tu[310][310];
bool vis[310][310];
int movex[] = {0, 0, 0, 1, -1};
int movey[] = {0, 1, -1, 0, 0};
struct Node {
int x, y, t;
Node(int _x, int _y, int _t) : x(_x), y(_y), t(_t) {}
};

void mark(int x, int y, int t) {
for (int i = 0; i < 5; i++) {
int nx = x + movex[i];
int ny = y + movey[i];
if (nx >= 0 && ny >= 0) {
if (tu[nx][ny] == -1) tu[nx][ny] = t;
else tu[nx][ny] = min(tu[nx][ny], t);
}
}
}

void bfs() {
queue<Node> q;
q.push(Node(0, 0, 0));
vis[0][0] = true;
while (!q.empty()) {
Node node = q.front();
q.pop();
if (tu[node.x][node.y] == -1) {
cout << node.t << endl;
return;
}
for (int i = 1; i < 5; i++) {
int nx = node.x + movex[i];
int ny = node.y + movey[i];
if (nx >= 0 && ny >= 0 && !vis[nx][ny]) {
if (tu[nx][ny] == -1) {
cout << node.t + 1 << endl;
return;
}
if (tu[nx][ny] > node.t + 1) {
vis[nx][ny] = true;
q.push(Node(nx, ny, node.t + 1));
}
}
}
}
cout << -1 << endl;
}

int main() {
memset(tu, -1, sizeof(tu));
cin >> m;
for (int i = 0; i < m; i++) {
int x, y, t;
cin >> x >> y >> t;
mark(x, y, t);
}
bfs();

return 0;
}

P1141 01迷宫

P1141 01迷宫 这道题的难度在于,我们需要 BFS 之后,把结果全部保存下来,之后每次查询的时候把答案直接输出就可以了。

参考代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
/**
* 此题 m 的量很大,所以要提前算出答案。
*/
#include <bits/stdc++.h>
using namespace std;

int n, m;
char tu[1100][1100];
int flag[1100][1100];
vector<int> ans;
int movex[]={1,-1,0,0};
int movey[]={0,0,-1,1};
bool debug=true;

char convert(char ch) {
if (ch == '0') return '1';
else return '0';
}

int mark(int x, int y, int v) {
int cnt = 0;
queue<pair<int,int> > q;
q.push(make_pair(x, y));
cnt++;
flag[x][y] = v;
while (!q.empty()) {
pair<int, int> a = q.front(); q.pop();
x = a.first;
y = a.second;
char ch = convert(tu[x][y]);
for (int i = 0; i < 4; ++i) {
int tox = x + movex[i];
int toy = y + movey[i];
if (tox >=0 && toy >=0 && tox <n && toy<n
&&tu[tox][toy]==ch
&&flag[tox][toy]==-1) {
q.push(make_pair(tox, toy));
cnt++;
flag[tox][toy] = v;
}
}
}
return cnt;
}

void process() {
for (int i = 0; i < n; ++i) {
for (int j = 0; j < n; ++j){
flag[i][j] = -1;
}
}
int idx = 0;
for (int i = 0; i < n; ++i) {
for (int j = 0; j < n; ++j) {
if (flag[i][j] == -1) {
// 标记 idx
int cnt = mark(i, j, idx);
// 把标为 idx 的个数放到 ans 数组中
ans.push_back(cnt);
idx++;
}
}
}
}

int main() {
scanf("%d%d", &n, &m);
for (int i = 0; i < n; ++i) {
scanf("%s", tu[i]);
}
process();
for (int i = 0; i < m; ++i) {
int x, y;
scanf("%d%d", &x, &y);
int idx = flag[x-1][y-1];
printf("%d\n", ans[idx]);
}
return 0;
}

P1746 离开中山路

P1746 离开中山路参考代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
/**
* P1746 离开中山路
*/
#include <bits/stdc++.h>
using namespace std;

int n;
char tu[1100][1100]={0};
char flag[1100][1100]={0};
int movex[]={-1,1,0,0};
int movey[]={0,0,-1,1};
int fx, fy, tx, ty;

int bfs(int x, int y, int step) {
queue<int> q;
q.push(x); q.push(y); q.push(step);
flag[x][y] = 1;
while (!q.empty()) {
x = q.front(); q.pop();
y = q.front(); q.pop();
step = q.front(); q.pop();
if (x == tx-1 && y == ty-1) return step;
for (int i = 0; i < 4; ++i) {
int tox = x+movex[i];
int toy = y+movey[i];
if (tox >= 0 && tox <n &&
toy >= 0 && toy <n &&
tu[tox][toy]=='0' &&
flag[tox][toy]==0) {
flag[tox][toy] = 1;
q.push(tox); q.push(toy); q.push(step+1);
}
}
}
return -1;
}

int main() {
scanf("%d", &n);
for (int i = 0; i < n; ++i) {
scanf("%s", tu[i]);
}
scanf("%d%d%d%d", &fx, &fy, &tx, &ty);
int ans = bfs(fx-1, fy-1, 0);
printf("%d\n", ans);
return 0;
}

P2802 回家

P2802 回家一题的解题技巧是:将 flag 数组用于保存走上去时的最大血量。如果走上去最大血量可以更高,也是可以再次走的。

另外,当只剩 1 格血时,下一步不管走到哪儿都是死,所以就不用扩展了。

参考代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
/**
* P2802 回家
*/
#include <bits/stdc++.h>
using namespace std;

int n,m;
int tu[15][15];
char flag[15][15]={0};
int sx, sy, tx, ty;
int movex[]={-1,1,0,0};
int movey[]={0,0,-1,1};

struct Node {
int x, y, s, t;
Node(int _x, int _y, int _s, int _t) {
x = _x; y=_y; s=_s; t=_t;
}
};

int bfs(int x, int y) {
queue<Node> q;
q.push(Node(x, y, 0, 6));
flag[x][y] = 6;
while (!q.empty()) {
Node node = q.front(); q.pop();
if (node.x == tx && node.y == ty) {
return node.s;
}
// 如果没到终点,只剩 1 点血,怎么都死
if (node.t == 1) continue;
for (int i = 0; i < 4; ++i) {
int tox = node.x + movex[i];
int toy = node.y + movey[i];
if (tox >= 0 && tox < n &&
toy >= 0 && toy < m &&
tu[tox][toy] != 0 &&
flag[tox][toy] < node.t - 1) {
flag[tox][toy] = node.t -1;
int life = node.t - 1;
if (tu[tox][toy] == 4) {
life = 6;
}
q.push(Node(tox, toy, node.s+1, life));
}
}
}
return -1;
}

int main() {
scanf("%d%d", &n, &m);
for (int i = 0; i < n; ++i) {
for (int j = 0; j < m; ++j) {
scanf("%d", &tu[i][j]);
if (tu[i][j] == 2) { sx = i; sy = j; }
if (tu[i][j] == 3) { tx = i; ty = j; }
}
}
int ans = bfs(sx, sy);
printf("%d\n", ans);

return 0;
}

颠覆技术的发展 - 读《浪潮将至》

最近看了 DeepMind 联合创始人和微软人工智能 CEO 苏莱曼的 《浪潮将至》。

该书主要介绍了未来极大可能改变世界的三个技术领域,分别是人工智能、合成生物学、量子技术。

以下是一些读书感悟。

颠覆技术的对抗非对称性

对抗非对称性指:拥有颠覆技术的一方可以用极小的力量对抗过去不可能对抗的力量。

这可以类比为在冷兵器时代拥有机关枪的一个人就可以对抗一整个敌人军队。

核武器的对抗也具备非对称性。拥有核武器的一方对非核国家也具备碾压性的优势。当然,后面全球努力在限制这种能力,以免被恐怖组织拥有带来全球的灭顶之灾。

人工智能的非对称性体现在对很多方面:拥有超级人工智能的组织的生产力可以是千倍于传统生产力。

书中列举了 DeepMind 公司在预测蛋白质结构上的突破,在这个技术出现之前,人类的蛋白质结构数据库中只有大概 20 万个蛋白质结构。DeepMind 公司一次性上传了 2 亿个新的蛋白质结构,几乎覆盖了所有已知的蛋白质。2 亿 vs 20 万,就是一个 1000 倍的对抗优势。

马斯克的擎天柱人形机器人如果成功大规模量产,也可能将全球制造业格局重塑。现在制造业主要还是集中于人力成本低廉的国家(例如中国,东南亚,墨西哥),到时候不需要吃饭和休息的机器的成本可能是人类的 百分之一。

现在看起来,人工智能似乎可以改变所有行业,唯一不可能替代的是人类亲自服务和沟通带来的某些情绪价值。

颠覆技术的普及性

不同于核武器技术,这些颠覆性技术的获取难度非常低。现在非常多的大模型技术公司的代码和模型都是开源的,普通人可以方便地从网上获取到相关资源。GitHub 平台上已经有 1.9 亿个代码库,其中大部分都是开源的。

现在全球顶尖的研究成果论文也可以从网上免费下载。特别是预印本网站,它加速了全球获取论文的方便程度。arXiv 上已经收录了超过 200 万篇论文。

对于生物技术来说,可打印定制 DNA 链的 DNA 合成器的购买只需要几万美元,而且该机器小巧便捷。下图是我在微信公众号搜到的一款 DNA 合成器,重量为 60 公斤,尺寸为 1/8 立方米,比一个家用洗衣机还小。

作者打了一个比方:一个邪恶的恐怖组织只需要在网上下单,就可以拥有制造出新型病原体的能力。这些病原体可以被设计成规避人类的已知对策,以无症状的方式传播,具备对抗治疗的能力。

所以,未来一个人很可能“具备杀死 10 亿人的能力”,所需的仅仅是一个动机。

“绝命毒师”如果出现在那个时代,会有这样的动机吗?

颠覆技术的监管难度

颠覆技术不像原子弹那样明显让人意识到危险,所以大众还没有对监管产生紧迫感。

作者曾经在谷歌成立了一个人工智能伦理委员会。但是最终因为委员会里面有几个人曾经发表过反对跨性别的言论,于是大家的争论变成了要求这几个人从委员会辞职。

政治正确比人工智能的伦理更重要,于是这个委员会就解散了。

顺便说一下,几年前我听说谷歌所有项目成立的时候,都需要考虑这个项目组成员有没有黑人,有没有女人,是不是政治正确的。

颠覆技术的监管连政治正确都克服不了,更别说国际社会之间的各种利益鸿沟了。

未来畅想:如何减小影响

颠覆技术在未来如果把工作替代了,会产生怎样的动荡?200 年前的工业革命可以给我们一些参考。

1807 年,由于工资被消减,6000 名英国织布工人发起抗议示威。1811 年,破坏者袭击了当地的工厂,摧毁了 63 台纺织机。

颠覆技术如果让大量普通民众失业,很显然是非常危险的。我们应该在推进技术进步的同时,考虑到对现有工人的就业影响,以尽量温和的方式来推进变革。

我曾经想过如何减小自动驾驶技术对滴滴司机的就业影响。我的方法如下:

  • 同价。通过税收调节,保证自动驾驶车和有人驾驶车同价。自动驾驶车收重税,税收用于安置被影响的司机。
  • 控量。通过颁发牌照,慢慢减少有人驾驶车。
  • 转移岗位。把司机转岗培训成无人驾驶车的看护员,做一些清洁保障保养等工作。

通过以上办法,慢慢把滴滴司机都安置好了,再减少税收,让自动驾驶慢慢赢得市场。

以上假想只是针对自动驾驶技术,但如果颠覆技术一次性颠覆了大部分行业,其应对方案会变得更难。

小结

《浪潮将至》介绍了颠覆技术(人工智能、合成生物学、量子技术)的对抗非对称性,知识普及性,和监管的难度。

未来如何发展,我们拭目以待。

以上。

❌