☰
门禁系统题解:从暴力扫描到计数数组的序列处理入门
2026/10/20 2:07:50 网站建设 项目流程

CCF201412-1 门禁系统,这道题在我接触CCF认证的那年纯属送分题,但它又特别值得拿出来讲。因为它是很多人的第一道“序列处理”题,也是所有CSP第一题的常见模板。题目本身很简单:给出n条门禁访问记录,每条记录有一个人员的编号,要求按顺序输出每个编号截至当前这一次是第几次出现。换个说法就是——给你一串数,边读边答“这个数到现在为止我已经见了几次”。整道题不涉及排序、不涉及查找最大值、不涉及任何图论动态规划,只要想通序列处理的思路,五分钟写完并且跑到满分是绰绰有余的。这篇文章写给刚开始刷CCF真题的人,也写给一些虽然能AC但说不清“为什么这么写”的人,我会从题目拆解、算法选型讲到逐行代码和考场避坑,希望能帮你把这类题彻底吃透。

1. 题目还原与考点定位:门禁系统到底考什么

1.1 题目场景还原

先把这个题的场景说透。某个场所装了一套门禁系统,系统会记录每一次进门的“人员编号”。现在给你一组访问记录,编号依次为a1, a2, ..., an,题目要求你对于每一次记录,输出这个编号在包括本次在内的所有已经出现过的记录里,出现了第几次。

举个例子,输入序列:

5 1 2 1 1 3

对应输出是:

1 1 2 3 1

怎么理解这个输出?第一次读到1,1之前没出现过,所以是第1次;第二次读到2,2之前没出现过,输出1;第三次又读到1,此时1已经出现过两次(第一次和第三次),所以输出2;第四次还是1,输出3;第五次读到3,3是第一次出现,输出1。

就是这么朴素的一道题。它不会问你任何一个编号总共有多少次,也不问你哪个编号出现次数最多,更不涉及权限验证和门禁开关的逻辑,纯粹就是统计“当前位置之前该编号的累计出现次数”。

1.2 “序列处理”不是什么高深东西

标题里挂了“序列处理”三个字,很多新手容易犯怵,觉得是不是涉及什么高端的序列算法。实际上,这类题的核心特征就是一个循环顺着数组从头扫到尾,每一步只依赖当前位置和历史信息,不需要预知后续内容,也不需要一次性把整个数组做排序或全局统计后再统一输出。

你回想一下平时开发的场景:后台日志里统计每个用户当天登录了几次,第几次登录时触发某种策略;或者一个匿名投票系统里,收到相同选项的第几次投票时切换显示方式。这些都是典型的序列处理。它们的共同点是“边读边处理”,当前结果只和“已经发生过的事情”有关,和未来无关。

正是因为这种特性,题目才被安排成计算机软件能力认证的第一题。它不考算法复杂度上的硬功夫,考的是你有没有建立起“用一个状态数组记录历史信息”的直觉。

1.3 100分标准:这道题没有中间档

CCF这类认证考试的前两题和普通OJ不太一样,它按测试点给分,但第一题的难度设计就是让你全拿的,不存在“暴力拿一半分、优化拿满分”的说法。你要是去网上搜历年真题回忆,会发现第一题大家讨论最多的是“为什么我本地跑得好好的,提交却是0分”,几乎没人讨论“怎么做才能拿满分”。

所以追求100分对这个题来说不是上限,而是及格线。心态上要把它当成送分题,但不能因此就麻痹大意。你需要在一分钟内看懂题意,三分钟内确定思路,五分钟内写出代码,再用两分钟自测边界。后面的题才是真正的分水岭,前面这道题你越快越稳,后面能分出的时间就越多。

2. 思路对比与算法选型:暴力遍历和计数数组的分水岭

2.1 方案一:每次从头再数一遍

刚接触这类题的人,第一反应往往是“那我每读到一个数,就回头扫一遍已经读过的所有数,数一数这个数出现了几次”。这个思路顺着题目描述来,完全正确,而且在这个题目给定的约束下,也能AC。

写成代码大致是这样:

