数据结构之---- 回溯算法

news2024/12/24 9:02:30

数据结构之---- 回溯算法

什么是回溯算法?

回溯算法是一种通过穷举来解决问题的方法,它的核心思想是从一个初始状态出发,暴力搜索所有可能的解决方案,当遇到正确的解则将其记录,直到找到解或者尝试了所有可能的选择都无法找到解为止。

回溯算法通常采用 深度优先搜索 来遍历解空间。
在二叉树章节中,我们提到前序、中序和后序遍历都属于深度优先搜索。下面,我们利用前序遍历构造一个回溯问题,逐步了解回溯算法的工作原理。

例题一
给定一个二叉树,搜索并记录所有值为 7 的节点,请返回节点列表。

对于此题,我们前序遍历这颗树,并判断当前节点的值是否为 7 ,若是则将该节点的值加入到结果列表 res之中。
相关过程实现如图和以下代码所示:

/* 前序遍历:例题一 */
void preOrder(TreeNode root) {
	if (root == null) {
		return;
	}
	if (root.val == 7) {
		// 记录解
		res.add(root);
	}
	preOrder(root.left);
	preOrder(root.right);
}

在这里插入图片描述

算法的尝试与回退

之所以称之为回溯算法,是因为该算法在搜索解空间时会采用“尝试”与“回退”的策略。
当算法在搜索过程中遇到某个状态无法继续前进或无法得到满足条件的解时,它会撤销上一步的选择,退回到之前的状态,并尝试其他可能的选择。

对于例题一,访问每个节点都代表一次 尝试 ,而越过叶节点或返回父节点的 return 则表示 回退
值得说明的是,回退并不仅仅包括函数返回。为解释这一点,我们对例题一稍作拓展。

例题二
在二叉树中搜索所有值为 7 的节点,请返回根节点到这些节点的路径。

在例题一代码的基础上,我们需要借助一个列表 path 记录访问过的节点路径。当访问到值为 7 的节点时,则复制 path 并添加进结果列表 res 。遍历完成后,res 中保存的就是所有的解。

/* 前序遍历:例题二 */
void preOrder(TreeNode root) {
	if (root == null) {
		return;
	}
	// 尝试
	path.add(root);
	if (root.val == 7) {
		// 记录解
		res.add(new ArrayList<>(path));
	}
	preOrder(root.left);
	preOrder(root.right);
	// 回退
	path.remove(path.size() - 1);
}

在每次 尝试 中,我们通过将当前节点添加进 path 来记录路径;而在 回退 前,我们需要将该节点从path 中弹出,以恢复本次尝试之前的状态。
观察图所示的过程,我们可以将尝试和回退理解为 前进 撤销 ,两个操作是互为逆向的。

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

剪枝

复杂的回溯问题通常包含一个或多个约束条件,约束条件通常可用于“剪枝”

例题三
在二叉树中搜索所有值为 7 的节点,请返回根节点到这些节点的路径,并要求路径中不包含值为 3 的节点。

为了满足以上约束条件,我们需要添加剪枝操作
在搜索过程中,若遇到值为 3 的节点,则提前返回,停止继续搜索。

/* 前序遍历:例题三 */
void preOrder(TreeNode root) {
	// 剪枝
	if (root == null || root.val == 3) {
		return;
	}
	// 尝试
	path.add(root);
	if (root.val == 7) {
		// 记录解
		res.add(new ArrayList<>(path));
	}
	preOrder(root.left);
	preOrder(root.right);
	// 回退
	path.remove(path.size() - 1);
}

剪枝是一个非常形象的名词。如图所示,在搜索过程中,我们“剪掉”了不满足约束条件的搜索分支,避免许多无意义的尝试,从而提高了搜索效率。

在这里插入图片描述

框架代码

下面,我们尝试将回溯的 尝试、回退、剪枝 的主体框架提炼出来,提升代码的通用性。
在以下框架代码中,state 表示问题的当前状态,choices 表示当前状态下可以做出的选择

/* 回溯算法框架 */
void backtrack(State state, List<Choice> choices, List<State> res) {
	// 判断是否为解
	if (isSolution(state)) {
		// 记录解
		recordSolution(state, res);
		// 停止继续搜索
		return;
	}
	// 遍历所有选择
	for (Choice choice : choices) {
		// 剪枝:判断选择是否合法
		if (isValid(state, choice)) {
			// 尝试:做出选择,更新状态
			makeChoice(state, choice);
			backtrack(state, choices, res);
			// 回退:撤销选择,恢复到之前的状态
			undoChoice(state, choice);
		}
	}
}

接下来,我们基于框架代码来解决例题三。
状态 state 为节点遍历路径,选择 choices 为当前节点的左子节点和右子节点,结果 res 是路径列表。

/* 判断当前状态是否为解 */
boolean isSolution(List<TreeNode> state) {
	return !state.isEmpty() && state.get(state.size() - 1).val == 7;
}

/* 记录解 */
void recordSolution(List<TreeNode> state, List<List<TreeNode>> res) {
	res.add(new ArrayList<>(state));
}

/* 判断在当前状态下,该选择是否合法 */
boolean isValid(List<TreeNode> state, TreeNode choice) {
	return choice != null && choice.val != 3;
}

/* 更新状态 */
void makeChoice(List<TreeNode> state, TreeNode choice) {
	state.add(choice);
}

/* 恢复状态 */
void undoChoice(List<TreeNode> state, TreeNode choice) {
	state.remove(state.size() - 1);
}

