☰
OJ“检查密码”题全解析:规则拆解与边界条件避坑
2026/10/20 19:08:12 网站建设 项目流程

“检查密码”这四个字,我在某高校OJ平台的实验九里见到时,第一反应是“这题不会太难吧”。结果你猜怎么着?同一批作业里它排在26题,看着就是给新手练字符串处理的,可身边不少同学交了七八次还在WA。后来我把题面规则、判题环境、代码实现从头到尾复盘了一遍,才发现这类题真正的考点根本不是“会不会写if”,而是规则拆得够不够细、边界想得够不够全、字符处理有没有踩到平台本身的坑。

这篇文章我尽量把东西写透:题面规则怎么还原、五个判定条件怎么拆开验证、判题系统里那些看不见的边界,以及C、Python、Java三版参考实现和一套提交前的测试清单。适合刚学完数组和字符串、准备第一次和别人在OJ上拼通过率的人读,也适合那些想从“能过题”进阶到“代码写干净”的同学参考。

1. 实验九最后一道题的题面还原与规则边界

1.1 原始题面很模糊,我先按最常用的版本补充规则

到手的原始信息其实只有一个标题:检查密码。真正做过这类题的人都知道,出题人通常不会好心把所有细节写在标题里,完整的规则都在题面正文中,而且每个学校每年的题面可能都不一样。为了把分析讲明白,我按自己在同类OJ题里见到概率最高的规则来还原:

  • 密码长度必须在 6 到 16 位之间,包含边界本身;
  • 密码必须同时包含大写字母、小写字母、数字;
  • 密码必须至少包含一个特殊字符,这里的“特殊字符”按可见标点符号处理;
  • 密码中不能出现连续三个相同的字符。

如果你拿到的题面规则跟我写的不完全一样,比如要求长度 8 到 20,或者只要满足四类字符中的三类,又或者不检查连续重复——不要慌,下面每一段拆开的判定单元是可以直接替换的。真正值钱的不是某一条规则,而是拆规则和验证规则的方法。

1.2 这类题真正隐藏的判定点不在“长度”,而在“字符”

很多第一次写的人会以为,检查密码就是if (strlen(s) >= 6 && strlen(s) <= 16),然后再数一下里面有没有数字。一旦你这么想,WA就跑不掉了。我看过最典型的错法是:只判断了长度和数字,忘了大写、小写、特殊字符各算一类;还有的人把“连续三个相同字符”理解成“相邻两个相同”,直接漏掉 1111 这种四连重复的用例。

还有一类隐蔽问题是字符范围。C语言的char可能是带符号的,直接拿s[i]去调用isupper、isdigit这类函数时,如果字符不在 ASCII 可见范围内,行为是未定义的。这一条在OJ上不一定会触发,但一旦测点里出现中文、扩展字符或者\xff这类值,你可能连“为什么本地好好的,提交就WA”都找不到原因。

老规矩,先别碰代码,把这道题的要求画成一张判定表:

判定条件合法密码必须满足不满足时的行为
长度6 <= len <= 16直接判为非法,不用继续看字符
大写字母至少一个直接判为非法
小写字母至少一个直接判为非法
数字至少一个直接判为非法
特殊字符至少一个直接判为非法
连续重复不能连续出现3次同字符直接判为非法

这张表看起来平平无奇,但把它写下来之后,你的代码结构就变成“先长度、后字符、再重复”的三段式。后面不管规则怎么变,你只需要改表里的一行,而不需要把整个程序推倒重来。

2. 把“检查密码”拆成五个可以独立验证的判定单元

2.1 先定数据读取,别让换行符背黑锅

OJ题目的输入通常是一组或者多组测试数据。常见写法是先读一个整数T,表示后面有T行密码,然后每行读一个字符串。这里的第一坑就在“每行”这两个字。

如果你用scanf("%d", &T)读完数字,紧接着用fgets读密码,第一次fgets会先把数字后面那个换行符读掉,于是你得到的是一个空行,真正密码被当成第二次输入。这不是密码检查的问题,而是混合读取行缓冲和fgets时的经典事故。

更稳妥的做法是:读完T之后立刻getchar(),把残留的换行符消费掉;或者干脆第一行也用fgets后再sscanf,从源头上避开缓冲区状态不一致。我自己的习惯是判断题主数据规模不大,用scanf读T加一个getchar()就够,但心里始终记着这一行换行符的存在。

2.2 字符分类最好交给库函数,不要手搓 ASCII 区间

