回溯

120 阅读3分钟

回溯三问

dfs(i)dfs(i+1)dfs(i) \rightarrow dfs(i+1)

  • 当前操作是什么?当前操作使从子问题i转化成i+1
  • 子问题:>=i
  • 下一个子问题 >= i+1

一个示例:电话号码的字母组合

给定一个仅包含数字 2-9 的字符串,返回所有它能表示的字母组合。答案可以按 任意顺序 返回。

给出数字到字母的映射如下(与电话按键相同)。注意 1 不对应任何字母。

image.png

var letterCombinations = function(digits) {
  if (digits.length === 0) return []
  const num2char = ['', '', 'abc', 'def', 'ghi', 'jkl', 'mno', 'pqrs', 'tuv', 'wxyz']
  // 最终结果
  let res = [], len = digits.length
  // 某个回溯分支的结果
  let cur = new Array(digits.length).fill('')
  // 表示当前遍历到digits的第i位
  const dfs = i => {
    // 递归结束,将回溯分支结果记录
    if (i === len) {
      res.push(cur.join(''))
      return
    }
    const chars = num2char[Number(digits[i])]
    // 第i位依次枚举
    for (const char of chars) {
      cur[i] = char
      // 遍历digits的第i+1位
      dfs(i+1)
    }
  }
  dfs(0)
  return res
};

子集型回溯

关键问题:每个元素可以选择 / 不选择

子集

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

解集 不能 包含重复的子集。你可以按 任意顺序 返回解集。

有两个思路:

  • 从输入的角度思考:对于数组中每个元素,可以选择,也可以不选择,依次枚举
  • 从输出的角度思考:枚举第一个数选谁,枚举第二个数选谁...
// 从输入的角度思考
var subsets = function(nums) {
  let res = [], cur = []
  const len = nums.length
  const dfs = i => {
    // 递归技术,记录回溯分支结果
    if (i === len) {
      res.push([...cur])
      return
    }
    // 不选择第i个数字
    dfs(i+1)
    // 选择第i个数字,需要pop恢复现场
    cur.push(nums[i])
    dfs(i+1)
    cur.pop()
  }
  dfs(0)
  return res
};

// 从输出的角度思考
var subsets = function(nums) {
  let res = [], cur = []
  const len = nums.length
  const dfs = i => {
    // 每个回溯节点都需要记录答案
    res.push([...cur])
    for (let k = i; k < len; k++) {
      // 答案的第i个数字的枚举
      cur.push(nums[k])
      dfs(k+1)
      cur.pop()
    }
  }
  dfs(0)
  return res
};

分割回文串

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

回文串 是正着读和反着读都一样的字符串。

分析:假设每个字符间有一个逗号,对每个逗号考虑选它/不选它,通过逗号将字符串分割

// 从输入的角度考虑,对每个逗号,选或不选
var partition = function(s) {
  let res = [], cur = []
  const len = s.length
  // start 表示当前这段回文子串的开始位置
  const dfs = (i, start) => {
    if (i === len) {
      res.push([...cur])
      return
    }

    // 不选 i 和 i+1 之间的逗号(i=n-1 时右边没有逗号)
    if (i < len - 1) {
      dfs(i+1, start)
    }
  
    // 选 i 和 i+1 之间的逗号
    const str = s.slice(start, i + 1)
    if (str === str.split('').reverse().join('')) {
      cur.push(str)
      dfs(i+1, i+1)
      cur.pop()
    }
  } 
  dfs(0, 0)
  return res
};

// 从答案的角度考虑,枚举第一个逗号的位置,枚举第二个逗号的位置...
var partition = function(s) {
  let res = [], cur = []
  const len = s.length
  const dfs = i => {
    if (i === len) {
      res.push([...cur])
      return
    }
    
    for (let k = i; k < len; k++) {
      const str = s.slice(i, k + 1)
      if (str === str.split('').reverse().join('')) {
        cur.push(str)
        dfs(k+1)
        cur.pop()
      }
    }
  } 
  dfs(0)
  return res
};

组合型回溯

枚举当前位置选哪个?

组合

给定两个整数 n 和 k,返回范围 [1, n] 中所有可能的 k 个数的组合。