/* 回溯算法:例题三 */
void backtrack(List<TreeNode> state, List<TreeNode> choices, 					List<List<TreeNode>> res) {
	// 检查是否为解
	if (isSolution(state)) {
		// 记录解
		recordSolution(state, res);
	}
	// 遍历所有选择
	for (TreeNode choice : choices) {
		// 剪枝:检查选择是否合法
		if (isValid(state, choice)) {
			// 尝试:做出选择,更新状态
			makeChoice(state, choice);
			// 进行下一轮选择
			backtrack(state, Arrays.asList(choice.left, choice.right), res);
			// 回退:撤销选择,恢复到之前的状态
			undoChoice(state, choice);
		}
	}
}

根据题意,我们在找到值为 7 的节点后应该继续搜索,因此需要将记录解之后的 return 语句删除。图对比了保留或删除 return 语句的搜索过程。

在这里插入图片描述
相比基于前序遍历的代码实现,基于回溯算法框架的代码实现虽然显得啰嗦,但通用性更好。
实际上,许多回溯问题都可以在该框架下解决。我们只需根据具体问题来定义 state 和 choices ,并实现框架中的各个方法即可。

常用术语

为了更清晰地分析算法问题,我们总结一下回溯算法中常用术语的含义,并对照例题三给出对应示例。
在这里插入图片描述
问题、解、状态等概念是通用的,在分治、回溯、动态规划、贪心等算法中都有涉及。

优势与局限性

回溯算法本质上是一种深度优先搜索算法,它尝试所有可能的解决方案直到找到满足条件的解。
这种方法的优势在于它能够找到所有可能的解决方案,而且在合理的剪枝操作下,具有很高的效率。

然而,在处理大规模或者复杂问题时,回溯算法的运行效率可能难以接受

  • 时间:回溯算法通常需要遍历状态空间的所有可能,时间复杂度可以达到指数阶或阶乘阶。
  • 空间:在递归调用中需要保存当前的状态(例如路径、用于剪枝的辅助变量等),当深度很大时,空间需求可能会变得很大。

即便如此,回溯算法仍然是某些搜索问题和约束满足问题的最佳解决方案。对于这些问题,由于无法预测哪些选择可生成有效的解,因此我们必须对所有可能的选择进行遍历。在这种情况下,关键是如何进行效率优化,常见的效率优化方法有两种:

  • 剪枝:避免搜索那些肯定不会产生解的路径,从而节省时间和空间。
  • 启发式搜索:在搜索过程中引入一些策略或者估计值,从而优先搜索最有可能产生有效解的路径。

回溯典型例题

回溯算法可用于解决许多搜索问题、约束满足问题和组合优化问题。

搜索问题:这类问题的目标是找到满足特定条件的解决方案。

  • 全排列问题:给定一个集合,求出其所有可能的排列组合。
  • 子集和问题:给定一个集合和一个目标和,找到集合中所有和为目标和的子集。
  • 汉诺塔问题:给定三个柱子和一系列大小不同的圆盘,要求将所有圆盘从一个柱子移动到另一个柱子,每次只能移动一个圆盘,且不能将大圆盘放在小圆盘上。

约束满足问题:这类问题的目标是找到满足所有约束条件的解。

  • 𝑛 皇后:在 𝑛 × 𝑛 的棋盘上放置 𝑛 个皇后,使得它们互不攻击。
  • 数独:在 9 × 9 的网格中填入数字 1 ~ 9 ,使得每行、每列和每个 3 × 3 子网格中的数字不重复。
  • 图着色问题:给定一个无向图,用最少的颜色给图的每个顶点着色,使得相邻顶点颜色不同。

组合优化问题:这类问题的目标是在一个组合空间中找到满足某些条件的最优解。

  • 0‑1 背包问题:给定一组物品和一个背包,每个物品有一定的价值和重量,要求在背包容量限制内,选
    择物品使得总价值最大。
  • 旅行商问题:在一个图中,从一个点出发,访问所有其他点恰好一次后返回起点,求最短路径。
  • 最大团问题:给定一个无向图,找到最大的完全子图,即子图中的任意两个顶点之间都有边相连。

请注意,对于许多组合优化问题,回溯都不是最优解决方案。

  • 0‑1 背包问题通常使用动态规划解决,以达到更高的时间效率。
  • 旅行商是一个著名的 NP‑Hard 问题,常用解法有遗传算法和蚁群算法等。
  • 最大团问题是图论中的一个经典问题,可用贪心等启发式算法来解决。

全排列问题

全排列问题是回溯算法的一个典型应用。它的定义是在给定一个集合(如一个数组或字符串)的情况下,找出这个集合中元素的所有可能的排列。

下表列举了几个示例数据,包括输入数组和对应的所有排列
在这里插入图片描述

无相等元素的情况

输入一个整数数组,数组中不包含重复元素,返回所有可能的排列。

从回溯算法的角度看,我们可以把生成排列的过程想象成一系列选择的结果
假设输入数组为 [1, 2, 3] ,如果我们先选择 1、再选择 3、最后选择 2 ,则获得排列 [1, 3, 2] 。回退表示撤销一个选择,之后继续尝试其他选择。

从回溯代码的角度看,候选集合 choices 是输入数组中的所有元素,状态 state 是直至目前已被选择的元素。
请注意,每个元素只允许被选择一次,因此 state 中的所有元素都应该是唯一的

