回溯之分割与子集:分割回文串/复原IP地址、子集/子序
Author:zhoulujun Date:
如果把 子集问题、组合问题、分割问题都抽象为一棵树的话,那么组合问题和分割问题都是收集树的叶子节点,而子集问题是找树的所有节点!
其实子集也是一种组合问题,因为它的集合是无序的,子集{1,2} 和 子集{2,1}是一样的。
切割问题
切割问题其实是一种组合问题!
分割回文串
给定一个字符串 s,将 s 分割成一些子串,使每个子串都是回文串。返回 s 所有可能的分割方案。
示例: 输入: "aab" 输出: [ ["aa","b"], ["a","a","b"] ]
这道题目在leetcode上是中等,但可以说是hard的题目了,但是代码其实就是按照模板的样子来的。
那么难究竟难在什么地方呢?
切割问题可以抽象为组合问题
如何模拟那些切割线
切割问题中递归如何终止
在递归循环中如何截取子串
如何判断回文
本题这涉及到两个关键问题:
切割问题,有不同的切割方式
判断回文
回溯究竟是如何切割字符串呢?
其实切割问题类似组合问题。
很多同学知道要用回溯法,但是不知道如何用。也就是没有体会到按照求组合问题的套路就可以解决切割。
例如对于字符串abcdef:
组合问题:选取一个a之后,在bcdef中再去选取第二个,选取b之后在cdef中再选取第三个.....。
切割问题:切割一个a之后,在bcdef中再去切割第二段,切割b之后在cdef中再切割第三段.....。
递归用来纵向遍历,for循环用来横向遍历,切割线(就是图中的红线)切割到字符串的结尾位置,说明找到了一个切割方法。
此时可以发现,切割问题的回溯搜索的过程和组合问题的回溯搜索的过程是差不多的。
那么在代码里什么是切割线呢?
if (start >= s.length) { results.push([...current]);// 如果起始位置已经大于s的大小,说明已经找到了一组分割方案了 return; }
在递归循环中如何截取子串呢?
for (let end = start; end < s.length; end++) { const substring = s.substring(start, end + 1); if (isPalindrome(substring)) {// 是回文子串 current.push(substring); backtrack(end + 1, current);// 寻找i+1为起始位置的子串 current.pop();// 回溯过程,弹出本次已经添加的子串 } else {// 如果不是则直接跳过 可以忽略 continue; } }
注意切割过的位置,不能重复切割,所以,backtracking(s, i + 1); 传入下一层的起始位置为i + 1。
最终代码
function partition(s) { const results = []; function backtrack(start, current) { if (start === s.length) { results.push([...current]); return; } for (let end = start; end < s.length; end++) { const substring = s.substring(start, end + 1); if (isPalindrome(substring)) { current.push(substring); backtrack(end + 1, current); current.pop(); } } } backtrack(0, []); return results; } function isPalindrome(str) { let left = 0, right = str.length - 1; while (left < right) { if (str[left] !== str[right]) { return false; } left++; right--; } return true; }
193.复原IP地址
给定一个只包含数字的字符串,复原它并返回所有可能的 IP 地址格式。
有效的 IP 地址 正好由四个整数(每个整数位于 0 到 255 之间组成,且不能含有前导 0),整数之间用 '.' 分隔。
例如:"0.1.2.201" 和 "192.168.1.1" 是 有效的 IP 地址,但是 "0.011.255.245"、"192.168.1.312" 和 "[email protected]" 是 无效的 IP 地址。
示例 1:输入:s = "25525511135" 输出:["255.255.11.135","255.255.111.35"]
示例 2:输入:s = "0000" 输出:["0.0.0.0"]
其实只要意识到这是切割问题,切割问题就可以使用回溯搜索法把所有可能性搜出来,和刚做过的131.分割回文串 (opens new window)就十分类似了。
切割问题可以抽象为树型结构,如图:
递归终止条件
回溯三部曲
递归参数
在131.分割回文串 (opens new window)中我们就提到切割问题类似组合问题。
startIndex一定是需要的,因为不能重复分割,记录下一层递归分割的起始位置。
本题我们还需要一个变量pointNum,记录添加逗点的数量。
所以代码如下:
vector<string> result;// 记录结果 // startIndex: 搜索的起始位置,pointNum:添加逗点的数量 void backtracking(string s, int startIndex, int pointNum) {
递归终止条件
终止条件和131.分割回文串 (opens new window)情况就不同了,本题明确要求只会分成4段,所以不能用切割线切到最后作为终止条件,而是分割的段数作为终止条件。
pointNum表示逗点数量,pointNum为3说明字符串分成了4段了。
然后验证一下第四段是否合法,如果合法就加入到结果集里
代码如下:
if (pointNum == 3) { // 逗点数量为3时,分隔结束 // 判断第四段子字符串是否合法,如果合法就放进result中 if (isValid(s, startIndex, s.size() - 1)) { result.push_back(s); } return; }
单层搜索的逻辑
在131.分割回文串 (opens new window)中已经讲过在循环遍历中如何截取子串。
在for (int i = startIndex; i < s.size(); i++)循环中 [startIndex, i] 这个区间就是截取的子串,需要判断这个子串是否合法。
如果合法就在字符串后面加上符号.表示已经分割。
如果不合法就结束本层循环,如图中剪掉的分支:
然后就是递归和回溯的过程:
递归调用时,下一层递归的startIndex要从i+2开始(因为需要在字符串中加入了分隔符.),同时记录分割符的数量pointNum 要 +1。
回溯的时候,就将刚刚加入的分隔符. 删掉就可以了,pointNum也要-1。
代码如下:
for (int i = startIndex; i < s.size(); i++) { if (isValid(s, startIndex, i)) { // 判断 [startIndex,i] 这个区间的子串是否合法 s.insert(s.begin() + i + 1 , '.'); // 在i的后面插入一个逗点 pointNum++; backtracking(s, i + 2, pointNum); // 插入逗点之后下一个子串的起始位置为i+2 pointNum--; // 回溯 s.erase(s.begin() + i + 1); // 回溯删掉逗点 } else break; // 不合法,直接结束本层循环 }
最终代码
class Solution {private: vector<string> result;// 记录结果 // startIndex: 搜索的起始位置,pointNum:添加逗点的数量 void backtracking(string& s, int startIndex, int pointNum) { if (pointNum == 3) { // 逗点数量为3时,分隔结束 // 判断第四段子字符串是否合法,如果合法就放进result中 if (isValid(s, startIndex, s.size() - 1)) { result.push_back(s); } return; } for (int i = startIndex; i < s.size(); i++) { if (isValid(s, startIndex, i)) { // 判断 [startIndex,i] 这个区间的子串是否合法 s.insert(s.begin() + i + 1 , '.'); // 在i的后面插入一个逗点 pointNum++; backtracking(s, i + 2, pointNum); // 插入逗点之后下一个子串的起始位置为i+2 pointNum--; // 回溯 s.erase(s.begin() + i + 1); // 回溯删掉逗点 } else break; // 不合法,直接结束本层循环 } } // 判断字符串s在左闭又闭区间[start, end]所组成的数字是否合法 bool isValid(const string& s, int start, int end) { if (start > end) { return false; } if (s[start] == '0' && start != end) { // 0开头的数字不合法 return false; } int num = 0; for (int i = start; i <= end; i++) { if (s[i] > '9' || s[i] < '0') { // 遇到非数字字符不合法 return false; } num = num * 10 + (s[i] - '0'); if (num > 255) { // 如果大于255了不合法 return false; } } return true; }public: vector<string> restoreIpAddresses(string s) { result.clear(); if (s.size() < 4 || s.size() > 12) return result; // 算是剪枝了 backtracking(s, 0, 0); return result; }};
但是上面的方法思路是对,如果是看下面的代码,还是更加容易理解一下
function restoreIpAddresses(s) { const results = []; // 存放所有有效的 IP 地址结果 function backtrack(start, currentParts) { // 回溯函数,生成所有可能的 IP 地址分割方案 // 如果已经有 4 部分且 start 刚好等于 s 的长度,说明已找到一个有效的 IP 地址 if (currentParts.length === 4 && start === s.length) { results.push(currentParts.join('.')); // 将当前的部分连接成 IP 地址形式,并加入结果数组 return; } // 如果已经有 4 部分或者 start 刚好等于 s 的长度,则终止当前分支的搜索 if (currentParts.length === 4 || start === s.length) return; for (let len = 1; len <= 3; len++) { // 尝试从 start 开始的长度为 1 到 3 的所有可能的部分 if (start + len > s.length) break; // 如果超出字符串长度,则终止当前尝试 const part = s.substring(start, start + len); // 截取当前部分 if (isValidPart(part)) { // 如果当前部分是有效的 IP 地址部分 currentParts.push(part); // 将当前部分加入当前部分组合中 backtrack(start + len, currentParts); // 递归处理剩余部分 currentParts.pop(); // 回溯,将最后加入的部分移出,尝试其他可能性 } } } backtrack(0, []); return results; } function isValidPart(part) { if (part.length > 1 && part[0] === '0') return false; // 如果长度大于1且以 '0' 开头(除了单独的 '0')则为无效 const num = parseInt(part); // 将字符串转换为整数 return num >= 0 && num <= 255;// 验证其是否在 0 到 255 的范围内 }
子集与子序列
78.子集
给定一组不含重复元素的整数数组 nums,返回该数组所有可能的子集(幂集)。
说明:解集不能包含重复的子集。
示例: 输入: nums = [1,2,3] 输出: [ [3], [1], [2], [1,2,3], [1,3], [2,3], [1,2], [] ]
求子集问题和77.组合 (opens new window)和131.分割回文串 (opens new window)又不一样了。
如果把 子集问题、组合问题、分割问题都抽象为一棵树的话,那么组合问题和分割问题都是收集树的叶子节点,而子集问题是找树的所有节点!
其实子集也是一种组合问题,因为它的集合是无序的,子集{1,2} 和 子集{2,1}是一样的。
那么既然是无序,取过的元素不会重复取,写回溯算法的时候,for就要从startIndex开始,而不是从0开始!
有同学问了,什么时候for可以从0开始呢?
求排列问题的时候,就要从0开始,因为集合是有序的,{1, 2} 和{2, 1}是两个集合,排列问题我们后续的文章就会讲到的。
以示例中nums = [1,2,3]为例把求子集抽象为树型结构,如下:
从图中红线部分,可以看出遍历这个树的时候,把所有节点都记录下来,就是要求的子集集合。
最总代码
var subsets = function(nums) { let result = [] let path = [] function backtracking(startIndex) { result.push([...path]) for(let i = startIndex; i < nums.length; i++) { path.push(nums[i]) backtracking(i + 1) path.pop() } } backtracking(0) return result };
90.子集II
这道题目和78.子集 (opens new window)区别就是集合里有重复元素了,而且求取的子集要去重——理解“树层去重”和“树枝去重”非常重要。给定一个可能包含重复元素的整数数组 nums,返回该数组所有可能的子集(幂集)。
说明:解集不能包含重复的子集。
示例: 输入: [1,2,2] 输出: [ [2], [1], [1,2,2], [2,2], [1,2], [] ]
最终代码
function subsetsWithDup(nums) { const results = []; nums.sort((a, b) => a - b); // 首先对数组进行排序,确保相同的元素相邻 function backtrack(start, current) { results.push([...current]); // 每次加入当前结果到最终结果中 for (let i = start; i < nums.length; i++) { if (i > start && nums[i] === nums[i - 1]) { continue; // 跳过重复的元素,保证每个数字只使用一次 } current.push(nums[i]); // 加入当前数字到当前解决方案 backtrack(i + 1, current); // 递归处理下一个位置 current.pop(); // 回溯,移除最后加入的元素,尝试其他可能性 } } backtrack(0, []); // 从第一个位置开始回溯 return results; }
491.递增子序列
给定一个整型数组, 你的任务是找到所有该数组的递增子序列,递增子序列的长度至少是2。
示例: 输入: [4, 6, 7, 7] 输出: [[4, 6], [4, 7], [4, 6, 7], [4, 6, 7, 7], [6, 7], [6, 7, 7], [7,7], [4,7,7]]
而本题求自增子序列,是不能对原数组进行排序的,排完序的数组都是自增子序列了。
function findSubsequences(nums) { const results = []; function backtrack(start, current) { if (current.length > 1) { results.push([...current]); // 当前长度大于1时,加入结果集 } const used = new Set(); // 使用 Set 来记录使用过的元素,避免重复 for (let i = start; i < nums.length; i++) { if ((current.length === 0 || nums[i] >= current[current.length - 1]) && !used.has(nums[i])) { // 如果当前为空或者当前元素大于等于当前序列的最后一个元素,并且未使用过当前元素 used.add(nums[i]); // 将当前元素加入到已使用集合中 current.push(nums[i]); // 将当前元素加入到当前序列中 backtrack(i + 1, current); // 递归处理下一个位置 current.pop(); // 回溯,移除最后加入的元素,尝试其他可能性 } } } backtrack(0, []); // 从第一个位置开始回溯 return results; }
参考文章:
https://programmercarl.com/0131.分割回文串.html
转载本站文章《回溯之分割与子集:分割回文串/复原IP地址、子集/子序》,
请注明出处:https://www.zhoulujun.cn/html/theory/algorithm/leetcode/9164.html