This commit is contained in:
krahets
2023-08-27 23:41:10 +08:00
parent 8c9cf3f087
commit 016f13d882
66 changed files with 262 additions and 270 deletions
@@ -5094,7 +5094,7 @@
<li><strong>时间</strong>:回溯算法通常需要遍历状态空间的所有可能,时间复杂度可以达到指数阶或阶乘阶。</li>
<li><strong>空间</strong>:在递归调用中需要保存当前的状态(例如路径、用于剪枝的辅助变量等),当深度很大时,空间需求可能会变得很大。</li>
</ul>
<p>即便如此,<strong>回溯算法仍然是某些搜索问题和约束满足问题的最佳解决方案</strong>。对于这些问题,由于无法预测哪些选择可生成有效的解,因此我们必须对所有可能的选择进行遍历。在这种情况下,<strong>关键是如何进行效率优化</strong>,常见方法有:</p>
<p>即便如此,<strong>回溯算法仍然是某些搜索问题和约束满足问题的最佳解决方案</strong>。对于这些问题,由于无法预测哪些选择可生成有效的解,因此我们必须对所有可能的选择进行遍历。在这种情况下,<strong>关键是如何进行效率优化</strong>,常见的效率优化方法有两种。</p>
<ul>
<li><strong>剪枝</strong>:避免搜索那些肯定不会产生解的路径,从而节省时间和空间。</li>
<li><strong>启发式搜索</strong>:在搜索过程中引入一些策略或者估计值,从而优先搜索最有可能产生有效解的路径。</li>
@@ -5119,7 +5119,7 @@
<li>旅行商问题:在一个图中,从一个点出发,访问所有其他点恰好一次后返回起点,求最短路径。</li>
<li>最大团问题:给定一个无向图,找到最大的完全子图,即子图中的任意两个顶点之间都有边相连。</li>
</ul>
<p>请注意,对于许多组合优化问题,回溯都不是最优解决方案,例如:</p>
<p>请注意,对于许多组合优化问题,回溯都不是最优解决方案</p>
<ul>
<li>0-1 背包问题通常使用动态规划解决,以达到更高的时间效率。</li>
<li>旅行商是一个著名的 NP-Hard 问题,常用解法有遗传算法和蚁群算法等。</li>
@@ -4028,8 +4028,8 @@
</div>
</div>
</div>
<p>逐行放置 <span class="arithmatex">\(n\)</span> 次,考虑列约束,则从第一行到最后一行分别有 <span class="arithmatex">\(n, n-1, \dots, 2, 1\)</span> 个选择,<strong>因此时间复杂度为 <span class="arithmatex">\(O(n!)\)</span></strong> 。实际上,根据对角线约束的剪枝也能够大幅地缩小搜索空间,因而搜索效率往往优于以上时间复杂度。</p>
<p>数组 <code>state</code> 使用 <span class="arithmatex">\(O(n^2)\)</span> 空间,数组 <code>cols</code> , <code>diags1</code> , <code>diags2</code> 皆使用 <span class="arithmatex">\(O(n)\)</span> 空间。最大递归深度为 <span class="arithmatex">\(n\)</span> ,使用 <span class="arithmatex">\(O(n)\)</span> 栈帧空间。因此,<strong>空间复杂度为 <span class="arithmatex">\(O(n^2)\)</span></strong></p>
<p>逐行放置 <span class="arithmatex">\(n\)</span> 次,考虑列约束,则从第一行到最后一行分别有 <span class="arithmatex">\(n\)</span><span class="arithmatex">\(n-1\)</span><span class="arithmatex">\(\dots\)</span><span class="arithmatex">\(2\)</span><span class="arithmatex">\(1\)</span> 个选择,<strong>因此时间复杂度为 <span class="arithmatex">\(O(n!)\)</span></strong> 。实际上,根据对角线约束的剪枝也能够大幅地缩小搜索空间,因而搜索效率往往优于以上时间复杂度。</p>
<p>数组 <code>state</code> 使用 <span class="arithmatex">\(O(n^2)\)</span> 空间,数组 <code>cols</code><code>diags1</code> <code>diags2</code> 皆使用 <span class="arithmatex">\(O(n)\)</span> 空间。最大递归深度为 <span class="arithmatex">\(n\)</span> ,使用 <span class="arithmatex">\(O(n)\)</span> 栈帧空间。因此,<strong>空间复杂度为 <span class="arithmatex">\(O(n^2)\)</span></strong></p>
@@ -3569,19 +3569,19 @@
<p class="admonition-title">Question</p>
<p>输入一个整数数组,数组中不包含重复元素,返回所有可能的排列。</p>
</div>
<p>从回溯算法的角度看,<strong>我们可以把生成排列的过程想象成一系列选择的结果</strong>。假设输入数组为 <span class="arithmatex">\([1, 2, 3]\)</span> ,如果我们先选择 <span class="arithmatex">\(1\)</span> 、再选择 <span class="arithmatex">\(3\)</span> 、最后选择 <span class="arithmatex">\(2\)</span> ,则获得排列 <span class="arithmatex">\([1, 3, 2]\)</span> 。回退表示撤销一个选择,之后继续尝试其他选择。</p>
<p>从回溯算法的角度看,<strong>我们可以把生成排列的过程想象成一系列选择的结果</strong>。假设输入数组为 <span class="arithmatex">\([1, 2, 3]\)</span> ,如果我们先选择 <span class="arithmatex">\(1\)</span>、再选择 <span class="arithmatex">\(3\)</span>、最后选择 <span class="arithmatex">\(2\)</span> ,则获得排列 <span class="arithmatex">\([1, 3, 2]\)</span> 。回退表示撤销一个选择,之后继续尝试其他选择。</p>
<p>从回溯代码的角度看,候选集合 <code>choices</code> 是输入数组中的所有元素,状态 <code>state</code> 是直至目前已被选择的元素。请注意,每个元素只允许被选择一次,<strong>因此 <code>state</code> 中的所有元素都应该是唯一的</strong></p>
<p>如图 13-5 所示,我们可以将搜索过程展开成一个递归树,树中的每个节点代表当前状态 <code>state</code> 。从根节点开始,经过三轮选择后到达叶节点,每个叶节点都对应一个排列。</p>
<p><img alt="全排列的递归树" src="../permutations_problem.assets/permutations_i.png" /></p>
<p align="center"> 图 13-5 &nbsp; 全排列的递归树 </p>
<h3 id="1">1. &nbsp; 重复选择剪枝<a class="headerlink" href="#1" title="Permanent link">&para;</a></h3>
<p>为了实现每个元素只被选择一次,我们考虑引入一个布尔型数组 <code>selected</code> ,其中 <code>selected[i]</code> 表示 <code>choices[i]</code> 是否已被选择。剪枝的实现原理为:</p>
<p>为了实现每个元素只被选择一次,我们考虑引入一个布尔型数组 <code>selected</code> ,其中 <code>selected[i]</code> 表示 <code>choices[i]</code> 是否已被选择,并基于它实现以下剪枝操作。</p>
<ul>
<li>在做出选择 <code>choice[i]</code> 后,我们就将 <code>selected[i]</code> 赋值为 <span class="arithmatex">\(\text{True}\)</span> ,代表它已被选择。</li>
<li>遍历选择列表 <code>choices</code> 时,跳过所有已被选择过的节点,即剪枝。</li>
</ul>
<p>如图 13-6 所示,假设我们第一轮选择 1 ,第二轮选择 3 ,第三轮选择 2 ,则需要在第二轮剪掉元素 1 的分支,在第三轮剪掉元素 1, 3 的分支。</p>
<p>如图 13-6 所示,假设我们第一轮选择 1 ,第二轮选择 3 ,第三轮选择 2 ,则需要在第二轮剪掉元素 1 的分支,在第三轮剪掉元素 1 和元素 3 的分支。</p>
<p><img alt="全排列剪枝示例" src="../permutations_problem.assets/permutations_i_pruning.png" /></p>
<p align="center"> 图 13-6 &nbsp; 全排列剪枝示例 </p>
@@ -4385,7 +4385,7 @@
<p>假设元素两两之间互不相同,则 <span class="arithmatex">\(n\)</span> 个元素共有 <span class="arithmatex">\(n!\)</span> 种排列(阶乘);在记录结果时,需要复制长度为 <span class="arithmatex">\(n\)</span> 的列表,使用 <span class="arithmatex">\(O(n)\)</span> 时间。因此,<strong>时间复杂度为 <span class="arithmatex">\(O(n!n)\)</span></strong></p>
<p>最大递归深度为 <span class="arithmatex">\(n\)</span> ,使用 <span class="arithmatex">\(O(n)\)</span> 栈帧空间。<code>selected</code> 使用 <span class="arithmatex">\(O(n)\)</span> 空间。同一时刻最多共有 <span class="arithmatex">\(n\)</span><code>duplicated</code> ,使用 <span class="arithmatex">\(O(n^2)\)</span> 空间。<strong>因此空间复杂度为 <span class="arithmatex">\(O(n^2)\)</span></strong></p>
<h3 id="3">3. &nbsp; 两种剪枝对比<a class="headerlink" href="#3" title="Permanent link">&para;</a></h3>
<p>请注意,虽然 <code>selected</code><code>duplicated</code> 都用作剪枝,但两者的目标不同</p>
<p>请注意,虽然 <code>selected</code><code>duplicated</code> 都用作剪枝,但两者的目标不同的。</p>
<ul>
<li><strong>重复选择剪枝</strong>:整个搜索过程中只有一个 <code>selected</code> 。它记录的是当前状态中包含哪些元素,作用是避免某个元素在 <code>state</code> 中重复出现。</li>
<li><strong>相等元素剪枝</strong>:每轮选择(即每个开启的 <code>backtrack</code> 函数)都包含一个 <code>duplicated</code> 。它记录的是在遍历中哪些元素已被选择过,作用是保证相等元素只被选择一次。</li>
@@ -3541,7 +3541,7 @@
<p class="admonition-title">Question</p>
<p>给定一个正整数数组 <code>nums</code> 和一个目标正整数 <code>target</code> ,请找出所有可能的组合,使得组合中的元素和等于 <code>target</code> 。给定数组无重复元素,每个元素可以被选取多次。请以列表形式返回这些组合,列表中不应包含重复组合。</p>
</div>
<p>例如,输入集合 <span class="arithmatex">\(\{3, 4, 5\}\)</span> 和目标整数 <span class="arithmatex">\(9\)</span> ,解为 <span class="arithmatex">\(\{3, 3, 3\}, \{4, 5\}\)</span> 。需要注意两点</p>
<p>例如,输入集合 <span class="arithmatex">\(\{3, 4, 5\}\)</span> 和目标整数 <span class="arithmatex">\(9\)</span> ,解为 <span class="arithmatex">\(\{3, 3, 3\}, \{4, 5\}\)</span> 。需要注意以下两点</p>
<ul>
<li>输入集合中的元素可以被无限次重复选取。</li>
<li>子集是不区分元素顺序的,比如 <span class="arithmatex">\(\{4, 5\}\)</span><span class="arithmatex">\(\{5, 4\}\)</span> 是同一个子集。</li>
@@ -3945,30 +3945,30 @@
<p><img alt="子集搜索与越界剪枝" src="../subset_sum_problem.assets/subset_sum_i_naive.png" /></p>
<p align="center"> 图 13-10 &nbsp; 子集搜索与越界剪枝 </p>
<p>为了去除重复子集,<strong>一种直接的思路是对结果列表进行去重</strong>。但这个方法效率很低,因为:</p>
<p>为了去除重复子集,<strong>一种直接的思路是对结果列表进行去重</strong>。但这个方法效率很低,有两方面原因。</p>
<ul>
<li>当数组元素较多,尤其是当 <code>target</code> 较大时,搜索过程会产生大量的重复子集。</li>
<li>比较子集(数组)的异同非常耗时,需要先排序数组,再比较数组中每个元素的异同。</li>
</ul>
<h3 id="2">2. &nbsp; 重复子集剪枝<a class="headerlink" href="#2" title="Permanent link">&para;</a></h3>
<p><strong>我们考虑在搜索过程中通过剪枝进行去重</strong>。观察图 13-11 ,重复子集是在以不同顺序选择数组元素时产生的,具体来看:</p>
<p><strong>我们考虑在搜索过程中通过剪枝进行去重</strong>。观察图 13-11 ,重复子集是在以不同顺序选择数组元素时产生的,例如以下情况。</p>
<ol>
<li>第一轮和第二轮分别选择 <span class="arithmatex">\(3\)</span> , <span class="arithmatex">\(4\)</span> ,会生成包含这两个元素的所有子集,记为 <span class="arithmatex">\([3, 4, \dots]\)</span></li>
<li>第一轮选择 <span class="arithmatex">\(4\)</span> <strong>则第二轮应该跳过 <span class="arithmatex">\(3\)</span></strong> ,因为该选择产生的子集 <span class="arithmatex">\([4, 3, \dots]\)</span><code>1.</code> 中生成的子集完全重复。</li>
<li>第一轮和第二轮分别选择 <span class="arithmatex">\(3\)</span> <span class="arithmatex">\(4\)</span> ,会生成包含这两个元素的所有子集,记为 <span class="arithmatex">\([3, 4, \dots]\)</span></li>
<li>之后,当第一轮选择 <span class="arithmatex">\(4\)</span> <strong>则第二轮应该跳过 <span class="arithmatex">\(3\)</span></strong> ,因为该选择产生的子集 <span class="arithmatex">\([4, 3, \dots]\)</span><code>1.</code> 中生成的子集完全重复。</li>
</ol>
<p>如图 13-11 所示,每一层的选择都是从左到右被逐个尝试的,因此越靠右剪枝越多。</p>
<p>在搜索中,每一层的选择都是从左到右被逐个尝试的,因此越靠右的分支被剪掉的越多。</p>
<ol>
<li>前两轮选择 <span class="arithmatex">\(3\)</span> , <span class="arithmatex">\(5\)</span> ,生成子集 <span class="arithmatex">\([3, 5, \dots]\)</span></li>
<li>前两轮选择 <span class="arithmatex">\(4\)</span> , <span class="arithmatex">\(5\)</span> ,生成子集 <span class="arithmatex">\([4, 5, \dots]\)</span></li>
<li>若第一轮选择 <span class="arithmatex">\(5\)</span> <strong>则第二轮应该跳过 <span class="arithmatex">\(3\)</span><span class="arithmatex">\(4\)</span></strong> ,因为子集 <span class="arithmatex">\([5, 3, \dots]\)</span>子集 <span class="arithmatex">\([5, 4, \dots]\)</span> <code>1.</code> , <code>2.</code> 中描述的子集完全重复。</li>
<li>前两轮选择 <span class="arithmatex">\(3\)</span> <span class="arithmatex">\(5\)</span> ,生成子集 <span class="arithmatex">\([3, 5, \dots]\)</span></li>
<li>前两轮选择 <span class="arithmatex">\(4\)</span> <span class="arithmatex">\(5\)</span> ,生成子集 <span class="arithmatex">\([4, 5, \dots]\)</span></li>
<li>若第一轮选择 <span class="arithmatex">\(5\)</span> <strong>则第二轮应该跳过 <span class="arithmatex">\(3\)</span><span class="arithmatex">\(4\)</span></strong> ,因为子集 <span class="arithmatex">\([5, 3, \dots]\)</span><span class="arithmatex">\([5, 4, \dots]\)</span> 与第 <code>1.</code> <code>2.</code> 中描述的子集完全重复。</li>
</ol>
<p><img alt="不同选择顺序导致的重复子集" src="../subset_sum_problem.assets/subset_sum_i_pruning.png" /></p>
<p align="center"> 图 13-11 &nbsp; 不同选择顺序导致的重复子集 </p>
<p>总结来看,给定输入数组 <span class="arithmatex">\([x_1, x_2, \dots, x_n]\)</span> ,设搜索过程中的选择序列为 <span class="arithmatex">\([x_{i_1}, x_{i_2}, \dots , x_{i_m}]\)</span> ,则该选择序列需要满足 <span class="arithmatex">\(i_1 \leq i_2 \leq \dots \leq i_m\)</span> <strong>不满足该条件的选择序列都会造成重复,应当剪枝</strong></p>
<p>总结来看,给定输入数组 <span class="arithmatex">\([x_1, x_2, \dots, x_n]\)</span> ,设搜索过程中的选择序列为 <span class="arithmatex">\([x_{i_1}, x_{i_2}, \dots, x_{i_m}]\)</span> ,则该选择序列需要满足 <span class="arithmatex">\(i_1 \leq i_2 \leq \dots \leq i_m\)</span> <strong>不满足该条件的选择序列都会造成重复,应当剪枝</strong></p>
<h3 id="3">3. &nbsp; 代码实现<a class="headerlink" href="#3" title="Permanent link">&para;</a></h3>
<p>为实现该剪枝,我们初始化变量 <code>start</code> ,用于指示遍历起点。<strong>当做出选择 <span class="arithmatex">\(x_{i}\)</span> 后,设定下一轮从索引 <span class="arithmatex">\(i\)</span> 开始遍历</strong>。这样做就可以让选择序列满足 <span class="arithmatex">\(i_1 \leq i_2 \leq \dots \leq i_m\)</span> ,从而保证子集唯一。</p>
<p>除此之外,我们还对代码进行了两项优化</p>
<p>除此之外,我们还对代码进行了以下两项优化</p>
<ul>
<li>在开启搜索前,先将数组 <code>nums</code> 排序。在遍历所有选择时,<strong>当子集和超过 <code>target</code> 时直接结束循环</strong>,因为后边的元素更大,其子集和都一定会超过 <code>target</code></li>
<li>省去元素和变量 <code>total</code><strong>通过在 <code>target</code> 上执行减法来统计元素和</strong>,当 <code>target</code> 等于 <span class="arithmatex">\(0\)</span> 时记录解。</li>