回溯②--分割与子集问题

94 阅读9分钟

在回溯①中我们介绍了利用回溯解决组合排列问题,并总结了回溯问题的统一模板。

除了排列组合外,回溯还可以解决分割与子集问题。

  • 分割问题:一个字符串按一定规则有几种切割方式
  • 子集问题:一个N个数的集合里有多少符合条件的子集

接下来我们一起来看看分割问题和子集问题如何转换为N叉树并利用模板解决吧!

分割问题

LeetCode-131.分割回文串

给你一个字符串 s,请你将 s 分割成一些子串,使每个子串都是 回文串 。返回 s 所有可能的分割方案。

示例:

输入: s = "aab"
输出: [["a","a","b"],["aa","b"]]

image.png

上图展示了如何将这个问题转化为树形结构,可以得出:

  • 终止条件是候选集合为空
  • for循环中,即进入当前层之后,要干的是分割,如果分割出来的是回文,则进入下一层,如果不是就可以直接跳到下一层是否是回文;
let res = []
/**
 * @param {string} string 字符串
 * @returns 是否回文串
 */
function isPalindrome(string) {
  let left = 0;
  let right = string.length - 1;
  for (left; left < right; left++, right--) {
    if (string[left] !== string[right]) return false;
  }
  return true;
}
/**
 * @param {string} candidates 
 * @param {string[]} curSplitCombine 
 * @returns 
 */
function backtracing(candidates, curSplitCombine) {
  if (!candidates.length) {
    res.push([...curSplitCombine]);
    return;
  }

  for (let i = 0; i < candidates.length; i++) {
    //分割
    const splitStr = candidates.slice(0, i + 1);
    //判断是否是回文
    if (!isPalindrome(splitStr)) continue;

    curSplitCombine.push(splitStr);
    backtracing(candidates.slice(i + 1), curSplitCombine);
    curSplitCombine.pop();
  }
}
/**
 * @param {string} s
 * @return {string[][]}
 */
var partition = function (s) {
  res = [];
  backtracing(s, []);
  return res;
};

LeetCode-93.复原 IP 地址

给定一个只包含数字的字符串 s ,用以表示一个 IP 地址,返回所有可能的有效 IP 地址,这些地址可以通过在 s 中插入 '.' 来形成。

一个有效的IP地址需要满足下列条件:

  • 包含四个整数,用.分隔
  • 每个整数介于[0,255]之间,且不能以0作为前导

实际上与上题相似,我们在每次分之前先判断分出的结果是否符合条件即可;

终止条件也十分好想,因为IP是由四个整数组成的,因此只需要切割三次就可以了;当然我们在收集结果的时候,要判断剩余的子串是否满足条件;

let res = [];
function checkIpBlock(blockStr) {
  if (!blockStr.length) return false;
  if (parseInt(blockStr) > 255) return false;
  if (blockStr.length > 1 && blockStr[0] === '0') return false;
  return true;
}
/**
 * @param {string} candidates 
 * @param {*} curCombin 
 * @param {*} cutCount 
 * @returns {void}
 */
function backtracing(candidates, curCombin, cutCount) {
  //终止条件 切了3次
  if (cutCount === 3) {
    //剩余子串不满足Ip要求直接返回
    if (!checkIpBlock(candidates)) return;
    //所有条件都满足,收集这组结果
    let combine = "";
    curCombin.forEach((str) => {
      combine += str + ".";
    })
    combine += candidates;
    res.push(combine);
    return;
  }

  for (let i = 0; i < candidates.length; i++) {
    //剪枝 切割长度超出3位
    if (i >= 3) return;
    const subStr = candidates.slice(0, i + 1);

    if (!checkIpBlock(subStr)) {
      continue;
    }
    //切割次数加一
    cutCount++;
    //收集切割出的串
    curCombin.push(subStr);
    //递归切割剩余子串
    backtracing(candidates.slice(i + 1), curCombin, cutCount);
    //回溯
    curCombin.pop();
    cutCount--;
  }
  return;
}
/**
 * @param {string} s
 * @return {string[]}
 */
var restoreIpAddresses = function (s) {
  res = [];
  backtracing(s, [], 0);
  return res;
};

子集问题

LeetCode-78.子集

给你一个整数数组 nums ,数组中的元素 互不相同 。返回该数组所有可能的子集(幂集)

示例

输入: nums = [1,2,3]
输出: [[],[1],[2],[1,2],[3],[1,3],[2,3],[1,2,3]]

想想子集与组合有什么关联?长度在[0,nums.length]的所有组合就是子集 在求组合时我们在达到某个长度后收集结果,而求子集时每个节点都要收集;

知道了这个代码就很好实现了:

let res = []
/**
 * @param {number[]} candidates 候选数字集合
 * @param {number[]} curCombin 当前已选组合
 */