判断一个字符是不是大写字母,新手最喜欢写if (ch >= 'A' && ch <= 'Z')。这没错,可一旦规则扩展到“特殊字符”“空白字符”“数字字符”,手写区间就容易漏。比如特殊字符,如果你写ch >= '!' && ch <= '~',看起来覆盖了 ASCII 标点,但空格也被包含进来了;如果你用排除法!(ch >= 'a' && ch <= 'z') && !(ch >= 'A' && ch <= 'Z') && !(ch >= '0' && ch <= '9'),那空格同样会被误判成“特殊字符”。到底空格算不算特殊字符?这要看题面。但如果规则说的是“标点符号”,那空格显然不属于这一类。

C语言的ctype.h提供了现成的分类函数:isupper、islower、isdigit、ispunct。使用它们的额外好处是可读性强,将来改成判断中文字符或 Unicode 代码点时,思路可以平移。需要注意的是调用前把char转成unsigned char,这是C语言老手都会主动做的一步。

同样的思路在 Python 里对应str.isupper()、str.islower()、str.isdigit()、str.isalnum(),在 Java 里对应Character.isUpperCase()、Character.isLowerCase()、Character.isDigit()、Character.isLetterOrDigit()。库函数的好处是你不用记住每个字符的具体编码,也不用担心平台字符集差异。

2.3 连续重复字符:只看当前和前面两位,不需要额外状态

“不能连续出现三个相同字符”的判定,最不需要写成状态机。一个循环,从下标2开始,每次看s[i]、s[i-1]、s[i-2]是否全部相等。一旦相等,立刻返回不合法。

这里很多人会犯的错是只比较s[i]和s[i-1],把规则记成了“相邻两个不能相同”。如果你用aaa来做测试,两种写法都拦得住,但遇到aaaa,只比较相邻的代码会漏掉吗?不会,它一样拦得住。真正漏的是aaabaaa?也不是,只要遇到aaa局部就会触发。那为什么说只比较相邻不对?因为你把条件写弱了,比如题目要求“不能出现连续三个相同”,意思其实是aaa非法,aa合法,而aaaa里面也包含aaa,所以非法。只检查相邻两个是否相等会让aaaa被放过去吗?不会。这其实是概念不清晰,而不是实际WA,但在读代码和向别人解释时会非常别扭。

从工程上讲,把“三连重复”写成独立函数还有另一层好处:将来题目改成“不能出现连续四个相同”,你只需要把比较往前推进一位,其他逻辑不用动。代码如下:

int has_triple_repeat(const char *s) { int len = (int)strlen(s); for (int i = 2; i < len; i++) { if (s[i] == s[i - 1] && s[i] == s[i - 2]) { return 1; } } return 0; }

2.4 用短路逻辑把五个判定串起来

五个判定条件最好独立成函数,然后在主逻辑里用短路方式组合:先判长度,长度不合法就直接输出NO,不要再继续访问字符,防止后面代码对空字符串或者超长字符串做多余操作。

这里还有个细节:如果密码长度超过题目限制,比如 100 个字符,你仍然遍历全串判断字符类型,虽然不会出错,但白白浪费了时间。把长度判断放在最前面,既符合逻辑,又能在超长测点上省掉一次扫描。代码结构大概是这样:

int check_password(const char *s) { int len = (int)strlen(s); if (len < 6 || len > 16) { return 0; } if (!has_upper(s) || !has_lower(s) || !has_digit(s) || !has_special(s)) { return 0; } if (has_triple_repeat(s)) { return 0; } return 1; }

3. 判题环境最容易翻车的四个细节与排查链路

3.1 翻车现场一:T组输入时,字符串读取被上次残留干扰

这个问题的表现是:本地测试第一组数据正常,第二组开始第一个字符莫名消失,或者整个字符串变成空行。原因几乎都是scanf和fgets混用导致缓冲区里还有换行符。

我当时调试时,在每组读入后马上打印:

printf("[%s]\n", s);

然后发现第二组输出变成[],才知道是换行符被读成一整行了。解决方式很简单:读完整数后用getchar()清掉换行,或者统一改用fgets读每一行再用sscanf解析。为了通用,我更建议写一个干净的读取函数,把“去换行”和“修剪\r”都封装进去。

3.2 翻车现场二:fgets 把换行符当成密码内容

用fgets读入字符串后,字符串末尾会带着一个'\n'。你没去掉它时,长度永远比你看到的字符数多 1。比如密码Abc123!明明只有 7 个字符,你却得到 len = 8,直接把长度判断卡在 6 到 16 的临界点附近疯狂出错。

