蛮荆

LeetCode 回溯 刷题模板

2022-05-30

📖 概述

回溯 (Backtracking) 算法是一类用于寻找某些计算问题(尤其是约束满足问题)的解决方案,通过逐步构建解决方案的候选方案 (路径),并在确定候选方案无法完成某个目标时立即放弃该候选方案(“回溯”)。

回溯算法通常采用递归方式实现,在尝试每一种可能的问题解时,都会进入下一层递归,直到知道问题解或者确定当前路径无解,然后回溯到上一层,尝试更多可能的解。

回溯算法的本质是暴力穷举,假设问题的规模为 n, 解空间的大小为 m,回溯算法的时间复杂度通常可以表示为 O(m^n)。虽然某些场景下可以通过剪枝来进行优化,减少搜索空间,降低时间复杂度,但是 “最坏” 情况下回溯算法往往是指数级别的。

通过回溯算法解决数独问题的过程示例。

图片来源: https://en.wikipedia.org/wiki/Backtracking

难点

回溯算法通常采用递归方式实现,在每一个递归过程中都要考虑多个选择,并不断进行递归调用,整个算法的控制流程会变得庞大复杂。此外,回溯算法涉及到对状态空间的搜索和维护,不同类型的问题有不同的解题思路和技巧,并没有像链表、二叉树等数据结构有固定的算法,因此解决回溯类算法时需要对问题本身进行抽象。

两个重要部分:

  1. 递归函数: 尝试每一种可能的解,递归搜索解空间
  2. 状态管理: 递归过程中,需要对状态进行管理,包括判断当前路径是否有效、选择新的当前路径、剪枝、回溯等操作

初学者面对回溯算法时,往往觉得难以理解和无从下手,主要原因在于: 对递归思想的理解和运用,以及对回溯过程中的状态空间搜索和剪枝策略的定义

采用回溯 ABC 三个字母的组合

算法步骤

  1. 检测: 判断当前路径是否已经符合条件,如果已经符合条件,将当前路径添加到结果集中 (查找结果是什么,终止条件就是什么),终止当前递归,回溯到上一步
  2. 选择: 选择一个元素,形成新的当前路径 (候选方案),尝试将问题分解为子问题
  3. 剪枝: 根据问题的限制条件,判断当前路径是否有效,如果当前路径无效,终止当前递归,回溯到上一步
  4. 递归: 进入下一层递归,传入参数为当前路径和其他变量,继续解决子问题
  5. 回溯: 如果当前路径无解,回溯到上一步,撤销当前的选择,尝试更多其他解

对应的伪代码如下:

func Solution(问题参数) {
    初始化结果集 result
    初始化当前路径 path

    backtrack(问题参数, path, result)
    
    返回结果集 result
}

func backtrack(问题参数, path, result) {
    if 判断当前路径是否已经符合条件 {
        将当前路径加入结果集 result
        return
	}

    for each 可选选择 in 候选集 {
        选择: 将当前选择加入路径 path, 形成新的当前路径

		剪枝: 结束当前递归回溯到上一步
		if 当前路径无效 {
			return 
		}

		递归: 进入下一层递归
        backtrack(更新后的问题参数, path, result)

        回溯: 撤销选择将当前选择从路径 path 中移除
	}
}

LeetCode 刷题模板

笔者根据 LeetCode 中的回溯相关算法题,整理了一个通用刷题模板。

func Solution(list []T) [][]T {
    // 初始化结果集
    var result [][]T
    // 初始化当前路径
    var path []T

    // 回溯
    backtrack(list, &path, &result)

    // 返回结果集
    return result
}

func backtrack(list []T, path *[]T, result *[][]T) {
    // 判断当前路径是否满足条件
    if 满足结束条件 {
        // 将当前路径加入结果集
        *result = append(*result, append([]T{}, path...))
        return
    }

    // 遍历所有可能的选择
    for _, 可选选择 := range 候选集 {
        // 做出选择,形成新的当前路径
        path = append(path, 可选选择)
        // 继续向下搜索
        backtrack(list, path, result)
        // 撤销选择
        path = path[:len(path)-1]
    }
}