function backtracing(candidates, curCombin) {
  //结果的收集放在最前面
  res.push([...curCombin]);
  if (!candidates.length) {
    return;
  }

  for (let i = 0; i < candidates.length; i++) {
    curCombin.push(candidates[i]);
    backtracing(candidates.slice(i + 1), curCombin)
    curCombin.pop();
  }
  return;
}
/**
 * @param {number[]} nums
 * @return {number[][]}
 */
var subsets = function (nums) {
  res = []
  backtracing(nums, [], 0);
  return res;
};

LeetCode-90.子集 II

给你一个整数数组 nums ,其中可能包含重复元素,请你返回该数组所有可能的子集(幂集)。

这道题需要注意重复元素,而去重的方法也很简单:提前排序

let res = []
/**
 *
 * @param {number[]} candidates 候选数字集合
 * @param {number[]} curCombin 当前组合
 */
function backtracing(candidates, curCombin) {
  //每次进来都是一个正确的子集,收集
  res.push([...curCombin]);
  //终止条件
  if (!candidates.length) {
    return;
  }

  for (let i = 0; i < candidates.length; i++) {
    //如果这次的待选数字与上次相同,那么说明以该数字开头的所有组合都在之前收集过了
    if (i && candidates[i] === candidates[i - 1]) {
      continue;
    }
    //记录
    curCombin.push(candidates[i]);
    //递归记录
    backtracing(candidates.slice(i + 1), curCombin);
    //回溯
    curCombin.pop();
  }
  return;
}
/**
 * @param {number[]} nums
 * @return {number[][]}
 */
var subsetsWithDup = function (nums) {
  res = [];
  nums.sort((a, b) => a - b);
  backtracing(nums, []);
  return res;
};

排序的目的是为了让相同的元素出现在同一层!

image.png

LeetCode-491.递增子序列

给你一个整数数组 nums ,找出并返回所有该数组中不同的递增子序列,递增子序列中 至少有两个元素 。你可以按 任意顺序 返回答案。

示例:

输入: nums = [4,6,7,7]
输出: [[4,6],[4,6,7],[4,6,7,7],[4,7],[4,7,7],[6,7],[6,7,7],[7,7]]
  • 终止条件:候选集合为空
  • 收集条件:已选组合长度大于等于2
  • 逻辑:
    • 每次选择一个元素,要判断这个元素是否满足递增,否则就跳过;
    • 此外,对于一个元素其向下递归会获得其所有可能的递增子序列,如果在选择这个数字的这一层中有重复元素,那么这个重复元素不需要再向下寻找了;
let res = []
/**
 * @param {number[]} candidates 候选数字集合
 * @param {number[]} curCombin 当前组合
 * @param {Set} used 使用过的数字
 */
function backtracing(candidates, curCombine) {
  if (curCombine.length >= 2) {
    res.push([...curCombine]);
  }
  if (!candidates.length) {
    return;
  }


  let layerUsed = []
  for (let i = 0; i < candidates.length; i++) {
    if (curCombine.length && candidates[i] < curCombine[curCombine.length - 1]) {
      continue;
    }
    if (layerUsed.includes(candidates[i])) { continue; }
    layerUsed.push(candidates[i]);
    curCombine.push(candidates[i]);
    backtracing(candidates.slice(i + 1), curCombine);
    curCombine.pop();
  }
  return;
}
/**
 * @param {number[]} nums
 * @return {number[][]}
 */
var findSubsequences = function (nums) {
  res = []
  backtracing(nums, []);
  return res;
};

其他回溯可解决问题

LeetCode-332.重新安排行程

描述: 给你一份航线列表 tickets ,其中 tickets[i] = [fromi, toi] 表示飞机出发和降落的机场地点。请你对该行程进行重新规划排序。

所有这些机票都属于一个从 JFK(肯尼迪国际机场)出发的先生,所以该行程必须从 JFK 开始。如果存在多种有效的行程,请你按字典排序返回最小的行程组合。

  • 例如,行程 ["JFK", "LGA"] 与 ["JFK", "LGB"] 相比就更小,排序更靠前。

假定所有机票至少存在一种合理的行程。且所有的机票 必须都用一次 且 只能用一次。

思考: 这一题实际上是图相关的题目,但实际上回溯就是在深度优先搜索的基础上实现的;

