哈喽各位,我是前端小L。
欢迎来到我们的回溯算法专题第八篇!IP 地址我们都很熟悉,比如 192.168.1.1。 它有以下铁律:
-
段数固定 :必须正好分成 4 段。
-
数值范围 :每段是
0到255之间的整数。 -
前导零限制 :除了单独的
0以外,不能出现以0开头的数字(比如01,00是非法的,但0是合法的)。
给定一个只包含数字的字符串,我们要把它还原成所有可能的 IP 地址。这本质上还是一个切割问题,但我们需要更小心地控制刀法。
力扣 93. 复原 IP 地址
https://leetcode.cn/problems/restore-ip-addresses/

题目分析:
-
输入 :字符串
s(纯数字)。 -
目标 :插入三个
.,将其变成一个合法的 IP。 -
输出:所有可能的 IP 字符串列表。
例子: s = "25525511135"
-
255.255.11.135(合法) -
255.255.111.35(合法)
核心思维:深度限制 + 严格剪枝
回溯的模板依然是 for 循环横向遍历(切多长),递归纵向深入(切下一段)。但这里有两个显著不同:
1. 树的深度固定为 4 不像 LC 131 那样可以切任意多段,IP 地址只有 4 段。 所以,我们的 Base Case 不再仅仅是"切到了字符串末尾",还要加上 "当前已经切了 4 段" 的判断。
-
成功 :切了 4 段,并且
startIndex正好走到了字符串末尾。 -
失败:切了 4 段,但字符串还有剩余;或者字符串切完了,但段数不够 4。
2. 严格的剪枝 (Pruning) 在 for 循环尝试切一个子串 sub 时,以下情况直接剪枝(停止当前循环或跳过):
-
长度限制:子串长度大于 3(IP段最大255,只有3位)。
-
前导零 :子串长度大于 1 且以
'0'开头(如"01")。 -
数值限制:数值大于 255。
算法流程
-
状态变量:
-
startIndex:当前从哪开始切。 -
path:存放已经切好的合法片段(例如["255", "255"])。
-
-
Base Case:
-
if (path.size() == 4):-
如果
startIndex == s.size():说明正好切完,拼装path为 IP 字符串,加入结果。 -
否则:说明切了 4 段但还有剩余字符,非法,直接返回。
-
-
-
遍历尝试:
-
for i从startIndex开始。 -
长度剪枝 :如果
i - startIndex + 1 > 3,直接break(后面更长,肯定不行)。 -
截取子串 :
sub = s.substr(startIndex, 长度)。 -
合法性校验:
-
检查是否有前导零。
-
检查数值是否
> 255。
-
-
递归与回溯:
- 合法 ->
path.push_back(sub)->backtrack(i + 1)->path.pop_back()。
- 合法 ->
-
代码实现 (C++)
为了逻辑清晰,我使用 vector<string> path 来存储片段,最后拼接。虽然比起直接在原字符串插 . 稍微多一点点开销,但通用性强,完全沿用了上一题的模板,极易理解。
C++
#include <vector>
#include <string>
using namespace std;
class Solution {
private:
vector<string> res;
vector<string> path; // 存储切好的每一段,如 ["255", "255", "11", "135"]
// 判断子串是否合法
bool isValid(const string& sub) {
// 1. 长度校验
if (sub.length() > 3) return false;
// 2. 前导零校验 (长度大于1时,第一位不能是0)
if (sub.length() > 1 && sub[0] == '0') return false;
// 3. 数值校验 (不能大于 255)
// 注意:stoll 可能溢出,但因为限制了长度<=3,所以 int 足够
int num = stoi(sub);
if (num > 255) return false;
return true;
}
void backtrack(const string& s, int startIndex) {
// 1. Base Case: 已经切了4段
if (path.size() == 4) {
// 必须正好切完整个字符串才算成功
if (startIndex == s.size()) {
string ip = path[0] + "." + path[1] + "." + path[2] + "." + path[3];
res.push_back(ip);
}
return;
}
// 2. 遍历切割点
// i 是当前切割子串的结束位置
for (int i = startIndex; i < s.size(); ++i) {
// 剪枝:如果当前切的长度超过3,后面肯定也不行,直接 break
if (i - startIndex + 1 > 3) {
break;
}
string sub = s.substr(startIndex, i - startIndex + 1);
// 合法性校验
if (isValid(sub)) {
// 做选择
path.push_back(sub);
// 递归
backtrack(s, i + 1);
// 撤销选择
path.pop_back();
} else {
// 如果当前子串不合法(比如是 "01" 或 "256")
// 那么继续往后切只会更不合法(变成 "01..." 或 "256..."),
// 但如果是前导0错误,其实可以直接 break。
// 简单起见,这里 continue 或者 break 依据具体逻辑。
// 对于前导0和数值过大,其实后面肯定都不行了,break 效率更高。
// 为了通用性,用 continue 也没大错,但 break 更优。
}
}
}
public:
vector<string> restoreIpAddresses(string s) {
res.clear();
path.clear();
// 简单剪枝:IP地址最多12位,最少4位
if (s.size() > 12 || s.size() < 4) {
return {};
}
backtrack(s, 0);
return res;
}
};
深度复杂度分析
-
时间复杂度:常数级。
-
IP 地址固定分为 4 段,每段长度最多为 3。
-
整棵决策树的高度是固定的 4。每层的分支数最多是 3(切1位、切2位、切3位)。
-
总的状态数是极少的(34=81 种可能的切割方式,再乘上校验时间)。
-
虽然看起来是 O(3N),但因为深度固定,其实可以看作 O(1)。
-
-
空间复杂度:O(1)。
- 递归栈深度固定为 4。
总结:精准的"外科手术"
今天这道题,让我们体会到了回溯算法中的**"精细化操作"**。
-
LC 131 分割回文串:只管切,只要是回文就行。
-
LC 93 复原 IP :不仅要切,还要管切了几刀 (深度控制),还要管切下来的肉好不好吃(数值校验)。
这种在递归过程中,通过path.size()来严格控制递归深度的技巧,在后续的棋盘问题(如 N 皇后)中也会用到。
下一篇,我们将离开"切割"问题,去挑战回溯算法中最经典、最能体现"二维矩阵搜索"魅力的棋盘游戏 ------N 皇后问题。准备好在棋盘上排兵布阵了吗?
下期见!