Regex Tuesday 题解
Regex Tuesday 是 Callum Macrae 做的一组在线正则练习。每道题都有一组输入和预期结果,可以直接在页面里填写正则,看哪些用例通过、哪些没有通过。题型分为匹配和查找替换两类,内容从重复单词、日期、URL,到元素符号、音乐和国际象棋记号,适合用来练习把文字规则变成正则。
偶然发现这个网站以后,我把里面的 20 道题逐一做完,也记录了解题时用到的规则和踩过的坑。最新一题发布于 2013 年 2 月 12 日,已经多年没有新增,我把现有题目当作一套完结练习,按第 1~20 题整理在这里。做题的目的,是以后在开发中遇到有格式的文本时,能根据约束写出正则,也知道什么时候该改用解析或数值计算。
写正则前,先判断输入的约束
题库只有有限的样例,正例能说明某种写法需要支持,却不能证明相似字符串都有效。先把观察写成假设,再用反例逐项核对;题面已经明确的规则,则直接按规则构造。
先确定要匹配什么、输出什么
校验题要区分「整串符合格式」和「其中存在一段符合条件」;替换题则要另外确定哪些内容要修改,哪些上下文必须保留。字符范围、重复次数、字段关系和输出要求,是四件要分别写清楚的事。
例如「三个数字相等」比「三个数字都在 0~255」多了相等关系;「给第二个重复词加粗」比「找到两个重复词」多了输出位置要求。先写出这些差别,再选择字符类、分组和断言。
每题开头选了几条代表性输入,并保留原题编号。匹配题的「接受 / 拒绝」沿用题库标签;替换题直接给出预期输出,需要保持原样的输入或片段也属于约束。题库与规范有出入时,会在该题中说明。阶段通过数同时计算正确接受和正确拒绝,不是匹配成功的条数。
按已知条件选择字符范围
写每一段之前,先判断它是固定分隔符,还是会变化的内容。如果内容会变化,已经知道允许哪些字符,还是只知道某些字符不能出现?以下都按 JavaScript 正则解释。
| 当前能确定的条件 | 选择方式 | 还要检查什么 |
|---|---|---|
| 某个位置必须是固定字符 | 字面量,如 !、@、\. |
点号在字符类外有特殊含义,需要转义 |
| 内容属于一组允许的字符 | 字符类或字符类转义,如 [A-Za-z]、\w |
前者只接受英文字母;无标志时,\w 还包含 ASCII 数字和下划线 |
| 已知某些字符不能出现,其余暂时放开 | 否定字符类,如 [^ ] |
这里只排除普通空格,其他空白和控制字符仍可能通过 |
| 一段单行内容可以有字母、空格和标点 | 不带 s 的 . |
普通空格和 Tab 都能匹配,行终止符不能匹配 |
| 需要匹配包括换行在内的任意字符 | . 配合 s,或 [\s\S] |
先确认确实允许跨行,别把协议分隔符一起吞掉 |
| 只处理普通空格和 Tab | [ \t] |
不要用包含换行的 \s 代替 |
字符类内部也不全是原始字面量:.、*、+ 通常只是字符,\d、\s 仍表示字符集合,- 可以定义范围,开头的 ^ 表示取反,] 用来结束字符类。[. ] 只能匹配点号或普通空格,不能代替通配符。
字符类限制的是单个位置可以出现什么;要选择整套格式,使用分支。字符范围与次数也要分开判断:只能由字母组成,不代表允许为空,更不代表长度没有上限。每个量词与可选分组,都应对应一条已知规则或待验证的假设。字符类转义和通配符 .的范围,可以在假设与结果冲突时核对。
把比较、读取与替换分开
这些题会反复遇到几种容易混在一起的操作:
| 操作 | 写法或机制 | 影响 |
|---|---|---|
| 保存已经匹配的内容 | 捕获组 (...) |
按左括号出现顺序编号,跨分支也不重新编号 |
| 只组织结构,不保存结果 | 非捕获组 (?:...) |
仍会读取其中匹配的字符 |
| 要求后面重复捕获内容 | 正则中的 \1、\2 |
比较文本;01 与 1 的数值等价需要另行处理 |
| 只检查当前位置两侧的条件 | 先行断言 (?=...)、(?!...),后行断言 (?<=...)、(?<!...) |
断言本身不把检查过的内容加入实际匹配范围 |
| 把捕获内容写回输出 | 替换模板中的 $1、$2;$& 表示完整匹配 |
它们是输出引用,职责与正则中的反向引用不同 |
一次 replace() 根据原输入寻找匹配,再拼接输出。g 会继续查找后续匹配,不会重扫刚生成的标签,也不会让实际匹配范围重叠。相邻两次处理需要共用一个词、空格或整行时,要检查是否把这些上下文过早读走。
i 影响比较时的大小写,不会统一捕获内容的原始拼写;m 改变 ^、$ 的行边界含义,不会让 . 跨行;让点号覆盖行终止符的是 s。贪婪量词优先尝试较多次数,懒惰量词优先尝试较少次数,两者都不会替表达式补齐合法性条件。
用最小差异推进,每次回归全部用例
- 从最简单的正例搭出骨架,先固定分隔符和字段次序,不急着容纳所有形式。
- 找一对只差一点的正反例,确认差别来自字符范围、位数、边界、相等关系还是字段依赖。
- 只修改负责这个条件的部分。若扩大了特殊分支,检查兜底分支是否仍能绕过新限制;互斥的格式尽量分别成支。
- 运行全部用例,逐条看修复与回归。再补空输入、缺失字段、边界内外、连续三次重复、分隔符混用等反例,不能只看总分。
- 全部通过后核对真实格式。区分题目约定、题库遗漏与规范冲突;需要数值计算、嵌套语法或外部状态时,重新判断正则是否适合承担完整校验。
数值范围可以按十进制或其他进制的前缀拆成有限区间;字段间需要相等时可以用捕获与反向引用;数据本来就是有限名单时,枚举往往最容易核对。题面里的 regex golf 指尽量缩短表达式,先保证接受集合正确,再合并前缀或字符类。
表格与配图用 ␣ 标出块首、块尾或需要辨认数量的普通空格,内部可辨认的单空格保持原样;⇥ 表示真实 Tab,↵ 表示真实 LF 换行。实际输入仍使用原字符。正则里的 \n 表示 LF,\r\n 表示 CRLF;这套练习的网页替换框不会把手动敲入的 \n 转成换行,需要通过捕获输入中的真实换行等方式保留它。
第 1 题:高亮相邻重复词
第 1 题要求把相邻重复词的第二次出现包进 <strong>。This is is a test 中第二个 is 要加粗,This test is a test 中的两个 test 隔着其他词,要保持原样。第 5、10 条还要求忽略大小写比较,第 11 条的 I'll 则说明撇号可以出现在词里。
| 原题序号 | 输入 | 预期输出 |
|---|---|---|
| 2 | This is is a test |
This is <strong>is</strong> a test |
| 4 | This test is a test |
This test is a test |
| 10 | This is IS a test |
This is <strong>IS</strong> a test |
| 7 | This test is a test tester |
This test is a test tester |
第一步:先捕获前词,再用反向引用比较
从 is is 入手,可以把输入拆成「前词、空格、相同的后词」。先把词片段定义为不含空白和句点的非空文本,用 [^\s.]+ 读取。这里不用 \w+,因为它读不过 I'll 中的撇号;句点先排除在词外,是为了保留第 11 条末尾的 bit bit.。
组 1 记住前词,组 2 保存分隔空格,组 3 再捕获一次反向引用匹配到的后词。替换模板 $1$2<strong>$3</strong> 把前词和空格放回去,只给后词加标签。g 用来处理同一句中的多处重复。
这个版本通过 5/11,主要问题是没有检查词的边界。第 1 条里的 This is 会被看成 Th 加上 is is,第 7 条里的 test tester 也会匹配后词的前半段;内容相同还不足以说明两个完整词相同。
第二步:补全两边的边界
观察题库里的分隔方式:前词从输入开头或普通空格后开始,后词后面则是空格、句点或输入结尾。左侧用 (?:^|(?<= )) 定位,右侧用 (?=[. ]|$) 确认。这两个边界都不读取分隔字符。
右边使用前行断言,所以 bit bit. 的句点留在原位;同一句里相邻两组重复词之间的空格也不会被顺手读走。句点在字符类里就是普通句点,[. ] 不需要再写成 [\. ]。把「输入结尾」也放进断言内部,比在外面再套一层选择更紧凑。
当前通过 9/11,只剩 This this 和 is IS 两处大小写差异。
第三步:比较忽略大小写,输出保留后词原文
加入 i 后,反向引用同样按忽略大小写的方式比较,题库变成 11/11。这个行为可核对 JavaScript 的反向引用说明。
这里的 (\1) 不能为了少一对括号就直接删掉,再把替换模板中的 $3 改成 $1。组 1 保存的是前词 is,组 3 保存的是后词 IS;两者比较相等,原始拼写却不同。用 $1 输出会把后词错误地改成小写。
全部通过以后:检查分隔符和连续重复
分隔空格必须至少出现一次。如果把它放宽成 [ .]*,两个词之间就可以没有任何字符,haha 会被拆成 ha 和 ha,得到 ha<strong>ha</strong>。允许句点作为分隔符还会让 go. go 跨句比较;题库末尾的句点只要求作为词尾保留,并没有要求跨过它寻找重复。
另一个遗漏是连续三个词。按「每个词与原输入中的前一个词比较」的规则,a a a 后两个 a 都应加粗。但当前版本一次匹配前两个词,全局搜索随后从第二个词的末尾继续,无法复用它作为下一对的前词。
两次匹配需要共用中间那个词作为上下文。g 不会让实际匹配范围重叠,也不会把替换后生成的标签重新当成输入搜索。
第四步:前词放进断言,只替换当前词
把「前词的起点、前词、分隔空格」一起放进正向后行断言,仍用组 1 记住前词;断言外只留下反向引用和右侧边界。这样每次实际匹配的就是当前重复词,前词仍可被下一次断言检查。
替换模板现在可以简化为 <strong>$&</strong>。$& 表示本次匹配到的完整原文;由于匹配范围只包含当前词,就不需要再给 \1 套捕获组,也不用重新拼回前词和空格。is IS 输出的仍然是 is <strong>IS</strong>,a a a 则会把后两个词都加粗。
这个版本保持 11/11,连续重复、大小写与分隔符等补测也通过。这里使用了长度可变的后行断言,目标是 JavaScript;其他正则引擎需要先确认其后行断言限制。
这份优化式的适用范围
题库没有完整定义自然语言中的「词」,所以补测仍沿用明确的边界约定:普通空格分隔相邻词,句点结束当前比较,其他标点暂未从词片段中拆出。
go. go保持原样,句点后可以重新开始比较,因此go. go go只加粗最后一个词。- Tab 和换行不作为相邻词之间的分隔符;如果业务允许它们,需要一起调整分隔符和左右边界。
go go,、go go!尚未支持。若要把逗号、叹号等视为词尾,需要先决定标点能否被跨过,再调整词片段和断言,不能只扩大其中一个字符类。
这仍是针对文本片段的替换,没有完成自然语言分词或 HTML 解析。题库中的 <Mack> 是输入文本的一部分;在真实页面中高亮时,应操作文本节点并生成 strong 元素,保留文本转义,而不是把整段 HTML 源码交给这份表达式。
第 2 题:用反向引用匹配灰阶颜色
第 2 题要匹配的是灰阶 CSS 颜色,并且排除颜色名称。黑、白和它们之间的灰色都属于灰阶。只检查十六进制位数、函数参数个数和数字范围,还没有完成题目要求:#123456 格式正确,却不是灰阶颜色。
| 原题序号 | 输入 | 题库预期 |
|---|---|---|
| 3 | #eEe |
接受 |
| 10 | rgb(1, 01, 000001) |
接受 |
| 17 | hsl(0, 10%, 100%) |
接受 |
| 23 | #eEf |
拒绝 |
| 38 | rgb(10%, 10, 10) |
拒绝 |
| 30 | hsl(20, 20%, 20%) |
拒绝 |
先按题库出现的写法逐步扩充:#RGB、#RRGGBB、rgb()、rgba()、hsl()、hsla()。这一版采用题库的数值范围和参数个数约定,现代 CSS 与它不同的地方放到最后讨论。
第一步:十六进制颜色需要重复相同的分量
最前面的正例是 #000、#aaa、#eEe。只用三位十六进制字符的写法,会连 #ef4 也一起接受。再看六位写法,正例 #6F6F6F 的三个 6F 相同,反例 #123456 的 12、34、56 不同。
三位写法的每一位分别表示红、绿、蓝,展开时各自重复一次,例如 #abc 等价于 #aabbcc。所以三位灰阶要求三个字符相同;六位灰阶要求三个两位一组的分量相同。这里可以直接捕获第一项,再用反向引用匹配后两项,无需枚举所有灰色。
#([0-9a-f])\1\1 中的组 1 保存一位,后面两个 \1 要求重复它。六位分支使用组 2,保存两位后再用 \2\2 重复。捕获组编号按整条正则中左括号出现的顺序计算,进入另一个分支不会重新从 1 编号。
i 不只是让 [a-f] 接受大写字母,也让反向引用忽略大小写。因此 #eEe、#efEfEF 都可以匹配,#eEf 则因为最后的 f 与 e 不同被拒绝。当前通过 25/40,剩下的正例都属于函数写法。
第二步:先比较 RGB 的三个整数
继续看 rgb(0, 0, 0) 与 rgb(15,15,15):三个分量相等,只是逗号两边的空格不固定。先用 [ \t]* 处理普通空格和 Tab,再把整数分量限定在 0~255。
把范围按数字前缀拆成四段:[1-9]?\d 表示 0~99,1\d\d 表示 100~199,2[0-4]\d 表示 200~249,25[0-5] 表示 250~255。捕获第一项,后两项引用它,就能同时检查范围和相等关系。
不过第 10 条 rgb(1, 01, 000001) 提醒我们,题目比较的是数值。三个数字都等于 1,原始文本却不相同。这里先处理前导零:把每项前面的 0* 留在捕获组外,组内只保存后面的分量。
前面的十六进制分支已经使用组 1、2,所以 RGB 的第一项是组 3,后两项写成 0*\3。01 的零由 0* 读取,000001 的五个零也由它读取,真正参与比较的都是 1。如果整个数就是零,组内仍要留下一位 0,不能让三个空字符串冒充三个分量。
当前通过 28/40。rgb(2, 4, 7)、rgb(10, 10,100) 因为分量不同被拒绝;小数与百分比正例还没有覆盖。
第三步:给分量补上小数和百分比
第 9 条是 rgb(2.5, 2.5,2.5),第 11 条是 rgb(20%, 20%,20%)。需要扩充的是第一项的完整分量格式,不是在三项后面分别补三个互不相关的可选部分。
普通数值保持 0~255,允许小数;百分比采用 0%~100%。上限要单独处理:255.0 仍然等于 255,但 255.1 已越界;100.0% 可以接受,100.1% 不行。因此端点后只允许全零的小数部分,其余区间才允许任意小数位。
百分号必须放进捕获组。这样第一项捕获 20%,后两项就都要重复 20%;rgb(10%, 10, 10) 不能把第一项的百分号单独省掉。逗号写法中的三个 RGB 分量也确实要求单位一致,不能混用数字和百分比。
小数点写成 \.,它与小数位一起进入捕获组。因此 2.5 的后两项必须重复完整的 2.5。前导零仍然由组外的 0* 处理,0002.5 与 02.5 也能比较成功。当前通过 30/40,RGB 正例全部通过。
第四步:RGBA 的透明度与灰阶判断分开
rgba(4,4,4,0.8) 比 RGB 多了最后一项 alpha,表示不透明度:0 完全透明,1 完全不透明,也可以使用对应的百分比。前三项依旧要求相等,alpha 只检查自身格式与范围,不参与三项的相等比较。
新分支捕获第一项时,组号已经变成 4,后面应使用 \4。只是为选择和重复服务的括号都写成 (?:...),避免无意增加捕获组,导致后续编号错位。
按题库约定,rgb 读取三个参数,rgba 读取四个,分别写两个分支。不能只把函数名中的 a 和最后的 alpha 各自设为可选,否则会放行题库要求拒绝的 rgb(10, 10, 10, 10),也会接受缺少第四项的 rgba(...)。
第 13 条 rgba(4,4, 4,1 ) 还要求接受连续空格和右括号前的空格。第 14 条的 0.12536 则说明 alpha 的小数位不能只允许一两位。补全后通过 34/40,剩下的是 HSL 与 HSLA。
第五步:先弄清 HSL 为什么会得到灰色
HSL 的三个参数分别是色相 H、饱和度 S、明度 L。色相可以理解为选取哪一种颜色;饱和度决定颜色有多鲜明;明度在这里从黑色的 0% 变化到白色的 100%。它们不是红、绿、蓝三个分量,不能继续套用「三项相等」的条件。
在 S、L 都处于 0%~100% 的前提下,有两个入口可以得到灰阶:S 为 0%,或者 L 为 0% 或 100%。例如 hsl(20,0%,50%) 失去了饱和度,是灰色;hsl(0,10%,100%) 的饱和度虽然不为零,但明度已经到顶,是白色。色相在这些情况下不影响结果。这些关系可以核对 HSL 的颜色模型说明。
因此,读取色相和第一个逗号以后,用两条分支判断后面的 S、L:第一条固定 S 为零,让 L 在范围内变化;第二条让 S 在范围内变化,固定 L 为两端之一。
这里的零写成 0+(?:\.0+)?,可以接受 0、00、0.0 等相同数值。100 同样允许前导零与全零的小数部分。色相先覆盖题库中的非负、无单位整数或小数,不把它误限成正整数,所以第 18 条的 0.5 和其他样例中的 0 都能通过。
反例 hsl(0, 1%, 01%) 的 S 等于 1%,L 也等于 1%,没有满足任一灰阶条件,不能因为写着零就把它当成零。当前通过 38/40,只剩两个 HSLA 正例。
第六步:给 HSLA 补上独立的 alpha
HSLA 复用刚才的 HSL 条件,末尾再读取一个 alpha。透明度范围与 RGBA 相同,不要求它和 H、S、L 中的任何一项相等。
这个版本通过 40/40。整条正则只有四个捕获组:两个用于十六进制分量,两个分别用于 RGB、RGBA 的第一项。HSL 与透明度部分只需要范围和条件选择,不需要增加捕获组。
全部通过以后:文本相等还不是数值相等
前导零可以留在捕获组外,但反向引用本身仍然只会比较文本。这一点在题库中被暂时隐藏了:小数正例的三个分量刚好写得一模一样,只有整数正例改变了前导零的数量。
| 额外输入 | 数值上是否为灰阶 | 上述正则 | 原因 |
|---|---|---|---|
rgb(0002.5,02.5,2.5) |
是 | 接受 | 组外的 0* 吸收前导零 |
rgb(1,1.0,1.00) |
是 | 拒绝 | 捕获的 1 不能直接匹配 1.0 |
rgb(2.5,2.50,02.500) |
是 | 拒绝 | 小数尾零没有规范化 |
rgb(10%,10.0%,010.00%) |
是 | 拒绝 | 百分比也存在同样的文本差异 |
rgba(2,3,2,0) |
否,RGB 分量不等 | 拒绝 | 完全透明与原始分量是否为灰阶是两项判断 |
不能为了兼容小数尾零,就在每个反向引用后随手加一个 0*。如果捕获的是整数 1,它会把 10、100 也视作相同分量。继续用正则处理时,至少要把整数、有效小数位和尾零的关系区分开,不能把这件事误当成反向引用自带的数值比较能力。
如果目标是准确判断这些数值等价写法,更直接的做法是先解析参数,将前导零、小数尾零和单位规范化,再比较 RGB 的三个数值;HSL 则继续检查 S 为零或 L 位于两端。先把语法解析与颜色判断分开,比在同一个捕获组周围不断增加例外更容易验证。
题库约定与现代 CSS 要分开
本节只覆盖题库的非负普通十进制、逗号与单位范围约定。现代 CSS 还会钳制越界值,并允许其他函数与分隔形式;现代 CSS 颜色规则对照弹窗列出差异。CSS.supports('color', value) 只回答浏览器是否接受语法,不能判断颜色是否灰阶或叠加背景后的观感。
范围、单位、透明度与空白等补测通过,但仍有 4 条数值相等、文本不同的已知漏判,需要前面的数值规范化处理。
第 3 题:用数字前缀约束日期与时间
第 3 题要求校验 YYYY/MM/DD HH:MM(:SS):年份为 1000~2012,月为 01~12,每个月统一按 30 天计算,秒数可省略。适合先用 \d 确定每段数字的位数,再逐项收紧范围。正则没有直接执行大小比较的运算符,但可以按数字前缀拆分区间,用分支表达这样的有限范围。
| 原题序号 | 输入 | 题库预期 |
|---|---|---|
| 1 | 2012/09/18 12:10 |
接受 |
| 2 | 2001/09/30 23:59:11 |
接受 |
| 9 | 2012/9/18 23:40 |
拒绝 |
| 10 | 2013/09/09 09:09 |
拒绝 |
| 15 | 2009/11/11 24:00:00 |
拒绝 |
第一步:先写位数和分隔符
第 1 条 2012/09/18 12:10 可以拆成年、月、日、时、分。年份四位,其他字段两位,斜杠、冒号和日期后的普通空格都固定下来。
这一步通过 11/19。2012/9/18 23:40 和 2012/12/4 12:12 因为缺少补零被拒绝,但 2013/09/09 09:09 仍会通过:\d{4} 只规定四位数字,没有规定它表示的年份。
第二步:让秒数连同冒号一起可选
第 2 条 2001/09/30 23:59:11 多了 :11。把「冒号加两位秒数」作为整体放进 (?:...)?,就可以同时接受有秒和没有秒的输入。
不能只让两位数字可选,否则会接受只有冒号、没有秒数的结尾。当前通过 12/19:带秒的正例得到修正,同时一些原先因含秒被拒绝的越界反例也被放行。结构补全以后,还要分别检查字段范围。
第三步:按前缀拆分年月日的范围
题面把年份限定为 1000~2012,并明确假设每个月都有 30 天,不考虑闰年。因此月范围是 01~12,日范围是 01~30,不需要把月份和天数关联起来判断。
年份分成三段来看:
| 区间 | 前缀与后续数字 | 对应片段 |
|---|---|---|
| 1000~1999 | 首位固定为 1,后三位任意 | 1\d{3} |
| 2000~2009 | 前三位固定为 200,个位任意 | 200\d |
| 2010~2012 | 前三位固定为 201,个位只能是 0、1、2 | 201[0-2] |
后两段共享 20,可以合并为 20(?:0\d|1[0-2])。月份同理,拆成 0[1-9] 与 1[0-2];日期拆成 0[1-9]、[12]\d 与 30。
这里不能用 [0-3]\d 表示日期,因为它还包含 00 和 31~39。按十位分组并单独列出 30,就排除了两端多余的值。当前通过 16/19,剩下三条分别是 24 时、60 分和 60 秒。
第四步:收紧时分秒
小时范围 00~23 拆成 [01]\d 与 2[0-3]。分和秒都是 00~59,直接把十位限定为 [0-5],个位继续用 \d 即可。
这个版本通过 19/19,已经覆盖题目的全部约束。所有括号都只用于组织结构,使用非捕获组即可;1\d\d\d 写成 1\d{3}、[1-2][0-9] 写成 [12]\d,也只是简化写法,不改变这里的匹配范围。
这份日期式不能直接校验真实日历
题面主动把每个月简化为 30 天,因此接受 2012/02/30、不检查闰年,却拒绝 1 月 31 日;年份上限也固定为 2012。日期边界与独立校验结果弹窗保留补测。真实业务需要另处理大小月、闰年与业务年份,不能照搬本题范围。
第 4 题:把单星号强调替换成 HTML
第 4 题要求将 *文字* 替换成 <em>文字</em>,双星号等多星号标记保持原样。最前面的样例包括整句强调、句中局部强调和同一句中的两处强调,表达式需要能在句中搜索,用 g 处理多处匹配。
| 原题序号 | 输入 | 预期输出 |
|---|---|---|
| 3 | This text is *partially* italic |
This text is <em>partially</em> italic |
| 5 | **bold text (not italic)** |
**bold text (not italic)** |
| 8 | *italic text **with bold** * |
<em>italic text **with bold** </em> |
| 11 | random *asterisk |
random *asterisk |
第一步:排除双星号,也排除了正文中的星号
用 \* 读取字面量星号,([^*]+) 捕获非空正文,再用 <em>$1</em> 放回捕获内容。起点前的 (?<!\*) 与终点后的 (?!\*) 排除相邻星号,这份写法已经通过 10/11。
唯一失败的是第 8 条:*italic text **with bold** *。它要求转换外层单星号,同时原样保留内部的双星号;[^*]+ 却不允许正文出现任何星号。
这条输入完全没有匹配,替换后保持原样。需要排除的是作为起止标记的多星号段,不能因此把所有正文中的星号都排除。题库第 6 条反过来要求转换双星号之间的 *italic*,也说明它没有要求跳过整个双星号区域。
第二步:正文放宽以后,边界还缺两项检查
把正文改为 ([\s\S]+?),让它可以包含星号;其中 +? 使用懒惰匹配,优先尝试较短的正文。[\s\S] 覆盖任意字符,也保留了原来 [^*] 可以读取空白与换行的能力。
但只改这一段,通过率反而降到 5/11。第 5 条的 **bold text (not italic)** 会被拆成第一颗星号、包含另两颗星号的正文、最后一颗星号,错误地替换成强调。
原式还有两项隐含限制:正文不能以星号开头,所以起点后面不能是星号;正文不能以星号结尾,所以终点前面也不能是星号。它们原来由 [^*]+ 间接保证,放宽正文后就消失了。
懒惰匹配只改变尝试顺序,不会替表达式补上这些条件。即使先尝试短正文,引擎仍会继续寻找某个能让后续模式成功的位置。
第三步:起止星号的两侧都要检查
在开头的 \* 后面增加 (?!\*),在结尾的 \* 前面增加 (?<!\*)。这样每个起止标记的左右都不能紧邻星号,它们各自属于长度恰好为一的星号段。
现在正文可以读取内部的双星号;遇到星号时,只有它前后都没有其他星号,才可能成为闭合标记。+? 会尽早选择符合条件的终点,所以 *two* *italic* 仍然是两次匹配,不会合并成一大段。
新增的都是断言,正文依然是组 1,替换模板继续使用 <em>$1</em>。内部的双星号、正文大小写与空格都由 $1 原样保留。
最终式通过 11/11,内部多星号、未闭合标记、连续多处强调与换行等补测也通过。
通关式没有实现完整 Markdown
这份式子只配对独立单星号。题库对闭合前空格和多星号的判断与 CommonMark 不同,见强调规则与规范示例对照弹窗。反斜杠转义、代码跨度优先级和 HTML 转义也未处理,不能直接作为 Markdown 渲染器。
第 5 题:匹配两套数字分组与小数格式
第 5 题要求检查数字的分组符与小数符号。最前面的正例是 124、1,024、2,000,204,之后还有 3,000.6 与 8,205,500.4672,可以从熟悉的逗号分组、点号小数开始。
| 原题序号 | 输入 | 题库预期 |
|---|---|---|
| 2 | 1,024 |
接受 |
| 15 | 30 000,7302 |
接受 |
| 16 | 0,5 |
接受 |
| 19 | 1025 |
拒绝 |
| 20 | 1,5826,000 |
拒绝 |
第一步:首组之外,每组都必须是三位
整数首组允许 1~3 位,后面每次读取「逗号加三位数字」,整体重复零次或多次。小数部分可选,但如果出现点号,后面就必须至少有一位数字。
这一步通过 25/31。.5 缺少整数部分,100. 缺少小数数字,1,5826,000 的中间组不是三位,都会被拒绝。第 19 条还明确拒绝没有分组符的 1025,因此这里没有把无分组整数放宽为任意位数。
第二步:空格也能分组,但逗号还有另一种作用
看到 1 024、9 999 352 后,容易先用 [, ] 同时接受逗号与普通空格,再保留原来的点号小数。下面这份写法通过 28/31,正好剩下 10,19836、30 000,7302、0,5 三条。
这三条的预期结果没有问题:逗号在这里是小数符号。10,19836 表示十点一九八三六,逗号后的五位是小数部分,不受整数三位分组的限制;30 000,7302 的空格分隔整数的三位组,逗号分隔整数与小数;0,5 对应熟悉的 0.5。
题面要求的是两套完整格式:一套用逗号分组、点号表示小数,另一套用空格分组、逗号表示小数。符号的作用要结合整套格式判断,不能只看到逗号就认定它是千分位。
第三步:允许两种小数符号,为什么还不够
将小数部分的 \. 改成 [.,],确实就能通过 31/31。字符类里的点号表示字面量点号,因此这一步只是让小数符号在点号和逗号之间选择。
但原来的整数部分还有两个隐患:重复单元中的 \d{1,3} 让每个中间组都能只有一两位;[, ] 则让每次重复独立选择分组符,没有要求整串保持同一种格式。
1,2,345 的中间组只有一位,1,234 567 在同一个整数中混用两种分组符,1 234.5 则把空格分组和点号小数拼在了一起。这三条都不属于题目指定的两套格式,却没有被现有题库覆盖。
第四步:两个分支各自负责一整套格式
第一支沿用第一步的规则:首组 1~3 位,后面是零组或多组「逗号加三位」,最后可选点号小数。第二支只替换对应的两个符号:整数使用普通空格分组,小数使用逗号。
最终式保持 31/31,混合分组、错误组长、格式错配与长小数等补测也通过。
这里用 | 选择完整规则,字符类负责的只是单个字符的选择。分组符与小数符号必须配套时,将整套格式分开写,比在多个字符类里分别放宽更容易表达约束。
题库格式与实际数字格式的边界
题库拒绝 1025,表达的是「四位以上整数必须按要求分组」,不能据此认为没有分组的四位整数本身有问题。另两条看起来只是 507 的反例,在题库源码里分别带有开头空格和末尾空格;它们不是与 124 相矛盾的数字反例。
这里的小数位数也没有固定为两位,10.444444444444 是明确的正例。当前最终式接受非负 ASCII 数字、允许前导零,不包含正负号、指数与货币单位;空格分组只接受普通空格,不能为了兼容不同宽度的空格就直接换成 \s,否则 Tab 与换行也会进入分组规则。
现实中的分组方式还会随地区变化。Unicode CLDR 的数字格式规范分别定义了小数符号与分组符号,还区分主要分组宽度和次要分组宽度。例如 12,34,56,789 使用最右侧三位、左侧两位的分组方式,也存在使用不换行空格等字符的数字格式。上面的正则只实现本题指定的两套规则。
最后还要区分「格式校验」与「数值解析」:1,234 在第一套格式中是整数 1234,在第二套格式中则是小数 1.234,两支都能匹配成功。
正则可以判断它符合至少一套格式,却无法只凭这串字符确定输入者想表达哪个数值。真正解析时,需要已知的格式、地区设置或明确的输入约定;简单地先删除所有逗号,会把逗号小数改成另一个数。
第 6 题:匹配多种进制的 IPv4 表示法
第 6 题要求接受六种 IPv4 表示法:带点号的十进制、十六进制、八进制,以及不带点号的整体十进制、十六进制、八进制。从最前面的 192.0.2.235 开始,可以先完成熟悉的四段式,再逐种补充。
| 原题序号 | 输入 | 题库预期 |
|---|---|---|
| 1 | 192.0.2.235 |
接受 |
| 9 | 0xC0.0x00.0x02.0xEB |
接受 |
| 12 | 0300.0000.0002.0353 |
接受 |
| 15 | 0xC00002EC |
接受 |
| 31 | 256.256.256.256 |
拒绝 |
| 35 | 0180.0100.0100.0100 |
拒绝 |
第一步:每段十进制限制在 0~255
一个 IPv4 地址有 32 位,常见的四段式将它分成四个字节,每字节 8 位,数值为 0~255。这里需要两层约束:每一段的范围,以及四段之间的三个点号。
正则不能直接执行数值比较,但可以按数字前缀表达有限范围。单段先拆成 \d{1,2}、1\d{2}、2[0-4]\d、25[0-5] 四类,再用「一段加点号」重复三次,末尾接最后一段。暂时接受两位数中的前导零,后面再检查它与八进制的关系。
这一步通过 26/48,256.256.256.256 已经被拒绝。下一批正例出现 0xC0.0x00.0x02.0xEB,需要给每个字节增加其他进制的候选。
第二步:在单字节候选里补十六进制
0x 是十六进制前缀,后面的数字使用 0~9、A~F。一个十六进制数字对应 4 个二进制位,因此两位最多表示 FF,恰好是十进制的 255。先按题库每段两位的形状,增加 0x[0-9A-F]{2}。
这一步通过 29/48。0x00、0x02 里的补零说明必须允许读取多位数字,但出现补零不等于必须固定宽度:单个 [0-9A-F] 只能读取一位,[0-9A-F]+ 也能读取 00。这里的 {2} 同时承担了位数与上限限制,是否必须恰好两位,还只是当前样例下的假设。
第三步:八进制同时限制数字和上限
下一批正例是 0300.0000.0002.0353。开头的 0 表示八进制,数字本身只能使用 0~7,因此不能继续用包含 8、9 的 \d。0180.0100.0100.0100 与 0100.0100.0109.0100 正好能排除这种过宽的写法。
单字节最大值 255 转成八进制是 377。按样例中「前缀 0 加三位数字」的宽度,后三位可以合并为 [0-3][0-7]{2},完整候选就是 0[0-3][0-7]{2}。不必分别列出 00…、0…、1…、2…、3…。
现在通过 35/48,0xFF.255.0377.0x12 这样的混合进制也能通过,因为每段都独立选择自己的进制。剩下的 13 条正例全部没有点号,问题已经从「一个字节如何书写」变成了「整个地址如何书写」。
第四步:另加整体十六进制分支
四个字节也可以作为一个 32 位无符号整数读取。对于 a.b.c.d,整体数值为 a × 256³ + b × 256² + c × 256 + d。例如 192.0.2.235 对应 3221226219,十六进制是 0xC00002EB。
题库里的 0xC00002EC 末尾是 EC,对应的是 192.0.2.236,比前面的示例大 1。按字节分组时,它是 C0、00、02、EC;作为整体时,只有开头需要一个 0x,中间不写点号。
整体十六进制最多有 8 位有效数字,新增 0x[0-9A-F]{1,8} 即可。这一分支放在整个四段式的外面,两边通过 | 选择;原来四段式中的每段仍然只判断 0~255,不需要调整成 32 位范围。
这一步通过 38/48。现在外层有两个候选:四段表示法,或者整体十六进制。
第五步:另加整体八进制分支
整体八进制也在外层独立处理。每个八进制数字对应 3 个二进制位,32 位需要最多 11 个八进制数字;但 11 × 3 = 33,最高位实际只能使用两位二进制,因此只能是 0~3。
去掉进制前缀 0 后,1~10 位数字使用 [0-7]{1,10};恰好 11 位时使用 [0-3][0-7]{10}。两者组成分组,再在前面补回进制前缀。
这一步通过 43/48。注意,最大值 037777777777 含有一个前缀和 11 位八进制数字,整个字符串是 12 个字符;如果把前缀也算进限长,就会少允许一位。
第六步:整体十进制按上限的前缀拆分
最后五条正例是整体十进制,范围为 0~4294967295,也就是 2 的 32 次方减 1。它不能只写成 \d{1,10}:10 位的 4294967296 虽然长度相同,数值已经越界。
零与 1~9 位的非零开头整数可以直接接受。对于 10 位整数,从左到右与 4294967295 比较:第一位小于 4 时,余下九位任意;第一位等于 4 时,继续限制第二位;只有前面都相等,才需要继续比较下一位。
例如 42[0-8]\d{7} 表示前两位已经等于 42,第三位小于上限中的 9,后七位便可以任取;429496729[0-5] 则处理前九位都相等时的最后一位。候选数量随上限的位数增长,无需逐个枚举所有整数。
整体十进制使用非零开头的分支与单独的 0,避免把前导零数字当成十进制。它作为第四个外层候选加入,另外三个候选保持原样。
这份写法通过 48/48。外层的四个候选分别是四段表示、整体十六进制、整体八进制、整体十进制;四段表示内部再独立选择三个进制,两个层级的范围各自处理。
通关后检查:补零、短格式与进制识别
传统解析规则按前缀识别进制:0x 或 0X 开头是十六进制,其他以 0 开头的数字是八进制,其余是十进制,各段允许混用。这些行为可在 Microsoft 的 inet_addr 文档中核对。
据此检查前面的固定宽度,会发现 0xF 与 0x0F 表示同一个字节,010 与 0010 也表示同一个八进制数,值为十进制的 8。样例中的补零不能成为拒绝短格式的理由。增加更多前导零也不会增大数值,因此不能只按原始字符串总长度判断是否越界。
还要防止十进制候选绕过进制限制:最初的 \d{1,2} 会接受 08,但按前缀规则,它已经进入八进制格式,8 应当被拒绝。十进制单字节需要改成单独的 0,或者非零开头的 1~255;十六进制、八进制则分别允许自己的前导零,同时限制有效数值。
这份完善式保持 48/48,短格式、大小写、额外补零与三种进制的端点补测也通过。i 接受十六进制字母与前缀的大小写,四个外层候选以及原有的十进制上限保持不变。
题库中的 037777777778 虽然标成「太大」,但它首先包含了非法八进制数字 8。真正检查上限,还要补 040000000000:它只使用合法的八进制数字,数值却恰好达到 2 的 32 次方,应当拒绝。十六进制同样补测 0x100000000,十进制补测 4294967296。
本题只接受一个整体数或四段表示法。历史 inet_addr 还支持二段、三段格式,末段占据剩余的字节;这不在本题实现中,题库也明确拒绝 100.100.100。IPv4 的不同文本形式属于解析接口的约定,实际业务应先确定接受哪些格式,再选择相应的解析与范围检查方式。
这题的拆分依据是数值单位:点号内每段是一个字节,不带点号时是整个 32 位地址。先把单字节的三个进制候选组合好,再在外层增加三种整体表示,可以保留已验证的范围逻辑;补零与位数则要结合进制和有效数值判断。
第 7 题:匹配带协议的域名
第 7 题要求匹配前面有 http:// 或 https://、末尾可带一个斜杠的域名。先从第 1 条 http://example.com/ 开始,把协议、域名和末尾斜杠分别处理,再根据后面的正反例扩充域名部分。
| 原题序号 | 输入 | 题库预期 |
|---|---|---|
| 1 | http://example.com/ |
接受 |
| 3 | http://www.example.com/ |
接受 |
| 21 | example.com |
拒绝 |
| 35 | http://example..com |
拒绝 |
| 37 | http://-example.com |
拒绝 |
第一步:先匹配两段式域名
example.com 只有一个点号,可以先假设两侧都由英文字母组成,分别用 [a-z]+ 匹配,中间用 \. 读取字面量点号。协议里的 s 与末尾斜杠都按题面设为可选,字母暂时用 i 忽略大小写。
这一步通过 31/41。第 1、2 条的 example.com 与第 4 条的 example.org 都符合两段结构,但第 3 条 www.example.com、第 5 条 example.co.uk 都多了一段。当前表达式把整个域名固定成「字母段、一个点号、字母段」,无法完整读取这些正例。
第二步:用三级域名引出重复分组
example.com 与 www.example.com 的区别在于末段前有几段内容。单独读取最后的 com 后,前者剩下一个 example.,后者剩下 www.、example. 两个。域名中由点号分隔的非空小段叫作 label;两种格式共用的重复单元是「一段字母加后面的点号」。
将这个单元放进 (?:[a-z]+\.)+,让它出现一次或多次,再接最后的字母段。这样一来,二级域名与三级域名只是在分组重复次数上不同,更多层级也能按同一结构继续匹配。
这一步通过 36/41。第 3 条的 www. 和 example. 各匹配一次分组;第 5 条的 example. 和 co. 也是两次,最后的 uk 单独读取。重复的是完整的「段加点号」,不是把点号本身重复多次,因此不会接受 example..com 中的空段。
第三步:允许段中出现数字
接着看第 14 条 http://0test.com/,段的开头可以出现数字。第 7 条以 ip6.arpa 结尾的正例还包含很多只有一个数字的段,说明前面的各段也不能强制包含字母。把它们的字符类从 [a-z] 扩为 [a-z0-9],仍用 + 要求至少一个字符。
分数仍是 36/41:第 7、14 条修好了,第 40、41 条两个超长反例却也被放行了。它们原先因字符类不接受数字而失败,长度限制实际上还没有写。只看总分会漏掉这次变化,仍要逐条比较结果。
第四步:连字符只能出现在段的内部
第 16 条 http://this-test.com 又要求允许连字符。直接把 - 加进整个段的字符类,会同时接受负例 -example.com 与 example-.com,因此还要约束连字符的位置。
一个 label 先由字母或数字开头,再接一个可选整体:中间允许字母、数字和连字符,最后仍由字母或数字结尾。整个 label 后面的点号继续留在外层重复分组中。
单个 label 的可选范围如下。首字符之后的整个分组可有可无,因此 a、0 这样的单字符段也能匹配。分组出现时,内部的 * 可以重复零次,所以两个字符的 ab 也成立。
这一步通过 39/41,连字符相关的三个正例都已匹配,剩下的是两个超长输入。最后一段仍暂按样例中的纯字母形式处理;题库接受 a.b,因此不能擅自要求后缀至少有两个字母,也不需要把常见后缀逐个枚举出来。
第五步:限制每个 label 的长度
第 40 条点号前有 64 位数字,并标注了 too long。只凭「64 不行」还不能推出准确上限。查 DNS(域名系统)的长度规定,单个 label 最多是 63 字节;这里使用 ASCII 字符,可以直接按字符数计算。
前面的首字符占一个位置,可选整体出现时,末字符再占一个位置,所以中间最多还可以放 61 个字符。把 * 改成 {0,61},就得到单字符或 2~63 字符的段。最后的纯字母后缀也补上 {1,63}。
这一步通过 40/41。第 40 条的 64 字符段已经被拒绝;剩下第 41 条,每个数字段都只有一个字符,虽然单段合格,整个域名仍然可能超长。
第六步:再检查整个域名的长度
这里不带尾点的 ASCII 域名,文本长度最多为 253 个字符。限制应包含域名中的点号,不包含协议和可选的末尾斜杠。
在协议后面放一个先行断言,检查接下来是否能由 1~253 个域名字符、可选斜杠和输入结尾组成。字符类 [a-z0-9.-] 不包含斜杠,最后的 / 就不会占用域名的长度额度。断言只检查、不读取,后面的 label 结构仍从域名开头正常匹配。
第 41 条有 127 个数字 0、127 个点号与最后的 com,合计 257 个字符。单段上限无法拒绝它,总长度断言才会让它失败。最终式通过 41/41;补测也确认了单段 63/64、整段 253/254 的长度边界,以及空 label、连字符位置等限制。
数字开头、纯数字 label 和 ip6.arpa 都有协议依据。单段 63 字节、编码 255 字节换算成本文不带尾点的 ASCII 文本上限为 253,RFC 依据与 DNS 编码长度推导弹窗给出计算过程。
最终式的适用范围
这份表达式选择了至少含一个点号、最后一段为纯字母的 ASCII 主机名形式。它接受的是语法形状,不检查后缀是否真实存在、域名是否已经注册或网站是否能访问。41/41 只能证明它满足现有题库。
最后一段的 [a-z]{1,63} 仍是简化条件。例如国际化域名的 ASCII 形式可能以 xn-- 开头,example.xn--p1ai 就会被当前的纯字母后缀拒绝。国际化域名需要按 IDNA 国际化域名规范中的 U-label、A-label 规则处理,长度也应作用于相应的编码形式,不能直接把中文字符数当成字节数。
题库明确拒绝 example.com. 这种末尾点号形式;完整 DNS 名称中的尾点有表示根的意义,实际应用需要明确是否接受。端口、路径、查询参数和 IP 地址字面量也都没有在这份表达式里实现。业务需要检查完整 URL 时,应先解析 URL,再分别验证协议、主机名与业务限制。
第 8 题:给相邻重复的列表项加粗
第 8 题要求处理 Markdown 列表:两条相邻的列表项正文相同时,把第二条正文包在 ** 中,前面的列表标记和空格保留。第 1 条的两个正文不同,保持原样;第 2 条连续出现两次 Repeated list item,第二次需要加粗。第 7 条虽然也有重复正文,但中间隔着其他列表项,因此不处理。
| 原题序号 | 输入 | 预期输出 |
|---|---|---|
| 2 | * Repeated list item↵* Repeated list item |
* Repeated list item↵* **Repeated list item** |
| 1 | * First list item↵* Second list item |
* First list item↵* Second list item |
| 10 | * Case insensitive↵* CASE inSensiTIVE |
* Case insensitive↵* **CASE inSensiTIVE** |
第一步:先匹配相邻的两条正文
先把输入拆成「星号与空格、正文、换行、星号与空格、相同正文」。用 (.*) 捕获第一条正文,再用 \1 匹配第二条里相同的内容。这里的 . 可以读取正文中的空格,默认不会跨过换行。
这份写法只通过 3/10。观察第 2 条的实际输出,两个列表项连在同一行,中间出现了两个普通字符 \n。原因是正则与替换模板走了不同的解释过程:正则中的 \n 会被正则引擎解释为换行,网页替换框中的 \n 却只是普通文本。
题目运行代码直接把输入框的值交给 String.prototype.replace,没有把其中的反斜杠序列转换成换行。在 JavaScript 源码里写字符串字面量时,"\n" 会先经过字符串解析;在网页输入框里敲入同样的两个字符,没有这一步。
第二步:捕获真实换行,再把它放回去
既然原输入里已经有换行,就可以用 (\n) 把它捕获下来。新增的组 2 保存真实换行,替换模板里的 $2 再把这个字符原样放回去。
这一步通过 6/10,换行已经恢复。剩下的失败样例又提供了四条约束:第 6 条有两组重复,需要全局替换;第 8 条的 Not 与 Not repeated 不能只比较共同前缀;第 9 条第二个星号后有两个空格;第 10 条忽略大小写比较,但输出仍保留第二条原本的大小写。
第三步:补齐整行比较、空格与大小写
用 ^、$ 配合 m 把比较约束到完整的列表行,避免把 Not repeated 开头的 Not 当成整个正文。星号后面的空格改用 +,允许一个或多个。再加上 g 处理多处匹配,i 让反向引用忽略大小写。
这里也可以重新安排捕获组:组 1 直接保存「第一条、换行、第二条的星号与空格」,这样 $1 就能一次放回这些内容;组 2 保存第一条正文,用于比较;组 3 用 (\2) 重新捕获第二条实际出现的正文,用于输出。
这份写法通过 10/10。第 10 条尤其容易写错:i 让 Case insensitive 与 CASE inSensiTIVE 比较相等,但不会把捕获结果变成同一种大小写。第二条实际文本保存在组 3,所以替换模板用 $1**$3**,不能直接复制第一条的组 2。
再补一条:连续三个相同的列表项
题库覆盖了两组各自重复两次的输入,还没有覆盖三条连续重复。把三条 * A 放在一起,上一份写法只会给第 2 条加粗,第 3 条保持原样。
这与第 1 题连续重复词的遗漏相同:第一次匹配已经把第 1、2 行都读走,全局搜索从匹配结束的位置继续。要处理第 3 行,却又需要从第 2 行开头匹配一次;这个位置已经过去了。即使替换模板把第 2 行放回输出,也不会改变后续搜索在原输入中的位置。
如果把要求扩展为「每条都与原输入中的前一条比较,相同就加粗」,三条相同正文里的第 2、3 条都应处理。要保留这种相邻关系,就需要让前一条可以再次被检查。
第四步:用后行断言检查前一条,只替换当前条
把第一条和换行放进 (?<=...),即可检查前文而不把它们放进当前匹配范围。这是正向后行断言;负向后行断言则写作 (?<!...)。断言内部仍可使用捕获组,组 1 记住前一条正文,后面的反向引用继续负责比较。
当前匹配只读取第二条:星号、组 2 保存的空格,以及组 3 保存的当前正文。模板写成 *$2**$3**,把标记、空格与原文重新组合。
如果模板仍写成 *$2**$1**,题库会通过 9/10:比较没有问题,但第 10 条会把第二条替换成第一条的大小写。改用 $3 后恢复 10/10,连续三条或更多条相同的输入也能逐条处理。
第二条已经生成的加粗标记,不会干扰第三条的比较。一次 replace 仍然在原输入中寻找各次匹配,输出另行组合;因此检查第三条时,断言看到的第二条仍然是 * A。后行断言文档与 replace 文档分别说明了这两种行为。
两份写法都使用捕获组与反向引用。区别在于前者一次读取两行,后者只匹配当前行,让前一行可以继续参与下一次比较。按行扫描或按整段重复项处理,也能实现这种连续比较。
最终式的输入范围仍要明确
连续重复、非相邻项、中文正文、大小写与末尾换行等补测通过。这份写法仍按简单的单行星号列表编写,依赖 LF 换行,以及前一条标记后恰好一个空格。
第 9 条只测试了当前条标记后的两个空格,不能据此认为任意一条前面都可以多写空格。当前条的 [ ]+ 会保存这些空格,但断言里的 \* 只读取一个空格:如果前一条也有两个,第二个空格就会进入组 1,比较对象随之改变。若要把所有前缀空格排除在正文之外,两侧都需要按这个约定重新划分。
同样,\n 只表示 LF。要兼容 CRLF,可以把行间部分扩成 \r?\n,并核对行尾匹配是否会把 \r 包进当前正文之外。缩进、其他列表标记、已有强调、跨行正文与嵌套列表,则还需要更多语法规则;完整 Markdown 的列表结构可参考 CommonMark 的列表项定义,处理完整文档时适合交给 Markdown 解析器。
第 9 题:把 Markdown 链接替换成 HTML
第 9 题要求把 [文字](地址) 转成 <a href="地址">文字</a>,图片  保持原样。这题的主体可以直接拆成方括号内的文字和圆括号内的 URL,再用捕获组放进 HTML 模板。
| 原题序号 | 输入 | 预期输出 |
|---|---|---|
| 1 | [Basic link](http://example.com) |
<a href="http://example.com">Basic link</a> |
| 3 | Link: [lynx.io](http://lynx.io/) |
Link: <a href="http://lynx.io/">lynx.io</a> |
| 13 |  |
 |
| 15 | l[Invalid MarkDown](http://example.com) |
l[Invalid MarkDown](http://example.com) |
第一步:先看原式实际替换了什么
先用两组记住文字和 URL,再用两侧的否定字符类排除图片与不合要求的相邻字符。这条思路已经找到了链接的主体结构,但 (?:[^!]) 和 (?:[^!\[]) 都会实际读取一个字符:(?:...) 只是不记录捕获结果,没有把其中的匹配变成断言。
原式通过 10/17,七条正例都没有得到预期结果。句首的链接没有前置字符,句尾的链接没有后置字符,因此无法匹配;l [l](http://TESTdomain.com) l 虽然匹配到了,左右两个空格却也进入了匹配范围,替换后变成 l<a href="http://TESTdomain.com">l</a>l。
这里应区分「链接本身」和「链接允许出现的位置」。左右字符用于约束位置时,要么只检查而不读取,要么捕获后在替换结果里放回去。
第二步:把句首、句尾放进边界分组
题库最后几条拒绝链接紧邻普通文字,以及额外的开方括号。按这套边界要求,可以把左侧写成 (^|\s),右侧写成 (\s|$):允许句首或空白后开始,允许在空白或句尾前结束。
^ 和 $ 仍然表示输入的起点与终点,只是现在分别与空白组成候选条件。如果直接把它们写在整个链接外面,就会要求整段输入只能是一条链接,Link: [lynx.io](http://lynx.io/) 这样的正例又会被排除。
同时,把文字部分收紧到不包含方括号与换行,避免把 [[Invalid MarkDown] 中的额外开括号吞进捕获内容;URL 暂时要求 http:// 或 https:// 开头,并排除题库涉及的下划线、逗号,以及空白和括号。
新增左侧捕获组后,原来的编号后移:组 1 是左空白或句首的空串,组 2 是链接文字,组 3 是 URL,组 4 是右空白或句尾的空串。替换模板相应写成 $1<a href="$3">$2</a>$4,把读走的两侧空白放回去。
这份写法通过 17/17。加上 g 是为了处理同一输入中的多处链接,i 允许协议名忽略大小写。图片前面的 ! 不满足左侧的句首或空白条件,因此保持原样。
第三步:把右侧改成只检查位置
再补一条同一行有两个链接的输入:[a](http://a.com) [b](http://b.com)。虽然上一份写法带有 g,第二个链接仍然没有转换。
原因是第一个匹配已经消耗了中间的空格。即使 $4 把空格放回输出,全局匹配继续检查的仍是原输入,并从上一次匹配结束的位置向后搜索;替换结果里的空格不会成为下一次匹配的输入。第二个链接前面已经没有尚未读取的空白,左侧分组因此无法成立。
只需把右侧改成 (?=\s|$),确认后面是空白或结尾,不把那个空白读进当前匹配。这样它就能留给第二个链接的左侧分组使用。
最终替换内容是 $1<a href="$3">$2</a>。左侧仍由组 1 捕获后放回,文字和地址仍分别是组 2、组 3;右侧已经没有捕获组 4,也就不再需要 $4。
最终式保持 17/17,多个链接共享空白、Tab 与换行,以及图片与链接混排等补测也通过。
题库边界与 Markdown 标准要分开看
两侧必须是空白或文本边界,是为了符合这份题库。按 CommonMark 的链接规则,链接可以紧邻普通文字,因此 l[文字](http://example.com) 和 [文字](http://example.com)l 都可以正常包含链接。题库把它们原样保留,不能据此把这种边界要求写成通用 Markdown 规则。
URL 部分也仍是简化筛选。域名中出现下划线或逗号与路径中出现这些字符是不同的问题;当前字符类把它们在整段地址中一并排除了,会漏掉 https://example.com/a_b 这样的地址,同时也没有验证主机名、端口等结构。Markdown 链接目标的语法与允许哪些协议,也应分别判断。
完整 Markdown 还允许转义、链接文字中的配对方括号、目标地址中的配对圆括号、标题与相对地址。直接把捕获内容插进 HTML 模板,也没有完成文字和属性值的 HTML 转义。因此,这份结果用于理解题目的替换机制;实际处理完整文档时,应使用 Markdown 解析器,并按应用要求设置链接协议与 HTML 输出策略。
第 10 题:按引号、撇号与连字符提取关键词
第 10 题要把句子整理成用逗号连接的 token,供搜索一类场景使用。这里的 token 是一段独立关键词:普通单词各占一段,引号包住的多个词则合成一段。
| 原题序号 | 输入 | 预期输出 |
|---|---|---|
| 1 | This is a test |
This,is,a,test |
| 3 | This "big test" is a test |
This,big test,is,a,test |
| 10 | This-----is-test |
This,is-test |
| 11 | Don't say anything |
Don't,say,anything |
前两个样例都是普通空白分隔;随后出现的引号、Smith-Hopper、don't 和分号,逐步增加了切分条件。先顺着这些差异完善正则,再检查题库没有覆盖的输入。
第一步:把词间空白替换成逗号
最初只需要把 This is a test 变成 This,is,a,test,用 \s+ 匹配连续空白、全局替换成逗号即可。
这份写法通过 8/17。don't 和 Smith-Hopper 没有被匹配,恰好能够原样留下;但 is--test 中的连字符没有成为分隔符,long; 后面的分号也仍然存在。
第二步:处理标点与连续连字符
把空白旁边的分号、单引号、双引号一同消耗,再加一个处理连续连字符的分支。省去不用于替换结果的捕获括号后,当前写法如下:
这样通过 14/17,只剩三条包含引号词组的用例。This "big test" is a test 被处理成 This,big,test,is,a,test,内部空格也变成了逗号。两侧的可选引号只是在空白附近顺带删除字符,没有表达「这是一对引号,里面的空白需要保留」。
末尾的 'big test' 还会留下最后一个单引号:它不紧邻空白,空白分支无法把它匹配进去。继续扩大字符类,也无法同时解决引号的配对与词组边界。
先判断空白位于引号里面还是外面
先单独处理双引号。假设它们成对出现,且没有转义双引号,从任意一个空白向右数引号:偶数个说明当前位置在引号外,奇数个说明在引号内。
[^"]* 读取任意数量的非双引号字符,空格也在这个范围内。每一段 [^"]*"[^"]*" 读取两个双引号,外层 * 允许任意多对,末尾 [^"]*$ 确认剩余部分不再出现双引号。因此,右边有 0、2、4、6 个引号时都可以通过,不依赖固定的词组数量。
这段条件放进 (?=...),就只检查右侧,不把右侧文本加入当前匹配。结果是 This,"big test",is,a,test:内部空格得到保护,引号还在。这一步只验证空白位置,暂时没有合并删除引号、处理连字符等条件。
单引号还不能照搬这个计数方法。don't 和 Daniel's 的撇号属于单词内容,不能把它们都视为词组边界。完整作答时,改用整体读取引号词组的方式,把内容捕获后放回输出。
第三步:整体读取引号词组,保留内容
新增的词组分支放在最前面,优先匹配「前面的分隔空白、开引号、词组内容、同类闭引号」。原来的普通分隔与连续连字符分支保留。
组 1 保存引号类型,\1 要求闭合时使用同一种引号;组 2 保存词组内容。.*? 以惰性方式寻找闭引号,(?!\w) 则要求引号后面不能紧接单词字符,避免把 can't 内部、后面还跟着 t 的撇号当作闭引号。
替换内容改成 ,$2。词组分支里的组 2 是 big test,因此匹配片段 ␣"big test" 变成 ,big test,两侧引号被删除。引擎一次读取完整词组,后面的空白分支不会再进入它的内部。
普通分隔分支中,组 2 没有参与匹配,JavaScript 的固定替换模板会把 $2 展开为空字符串,只留下逗号。这让同一份替换模板在不同分支中产生不同输出;具体语义见 replace() 的替换字符串规则。
这份写法通过 17/17。偶数引号断言帮助建立了内外判断的直觉;最终式通过整体消费词组保护内部空白,所以不再需要把该断言叠进去。
全部通过后,哪些规则仍未覆盖
现有词组分支要求前面有空白,句首直接出现引号就匹配不到。普通分支只删除空白附近的一小部分标点;单个连字符仅被原样保留,还没有判断它是否处在词首或词尾。
这些反例分别对应题面的引号、标点清理与连字符规则。首尾空白还会生成多余逗号;多个标点连续出现、逗号或句号紧邻单词、空引号,以及引号中的 Tab 和换行,也需要补测。.*? 不会跨越换行,惰性量词本身也不代表已经解决了长文本的执行成本。
转义、未闭合引号、同类引号嵌套和自然语言中的所有格还存在文法问题。例如 'boys' toys' 里的第二个单引号,可以被理解为所有格符号,也可以是前面词组的结束。题面没有约定这种输入的解析方式,测试标签也无法替作者补出唯一含义。
完整题面需要调整替换方式
一次固定替换模板无法同时处理 a b → a,b 与 "big test" → big test:前者必须从模板新增逗号,后者要删除引号却不能生成逗号。增加捕获组无法让模板中的字面量逗号按分支消失。
实际实现可以用 replace 回调、分步骤处理,或先生成 token 数组再 join(',')。限制证明与完整分词源码弹窗给出了后一种实现,覆盖原题 17/17、33 条手工边界用例和 216 组组合。转义、未闭合引号与标点政策仍需先约定,不能用题库通过率代替完整文法。
第 11 题:匹配严格按字母表顺序排列的字母
第 11 题要求忽略空白,判断字母是否按字母表顺序排列。正例 abdfkmnpstvxz 说明字母可以跳过;反例 abbc 则补充了一个条件:不能重复。因此,这里的顺序是严格递增。
| 原题序号 | 输入 | 题库预期 |
|---|---|---|
| 1 | abc |
接受 |
| 3 | abdfkmnpstvxz |
接受 |
| 9 | a z |
接受 |
| 16 | abbc |
拒绝 |
| 20 | zyxcba |
拒绝 |
第一步:把顺序写进可选字母的位置
如果只考虑 a、b、c,依次写出 a?b?c? 就能表达这个规则。每个字母都可有可无,字母位置的先后顺序固定:ac 可以跳过 b,ca 却无法在读取 c 后回到前面的 a。把这个结构扩充到 z,再限制整个输入都符合条件:
这份写法通过 18/26,剩下的 8 条都是带空格的正例。它只列出了 26 个有顺序的位置,每个 ? 表达一次选择;无须把所有可能的字母组合逐个写成分支。
这里不能用 [a-z]+ 代替:字符类只限制单个字符的取值范围,不会约束连续字符之间的顺序。也不能把每个字母后的 ? 改成 *,否则同一个字母就可以重复出现,abbc 会被错误接受。
第二步:在字母之间允许空白
题目要求忽略空白,因此在开头及每个字母后面加入 \s*。它可以读取零个或多个空白,既处理 a z,也处理 v z 中的连续空格;字母的先后顺序仍由原来的位置决定。
这份完整写法通过 26/26。a z a 仍然拒绝:空白可以忽略,但最后一个 a 已经无法回到第一个字母的位置。
通关后的范围与边界
题库的字母都来自小写 a-z,这份答案也按这个范围判断,不接受大写、数字或其他文字。\s 还包括 Tab、换行等空白;题库只用了普通空格,补测确认这些字符也能被忽略。
所有字母都可选,因此空串、纯空白也会被接受,题库没有规定这两种输入的结果。如果实际需求至少要有一个字母,可以在 ^ 后增加 (?=\s*[a-z]),先确认略过开头空白后确实存在小写字母。
题面还要求尽量缩短表达式。这里给出的是便于解释和核对的写法,没有证明它最短。
第 12 题:整理词间空白与句号后的间距
第 12 题是一道替换题:把词间的空格、制表符整理成一个普通空格;如果前面是句号,就整理成两个普通空格;换行保持原样。这里的 full stops 指句号,before 描述处理前的状态,都不是在限制「句子前面的空白」。
| 原题序号 | 输入 | 预期输出 |
|---|---|---|
| 2 | Extra␣␣spaces |
Extra spaces |
| 4 | Tab⇥here |
Tab here |
| 9 | Sentence. Sentence. |
Sentence.␣␣Sentence. |
| 1 | This is already a valid sentence. |
This is already a valid sentence. |
普通词间与句号后需要不同数量的空格,换行则不参与替换。
第一步:把连续空白缩成一个字符
观察有多个空格的例子,先想到「匹配至少两个空白,替换成其中一个」。于是用 \s 匹配空白,用捕获组记住它,再让这组重复至少两次,替换内容填写 $1。
原式通过 4/12。没有 g 时,每条输入只替换第一处,因此 Multiple extra spaces 后面的连续空格还会留下。补上全局标志后是 5/12,但问题还没有解决。
首先,{2,} 不会匹配单个 Tab,而 Tab⇥here 也需要改成普通空格。其次,(\s){2,} 重复的是捕获组,组里最终保存最后一次捕获的字符。两个 Tab 被替换后仍是一个 Tab;混合空白的结果也取决于最后一个字符。把括号改成 (\s{2,}) 同样不行,那会捕获整段空白,$1 又把它原样放回。
这一步还把范围选得太宽了:\s 包括换行符。如果把连续换行也压成一个字符,就违背了保留换行的要求。
第二步:只匹配空格和 Tab,输出固定空格
题目已经明确需要处理哪两种字符,就直接使用 [ \t]。数量改成 +,使单个 Tab 也能进入匹配。替换内容不再引用输入,而是固定填写一个普通空格。
这样通过 9/12。普通空白都能整理好,1.2 中间没有空白,也不会受影响。剩下三条都包含句号后的间距:这里需要两个空格,当前写法却统一变成了一个。
第三步:跳过句号后面的第一个空格
一种尝试是在前面增加否定后行断言 (?<!\.):只有当前位置左侧不是点号,才开始匹配空白。这样通过 11/12,看起来只差一点。
但这条断言只检查每个候选起点左边的那个字符。对于 .␣␣␣,引擎从第一个空格开始尝试时失败,还会继续尝试第二个空格。第二个空格左侧是空格,断言成立,于是剩下两个空格被替换成一个。加上未被匹配的第一个空格,结果恰好是两个。
Sentence. Sentence. 却不同:第一个句号后面只有一个空格,它被断言排除后,已经没有剩余空白可供替换,结果仍然只有一个空格。这也是唯一没通过的用例。需要调整的是第一个句号后面的间距;末尾的句号没有后续空白,不需要处理。
因此,这份写法只是保住了第一个空格,还没有完成「不足两个时补齐」的要求。
第四步:让捕获组携带需要保留的内容
处理句号后的空白时,先保留「句号和一个空格」,再追加一个固定空格;处理普通空白时,只输出那个固定空格。两种输出共用同一份替换模板,差别由捕获组提供。
第一个分支用 (\. ) 把句号和一个普通空格一起捕获到组 1,后面的 [ \t]* 消耗剩余空白。这里必须用 *,因为句号后只有一个空格时,剩余部分可以为空。第二个分支 [ \t]+ 处理普通空白。
替换框填写 $1 后紧跟一个普通空格。不要漏掉末尾的空格,也不要把图中的空格标记直接填进去。
句号分支里的 $1 是 .␣,再加固定的一格,得到 .␣␣。普通分支中,组 1 没有参与本次匹配,JavaScript 会把替换内容里的 $1 解释为空字符串,于是只剩固定的一格。这与引用一个根本不存在的捕获号是两回事。
这份正则和替换内容通过 12/12,也不需要后行断言。原来的多个空格或 Tab 被整个匹配消耗,只有捕获内容和固定替换文本进入输出。
通关后,再检查换行与题面提示
题面额外保证:句号后面不会紧接 Tab。因此,上面的 (\. ) 可以要求句号后先有一个普通空格。第 11 条里的 .␣⇥ 并不违反这条提示:紧接句号的是空格,Tab 排在它后面,由 [ \t]* 消耗。
取消这个保证后,A.⇥B 就会暴露局限:句号分支匹配不到,普通分支只把 Tab 换成一个空格。要让句号后直接出现 Tab 也能得到两格,可以进一步改成下面这样,替换内容仍是 $1 加一个普通空格。
这次组 1 只捕获句号,先行断言 (?=[ \t]) 确认后面存在空格或 Tab,但不读取它。第一次匹配把句号替换成 .␣;接着,全局搜索继续处理原输入里紧随其后的空白,由普通分支替换成 ␣。两次输出拼起来,正好得到 .␣␣。
这里没有重新扫描刚插入的空格。replace() 的匹配基于原输入,替换结果只是依次拼接;否则第一步刚补出的空格又会被压缩,推导也就不成立了。
扩展写法同样通过 12/12,句号后直接接 Tab、空格与 Tab 混排、小数等补测也通过。生成输入与独立逐段替换算法的结果一致,重复执行也不再改变输出。
原题的 12 条用例其实都没有真实换行,不能仅凭全部通过就断定换行要求得到验证。这两份最终写法只消耗普通空格与 Tab,不消耗 \r、\n,补测也确认了连续换行和 CRLF 保持原样。
它们实现的是字符规则:点号后面已有水平空白,就整理成两格;其他水平空白整理成一格。它们不会识别 Dr. Smith 里的缩写,也不会给 A.B 凭空添加分隔符。题面没有规定清除首尾空白,因此这里也没有增加 trim;末尾句号后没有空白时,保持原样。其他 Unicode 空白是否需要处理,同样需要单独约定。
第 13 题:找出换行后上下对齐的重复词
第 13 题要求找到这样的文本:自动换行后,相邻两行的同一列出现了同一个单词。只要任意一对相邻行满足条件,就应该匹配。
| 原题序号 | 输入 | 题库预期 |
|---|---|---|
| 1 | This sentence is pretty long and this sentence is also a test |
接受 |
| 5 | Hello world, this exclamation of surprise has this by surprise |
接受 |
| 6 | Hello world, this exclamation of surprise is this by surprise |
拒绝 |
| 3 | This sentence shouldn't match |
拒绝 |
这题先难在读懂页面。前两个样例都标着 match,但有些字体下,第二行直接从 sentence 开始,它与上一行的 sentence 明显没有对齐。按这样的显示结果理解题目,确实会产生矛盾。
先固定换行规则,再读测试用例
页面的样式把用例文本区域的宽度设置为 260px,字体使用通用的 monospace,没有固定字符列数。不同字体的字符宽度会改变换行位置;右侧 match 和 no match 则是测试源码预先写好的标签,不会根据当前排版重新计算。
把文本按每行最多 32 个等宽字符、放不下的整个词移到下一行重排,就能解释全部 8 个标签。这是根据用例反推的一套自洽约定,题面没有明确写出 32。图中的 ␣ 仍然只表示空格,实际正则使用普通空格。
第一条的 This sentence is pretty long and 正好占 32 列,末尾的 this 必须移到下一行。第二条的第一行只有 30 列,但再加上一个空格与 this 就会超过 32,也要换行。于是两条的第二行都以 this sentence is 开头,sentence 从第 6 列开始,is 从第 15 列开始。
行宽是上限,不要求每行都恰好占满。标点和空格也占列数;判断对齐时比较单词的起始列。
第一步:先找到重复的完整单词
最容易想到的做法,是用捕获组记住一个单词,再用反向引用寻找下一次出现。这里采用 JavaScript 的 \b\w+\b 作为单词边界,先保持大小写敏感。
这份写法已经通过 7/8,只有第 6 条误接受。它证明了两个地方出现相同单词,却没有判断它们是否在相邻行的同一列。cat cat 也会被接受,尽管这两个词都还在第一行。
第 5、6 条恰好只差一个字符,适合检查缺少的条件:
has 比 is 长一个字符,使第 5 条下行的 this 恰好从第 14 列开始。第 6 条下行的 this 从第 13 列开始,与上行错开,因此应该拒绝。增加 .* 的长度限制无法代替真实的换行和列号判断。
第二步:确定一行在哪里结束
原始输入里没有换行符。测试脚本直接把单行字符串交给 regex.test(),浏览器显示出来的折行不会变成 \n。因此,正则必须自己模拟 32 列的整词换行。
从一行开头出发,在前 32 个字符内寻找后面跟着空格或文本末尾的最长前缀,就得到这一行的内容。先把这部分单独写出来:
.{1,32} 贪婪地尝试最多 32 个字符,后面的空格或 $ 把结束位置限制在排版片段的边界。正向先行断言 (?=...) 只查看内容,不前进;组 1 记住结果后,外面的 \1 才按这个结果读取整行,再处理行间空格或文本末尾。
把捕获放进先行断言还有一个作用。JavaScript 不会回溯进入已经成功的先行断言,因此后面匹配失败时,不能擅自把这一行缩短到更早的空格。允许这种缩短,就会制造出不符合整词换行规则的假行。
这一步只解决分行,还没有判断重复词。这里先限定输入与题库类似:单行 ASCII 文本,各非空白片段之间只有一个普通空格,没有首尾空格,每个片段不超过 32 个字符。连续空格、制表符和超长片段的排版规则留到最后讨论。
第三步:先把第 14 列的判断写完整
以第 5 条为例,第 14 列前面恰好有 13 个字符。先检查相邻两行的这一列是否出现同一个完整单词,再扩展到其他列。
最前面的重复部分用组 1 逐行前进。外层 *? 先跳过零行,失败后再多跳一行,依次检查第 1、2 行,第 2、3 行,以此类推。每一行内部仍按上一节的方法贪婪确定长度,不能随意改变断行位置。
到达候选上行后,组 2 记住这一行。第一个先行断言从行首经过 13 个字符,在第 14 列把单词捕获到组 3;随后,\2 和一个空格读取上行及其分隔符,直接到达下一行开头。再经过 13 个字符,用 \3 检查同一个完整单词。
两处 (?=\S{1,19}(?: |$)) 用来确认当前位置所在的非空白片段能够在本行结束。前面已经占了 13 列,只剩 19 列;如果片段放不下,整段本来就应该移到下一行,不能把其中的字母当成还留在当前列的单词。
这里要区分两种对象。换行按空格分隔的完整片段进行,标点也跟着片段一起移动;相等判断则比较 \w+ 单词。例如 "No!", 是一个排版片段,里面比较的单词是 No。只检查 No 能否放下、忽略后面标点的占位,会漏掉边界情况。
这份局部写法能正确区分第 5、6 条,但它只检查第 14 列,还不能作为整题答案。它在全部样例上是 4/8,其余正例需要扩展到别的起始列。
第四步:枚举 32 个可能的起始列
同一个单词可以从第 1 到第 32 列开始。把上一节的 13 换成 c,19 换成 32 - c,让 c 从 0 到 31,最后用 | 连接 32 个候选,就覆盖了全部位置。
展开后的表达式有 2,314 个字符,手写容易弄错捕获号。下面给出完整生成过程;运行后,String(answer) 就是能直接粘贴到原题输入框的正则,不需要先修改测试输入。
前面两组已经分别用于逐行前进和记住上行,所以第 c 个候选里的单词捕获号是 c + 3。单独演示第 14 列时可以用 \3;放进 32 个候选后,这一列对应的单词捕获号变成 16。每个分支引用自己的捕获组,不能在所有分支里都写 \3。
fits 是当前位置到排版片段末尾的检查。first 在上行同一列捕获单词,second 用组 2 到达下行,再检查同列的同一个词。String.raw 用于保留这些字符串里的反斜杠;拼接出来的反向引用仍是正则中的 \3、\4 等写法。
可在32 个起始列的完整展开式弹窗中对照全部分支。
这份写法通过 8/8。前两条分别在第 6、15 列找到相同单词,第 5 条在第 14 列找到 this;第 7、8 条还覆盖了后续行和标点:
第 7 条的重复词在第 2、3 行,因此前面的逐行扫描不能省略。第 8 条两个 No 都从第 2 列开始,前面的引号各占一列。第 3 条只有一行,第 4 条没有相邻行同列的重复词,因此都拒绝。
最终式从输入开头计算行边界,但末尾没有 $:找到一对满足条件的单词就已经足够,后面仍然可以有其他文本。这里判断的是「存在这样的相邻行」,不能要求命中的词恰好处于整个输入末尾。
32 列是这份构造解的前提
这份答案按固定 32 列模型分行,不读取浏览器字体或实际像素换行;单空格、单词边界和片段长度也属于前提。除原题 8 条外,独立分行算法还核对了 18,000 组生成输入和 70 条补充输入。补测与构造解的局限弹窗保留细节;这没有证明枚举是唯一方法或表达式最短,实际业务通常更适合直接比较分行后的单词位置。
第 14 题:枚举前 50 个元素符号
第 14 题要求匹配原子序数不大于 50 的元素符号,也就是从 H(1)到 Sn(50)。原子序数是元素在周期表中的编号,等于原子核内的质子数。先按元素周期表确定名单,再把 50 个候选用 | 连接即可。
| 原题序号 | 输入 | 题库预期 |
|---|---|---|
| 1 | H |
接受 |
| 50 | Sn |
接受 |
| 51 | A |
拒绝 |
| 65 | I |
拒绝 |
下面这份枚举通过 101/101。大小写保持严格,整个输入只能是一个元素符号,因此 Al、Sn 接受,al、AL、H2、HHe 拒绝;Sb、Te、I 虽然也是元素符号,原子序数超出范围,也应拒绝。
若继续按 regex golf 的目标压缩,可以合并相同前缀,例如把 H|He 写成 He?,把 B|Be|Br 写成 B[er]?。枚举方便核对接受集合,每次合并也要保持这个集合不变。
题库的 50 条正例只覆盖了 49 种元素:Ni 出现两次,漏掉了 Al。这份枚举已经包含 Al,补测也能通过。名单仍应来自元素表,不能因为正例数量刚好是 50,就认为所有符号都被覆盖了。
第 15 题:匹配和弦记号
第 15 题要匹配的是音乐里的和弦记号,例如 Cmin、Bmaj。记号以根音开头,根音是构成和弦时作为基准的音;后缀说明和弦类型。可以先观察它们的字符结构,逐步整理规则;遇到看似能组合、题库却拒绝的后缀,再核对这些标记各自的含义。
| 原题序号 | 输入 | 题库预期 |
|---|---|---|
| 1 | C |
接受 |
| 5 | B♭ |
接受 |
| 45 | Fø7 |
接受 |
| 52 | E♯ |
拒绝 |
| 88 | Fø6 |
拒绝 |
从音名开始,逐步限制组合
音名决定前半段的字符范围,后缀的合法组合还需要用负例检验。
第一步:一个音名,后面可能有升降号
最前面的正例是 C、E、G、A、B♭、F♯、D。先不考虑后面的和弦种类,只提取这两段:一个 A 到 G 的大写字母,后面可以跟一个 ♯ 或 ♭。
这一版是 46/89。往后看,Cmaj、Dmin、Em 这样的正例开始失败,说明音名后还需要容纳其他内容。
第二步:把观察到的后缀都放进去
继续观察,后面会出现 +、°、ø,以及 M、m、min、maj、aug、dim、dom,末尾还可能有 6、7。先把这些部分都设成可选,就得到下面的尝试。这里不需要提取捕获结果,所以用非捕获分组;接受的字符串与把每段都包进捕获括号的写法相同。
这一版得到 71/89,全部正例都能接受,剩下的失败都是误接受负例。它们分成两类:E♯、F♭aug 等来自升降号限制,Cdom6、Ddom6、Fø6 则来自后缀组合。
后一个问题尤其容易被一串问号掩盖:? 只表示「这一段可有可无」,不会自动限制另一段。于是 +、°、ø 和文字后缀可以叠在一起,C+°ømaj6 这样的字符串也会通过。虽然题库没测这条,当前写法已经把这个组合放进了接受范围。
第三步:升号和降号分别限制音名
题面明确作了简化:本题不接受 E♯、B♯、F♭、C♭。按升降号分三组,就能表达根音的范围:
- 自然音:
[A-G]。 - 带升号:
[ACDFG]♯,排除B、E。 - 带降号:
[ABDEG]♭,排除C、F。
这一步得到 86/89,只剩 Cdom6、Ddom6、Fø6 三条误接受。
如果把升降音只列成 A♯、B♭、C♯、E♭、F♯、G♭,也能得到这个分数,但会漏掉题目允许、题库没测到的 A♭、D♭、D♯、G♯。字符类保留了整组音名的规则,也更容易检查是否漏项。
第四步:后缀按类型选择
剩下三个反例说明,数字必须与前面的和弦类型一起判断。dom7 是属七和弦的一种写法;ø 表示半减七和弦,题库同时接受 ø、ø7。它们都不能照搬末尾任意可选的 [67]?。
先按本题涉及的常见记号,把后缀整理为三类:
- 大、小和弦标记
M、maj、m、min可以省略,后面的6、7也可以省略,对应(?:M|maj|m|min)?[67]?。 - 增、减、半减标记在这里采用
+、aug、°、dim、ø,末尾只保留可选的7,对应(?:\+|aug|°|dim|ø)7?。它们共享字符结构,音乐含义仍有区别。 - 显式写
dom时,直接与7组成一个候选:dom7。
三类后缀互相选择,前面的根音只写一次。完整结果如下:
现在是 89/89。变化来自组合关系:原来是多个标记独立可选,现在是选择一类后缀,再使用这一类允许的数字。
通关式与真实和弦记号的范围
题面排除 E♯、B♯、F♭、C♭,真实记谱仍会使用这些根音;小大七、增大七与半减七的等价写法,也不能只靠题库样例补齐。类型标记作用的位置不同,例如 Cmmaj7 的 m 决定三音,maj7 决定七音,可以组合。
乐理示意与和弦文法弹窗解释各后缀,并给出覆盖 21 种根音 × 36 种后缀的表达式。它在原题中是 74/89,15 条差异来自题面排除的四种根音拼写;仍未覆盖 9、sus、add、斜杠低音等记法,拒绝这些输入不能据此判定音乐上的合法性。
第 16 题:枚举元素符号
第 16 题接着第 14 题的元素符号练习,题面要求匹配原子序数大于 50 的元素。符号与原子序数之间需要查表,不能从字母形状推出数值;直接列出名单,再用 | 连接,是合适的解法。
| 原题序号 | 输入 | 题库预期 |
|---|---|---|
| 1 | Sn |
接受 |
| 2 | Sb |
接受 |
| 55 | A |
拒绝 |
| 60 | Bh |
拒绝 |
枚举没有问题,先核对三个失败
从 Sn 一直列到 Og,得到的是当前元素表中 50–118 号的 69 个符号。这份枚举在题库里得到 132/135,失败的三条都是被错误接受的负例:
| 输入 | 原子序数 | 题库标签 |
|---|---|---|
Bh |
107 | 拒绝 |
Rf |
104 | 拒绝 |
Ts |
117 | 拒绝 |
它们都是实际存在的元素符号,而且原子序数确实大于 50;这里的矛盾来自题库标签。再把全部正例列出来,名单恰好是从 Sn(50)到 Lr(103),共 54 个符号。
先按这份正例名单完成题目:保留从 Sn 到 Lr 的所有候选,删除 Lr 后面的分支,就能通过 135/135。它匹配的是完整符号,大小写也保持严格,不添加 i 标志。
题库名单与元素范围要分开
题库标签与题面有两处冲突。第一,Sn 的原子序数是 50,按题面「大于 50」本应排除,却被列为正例。第二,正例只列到 103,题面没有给出这个上限,却又把 Rf、Bh、Ts 列为负例。因此,上面的通关式是在复现题库名单,不能代表题面条件的完整答案。
也不能把三个失败统一归因于元素表更新:Rf、Bh 已出现在1997 年的 IUPAC 命名建议中。按现在的元素符号判断,可以对照 IUPAC 元素周期表及其完整表格。
如果严格按当前元素表执行「原子序数大于 50」,应从 Sb(51)开始,一直枚举到 Og(118),共 68 个符号。也就是在最初的 69 个候选里只去掉 Sn,保留 Rf、Bh、Ts 等后续元素。
这份表达式与题库有四条标签冲突:它拒绝 Sn,接受 Bh、Rf、Ts。这些差异符合所选的元素范围,不需要为了通关再次改回去。题库没有测试其他 104–118 号符号,也不能据此认定那些元素不存在。
这类有限名单题,先把数据来源和筛选条件确定好,再考虑正则压缩。若要继续做 regex golf,可以合并相同前缀,例如把 I|U|W 合成 [IUW];压缩必须保持接受集合不变。名单本身有出入时,再短的表达式也不会修正筛选条件。
第 17 题:用正则检查正则
第 17 题的输入本身也是正则,例如 /regexp?/gi。题目只要求支持普通字符、字符类、点号、?、*、+,以及 g、i 标志;分组等更多语法不在这一题的要求里。
| 原题序号 | 输入 | 题库预期 |
|---|---|---|
| 1 | /regex/ |
接受 |
| 7 | /a[bc]+/ |
接受 |
| 2 | /regexp?/ |
接受 |
| 28 | regex |
拒绝 |
| 31 | /*/ |
拒绝 |
这里有两层含义:输入里的 ? 可以修饰前面的 p;校验表达式则需要先识别出这个问号。例如 \? 用来读取输入里的一个问号,后面再加一个 ?,才表示这个问号可有可无。推导时始终区分「正在读取什么字符」和「用什么正则读取它」。
从最前面的样例逐步扩充
先固定两侧斜杠,把正文拆成可重复的单元;后续再扩充每个单元与它能接的量词。末尾的标志独立处理,避免把正文符号与控制匹配方式的字母混在一起。
第一步:两条斜杠之间有一段字符
第一条正例是 /regex/。先把两侧的斜杠看成固定分隔符,中间暂时猜测为非空单词字符,用 \w+ 表示。它额外接受数字和下划线,这时还只是一个起点,不能据此认为正则正文只能由这些字符组成。
第一轮得到 17/43,其中包含正确拒绝的反例。没有两侧斜杠的 regex、regex?,以及中间为空的 // 都被拒绝;后面的量词、字符类和转义还没有处理。
第二步:问号可以跟在某个字符后面
第二条正例 /regexp?/ 多了一个问号。最直接的补法是在整个单词后面允许 ?,但后面还有 /r?e?x*/,说明量词可以跟在中间的字符后面。
因此把重复单位改成「一个单词字符,加一个可选问号」,再让整个单位重复一次或多次。这里读取问号的部分是 \??:\? 读取字面问号,最后的 ? 控制它可省略。
这一步是 18/43。中间的字符已经可以各自接问号,但带标志的版本仍然失败。
第三步:结束斜杠后面可以带标志
紧接着的两条正例分别是 /regexp?/g 和 /regexp?/gi。结束斜杠后再接 [gi]*,先允许这两个字母组成任意长度的结尾。
结果是 20/43。此时还没有反例约束标志能不能重复,gg 也会被接受;先记下这个尚未验证的假设。
第四步:每个字符可以接一种量词
/ab?c+/i 和 /ab*c+/g 又出现了 +、*。把原来可选的问号换成 [?*+]?,表示某个字符后面可以没有量词,也可以跟三种量词中的一种。
现在是 23/43。这组结构也能拒绝开头直接出现量词的 /*/、/+/、/?/,以及 /regex?+/、/regex*+/:量词之前必须有一个可读取的单元,当前也不允许两个量词直接连在一起。
这里保持一次读取一个字符即可。如果改成 \w+ 后再整体重复,连续字母会有多种分组方式,却没有增加当前需要的能力。按单个字符确定量词的作用对象,后面加入新候选时也更容易检查。
第五步:字符类整体也是一个单元
接下来是 /a[bc]+/、/a?[bc]?/i、/[bc]*/。方括号里面的部分需要整体读取,而且它和普通字符一样,后面都可以接量词。
把原来的单词字符位置改成两个候选:字符类,或者不承担分隔、括号、量词职责的单个字符。字符类暂时用 \[[^\]]*\] 读取,从 [ 开始,允许零个或多个非 ] 字符,再由 ] 结束。普通字符也不再局限于 \w,点号等字符可以进入这一分支。
/[**]*/ 很适合检查分组位置:[**] 是完整字符类,里面的星号没有重复含义,外面的 * 才是量词。把量词放在两个候选共同的后面,字符类就能复用相同规则;/[ab][cd]+/ 也能按两个单元依次读取。
这一步得到 35/43,不过同时新放进了两个反例:/\/、/\\\/。刚扩大的普通字符分支允许反斜杠单独出现,还没有理解它会改变后一个字符的含义。分数提高了,失败名单里的回归仍要保留。
第六步:量词后面还可以再跟一个问号
/regex+?/、/regex*?/、/regex??/ 都是正例,不能把它们与 /regex?+/ 混为一谈。
观察这几条,可以把后缀写成「一种量词,后面再跟一个可选问号」,整个后缀也可省略,即 (?:[?*+]\??)?。它允许的非空组合只有 ?、*、+、??、*?、+?,不会放进 ?+ 或任意多个问号。
图中用缩短后的补充正例 /a+?/ 展示单个单位,完整题库输入仍参与每轮测试。
结果是 39/43。剩下四条正好都涉及反斜杠:正例 /a\/a/、/\?+/g,以及第五步开始被错误接受的两个反例。
第七步:反斜杠必须和后面的字符一起读取
在 /a\/a/ 里,中间的 / 已被反斜杠转义,不能拿它结束整条正则。在 /\?+/g 里,\? 也是一个完整单元,后面的 + 才是量词。
给单元增加 \\. 分支,表示读取一个反斜杠,再读取后面的一个字符。同时从普通字符候选中排除反斜杠,防止引擎回退后把它单独吞掉。字符类内部也采用相同原则:要么读取一对转义字符,要么读取一个既非 ]、也非反斜杠的字符。这样 \] 就不会提前结束字符类。
这里的 \\. 中,前面的 \\ 读取反斜杠,后面的 . 是通配符;它和「读取字面点号」的 \. 不同。输入里的 \? 被完整读取后,共用量词规则就能自然接上 +。
结束斜杠也因此能正确区分。/\/ 中唯一可能结束正则的斜杠已经属于 \/;/\\\/ 则先读取一对 \\,剩下的反斜杠又与 / 配对。两者都没有独立的结束分隔符。作为对照,/\\/ 的两个反斜杠互相配对,最后一个 / 可以正常结束。
到这里是 43/43。
通关以后,再核对语法和遗漏
前面的结构可以概括为「单元加可选量词,整体重复」。在正则语法中,这类可被量词作用的部分称为原子(atom)。普通字符、点号、字符类都能出现在这个位置;转义序列则需要整体读取,再按具体语法解释它代表的内容。遇到分组等新语法时,也应先确定完整单元的边界,再考虑后缀。
量词 ?、*、+ 分别表示零或一次、零或多次、一次或多次,默认尽可能多匹配。后面再加 ?,就变成对应的懒惰形式,优先尝试较少次数。a?? 的两个问号分别承担「零或一次」和「尽量少」的职责;在这些量词前后增加任意符号并不成立。量词与懒惰匹配有完整说明。
题库接受 [] 也有依据:它是语法有效、但不匹配任何字符的空字符类。[]? 和 []* 可以选择出现零次,因此仍可能匹配空字符串;「语法有效」与「能匹配到某个字符」是两个判断。字符类规则同时解释了字符范围和转义。
标志允许换序,但不能重复
第三步的 [gi]* 还会接受 /a/gg。按本题限定的 g、i 范围,标志可以省略,每种只能出现一次,而 gi、ig 都成立,因此把结尾改成 (?:gi?|ig?)?。
这个版本仍是 43/43,并修好了重复标志。这里限定的是题目要求,JavaScript 还有其他标志;题目发布于 2013 年,也不意味着当时只有这两个,例如 2011 年的 ECMAScript 5.1已经包含 m。
当前 JavaScript 的八个标志如下,实际使用还需要目标运行时支持。MDN 的标志列表给出了对应属性。
| 标志 | 作用 |
|---|---|
d |
在匹配结果中提供各组的起止索引 |
g |
全局搜索,可继续查找后续匹配 |
i |
忽略大小写 |
m |
让 ^、$ 也能匹配每行的开头、结尾 |
s |
让 . 也能匹配行终止符 |
u |
启用 Unicode 感知的解析与匹配规则 |
v |
启用增强的 Unicode 模式,支持字符类集合运算等语法 |
y |
从引擎记录的匹配起点 lastIndex 开始,失败时不向后寻找其他起点 |
这些标志同样不能重复,u、v 还不能同时出现。它们也会影响正文的语法,例如 Unicode 转义和字符类的解释;完整校验时不能只把 [gi] 扩成一个更大的字符类,再独立检查正文。ECMAScript 的初始化规则明确检查未知标志、重复标志和 u、v 冲突。
其他补充输入也值得单独检查:
| 补充输入 | 调整标志后的结果 | 应如何判断 |
|---|---|---|
/a/gg |
拒绝 | 重复标志不合法 |
/a/ig |
接受 | 两个标志可以换序 |
/a???/ |
拒绝 | 懒惰标记后不能再接第三个问号 |
/[a\]]+?/ |
接受 | 字符类里的右括号已转义,整体可接懒惰量词 |
/[ab/ |
拒绝 | 字符类没有结束 |
/[a-z]/ |
接受 | 正常字符范围 |
/[z-a]/ |
接受 | 仍有遗漏:逆序范围应当报语法错误 |
/a 加实际换行再加 b/ |
接受 | 仍有遗漏:正则字面量不能包含实际行终止符 |
字符类的内部结构目前只检查了边界和转义,没有比较范围两端的顺序。普通字符的否定字符类也没有排除实际换行;\\. 里的点号不匹配行终止符,并不会替其他分支施加相同限制。扩充时需要逐个检查候选,不能因为某个分支已经限制了字符,就认为整个表达式都受到了约束。
断言和嵌套分组,不能靠继续列字符补全
完整语法还包括分组、分支、反向引用,以及 (?=...)、(?!...)、(?<=...)、(?<!...) 这样的断言。识别几种断言的开头本身可以用正则完成,困难在于括号里面仍然可以出现更多分组、分支和断言。ECMAScript 的正则文法描述了这些组合关系。
例如 /(?=a)a/ 有效,缺少右括号的 /(?=a/ 无效;但当前通关式会把两者都接受。它把 ( 当成普通字符,再把 ? 当成这个字符的量词,并没有识别前瞻。/\b+/ 也会被错误接受:\b 在这里是词边界断言,不能像字符一样加上 +。所以「反斜杠加一个字符」只解决了读取边界,还不足以判断每种转义的职责。
正则能否完成校验,取决于使用的引擎和要求覆盖的语法。有限子集、限定嵌套深度的语法可以写成校验表达式;经典正则依赖有限状态,无法处理任意深度的括号配对。JavaScript 的反向引用等扩展又超出了经典正则的能力,不能把有限状态的结论直接当成对所有现代引擎的证明。
不同引擎也不相同。PCRE2 提供递归子模式,可以用它处理嵌套结构;JavaScript 没有对应的递归子模式语法。前瞻、后顾可以检查位置两侧的条件,也不会自动成为一套递归语法分析器。对 JavaScript 的完整语法,继续给这份通关式叠加分支,很快会同时遇到嵌套结构、转义类别、捕获引用和标志相关的合法性规则。
因此,这份表达式保留为本题语法子集的练习答案。实际开发需要完整校验时,让目标运行时的解析器或编译器检查语法,更容易与该环境真正支持的能力保持一致。
如果开发中要校验用户输入的 /正文/标志,应先按正则字面量的词法规则识别分隔符、转义和字符类,拒绝实际行终止符,再把正文和标志交给 new RegExp(body, flags) 编译,并捕获语法错误。构造器能检查逆序范围和分组等语法,但它不接收两侧斜杠,单独调用它也不能代替字面量的词法检查。校验时不需要执行输入,更不需要用 eval。
构造这类表达式时,先把普通字符、字符类和转义分别识别为完整单元,再共用量词规则。新增特殊分支时,也要限制普通字符分支,避免它绕过新约束。
第 18 题:IRC 消息
IRC 是一种通过文本消息通信的聊天协议。第 18 题要求检查发给用户或频道的消息。这里先不查 IRC 规则,从输入和预期结果推测格式,逐步改到通过全部用例,再回头核对协议。这个过程里的字符范围、长度和可选项,先都只是待验证的假设。
| 原题序号 | 输入 | 题库预期 |
|---|---|---|
| 1 | callum!callum@lynx.io PRIVMSG #chat :Hello! |
接受 |
| 3 | [a]!abc@test PRIVMSG #chat :Test |
接受 |
| 6 | a0!abc@test PRIVMSG #chat :Test |
接受 |
| 20 | (cm)!callum@lynx.io PRIVMSG #chat :Hello! |
拒绝 |
| 27 | 0a!abc@test PRIVMSG #chat :Test |
拒绝 |
先根据样例推测格式
第一条输入是 callum!callum@lynx.io PRIVMSG #chat :Hello!。最容易看到的结构是:一段字符、叹号、另一段字符、@、带点号的字符串、几个用空格隔开的单词,最后是冒号和正文。
第一步:按字段选择字符范围,搭出骨架
叹号、@ 和空格先作为固定分隔符;前面的几段暂按 ASCII 单词字符处理,并假设它们都不能为空,写成 \w+。数字和下划线因此也被接受,这仍需要后面的反例检验。带点号的部分写成 \w+\.\w+,目标暂时只接受 # 加单词字符。此时还不知道 PRIVMSG 是否固定,所以中间单词也先用 \w+。
冒号后的正文则用 .+。样例里的空格和标点已经在 . 的范围内,没有证据要求正文跨行,因此暂时不需要用 [\s\S]+ 把行终止符也放进来。正文能否为空、是否还有应当排除的控制字符,仍要继续验证。
lynx.io 先只称为带点号的字符串,不据此断定所有输入都必须是某一级域名。图中的空格沿用开头的标记方式;实际正则使用字面空格,而 \s 还会匹配其他空白字符。
第一轮是 18/36。题库没有覆盖正文换行,.+ 与 [\s\S]+ 在这 36 条输入上的结果相同;选择前者是因为当前只需要单行内容。接下来要看具体哪些输入失败,以及它们推翻了哪个字段的假设。
第二步:点号后面不一定还有字符
[a]!abc@test ... 里的 test 没有点号;ChanServ!ChanServ@Services. ... 里的 Services. 又以点号结尾。这两条样例已经推翻了「点号和前后两段都必须存在」的猜测。
先把这一段改成 \w+\.?\w*:第一段仍必需,点号可省略,最后一段可以为空。Services. 所在的整条消息仍然失败,因为它后面的目标是 callum,还不符合暂定的 #\w+;这不代表点号的修改无效。
结果仍是 18/36,但失败名单变了:正例 a0!abc@test ... 已经通过,反例 0a!abc@test ... 却开始被错误接受。原先它碰巧被缺少点号的 test 挡住;修好后面的假设,才暴露出前面尚未限制首字符。
第三步:叹号前的一段可以含特殊字符
继续看失败的正例,叹号前还会出现 [a]、[_],以及由反引号、竖线、反斜线和 ^ 组成的标识。原来的 \w+ 不够,可以在字符类里保留 \w,再加入这些已经观察到的字符。
这里没有直接写成「叹号以外的任意字符」。反例中的 (cm)、c@m、c,m、c$m、c.m、c;m 都应被拒绝,说明这一段确实有字符范围限制。先列出目前允许的字符,比不断添加排除项更容易检查;不过样例没出现的字符是否允许,仍需后面核对。
第三步得到 21/36,数字开头和长度问题仍然留在失败名单里,后面继续用反例补齐。
第四步:多个叹号,不等于多个分隔符
两条正例分别含有 callum!call!um@lynx.io 和 callum!cal@lum@lynx.io。前一条多了一个 !,后一条多了一个 @。
前面刚写好的标识字符类不含 !,因此第一个叹号仍可作为边界;后面的 ! 应允许出现在第二段里,而不是把分隔符改成 !+。同理,不能根据第一条消息就认定 @ 在整段前缀里只能出现一次。
暂把第二段改成 [^ ]+,只排除普通空格。它可以包含 ! 和 @;后面的带点号字符串仍不接受 @,所以匹配会回退到能接上后续结构的位置。这一步扩大的是第二段,叹号前的标识并没有放宽。
第四步得到 22/36。两个正例修好了,但反例 cm!!callum@lynx.io ... 也被接受了。扩大字符范围时出现回归,要把它记下来,不能因为总分涨了就忽略。
第五步:目标可以带井号,也可以不带
PRIVMSG 后面有时是 #chat,有时直接是 callum、[a] 或 a0。带井号的目标还可能是 ##、#(cm)、#$、#€,甚至包含引号、冒号等标点。
因此给井号加一个 ? 还不够:两种目标的字符范围不同。把目标拆成两个候选,带井号时暂接受 # 加一段非空格字符;不带井号时,复用第三步观察到的标识字符类。整个目标仍然必需。
结果跳到 33/36。前面 Services. 那条正例也终于通过了:它一直还有一个不带井号的目标需要处理。剩下三条都属于错误接受,可以集中看反例给出的限制。
第六步:用反例补上开头、长度和连续叹号的限制
a0 是正例,0a 是反例,说明数字可以出现在后面,但不能放在第一位。把首字符和后续字符分开,首字符只保留字母、下划线和目前观察到的特殊字符。
a234567890123456 有 16 个字符,可以通过;多一个 7 的版本有 17 个字符,应被拒绝。因此采用「一个首字符,加零到十五个后续字符」。不带井号的目标暂时复用这组规则;目标位置没有对应的 17 字符反例,这个上限在该位置仍是根据相似形式作出的推测。
最后处理 cm!!callum@lynx.io ...。在第一个叹号已经作为边界的前提下,这条反例等于要求第二段不能以 ! 开头。把 (?!\!) 放在分隔符之后、第二段之前,就能保留内部含叹号的 call!um,同时拒绝这条反例。这只是按题库标签加上的条件,稍后还要核对它能否由协议推出。
到这里是 36/36。样例已经全部满足,但那些只靠样例作出的假设还没有变成协议规则。
通关以后,再检查刚才的假设
现在再查 IRC 文档,就能给前面几段字符补上名字。nick!user@host 表示消息来源,分别是昵称、用户名和来源主机;PRIVMSG 是发消息的命令,后面接目标和正文。目标可以是用户昵称,也可以是频道名。PRIVMSG 的定义明确包含私聊和频道消息。
这里的 user@host 不能直接当成邮箱地址。昵称和用户名是两个字段,用户名允许的字符也不能套用昵称字符类。正式消息中的来源前面还有 :,整条消息以 \r\n 结束;本题样例省略了这两部分。消息格式与来源字段有完整定义。
把新的反例补进去,第六步仍然会出现这些问题。表中的 \x00 指实际的 NUL 字符,也就是值为 0 的控制字符:
| 补充输入 | 第六步结果 | 需要修正的假设 |
|---|---|---|
alice!u@host GARBAGE #chat :Hi |
接受 | 中间的单词没有按消息类型校验 |
alice!u@host PRIVMSG #chat :Hi\x00there |
接受 | . 虽然排除了行终止符,仍然允许 NUL |
alice!u@irc.example.org PRIVMSG #chat :Hi |
拒绝 | 来源主机不止能有一个点 |
foo-bar!u@host PRIVMSG #chat :Hi |
拒绝 | 只枚举样例出现过的昵称字符,漏掉连字符 |
{alice}!u@host PRIVMSG #chat :Hi |
拒绝 | 同样漏掉传统昵称范围中的大括号 |
对于题库展示的 PRIVMSG 消息,命令可以明确写成 PRIVMSG;如果还要接收 NOTICE,也应显式加入这种消息类型。只有正例里出现了同一个单词,并不能证明它可以被任意 \w+ 替换。NOTICE 的定义也指定了自己的命令和参数。
正文的 .+ 已经拒绝了内部含回车或换行的输入,但它仍能接受 NUL;同时,它还会排除 JavaScript 额外定义的 Unicode 行终止符,不能直接等同于协议的字符范围。按 RFC 1459 的参数文法,这里应明确排除 NUL、CR、LF,例如 [^\x00\r\n]+。冒号让最后一个参数可以包含空格,并没有允许这几个控制字符。来源和目标也要排除它们,第四、五步只排普通空格的字符类仍然过宽。
昵称字符范围也需要补上规范中允许、样例却没出现的字符。RFC 2812 的传统昵称文法包含大括号,后续字符还允许连字符;长度不能直接推广为所有网络都固定 16。实际网络可以通过 NICKLEN 公布限制。上面的白名单能解释已有反例,却仍不完整。
来源主机也不能由 lynx.io、test、Services. 这几个样例推出「至多一个点」。它可能是多级主机名或地址,需要按选定的来源字段规则处理;这些样例只能推翻一部分假设,不能穷尽主机形式。
题库标签还额外限制了 ! 的位置,而用户名里的 @ 又涉及 RFC 1459、RFC 2812 的版本差异。用户名文法与协议版本对照弹窗说明这两种情况;本题也没有覆盖完整 IRC 的频道前缀、多个接收目标与字节长度限制。
第 19 题:HTML 标签名
第 19 题检查 div、h1 这样的标签名,输入不带尖括号或属性。题面还要求在正确匹配的前提下,尽量缩短表达式。
| 原题序号 | 输入 | 题库预期 |
|---|---|---|
| 28 | div |
接受 |
| 38 | h1 |
接受 |
| 109 | ab |
拒绝 |
| 132 | h7 |
拒绝 |
直接枚举题库允许的标签名,用 | 连接,再约束完整输入即可。这里保留容易核对的枚举写法,只做两处合并:h[1-6] 匹配 h1 到 h6,col(?:group)? 匹配 col 和 colgroup。图中用橙色标出这两处。
这个表达式通过了全部 168/168 条用例。若继续按字符数优化,分组的开销也要算进去:直接写 col|colgroup,反而比 col(?:group)? 少一个字符。
这份名单以 2013 年的题库为准,未覆盖 dialog、template 等当前元素。实际项目需要按目标标准维护名单,可对照 HTML 标准的元素表。
第 20 题:国际象棋走法记号
第 20 题要求匹配代数记谱法的单个走法,也就是用棋子字母、格子坐标等字符记录的一步棋,不处理 !、!! 等评价符号,也不要求判断当前棋局中这一步是否合法。下面先把棋谱当作一组陌生的字符串,从正反例提出假设,逐步改到通过全部用例,再核对真正的记号规则。
| 原题序号 | 输入 | 题库预期 |
|---|---|---|
| 1 | e4 |
接受 |
| 8 | Qh4 |
接受 |
| 19 | 0-0 |
接受 |
| 58 | i2 |
拒绝 |
| 70 | B2 |
拒绝 |
| 93 | a2=Q |
拒绝 |
先观察样例,逐步满足题库
先看 e4、a8、g5、h7、a1 这几条正例:它们都是一个小写字母加一个数字。
第一步:先写两个字符的骨架
反例里又有 i2、a9、5d、b,分别提示字母范围、数字范围、顺序和完整性需要受限。
暂时把字母范围设为 [a-h],数字范围设为 [1-8],两者都必须出现一次,再约束完整输入。这里还不知道它们在棋盘上叫什么,也先不排除题库明确接受的 a1、a8。
第一轮是 53/103。接下来失败的正例中,有一批只是在这个骨架前多了一个大写字母。
第二步:允许前面出现一个大写字母
Kd2、Qh4、Nf2、Be4、Ra1 的末尾仍是刚才的两个字符。先把已经出现的五种大写字母列成 [KQRBN],再加 ?,让前缀可以省略。
这个可选项只作用于前面的字母,后面两个字符仍然必需,所以 K2、B2、Bb 继续被拒绝。此时只是在枚举已经观察到的前缀,还没有证明其他字母为什么不能出现。
得到 66/103。下一批失败的正例开始出现 x。
第三步:按 x 前面的内容拆开两类输入
Bxc6、Qxe2、Rxh8 可以在大写字母后插入 x;exd5、axb7 却是小写字母、x、另一个小写字母和数字。它们不能直接共用一个到处可选的前缀。
如果在第二步的可选大写字母后直接加 x?,自己补的一条 xe4 就会被接受:大写前缀省略了,x 却还在。于是把主体拆成三类:大写前缀加可选 x、原来的两字符形式、小写字母加 x 再接两字符形式。
最后一类先写成 [a-h]x[a-h][1-8],沿用前面暂定的字符范围。两个小写字母之间是否还有限制,现有样例不足以回答,先保留这个问题。
得到 78/103。接下来检查等号和括号结尾。
第四步:加入等号结尾和括号 ep
正例里有 e8=Q、a1=N、b8=B、f1=R,反例里有 a2=Q、b4=N、d1=S。先据此作两条假设:等号前的数字只能是 1 或 8,等号后的字母只接受 Q、R、B、N。
把 [a-h][18]=[QRBN] 作为独立分支,可以避免把 =Q 随意挂到所有已有输入后面。bxc1=Q、exd1=Q 则复用第三步的小写字母加 x 前缀,只在后面换成 [18] 和等号结尾。
另两条正例是 exd5(ep)、exd2(ep)。此时只按题库推断,把字面上的 (ep) 设为数字后面的可选结尾,暂时允许整个 [1-8] 范围。
得到 90/103。这些分支已经能覆盖题库中的等号和 (ep) 输入;它们在真实棋盘上是否合理,尚未验证。
第五步:为 R 增加一个前缀字符
Raxc1、Rfxd2、R2xc1、R5xd2 比普通的大写前缀形式多了一个字母或数字,位置在 R 后、x 前。现有这批样例都以 R 开头,于是先把它从 [KQRBN] 拆出来,其余字母保留 [KQBN]。
R 的额外字符暂写成 [a-h1-8]?。字符类表示从字母或数字中选一个,? 再允许它省略;不需要为每个字母和数字各写一个分支。其他大写字母后面是否也有这种写法,以及是否会出现两个额外字符,题库此时没有给出答案。
得到 91/103。这一轮修好了四条正例,也让 Rdxd2、Rexe2、R2xe2 三条反例漏过,所以总分只增加了 1。
第六步:加入两种零和连字符串
0-0、0-0-0 不符合任何一个已有分支。它们的字符已经完全确定,可以另开一个候选:先写必需的 0-0,再让整个 -0 可选,得到 0-0(?:-0)?。
这是另一类输入,不需要往前面的字母、数字骨架里塞更多可选项。两个零都必须存在,0、0--0、0-0-1 等反例继续被拒绝。
得到 93/103。剩下的正例都可以拆成已有主体加末尾的加号。
第七步:把加号放到整个主体之后
Bxc6+、Rh8+、Rxc3++、d8=Q++ 都是在已能识别的内容后面追加一个或两个 +。因此把 \+{0,2} 接在整个主体分组之后,让所有分支共用它。
目前只把它当作字符规律:没有后缀、有一个加号、有两个加号。为什么上限是两个、两个加号代表什么、是否还有其他符号,通关后再查。
得到 100/103。现在只剩第五步引入的三条错误接受。
第八步:根据反例暂时限制 R 的前缀
比较正例 Raxc1、Rfxd2 与反例 Rdxd2、Rexe2,可以先猜测:R 后额外出现的字母不能与末尾那个小写字母相同。再比较 R2xc1、R5xd2 和反例 R2xe2,同样先猜测额外数字不能与末尾数字相同。
这两条猜测只用于目前出现对应反例的 R 分支。把它拆成不带额外字符、带字母、带数字三个候选;带前缀时用捕获组记住字符,再用负向前瞻排除相同值。
([a-h]) 是第一个捕获组,(?!\1) 检查接下来的字符不能等于刚才的字母。它只检查位置,不消耗字符,后面的 [a-h] 仍然不能删。第二组和 \2 对数字做同样的处理。不带前缀的情况单独成支,避免引用没有参与匹配的捕获组;这种引用在 JavaScript 中会按空字符串成功匹配,见 MDN 的反向引用说明。
到这里是 103/103。这个结果只证明表达式满足现有标签,前面关于字符含义和取值关系的猜测仍要核对。
把字符结构与棋规分开检查
兵省略棋子字母,吃子写 x,底线兵步必须升变;消歧需要按同类棋子的合法候选选择出发列、横线或完整格。棋谱记号与棋盘动作弹窗用棋盘解释这些动作,并对照 FIDE 与 PGN 的写法。本题采用数字零易位、++ 和 (ep),不能直接叫作严格 PGN。
题库标签仍有七条规则冲突
题库接受 a1、a8,但兵到底线必须升变;接受的 exd5(ep)、exd2(ep) 也不在显式吃过路兵允许的第 3、6 横线。它拒绝 Rexe2、Rdxd2,却能构造合法棋局生成这两个记号;接受的 R2xc1 则不符合标准 SAN 的最小消歧条件。
因此,第八步为了通关加入的「出发列不能与落点列相同」必须撤回。棋局反例与修正表达式弹窗保留逐项推导及 40 条补充用例。修正结构式与题库的七条差异对应这些标签冲突;它仍只检查字符形式和静态关系,不能判断当前棋局里消歧是否必要、将军后缀是否属实。
完整判断 SAN,还需要棋局
例如 Rexe2 在补充材料给出的 FEN 中成立;把 e1 的白车移走,同一串字符就不能代表那个走法。即使结构正确,漏写必需的消歧、把普通走法标成将军、写错将死后缀,也都可能使它不再是该局面的规范 SAN。
以 PGN SAN 为目标时,可以提供当前 FEN,枚举所有合法走法并生成它们的规范 SAN,再与输入逐字比较。这样能同时检查棋子是否存在、路径是否被阻挡、消歧是否最小、将军或将死后缀是否正确。吃过路兵和易位还要依赖局面中的相关状态。
正则适合先筛掉格式错误的输入;要得到完整的 SAN 判断,需要这一步带棋局的比较。前面的 103/103、按规则修正的结构式,以及带棋局的完整校验,分别回答了不同的问题。
总结
这 20 题反复遇到的困难,是把样例中的偶然形状和必须遵守的规则区分开。出现补零,不代表字段必须固定宽度;几个符号都允许出现,不代表它们可以任意组合;总分上升,也可能同时引入新的误接受。表达式的每一处扩充,都要能解释它放进了哪些输入、排除了哪些输入,以及为什么。规则明确后,才有依据决定是拆分支、确定重复单元,还是从有限名单直接枚举。
替换题还要单独设计实际匹配范围与输出。重复词、列表项和相邻链接都可能共用上下文,把这些内容读进前一次匹配,就会影响下一次查找;捕获后放回输出也不会让它们重新成为输入。关键词题进一步说明,即使能识别词组,一次固定替换模板也未必能生成题面要求的全部结果,需要回调、分步处理或先分词再连接。
题库标签也不是规则的最终来源。通关式可以忠实复现练习约定,却与选定规范冲突;字符结构全部正确,仍可能缺少数值解释、换行模型或棋局状态。因而,通关、按规则修正和完整业务校验是不同的完成标准。正则适合承担规则清楚、边界可验证的部分,需要解析、数值规范化或领域模型的判断,则应明确交给相应实现。通过率和补测记录保留的是验证范围,不能替代这项分工。
下次遇到类似需求,先选哪种做法
按输入约束选择起手方法,再用对应的边界用例检查:
| 需求(题号) | 起手方法 | 必须补的边界 |
|---|---|---|
| 某段内容必须再次出现(1、2、8) | 捕获一次,后面用反向引用;只修改当前项时,把前项留作断言上下文 | 大小写保留、数值等价、连续三次重复 |
| 固定位数与有限数值范围(3、6) | 先写字段骨架,再按高位前缀拆范围 | 上下限两侧、补零、进制与有效位数 |
| 多个字段只能按某些方式组合(5、15) | 每个分支负责一整套合法组合,共享前缀后缀 | 分隔符混用、独立可选项形成的非法拼接 |
| 分隔符连接多段内容(7、18) | 先确定重复单元,再分别检查单段结构与整串约束 | 空段、段首段尾、单段与总长度、控制字符 |
| 找到一段后生成另一种文本(4、9、12) | 先设计实际匹配范围,再设计捕获与输出 | 边界是否丢失、多个匹配是否争用上下文、HTML 转义 |
| 引号或转义改变分隔符的含义(10) | 按词法单元读取,必要时先分词再连接结果 | 句首词组、未闭合引号、转义、固定模板能否表达不同输出 |
| 从有限有序集合中选择若干项(11) | 按顺序列出可选位置,无须枚举全部组合 | 重复、逆序、空串与纯空白 |
| 依赖固定宽度的位置计算(13) | 先建立位置模型,推导一个位置,再生成有限分支 | 换行假设、相邻位置、标点占位、模型外输入 |
| 允许值来自一张有限数据表(14、16、19) | 先查表筛选,再枚举;压缩时保持集合不变 | 数据版本、边界元素、大小写、名单漏项 |
| 语法能够嵌套,或结果依赖外部状态(17、20) | 先划定正则能筛选的格式范围,再交给解析器或领域规则判断 | 目标运行时支持的语法、棋局与历史状态、格式正确却含义错误 |
补测要针对当前写法容易放过的输入。给字符类加一种字符,就试它能否出现在不该出现的位置;加一个可选分组,就试它与其他可选项能否拼出非法形式;用 g 处理重复内容,就试连续三次与共享分隔符。这样写出的反例,才能验证表达式里的具体假设。
32 个起始列的完整展开式
固定替换的限制证明与完整分词实现
一次固定替换为什么无法覆盖完整题面
题目页的执行脚本只调用一次 input.replace(regex, replace.value)。这意味着所有输入共用同一条正则、同一份替换模板,每条输入只经过一次替换调用;即使加了 g,也只是把同一规则应用到更多匹配位置。
固定模板可以包含 $1、$2 等捕获引用,所以每次输出的内容可以不同。例如 ,$2 在词组分支里输出逗号加词组,在普通分隔分支里只输出逗号。但这里能按匹配变化的是捕获内容,模板里直接写出的那个逗号始终都会生成。
考虑下面两条输入:
a b 的原输入没有逗号,捕获组、匹配前缀与后缀也都只能提供原输入中的内容,因此新增的逗号必须来自固定替换模板。模板一旦包含这个字面量逗号,每发生一次替换,就会输出它。
但 "big test" 需要删除引号,至少发生一次替换,目标输出却完全没有逗号。这与同一个模板必须新增逗号的要求冲突。扩大匹配范围、增加捕获组或更换断言,都无法让模板里的字面量逗号按分支自行消失。
因此,在「普通 JavaScript 正则、一次调用、固定替换字符串」的约束下,不存在覆盖这两条输入的统一答案。这个反例只用了普通空格与成对双引号,不依赖转义、嵌套或自然语言歧义;即使把那些复杂情况全部排除,输出要求仍然冲突。
原题的 17 条样例没有出现整句只有一个引号词组的情况,词组前面也都有可消耗的分隔空白。通关式因此可以把「空白加整个词组」替换成「逗号加捕获内容」,在这些输入里同时完成分隔与去引号。
replace 仍然可以参与完整实现。它支持以函数作为第二个参数,由回调根据匹配内容和上下文决定返回什么,因而可以在一处返回逗号、另一处只返回去掉引号后的内容。这个函数仍要正确处理词组边界与撇号等规则;改变第二个参数的形式,只是开放了按情况生成输出的能力。原题的替换输入框不接受这种 JavaScript 回调,不能把函数源码当作替换文本直接填进去。
固定替换模板、回调与分词实现的适用范围如下:
| 做法 | 能处理的范围 |
|---|---|
原题输入框中的通关式与 ,$2 |
满足现有 17 条样例,保留已经指出的遗漏。 |
一次 replace 加固定替换字符串 |
无法同时满足这两个反例,因此无法覆盖完整题面。 |
replace 加回调,或分步骤处理 |
可以按情况删除标点、保留内容和插入分隔符,需要另外实现完整的词法规则。 |
提取 token 数组后 join(',') |
先确定每个 token,再统一连接;零个、一个或多个 token 都不需要额外补删首尾逗号。 |
选择分步骤处理时,也要保留各阶段需要的信息。例如先删除所有引号,再把全部空白替换成逗号,就会丢失 big test 原本属于同一词组的依据。这里采用先提取 token 再连接的方案,把词法识别与输出分隔符分开处理。
明确文法后,给出完整的分词实现
完整实现先返回 token 数组,最后再执行 join(',')。这样首尾不需要逗号,只有一个 token 时也不产生逗号。为了让题面之外的输入有确定结果,本实现补充以下约定:
- 词字符采用 Unicode 字母、组合标记与数字,保留大小写。下划线、emoji 和其他符号充当分隔符;不做中文自然语言分词或 Unicode 归一化。
- 单引号与单连字符只有在左右都紧邻词字符时保留。连续连字符、词首或词尾连字符分隔词。
a-b-c中两个单连字符分别满足条件,因此作为一个 token 保留;原题没有给出这种形式的标签。 - 普通标点作为分隔符处理,
one,two得到两个 token。引号内的内容按相同规则清理,再用一个普通空格连接成一个 token;多个空白也归一为一格。 - 双引号可以开启词组;单引号只在左侧不是词字符时开启,闭合单引号的右侧不能是词字符。使用最近的合法同类闭引号,同类引号不嵌套。
- 支持反斜杠转义引号或反斜杠。找不到合法闭引号时,把该引号当普通标点处理;空引号、纯标点与空输入不生成 token。
这些约定覆盖了原题核心要求,也明确了原题没有定义的选择。它们构成这份实现的词法文法;涉及有歧义的所有格、嵌套引号或不同的标点政策时,应先调整文法,再修改实现和对应测试。
实现先记录哪些引号被转义,再从右向左预存最近的合法闭引号位置。从左向右提取时,普通片段拆成多个词,引号片段清理后整体加入数组。词字符由 \p{L}、\p{M}、\p{N} 识别,单引号与连字符通过相邻词字符判断。
预存闭引号使未闭合输入不必在每个候选起点重新扫描整个后缀。各段只进行常数次扫描,辅助索引和结果随输入长度增长。较长或外部可控的输入仍应由调用方设置业务长度与资源限制。
tokenizeKeywords(input) 返回数组,keywordsToCommaList(input) 返回原题要求的逗号字符串。完整源码、原题与边界用例放在同一个工作区里:
从 https://shengsheng.fun/files/regex-tuesday-solutions/kits/search-keyword-tokenizer/ 读取 README.md、MANIFEST.json、FILES.json,按索引迁移 copy/src 与对应测试。先核对引号、转义和标点规则是否符合项目需求。
这份实现通过原题 17/17,另有 33 条手工边界用例,覆盖句首引号、首尾空白、转义、未闭合输入、连续与内部连字符、Unicode 和空 token;同时核对了 216 组不同 token 与分隔符的组合,以及较长的未闭合输入。
在原题输入框的限制下,可以给出 17/17 的通关式;按完整题面处理任意符合约定的输入,则需要放宽「一次调用加固定替换模板」的限制。实际开发仍然可以使用 replace 回调或分步处理,也可以采用这里的分词函数。完整性的依据是明确的词法规则和边界验证,题库通过率只覆盖其中已经被测试的部分。
和弦的乐理背景与完整记号文法
把记号还原成键盘上的音
和弦记号开头给出根音,后面说明和弦怎样构成。先把这些字符对应到具体的音,再回看哪些部分能组合、哪些部分需要互斥。下面统一以 C 为根音画例子;换成其他根音,保持相同的音程关系即可。
音名、半音与升降号
钢琴白键的音名按 C D E F G A B 循环,下一个 C 与前一个 C 相差一个八度。以 C 大调为参照,把 C D E F G A B C 依次记为第 1–8 级,后面的 3、5、6、7 就可以对应到这些位置。
键盘上相邻的两个音,包括黑键在内,相差一个半音。C 与 D 之间隔着一个黑键,相差两个半音;E 与 F、B 与下一个 C 之间没有黑键,相差一个半音。♯ 表示升高半音,♭ 表示降低半音。黑键 C♯ 也可以叫 D♭:一个从 C 向上找,一个从 D 向下找,最后落在同一个键上。半音与升降号可以继续对照。
这也解释了题目的简化在哪里:E♯ 与 F、B♯ 与 C、F♭ 与 E、C♭ 与 B 可以对应同一个键,真实记谱仍会使用这些拼写。音高相同不代表谱面角色相同;这里按十二平均律画键位,不把同键的音名自动合并。题面排除这四种拼写,是练习约定。
M、m、aug、dim:三和弦里的三音和五音
以 C 为根音,取第 1、3、5 级,得到 C、E、G,组成 C 大三和弦。三个构成音分别叫根音、三音、五音;这里的「三音」指与根音的音程关系,不能理解成键盘上数到第三个键。
在这组例子里,C 到 E 是四个半音,C 到 E♭ 是三个半音。把大三和弦的三音 E 降低半音,得到小三和弦 C、E♭、G。大小写在这里有实际含义:M、maj 表示 major,m、min 表示 minor;大三和弦的类型标记可以省略,所以单独一个 C 已经完整。
aug 是 augmented 的缩写,+ 是它的符号写法:把大三和弦的五音 G 升到 G♯。dim 是 diminished 的缩写,° 表示同一类型:在小三和弦的基础上,把五音 G 降到 G♭。图中的 ♭3、♯5、♭5 都是相对于 C 大调参照音的位置变化。三和弦构成与记号给出了这四类的音程关系。
根音旁的升降号与这些后缀承担不同职责。例如 C♯m 先把根音定为 C♯,再构造小三和弦;C+ 的根音仍然是 C,只把和弦内部的五音升高。于是 C+min 不能照着「符号都认识」就接受:同一个三和弦类型的位置不能同时选增三和弦与小三和弦。
6:加入六音,小六和弦的六音也不用降低
C 大调的第六级是 A,C 到 A 相差九个半音。C6 在 C、E、G 上加入 A;Cm6 在 C、E♭、G 上加入的也还是 A。这里的小和弦标记约束三音,没有同时把六音改成 A♭。
因此,本题里的大、小六和弦可以共用末尾的 6:C6、CM6、Cmaj6 对应同一组音,Cm6、Cmin6 对应另一组音。六音与和弦类型标记说明了这种组合方式。6 描述构成音的位置,不是要求弹六个音。
7、maj7、dom7:先看七音,再看三音
从 C 向上到 B 是大七度,相差十一个半音;到 B♭ 是小七度,相差十个半音。Cmaj7、CM7 使用 B,C7、Cdom7 使用 B♭。后两种写法表示属七和弦,dom 是 dominant 的缩写,构成是大三和弦加小七度。
Cm7 使用小三和弦 C、E♭、G,再加小七度的 B♭。Cmmaj7 则在同一个小三和弦上加大七度的 B:m 决定三音,maj7 决定七音。它们作用于不同位置,可以一起出现,所以不能把所有大小和弦标记都当成全局互斥。小大七和弦正是通关式尚未容纳的一类有效记号。
这些名字也没有规定四个音必须怎样排列。实际弹奏时可以改变八度、换排列或重复某个音;本题匹配的是记号中的和弦构成。
+7 与 augmaj7:增五音和七音也要分开读
增三和弦 C、E、G♯ 可以加 B♭,写成 C+7、Caug7;也可以加 B,写成 C+maj7、Caugmaj7。后者称为增大七和弦。这里约定的 +、aug 修改五音,maj7 指定大七度。
因此,C+7 中的加号不能读成「把七音升高」;它与 Cmaj7 的构成也不同。爵士记号中的加号明确区分了这种用法,增三和弦加大七度的构成可以作为另一种组合对照。
°7 与 ø7:减七度和小七度不同
减三和弦的三个音是 C、E♭、G♭。若加入减七度,七音是 B♭♭;若加入小七度,七音是 B♭。前者是减七和弦 C°7、Cdim7,后者是半减七和弦 Cø7。
B♭♭ 表示把 B 降低两个半音,钢琴上与 A 同键。这里仍保留 B 的拼写,是因为它担任七音;不能只看到键位是 A,就把这个七和弦改读成六和弦。减七与半减七的音程关系区分的正是这一步。
ø 自身已经表示半减七和弦,所以 Cø 省略末尾数字后,构成音里仍有 B♭。把它写成 Cø6,就不符合这里采用的记号规则。半减七还常写作 Cm7♭5:m 给出小三音,7 给出小七音,♭5 降低五音,最后仍是 C、E♭、G♭、B♭。半减记号的等价写法也可以这样逐段理解。
理解构成后,回看通关式的局限
现在可以从具体的音程看出两个遗漏。根音部分删掉了题目不接受、音乐里却存在的四种拼写;后缀部分只允许少量类型标记,没有表达「三和弦类型 + 七音类型」这样的组合,也没包含半减七的另一种写法。
| 输入 | 通关式 | 按上面的构成判断 |
|---|---|---|
E♯M、C♭° |
拒绝 | 根音拼写成立,可以构造对应和弦 |
Cmmaj7、CminM7 |
拒绝 | 小三和弦加大七度,应接受 |
Caugmaj7、C+M7 |
拒绝 | 增三和弦加大七度,应接受 |
Cm7♭5、Cmin7♭5 |
拒绝 | 半减七和弦,应接受 |
C+min、Cdom6、Cø6 |
拒绝 | 不符合这里采用的组合规则,继续拒绝 |
通过题库之后,要沿着每个字段的职责补充情况。看到两个类型词时,先判断它们是不是在修改同一个构成音;单纯增加可选项,或把所有类型词全局互斥,都会丢失这层关系。
给这套记号一个可验证的完整答案
上面的三和弦、六和弦和七和弦可以用正则准确判断。先把接受范围固定下来,避免把有限的记号练习写成对所有乐谱字符串的判断:
- 根音使用一个
A到G的大写字母,后面可带一个♯或♭,包含E♯、B♯、F♭、C♭。 - 三和弦支持图中的大、小、增、减四类;六和弦支持图中的大六、小六两类;七和弦支持图中展示的八类构成。
- 同义标记采用本节列出的
M / maj、m / min、+ / aug、° / dim、dom7、ø / ø7、m7♭5 / min7♭5。小大七和增大七允许短、长标记组合,例如CmM7、Cminmaj7、C+M7、Caugmaj7。
在这个范围内,根音只写一次;已经成立的大、小、减、半减与属七分支保留,把增和弦的大七度组合、小大七和弦,以及半减七的等价写法补全:
这份正则覆盖 21 种根音拼写与 36 种后缀,共 756 种组合。每种支持的拼写都做了组合核对,也检查了错误数字、互斥后缀、额外字符等情况。
放回原题库会得到 74/89。15 条差异全部来自题面主动排除的四种根音拼写,其余用例仍符合预期。保留前面的通关式,就能复现练习约定;这里的结论式则执行刚刚列明的记号范围。
这个范围还没有包含 9、sus、add、斜杠低音、重升降根音或其他同义字符。Caug6、Cdim6 也不在这里定义的两类六和弦中。对这些输入返回 false,只表示不属于这份文法,不能据此判定所有音乐记法中的合法性。以后要扩充时,应先确定构成与拼写,再增加对应的分支和用例。
棋谱记号与棋盘动作的背景说明
通关以后,再读懂棋谱记号
多数记号描述的是「什么棋子走到哪里」,吃子、消歧和升变在这个主体上补充信息:消歧是补充出发位置以区分同类棋子,升变是兵到达底线后换成其他棋子。理解这些动作以后,才能核对从样例作出的假设。
这里会对照国际棋联(FIDE)的手写记谱规则,以及 PGN 棋谱文件采用的标准代数记谱法 SAN。PGN 是用于保存、交换整盘棋谱的文本格式;SAN 约定其中每一步走法怎样写。
格子坐标与棋子字母
国际象棋有 8 列、8 条横线。列从白方左边开始依次是小写 a 到 h,横线从白方这一侧开始是 1 到 8,一个格子的名字由列和横线编号组成,例如 a1、e4、h8。坐标属于整张棋盘,不会因为轮到黑方就倒过来。
棋子用英文大写字母表示。下面六种普通走法中,蓝色箭头从出发格指向落点;兵省略字母,其他棋子在落点前写自己的字母。后续走法示意只保留与规则有关的棋子。
兵的字母在这里必须省略,因此普通兵步写成 e4,而不是 Pe4。P 会出现在 FEN(记录棋盘状态的文本格式)等格式里,不属于这里的兵步记号。棋子字母和坐标还区分大小写:b2 是一个兵步,B2 则只有象的字母和横线编号,缺少完整落点。这些基本约定可对照 FIDE 规则附录 C.2—C.7。
吃子:兵与其他棋子的写法不同
吃子时,己方棋子走到对方棋子占据的格子,并把对方棋子移出棋盘。记号中使用 x 表示吃子。除兵外的棋子保留自己的字母,兵则在 x 前写出发列。
Bxc6 依次表示象、吃子、落在 c6。exd5 中,e 是兵的出发列,d5 才是落点;它没有写出发横线。图里是白兵从 e4 走来,换成黑兵时也可能从 e6 吃到 d5。兵沿斜前方一格吃子,所以出发列和落点列必须相邻。
升变:兵到达底线,必须换成其他棋子
白兵向编号增大的方向走,到第 8 横线升变;黑兵向编号减小的方向走,到第 1 横线升变。升变可以选择后、车、象、马,不能选择王或兵,也不要求这个棋子之前被吃掉过。
本题采用带等号的形式:普通升变如 e8=Q,吃子升变如 bxc1=Q。升变目标只能是后、车、象、马,落点只能在第 1 或第 8 横线。a2=Q、b4=N 都不成立:兵还没到达底线。
升变是兵完成这一步时的一部分,并不是再走一个回合。通常升后最有力量,但也可能为了避免逼和或利用马的走法而选择其他棋子。Chess.com 的兵规则说明包含走兵、吃子和升变的示例。
同类棋子都能到达时,补出发位置
如果有两个马都能走到 e2,单写 Ne2 就不能区分是哪一个。这时会在棋子字母后面补出发位置,比如 Nge2 中的 g。
标准代数记谱法 SAN 的消歧顺序是:能用出发列区分,就写列;列相同而横线不同,就写横线;两者单独都不能区分,才写完整出发格。判断候选棋子时,要看它们是否真的能合法走到目标格,被牵制而不能走的棋子不算候选。这个顺序见 PGN 规范 §8.2.3.4。
下图的蓝色箭头表示所记的走法,绿色箭头表示其他候选。左边两个马分别在 c 列、g 列,用 g 区分;中间两个马同在 g 列,改用横线 1;右边三个后让单独的列、横线都不足以区分,需要完整的 a1。
完整出发格并非理论上凑出来的情况。兵可以升变成新的后,所以同一方可能有多个后。白后分别位于 a1、a7、g1 时,三者都可能走到 d4。从 a1 出发的后既不能只写 a,也不能只写 1,于是需要 Qa1d4。王只有一个,不需要同类棋子的消歧。车沿直线移动,同一方向上只有离落点最近的一辆车可能到达,所以用出发列或出发横线就足以区分。
吃过路兵:落点上没有棋子,也能吃
兵通常向前走一格,第一次移动可以走两格;吃子则沿斜前方走一格。吃过路兵是一个特殊规则:对方的兵刚从初始位置走了两格,停在己方兵的相邻列、同一横线上时,己方兵可以立即把它当作只走了一格来吃。
例如白兵在 e5,黑兵刚从 d7 走到 d5。白方可以走 exd6:白兵落在空着的 d6,黑方位于 d5 的兵被移走。本题把这一步标为 exd6(ep)。
图中只画与这一步有关的兵,省略其他棋子。蓝色箭头表示白兵移动,红色箭头表示黑兵刚完成的两格推进。
白方吃过路兵的落点一定在第 6 横线;黑方的对称情况则落在第 3 横线。显式带 (ep) 的走法不可能落在其他横线上。此外,它只在对方两格推进后的紧接着一手有效,之后就失去机会;这些历史条件无法从字符串单独判断。吃过路兵的完整条件可以对照棋盘理解。
这条规则让初始两格推进不能直接绕过相邻敌兵原本控制的格子。走两格的一方当然可以选择不走,或者只走一格;不过只走到 d6 也可能被 e5 的白兵普通吃掉。允许被吃也不一定是坏棋,可能是一次有利的交换。规则提供的是吃子的机会,并不强迫任何一方这样走。
王车易位:一手同时移动王和车
本题的 0-0 是王翼易位,也叫短易位;0-0-0 是后翼易位,也叫长易位。这里使用数字零。
以白方为例,短易位时王从 e1 到 g1,h 列的车从 h1 到 f1;长易位时王从 e1 到 c1,a 列的车从 a1 到 d1。黑方使用相同列,横线换成 8。下图只展示第一横线和参与易位的棋子。
易位需要王和参与的车此前都没动过,它们之间没有其他棋子,王当前不被将军,而且王经过、到达的格子都不能受对方攻击。王或车走出去再走回来,也不能恢复易位权。车本身受到攻击并不自动禁止易位。
易位常用于让王离开中路,同时让车靠近中心、更快参与棋局;王的安全通常也是重要目的。Chess.com 的易位说明整理了这些条件和常见误解。
+、++ 与 #
+ 表示将军:这一步以后,对方的王受到攻击,下一手必须解除。将死则是王受到攻击,而且没有合法应对方法,棋局结束。
本题接受 ++,这里将它按将死记号处理;FIDE 附录 C.13.5 也列出了这个写法。结尾可能没有加号、有一个加号,或者有两个加号。这里的上限来自记号约定,不是因为一手最多能「将军两次」。
# 也是将死记号,而且是 PGN 标准 SAN 的写法。FIDE 附录 C.13.5 同时列出 ++ 和 #。另一些教材会用 ++ 表示双将,即两个棋子同时将军,例如这份代数记谱教学材料。离开这道题以后,需要先确认使用哪套约定,不能只看两个加号就断定含义。PGN 用 + 表示将军、# 表示将死,不另设双将后缀,见 §8.2.3.5。
双将描述的是攻击来源的数量,将死描述的是对方有没有合法应对,两者要分别判断。下图白象从 e2 移到 b5 后,象和车同时攻击黑王,但黑王仍有逃离方法。PGN 仍只写一个 +;使用双将方言时可能写 ++。
图中的将军、将死与双将局面已用 chess.js 1.4.0 核对。
这道题采用哪一种写法
「代数记谱法」有不同使用场景。比赛手写记谱的容许形式,与 PGN 文件里用于交换数据的标准 SAN,并不完全相同。同一个走法可能出现下面这些写法。
| 情况 | 本题采用的形式 | 其他常见或规范形式 |
|---|---|---|
| 王车易位 | 数字零:0-0、0-0-0 |
PGN 使用大写字母 O:O-O、O-O-O;FIDE 附录列的是数字零 |
| 将死 | ++ |
PGN 使用 #;FIDE 同时列出两者 |
| 吃过路兵 | exd6(ep) |
FIDE 可加 e.p.;PGN 只写普通兵吃子形式 exd6 |
| 升变 | e8=Q |
PGN 也带 =;FIDE 附录示例可写 e8Q |
| 吃子标记 | 使用 x |
PGN 的吃子必须有 x;FIDE 手写记谱允许省略 |
这些差异分别来自 FIDE 附录 C和 PGN 规范 §8.2.3。不能因为题目使用数字零,就说它把字母 O 写错了;也不能把题库允许的形式直接叫作严格 PGN。
本题按表格中间一列处理单个走法:兵不写 P,吃子写 x,升变写等号,易位用数字零,吃过路兵可带 (ep),结尾可有一个或两个加号。!、!!、? 之类的符号是对走法好坏的评价,题目明确不要求处理。这些约定决定了按规则修正时应当接受什么;后缀是否属实、出发位置是否需要写出,还要结合棋局判断。
棋规反例、FEN 复现与修正后的结构式
题库标签与棋规有哪些冲突
按真实规则回头看,通关式里有几处假设需要撤回。下面这些差异不能用「全部样例通过」来证明正确。
| 题库输入 | 题库标签 | 规则上的问题 |
|---|---|---|
a1、a8 |
接受 | 没有棋子字母时是兵步,到达底线必须写升变 |
exd5(ep)、exd2(ep) |
接受 | 显式吃过路兵只能落在第 3 或第 6 横线 |
Rexe2、Rdxd2 |
拒绝 | 可以构造合法棋局生成这些记号 |
R2xc1 |
接受 | 形状可以解析,但不符合标准 SAN 的最小消歧条件 |
题库把普通兵步 a1、a8 当作正例,第一步因此保留了整个 [1-8]。按规则修正时,要把普通兵步限定为 [a-h][2-7],底线留给升变分支。同样,第四步为了通关而接受任意横线上的 (ep),现在应当收紧到 [36]。
Rexe2 和 Rdxd2 可以是合法记号
设白车分别在 a2、e1,黑兵在 e2。两辆车都能吃掉这个兵,所以需要写出发列:从 a2 来的是 Raxe2,从 e1 来的是 Rexe2。后者的出发列和落点列都是 e,并没有矛盾。
下图画出这个示例局面和两种候选吃法,王也包含在棋盘中。
用 FEN 可以复现这个局面:
7k/8/8/8/8/8/R3p3/4R1K1 w - - 0 1
FEN 是棋盘状态的文本表示。这里的字符串依次记录各横线上的棋子、轮到白方走,以及易位权、吃过路兵目标格等信息。可以把它导入支持 FEN 的棋盘工具,再查看合法走法。
同理,把 e1 的白车和 e2 的黑兵分别移到 d1、d2,就得到 Rdxd2 的例子:
7k/8/8/8/8/8/R2p4/3R2K1 w - - 0 1
这两组局面使用 chess.js 1.4.0 的合法走法生成器核对过,输出分别包含 Rexe2、Rdxd2。
题库把这两个输入标成负例,却存在能生成它们的合法棋局。因此,第八步从标签推导出的「出发列必须与落点列不同」会拒绝正确记号。
R2xe2 的问题在消歧方式
R2xe2 不能简单解释成「车不能从第 2 横线走到第 2 横线」:车当然可以横着走。
问题是标准 SAN 会优先用出发列消歧。某辆车从第 2 横线横走到 e2 时,同一出发列上的其他车无法也沿直线到达 e2,因此出发列足以区分,应写成类似 Raxe2 的形式。只写出发横线 2 不符合最小消歧顺序。如果把它理解成从 e 列沿竖线走来,那么出发格和落点都成了 e2,又没有发生移动。
这也说明为什么同一个限制不能推广到后。白后在 a2、a6,黑兵在 e2 时,两个后都能吃到 e2:它们同属 a 列,需要用横线区分。从 a2 出发的一步正是 Q2xe2,出发横线与落点横线相同却完全合理。
题库也把 R2xc1 标为正例。它的字符结构没有问题,但按标准 SAN 的最小消歧规则,落点在底线上的车步用不到这种横线消歧:同一列能到达底线目标格的车至多只有一辆,其他方向来的车可以用出发列区分。题库混入了「形状像记号」和「按棋局规范消歧」两种判断口径。
从规则补出题库没有覆盖的用例
题库没有给出反例,并不等于一种拼接方式就成立。通关后可以沿着每个分支补边界,再把从棋规得到、题库没展示的正例也加进来。
| 补充输入 | 通关式 | 按所选记法应如何处理 |
|---|---|---|
Pe4、xe4 |
拒绝 | 保持拒绝:兵不写 P,吃子也不能缺少棋子或出发列 |
exe5、exh5 |
接受 | 拒绝:兵吃子的出发列、落点列必须相邻 |
exd1 |
接受 | 拒绝:到达底线必须升变 |
Nge2、N1e2、Qa1d4 |
拒绝 | 在适当棋局中可以成立,其他棋子也需要消歧 |
Qexe2、Q2xe2 |
拒绝 | 在适当棋局中可以成立,不能把车的排除条件推广给后 |
Kae2、K2e4 |
拒绝 | 保持拒绝:王不需要同类棋子消歧 |
Nf3=Q、e8=K、e8=P |
拒绝 | 保持拒绝:只有兵升变,且不能升王或兵 |
exd6(ep)、exd3(ep) |
接受 | 保持接受:显式吃过路兵的两种落点横线 |
0-0+、bxc1=Q+ |
接受 | 保持接受:后缀应覆盖特殊走法 |
相邻列关系不能用两个独立的 [a-h] 表达。a 列只能去 b 列,e 列可以去 d、f 列,h 列只能去 g 列;因此按八种出发列枚举关系,再共用后面的横线与结尾。
按规则修正后的完整表达式
这里采用 SAN 的最小消歧规则,并保留题目采用的数字零、++、(ep) 和等号记法,把已经发现的静态条件补齐:普通兵步不落底线;兵吃子必须相邻列;显式吃过路兵限定横线;王独立处理;后、象、马允许列、横线或完整格消歧。
车的分支也重新改正。出发列可以与落点列相同,因此不能保留通关式的同列排除;列前缀直接设为可选即可。只有采用出发横线消歧时,才排除相同横线,并把落点限定在 [2-7]:落点在底线时,车沿列接近它的方向只有一个,不需要用横线区分同列车。车也不需要完整出发格消歧。
下面给出修正后的完整结构式。它检查这些字符形式和静态关系,消歧是否必要、将军后缀是否属实,仍由棋局决定。
这份结构式已检查上表和其他边界输入,共 40 条补充用例;Rexe2、Rdxd2、Q2xe2、Qa1d4 等记号还用具体 FEN 的合法走法核对过。它与题库的七条标签差异,均对应前面列出的规则冲突。
实际使用时不添加 i、m、g 标志。若目标是 PGN 的 SAN,还要把易位改成字母 O,后缀改为 (?:\+|#)?,并去掉 (ep) 分支;这几处只是记法切换,不会提供棋盘信息。
题库颜色约定与现代 CSS 规则
题库约定也不等于现代 CSS 的完整规则
上面的答案限定在题库出现的几类格式:非负普通十进制数,小数点前至少一位数字,函数内部允许普通空格与 Tab;RGB、百分比和 alpha 都按约定范围拒绝越界值。它没有覆盖省略整数零的 .5、带符号或指数的数字、带角度单位的色相、函数内换行,以及更多 CSS 颜色语法。
还有两条题库反例,不能直接拿来判断现代 CSS 是否有效。CSS 会钳制这里的越界分量,也就是低于下限时使用下限,高于上限时使用上限:
| 输入 | 题库要求 | 现代 CSS 的处理 |
|---|---|---|
rgb(257, 257, 257) |
拒绝 | 超出通道范围的值会被钳制到上限,得到白色 |
rgb(10, 10, 10, 10) |
拒绝 | 现代 rgb() 与 rgba() 是别名,可带第四项 alpha;这里 alpha 会被钳制到 1 |
现代 CSS 还支持空格分隔与斜杠透明度,例如 rgb(50% 50% 50% / .5),以及四位、八位十六进制透明度。逗号写法仍要求 RGB 三项统一使用数字或百分比,但空格写法可以混用。因此,不能把本题的「三项或四项」「单位必须相同」「越界即无效」不加区分地推广到所有 CSS 颜色。CSS 颜色语法、RGB 与 RGBA 的定义列出了这些差别。
真实业务如果要支持完整 CSS,先明确检查的是「语法有效」「颜色本身为灰阶」,还是「叠加背景以后看起来为灰阶」。CSS.supports('color', value) 只能帮助检查浏览器是否接受语法,不能直接回答后两项;完整的颜色判断还需要按支持的颜色空间解析、换算,并单独约定透明度和背景的作用。
范围端点、单位混用、透明度与空白等补测通过,但仍有 4 条数值相等、文本不同的已知漏判,对应前面讨论的数值规范化问题。
题目日期约定与真实日历的补测
题目的日期约定与真实日历不同
把每个月都当成 30 天,是题面主动做出的简化。它会产生两个容易误判的结果:
| 补测输入 | 按题目约定 | 原因 |
|---|---|---|
2012/02/30 12:00 |
接受 | 二月同样按 30 天处理 |
2001/02/29 12:00 |
接受 | 不检查真实日历中的闰年 |
2012/01/31 12:00 |
拒绝 | 一月也不能超过 30 日 |
2013/01/01 00:00 |
拒绝 | 年份上限固定为 2012 |
这些结果符合题面,不能当作错误测试。真实日期校验还要处理大小月和闰年,不能直接沿用这个日范围;年份上限也应来自实际业务要求,而不是随着当前年份自动变化。
年份与时分秒边界、缺失字段、额外空白等补测通过;逐字段生成的输入,也与独立的位数和数值检查结果一致。
单星号题库与 CommonMark 强调规则的差异
题库的单星号规则与 CommonMark 不同
题库第 8 条允许闭合星号前有空格,CommonMark 的强调规则却不允许在这个位置闭合。题库第 10 条要求 *文字** 保持原样,而规范允许其中一颗星号闭合强调。对应行为可核对 CommonMark 示例 366与示例 443。
这份通关式实现的是「依次配对两颗独立单星号」,没有实现完整 Markdown。反斜杠转义与代码跨度的优先级都没有处理,内部的多星号段也只作为原文保留,没有验证它们是否配对;直接套用 HTML 模板同样没有完成正文的 HTML 转义。
放宽正文字符范围时,原先由字符类间接保证的边界条件也可能消失,需要用断言重新约束。
数字域名与 DNS 文本长度的来由
数字段和长度限制背后的规则
域名中的段可以以数字开头,也可以完全由数字组成,这是 RFC 1123 明确允许的形式。题库里的 ip6.arpa 样例来自 IPv6 反向查询的命名方式:把 128 位地址拆成 32 个十六进制数字,倒序用点号连接,再接上 ip6.arpa。因此,前面那些单独的 1、0 都是各自的 label。RFC 3596 的说明给出了这种转换规则。
63 和 253 对应两个不同层级的限制。DNS 中的一个 label 最多 63 字节,整个域名的编码最多 255 字节。编码时,每个 label 前面都有一个长度字节,最后还有一个表示根 label 的零字节;文本中的点号则不直接写进这份编码。
设所有 label 内容共 L 个 ASCII 字符,一共有 n 段。不带尾点的文本需要 n - 1 个点号,长度是 L + n - 1;DNS 编码需要 n 个长度字节和一个根 label 的零字节,长度是 L + n + 1。编码始终比这种文本形式多两个字节,因此从 255 换算得到 253。这里比较的是普通的未压缩名称表示。DNS 名称编码定义也说明了长度字节和根 label 的作用。
32 列排版模型的补测与适用范围
通关后,哪些条件仍需要明确
32 列使全部标签自洽,但页面的像素宽度和通用字体没有把这个条件固定下来。因此,这份答案判断的是本节明确定义的 32 列模型;它无法读取浏览器当前字体,也不能保证与每个人眼前的换行一致。
这次除了原来的 8 条用例,还用独立的「先贪婪分行、再比较相邻行单词起始列」算法核对了 18,000 组可复现的生成输入,以及 70 条补充输入。其中专门覆盖了全部 32 个起始列的正例、向左或向右错一列的反例、标点和长度达到 32 的排版片段,结果一致。
\w 在这份无标志的 JavaScript 正则里按英文字母、数字与下划线识别单词,\b 也沿用这个范围。它没有处理自然语言里所有单词边界;大小写相等规则、带撇号的词怎样划分,以及中文、组合字符和 emoji 占几列,都需要另行约定。
单空格与片段长度限制也影响分行。允许连续空格、制表符、真实换行或超过 32 列的片段后,需要明确空白怎样保留、制表符怎样展开、超长片段是否拆开,再调整实现。这些范围之外的输入不能直接套用上面的正确性结论。
从位置规则到正则生成器
这份解法采用了「建立换行模型、推导单列规则、枚举位置生成正则」的做法。先确定行宽,再把一个起始列写成公式:前面跳过 c 个字符,当前位置还剩 32 - c 列,单词的捕获号是 c + 3。最后交给代码展开 32 个候选,得到前面的长正则。
因此,这份解法的主要工作已经变成行列计算和代码生成。它枚举的是模型中全部可能的起始列,能判断题库之外的输入;但它没有给出一条简短、便于手写的表达式。目前核查的题目页与作者仓库中,没有找到作者公布的第 13 题参考解。这份答案应当视为固定 32 列模型下的一个构造解,尚未证明枚举是唯一方法,也没有证明表达式最短。
实际开发中,如果需求本来就在计算行与列,直接先分行、记录单词起始列,再比较相邻行,通常更容易维护。做这类练习时,要分清「已经构造出一个可验证的答案」和「已经找到简洁或最优的解法」。
IRC 题库标签与 RFC 文法版本的差异
题库标签与协议文法也要分开
cm!!callum@lynx.io ... 按字段可以解释成昵称 cm、用户名 !callum。RFC 1459 和 RFC 2812 的用户名文法都没有禁止以 ! 开头,因此第六步加上的 (?!\!) 不能从这些文法直接推出。这条限制来自题库额外给出的拒绝标签。
用户名里的 @ 则涉及文法版本差异。RFC 1459 的宽字符范围允许它,RFC 2812 的 user 范围排除了 @;题库接受的 cal@lum@lynx.io 不能被解释成严格遵循后者。选择校验口径时,需要明确是在复现题库,还是在校验某个协议版本及网络实现。
完整 IRC 消息还有其他频道前缀、多个接收目标和字节长度限制,本题只展示了其中一部分形式。