如图所示,我们可以将搜索过程展开成一个递归树,树中的每个节点代表当前状态 state 。从根节点开始,经过三轮选择后到达叶节点,每个叶节点都对应一个排列。

在这里插入图片描述

1. 重复选择剪枝

为了实现每个元素只被选择一次,我们考虑引入一个布尔型数组 selected ,其中 selected [ i ] 表示 choices [ i ]是否已被选择,并基于它实现以下剪枝操作。

  • 在做出选择 choice [ i ] 后,我们就将 selected [ i ] 赋值为 True ,代表它已被选择。
  • 遍历选择列表 choices 时,跳过所有已被选择过的节点,即剪枝。

如图所示,假设我们第一轮选择 1 ,第二轮选择 3 ,第三轮选择 2 ,则需要在第二轮剪掉元素 1 的分支,在第三轮剪掉元素 1 和元素 3 的分支。
在这里插入图片描述
观察上图发现,该剪枝操作将搜索空间大小从 𝑂(𝑛𝑛) 降低至 𝑂(𝑛!) 。

2. 代码实现

想清楚以上信息之后,我们就可以在框架代码中做 完形填空 了。为了缩短代码行数,我们不单独实现框架代码中的各个函数,而是将他们展开在 backtrack() 函数中。

/* 回溯算法:全排列 I */
void backtrack(List<Integer> state, int[] choices, boolean[] selected, List<List<Integer>> res) {
	// 当状态长度等于元素数量时,记录解
	if (state.size() == choices.length) {
		res.add(new ArrayList<Integer>(state));
		return;
	}
	// 遍历所有选择
	for (int i = 0; i < choices.length; i++) {
		int choice = choices[i];
		// 剪枝:不允许重复选择元素
		if (!selected[i]) {
			// 尝试:做出选择,更新状态
			selected[i] = true;
			state.add(choice);
			// 进行下一轮选择
			backtrack(state, choices, selected, res);
			// 回退:撤销选择,恢复到之前的状态
			selected[i] = false;
			state.remove(state.size() - 1);
		}
	}
}

/* 全排列 I */
List<List<Integer>> permutationsI(int[] nums) {
	List<List<Integer>> res = new ArrayList<List<Integer>>();
	backtrack(new ArrayList<Integer>(), nums, new boolean[nums.length], res);
	return res;
}

考虑相等元素的情况

输入一个整数数组,数组中可能包含重复元素,返回所有不重复的排列。
假设输入数组为 [1, 1, 2] 。为了方便区分两个重复元素 1 ,我们将第二个 1 记为 1 。
如图所示,上述方法生成的排列有一半都是重复的
在这里插入图片描述
那么如何去除重复的排列呢?
最直接地,考虑借助一个哈希表,直接对排列结果进行去重。然而这样做不够优雅,因为生成重复排列的搜索分支是没有必要的,应当被提前识别并剪枝,这样可以进一步提升算法效率。

1. 相等元素剪枝

观察下图,在第一轮中,选择1 或选择 1 ^ \hat{1} 1^ 是等价的,在这两个选择之下生成的所有排列都是重复的。因此应该把 1 剪枝掉。

同理,在第一轮选择 2 之后,第二轮选择中的 1 和 1 ^ \hat{1} 1^ 也会产生重复分支,因此也应将第二轮的 1 ^ \hat{1} 1^ 剪枝。
本质上看,我们的目标是在某一轮选择中,保证多个相等的元素仅被选择一次
在这里插入图片描述

2. 代码实现

在上一题的代码的基础上,我们考虑在每一轮选择中开启一个哈希表 duplicated ,用于记录该轮中已经尝试过的元素,并将重复元素剪枝。

/* 回溯算法:全排列 II */
void backtrack(List<Integer> state, int[] choices, boolean[] selected, List<List<Integer>> res) {
	// 当状态长度等于元素数量时,记录解
	if (state.size() == choices.length) {
		res.add(new ArrayList<Integer>(state));
		return;
	}
	// 遍历所有选择
	Set<Integer> duplicated = new HashSet<Integer>();
		for (int i = 0; i < choices.length; i++) {
			int choice = choices[i];
			// 剪枝:不允许重复选择元素 且 不允许重复选择相等元素
			if (!selected[i] && !duplicated.contains(choice)) {
				// 尝试:做出选择,更新状态
				duplicated.add(choice); // 记录选择过的元素值
				selected[i] = true;
				state.add(choice);
				// 进行下一轮选择
				backtrack(state, choices, selected, res);
				// 回退:撤销选择,恢复到之前的状态
				selected[i] = false;
				state.remove(state.size() - 1);
			}
		}
}

/* 全排列 II */
List<List<Integer>> permutationsII(int[] nums) {
	List<List<Integer>> res = new ArrayList<List<Integer>>();
	backtrack(new ArrayList<Integer>(), nums, new boolean[nums.length], res);
		return res;
}

假设元素两两之间互不相同,则 𝑛 个元素共有 𝑛! 种排列(阶乘);在记录结果时,需要复制长度为 𝑛 的列表,使用 𝑂(𝑛) 时间。因此时间复杂度为 𝑂(𝑛!𝑛)

最大递归深度为 𝑛 ,使用 𝑂(𝑛) 栈帧空间。selected 使用 𝑂(𝑛) 空间。同一时刻最多共有 𝑛 个 duplicated ,使用 𝑂(𝑛2) 空间。因此空间复杂度为 𝑂(𝑛2)

3. 两种剪枝对比