💡 典型题目 (图)

1. 电话号码的字母组合

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

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

图片来源: https://leetcode.cn/

# 示例来自: https://leetcode.cn/

示例 1:

输入:digits = "23"
输出:["ad","ae","af","bd","be","bf","cd","ce","cf"]

示例 2:

输入:digits = ""
输出:[]

解题思路:

  1. 将电话键的数字和对应的字母形成一个映射 Map, 便于查找和组合
  2. 检测: 判断当前路径是否已经符合条件,也就是判断 当前路径的长度是否等于数字串的长度
  3. 选择: 遍历当前数字对应的字母列表,将当前字符添加到 当前路径 中,形成新的当前路径
  4. 剪枝: 因为题目要求找到所有的字母组合,所以不存在剪枝策略
  5. 递归: 将当前路径的长度加一,进入下一层递归
  6. 回溯: 选择当前字母列表的下一个字符,回溯到上一步,尝试更多其他组合
// 题解代码
var letterArray = []string{
	"abc",  //2
	"def",  //3
	"ghi",  //4
	"jkl",  //5
	"mno",  //6
	"pqrs", //7
	"tuv",  //8
	"wxyz", //9
}

func letterCombinations(digits string) []string {
	if len(digits) == 0 {
		return []string{}
	}

	var res []string
	var str string

	backtrack(digits, &res, str, 0)
    
	return res
}

func backtrack(digits string, res *[]string, str string, begin int) {
    // 如果当前路径的长度是否等于数字串的长度
    // 正好满足一个组合,将当前路径加入到结果集中
	if begin == len(digits) {
		*res = append(*res, str)
	} else {
        // 获取当前数字对应的字符列表
        // 如数字 2 对应的 {a, b, c}
		strMap := letterArray[digits[begin]-'2']

		for i := range strMap {
            // 将当前字符添加到 当前路径 中,形成新的当前路径
            // 将当前路径的长度 + 1
            // 进入下一层递归
			backtrack(digits, res, str+string(strMap[i]), begin+1)
		}
	}
}

电话号码的字母组合 - 执行过程

2. 全排列

给定一个不含重复数字的数组 nums ,返回其 所有可能的全排列 。你可以 按任意顺序 返回答案。

# 示例来自: https://leetcode.cn/
示例 1:

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

示例 2:

输入:nums = [0,1]
输出:[[0,1],[1,0]]

解题思路:

  1. 检测: 判断当前路径是否已经符合条件,也就是判断 当前路径的长度是否等于参数数组的长度
  2. 选择: 遍历数组,将当前元素添加到 当前路径 中,形成新的当前路径,同时将当前元素标记为已访问 (当前元素已经存在于 当前路径 中)
  3. 剪枝: 如果当前元素已访问,直接跳过
  4. 递归: 进入下一层递归
  5. 回溯: 将当前元素标记为未访问,同时从当前路径中删除当前元素,回溯到上一步,回溯尝试更多其他组合
// 题解代码
func permute(nums []int) [][]int {
	var res [][]int
	var path []int

    // 初始化一个数组作为 Map
    // 用来标记递归过程中已经存在于 “当前路径” 中的元素索引
    // 避免单个元素被重复使用多次
    // 例如正常情况下,[1, 2, 3] [1, 3, 2] 都会正常组合
    // 但是 [1, 1, 1], [2, 2, 2] 属于元素重复使用,不能算作组合
	var visited = make([]bool, len(nums))

	backtrack(nums, &path, &visited, &res)

	return res
}

