☰
回溯算法入门:LeetCode 46全排列从模板到细节
2026/10/8 3:47:32 网站建设 项目流程

刷题刷到第46题全排列的时候,我差点被递归绕进去。你听过的那个感觉:明明知道套路是回溯算法,也知道要用一个列表记路径,但一跑起来结果全是空,或者同一个排列出现好几遍。这道题几乎是 LeetCode 热门100题里回溯算法的第一块正门门槛,刷透它,“组合、子集、全排列II、N皇后”大概率都能顺势拿下来。我这份总结不打算写教科书式的原理,直接站在踩过坑的人角度,把全排列这道题从模板到细节、从调试到变体全部拆一遍。

很多朋友刷题时喜欢背代码,背完第46题第二天就不认识第47题。问题不在于记性,而在于没理解回溯算法到底在函数调用里做了什么。这篇文章会对着[1,2,3]这个输入把递归过程一帧一帧推进去,告诉你路径怎么生长、结果什么时候被收集、撤销选择为什么不能省。看的过程中你最好也把编辑器打开,跟着敲一遍,体感完全不一样。无论你是刚接触回溯算法的新手,还是刷了一轮但卡在递归结构上的进阶选手,这篇都建议从头看。

1. 为什么全排列是回溯算法的第一块敲门砖

1.1 回溯到底在干什么:用决策树理解它

回溯算法听起来玄,其实本质就一句话:在多个岔路口逐层做选择,选错了就退回到上一个岔路口重新选。生活里最典型的例子是穿鞋出门,左右脚先穿哪只,穿到一半发现鞋带在另一边,你会脱掉重穿。这就是回溯:尝试一条路,不通或者枚举完毕就“撤销”这一步,再走另一条路。

放到全排列的场景里,[1,2,3]的每个排列都对应一棵决策树的一条完整路径。树的第一层有三个分支:第一个位置放 1、放 2 或放 3。选了 1 之后,第二层只能在剩下的[2,3]里选,选完 2 第三层只能选 3。走到底部就是叶子节点,此时路径长度等于数组长度,一个排列就完成了。接下来要做的不是直接停,而是从叶子节点爬回去,把最后选的那个数“吐出来”,再尝试另一个还没用过的数。

回溯和普通递归、DFS 的关系也容易混。我的理解是:DFS 是一种遍历图或树的框架,而回溯算法是 DFS 在“状态空间”里的应用。全排列的状态空间就是“当前已经选了哪些数、下一个位置还能选哪些数”。所以很多人说回溯是“DFS 的一种剪枝版”,本质没毛病。有了这层认识,再去看很多回溯题解就不会觉得代码是凭空冒出来的。

1.2 为什么先刷全排列而不是组合子集

LeetCode 里回溯相关的经典题很多,组合总和、子集、全排列都是热门。它们的模板长得非常像,但如果你第一题就选组合总和,大概率会被“是否允许重复取同一个数”和“startIndex 从哪里开始”搞晕。因为组合题里没有明显的“数被用过了”的判断,新手很容易把同一个排列算两遍。

全排列天然适合做入门,原因有三个。第一,排列的结果标准直观,[1,2,3]的排列就是 6 个,错一个都能一眼看出来。第二,它必须使用used数组或等价结构来标记哪些数已经用过,这逼迫你理解“选择列表是怎么变化的”,而不是糊弄过去。第三,它的撤销操作特别经典:加入一个数、递归、再移除这个数,三行代码一条龙,完全对应决策树的“进入分支、探底、退回岔路口”。

我自己刷题时有个体会:排列题刷完一遍,再去做组合和子集,模板几乎可以平推过去。区别无非是组合用startIndex控制选择范围,排列用used数组控制哪些数不能选。先掌握更严格的那个,再学灵活的那个,心理负担小得多。这也是为什么很多老手建议把全排列放在回溯专题的第一题。

2. 一个套路吃透全排列:模板拆解与核心原理

2.1 第46题的输入输出与前置知识

题目本身很简单:给一个不含重复数字的数组,返回所有可能的全排列。你不需要考虑数组里有重复元素的场景,那是第47题的事。输入[1,2,3],输出就是六个排列,顺序不限。这道题的输入规模不会太大,一般在 1 到 6 之间,所以哪怕你的解法时间复杂度有点难看,也能通过,关键是递归结构要写对。

