目录

题目描述

LCR 087. 复原 IP 地址

题意分析

给一个只含数字的字符串 s,在其中插入三个点,把它切成四段,使每一段都是合法的 IPv4 地址段,返回所有可能的结果。不能重排、不能增删任何字符,只能决定三个点插在哪里。

「合法段」的定义要抠准,它由三条独立的规则组成:段的数值在 [0, 255] 之间;段的长度在 1 到 3 之间(这条其实被前一条与下一条蕴含,但写代码时当作循环上界很方便);除了单独的 "0" 之外不允许有前导零,所以 "01""00""012" 都非法,而 "0" 合法。第三条是本题最容易漏的规则,也是出题人真正想考的细节。

题目要输出所有方案,说明必须枚举。切法的总数很有限:三个点各自有不超过 3 种相对位置,粗算不超过 $3^3 = 27$ 种组合,因此本题的难点从来不是效率,而是把三条合法性规则不遗漏地编码进搜索

约束 1 ≤ s.length ≤ 20 且只含数字,这给出一个立刻可用的信号:四段最多覆盖 12 个字符,所以长度超过 12 的输入必然无解;长度小于 4 的输入同样无解。边界还包括:"0000" 应输出 "0.0.0.0"(四个单零全合法);"25525511135" 这类恰好卡在 255 附近的输入要能正确区分 "255""256";无解时返回空列表而不是含空串的列表。

解法:回溯搜索

核心思路

骨架仍是「枚举分割点」:dfs(i) 表示 s[0..i-1] 已经被切成若干合法段并存在 t 中,当前要决定从下标 i 开始的这一段延伸到哪里。每层横向枚举段尾 j,合法就递归 dfs(j + 1)

与「分割回文串」的差别在于本题有两个必须同时满足的终止条件:字符要用完(i == n),段数要恰好是 4(t.size() == 4)。只满足其一都不算答案。字符用完但只切了 3 段,说明段切得太长;切满 4 段但还剩字符,说明段切得太短。这两种失败必须分别识别并及时返回,否则会产出残缺答案。

剪枝的关键观察是:段的合法性判断可以在逐字符延伸的过程中增量完成,而且一旦失效就再也不会恢复,因此可以用 break 而不是 continue。具体地,在内层循环里维护 x 表示 s[i..j] 的数值,每延长一位就 x = x * 10 + (s[j] - '0')

于是两条 break 条件成立:x > 255 时继续延长只会让数值更大,本段及其后所有更长的切法全部无效;s[i] == '0' && i != j 时说明本段以 0 开头且长度大于 1,前导零已经出现,再延长依然带前导零。两者都是单调失效的性质,所以用 break 整体截断,而不是逐个 continue 跳过。

循环上界 j < min(i + 3, n) 把段长限制在 3 以内,同时防止越界。要维持的不变量是:t 中每一段都合法,且 t 拼接起来恰好等于 s[0..i-1]

解题步骤

  • 成功条件放最前if (i >= n && t.size() == 4) 时用 . 把四段连接成 IP 存入答案并返回。两个条件必须同时成立,缺一都不是合法答案。
  • 失败条件紧随其后if (i >= n || t.size() >= 4) return;。走到这里说明上一个判断没通过,那么要么字符用完了但段数不够,要么段数已满但字符没用完,两种情况都无解,直接剪枝。把这一步写在循环之前,可以避免在段数已满时还去做无谓的横向枚举。
  • 初始化本段数值int x = 0;,放在循环外,随 j 的推进累积。用数值累加而不是每次 Integer.parseInt(substring),既省去反复建子串的开销,也让 x > 255 的剪枝可以在延伸途中立即触发。
  • 枚举段尾并增量求值for (int j = i; j < Math.min(i + 3, n); ++j)x = x * 10 + s.charAt(j) - '0'。上界取 i + 3n 的较小值,前者限制段长不超过 3,后者防越界。
  • 两条 break 剪枝if (x > 255 || (s.charAt(i) == '0' && i != j)) break;。前导零条件写成 s.charAt(i) == '0' && i != j,含义是「本段首字符是 0 且本段长度大于 1」;i == j 时是单独的 "0",必须放行。
  • 选择、递归、撤销t.add(s.substring(i, j + 1)),递归 dfs(j + 1),返回后 t.remove(t.size() - 1)。递归传 j + 1 是下一段的起点。

