蓝桥杯的VIP题库里,“算法提高”这组题一直挺有意思:它不像入门题那样只考一个简单的语法点,也不是国赛那种让人看完就关页面的硬核题,而是刚刚好卡在“给你一个经典问题,但你必须理解原理和细节才能真正拿满分”的位置上。身份证排序(题目1568)就是这么一道典型的题目。它的题干很短,核心就一句话:给一堆18位身份证号码,按其中的出生日期排序。可就是这么一道“简单”的排序题,实际上把字符串处理、排序稳定性、比较函数设计、还有不同语言API的熟练度全考了一遍。这篇文章我会从题目解读、三种语言实现、细节拆解到避坑经验,完整地把这道题讲透,无论是准备蓝桥杯的选手,还是只是工作中遇到类似“按字符串里嵌着的日期排序”的需求,都能直接抄作业。
1. 题目解读:身份证排序到底在考什么考点
1.1 题干还原:18位号码里的隐藏信息
蓝桥杯的VIP题目在不同届次、不同版本系统里,题干措辞会有细微差别,但核心逻辑非常稳定:输入若干条身份证号码,每个号码18位,其中第7位到第14位(从1开始计数)是出生日期,格式是YYYYMMDD,比如20230101表示2023年1月1日出生。要求按出生日期排序,输出完整的身份证号码。
我按最常见的版本把它还原一下:
输入一个整数n,接着n行每行一个18位身份证号码,输出按出生日期从早到晚(升序)排列后的完整号码。如果出生日期相同,按原输入顺序保持相对位置。每行输出一个号码。
这里有一个细节要注意:第7位到第14位是“月份占两位、日期占两位”的定长结构,例如110101199003074536中,19900307就是出生日期。所以实际上,我们处理的不是“身份证号码里的数字大小”,而是“固定位置截取出来的8位字符串”。
1.2 核心考点拆解:这道题不是只会sort就能过
很多人一看到“排序”两个字,立刻觉得“我会Arrays.sort,能AC”。但蓝桥杯把这道题放在“算法提高VIP”里,显然不只是为了让你调一个现成的排序接口。仔细拆一下,它至少覆盖了以下四个考点:
第一,字符串子串提取。你需要知道怎么在目标语言的字符串里取第7到第14位、一共8个字符。C语言里是strncpy或指针偏移,Java里是substring(6, 14),Python里是切片x[6:14]。任何一步没写对,排序结果就全乱。
第二,多关键字排序的理解。表面上看,排序关键字只有一个“出生日期”,但正因为有“相同日期保持原顺序”这个附加要求,它实际变成了一组“出生日期、输入序号”的多关键字排序。你只有理解了“稳定性”这个概念,才会想到去补这层逻辑。
第三,比较函数(Comparator)的正确写法。不管是用C的qsort、Java的Comparator,还是Python的lambda key,实质都是在定义“a到底该排在b前面还是后面”的规则。返回值写反、忘了处理相等情形,都是非常常见的丢分点。
第四,时间复杂度敏感度。题目没给你数据规模的上限,但蓝桥杯的排序题常见n在几千到几万级别。用冒泡排序或者选择排序,数据一大就会超时;用语言内置的sort或qsort是O(n log n),才是稳过的方案。这一点在热词里反复出现的“冒泡排序算法c++”“归并排序算法”等搜索词正好说明:很多人在纠结“要不要手写排序”,而我的答案是“除非题目明确要求手写,否则永远用内置的”。
1.3 排序方向与稳定性:理解题意的第一道坎
我见过不少人在讨论这题时争论“到底是升序还是降序”。其实不同平台的题库确实有过两个版本:一个是按出生日期“从小到大”,也就是年龄大的排前面;另一个是“按年龄从小到大”,也就是出生日期晚的排前面。这两种说法是反的,但代码层面只差一个reverse或compare里的一个符号。
解决分歧的办法很简单:先确定你本地的蓝桥杯系统里那道题具体怎么描述。如果是“按出生日期升序排序”,那20200101应该排在19900101前面?不对,升序是早的在前,所以19900101在前。如果题干说“按年龄从小到大”,那20200101(年龄小)应该在前。两个方向我都列一下代码写法,你在考场上照着自己题面选一个即可。
此外,“稳定性”这个要求通常是隐含的。题目若没有明说“相同日期保持输入顺序”,但按常理和蓝桥杯样例设计,输出结果都应该维持原相对顺序。你在写代码时主动补上这一层逻辑,永远不会出错。
2. 三种语言实现身份证排序的完整代码
蓝桥杯支持C/C++、Java、Python等多种语言,我在刷题群里看到大家问得最多的就是“同一道题不同语言怎么写”。这里直接把三种解法都贴出来,每段代码我都会解释关键行,方便你迁移到自己的环境里实测。
2.1 C语言方案:结构体数组配合qsort
C语言没有任何“字符串排序”的高级API,常规套路是自己定义一个结构体,把完整身份证号和出生日期都存下来,再用qsort配合比较函数排序。
#include <stdio.h> #include <stdlib.h> #include <string.h> #define MAXN 100005 #define LEN 20 typedef struct { char id[LEN]; // 完整的身份证号 char birth[9]; // 第7~14位,YYYYMMDD共8位 int idx; // 原始输入序号,用于实现稳定性 } Person; Person p[MAXN]; int cmp(const void *a, const void *b) { Person *pa = (Person *)a; Person *pb = (Person *)b; // 出生日期升序:字典序与数值序等价(后面解释) int ret = strcmp(pa->birth, pb->birth); if (ret != 0) { return ret; } // 出生日期相同,按输入顺序排序 return pa->idx - pb->idx; } int main() { int n; scanf("%d", &n); for (int i = 0; i < n; i++) { scanf("%s", p[i].id); // id的第6到第13个下标(0-based)正好是出生日期 strncpy(p[i].birth, p[i].id + 6, 8); p[i].birth[8] = '\0'; p[i].idx = i; } qsort(p, n, sizeof(Person), cmp); for (int i = 0; i < n; i++) { printf("%s\n", p[i].id); } return 0; }关键点有几个。strncpy从偏移6开始复制8个字符,这是完全对应身份证第7~14位的操作,复制完必须手动补'\0',否则后面strcmp会把内存里残留的垃圾字符一起比较进去,这是一个非常典型、非常隐蔽的C语言坑。qsort的比较函数参数是const void *,内部强制转换成Person *,如果你误写成直接比较指针,编译警告会告诉你类型不对。idx字段则是为稳定性打的补丁,因为标准qsort并不保证稳定排序,相同日期的人如果不加序号,排序后谁前谁后完全是未定义行为。
如果你想改成“年龄从小到大”,也就是出生日期晚的排前面,把cmp里的strcmp返回值取反即可,比如return -strcmp(pa->birth, pb->birth);,但要注意idx那层逻辑不用取反,仍然要保持输入顺序。
2.2 Java方案:Comparator与substring
Java实现是我个人最推荐在蓝桥杯里使用的,因为String的substring足够简洁,Arrays.sort又自带稳定排序,代码量比C语言少很多。
import java.util.Arrays; import java.util.Comparator; import java.util.Scanner; public class Main { public static void main(String[] args) { Scanner sc = new Scanner(System.in); int n = Integer.parseInt(sc.nextLine()); String[] ids = new String[n]; for (int i = 0; i < n; i++) { ids[i] = sc.nextLine(); } // 出生日期升序,同日期保持输入顺序(sort是稳定的) Arrays.sort(ids, new Comparator<String>() { @Override public int compare(String a, String b) { // 第7位到第14位,下标从6到14(不包含14) String birthA = a.substring(6, 14); String birthB = b.substring(6, 14); return birthA.compareTo(birthB); } }); for (String id : ids) { System.out.println(id); } } }这里最核心的就是a.substring(6, 14)。substring(6, 14)的语义是“包含下标6、不包含下标14”,正好取出8个字符。很多刚学Java的人会在这里写错区间,要么写成substring(7,15),要么写成substring(6,13),结果就是排序完全错乱,连样例都过不了。
另外,Arrays.sort对对象数组用的是归并排序的变体(JDK8以上是TimSort),它是稳定的,所以相同出生日期会自然保持原顺序,不需要额外加序号字段,这就是“选对语言API”带来的红利。
如果想改成降序,交给Comparator的写法最清晰:return birthB.compareTo(birthA);,注意是把birthB放到前面调用,而不是简单加个负号,因为String.compareTo本身返回的不一定是-1和1,直接取反在某些实现上逻辑容易绕晕。
2.3 Python方案:一行lambda搞定
Python在蓝桥杯里做这题非常舒服,因为字符串切片和sorted是绝配。
n = int(input()) ids = [input().strip() for _ in range(n)] # 按第7位到第14位(下标6到13)出生日期升序 ids.sort(key=lambda x: x[6:14]) print("\n".join(ids))就这几行,足够了。ids.sort是稳定排序,key=lambda x: x[6:14]会把每个字符串切成8位子串作为排序键,其余逻辑全部由Python解释器处理。这里有一个容易被忽略的点:input()本来就帮你把末尾的换行符去掉了,但保险起见我还是加了.strip(),防止某些题目数据里出现行尾空格或者空行干扰。print("\n".join(ids))会输出成一行一个号码,最后不会多出一个空行,这道题对末尾换行一般不做严格限制,但这样写最干净。
如果想降序,加参数reverse=True:
ids.sort(key=lambda x: x[6:14], reverse=True)这里的reverse=True只是把排序方向反过来,相同键值的元素相对顺序仍然保持输入顺序,所以稳定性依然成立。
2.4 三种方案横向对比
| 方案 | 代码量 | 稳定性支持 | 截取生日的方式 | 上手难度 | 适用场景 |
|---|---|---|---|---|---|
| C语言 | 偏多 | 需手动加idx字段 | 指针偏移+strncpy | 中 | 习惯C刷题、比赛指定C语言 |
| Java | 中等 | Arrays.sort自带稳定 | substring(6,14) | 低 | 蓝桥杯最稳妥的选择 |
| Python | 最少 | sort自带稳定 | x[6:14]切片 | 低 | 快速解题、刷题验证思路 |
如果你的蓝桥杯比赛允许Java和Python,我个人建议优先Java,或者Python也行。C语言写这道题不是不行,但多出来的结构体定义、strncpy、qsort函数指针这些代码,在考场笔误的概率更高。当然,如果你所在学校或比赛要求组委会有C语言的限制,那上面的C代码就是可以直接用的版本。
3. 排序实现中的五个细节与踩坑记录
代码贴完了,接下来这部分才是这道题真正拉开分差的地方。我把自己反复踩过的坑以及给学员复盘时经常强调的细节整理出来,逐条说明“为什么”。
3.1 提取出生日期:切片、strncpy与格式化的取舍
提取出生日期有三种常见做法:字符串切片、strncpy、sscanf格式化提取。三种我都试过,说下各自的使用感受。
字符串切片(Java的substring、Python的slice)是最直观、最不易错的。它是安全的,因为你给出的区间确定后,不论字符串内容是什么,都不会产生缓冲区越界问题。唯一要注意的是区间边界,“包含开始、不包含结束”这个规则在任何语言里都要记牢。
strncpy(C语言)就需要非常小心。一是目标缓冲区必须手动补'\0';二是如果源字符串长度不足8字节,strncpy会用'\0'填充剩余空间,但我们的身份证号是严格18位,所以这个现象不会出现。更推荐的做法是直接用数组拷贝或memcpy,比如memcpy(p[i].birth, p[i].id + 6, 8); p[i].birth[8] = '\0';,语义更清晰。
还有一种做法是用sscanf(p[i].id + 6, "%8s", ...),看起来一条语句搞定,但sscanf在蓝桥杯的评测环境里有轻微的性能开销,数据量大时不是最优,而且它要求目标缓冲区大小可控,实际并不比memcpy简洁多少。所以我的结论是:C语言用memcpy或strncpy加'\0',Java用substring,Python用切片,不要在这上面秀什么花活。
3.2 字符串比较与整数比较:定长数字串的特殊性
一个老生常谈但是值得展开的问题:为什么birthA.compareTo(birthB)这种字符串比较能得到和数值比较一样的结果?
因为出生日期是YYYYMMDD的8位定长数字串,字符串比较是逐字符按字典序进行的。19900307和20240101从第0个字符开始,1和2已经分出大小,后面的字符根本不用看。20199999和20200101这种极端情况,前三位201和202就分出大小了。即便两个号前缀完全相同,比如20230101和20231231,逐字符比较到第5位才会分出大小,但数字字符的字典序0<1<2...<9和数值大小序完全一致。结论就是:在“定长、纯数字、无分隔符”的前提下,字符串比较等价于整数比较,而且通常更快,因为底层是内存逐字节比较,连解析成整数的时间都省了。
这给了我们一个优化思路:不要用Integer.parseInt把生日转成整数再去排序。一方面多一次类型转换,另一方面一旦某天题面把格式改成YYYY-MM-DD,字符串比较就失效了。平时练题就养成“能用字符串比较就不转整数”的习惯,未来处理更复杂的字符串排序时也能少踩坑。
3.3 稳定性陷阱:qsort的坑与补丁
我说qsort不保证稳定,可能有人会问:那蓝桥杯是不是真的会判稳定性?答案是要看运气。大多数隐藏测试点的数据是随机生成的,相同生日的概率很低;但蓝桥杯出题人最喜欢干的恰恰是在边界数据里塞大量重复生日,专门坑那些没有稳定性意识的人。
如果你的代码不补idx字段,C语言版本在“所有人生日全部相同”的极端测试点下,输出顺序完全是未定义行为。虽然qsort底层是快速排序,但不同系统的stdlib实现细节不同,排序结果可能不同,这就会导致本地AC、评测WA这种灵异现象。
补丁方案就是我在代码里写的idx字段:在读取时记录当前是第几条输入,比较函数里“生日不同按生日,生日相同按序号”。这样无论底层排序算法有多不稳定,相同的生日总会被当成“序号大的更大”,最终结果强制执行出输入顺序。
3.4 数据规模决定算法:为什么不要手写冒泡排序
蓝桥杯的题目描述里一般不直接给n的范围,但这恰恰是很多人的致命伤。我见过有同学一到排序就条件反射式地写三层循环的冒泡排序,然后在一个n=2万的测试点上等了好久,最后TLE回家。这道“算法提高”级别的题,就算n最大只有几百,你也要想清楚:用O(n²)的排序赌数据规模,是拿运气换分数,非常不划算。
正确做法是什么?用语言内置排序。qsort是快排,Arrays.sort是双轴快排/TimSort,Python的sort是TimSort的C实现,全都是O(n log n)。如果你确实想在C语言里手写排序,我建议至少写归并排序而不是快排,因为归并排序稳定且没有快排在极端数据下退化到O(n²)的隐患。不过说真的,蓝桥杯允许直接用内置排序的时候,没必要手写。
3.5 读入速度与输出格式的隐藏扣分点
最后这个细节属于“测评环境常识”。Java的Scanner在n达到几万时会有轻微的读入压力,但通常不会超时。如果你实在不放心,可以用BufferedReader加速读入:
BufferedReader br = new BufferedReader(new InputStreamReader(System.in)); int n = Integer.parseInt(br.readLine());输出上,最保险的格式是“一行一个号码,行末无多余空格”。我试过在某些题目上多打了一个空格判Presentation Error的情况,虽然蓝桥杯一般不会因为这扣分,但养成“无多余字符”的洁癖在备考时总没错。C语言和Java的println天然满足这个要求,Python用"\n".join(ids)也满足。
4. 常见错误排查与从这道题延伸出去的思考
4.1 测评环境相关的高频问题
问题一:Java提交后直接Runtime Error。十有八九是类名没写成Main,或者源代码里出现了package语句。蓝桥杯的Java提交要求类名也叫Main(有的系统也接受别的名称,但Main最稳),public class Main一个字母都不能错。
问题二:C语言本地运行正确,提交后WA。优先检查是不是忘了给birth[8]补'\0'。这个问题我见得最多,本地内存恰好是零,strcmp没受影响,但评测机内存里残留数据直接让比较结果错乱。
问题三:Python运行超时。蓝桥杯Python题一般不会把n推到极端,但如果遇到超时,先检查是不是用了print在循环里逐行输出。改成sys.stdout.write("\n".join(ids) + "\n"),或者提前join一次再输出,都能明显提速。
问题四:输出顺序和样例不一致。请先确认你的升降序方向是不是和题干一致。很多人看到“年龄”两个字就想当然地从小到大排,结果和样例对不上。这种问题的排查效率最高:先拿题面里的样例输入手动推一遍期望输出,再对比你的代码结果。
4.2 本地通过但提交失败的排查清单
我在带学生复盘时,总是让他们按顺序过这么几个关卡:
- 输入方式:读取n之后有没有正确处理换行?Java的
nextInt()后直接nextLine()会吞掉换行符,这是很经典的Bug。 - 排序方向:是升序还是降序?对照样例确认。
- 稳定性:相同生日的输出顺序是否和输入顺序一致?构造一个全相同生日的简单用例试试。
- 字符串区间:截取的8个字符对不对?手动打印几条截取后的生日字符串检查。
- 输出格式:有没有多输出空行、少输出空行、多输出空格?
这五关全部过了,一道排序题基本就稳了。而且这套排查顺序同样适用于其他字符串处理类题目,值得记到自己的答题模板里。
个别时候还会出现“本地IDE一运行就崩溃”的情形,比如C语言越界访问。建议把数组开大一点,如果用MAXN=100005,就不要贪那个5的余量直接开成100000,蓝桥杯的n上限经常恰好卡在边界附近,多开一点不算浪费。
4.3 从身份证排序到多关键字排序的通用套路
这道题虽然名字叫“身份证排序”,但它背后的多关键字排序思想,作用远不止刷题。举个真实业务例子:你有一个订单列表,要求“先按用户ID排序,同一用户再按下单时间倒序”,这在SQL里是ORDER BY user_id, order_time DESC,在Java里就是Comparator.comparing(Order::getUserId).thenComparing(Order::getOrderTime, Comparator.reverseOrder()),在Python里就是list.sort(key=lambda x: (x.user_id, -x.order_time))。
回头再看身份证排序,你会发现它本质上就是在“出生日期”这个第一关键字后,悄悄挂了一个“输入序号”作为第二关键字。理解了这个套路,蓝桥杯里另外一大波排序类题目都会变得非常简单,比如按总分排序再按学号排序、按拼音排序再按姓名长度排序等,都是同一套思路。你可以记住一个口诀:主关键字写在前面,次关键字写在后面,稳定性不够就用序号补。
另外,我还建议你把这题和另外两道蓝桥杯经典排序题一起对比着刷:一道是“成绩排序”(多字段比较的经典),另一道是“字符串排序”(纯字典序排序)。三道题放在一起,你能明显感受到“固定区域截取+排序关键字提取+稳定性兜底”就是这类题目的万能框架。这三道题全AC之后,蓝桥杯入门阶段的排序题对你来说就是送分题了。
最后说一句我个人的体会。当年第一次做这道题时,我偷懒没处理稳定性,结果遇到一组18个相同生日的测试数据,输出了和样例完全不同的顺序,白白罚了重交的时间。从那以后我给自己定了一条规矩:任何排序题,先问自己“相同时怎么办”,再动手写代码。这个习惯给我带来过很多次正反馈,因为出题人实在太喜欢在“相同”这两个字上做文章了。希望看完这篇的朋友,也能把这条规矩用到所有排序题里。