#include <cstdio> int a[1005]; int main() { int n; scanf("%d", &n); for (int i = 0; i < n; i++) { scanf("%d", &a[i]); int cnt = 0; for (int j = 0; j <= i; j++) { if (a[j] == a[i]) cnt++; } printf("%d ", cnt); } return 0; }

这种解法的时间复杂度是O(n²)。当n最大是1000时,最坏情况下要做大约50万次判断,现代机器上跑起来几乎没有任何压力,完全能在一秒内完成。所以从纯“拿分”角度看,暴力解法已经够了。

但如果继续用这个思路,有一个隐患:你的算法能力会被题目数据规模局限住。今天门禁系统n是1000,明天如果换成某个统计系统,n是10万、100万,O(n²)直接原地爆炸。所以我还是建议,哪怕第一题用暴力能过,也要养成写“聪明方案”的习惯。

2.2 方案二:开一个计数数组

更好的做法是用一个数组记录“每个编号到目前为止出现了几次”。

思路分三步:

  1. 确定编号的范围。门禁系统的规则是编号从1到某个最大值,常见版本里编号不超过100。所以可以开一个长度为101或105的数组。
  2. 每次读入一个编号x,把count[x]加1。
  3. 输出count[x]的值。

代码核心就这几行:

#include <cstdio> int cnt[105]; int main() { int n, x; scanf("%d", &n); for (int i = 0; i < n; i++) { scanf("%d", &x); cnt[x]++; printf("%d ", cnt[x]); } return 0; }

没有嵌套循环,没有第二次扫描,一次读入一次输出,时间复杂度是O(n),空间复杂度是O(M),其中M是编号的最大值。这里M很小,因为题目明确说了编号范围,数组开个100左右就够了。

2.3 复杂度对比,结论一目了然

我把两种方案放在一起对比一下:

方案时间复杂度空间复杂度代码量适用场景
暴力扫描O(n²)O(n)较少数据规模小,仅追求AC
计数数组O(n)O(M)更少数据规模大,通用性强

这个对比里最直观的差距是什么?是思维习惯。暴力方案在每次处理时都把自己当成一个“失忆的人”,不利用已经统计过的结果;计数数组方案则相当于手上拿了一本台账,每看到一个编号就在对应页码画一道杠,然后直接报出这个编号累计杠数。

生活里到处都是这种例子。你参加一个运动会,进场时工作人员按号码牌发序号,第一次来的运动员给一个“1”的贴纸,第二次再来就发“2”,这其实就是计数数组。工作人员不需要把之前所有到场人员再排一遍队去数,只需要在每个人名下的计数器上加一。

所以,虽然两种方案在这个题目里都能满分,但从序列处理能力的培养角度来看,计数数组才是这道题真正想教你的东西。

3. 完整代码与逐行拆解:序列处理的细节都藏在边界里

3.1 先给代码:符合考场习惯的核心写法

这道题考场上最推荐的写法是纯C风格配合stdio,不要为了图省事用cin和cout。不是说cin不行,而是在某些版本编译器未开启同步关闭时会慢,第一题数据量小无所谓,但养成从输入输出层面追求高效的习惯没有坏处。

完整代码如下:

#include <cstdio> int cnt[101]; int main() { int n, x; scanf("%d", &n); for (int i = 0; i < n; i++) { scanf("%d", &x); cnt[x]++; printf("%d", cnt[x]); if (i != n - 1) { printf(" "); } } printf("\n"); return 0; }

为什么用全局数组?因为全局变量在程序启动时会被自动零初始化,也就是说cnt数组里的每个元素默认都是0。这一点对于“统计出现次数”来说太关键了,如果没有这个初始为0的前提,第一次读入x时cnt[x]++的结果就不确定。

如果你习惯把数组定义在main函数内部,那一定要记得显式初始化,比如:

int cnt[101] = {0};

局部数组不一定会自动清零,它拿到的是一段栈上内存,里面是什么完全取决于之前调用栈留下了什么,这个坑我在早期刷题时踩过不止一次,输出结果一会儿对一会儿错,排查了半天才发现是初始化问题。

3.2 数组开多少:100和101的区别不是小事

