拿到【字符串分割】验证IP地址这个题目我第一反应是这不就是拿分隔符切一下嘛点分十进制四个数字逐段判断范围输出合法或非法。直到在一次代码评审里看到用同样思路写出来的校验函数被1.2.3.这个用例打回紧接着又在另一个项目里因为01.2.3.4这类前导零输入被上游系统直接拒绝我才意识到题目里字符串分割四个字恰恰是这道题最容易被低估的部分。这篇文章不打算只贴一段标准答案而是想把从第一版split实现、到手写扫描校验、再到生产环境可用校验器的完整链路拆开讲。适合正在刷字符串题目的读者也适合在真实项目里需要做IP合法性校验的人。我会把踩过的坑、代码评审里看到的问题以及最后沉淀下来的实现思路都写在这里。1. 这个题目真正要考核的不是会不会split1.1 一次代码评审让我重新审视这个题目我见过不止一次这样的实现先按点把字符串切开然后对每段做整数转换落在0到255就算过。用这类写法的人通常栽在三个地方。第一个是分隔符本身。像1.2.3.这种尾随点在一些语言的split实现里会被静默忽略四个有效段也能凑齐但按规范根本不该过更隐蔽的是1.2.3.4.前四段都正常第五段消失后段数恰好还是四段合法判定第一关直接就失真了。第二个是前导零。字符串01.2.3.4转成整数后是1范围判断完全看不出来但它并不是合法的点分十进制表示。第三个是语义混淆。题目只问格式是否合法不是这个IP通不通或是否落在某个网段很多人会把这几种需求混在一起导致校验函数里塞进一堆跟格式无关的逻辑。代码评审里发生的真实一幕是同事提交的校验函数自测用例全过换到包含尾随点、前导零、大小写十六进制混合的数据集上马上出现误判。与其反复调试split不如先把校验规则完整列出来再选实现方式。1.2 IPv4和IPv6的格式差异决定了校验思路这个题目第二层容易被忽略的地方是IPv4和IPv6共用分割再判断的外壳但内部规则完全不同。IPv4是32位地址用点分十进制表示四个数字段各占一个字节取值范围0到255段内必须是十进制数字。IPv6是128位地址用冒号分隔的八组十六进制数表示每组一到四位字符范围是0-9和a-f大小写均可。设计差异带来的直接结果是校验时必须区分类型不能把同一个切段、转数字、看范围的流程套在两种格式上。IPv6组能不能转成十进制数再做0到65535的范围判断理论上可以因为每组十六进制最多四位数值范围恰好是0x0000到0xFFFF。但更稳妥的做法是直接检查字符串里的每个字符是否属于十六进制字符集同时限制组长度不超过4位。这样既避免了进制转换的边界问题也杜绝了0x这类前缀混入的可能——标准文本表示里不允许出现0x前缀一旦先做进制转换这种非法写法很容易被误放行。2. IPv4这道坎点分十进制里的前导零陷阱2.1 最顺手的split做法为什么连错三步先说最直觉的版本。假设用支持正则分割的语言写1.2.3.4的校验第一反应是把点号当分隔符切成[1,2,3,4]然后逐段检查。第一个坑是转义。如果直接写1.2.3.4.split(.)在很多语言里点号是正则通配符能匹配任意字符于是字符串会被切成一堆空串结果完全离谱。正确做法是写成转义后的\.或者使用字面量分割API。第二个坑是尾随空串。1.2.3.如果被切成[1,2,3]段数从四变成三数字范围检查全过但段数不足还有机会被拦下来。但1.2.3.4.这种输入split默认丢弃尾随空串后得到[1,2,3,4]正好四个元素——段数检查直接通过非法输入就被错误放行了。第三个坑是前导零。01.2.3.4经过split和整数转换后变成[1,2,3,4]范围判断全部通过。可它在点分十进制规范里是非法表示。一个只有split加int加范围判断的校验器对这类输入毫无防御力。测试用例期望结果直接split后可能出现的误判1.2.3.4合法正常通过1.2.3.非法可能只剩3段误判非法侥幸1.2.3.4.非法丢尾空串后仍是4段误判合法01.2.3.4非法int后为1范围通过误判合法256.1.1.1非法int后超范围能拦下2.2 手工扫描法把五条规则一次落实与其和split的语义斗智斗勇不如逐字符扫描。IPv4格式校验需要同时满足五条规则必须恰好出现三个点号任意两个点号之间必须存在至少一个字符每个字段只能由ASCII数字组成字段长度不能超过3如果字段长度大于1首字符不能是0字段的数值不能超过255。逐字符扫描时用一个变量累积当前字段的数值遇到点号就结算一个字段检查上面五条里适用的部分然后重置状态继续。这么做的好处很直观尾随点会产生一个空字段在字段必须有内容这条规则下直接暴露前导零会在首字符为0且长度大于1的规则下被当场拦截字母字符会在只能由ASCII数字组成的规则下被拒绝。规则和检查点一一对应。这里我给一个不借助split的Python实现def valid_ipv4(s: str) - bool: if not s or len(s) 7 or len(s) 15: return False dots 0 num 0 num_len 0 for i, ch in enumerate(s): if ch .: if num_len 0 or num 255: return False dots 1 num 0 num_len 0 elif 0 ch 9: if num_len 0 and ch 0 and i 1 len(s) and s[i 1] ! .: return False num num * 10 int(ch) num_len 1 if num_len 3 or num 255: return False else: return False return dots 3 and num_len 0 and num 255这段代码里最值得讲的是那个前导零判断条件num_len 0说明当前字符是某个字段的第一位如果这位是0同时后面还有字符且不是点号说明这个字段形如01、001直接拒绝。如果字段只有一个0比如10.0.0.1里的0它的下一位是点号条件不满足正常放行。为什么不建议用正则IPv4的正则写起来很绕匹配0到255的分支结构本身就容易出错还要额外限制字段长度、排除前导零最后正则串比校验逻辑还难读。工程项目里我优先选择扫描法逻辑透明、可扩展还方便在出错时定位。2.3 前导零被禁止不是矫情是历史教训很多人觉得01.2.3.4这种输入没必要管反正转换成数字后语义一样。但前导零被禁止背后是有实际教训的。一些传统系统受到早期编程语言八进制字面量的影响会把前导零的数字按八进制解释。同一个字符串一部分解析器按十进制读成1.2.3.4另一部分按八进制读成别的数值语义就对不上了。历史上甚至出现过利用这种解析差异绕过访问控制的案例。为杜绝歧义IP地址规范明确禁止前导零的文本表示。所以写校验函数时这个检查不能省略。看起来是在为难字符串处理实际上是在规避一类真实存在的安全与兼容性问题。只要目标是合法格式校验前导零就必须拒绝。3. IPv6校验十六进制的组比想象的严格3.1 IPv6的完整规则逐条拆开看IPv6标准文本表示的核心规则可以概括为八组每组一到四位十六进制数字组与组之间用单个冒号分隔。展开来看第一十六进制字符包括0-9和a-f大写小写都允许所以8A2E和8a2e是等价的合法写法。第二每组允许前导零0000是合法组0370也合法这和IPv4正好相反。第三不允许空组所以任何连续冒号、首冒号、尾冒号都必须判非法。这里有一个常见误区真正使用中的IPv6地址常常用::压缩表示连续的零组比如2001:db8::1。但本题要求验证的是完整形式的IPv6文本压缩写法不在考量范围内。所以::在本校验器里应当返回非法不代表这个地址在真实网络里不合法而是题目或需求限定了输入格式。可以对照下面这些用例自测测试用例期望结果2001:0db8:85a3:0:0:8A2E:0370:7334合法2001:db8:85a3:0000:0000:8a2e:370:7334合法0:0:0:0:0:0:0:0合法1:2:3:4:5:6:7:8合法2001:0db8:85a3::8A2E:0370:7334非法连续冒号2001:0db8:85a3:0:0:8A2E:0370:7334:非法尾冒号:2001:0db8:85a3:0:0:8A2E:0370:7334非法首冒号2001:db8:85a3:0:0:8A2E:0370:gh34非法非法字符3.2 校验的风险点集中在空组处理很多人第一次写IPv6校验用的是先分割再判断长度的思路按冒号分割看切出来是不是八段每段长度一到四字符都在十六进制范围内。思路本身没错但风险点全在空组的处理上。比如2001:db8::1按冒号分割在Python里会得到[2001,db8,,1]中间出现空字符串。如果检查逻辑只是遍历每段看长度是否在1到4之间空串长度为0会被拦下来没问题。但如果某个语言或API的分割结果丢弃了空串得到[2001,db8,1]段数检查可能直接失败也能拒绝。真正危险的是另一种情况尾随冒号场景比如1:2:3:4:5:6:7:分割结果里尾空串被丢弃剩七段段数不等于八会被拦可如果检查逻辑写得不严谨比如段数小于等于8就继续逐段检查尾空串丢了七段字符又都合法就有可能误判。核心结论是用分割法做IPv6校验段数恰好等于8和每段非空两道锁缺一不可。因为分割函数在不同语言里对空串的处理逻辑不一样任何只依赖其中一道锁的写法都可能在某个环境下翻车。3.3 一次扫描完成IPv6校验的实现我的习惯是不依赖split直接用一次扫描完成全部检查下面是Python实现def valid_ipv6(s: str) - bool: if not s or len(s) 39: return False hex_chars set(0123456789abcdefABCDEF) colon_count 0 group_len 0 for ch in s: if ch :: if group_len 0: return False colon_count 1 group_len 0 if colon_count 7: return False elif ch in hex_chars: group_len 1 if group_len 4: return False else: return False return colon_count 7 and group_len 0这里的关键点有三个遇到冒号时必须保证前面已经累积了非空内容否则就是首冒号、连续冒号或上一个组为空冒号数一旦超过7直接拒绝保证不可能出现九组以上循环结束后检查colon_count 7 and group_len 0确保正好八组且最后一个组非空尾冒号在这里被拦下。整个校验不产生分割数组不依赖任何语言的split空串行为复杂度O(n)空间O(1)。如果将来需求要支持::压缩表示算法会复杂不少因为一个::可以代表连续多组零需要额外解析。但就本题语义而言上面这个版本已经足够清晰可靠。4. 不同语言里split的语义差异同一道题两个世界4.1 Java的split不带负limit会悄悄吞掉尾随空串很多从Python或JavaScript迁移到Java的开发者会在这一处翻车。Java的String.split默认丢弃尾随空字符串这是API设计的一部分不是bug。看这个例子String input 1.2.3.4.; String[] parts input.split(\\.); System.out.println(parts.length); // 输出 4 String[] fixed input.split(\\., -1); System.out.println(fixed.length); // 输出 5第一个split(\\.)返回的是[1,2,3,4]四个元素段数检查直接通过——1.2.3.4.被误判成合法地址。第二个写法传入了负limit表示尽可能保留所有字段包括空串于是得到[1,2,3,4,]第五个空串让段数检查恢复作用。需要注意的是Java的split第一个参数是正则表达式。正则里.是任意字符必须写成\.而在Java字符串字面量里反斜杠还要再转义一层所以最终写成了\\.。这个双重转义是另一处高频出错点。4.2 不同语言对split空串的处置完全不一样同一段逻辑在不同语言里的行为可能完全不同。我整理了一份常见的split行为对照语言 / API示例尾随空串是否保留备注JavaString.split(String)1,2,.split(,)否不传负limit会丢Pythonstr.split(str)1,2,.split(,)是字面量分割非正则JavaScriptString.split(str)1,2,.split(,)是字面量分割非正则Gostrings.Splitstrings.Split(1,2,, ,)是返回包含尾空串C#string.Split(char)1,2,.Split(,)否默认移除空条目这不是哪个语言更好的问题而是提醒我们写校验逻辑时不要把判断建立在对某个特定split行为的依赖上。最稳妥的办法是完全避开split用逐字符扫描把字段边界控制在自己的状态变量里这样换语言时才不会出现同一套逻辑换了运行环境就翻车的情况。4.3 用扫描法写一份跨语言一致的校验函数把前面两段代码组合起来就是一个完整的入口函数def valid_ip_address(ip: str) - str: if not ip: return Neither if . in ip: return IPv4 if valid_ipv4(ip) else Neither if : in ip: return IPv6 if valid_ipv6(ip) else Neither return Neither这个入口利用包含点号还是冒号做简单分流。更严谨的做法是点号和冒号都不存在时返回Neither两者都出现时如果后续要实现IPv4映射IPv6如::ffff:192.0.2.1再走专门逻辑目前简单拒绝即可。返回值设计成IPv4、IPv6、Neither是评测场景的常见格式如果面向真实生产我更推荐返回结构化的校验结果把错误原因一起带回来这部分在下一章展开。5. 从算法题到生产环境地址校验器的工程化落地5.1 真实数据里不会只有干干净净的IP算法题里输入只有两类合法字符串和非法字符串。生产环境不是这样。日志清洗时会遇到带端口的192.168.1.1:8080带方括号的IPv62001:db8::1:8080严格来说是[2001:db8::1]:8080带CIDR的192.168.1.0/24还有带前导空格的 192.168.1.1甚至混入全角冒号、全角点号。如果直接拿算法题的判断逻辑去线上这些输入都会返回不是合法地址。从校验语义来说没错但从产品视角会把很多可读信息丢掉。真实项目里的校验器职责往往不是非黑即白而是先识别里面藏的到底是不是地址再决定要不要提取、要不要纠偏。5.2 端口、CIDR和脏数据该怎么拆常见的业务扩展有三种IP加端口。IPv4场景相对简单可以先把最右侧的冒号切出来判断冒号右边是不是纯数字端口IPv6的端口通常用方括号包裹形如[2001:db8::1]:8080需要先把外层方括号剥掉再对内部IPv6做格式校验。CIDR前缀。输入形如192.168.1.0/24先把/右侧的掩码位宽提取出来IPv4限制在0到32IPv6限制在0到128再做整数范围判断其余部分继续走地址格式校验。如果不拆开/本身会导致字符集校验失败。脏数据。前后空白直接strip全角冒号、全角点号可以在输入侧做归一化映射。我倾向于在入口做清理而不是在校验器内部隐式修复否则接口语义会变得不可预期。5.3 状态机版校验器与错误定位设计生产友好的校验器最好返回结构化错误而不是简单的true/false。比如定义这样的结果对象class ValidationResult: def __init__(self, valid: bool, address_type: str, error: str ): self.valid valid self.address_type address_type self.error error错误消息可以这样组织第2段01包含前导零IPv4不允许、第3组包含非法字符g、IPv6组数不足期望8组实际7组。要做到这一点扫描时记录当前是第几字段、当前字段的起始位置。最简单的方式是遇到分隔符时把当前累计的字段内容暂存下来相当于一次保留空字段的手动分割再把上一段交给检查函数也可以记录segment_index和start_index报错时直接切片。一次遍历加错误定位虽然比最小实现多几行代码但线上排查问题时的效率提升非常大。性能方面扫描法本身是O(n)空间O(1)不产生分割数组。单次调用和split差异不大但校验函数通常会被循环调用每秒处理几十万条日志时少创建大量临时字符串对象GC压力会明显小很多。这也是我在批量场景里坚持用扫描法的原因之一。在多次处理类似校验需求后我个人的体会是字符串分割类题目最大的价值不在让你记住某个语言的API细节而是逼你把分隔符、边界、空串、残留字段这些状态变量管理好。这个能力在解析CSV、日志、URL参数、配置文件时都会复用。最后再分享一个小技巧动手写代码前先在纸上画出字段开始、累积字符、遇到分隔符、结算字段的状态流转再落到代码上错误率会低很多换语言实现时也能第一时间避开split的语义坑。