s = "10203" 走一遍,n = 5。四段总长必须是 5,所以各段长度只能是 (2,1,1,1)(1,2,1,1)(1,1,2,1)(1,1,1,2) 四种分配之一。

dfs(0)t = []x 从 0 开始。j = 0x = 1,不超 255,首字符 '1' 不触发前导零,切出 "1",进入 dfs(1)

dfs(1)s[1] = '0'j = 1x = 0i == j 所以前导零规则放行,切出 "0",进入 dfs(2)dfs(2)j = 2 切出 "2" 进入 dfs(3)dfs(3)s[3] = '0'j = 3 切出 "0" 进入 dfs(4)——此时 i = 4 < nt.size() == 4,命中失败条件返回;回到 dfs(3)j = 4s[3] == '0' && i != j 触发 break"03" 被正确否决。

回到 dfs(2)j = 3x = 2 * 10 + 0 = 20,切出 "20",进入 dfs(4)dfs(4)j = 4 切出 "3",进入 dfs(5),此时 i = 5 >= nt.size() == 4记下第一个答案 "1.0.20.3"。继续 dfs(2)j = 4x = 203,不超 255,切出 "203" 进入 dfs(5),但 t.size() 只有 3,落入失败条件返回。

回到 dfs(1)j = 2s[1] == '0'i != jbreak"02" 被否决——这正是前导零规则挡掉的分配 (1,2,1,1)

回到 dfs(0)j = 1x = 10,切出 "10" 进入 dfs(2);沿 "2""0""3" 一路下去在 dfs(5) 处凑满四段,记下第二个答案 "10.2.0.3"。该分支的其它切法(如 "10","20","3" 只有三段)都在失败条件处被拦下。dfs(0)j = 2x = 102,切出 "102",其后最多再凑出 "0""3" 两段,总数不足 4,全部失败。

最终答案是 ["1.0.20.3", "10.2.0.3"]。若漏掉前导零判断,会额外产出 "1.02.0.3""1.0.2.03" 两个非法结果。

代码实现

class Solution {
    private int n;
    private String s;
    private List<String> answer = new ArrayList<>();
    private List<String> t = new ArrayList<>();

    public List<String> restoreIpAddresses(String s) {
        n = s.length();
        this.s = s;
        dfs(0);
        return answer;
    }

    // i:当前待切分段的起点,s[0..i-1] 已切成 t 中的若干合法段。
    private void dfs(int i) {
        // 字符用完且恰好四段,才是答案。
        if (i >= n && t.size() == 4) {
            answer.add(String.join(".", t));
            return;
        }
        // 字符用完但段数不足,或段数已满但字符没用完,都无解。
        if (i >= n || t.size() >= 4) {
            return;
        }
        int x = 0;
        // 段长最多 3,同时不越界。
        for (int j = i; j < Math.min(i + 3, n); ++j) {
            x = x * 10 + s.charAt(j) - '0';
            // 超过 255 或出现前导零后,继续延长只会更糟,直接 break。
            if (x > 255 || (s.charAt(i) == '0' && i != j)) {
                break;
            }
            t.add(s.substring(i, j + 1));
            dfs(j + 1);
            t.remove(t.size() - 1);
        }
    }
}
func restoreIpAddresses(s string) (answer []string) {
    n := len(s)
    t := []string{}

    // i:当前待切分段的起点,s[0..i-1] 已切成 t 中的若干合法段。
    var dfs func(int)
    dfs = func(i int) {
        // 字符用完且恰好四段,才是答案。
        if i >= n && len(t) == 4 {
            answer = append(answer, strings.Join(t, "."))
            return
        }
        // 字符用完但段数不足,或段数已满但字符没用完,都无解。
        if i >= n || len(t) == 4 {
            return
        }
        x := 0
        // 段长最多 3,同时不越界。
        for j := i; j < i+3 && j < n; j++ {
            x = x*10 + int(s[j]-'0')
            // 超过 255 或出现前导零后,继续延长只会更糟,直接 break。
            if x > 255 || (j > i && s[i] == '0') {
                break
            }
            t = append(t, s[i:j+1])
            dfs(j + 1)
            t = t[:len(t)-1]
        }
    }
    dfs(0)
    return
}