题目里编号上限如果写的是100,很多新手会写成int cnt[100],然后陷入一种诡异的低概率错误中:当特殊测试点出现编号100时,cnt[100]实际上越界了,因为C/C++数组下标从0开始,cnt[100]合法的情况只出现在数组长度至少是101时。

所以稳妥的写法是cnt[101]或cnt[105],多开5个、10个位置是竞赛选手的常见习惯,目的就是防一手边界越界。比如编号范围是1到1000,可以开cnt[1005],多出来的几个位置不参与逻辑,只作为安全余量。这不是技术洁癖,而是实实在在防止“数组越界导致神秘错误”的最简单手段。

顺便还要想清楚:编号是从1开始,而数组长度为101时,下标0到100都可用。题目如果存在编号0怎么办?也很简单,cnt[0]照常工作,代码逻辑不受影响。所以把下标0留成“空向”或干脆让它闲置,没有任何问题。

3.3 输出格式:空格和换行的细节决定评测结果

很多基础扎实的选手在代码逻辑上没问题,却因为输出格式丢分。CCF评测系统和很多OJ一样,对输出格式有严格要求。这个题要求数字之间用空格分隔,末尾不能有多余空格是最好看的写法,但实际上评测系统对行尾空格通常比较宽容。

不过宽容不等于你该留尾巴。上面给的代码里我用了一个条件判断:

if (i != n - 1) { printf(" "); }

意思是我在非最后一个元素后面打空格,最后一个元素不追加空格,循环结束后统一换行。

你如果没有这个判断,直接写成:

printf("%d ", cnt[x]);

在CCF平台上大概率也能过,因为评测只看数字序列是否匹配,忽略行末空白字符。但一旦你养成了“每次都打空格”的习惯,后续做那些对输出格式要求更死板的题目时,就容易因为Presentation Error被罚时。所以,第一题就养成好习惯,后面少流很多汗。

还有种写法是先把输出存到数组里最后统一打印,但对于这道题来说完全没必要,边读边打印就能拿到结果,序列处理的优势就在这里。

4. 考场避坑清单:这些失误会让你的100分缩水成60分

4.1 高频失误速查表

第一题的代码量这么少,还能丢分,基本都栽在这些细节上。我按踩坑率排了个序:

错误类型典型表现后果解决办法
数组长度不足编号100时访问cnt[100]越界神秘运行错误或答案错误多开5个以上位置
局部数组未初始化内部声明int cnt[101];忘给零结果随机,时对时错用={0}初始化或改全局
输出格式多空格每轮都输出空格,末尾多余不少平台报Presentation Error用非末尾判断控制空格
scanf格式错漏了变量取地址符&输入没读进去,输出异常检查scanf("%d", &x)
读题看错输入格式以为是多组输入,实际只有一组程序卡在读第二组数据上看清n的位置和测试组数
把编号当人名建模试图记录“人”而不是“编号”代码复杂度上升,逻辑绕远抓住本质:统计编号出现次数
忘记换行无printf("\n")部分平台判错养成输出后补换行习惯

这里面我自己曾经栽过最惨的一次是数组越界。当时写的是稳定排序类的题,因为数组开小,某个边界数据让程序直接崩了,评分系统给的是0分而不是60分。从那以后我所有题目的数组一律比上限多开5到10个,再没犯过类似的错。

4.2 三个必测边界用例

拿到一段写好的代码,别急着提交,自测三个用例,基本能帮你过滤掉80%的低级错误。

第一个用例是单元素:

1 42

期望输出:

1

这个用例主要验证最小规模下程序能正常跑通,也顺便检查换行符有没有打。

第二个用例是官方样例:

5 1 2 1 1 3

期望输出:

1 1 2 3 1

这个用例验证核心逻辑是否正确,特别要自己想明白第三个输出为什么是2而不是1。

第三个用例是编号重复出现到最大边界:

10 100 100 100 100 100 100 100 100 100 100

期望输出:

1 2 3 4 5 6 7 8 9 10

如果代码里数组只开到cnt[100],这个用例就会直接爆出数组越界;数组开到101以后,这个用例就是纯粹的“每次+1”验证。还有一个类似的边界用例是把编号5重复输入n次,道理是一样的。