前置知识并不多,但至少得理解三样东西:递归函数是什么、List 和 ArrayList 的基本操作、以及“值传递与引用传递”的区别。前两个不说了,第三个是这道题真正埋雷的地方。后面你会发现,如果不小心把同一个track对象反复加进结果集,最终结果会是清一色重复的空列表或最后一版列表。理解引用传递是避开这个坑的起点。

另外,很多人一上来就想“怎么用迭代生成全排列”。迭代也能做,比如每次往已有排列的各个位置插入新数,或者按字典序生成下一个排列,但这些思路对回溯算法的训练没有帮助。刷这道题的目的不是“解出来”,而是“建立递归思维模型”。所以我建议你老老实实按回溯模板来,迭代解法留到二刷时再研究。

2.2 回溯三要素怎么映射到这道题

回溯算法的标准套路可以归纳为三个要素:路径、选择列表、结束条件。我刷了几十道回溯题之后发现,只要能把这三点讲清楚,代码就是水到渠成的事。放到第46题里,具体对应关系是这样的:

  • 路径:已经选出的数字序列,用一个LinkedList或ArrayList维护,比如当前选了[1,2],这就算一条路径,对应决策树上走到第二层。
  • 选择列表:当前还能选哪些数字。在全排列里选择列表不是固定的,而是动态变化的——凡是已经在路径里的数都不能再选。这正是used数组存在的意义,它标记某个数是否已经出现在路径中。
  • 结束条件:路径的长度等于nums.length。此时没有可再选的数了,说明已经走到决策树的叶子节点,找到一种排列,把它拷贝进结果集然后返回。

每次递归调用要做的事情也很固定:遍历所有候选数字,如果这个数字已经被used标记,就跳过;否则将其加入路径、标记used、递归调用下一层,等递归返回后再做撤销。这个“撤销”动作就是路径的最后一个元素移出,同时把used对应位置改回false。没有撤销,决策树就回不到岔路口,枚举就会漏掉大量分支。

我给你画一个抽象理解:递归函数每次进入都站在某个岔路口,循环负责枚举岔路口的每条路,递归负责沿着一条路走到尽头,撤销则负责从路的尽头退回路口。三者配合起来,就能把所有路径无重复、无遗漏地走一遍。这段逻辑只要想通了,后面几乎所有回溯题都是一个套路。

2.3 标准模板代码逐行讲解

直接看代码,这是我刷了多版题解后觉得最清晰的一份,用 Java 写,逻辑可以平移到 Python、C++:

class Solution { public List<List<Integer>> permute(int[] nums) { List<List<Integer>> res = new ArrayList<>(); Deque<Integer> track = new ArrayDeque<>(); boolean[] used = new boolean[nums.length]; backtrack(nums, track, used, res); return res; } private void backtrack(int[] nums, Deque<Integer> track, boolean[] used, List<List<Integer>> res) { if (track.size() == nums.length) { res.add(new ArrayList<>(track)); return; } for (int i = 0; i < nums.length; i++) { if (used[i]) { continue; } track.addLast(nums[i]); used[i] = true; backtrack(nums, track, used, res); track.removeLast(); used[i] = false; } } }

代码表面上只有三块,我拆开讲一下。

res.add(new ArrayList<>(track))这一步是最容易出事的。如果直接写res.add(track),那res里所有元素都是同一个track对象的引用,之后track一变,之前所有“排列”都会跟着变。所以必须new一个新列表出来,把当前路径内容拷贝一份存进去。

循环里的if (used[i]) continue是“选择列表”的具体实现。因为全排列要求每个数字只能用一次,所以只要这个数字在当前路径里,就直接跳过。递归进入下一层后,used数组里被标记为true的位置越来越多,循环里能真正执行下去的分支就越来越少,直到路径长度等于nums.length。

递归调用后面的track.removeLast()和used[i] = false就是撤销。很多新手会漏掉其中一行,写完之后结果要么多一堆重复排列,要么栈溢出。我自己的习惯是:写完加入动作顺手写撤销动作,两行贴在一起,不分开,这样能最大程度避免“加进去了却忘了撤”的问题。

Python 写法也很接近,不同之处在于track用列表,撤销时用track.pop():