修法是用strcspn找到第一个换行符的位置并替换成\0:

s[strcspn(s, "\n")] = '\0';

Windows 环境有时还会混入\r,所以更稳一点的做法是连续把\r和\n都清掉。某类题目如果明确说密码“不含空格”,也有人直接改用scanf("%s", s),但遇到字符串里可能包含空格的情况就必须回到fgets,因此我建议直接养成“读取后清理换行”的肌肉记忆。

3.3 翻车现场三:把特殊字符定义成“不是字母数字的一切”

我第一次写has_special时,用的逻辑是“遍历所有字符,只要不是大小写字母和数字,就算特殊字符”。这个写法在遇上空格时会立刻判断成功。可是密码规则里如果写的是“必须包含特殊符号”,空格一般不应当被当成合法特殊字符。

这条坑很隐蔽,因为本地测试时你可能专门挑了Abc123$这类带$的样例,完全没测带空格的输入。等你哪天手滑输入Abc 123,程序会直接判YES,跟预想不一致。最终我把has_special改成用ispunct,空格被排除在外:它既不是字母,也不是数字,也不是标点,所以不会触发“特殊字符已出现”的标志。

真正的教训是:字符分类不能光用排除法,最好同时考虑“它是什么”和“它不是什么”。字母、数字、标点、空白是四类不同概念,混在一起写迟早出事。

3.4 翻车现场四:带符号 char 传给 ctype 函数

这个问题不那么容易遇到,但遇到了就是玄学。C标准里isupper这类函数要求参数能表示为unsigned char或EOF,否则是未定义行为。如果机器上char默认带符号,那么s[i]的高位为 1 时,传给函数会变成负数,查表直接越界,结果完全不可预期。

修法是每次调用前先转一次:isupper((unsigned char)s[i])。虽然这一行看起来啰嗦,但在跨平台提交时能帮你省掉一批“本地正常、OJ WA”的深夜崩溃。

3.5 一套可以反复用的调试图标法

遇到WA,我习惯在代码里临时保留一组“裁判输出”:在每个判定函数返回值后面打印一个标志位,比如长度 01、大写 02、小写 03、数字 04、特殊 05、重复 06,然后输入一组故意构造的密码,观察哪个标志位不符合预期。这比一步一步断点更快,尤其是OJ题没法逐步调试时。

不要觉得打印代码很土。OJ题真正要保的是最终正确性,不是代码整洁度。等确认全过之后,再把这些调试输出删掉重交一次,干净又安全。

4. 参考实现:C、Python、Java 三版对照

4.1 C 版本:最贴近课程OJ的标本答案

这门课既然叫“实验九”,大部分同学用的应该是 C。C 版本放到OJ上的好处是编译快、运行快、语法限制少,而且课程的样例评测一般也是用 GCC 跑。下面这份代码可以直接复制进编辑器里验证,但比赛提交前请删除调试打印。

#include <stdio.h> #include <string.h> #include <ctype.h> int has_upper(const char *s) { for (int i = 0; s[i]; i++) { if (isupper((unsigned char)s[i])) return 1; } return 0; } int has_lower(const char *s) { for (int i = 0; s[i]; i++) { if (islower((unsigned char)s[i])) return 1; } return 0; } int has_digit(const char *s) { for (int i = 0; s[i]; i++) { if (isdigit((unsigned char)s[i])) return 1; } return 0; } int has_special(const char *s) { for (int i = 0; s[i]; i++) { if (ispunct((unsigned char)s[i])) return 1; } return 0; } int has_triple_repeat(const char *s) { int len = (int)strlen(s); for (int i = 2; i < len; i++) { if (s[i] == s[i - 1] && s[i] == s[i - 2]) { return 1; } } return 0; } int check_password(const char *s) { int len = (int)strlen(s); if (len < 6 || len > 16) return 0; if (!has_upper(s) || !has_lower(s) || !has_digit(s) || !has_special(s)) return 0; if (has_triple_repeat(s)) return 0; return 1; } int main(void) { int T; scanf("%d", &T); getchar(); while (T--) { char s[1024]; fgets(s, sizeof(s), stdin); s[strcspn(s, "\n")] = '\0'; printf("%s\n", check_password(s) ? "YES" : "NO"); } return 0; }

这里缓冲区长度没有写太死,用了一个相对宽裕的 1024。实际OJ通常会限定每行不超过 50 或 100,给几倍余量总是对的。如果你确定字符串长度不超过 16,也可以直接给 32,但多留空间不是坏习惯。