4.3 考场上的完整验证流程

我自己的做题流程是固定的一套,拿这个题举例就是:

  1. 读题:确认是单组输入,第一行是n,第二行是n个整数,编号范围在1到100。
  2. 设计:确定计数数组长度101,循环读入,cnt[x]++,输出cnt[x]。
  3. 编译:先编译一遍,确保没有语法错误。
  4. 跑样例:把题目给的样例输入贴进去,对比输出。
  5. 跑边界:用上面说的三个自测用例逐一遍历。
  6. 检查输出:重点看每行末尾有没有多余空格、最后有没有换行。
  7. 提交:全部无误再交。

这套流程看起来繁琐,但真正执行下来也就三分钟。它最大的作用是给你建立信心:提交之前你已经知道这个代码不可能错了,提交之后看到100分只是走个流程。

5. 拓展变化与能力提升:序列处理还能怎么考

5.1 变式一:改成输出出现次数最多的编号

原题是“每次报当前出现次数”,你可以变着玩一下:如果题目要求输出到目前为止出现次数最多的编号以及出现次数,那你要怎么改?

这时候只靠一个cnt数组就不够了,因为每轮你都需要知道当前的最大值是谁。你可以在循环里同步维护两个变量:maxCnt记录历史最大次数,maxId记录达到这个次数的编号。每次cnt[x]更新后,如果cnt[x]大于maxCnt,就把maxCnt和maxId更新为x和cnt[x]。如果题目要求次数相同时输出编号较小的,那么更新条件要加一个等号时的编号比较判断。

这种“边处理边维护一个统计量”的模型,在真实业务里特别常见。比如社群的当日签到榜、接口调用次数的热点追踪,都是同一个套路。理解了维护maxCnt的基本盘,碰到类似需求你不需要重新想,直接套这个模式就好。

5.2 变式二:编号范围大到数组开不下怎么办

原题的编号范围很小,所以数组计数是最优解。可如果编号范围变成10的9次方,你再开数组就是开玩笑。这时候有两条路线:

第一条是哈希表。用C++的std::map或者std::unordered_map来当cnt用,key是编号,value是出现次数,逻辑和原题一摸一样,只是把数组换成了哈希表,时空复杂度也依然理想。这个在刷题里很常见,面试里考代码能力时也容易被问到。

第二条是离散化。如果编号个数有限但数值很大,可以先收集所有出现过的编号,排序后映射到1到m的连续区间,然后再用普通的计数数组。这种做法适合对性能极度敏感、连哈希表都不太常用的场景,在部分嵌入式软件开发里反而比哈希表更贴近底层思维。

我建议备考CCF的人至少把哈希表的解法自己写一遍,不是为了这道题,而是为了后续遇到第二、三题里的相似需求时不慌。毕竟第一题的难度是让你入门的,不是让你止步于此。

5.3 训练建议:如何用这类小题锻炼真正的功底

最后给一点训练建议。刷这类“序列处理”小题的时候,不要满足于AC,那只是最低标准。我自己的习惯是,AC之后再强制自己写出第二版、第三版:第一版用最朴素的思路,第二版用最优的数据结构,第三版尝试换一个角度去建模同一个问题。比如这个门禁系统题,你甚至可以试试不用数组,而用一个链表来维护各自编号的次数,虽然在性能上没必要,但这个思考过程能让你在“数据组织”这件事上积累更多直觉。

这种小题一题多解的价值,不在于代码本身,而在于你面对新问题时能更快地匹配出合适的解法。你真到考场上,留给第一题的建模时间只有几分钟,平时练得越熟,考场上的肌肉记忆就越可靠。

CCF第一题的门禁系统,数据是流水账一样的序列,操作是“读一个记一个”,数组空间换时间,边界纯净无陷阱,语言上怎么折腾都不容易错。但我始终觉得,这道题真正的考点不是会不会写代码,而是你有没有形成“按序处理、状态累积”的心智模型。我后来在项目里做实时指标统计、处理日志事件流的时候,无数次想到这个只有十来行的代码。所以把这个小题吃透,对你来说绝对不止是一道真题的意义。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询