class Solution: def permute(self, nums: List[int]) -> List[List[int]]: res = [] track = [] used = [False] * len(nums) def backtrack(): if len(track) == len(nums): res.append(track[:]) return for i in range(len(nums)): if used[i]: continue track.append(nums[i]) used[i] = True backtrack() track.pop() used[i] = False backtrack() return res

Python 里track[:]是拷贝列表的惯用写法,它的作用和 Java 里new ArrayList<>(track)完全一样。学完这一版,把语言换成 C++ 也只是把vector的push_back、pop_back换一换的问题。模板就是模板,关键是理解每一步在决策树上对应什么动作。

3. 把递归过程摊开看:我手动跑了一遍 [1,2,3]

3.1 从空路径出发,完整走完第一个分支

为了让你彻底看明白,我决定把执行过程写成“人话版”的轨迹。初始状态是track = [],used = [false, false, false],从backtrack第一层进入循环。

  • 第一层循环i=0,nums[0]=1,used[0]是 false,选择它。路径变成[1],标记used[0]=true,调用backtrack进入第二层。
  • 第二层循环从i=0开始,发现used[0]已经是 true,跳过。接着i=1,nums[1]=2,选择它。路径变成[1,2],标记used[1]=true,调用进入第三层。
  • 第三层循环里i=0和i=1都被跳过,i=2时nums[2]=3可用。路径变成[1,2,3],标记used[2]=true,调用进入第四层。
  • 第四层一进来就发现track.size() == nums.length,也就是3 == 3,于是拷贝[1,2,3]加入结果集,直接return。

注意这个return不代表整个程序结束,它只结束第四层的函数调用,接着执行权回到第三层调用backtrack之后的代码。第三层随即执行track.removeLast(),把 3 移出去,used[2]改回 false。此时路径回到[1,2],第三层的循环也走完了,函数自然结束,回到第二层。

3.2 叶子节点收获结果,撤销选择再走新路

接着刚才的状态,第二层backtrack调用结束后,回到第二层循环里i=1的那次迭代的撤销动作:track.removeLast()把 2 移出去,used[1]改回 false。路径又变回[1]。第二层循环继续,i=2,nums[2]=3还没用过,选择 3。路径变成[1,3],标记后进入第三层。

第三层里i=0已用过,i=1的 2 可用,选择 2,路径变成[1,3,2],在第四层收获第二个结果。同样地,第三层撤销 2,第二层撤销 3,路径回到[1],第二层循环结束,回到第一层i=0的撤销动作,路径变成空[],used[0]改回 false。

到这里,第一个数字是 1 的两条路径全部枚举完毕。第一层循环接着进入i=1,即第一个数字放 2,然后继续枚举[2,1,3]和[2,3,1]。后面同理再走i=2,得到[3,1,2]和[3,2,1]。整个流程就是不断重复“选择、深入、收获、撤销、换路”的节奏。

看到这里你应该能感觉到:递归深入是在往叶子走,撤销是在往回攀爬,循环是在一个分岔口轮换不同选择。三者组成一个完整的环路。只要撤销写对了,每条路径都能完整走完;只要used判断写对了,就不会选中已经用过的数字;两个条件都满足,排列数量必然是3! = 6个,一个不多一个不少。

3.3 拿 DFS 的直觉理解回溯的“进入—返回”

如果你学过二叉树的前序遍历,你会发现全排列的递归轨迹和它是同一个味道。二叉树 DFS 进入左子树再返回根节点再进入右子树,就是“进入—返回—再进入”的节奏。回溯算法也是这个节奏,只不过树的形状不是给定的,而是由“选择列表”动态生成的。

区别在于二叉树里每个节点只有一个确定的值,而回溯里每个节点的值取决于你在路径上做过的选择。比如第一层选了 1,第二层可选的只有 2 和 3;第一层选了 2,第二层可选的变成 1 和 3。因此同一层节点在不同分支下的选择列表不同,这正是used数组存在的意义。

我建议你手动画一棵三层的树,每层标上“当前路径”和“剩余可选数字”。画完你会看到,树的路径数量和排列数量完全对得上。很多资料喜欢把回溯的过程叫做“在解空间树上 DFS”,这句话你刷完这道题再回看,会突然觉得特别直白。解空间树就是由所有可能状态组成的树,DFS 负责一条条路径去遍历,回溯负责遍历完一条后回到上一个岔路口。

4. 新手最容易踩的四个坑:引用拷贝、撤销漏写与复杂度误判