你可以按 任何顺序 返回答案。

var combine = function (n, k) {
  const res = [],
    cur = []
  // dfs(i)表示从[1, ..., i]中选择数
  const dfs = (i) => {
    if (cur.length === k) {
      res.push([...cur])
      // 可以直接返回,剪枝
      return
    }
    // [1, ..., i]最多有i个数,当小于剩余未选数的个数时,可以提前返回
    // 因为即使全选也满足不了条件
    if (i < k - cur.length) return
    // 倒序枚举
    for (let j = i; j > 0; j--) {
      cur.push(j) 
      dfs(j - 1)
      cur.pop()
    }
  }
  dfs(n)
  return res
}

// 整体框架上是对数的子集回溯,只是需要判断cur,是否满足条件
var combine = function (n, k) {
  const res = [],
    cur = []
  // dfs(i)表示从[i, ..., n]中选择数
  const dfs = i => {
    if (cur.length === k) {
      res.push([...cur])
      return
    }
    // 元素个数肯定不满足条件,提前返回
    if (n - i + 1 < k - cur.length) return
    // 正序枚举
    for (let j = i; j <= n; j++) {
      cur.push(j) 
      dfs(j + 1)
      cur.pop()
    }
  } 
  dfs(1)
  return res
}

// 选/不选
var combine = function (n, k) {
  const res = [],
    cur = []
  const dfs = i => {
    if (cur.length === k) {
      res.push([...cur])
      return
    }
    // 提前结束-剪枝
    if (n - i + 1 < k - cur.length) return
    // 选i
    cur.push(i)
    dfs(i + 1)
    cur.pop()

    // 不选i
    dfs(i + 1)
  }
  dfs(1)
  return res
}

组合总和-III

找出所有相加之和为 n **的 k ****个数的组合,且满足下列条件:

  • 只使用数字1到9
  • 每个数字 最多使用一次 

返回 所有可能的有效组合的列表 。该列表不能包含相同的组合两次,组合可以以任何顺序返回。

// 选/不选
var combinationSum3 = function(k, n) {
  let res = [], cur = []
  const dfs = i => {
    if (i > 10) return
    if (cur.reduce((prev, val) => {
      return prev + val
    }, 0) === n && cur.length === k) {
      res.push([...cur])
      return
    }

    // 选i
    cur.push(i)
    dfs(i + 1)
    cur.pop()
    // 不选i
    dfs(i + 1)
  }
  dfs(1)
  return res
};

排列型回溯

关键问题:不再区分元素的选择顺序,1,2和2,1是两个答案

全排列

var permute = function(nums) {
  const n = nums.length, res = [], cur = []
  // 也可以使用一个数组表示
  const selected = {}
  const dfs = (i) => {
    if (i === n) {
      res.push([...cur])
      return
    }
    for (let j = 0; j < nums.length; j++) {
      const element = nums[j];
      if (selected[element]) continue
      selected[element] = 1
      cur.push(element)
      dfs(i + 1)
      cur.pop()
      selected[element] = 0
    }
  }
  dfs(0)
  return res
};

N皇后

function canPut(i, cur, n) {
  // 不在同一列
  if (cur.indexOf(i) > -1) return false
  let res = true
  for (let k = 0; k < cur.length; k++) {
    const element = cur[k];
    // 不在左上角
    if (cur.length + i === k + element) {
      res = false
      break
    } 
    // 不在右上角
    if (cur.length - i === k - element) {
      res = false
      break
    }
  }
  return res
}

var solveNQueens = function(n) {
  // cur表示[1,3,0,2],每一行上皇后所在的列
  const res = [], cur = []
  const dfs = i => {
    if (i === n) {
      res.push([...cur])
      return
    }
    for (let k = 0; k < n; k++) {
      // 也可以使用数组,快速进行判断
      if (!canPut(k, cur, n)) continue
      cur.push(k)
      dfs(i + 1)
      cur.pop()
    }
  }
  dfs(0)
  return res.map(item => {
    return item.map(item2 => {
      const arr = new Array(n).fill('.')
      arr[item2] = 'Q'
      return arr.join('')
    })
  })
};