首先题目需要的是一个最小排序的形成,那么我们可以提前将tickets排序,这样只要回溯过程中获得一个结果就可以结束了(刚好我们之前二叉树学习过如何中断递归

其次是我们需要构建好数据结构:题目所给数据结构位:tickets = [["JFK","SFO"],["JFK","ATL"],["SFO","ATL"],["ATL","JFK"],["ATL","SFO"]],而图相关问题一般我们需要使用邻接矩阵/邻接表

这里我们选择邻接表,因为邻接表更方便构造并且取值等操作更加方便:目标结构{JFK:[SFO,ATL],SFO:[ATL],ATL:[JFK,SFO]},构造好后还需要排序

根据题目描述:

  • 终止条件为所有的票被使用完
  • 返回值,由于我们需要中断循环,因此返回true|false代表是否找到合理的行程;
  • 参数:我们需要知道已经使用过的票数usedCount以及这次递归的起始位置是哪from
  • 逻辑:
    • 每次递归遍历的是候选集合,这里的候选集合应该是当前起始位置的可达位置:ticketsMap[from]
    • 在选择完一张票后,应该及时的删除该机票

始发站已经确认!

let line = ["JFK"];
/**
 * @typedef {object} ArrayObject
 * @property {string[]} cityList
 */
/**
 * 超时了,应该使用 hierholzer欧拉回路算法,但是回溯思路正确
 * @param {object} ticketsMap 机票邻接表
 * @param {string[]} curLine 当前航线
 * @param {string} from 起始地
 * @param {number} usedCount 使用过的机票
 */
function backtracing(ticketsMap, curLine, from, usedCount, totalCount) {
  //如果所有机票都用完了,则结束,题目中已告知所有机票都应该被使用
  if (totalCount === usedCount) {
    line = [...curLine];
    return true;
  }

  //当前from可到达的其他城市
  const cityList = ticketsMap[from];
  //如果无可达城市,且机票没用完,则该路不通
  if (!cityList) return false;

  for (let i = 0; i < cityList.length; i++) {
    //下一站
    const nextCity = cityList[i];
    //收集航线信息
    curLine.push(nextCity);
    //删除该机票
    cityList.splice(i, 1);
    //递归收集航线
    const hasLine = backtracing(ticketsMap, curLine, nextCity, usedCount + 1, totalCount);
    //终端递归,找到了一条能够用完所有机票的航线,由于已经排序过了,找到的第一个就是字母排序最小的
    if (hasLine) return true;
    //回溯
    cityList.splice(i, 0, nextCity)
    curLine.pop();
  }
  return false;
}
/**
 * @param {string[][]} tickets
 * @return {string[]}
 */
var findItinerary = function (tickets) {
  line = ["JFK"]
  //构建邻接表
  let ticketsMap = {};
  for (let ticket of tickets) {
    const [from, to] = ticket;
    if (!ticketsMap[from]) {
      ticketsMap[from] = []
    }
    ticketsMap[from].push(to);
  }

  //将邻接表中的每一个进行排序
  for (let city in ticketsMap) {
    ticketsMap[city].sort()
  };

  backtracing(ticketsMap, line, "JFK", 0, tickets.length);
  return line;
};

这个解法没啥问题,就是超时了😅,应该使用欧拉回路算法;但是我还没有学,就先暂且搁置,等到了图专题再来解决;

LeetCode-51.N 皇后

按照国际象棋的规则,皇后可以攻击与之处在同一行或同一列或同一斜线上的棋子。

n 皇后问题 研究的是如何将 n 个皇后放置在 n×n 的棋盘上,并且使皇后彼此之间不能相互攻击。

给你一个整数 n ,返回所有不同的 **n 皇后问题 的解决方案。

每一种解法包含一个不同的 n 皇后问题 的棋子放置方案,该方案中 'Q' 和 '.' 分别代表了皇后和空位。

let res = []
//判断是否是同一列
function isSameColumn(pose, curPos) {
  for (let i = 0; i < curPos.length; i++) {
    if (curPos[i] === pose) {
      return true;
    }
  }
  return false;
}
//判断是否是同一斜线
function isSameSlash(pose, curPos, curRow) {
  for (let i = 0; i < curPos.length; i++) {
    //行差
    const offset = Math.abs(curRow - i);
    if (curPos[i] + offset === pose || curPos[i] - offset === pose) {
      return true;
    }
  }
  return false;
}
//获取某行的布局,n是列数, qPos是皇后的列下标
function getLayout(n, qPos) {
  let rowRes = ''
  for (let j = 0; j < n; j++) {
    if (j === qPos) {
      rowRes += 'Q';
    } else {
      rowRes += '.';
    }
  }
  return rowRes;
}
/**
 * 
 * @param {number} n N皇后
 * @param {number} curRow 当前的行数
 * @param {number[]} curPos 当前已经存放的位置,列下标
 * @param {number[]} curRes 当前收集的结果集
 */
function backtracing(n, curRow, curPos, curRes) {
  if (curRow === n) {
    res.push([...curRes])
    return;
  }

  for (let i = 0; i < n; i++) {
    //是否相同列
    if (isSameColumn(i, curPos)) {
      continue;
    }
    //是否相同斜线
    if (isSameSlash(i, curPos, curRow)) {
      continue;
    }
    //放置Q
    let rowRes = getLayout(n, i);
    curRes.push(rowRes);
    curPos.push(i)
    curRow++;
    //递归下一行
    backtracing(n, curRow, curPos, curRes);
    curRow--;
    curPos.pop();
    curRes.pop();
  }

  return;
}
/**
 * @param {number} n
 * @return {string[][]}
 */
var solveNQueens = function (n) {
  res = []
  backtracing(n, 0, [], []);
  return res;
};