4.1res.add(new ArrayList<>(track))的引用陷阱

这个坑我见过太多人踩了,包括我自己第一次写的时候也踩过。最经典的翻车现场是:运行完代码,res不是六个排列,而是六个[],或者全是同一个排列。原因就是res.add(track)只把track的引用放进了结果集,之后track每次做removeLast,已经加入结果集的“排列”也会跟着变。最后一次track被清空,所有结果自然就都变成了空列表。

解决办法只有一个:加入结果集时做一次拷贝。Java 里用new ArrayList<>(track),Python 里用track[:],C++ 里直接res.push_back(track)本身就会拷贝一份,因为按值传递会自动复制。这个动作不是可选项,而是必选项。我有时看到有人偷懒省略拷贝,然后代码死活不对,排查半天最后发现是引用问题,千万别犯同样的错误。

另外说一句,选择用什么容器装track也有讲究。Java 里用ArrayDeque当栈用很方便,addLast和removeLast成对出现;用LinkedList也行。关键是增删操作的对称性,不要一边add一边removeFirst,那会让路径顺序彻底乱掉,结果全部错误。保持同一个端进出,路径顺序才能始终对应递归层级。

4.2used数组的回退时机:早一行晚一行都是 bug

used[i] = true的位置很好找,紧跟track.addLast之后。但used[i] = false的位置,新手经常写错。它必须在递归返回之后、同一轮循环继续之前执行,也就是紧跟在backtrack(...)调用之后。我见过有人把它写在循环外面,也有人把它写在continue分支里,结果都是一样:used数组状态错乱,要么漏分支,要么死循环。

一个稳妥的检查方法是在心里默念,每一次“选择”都必须对应一次“撤销”,二者在代码里应该挨在一起。像这样:

track.addLast(nums[i]); used[i] = true; backtrack(nums, track, used, res); track.removeLast(); used[i] = false;

这四行是一个不可拆分的整体。你甚至可以把它当成一个固定代码块来记忆。等刷到后面的组合问题时,你会发现撤销动作可能变成“从sum里减掉当前数”等变体,但原则不变:进入递归前做了什么修改,递归出来后就要把修改还原。

4.3 复杂度算明白:为什么是 O(N! * N)

很多题解直接写“时间复杂度 O(N! * N)”,但没说这个N从哪来的。我来推导一遍。对于长度为N的输入数组,第一层循环有N个选择,第二层每个分支有N-1个选择,第三层N-2个,以此类推。所以叶子节点总数是N*(N-1)*(N-2)*...*1,也就是N!个排列。

每个叶子节点生成时,都要把track拷贝一份加入结果集。track的长度是N,所以每次拷贝是O(N)的代价。于是时间复杂度的来源就是两部分:遍历整棵决策树需要O(N!)级别次数,而每次在叶子节点拷贝路径又贡献了O(N)。两者相乘,得到O(N * N!)。如果输入的N是 6,结果不算大,但 N 长到 10,运算量就非常恐怖了,这也是为什么全排列题目的数据规模都很小。

空间复杂度方面,输出结果占用的空间是O(N! * N),因为要存下所有排列。但如果不算输出,只看递归调用栈和used数组,额外空间是O(N)。有些面试官会问“优化一下,不用额外数组可以吗”,答案是可以用交换法来做,以后刷到第47题变体时会遇到。不过那属于进阶优化,现阶段先把模板写对更重要。

4.4 自测清单:结果数量对不对的三条标准

写完之后怎么判断代码对不对?最简单的方法是数结果数量。输入长度为N且无重复数字时,结果必须是N!个。我用过的自测方法有三个:第一,跑[1,2],应该得到两个排列;第二,跑[1,2,3],应该是六个;第三,跑[1,2,3,4],应该是二十四个。数量不对,说明used标记或撤销出了问题,立刻检查。

如果数量对但内容有重复,那大概率是输入数组本身有重复元素。第46题明确说数组不含重复数字,所以不需要去重。万一你把第47题的输入拿过来跑,自然会出现很多一模一样的排列。这不是模板错了,是题目条件不同。遇到这种情况不要慌,去查第47题的解法,核心是“排序 + 同层剪枝”,我下面会专门讲。