4.2 Python 版本:能用库函数就用库函数,别硬写正则

Python 写这种题非常快,几个内置方法就能完成字符分类。要注意的点是:str.isalnum()对中文也会返回 True,所以如果平台测点包含中文字符,你要先想清楚中文字符算不算字母或特殊字符。

def has_triple_repeat(s: str) -> bool: for i in range(2, len(s)): if s[i] == s[i - 1] == s[i - 2]: return True return False def check_password(s: str) -> bool: if not (6 <= len(s) <= 16): return False has_upper = any(c.isupper() for c in s) has_lower = any(c.islower() for c in s) has_digit = any(c.isdigit() for c in s) has_special = any((not c.isalnum()) and (not c.isspace()) for c in s) if not (has_upper and has_lower and has_digit and has_special): return False if has_triple_repeat(s): return False return True

你可能会想用正则re.fullmatch(r'...', s)一行搞定,但正则的优点是直观,缺点是可读性差、扩展性差。如果题目只判断一次还好,一旦你需要在多组数据里复用,逐字符的语义函数反而更容易维护。

4.3 Java 版本:不要用 matches 一把梭

Java 版本的思路和C完全一致,但要注意String.matches默认对整个字符串做匹配,正则稍微写错一个边界就会出事。我建议老老实实逐字符遍历,依赖Character来判断类型。

public class Main { static boolean hasTripleRepeat(String s) { for (int i = 2; i < s.length(); i++) { if (s.charAt(i) == s.charAt(i - 1) && s.charAt(i) == s.charAt(i - 2)) { return true; } } return false; } static boolean checkPassword(String s) { if (s.length() < 6 || s.length() > 16) return false; boolean upper = false, lower = false, digit = false, special = false; for (int i = 0; i < s.length(); i++) { char c = s.charAt(i); if (Character.isUpperCase(c)) upper = true; else if (Character.isLowerCase(c)) lower = true; else if (Character.isDigit(c)) digit = true; else if (!Character.isLetterOrDigit(c) && !Character.isWhitespace(c)) special = true; if (i >= 2 && c == s.charAt(i - 1) && c == s.charAt(i - 2)) return false; } return upper && lower && digit && special; } public static void main(String[] args) { System.out.println(checkPassword("Abc123!") ? "YES" : "NO"); } }

Java 这里有个天然好处:char是十六位无符号值,不会像C那样遇到带符号char的未定义行为。但charAt处理的是 UTF-16 编码单元,如果测点出现某些增补字符,判断逻辑需要换成codePointAt,不过课程OJ出现这种数据的概率极低。

4.4 三版对照下来,我的选型理由

用 C 当主力是因为这门课和 OJ 的评测环境默认支持 C,提交简单,运行快,而且数组、指针、字符串的关系正好在这道题里全练到。Python 适合拿来本地造测试用例、快速验证规则边界,因为脚本语言写一小段正则和循环都很快。Java 则适合练习类封装和Character方法,不过对新生而言,C 版仍是投入产出比最高的选择。

语言主要优势主要风险推荐场景
C贴近系统底层,OJ兼容最好unsigned char转换、buffer清理课程实验、严苛运行环境
Python代码量少,字符串方法丰富isalnum对中文判定与题意可能不符快速原型、本地验证
Java类型安全,字符方法可靠代码偏长,需要完整类结构练习工程化写法、面试手撕

5. 提交之前,自己先当一遍判题机

5.1 十六个必测用例直接抄

有些人觉得自己写的代码逻辑没问题,就直接交,然后被OJ反手一个WA教做人。我自己吃过太多次亏,现在养成的习惯是:先把测试用例写在纸上,再跑代码。下面这一组用例覆盖了长度边界、字符缺失、连续重复、多组输入四种情况。

输入: 8 abc12 Abc123 Abc123! Abc123!!a AAAAAA! AABBCC! Abc 123! Abc123!
  • abc12:长度 5,不够6,输出NO。
  • Abc123:长度 6,有大写、小写、数字,但没有特殊字符,输出NO。
  • Abc123!:各项都满足,输出YES。
  • Abc123!!a:长度超过16的一部分?其实是9位,满足条件,输出YES。
  • AAAAAA!:有连续重复字符,输出NO。
  • AABBCC!:虽然不重复三连,但是否有大写、数字、特殊字符?这里既有大写A、小写B、数字没有、特殊字符!,所以缺数字,输出NO。
  • Abc 123!:包含空格,看特殊字符定义。按我的规则空格不算特殊,但字符串里有!,所以依然YES。
  • Abc123!:重复,确认YES。