复杂度分析

  • 时间复杂度:$O(1)$ 级别的常数时间。段数固定为 4,每段最多 3 种长度,搜索树的叶子不超过 $3^4 = 81$ 个;每条成功路径拼接字符串的开销不超过 $O(12)$。输入长度虽然可达 20,但超过 12 的部分会在前几层就被段数与长度限制截断,规模与 n 无关。
  • 空间复杂度:$O(1)$,递归深度最多 5 层,t 中最多 4 个长度不超过 3 的字符串。返回值按惯例不计入。

关键点总结

  • 多条件终止要拆成「成功」与「失败」两个判断分别写:本题必须同时满足「字符用完」和「段数为 4」,把两者合成一个条件或只判其一,都会产出残缺答案。凡是终止条件由多个维度组成的搜索题,都该这样拆。
  • 「单调失效」的约束用 break 而不是 continuex > 255 和前导零一旦成立就不会随着段延长而恢复,break 能省掉整段无效枚举。识别约束是「单调」还是「非单调」,是写好剪枝的前提。
  • 增量维护段的数值而不是反复 parseInt(substring),既避免了重复建串,也让越界剪枝可以在延伸过程中立刻触发。
  • 前导零的正确写法是「首字符为 0 且段长大于 1」,不是「首字符为 0」。面试时能主动举出 "0" 合法、"01" 非法这组对照,说明规则读到位了。
  • 善用约束反推早停条件:四段最多 12 个字符,所以 n > 12n < 4 时可以直接返回空。哪怕代码里不写这个特判,面试中说出来也是加分项。

易错点总结

  • 忘记前导零判断s = "010010" 会输出 "0.10.01.0" 这类含 "01" 的非法结果,答案数量明显多于预期。
  • 前导零条件写成 s.charAt(i) == '0' 而不带 i != js = "0000" 时四个单独的 "0" 全被否决,本应输出 "0.0.0.0" 却返回空列表。
  • 只判 i >= n 就收答案s = "1111" 会把只切了 2 段的 "11.11" 也当成答案输出。
  • 只判 t.size() == 4 就收答案s = "101023" 会输出 "1.0.1.0" 这种把末尾 "23" 丢掉的结果。
  • 漏掉失败条件那一行 if (i >= n || t.size() >= 4) return;s = "1111" 在切满四段后仍继续往下枚举,substring 越界抛异常。
  • 数值判断写成 x >= 255s = "255255255255" 会把合法的 "255" 段否决,正确答案 "255.255.255.255" 丢失。
  • 循环上界写成 j < i + 3 而不与 n 取小s = "12"j 会取到 2、3,s.charAt(j) 越界抛异常。
  • 递归传 j 而不是 j + 1s = "1111" 第一段 "1" 之后又从下标 0 重新开始,字符被重复使用,产出 "1.1.1.1" 之外还带上无限深的错误路径。
  • 忘记 t.remove(t.size() - 1)s = "10203" 找到 "1.0.20.3" 后残留不清,第二个分支会拼出五段甚至更多段的字符串。
  • x > 255 的判断写成 continues = "9999999999" 时每一层都要把三种长度全试一遍,虽然结果仍对,但白白多枚举了必然失败的更长段。
  • 用正则或 String.split 事后校验整个 IPs = "25525511135" 结果虽对,但等于先生成再过滤,既绕过了增量剪枝这个考点,也无法解释「为什么可以 break」。

相似题目

题目 难度 考察点
93. 复原 IP 地址 中等 与本题完全同题,代码可原样提交
131. 分割回文串 中等 同为枚举分割点,段数不固定,合法性判断换成回文且可预处理成表
LCR 086. 分割回文串 中等 与 131 同题,可对照体会「段数固定」与「段数任意」在终止条件上的差别
139. 单词拆分 中等 段的合法性由词典给出,只问能否拆分,退化为一维可达性 DP
140. 单词拆分 II 困难 要求列出全部拆分方案,骨架相同但需记忆化,否则指数级重算
468. 验证IP地址 中等 只做判定不做切分,但要同时处理 IPv4 与 IPv6 的完整规则
306. 累加数 中等 同样是数字串切分加前导零规则,但段与段之间还有加法关系约束