func backtrack(nums []int, path *[]int, visited *[]bool, res *[][]int) {
    // 当前路径的长度是否等于参数数组的长度
    // 正好满足一个组合,将当前路径加入到结果集中
	if len(*path) == len(nums) {
        // 注意这里要拷贝当前路径中的元素
        // 因为当前路径变量 path 会在递归过程中发生变化
		row := make([]int, len(nums))
		copy(row, *path)

        // 将拷贝后的路径添加到结果集中
		*res = append(*res, row)
		return
	}

    // 遍历数组,执行递归 + 回溯操作
	for i, v := range nums {
        // 剪枝策略
        // 如果当前元素未访问
		if !(*visited)[i] {
            // 将当前元素添加到 当前路径 中,形成新的当前路径
			(*visited)[i] = true
            // 将当前元素标记为已访问
			*path = append(*path, v)

            // 进入下一层递归
			backtrack(nums, path, visited, res)

            // 将当前元素标记为未访问
			(*visited)[i] = false
            // 从当前路径中删除当前元素
            // 回溯到上一步,尝试更多可能的解
			*path = (*path)[:len(*path)-1]
		}
	}
}

全排列 - 执行过程

3. 组合总和

给你一个 无重复元素 的整数数组 candidates 和一个目标整数 target ,找出 candidates 中可以使数字和为目标数 target 的 所有 不同组合 ,并以列表形式返回。你可以按 任意顺序 返回这些组合。

candidates 中的 同一个 数字可以 无限制重复被选取 。如果至少一个数字的被选数量不同,则两种组合是不同的。

# 示例来自: https://leetcode.cn/
示例 1:

输入:candidates = [2,3,6,7], target = 7
输出:[[2,2,3],[7]]
解释:
23 可以形成一组候选,2 + 2 + 3 = 7 。注意 2 可以使用多次。
7 也是一个候选, 7 = 7仅有这两种组合。

示例 2:

输入: candidates = [2,3,5], target = 8
输出: [[2,2,2,2],[2,3,3],[3,5]]

解题思路:

  1. 检测: 判断当前路径是否已经符合条件,也就是判断 当前查找目标值等于 0
  2. 选择: 从指定的起始位置开始遍历数组,将当前元素添加到 当前路径 中,形成新的当前路径
  3. 剪枝: 如果当前元素大于目标值,直接跳过
  4. 递归: 将查找目标值减去当前元素,进入下一层递归
  5. 回溯: 当前路径中删除当前元素,回溯到上一步,回溯尝试更多其他组合
// 题解代码
func combinationSum(candidates []int, target int) [][]int {
	if len(candidates) == 0 {
		return nil
	}

	var path []int
	var res [][]int

	// 为了在递归过程中优化剪枝
	// 提前对数组进行排序
	sort.Ints(candidates)

	backtrack(candidates, target, 0, &path, &res)

	return res
}

func backtrack(candidates []int, target, begin int, path *[]int, res *[][]int) {
	// 如果当前查找目标值等于 0
	// 正好满足一个组合,将当前路径加入到结果集中
	if target == 0 {
		// 这里使用了另外一种语法来实现追加功能
		*res = append(*res, append([]int{}, *path...))
		return
	}

	for i := begin; i < len(candidates); i++ {
		// 剪枝 (前提:已排序)
		// 数组排序之后
		// 如果当前元素大于目标值,那么当前元素之后的所有元素都不可能出现有效的组合了
		// 例如数组排序之后为 [2,3,6,7], target = 5
		// 那么当前元素遍历到 6 时,就可以直接剪枝了
		if candidates[i] > target {
			return
		}

		// 将当前元素添加到 当前路径 中,形成新的当前路径
		*path = append(*path, candidates[i])

		// candidates 中的 同一个 数字可以 无限制重复被选取
		// i 作为起点,查找 target-candidates[i]
		// 指定起始位置为当前位置
		backtrack(candidates, target-candidates[i], i, path, res)

		// 从当前路径中删除当前元素
		// 回溯到上一步,尝试更多可能的解
		*path = (*path)[:len(*path)-1]
	}
}

组合总和 - 执行过程

转载申请

本作品采用 知识共享署名 4.0 国际许可协议 进行许可,转载时请注明原文链接,图片在使用时请保留全部内容,商业转载请联系作者获得授权。