还有一个隐蔽问题:结果顺序不对。LeetCode 只要求返回所有排列,不要求字典序,所以顺序无所谓。但如果你自己测试时希望输出整齐,可以给nums先排个序。注意排序只影响输出顺序,不影响算法正确性。我自己调试时喜欢排好序,因为一眼能看出六种排列是否齐全。

5. 从46到47:重复元素去重和变体格教科书的进阶

5.1 排序加同层剪枝解决全排列II

第47题是第46题的加强版:输入数组可能包含重复数字,要求返回不重复的全排列。比如输入[1,1,2],合法结果只有[1,1,2]、[1,2,1]、[2,1,1]三种。如果直接套第46题的模板,会得到六个结果,其中每个排列都出现两遍,因为两个 1 被认为是不同的元素。

解决办法是在第46题模板上加一条剪枝逻辑。先把nums排序,让相同的数字挨在一起,然后在循环里这样判断:

if (used[i]) continue; if (i > 0 && nums[i] == nums[i - 1] && !used[i - 1]) continue;

这条剪枝的意思是:当前数字和前一个数字相等,而且前一个数字在当前这层还没被使用,那就跳过。它保证相同数字在“同一层”只会被选择一次。很多题解管这个叫“同层去重”,用来区分“树枝去重”。理解它的关键是:used[i-1]为 false 说明前面那个相同的数不是上一步选择的,而是在这个层面已经撤销了,所以当前再选就会产生重复排列。

我建议你先把第46题的代码改成第47题,跑一遍对比。改完你会发现,去掉重复排列的关键不在撤销,而在进入递归前的“跳过”。去重逻辑和回溯模板是两个独立维度,模板负责枚举所有可能,剪枝负责砍掉重复分支,组合起来就是完整解法。

5.2 全排列与组合、子集:一个模板的三种分身

刷完第46题再看组合总和、子集这些题,你会觉得套路非常亲切。区别主要在于循环的起始位置。全排列每一层循环都是从0开始,因为每个位置都可以选任何没被用过的数;组合问题则引入startIndex,让下一层循环从i+1开始,这样保证[1,2]和[2,1]不会同时出现,天然避免排列意义上的重复。

子集问题更简单,它连“排列”概念都没有,只要在递归一开始把当前路径加入结果即可。你看,全排列教会的递归结构与撤销动作,几乎是所有回溯题的通用底座。我刷到后面甚至觉得,回溯题就是“在模板上改三处”:改结束条件、改选择范围、改去重逻辑。每道题都是这三处里的某一处做了变化。

因此我的建议是:搞完第46题,马上做第78题子集、第77题组合、第47题全排列II。四道题并排刷,对比它们循环起点、结束条件和去重逻辑的差异。这个“对照阅读”的过程,比单独刷二十道同类型题都更有效。你会在对照中真正理解startIndex和used各自解决什么问题。

5.3 回溯算法的真正战场:N皇后、括号生成等典型场景

全排列只是回溯算法的皮毛。回溯真正威力在“约束满足问题”上:N 皇后、数独求解、括号生成、复原 IP 地址、单词搜索,全是回溯的应用场景。它们的公共骨架一模一样,区别在于每层的选择列表、合法性检查、结束条件各不相同。N 皇后就是在每一行选择一个列位置,并用三个数组判断斜线冲突;括号生成就是在每个位置决定放左括号还是右括号,同时维护左右括号数量。

为什么我仍然坚持先把全排列刷透?因为全排列是这些高级题里“选择列表”最简单的。你不需要检查对角线冲突,不需要维护左右括号计数,只需要一个used数组。先把最纯净的回溯流程走顺,再往里面加各种约束条件,心智负担会小很多。如果一上来就啃 N 皇后,很容易把“回溯框架”和“约束检查”混在一起,最后哪部分都没学好。

等你回刷完第47题和组合、子集,再来看 N 皇后,你会发现它的核心就是四个字:合法判断。DFS 回溯的部分一模一样。这也是为什么很多算法培训班把“全排列”作为回溯专题第一题,它是整个专题的锚点。一旦锚点立住了,后面的题目就都是添砖加瓦。

6. 调试日志、面试表现与回刷技巧

6.1 用缩进日志可视化递归过程

写递归最怕脑子转不过来。我的土办法是加打印日志,用缩进表示递归深度。每次进入函数时打印当前路径和used状态,每次退出时也打印。看到缩进一点点变深再变浅,你就知道撤销动作正在正确执行。下面这段代码可以直接贴到你的本地环境里跑跑看:

