39.复原IP地址【LC93】【搞定回溯】

116 阅读4分钟

题目

有效 IP 地址 正好由四个整数(每个整数位于 0 到 255 之间组成,且不能含有前导 0),整数之间用 '.' 分隔。

  • 例如:"0.1.2.201" 和 "192.168.1.1" 是 有效 IP 地址,但是"0.011.255.245"、"192.168.1.312" 和 "192.168@1.1" 是 无效 IP 地址。 给定一个只包含数字的字符串 s ,用以表示一个 IP 地址,返回所有可能的有效 IP 地址,这些地址可以通过在 s 中插入 '.' 来形成。你 不能 重新排序或删除 s 中的任何数字。你可以按 任何 顺序返回答案。

思路解析

以 "25525511135" 为例,第一步时我们有几种选择?

  • 选 "2" 作为第一个片段
  • 选 "25" 作为第一个片段
  • 选 "255" 作为第一个片段 能切三种不同的长度,切第二个片段时,又面临三种选择。
    这会向下分支,形成一棵树,我们用 DFS 去遍历所有选择,必要时提前回溯。
    因为某一步的选择可能是错的,得不到正确的结果,不要往下做了。撤销最后一个选择,回到选择前的状态,去试另一个选择。
回溯的第一个要点:选择

它展开了一颗空间树

回溯的要点二:约束

约束条件限制了当前的选项,这道题的约束条件是:

  • 一个片段的长度是 1~3
  • 片段的值范围是 0~255
  • 不能是 "0x"、"0xx" 形式(测试用例告诉我们的) 用这些约束进行充分地剪枝,去掉一些选择,避免搜索「不会产生正确答案」的分支。
回溯的要点三:目标
  • 目标决定了什么时候捕获答案,什么时候砍掉死支,回溯。
  • 目标是生成 4 个有效片段,并且要耗尽 IP 的字符。
  • 当条件满足时,说明生成了一个有效组合,加入解集,结束当前递归,继续探索别的分支。
  • 如果满4个有效片段,但没耗尽字符,不是想要的解,不继续往下递归,提前回溯。
定义 dfs 函数
  • dfs 函数传什么?也就是,用什么描述一个节点的状态?
  • 选择切出一个片段后,继续递归剩余子串。可以传子串,也可以传指针,加上当前的片段数组,描述节点的状态。
  • dfs 函数做的事:复原从 start 到末尾的子串。

image.png

如图['2','5','5','2']未耗尽字符,不是有效组合,不继续选下去。撤销选择"2",回到之前的状态(当前分支砍掉了),切入到另一个分支,选择"25"。

回溯会穷举所有节点,通常用于解决「找出所有可能的组合」问题。

下图展示找到一个有效的组合的样子。start 指针越界,代表耗尽了所有字符,且满 4 个片段。

image.png

这有一份回溯的讲义 大白话的英文,耐心看完收获不小。

Backtracking is a form of recursion.
The usual scenario is that you are faced with a number of options, 
and you must choose one of these. 
After you make your choice you will get a new set of options; 
just what set of options you get depends on what choice you made. This procedure is repeated over and over until you reach a final state.
If you made a good sequence of choices, 
your final state is a goal state; if you didn't, it isn't.

粗略翻译:回溯是递归的一种形式,通常情况是,你面临一些选项,你必须选择其中一个。
在你做出选择后,你又会得到一组新的选择,即你所得到的选项取决于你所做的选择。
这种步骤不断重复,直到你到达最终状态,如果你一直做出对的选择,最后的状态就是目标状态。
如果你没有,它就不是。

代码示例

const restoreIpAddresses = (s) => { //回溯法
  if (!s || s.length < 4 || s.length > 12) {
    return [];
  }
  let res = [];
  // 复原从start开始的子串
  const dfs = (start, path) => { // start标识当前段起始位置idx
    if (path.length === 4 && start === s.length) {
      // 片段满4段,且耗尽所有字符
      res.push(path.join('.')) // 拼成字符串,加入解集
      return; // 返不返回都行,指针已经到头了,严谨的说还是返回
    };
    if (path.length === 4 && start < s.length) {
      // 满4段,字符未耗尽,不用往下选了
      return;
    }
    // path还没满的情况
    for (let i = 1; i <= 3; i++) { // 枚举出选择,三种切割长度
      // len === i,有可能长度超出,也有可能数值超出
      if (start + i - 1 >= s.length) { // 加上要切的长度就越界,不能切这个长度
        return;
      }
      // 不能是0x或者0xx,可以是0
      if (i != 1 && s[start] == '0') return;
      let str = s.substring(start, start + i) // 当前选择切出的片段
      if (+str > 255) {// 不能超过255
        return;
      }
      path.push(str) // 作出选择,将片段加入path
      dfs(start + i, path) // 基于当前选择,继续选择
      path.pop() 
      // 上面一句的递归分支结束,撤销最后的选择,进入下一轮迭代,考察下一个切割长度
    }
  }
  dfs(0, [])
  return res;
}