ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

正则表达式考点全解析:从基础语法到性能安全

正则表达式考点全解析:从基础语法到性能安全 正则表达式这门让人又爱又恨的“文本匹配利器”几乎是各类技术笔试和面试里的常客。我自己面过不少候选人也帮别人出过题发现凡是能把正则讲清楚的人基础通常不会差而大部分挂在正则面试题上的人不是不知道\d代表数字而是说不清回溯、边界和不同语言的转义差异。这篇文章准备以“考点”为主线把正则表达式的语法基础、高频公式、身份证校验、PHP/Java/Perl/爬虫实战以及性能安全问题串成一整条线。目标读者很明确马上要笔试面试的同学、写爬虫想快速提取数据的工程师、以及日常维护脚本需要准确匹配文本的朋友。1. 正则表达式基础考点元字符、量词与匹配引擎的底层逻辑1.1 元字符与字符类先分清“匹配什么”和“排除什么”正则考点第一个高频点就是元字符。^表示开头$表示结尾.匹配除换行外的任意字符*匹配前一个字符0次或多次匹配1次或多次?匹配0次或1次。\d匹配数字\w匹配字母数字下划线\s匹配空白符。大写形式是反义\D是非数字\W是非单词字符\S是非空白。这些内容背下来不算本事真正的考点在于它们的组合和边界条件。容易被问到的题比如[^a-z]和^a-z的区别。前者是在字符类内部^表示“排除a到z”匹配任意一个不在a到z范围内的字符后者是锚点表示字符串必须以字母 a 到 z 中的某个字符开头。还有个经典坑[.]在字符类中就是点号本身不需要转义但在字符类外.需要写成\.。这类细节在面试里是天然的“区分题”写在纸上最容易暴露一个人对基础概念的掌握程度。1.2 量词的贪婪本质与回溯匹配引擎的工作方式才是考点量词考点往往结合“贪婪 vs 懒惰”出现。默认的量词是贪婪的比如.*会尽量多匹配直到字符串尽头而.*?是懒惰匹配能少匹配就少匹配。举例字符串divaa/divdivbb/div用/div.*\/div/去匹配贪婪版本会从第一个div一直吃到最后一个/div整段都是匹配内容懒惰版本/div.*?\/div/只会匹配第一个divaa/div。为什么贪婪会导致问题因为正则引擎在找不到后续匹配时会一步步“回退”已经吞进去的字符这个过程叫回溯。回溯是NFA型正则引擎的核心机制它让正则有能力处理“分组 反向引用”这类复杂模式但也带来了性能风险。后面第五章我会专门展开灾难性回溯这里只需要记住量词越灵活引擎的猜测空间越大匹配耗时越不可控。这里还可以顺带提一下多行模式某些语言和工具里加上m修饰符之后^和$的语义会从“整段字符串的首尾”变成“每一行的行首和行尾”。Perl 的/m、Java 的Pattern.MULTILINE、PHP 的m修饰符都是同一个意思。如果你在日志分析场景里想匹配每一行的开头不加这个修饰符就会出问题。1.3 分组、反向引用与零宽断言笔试里的“进阶题”分组是另一个必考点。括号()的作用不只是分组还有一个隐藏效果捕获匹配内容。(\d{4})-(\d{2})-(\d{2})会把年月日分别捕获到第1组、第2组、第3组。如果只想分组而不想捕获可以用(?:...)它不产生编号能减少不必要的资源占用也避免编号混乱。反向引用也是高频考点正则里用\1引用第1组捕获的内容用来处理重复模式。比如匹配连续相同的单词\b(\w)\s\1\b。在 PHP 和 Perl 的替换场景里捕获组通常用$1引用这和匹配阶段的\1写法不同。这个细节很值得记下来因为两种写法在不同语言混用时最容易出错。零宽断言是另一个高频点(?...)是正向先行断言(?!...)是负向先行断言(?...)是正向后行断言(?!...)是负向后行断言。它们的特点是不消费字符只要求“位置”满足条件所以叫零宽。一个非常经典的实战案例是密码强度校验要求同时包含数字和字母且长度不少于8位可以写成^(?.*\d)(?.*[a-zA-Z]).{8,}$。这里两个(?...)都从开头位置检查整个字符串互不干扰逻辑很清晰。这种写法在表单校验里大量使用。2. 常用正则表达式考点邮箱、手机号、IPv4地址这些公式别只背结果2.1 高频句式速查表笔试题里最喜欢考的就是“现场写邮箱、手机号、IP地址正则”。很多人背过但背得不够细一追问边界就崩。我把高频公式整理成一份速查表注意这只是“业务够用”版本生产环境还要结合具体场景加强校验。目标常用正则说明邮箱^[a-zA-Z0-9._%-][a-zA-Z0-9.-]\.[a-zA-Z]{2,}$未处理连续点、开头结尾点等细节手机号^1[3-9]\d{9}$中国大陆11位手机号常见写法IPv4地址^((25[0-5]|2[0-4]\d|1\d{2}|[1-9]?\d)\.){3}(25[0-5]|2[0-4]\d|1\d{2}|[1-9]?\d)$每段0-255不能直接用\d{1,3}日期(简单)^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$未校验每个月具体天数中文^[\u4e00-\u9fa5]$PCRE中要配合u修饰符HTML注释!--.*?--非贪婪写法避免跨注释匹配URL^https?://[^\s]$更严格写法很长够用版这样这种速查表最大的价值不是临场背而是让你知道“边界条件才是考官想听的东西”。比如手机号你写^[0-9]{11}$只能说明匹配11位数字但^1[3-9]\d{9}$会约束开头必须是13到19这是符合当前手机号段划分的常见约束。容易被追问为什么不是^1[3456789]\d{9}$因为运营商号段在持续增加用[3-9]这种区间写法更抗变化。2.2 IPv4地址正则为什么要这样拆IPv4 地址每段范围是 0 到 255如果直接写\d{1,3}那999.888.777.666也能匹配过去这显然是错的。正确思路是把 0 到 255 拆成几段区间25[0-5]匹配 250-2552[0-4]\d匹配 200-2491\d{2}匹配 100-199[1-9]?\d匹配 0-99允许一位或两位数字首位不能为0而0本身也能被这个分支覆盖因为[1-9]?可以不出现。四个分支用|连接外面必须套括号因为|的优先级很低不套括号会把整段正则的优先级打乱。每段后面跟一个点前三段用(...\.){3}重复最后一段不跟点。整套写下来才算完整。还有边界问题如果是在长文本里找IP比如192.168.1.1出现在一串数字中间那1299.123.123.1232这种大于255的段也可能被部分匹配。建议配合\b字词边界或在前后加分隔符限制写成\b((25[0-5]|2[0-4]\d|1\d{2}|[1-9]?\d)\.){3}(25[0-5]|2[0-4]\d|1\d{2}|[1-9]?\d)\b。这也是“考点里的考点”。2.3 日期正则从“简单格式”到“闰年判断”日期类题目比较有层次。第一层是\d{4}-\d{2}-\d{2}能匹配但什么都验证不了第二层是\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])限定了月份1-12、日期1-31第三层要处理不同月份天数差异和闰年2月29日。闰年的规则是能被4整除但不能被100整除或者能被400整除。用正则表达闰年需要把年份拆成几种形态。一个常见写法是匹配1900到2099之间的闰年(?:(?:19|20)(?:[02468][048]|[13579][26])|(?:16|[2468][048]|[3579][26])00)这个正则看着吓人本质是枚举末尾两位能被4整除且不是00的年份加上整百且能被400整除的年份。实际开发里我不太建议为了闰年把正则写这么复杂因为维护成本高也容易写错。更稳的方案是先用简洁正则粗筛格式再用语言里的日期函数解析比如 Java 的LocalDate.parse、PHP 的checkdate失败就抛异常。正则负责“像不像”业务代码负责“对不对”这个分工原则值得记下来。3. Java 身份证号码正则校验从正则匹配到校验位计算的完整实现3.1 18位身份证号码结构拆解身份证号码是正则考点的“常青树”因为它既能考格式匹配又能考算法校验还牵扯边界讨论。18位身份证由四部分组成6位地址码前两位是省份中间两位是城市后两位是区县首位不能是08位出生日期YYYYMMDD比如1990年1月1日写作199001013位顺序码同一地址码、同一出生日期的人按顺序编号奇数为男偶数为女1位校验码0到9或X由前17位计算得出。对应正则可以写成^[1-9]\d{5}(?:18|19|20)\d{2}(?:0[1-9]|1[0-2])(?:0[1-9]|[12]\d|3[01])\d{3}[\dXx]$再解释几个设计点首位地址码为什么是[1-9]因为地区编码不会以0开头出生年份用(?:18|19|20)覆盖当前实际存在的出生年份区间如果想更宽松可以改成(?:1[89]|20)\d{2}月份和日期用分组限定校验码位用[\dXx]同时接受大写和小写X方便用户输入。(?:...)在这里用的是非捕获组。因为整体只需要判断是否匹配不需要单独提取地址码或出生日期分组非捕获组能减少匹配时的资源占用也能避免后续group(1)编号混乱。如果业务还要抽出出生日期做进一步判断就要把日期部分改成捕获组()以便后续matcher.group(1)直接取值。3.2 正则匹配之后校验位该怎么算正则只能保证“格式长得像”并不能保证这个身份证号真实存在。身份证第18位校验码采用模11-2校验算法。计算方法是前17位数字分别乘以权重数组[7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2]求和再对11取模根据余数查校验码表[1, 0, X, 9, 8, 7, 6, 5, 4, 3, 2]。为什么取模11因为模11能保证加权累加后的余数空间足够大配合不同权重可以显著降低“错一位数字但校验码碰巧相同”的概率。这就是校验算法的作用把一位数字错误、两位数字互换这类常见录入错误尽量暴露出来。Java 完整实现public static boolean isValidIdCard(String idCard) { if (idCard null) { return false; } idCard idCard.trim().toUpperCase(); String regex ^[1-9]\\d{5}(?:18|19|20)\\d{2}(?:0[1-9]|1[0-2])(?:0[1-9]|[12]\\d|3[01])\\d{3}[\\dX]$; if (!idCard.matches(regex)) { return false; } int[] weights {7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2}; String[] codes {1, 0, X, 9, 8, 7, 6, 5, 4, 3, 2}; int sum 0; for (int i 0; i 17; i) { sum Character.digit(idCard.charAt(i), 10) * weights[i]; } return codes[sum % 11].equals(String.valueOf(idCard.charAt(17))); }注意几个细节先用toUpperCase()把用户输入的小写x统一成大写X避免后面比较麻烦idCard.matches(regex)在 Java 里就是全量匹配正则前后不需要再加^$但加了也不影响结果Character.digit比Integer.parseInt处理单个字符更简洁安全。3.3 身份证校验的边界情况和测试用例身份证正则校验项目里最容易翻车的是测试用例。很多人用一个虚构号码跑一遍发现通过了就以为万事大吉实际上没有覆盖格式不对、校验位不对、生日不合理等场景。我整理几个典型的测试方向格式错误长度不足、含有非数字字符、出生日期出现19991301校验位错误110105194912310021这一串前17位是标准样式但第18位不是正确的X结果应当返回false生日合理性正则允许2月30日这种情况需要额外用日期API判断15位旧身份证这种方法不适用如果你在对接老系统要单独处理或提示不受支持顺序码000理论上是异常值但通常不会因为这一点单独拒绝。另外一个相关考点是身份证脱敏。日志和前端展示时常需要把中间出生日期隐藏比如110105**********2X实现方式可以用(?\d{6})\d{8}(?\d{3}[\dX])做零宽断言替换或直接用正则替换字符串。能写出来说明你对零宽断言和捕获组都有了实际掌握。4. PHP、Perl 与爬虫场景里的正则表达式考点4.1 PHPpreg_系列函数与PCRE修饰符PHP的正则实现基于PCRE库常用函数有四个preg_match、preg_match_all、preg_replace、preg_split。笔试常问preg_match和preg_match_all有什么区别前者只要找到一个匹配就停止并返回1后者会继续扫描直到找出全部匹配返回匹配次数或false。所以提取字符串里的所有手机号时用preg_match_all就对了。PHP正则最容易被新手坑的是定界符。表达式必须被一对定界符包起来最常见是/但如果要匹配的字符串本身就含斜杠比如 URL建议改用#...#或~...~免去大量转义。比如$pattern #^https?://[^\s/$.?#].[^\s]*$#i; if (preg_match($pattern, $url)) { echo URL格式合法; }#定界符让 URL 里的斜杠不用加反斜杠可读性高一大截。末尾的i是修饰符表示不区分大小写。处理 UTF-8 中文时还要加u例如/^[\x{4e00}-\x{9fa5}]$/u如果没有u中文会被按字节匹配结果常常是匹配失败或匹配到半个字符。替换时还有一个经典细节preg_replace(/\d/, *, $str)可以直接把所有数字换成星号如果要保留捕获内容替换串里用$1或\1。PHP 5.5 以后preg_replace的/e修饰符被废弃到了 PHP 7.0 直接移除想执行动态替换逻辑要用preg_replace_callback。笔试如果还写/e基本可以判定知识体系太旧了。4.2 Perl正则不是库而是语言自带的基本操作符Perl 里的正则直接内建在语法里核心操作符是~和!~。~表示“进行匹配”!~表示“不匹配则成功”。匹配写法$str ~ /pattern/捕获结果自动放进$1、$2等变量整个匹配内容存在$。这种设计让 Perl 在文本处理任务里极其顺手一行代码就能完成解析my $line 192.168.1.10 - - [10/Oct/2024:13:55:36 0800] GET /index.html 200; my ($ip, $time) $line ~ /^(\S)\s.*?\[([^\]])\]/; print IP: $ip, Time: $time\n;替换语法是s/pattern/replacement/注意它默认只替换第一处要全部替换得加g修饰符$str ~ s/\s//g;。还有一个tr///用于字符一对一替换比如$str ~ tr/a-z/A-Z/;做小写转大写它做的是字符级翻译不是正则匹配。Perl 的命名捕获很值得记(?year\d{4})-(?month\d{2})-(?day\d{2})匹配之后可以${year}取年份。Perl 正则是后来 PCRE 等实现的重要参考理解了 Perl 的$1、$机制再看 PHP 的$matches、Java 的matcher.group()会发现思路是一样的只是“取结果的接口”变了。4.3 Spider爬虫正则提取字段时最该注意的匹配习惯写爬虫时正则的高频用途是从 HTML 源码里提取链接、标题、价格、评论数这些字段。很多人上来就写.*结果把页面里所有内容都吞进去了。举个例子想抓这种结构div classtitlea href/post/123正则表达式考点/a/div用 Python 的re模块正确姿势是import re html div classtitlea href/post/123正则表达式考点/a/div m re.search(rclasstitlea href([^])[^]*(.*?)/a, html, re.S) if m: print(m.group(1), m.group(2))这段里有三个关键习惯第一链接用([^])而不是(.*?)因为引号是明确边界[^]遇到右引号就停不会贪婪越界第二链接后的标签属性用[^]*跳过因为a标签里可能还有target、class、style等属性第三标签中间的文本用.*?非贪婪并且搭配re.S让点号能匹配换行防止文本被换行拆断。我见过太多新手用re.findall(rdiv.*?(.*?)/div, html)去抓内容结果因为 HTML 标签嵌套而被截断得乱七八糟。正则解析 HTML 属于“在泥地里开车”结构不稳定、嵌套多变、属性顺序还可能变。对于复杂或健壮性要求高的抓取还是建议上解析库比如 Python 的BeautifulSoup或lxml正则适合处理那些格式极其稳定的片段比如从 JSON 里抠数字、从一长串日志里抓 IP 和状态码。爬虫里还有一个高频操作是提取页面里所有链接links re.findall(rhref([^]), html)拿到之后通常还要用urljoin拼成完整 URL这个不属于正则本身的考点但考试时容易被顺带问一句“你怎么处理相对路径”提前准备一下会显得经验足。5. 正则表达式性能与安全从灾难性回溯到排查思路5.1 为什么一行正则能让 CPU 打满ReDoS 原理性能考点这几年出现的频率越来越高几乎成了“防挂题”。核心概念是灾难性回溯。最经典的例子是^(a)$把它拿去匹配aaaaaaaaaaaaaaaaaaaaaaaaaaaaaab前面的(a)贪婪吞掉所有a发现后面还有b不满足$于是吐出一个a进入内层再尝试外层又把a划分成不同段数每一次划分失败都要重新组合。随着a的个数增加尝试次数呈指数级增长。为什么会有这种问题因为NFA引擎允许“猜测 回退”。量词嵌套量词时引擎要为每一个可能的分割方式尝试一遍。攻击者只要构造一个让正则“几乎匹配、但最后不匹配”的长字符串就能占用大量CPU时间这被称为ReDoS。PHP中遇到这种情况preg_match可能触发pcre.backtrack_limit限制并直接报错Java 和 Python 的re模块则可能长时间卡住。避免灾难性回溯有几条路第一重写正则去掉嵌套量词^(a)$可以改成^a$第二用原子组比如^(?a)$原子组内一旦匹配就不再回退第三用占有量词写成^a$效果类似原子组但要注意不是所有语言都支持第四在工程层面限定输入长度和匹配超时。这些都是面试加分项能答出来说明你真的见过线上问题。5.2 转义和编码在不同语言里写同一份正则哪里最容易翻车同样的正则在 Java、Python、PHP、Perl 里写法可能天差地别差别往往出在“反斜杠被谁消费了”这件事上。Java 字符串里\d会被 Java 编译器先解析成特殊含义所以必须写成\\dPython 推荐用原生字符串r\d让反斜杠原样传给rePHP 单引号字符串里\d会保留反斜杠双引号\d也会保留因为\d不是 PHP 双引号里的转义序列但双引号里的$会做变量插值容易踩坑Perl 的正则可以直接写/ \d /x受语言字符串影响较小但替换串里$1的使用也需要区分它到底是变量还是捕获引用。编码方面中文匹配是高频考点。PCRE 中要匹配中文字符常用[\x{4e00}-\x{9fa5}]并且加u修饰符Java 字符串里则要写成[\u4e00-\u9fa5]。如果你在爬虫场景下处理中文网页还要先确认页面编码并解码否则正则在乱码上匹配中文必然失败。一个容易混淆的点是\s它默认匹配空格、制表符、换行、回车、换页、垂直制表符但不会匹配中文全角空格匹配全角空格要单独把\u3000放进字符类。5.3 定位“卡死正则”的三种排查思路如果线上已经出现了正则超时我习惯按下面三步排查。第一步是二分法把正则从中间切开分别测试哪一段匹配aaaaaaaa...这类长串时卡顿快速缩小范围。第二步是交给可视化工具把表达式粘贴到 regex101 或 regexr 这类在线调试工具里看每一步的回溯次数如果步数上万基本可以断定回溯爆了。第三步是简化输入法用一个最长的“不匹配”字符串测试不断减少字符个数当某条路径下步数骤增时就能复现递归展开的过程。我自己写复杂正则前会提前做一次“反向测试”不光验证该匹配的能匹配还验证最接近但不匹配的情况会不会超时。比如验证邮箱拿a..bexample.com这种连续点串去跑如果某个正则写法在这里开始卡那多半是边界分支没设计好。正则的核心不是“写出来能跑”而是在最坏情况下也能可控地返回结果。这套习惯在笔试里可能没有直接题目但面试官往往会从你的语气里听出你踩过坑。6. 考点自测清单与练手思路最后整理一份自测清单适合笔试前一天快速过一遍元字符、字符类、量词、锚点、分组与反向引用preg_match与preg_match_all的区别、Java 里\d写不写双反斜杠、Perl 的~和$1捕获、爬虫里.*?和[^]的取舍身份证正则和校验位算法能独立写出来能解释灾难性回溯。每一条如果都能在五分钟内讲清楚基本就不用慌。练手的时候不用追求把所有花哨语法都学会把常用的写熟就够了。我个人的习惯是每天挑三个场景各写一遍邮箱、手机号、IP再挑一个综合场景比如身份证或密码强度校验。写完直接丢进线上调试工具看步数观察哪个分支会让回溯次数暴涨比单纯背题管用得多。正则这东西属于典型的“知识一看就懂能力一写就废”动手练和不练差别非常大。最后分享一点实际体会正则这门“考点”最容易通过短期记忆应付过去但真正能拉开差距的是理解引擎行为。我在项目里见过很多人正则表达式能写却不知道贪婪量词和.*的区别线上一个(\d)直接把接口压垮。如果你准备备考我的建议是别只背公式而是每天手写两三个常用正则然后用在线工具观察它的匹配步数把“回溯”这个概念变成身体记忆。这样无论是笔试填空、面试追问还是到工作里写爬虫、做表单校验你都不会露怯。
返回列表