身份证号码校验算法详解:从Identity Card题到完整实现
在某个在线题库里看到“Identity Card”这道题时不少人的第一反应都是一样的这不就是考字符串截取吗身份证号读进来生日截一段性别截一位输出的时候注意一下格式完事。但真正按编号 “acm2629” 去本地写题、提交之后才发现 Wrong Answer 一个接一个完全没有想象中那么轻松。身份证号码背后有一套校验码规则题目希望你做的也不是简单解析而是先理解这套规则再用代码把它完整地表达出来。这篇文章就把这道题的完整解法、实现代码以及我提交过程中反复踩过的边界坑一次讲清楚适合刚入门算法练习、想搞懂身份证号码校验原理的读者。1. 题面一句话坑却在代码之外1.1 拿到题目先别急着写很多版面的“Identity Card”题面都很短每行给一个身份证号码要求判断它是否合法如果合法就输出对应的出生日期和性别不合法就输出“Invalid”之类的提示。看起来逻辑非常直白但“合法”两个字才是整道题的核心。这里先说一个容易忽略的点一个身份证号码的合法性不只是“长度是18位”这么简单。它至少包含三层含义出生日期部分必须是真实存在的日期第17位作为性别标志必须有意义最后一位校验位必须和前面17位计算出来的结果一致。有些版本的题目还会省略校验位这一步只要求解析生日和性别但真正训练用的题目通常会把“判断是否合法”写进去。所以拿到题面之后我建议你先别打开编辑器而是先在草稿纸上把“什么情况算合法”一条条写下来再对照题面确认它要求你输出什么。这个过程看起来浪费时间实际上能省下后面好几天调试的功夫。1.2 由输出反推输入处理读题的时候要养成一个习惯从输出格式反推输入处理。比如题目如果要求“He/She was born on YYYY-MM-DD”那就意味着你不光要解析出生日期还要根据第17位决定输出“He”还是“She”如果题目只要求输出“Valid/Invalid”那校验逻辑就必须放在第一位输出反而简单。我在某次练题时就是因为没注意大小写把“Male”写成了“male”结果一个测试点都没过。这类输出格式问题在OJ上非常常见它不是技术难点却能把人折磨得够呛。稳妥起见凡是题面里给了明确输出样例的最好把样例原样抄到本地作为肉眼对照的依据。2. 居民身份证号码编码18位和15位究竟怎么拆2.1 18位的位段含义这道题的知识基础是身份证号码的编码结构。现版18位号码从左到右由四段组成6位地址码8位出生日期码3位顺序码1位校验码。具体到每一位第7到第14位是出生日期第15、16位是当地派出所分配的顺序码第17位是性别码奇数分配给男性偶数分配给女性第18位是校验码。这里最容易踩的坑是“第17位是性别”而不是第18位有些初学者会误以为最后一位的奇偶决定性别实际上最后一位是校验码有可能是数字也有可能是大写字母X。15位的旧版号码结构稍有不同第7到第12位是出生日期两位年份15位是性别码没有校验位。两种格式在很多OJ题里都可能出现稍后再细说兼容处理。位数18位号码含义举例构造测试数据第1-6位地址码表示户籍所在区划110101第7-14位出生日期格式YYYYMMDD19900307第15-17位顺序码第17位奇偶定性别331第17位为1第18位校验码可能是数字或大写X92.2 校验位算法才是真正考点校验位的计算规则是这样的把前17位数字分别乘以固定的加权因子然后求和再对11取模最后根据余数查表得到校验码。加权因子从左到右分别是7、9、10、5、8、4、2、1、6、3、7、9、10、5、8、4、2。这组数字不是随手定的它来自身份证编码规范中的ISO 7064标准。对11取模的结果是0到10共11种对应的校验码映射表如下。余数012345678910校验码10X98765432如果题目要求你判断一个18位号码是否合法那么核心就是把前17位加权求和、取模、查表再和给出的第18位比较。不相等就是非法号码。2.3 一组测试号码的完整计算为了验证自己写的算法对不对我一般会手算一组测试数据。这里用构造测试号码110101199003073319来演示其中第17位是1对应男性最后一位9是校验码。前17位是11010119900307331逐位加权求和的过程如下1×7 1×9 0×10 1×5 0×8 1×4 1×2 9×1 9×6 0×3 0×7 3×9 0×10 7×5 3×8 3×4 1×2计算结果为190190除以11等于17余3查校验码映射表余数3对应校验码9与号码第18位一致说明这组号码在结构上是自洽的。手算一次的好处是你写完代码后可以拿同样一组数据去跑如果输出和手算结果一致才说明核心算法部分没问题。3. 解题方案设计先定边界再写代码3.1 什么输入必须判为不合法在设计代码之前我先确定好非法输入清单这是整道题最容易遗漏的部分。长度不是15位也不是18位直接判非法。长度够的情况下如果前17位里出现非数字字符判非法。第18位允许数字也允许大写X但如果是其他字母或小写x需要先判断题目到底接受哪种。很多OJ实际用的小写x是能过的但为了稳妥我通常在读入后统一转成大写再处理。生日部分必须是一个真实存在的日期月份在1到12之间日期不能超过当月最大天数2月份还要单独考虑闰年。最后再校验第18位校验码是否匹配。这些条件全部满足才输出生日和性别。我踩过最疼的一个坑就是只校验了月份和日期没有注意1990年2月29日这个不存在的日期结果本地测试怎么都过提交之后才有测试点亮红灯。3.2 输出格式与性别判定性别判断很简单取第17位数字对2取余余1是男性余0是女性。这里关键在格式化输出年份要补足四位月和日要补足两位比如1990年3月7日要输出成1990-03-07不能输出成1990-3-7。至于输出“Male/Female”还是“M/F”完全取决于题面我下面的代码里用的是“Male/Female”但你写的时候一定要先看自己那道题的要求。这种细节问题在OJ上属于“错一个词就是零分”的类型宁可多看一眼样例也不要急着把代码丢上去。3.3 15位旧号码怎么兼容如果题面明确只处理18位号码那就不用管15位省点脑细胞。但如果题目没写或者从样例里能看到15位的输入就需要做一套兼容逻辑。15位转18位的过程很简单前6位不变第7位和第8位插入“19”把出生年份从两位补成四位然后重新计算校验位补到末尾变成18位再走统一流程。要注意的是15位号码的最后一位本身就是性别码所以升位后它仍然是第17位性别判断逻辑不变。这一步的常见错误是只补了年份忘了把最后一位性别码放到第17位的位置上导致性别判断读错位置。4. 可运行实现C语言版本与Python版本4.1 C语言实现C语言版本适合在OJ上提交逻辑直白运行速度快。我写的是每行读入一个身份证号码、读到文件结束为止的版本完整代码如下。#include stdio.h #include string.h const int W[17] {7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2}; const char CHECK[11] 10X98765432; int is_leap(int y) { return (y % 4 0 y % 100 ! 0) || (y % 400 0); } int valid_date(int y, int m, int d) { if (m 1 || m 12) return 0; int days[12] {31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31}; if (m 2 is_leap(y)) days[1] 29; return d 1 d days[m - 1]; } int main(void) { char id[25]; while (scanf(%s, id) ! EOF) { int len strlen(id); char id18[25]; if (len 15) { int i, j 0; for (i 0; i 6; i) id18[j] id[i]; id18[j] 1; id18[j] 9; for (i 6; i 15; i) id18[j] id[i]; id18[j] \0; len 18; } else if (len 18) { strcpy(id18, id); } else { printf(Invalid\n); continue; } int invalid 0; for (int i 0; i 18; i) { if (i 17 id18[i] X) continue; if (id18[i] 0 || id18[i] 9) { invalid 1; break; } } if (invalid) { printf(Invalid\n); continue; } if (len 15) { int sum 0; for (int i 0; i 17; i) { sum (id18[i] - 0) * W[i]; } id18[17] CHECK[sum % 11]; id18[18] \0; } int sum 0; for (int i 0; i 17; i) { sum (id18[i] - 0) * W[i]; } if (CHECK[sum % 11] ! id18[17]) { printf(Invalid\n); continue; } int y (id18[6] - 0) * 1000 (id18[7] - 0) * 100 (id18[8] - 0) * 10 (id18[9] - 0); int m (id18[10] - 0) * 10 (id18[11] - 0); int d (id18[12] - 0) * 10 (id18[13] - 0); if (!valid_date(y, m, d)) { printf(Invalid\n); continue; } int gender (id18[16] - 0) % 2; printf(%04d-%02d-%02d %s\n, y, m, d, gender ? Male : Female); } return 0; }代码有几个细节可以说明一下CHECK字符串的下标直接对应取模结果所以CHECK[sum % 11]拿到的是这一位应有的校验码字符15位转18位时先不生成立即校验而是统一走到后面的校验逻辑避免重复代码最后的性别判断直接对字符做数学运算因为第17位已经在前面的循环里确认是数字字符。4.2 Python版本Python版本更适合用来做本地验证、快速试错比如临时生成测试数据、批量跑一批号码。代码思路和C语言版本一致只是写起来更简短。import sys W [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2] CHECK 10X98765432 def leap(y): return (y % 4 0 and y % 100 ! 0) or (y % 400 0) def valid_date(y, m, d): if not 1 m 12: return False days [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31] if m 2 and leap(y): days[1] 29 return 1 d days[m - 1] def main(): for line in sys.stdin: s line.strip() if len(s) 15: s s[:6] 19 s[6:] s CHECK[sum((ord(c) - 48) * W[i] for i, c in enumerate(s[:17])) % 11] if len(s) ! 18: print(Invalid) continue ok True for i, c in enumerate(s): if i 17 and c X: continue if not (0 c 9): ok False break if not ok: print(Invalid) continue if CHECK[sum((ord(c) - 48) * W[i] for i, c in enumerate(s[:17])) % 11] ! s[17]: print(Invalid) continue y int(s[6:10]) m int(s[10:12]) d int(s[12:14]) if not valid_date(y, m, d): print(Invalid) continue gender Male if int(s[16]) % 2 1 else Female print(f{y:04d}-{m:02d}-{d:02d} {gender}) if __name__ __main__: main()Python里用enumerate把下标和字符一起拿出来既用来做字符检查也用来计算加权和代码看起来更紧凑。ord(c) - 48是把字符转成数字的写法也可以直接用int(c)两种都行。4.3 关于代码结构的几个细节写这类题目时我建议把“日期合法性判断”单独抽成一个函数不要直接写在主流程里。原因很简单后面如果要扩展其他题目比如判断一个日期字符串是否合法这个函数可以直接复用而且独立函数更容易针对闰年逻辑做单元测试。校验位的计算也是一样最好单独写。我自己习惯把“给定前17位返回校验码字符”抽成一个小函数这样不管输入是18位还是15位代码都只需要调用一次。如果你把校验逻辑和主流程混在一起一旦出bug排查起来会非常吃力。5. 提交之后我才想明白的五个边界问题5.1 2月29日闰年判断的经典陷阱我第一次写日期判断时直接写了一个固定数组认为2月就是28天结果遇到合法闰年号码全部误判。闰年的规则是能被4整除且不能被100整除或者能被400整除。这个规则看起来简单但很容易把“能被400整除”漏掉导致公元2000年这类闰年被误判成平年。实际写代码时我建议先在本地跑几个边界日期2000年2月29日应该合法1900年2月29日应该非法2001年2月29日应该非法。这几个用例能帮你快速验证闰年逻辑是否写对。5.2 X到底算不算字母第18位校验码可能是大写X这里X一共只可能出现这一种情况前17位绝不可能是X。所以检查非法字符时要单独放行第18位的X但前17位只要出现X就直接判非法。有些选手会直接用字符串比较把整个号码丢进正则去匹配结果把X当成普通字母放行了。更隐蔽的问题是小写x校验规则里的小写x其实也经常被接受但输出时往往要求保持原样或转成大写。稳妥做法是读入后统一toupper把x转成X再处理。5.3 前导零不能丢身份证号码前6位地址码完全可能包含0比如某些省份的区县码就是0开头生日部分也可能有0比如3月7日写成0307。如果用整数类型读入号码前导0会被直接吃掉导致长度判断错乱、日期解析错位。正确做法是始终用字符串读入不要转换成整数。这一点看起来像废话但我确实见过有人图省事把scanf(%d)用上去然后怎么调都调不对。5.4 长度多一少一的输入有些测试数据会在号码前后带空格用scanf(%s)或strip()之后一般没问题。但如果是按行读入、没做去空格处理一个18位号码末尾多一个换行符长度就变成19直接走进非法的分支。我遇到过更刁钻的输入是号码中间夹着空格或制表符这时候逐字符校验就会报非法。所以做字符检查时一旦碰到非数字且不是第18位X的字符就要立刻判定非法而不是简单比较字符串长度。5.5 输出格式里的空格和换行输出格式的细节有时候比算法本身更坑。算对了校验位却因为多打了一个空格被判错这种事在OJ上一点都不新鲜。我常用的办法是把题面给的输出样例保存下来自己写的程序输出后用文本对比工具和样例文件做逐字符比对。另外要注意题目要求的是“每行一个输出”还是“全部输出在一行”。用printf时换行符掉没掉直接决定你是否能过样例。说实话这类问题输给格式比输给算法更让人窝火。6. 从Identity Card到真实业务一道入门题能延伸多远6.1 真实业务中的身份证校验刷完这道题之后我最直观的感受是真实业务里的身份证校验比OJ题目复杂得多。OJ只要校验结构、日期、校验位但实际系统往往还需要校验地址码是否存在、年龄是否合理、号码是否在某个可信名单里甚至要做脱敏和加密存储。比如在用户注册场景中前端会先做格式校验后端还要再做一遍防止有人绕过前端直接调接口。这时候你写的校验函数不仅要能跑还要能返回明确错误码是长度不对还是日期非法还是校验位不匹配每一种错误都要有对应提示。这比OJ里统一输出“Invalid”要细得多。从学习角度来说“Identity Card”是一道非常好的入门题因为它把字符串处理、查表、数学计算、边界判断揉在一起代码量不大但每个环节都考察基本功。你把它彻底吃透后再去做那些涉及数据校验的题思路会清晰很多。6.2 刷题技巧上的延伸最后聊点实在的刷题建议。这类题目拿到手我建议给自己定一个流程先手写一个合法样例和一个非法样例把预期输出写在纸上再写代码最后用三个不同场景的测试数据去验证。第一个场景是标准合法号码直接看输出第二个场景是校验位改错的号码看能不能正确拒绝第三个场景是边界日期比如2000年2月29日和1900年2月29日。只要这三个场景都通过这道题基本就稳了。我自己后来刷所有模拟类题目都沿用这套验证方法确实比闷头提交要靠谱得多。这道题让我体会最深的一点是OJ题从来不会白给的“简单题”。越是看起来简单的题面越可能埋着几个让你意想不到的边界条件。把“Identity Card”完整吃透你收获的不只是一个答案而是一套从读题、设计、编码到测试的完整思考习惯。