请注意,虽然 selected 和 duplicated 都用作剪枝,但两者的目标是不同的。

  • 重复选择剪枝:整个搜索过程中只有一个 selected 。它记录的是当前状态中包含哪些元素,作用是防止 choices 中的任一元素在 state 中重复出现。
  • 相等元素剪枝:每轮选择(即每个调用的 backtrack 函数)都包含一个 duplicated 。它记录的是在本轮遍历(即 for 循环)中哪些元素已被选择过,作用是保证相等的元素只被选择一次。

下图展示了两个剪枝条件的生效范围。注意,树中的每个节点代表一个选择,从根节点到叶节点的路径上的各个节点构成一个排列。
在这里插入图片描述

子集和问题

无重复元素的情况

问题:
给定一个正整数数组 nums 和一个目标正整数 target ,请找出所有可能的组合,使得组合中的元素和等于 target 。
给定数组无重复元素,每个元素可以被选取多次。请以列表形式返回这些组合,列表中不应包含重复组合。

例如,输入集合 {3, 4, 5} 和目标整数 9 ,解为 {3, 3, 3}, {4, 5} 。
需要注意以下两点:

  • 输入集合中的元素可以被无限次重复选取。
  • 子集是不区分元素顺序的,比如 {4, 5} 和 {5, 4} 是同一个子集。
1. 参考全排列解法

类似于全排列问题,我们可以把子集的生成过程想象成一系列选择的结果,并在选择过程中实时更新 元素和 ,当元素和等于 target 时,就将子集记录至结果列表。

而与全排列问题不同的是,本题集合中的元素可以被无限次选取,因此无须借助 selected 布尔列表来记录元素是否已被选择。我们可以对全排列代码进行小幅修改,初步得到解题代码。

/* 回溯算法:子集和 I */
void backtrack(List<Integer> state, int target, int total, int[] choices, List<List<Integer>> res) {
	// 子集和等于 target 时,记录解
	if (total == target) {
		res.add(new ArrayList<>(state));
		return;
	}
	// 遍历所有选择
	for (int i = 0; i < choices.length; i++) {
		// 剪枝:若子集和超过 target ,则跳过该选择
		if (total + choices[i] > target) {
			continue;
		}
	// 尝试:做出选择,更新元素和 total
	state.add(choices[i]);
	// 进行下一轮选择
	backtrack(state, target, total + choices[i], choices, res);
	// 回退:撤销选择,恢复到之前的状态
	state.remove(state.size() - 1);
	}
}

/* 求解子集和 I(包含重复子集) */
List<List<Integer>> subsetSumINaive(int[] nums, int target) {
	List<Integer> state = new ArrayList<>(); // 状态(子集)
	int total = 0; // 子集和
	List<List<Integer>> res = new ArrayList<>(); // 结果列表(子集列表)
	backtrack(state, target, total, nums, res);
	return res;
}

向以上代码输入数组 [3, 4, 5] 和目标元素 9 ,输出结果为 [3, 3, 3], [4, 5], [5, 4] 。虽然成功找出了所有和为9 的子集,但其中存在重复的子集 [4, 5] 和 [5, 4]

这是因为搜索过程是区分选择顺序的,然而子集不区分选择顺序。
如图所示,先选 4 后选 5 与先选 5 后选 4 是两个不同的分支,但两者对应同一个子集。

在这里插入图片描述
为了去除重复子集,一种直接的思路是对结果列表进行去重
但这个方法效率很低,有两方面原因:

  • 当数组元素较多,尤其是当 target 较大时,搜索过程会产生大量的重复子集。
  • 比较子集(数组)的异同非常耗时,需要先排序数组,再比较数组中每个元素的异同。
2. 重复子集剪枝

我们考虑在搜索过程中通过剪枝进行去重。
观察下图,重复子集是在以不同顺序选择数组元素时产生的,例如以下情况:

  1. 当第一轮和第二轮分别选择 3 和 4 时,会生成包含这两个元素的所有子集,记为 [3, 4, … ] 。
  2. 之后,当第一轮选择 4 时,则第二轮应该跳过 3 ,因为该选择产生的子集 [4, 3, … ] 和 1. 中生成的子集完全重复。

在搜索中,每一层的选择都是从左到右被逐个尝试的,因此越靠右的分支被剪掉的越多。

  1. 前两轮选择 3 和 5 ,生成子集 [3, 5, … ] 。
  2. 前两轮选择 4 和 5 ,生成子集 [4, 5, … ] 。
  3. 若第一轮选择 5 ,则第二轮应该跳过 3 和 4 ,因为子集 [5, 3, … ] 和 [5, 4, … ] 与第 1. 和 2. 步中描述的子集完全重复。