这种表格别嫌琐碎,每一行都对应一个判定分支。你手动把预期结果写下来,再用程序跑一遍,等于提前把OJ的测试点模拟了一部分。

5.2 长度区间必须测“边边的邻居”

长度规则是 6 到 16,那测试时至少要看四组:最短合法(6位)、最短非法(5位)、最长合法(16位)、最长非法(17位)。很多漏判都出在对界限的理解上:有人把>写成>=,结果 6 位密码被算非法,或者 17 位密码被放过去。

更好的测试方式是写一个生成函数,用循环构造出 5、6、16、17 位的字符串,然后手动确认每一组输出。字符串不要只用一个字母凑长度,比如AAAAAA确实长度为 6,但它同时触发了连续重复规则,你会分不清到底是因为长度还是因为重复才输出NO。正确的做法是让字符串既满足字符要求又卡在长度边上,比如Ab1!Ab正好 6 位,Ab1!Abcdefghijklmn正好 16 位。

5.3 屏蔽“我写的代码一定没问题”的盲区

交题之前最容易犯的错是只看输出 YES 还是 NO,却不看判定顺序。建议临时加一行打印,把每个子判断结果都带出来,比如:

printf("len=%d upper=%d lower=%d digit=%d special=%d triple=%d\n", len, has_upper(s), has_lower(s), has_digit(s), has_special(s), has_triple_repeat(s));

然后对每个样例观察一下输出,尤其盯着特殊字符那一列。如果Ab1!Ab显示 special=0,说明你的特殊字符定义和预期不一致;如果AAAAAA!显示 triple=0,说明重复判断根本没进循环。这种调试方法比对着屏幕空想快得多,也更接近“判题机”的视角。

5.4 多组数据时,别让上一次的输入影响下一次判断

多组数据场景下最常见的错误是某个标志变量没有重置。比如你在循环外定义了has_upper = 0,第一组数据读到大写字母后把它置1,第二组数据没有大写字母,但变量仍然是1,于是误判YES。解决方法是把标志变量声明在每次循环内部,或者每次循环开始前统一清零。

6. 从“能过题”到“能复用”:把检查逻辑朝工程化扩展一点

6.1 把 YES/NO 改成错误列表

这道题如果只输出 YES 或 NO,实在有点浪费。实际项目里,用户输入弱密码时,你几乎永远要告诉他“到底哪里不够好”。稍微扩展一下,写一个collect_errors函数,把不满足的规则一条条加进链表或者字符串数组,输出效果就完全不同。

比如错误信息可以这样组织:

  • “密码长度应在 6 到 16 位之间”
  • “需要包含大写字母”
  • “需要包含小写字母”
  • “需要包含数字”
  • “需要包含特殊符号”
  • “不能包含连续三个相同字符”

然后主程序只做一件事:先收集错误,有错误就输出错误列表,没错误就输出YES。这样代码的可读性、可测试性都上了一个台阶,而且你在面试手撕代码时,这种拆法也会让面试官更容易跟上你的思路。

6.2 给密码强度打分

还有一种常见扩展是强度评级:长度、大小写、数字、特殊字符各占权重,最后算出一个总分,再映射成“弱、中、强”。实现起来并不难,关键是权重怎么定。实际产品里长密码通常比强行堆符号更重要,所以长度权重可以给高一点。课程实验不强制做这个,但如果你想练一练模块化设计,把这道题拆成“规则校验”和“强度评估”两个模块,就是一次很好的小重构。

6.3 这道题复盘完,我最后留下三个习惯

第一个习惯:任何字符串题,拿到手先问清楚字符范围。是ASCII还是UTF-8?允不允许空格?允不允许中文?这些信息比代码本身更影响正确性。

第二个习惯:所有判定条件都做成独立函数。别在main里堆五个for,独立函数让你可以单独测试每一个规则,出了问题也能立刻定位。

第三个习惯:提交前必须跑一组带边界的用例,而不是只跑题面样例。题面样例是出题人用来安抚你的,真正的血泪都在边界测试里。

我现在再去写类似题目时,基本不会长篇大论调试了,而是直接在纸上画出判定表,按表格翻译代码,然后跑一遍边界用例,最后提交。整个过程大概十分钟,但通过率比从前瞎试高得多。如果你手头正好也在写“检查密码”这道实验题,不妨试试这个流程——先不要急着写代码,把规则和边界用例列清楚,你会发现自己离AC并不远。

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

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

立即咨询