def permute_trace(nums): res = [] track = [] used = [False] * len(nums) def dfs(depth): print(" " * depth + f"进入: track={track}, used={used}") if len(track) == len(nums): res.append(track[:]) print(" " * depth + "收获一个结果") return for i in range(len(nums)): if used[i]: continue track.append(nums[i]) used[i] = True dfs(depth + 1) track.pop() used[i] = False print(" " * depth + f"撤销后: track={track}") dfs(0) return res

跑一下[1,2],你会看到日志层级非常清晰。进入一层层往下加深,撤销后一层层回到浅处。当你调试第47题时,还可以在日志里把i和nums[i]打出来,观察剪枝跳过了哪些分支。这种“可视递归”的方式比反复print(res)高效得多。

6.2 面试现场怎么写才稳:先讲思路再动代码

面试遇到全排列,别上来就闷头写。先跟面试官讲三句话:第一,这是一个回溯问题,本质是对决策树做 DFS;第二,我要维护一个当前路径和一个used数组;第三,路径长度等于数组长度时收结果,否则遍历候选数字、递归、撤销。这三句话说完,代码已经在你脑子里成型了。

写代码时注意节奏,先写backtrack的整体框架,再补used判断。写完循环体立刻核对四行核心代码:加入路径、标记 used、递归、撤销。不要先写res的拷贝再回头补撤销,顺序容易乱。最后跑一个[1,2,3]的例子,数一数结果是不是六个。

如果面试官追问“复杂度是多少”,别只说O(N!)。你把O(N! * N)的推导过程讲出来,说明拷贝路径的代价,再补一句空间复杂度O(N)递归栈。这个深度足够让面试官觉得你是真懂,而不是背了模板。如果追问“能不能不用 used 数组”,可以提交换法:每次固定一个位置然后交换元素,少一个数组但顺序会变,属于进阶解法。

6.3 回刷计划:什么才算真正学会这道题

我第一次刷第46题时,跟着题解写完,感觉全懂了。第二天默写模板,写到used[i] = false时突然卡住,这才知道自己只是眼睛懂了,手还没懂。后来我总结出一个回刷标准:隔一天,不看题解,独立把第46题写出来,同时讲清楚为什么res.add要拷贝、为什么used要回退、为什么时间复杂度是O(N! * N)。三条都能做到,才算真正会。

回刷时我建议做三件事。第一,把第46题改成第47题,不提前看题解,尝试自己加剪枝。第二,试着用 Python 和 Java 各写一遍,语言不通时你会被迫理解框架而不是背语法。第三,合上代码,在纸上画出[1,2,3]的决策树,标出路径、选择和回溯拐点。这三点做完,你的回溯基本功基本就扎实了。

如果后面刷 LeetCode 周赛碰到看起来像回溯的题,比如某个排列计数问题,你会发现底气完全不一样。不是因为你背下来了,而是因为你知道回溯的代码骨架长什么样,也知道如何套用。刷题最怕“这题我会,换个壳就不会”,而“理解原理 + 回刷默写”正好治这个毛病。

6.4 我踩过的坑与最后的个人体会

最后分享一点我个人刷题过程中的真实感受。最让我意外的坑是:第一次写完代码,结果集里全是空列表。当时以为是逻辑问题,排查了半小时,最后发现就是忘了拷贝track的引用。那之后我养成了一个习惯:凡是往结果集里加可变容器,先想它会不会在后续被修改,会就立刻拷贝。这个习惯后来帮我避开了很多别的坑,不只是回溯题。

还有一个体会是,回溯算法真正难的不是代码,而是“相信递归能自己把问题解决”。很多人总想手动追踪每一层循环,结果脑子直接过载。正确姿势是只关注“当前层做什么、给下一层留下什么状态”,剩下的交给函数调用本身。写的时候心要大一点,检查的时候再细一点,这种矛盾恰恰是回溯题的魅力。

如果你刚开始接触这道题,别急着追求最优解。先把标准模板写熟,把撤销动作练成肌肉记忆,再慢慢往里面加各种约束。全排列是回溯算法的起点,但也是最能帮你建立信心的一个点。把这个点打穿,后面组合、子集、棋盘类题目都会顺利得多。我的建议就一句话:别背题,去理解那份“选择—深入—撤销—换路”的节奏感。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询