在这里插入图片描述
总结来看,给定输入数组 [ x 1 x_1 x1 , x 2 x_2 x2, … , x n x_n xn] ,设搜索过程中的选择序列为 [ x i 1 x_{i1} xi1, [ x i 2 x_{i2} xi2, … ,[ x i m x_{im} xim] ,则该选择序列需要满足 i 1 i_1 i1 i 2 i_2 i2 ≤ ⋯ ≤ i m i_m im不满足该条件的选择序列都会造成重复,应当剪枝。

3. 代码实现

为实现该剪枝,我们初始化变量 start ,用于指示遍历起点。当做出选择 x i x_{i} xi 后,设定下一轮从索引 𝑖 开始遍历

这样做就可以让选择序列满足 i 1 i_1 i1 i 2 i_2 i2 ≤ ⋯ ≤ i m i_m im ,从而保证子集唯一。
除此之外,我们还对代码进行了以下两项优化。

  • 在开启搜索前,先将数组 nums 排序。在遍历所有选择时,当子集和超过 target 时直接结束循环,因为后边的元素更大,其子集和都一定会超过 target 。
  • 省去元素和变量 total ,通过在 target 上执行减法来统计元素和,当 target 等于 0 时记录解。
/* 回溯算法:子集和 I */
void backtrack(List<Integer> state, int target, int[] choices, int start, List<List<Integer>> res) {
	// 子集和等于 target 时,记录解
	if (target == 0) {
		res.add(new ArrayList<>(state));
		return;
	}
	// 遍历所有选择
	// 剪枝二:从 start 开始遍历,避免生成重复子集
	for (int i = start; i < choices.length; i++) {
		// 剪枝一:若子集和超过 target ,则直接结束循环
		// 这是因为数组已排序,后边元素更大,子集和一定超过 target
		if (target - choices[i] < 0) {
			break;
		}
	// 尝试:做出选择,更新 target, start
	state.add(choices[i]);
	// 进行下一轮选择
	backtrack(state, target - choices[i], choices, i, res);
	// 回退:撤销选择,恢复到之前的状态
	state.remove(state.size() - 1);
	}
}

/* 求解子集和 I */
List<List<Integer>> subsetSumI(int[] nums, int target) {
	List<Integer> state = new ArrayList<>(); // 状态(子集)
	Arrays.sort(nums); // 对 nums 进行排序
	int start = 0; // 遍历起始点
	List<List<Integer>> res = new ArrayList<>(); // 结果列表(子集列表)
	backtrack(state, target, nums, start, res);
	return res;
}

如图所示,为将数组 [3, 4, 5] 和目标元素 9 输入到以上代码后的整体回溯过程。
在这里插入图片描述

考虑重复元素的情况

给定一个正整数数组 nums 和一个目标正整数 target ,请找出所有可能的组合,使得组合中的元素和等于 target 。给定数组可能包含重复元素,每个元素只可被选择一次。请以列表形式返回这些组合,列表中不应包含重复组合。

相比于上题,本题的输入数组可能包含重复元素,这引入了新的问题。
例如,给定数组 [4, 4, 5] 和目标元素9 ,则现有代码的输出结果为 [4, 5], [4, 5] ,出现了重复子集。
造成这种重复的原因是相等元素在某轮中被多次选择。
在下图中,第一轮共有三个选择,其中两个都为 4 ,会产生两个重复的搜索分支,从而输出重复子集;同理,第二轮的两个 4 也会产生重复子集。
在这里插入图片描述

1. 相等元素剪枝

为解决此问题,我们需要限制相等元素在每一轮中只被选择一次

实现方式比较巧妙:由于数组是已排序的,因此相等元素都是相邻的
这意味着在某轮选择中,若当前元素与其左边元素相等,则说明它已经被选择过,因此直接跳过当前元素。

与此同时,本题规定数组中的每个元素只能被选择一次。幸运的是,我们也可以利用变量 start 来满足该约束:当做出选择 x 1 x_1 x1 后,设定下一轮从索引 𝑖 + 1 开始向后遍历。这样即能去除重复子集,也能避免重复选择元素。

2. 代码实现
/* 回溯算法:子集和 II */
void backtrack(List<Integer> state, int target, int[] choices, int start, List<List<Integer>> res) {
	// 子集和等于 target 时,记录解
	if (target == 0) {
		res.add(new ArrayList<>(state));
		return;
	}
	// 遍历所有选择
	// 剪枝二:从 start 开始遍历,避免生成重复子集
	// 剪枝三:从 start 开始遍历,避免重复选择同一元素
	for (int i = start; i < choices.length; i++) {
		// 剪枝一:若子集和超过 target ,则直接结束循环
		// 这是因为数组已排序,后边元素更大,子集和一定超过 target
		if (target - choices[i] < 0) {
			break;
		}
	// 剪枝四:如果该元素与左边元素相等,说明该搜索分支重复,直接跳过
	if (i > start && choices[i] == choices[i - 1]) {
		continue;
	}
	// 尝试:做出选择,更新 target, start
	state.add(choices[i]);
	// 进行下一轮选择
	backtrack(state, target - choices[i], choices, i + 1, res);
	// 回退:撤销选择,恢复到之前的状态
	state.remove(state.size() - 1);
	}
}

/* 求解子集和 II */
List<List<Integer>> subsetSumII(int[] nums, int target) {
	List<Integer> state = new ArrayList<>(); // 状态(子集)
	Arrays.sort(nums); // 对 nums 进行排序
	int start = 0; // 遍历起始点
	List<List<Integer>> res = new ArrayList<>(); // 结果列表(子集列表)
	backtrack(state, target, nums, start, res);
	return res;
}

下图展示了数组 [4, 4, 5] 和目标元素 9 的回溯过程,共包含四种剪枝操作。
请你将图示与代码注释相结合,理解整个搜索过程,以及每种剪枝操作是如何工作的。
在这里插入图片描述

N 皇后问题

问题
如图所示,当 𝑛 = 4 时,共可以找到两个解。从回溯算法的角度看,𝑛 × 𝑛 大小的棋盘共有 𝑛2 个格子,给出了所有的选择 choices 。在逐个放置皇后的过程中,棋盘状态在不断地变化,每个时刻的棋盘就是状态 state 。
在这里插入图片描述
下图展示了本题的三个约束条件:多个皇后不能在同一行、同一列、同一对角线
值得注意的是,对角线分为主对角线 \ 和次对角线 / 两种。
在这里插入图片描述

1. 逐行放置策略

皇后的数量和棋盘的行数都为 𝑛 ,因此我们容易得到一个推论:棋盘每行都允许且只允许放置一个皇后
也就是说,我们可以采取逐行放置策略:从第一行开始,在每行放置一个皇后,直至最后一行结束。
如图所示,为 4 皇后问题的逐行放置过程。受画幅限制,下图仅展开了第一行的其中一个搜索分支,并且将不满足列约束和对角线约束的方案都进行了剪枝。
在这里插入图片描述
本质上看,逐行放置策略起到了剪枝的作用,它避免了同一行出现多个皇后的所有搜索分支。

2. 列与对角线剪枝

为了满足列约束,我们可以利用一个长度为 𝑛 的布尔型数组 cols 记录每一列是否有皇后。在每次决定放置前,我们通过 cols 将已有皇后的列进行剪枝,并在回溯中动态更新 cols 的状态。

那么,如何处理对角线约束呢?设棋盘中某个格子的行列索引为 (𝑟𝑜𝑤, 𝑐𝑜𝑙) ,选定矩阵中的某条主对角线,我们发现该对角线上所有格子的行索引减列索引都相等,即对角线上所有格子的 𝑟𝑜𝑤 − 𝑐𝑜𝑙 为恒定值。

也就是说,如果两个格子满足 𝑟𝑜𝑤1 − 𝑐𝑜𝑙1 = 𝑟𝑜𝑤2 − 𝑐𝑜𝑙2 ,则它们一定处在同一条主对角线上。利用该规律,我们可以借助图 13‑18 所示的数组 diag1 ,记录每条主对角线上是否有皇后。

同理,次对角线上的所有格子的 𝑟𝑜𝑤 + 𝑐𝑜𝑙 是恒定值。我们同样也可以借助数组 diag2 来处理次对角线约束。

在这里插入图片描述

3. 代码实现

请注意,𝑛 维方阵中 𝑟𝑜𝑤 − 𝑐𝑜𝑙 的范围是 [−𝑛 + 1, 𝑛 − 1] ,𝑟𝑜𝑤 + 𝑐𝑜𝑙 的范围是 [0, 2𝑛 − 2] ,所以主对角线和次对角线的数量都为 2𝑛 − 1 ,即数组 diag1 和 diag2 的长度都为 2𝑛 − 1 。

/* 回溯算法:N 皇后 */
void backtrack(int row, int n, List<List<String>> state, List<List<List<String>>> res,
boolean[] cols, boolean[] diags1, boolean[] diags2) {
	// 当放置完所有行时,记录解
	if (row == n) {
		List<List<String>> copyState = new ArrayList<>();
		for (List<String> sRow : state) {
			copyState.add(new ArrayList<>(sRow));
		}
		res.add(copyState);
		return;
	}
	// 遍历所有列
	for (int col = 0; col < n; col++) {
		// 计算该格子对应的主对角线和副对角线
		int diag1 = row - col + n - 1;
		int diag2 = row + col;
		// 剪枝:不允许该格子所在列、主对角线、副对角线存在皇后
		if (!cols[col] && !diags1[diag1] && !diags2[diag2]) {
			// 尝试:将皇后放置在该格子
			state.get(row).set(col, "Q");
			cols[col] = diags1[diag1] = diags2[diag2] = true;
			// 放置下一行
			backtrack(row + 1, n, state, res, cols, diags1, diags2);
			// 回退:将该格子恢复为空位
			state.get(row).set(col, "#");
			cols[col] = diags1[diag1] = diags2[diag2] = false;
		}
	}
}

/* 求解 N 皇后 */
List<List<List<String>>> nQueens(int n) {
	// 初始化 n*n 大小的棋盘,其中 'Q' 代表皇后,'#' 代表空位
	List<List<String>> state = new ArrayList<>();
	for (int i = 0; i < n; i++) {
		List<String> row = new ArrayList<>();
		for (int j = 0; j < n; j++) {
			row.add("#");
		}
		state.add(row);
	}
	boolean[] cols = new boolean[n]; // 记录列是否有皇后
	boolean[] diags1 = new boolean[2 * n - 1]; // 记录主对角线是否有皇后
	boolean[] diags2 = new boolean[2 * n - 1]; // 记录副对角线是否有皇后
	List<List<List<String>>> res = new ArrayList<>();
	backtrack(0, n, state, res, cols, diags1, diags2);
	return res;
}

逐行放置 𝑛 次,考虑列约束,则从第一行到最后一行分别有 𝑛、𝑛 − 1、…、2、1 个选择,因此时间复杂度为 𝑂(𝑛!)
实际上,根据对角线约束的剪枝也能够大幅地缩小搜索空间,因而搜索效率往往优于以上时间复杂度。
数组 state 使用 𝑂(𝑛2) 空间,数组 cols、diags1 和 diags2 皆使用 𝑂(𝑛) 空间。最大递归深度为 𝑛 ,使用𝑂(𝑛) 栈帧空间。因此,空间复杂度为 𝑂(𝑛2)

总结

  • 回溯算法本质是穷举法,通过对解空间进行深度优先遍历来寻找符合条件的解。在搜索过程中,遇到满足条件的解则记录,直至找到所有解或遍历完成后结束。
  • 回溯算法的搜索过程包括尝试与回退两个部分。它通过深度优先搜索来尝试各种选择,当遇到不满足约束条件的情况时,则撤销上一步的选择,退回到之前的状态,并继续尝试其他选择。尝试与回退是两个方向相反的操作。
  • 回溯问题通常包含多个约束条件,它们可用于实现剪枝操作。剪枝可以提前结束不必要的搜索分支,大幅提升搜索效率。
  • 回溯算法主要可用于解决搜索问题和约束满足问题。组合优化问题虽然可以用回溯算法解决,但往往存在更高效率或更好效果的解法。
  • 全排列问题旨在搜索给定集合的所有可能的排列。我们借助一个数组来记录每个元素是否被选择,剪枝掉重复选择同一元素的搜索分支,确保每个元素只被选择一次。
  • 在全排列问题中,如果集合中存在重复元素,则最终结果会出现重复排列。我们需要约束相等元素在每轮中只能被选择一次,这通常借助一个哈希表来实现。
  • 子集和问题的目标是在给定集合中找到和为目标值的所有子集。集合不区分元素顺序,而搜索过程会输出所有顺序的结果,产生重复子集。我们在回溯前将数据进行排序,并设置一个变量来指示每一轮的遍历起点,从而将生成重复子集的搜索分支进行剪枝。
  • 对于子集和问题,数组中的相等元素会产生重复集合。我们利用数组已排序的前置条件,通过判断相邻元素是否相等实现剪枝,从而确保相等元素在每轮中只能被选中一次。
  • 𝑛 皇后旨在寻找将 𝑛 个皇后放置到 𝑛 × 𝑛 尺寸棋盘上的方案,要求所有皇后两两之间无法攻击对方。该问题的约束条件有行约束、列约束、主对角线和副对角线约束。为满足行约束,我们采用按行放置的策略,保证每一行放置一个皇后。
  • 列约束和对角线约束的处理方式类似。对于列约束,我们利用一个数组来记录每一列是否有皇后,从而指示选中的格子是否合法。对于对角线约束,我们借助两个数组来分别记录该主、副对角线是否存在皇后;难点在于找处在到同一主(副)对角线上格子满足的行列索引规律

Q & A

怎么理解回溯和递归的关系?

总的来看,回溯是一种 算法策略 ,而递归更像是一个 工具

  • 回溯算法通常基于递归实现。然而,回溯是递归的应用场景之一,是递归在搜索问题中的应用。
  • 递归的结构体现了 子问题分解 的解题范式,常用于解决分治、回溯、动态规划(记
    忆化递归)等问题。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/1315664.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

算法训练第三十九天|62. 不同路径、63. 不同路径 II

62. 不同路径&#xff1a; 题目链接 一个机器人位于一个 m x n 网格的左上角 &#xff08;起始点在下图中标记为 “Start” &#xff09;。 机器人每次只能向下或者向右移动一步。机器人试图达到网格的右下角&#xff08;在下图中标记为 “Finish” &#xff09;。 问总共有…

Slate基础使用说明

目录 Slate基础使用说明 1. 简单教程 2. 要点说明 2.1 TCommands以及TCommands基类 2.2 FUICommandInfo 2.3 FUICommandList 2.4 FUIAction 2.5 UICommand 3. 代码源码 4. 工具使用 4.1 Display Ul Extension Points 4. 参考文章 Slate基础使用说明 1.…

TCP/IP详解——HTTPS 协议

文章目录 1. HTTPS 协议1.1 HTTPS 原理1.2 HTTPS 过程1.3 从数据包角度看 HTTPS 交互过程1.4 常见的 HTTPS 数据包解码1.4.1 ClientHello 数据包1.4.2 ServerHello 数据包 1.5 思考 1. HTTPS 协议 1.1 HTTPS 原理 HTTPS概念 HTTPS 是以安全为目标的HTTP通道&#xff0c;并不…

在线弹钢琴网站源码,带练习教学和推销乐谱功能

源码介绍 AutoPiano-在线弹钢琴网站源码&#xff0c;在线钢琴模拟器&#xff0c;自由在线弹奏虚拟钢琴&#xff0c;是所有人的绝佳音乐平台。 背景图片在./static/images, 另外需要在js内的0.7fc8a67cbbcb823cf89e.js和1.f9a3f1dba143d1b59ca4.js修改图片对应路径。 钢琴声…

Docker 部署 Lobe Chat 服务

拉取最新版本的 Lobe Chat 镜像&#xff1a; $ sudo docker pull lobehub/lobe-chat:latest使用以下命令来运行 Lobe Chat 容器: $ sudo docker run -d --name lobe-chat -p 10084:3210 -e OPENAI_API_KEYsk-xxxx -e OPENAI_PROXY_URLhttps://api.openai.com/v1 -e ACCESS_CO…

《Linux C编程实战》笔记:实现自己的ls命令

关键函数的功能及说明 1.void display_attribute(struct stat buf,char *name) 函数功能&#xff1a;打印文件名为name的文件信息&#xff0c;如 含义分别为&#xff1a;文件的类型和访问权限&#xff0c;文件的链接数&#xff0c;文件的所有者&#xff0c;文件所有者所属的组…

2023年【公路水运工程施工企业安全生产管理人员】考试总结及公路水运工程施工企业安全生产管理人员试题及解析

题库来源&#xff1a;安全生产模拟考试一点通公众号小程序 公路水运工程施工企业安全生产管理人员考试总结根据新公路水运工程施工企业安全生产管理人员考试大纲要求&#xff0c;安全生产模拟考试一点通将公路水运工程施工企业安全生产管理人员模拟考试试题进行汇编&#xff0…

PCL点云处理之自定义生成多种类型的圆柱点云(C++)(二百二十五)

PCL点云处理之自定义生成多种类型的圆柱点云(C++)(二百二十五) 一、算法介绍1.空心圆柱点云2.实心圆柱点云二、算法实现1.生成方法1(空心)2.生成方法2(实心)一、算法介绍 根据自己指定的圆柱长度、圆柱半径、以及中心轴的方向,生成一个圆柱点云数据,保存在PCD文件中…

c#可变参数(params)关键字

通过使用 params 关键字&#xff0c;可以指定采用可变数量参数的方法参数。 可以发送参数声明中指定类型的参数的逗号分隔列表&#xff0c;也可以发送指定类型的参数数组。您也可以不发送任何参数。如果未发送任何参数&#xff0c;则参数列表的长度为零。 方法声明中的 param…

Anaconda中使用Jupyter出现’No module named ‘pymysql‘问题解决

问题截图&#xff1a; 解决办法&#xff1a; 一.找到Anaconda所在文件夹&#xff0c;文件夹处输入 cmd 进入命令控制 二. 在打开的cmd中输入‘conda install pymysql’ 三、输入y 安装完成~ 测试&#xff1a; import pandas as pd from sqlalchemy import create_engine …

同义词替换降低论文抄袭率的有效性探讨 papergpt

大家好&#xff0c;今天来聊聊同义词替换降低论文抄袭率的有效性探讨&#xff0c;希望能给大家提供一点参考。 以下是针对论文重复率高的情况&#xff0c;提供一些修改建议和技巧&#xff0c;可以借助此类工具&#xff1a; 标题&#xff1a;同义词替换降低论文抄袭率的有效性探…

Redis实现延迟队列

目录 一、什么是延时队列 二、延时队列的应用 三、举例说明 我的设计思想: 一、什么是延时队列 延时队列相比于普通队列最大的区别就体现在其延时的属性上&#xff0c;普通队列的元素是先进先出&#xff0c;按入队顺序进行处理&#xff0c;而延时队列中的元素在入队时会指定…

【Android12】WindowManagerService架构分析

Android WindowManagerService架构分析 WindowManagerService(以下简称WMS) 是Android的核心服务。WMS管理所有应用程序窗口(Window)的Create、Display、Update、Destory。 因为Android系统中只有一个WMS&#xff08;运行在SystemServer进程&#xff09;&#xff0c;可以称其为…

拼多多ID取商品详情API:电商行业的秘密武器与实时数据获取的智慧之路

一、引言 电商行业是一个不断发展和创新的领域&#xff0c;各种电商平台不断涌现&#xff0c;为消费者提供了更加便捷和多样化的购物体验。拼多多作为中国电商市场的一匹黑马&#xff0c;以其独特的社交电商模式和丰富的商品资源&#xff0c;吸引了大量用户。为了满足用户对商…

多模态融合-MVP

提示:文章写完后,目录可以自动生成,如何生成可参考右边的帮助文档 文章目录 前言在这里插入图片描述 通过将激光点云3D BBox投影道2D图像,可以发现激光对远处的目标检测要弱于传统的2D detector,所以作者想用2D的图像信息去补充3D的点云信息。一、MVP优势二、MVP方法1.Lid…

【QT】非常简单的登录界面实现

本系列是作者自学实践过程的记录 本文是关于登录界面设计 有问题欢迎讨论 效果图&#xff1a; 一、创建项目和主界面 创建Qt Widget Application 这里我们使用qmake而不是cmake 这是主界面&#xff0c;登录界面等后面再创建&#xff0c;这里要勾选上generate form&#xff0…

AI智能化办公:ChatGPT使用方法与技巧

文章目录 ChatGPT简介✨ChatGPT的使用方法✨登录与访问发送请求调整参数 ChatGPT技巧分享✨清晰的提问实验不同的温度值多轮对话 图书推荐✨AI智能化办公内容简介获取方式 AI短视频内容简介获取方式 随着人工智能技术的不断发展&#xff0c;AI助手在办公场景中扮演着越来越重要…

windows10 php8连接sql server

一、环境安装 文章目录 一、环境安装1.安装php拓展2.在 Windows 上安装PHP驱动程序3.在 Windows 上安装ODBC驱动 二、php连接sqlserver三、注意事项数据库相关设置相关语法sqlsrv_fetch_array 的示例&#xff1a;sqlsrv_fetch 的示例&#xff1a;echo 和 print_r 的不同 所用资…

抠图软件哪个好用?什么软件可以抠图换背景?

抠图软件哪个好用&#xff1f;在图片处理中&#xff0c;抠图换背景是一项常见的操作。很多新手可能会对此感到困惑&#xff0c;不知道应该使用什么软件来进行抠图换景。实际上&#xff0c;现在市面上有很多图片处理软件都具备抠图换背景的功能&#xff0c;每款软件都有其优缺点…

王世军:铁笔翰墨染丹青 九峰冠华传千古

鸡是十二生肖中一员&#xff0c;在民间过年时常被剪成窗花&#xff0c;贴于窗户大门上。为表达人们对鸡的喜爱&#xff0c;将正月初一定为“鸡日”&#xff0c;鸡谐音“吉”&#xff0c;意为大吉大利&#xff0c;讨个好彩头。鸡又为“五德之君”&#xff0c;鸡的五德谓之文、武…