【由于算法小抄大部分代码都是java,而作者其实是学c++的,因此,本笔记在记录的同时,对作者的java代码进行了转换,希望也能够帮到大家】在刷完力扣的数据结构后,无论是对题目还是对自己之前看书学的数据结构都有了一定的认识。但是在学计网的时间内,充分发现题不刷就会生疏的道理。因此我打算先通过算法小抄快速过渡到刷题期,随后保证连续刷题的同时展开操作系统的学习,最后再系统学习数据结构来过渡至找实习/工作。这是第0章前半部分(因为怕文章太长自己都不好找,正好前半部分讲的框架,后半部分讲的是技巧)。
数据结构的存储方式只有两种:数组(顺序存储)和链表(链式存储)。
两者优缺点如下:
数组由于是紧凑连续存储,可以随机访问,通过索引快速找到对应元素,而且相对节约存储空间。但正因为连续存储,内存空间必须一次性分配够,所以说数组如果要扩容,需要重新分配一块更大的空间,再把数据全部复制过去,时间复杂度 O(N);而且你如果想在数组中间进行插入和删除,每次必须搬移后面的所有数据以保持连续,时间复杂度 O(N)。
链表因为元素不连续,而是靠指针指向下一个元素的位置,所以不存在数组的扩容问题;如果知道某一元素的前驱和后驱,操作指针即可删除该元素或者插入新元素,时间复杂度 O(1)。但是正因为存储空间不连续,你无法根据一个索引算出对应元素的地址,所以不能随机访问;而且由于每个元素必须存储指向前后元素位置的指针,会消耗相对更多的储存空间。
数据结构种类很多,但它们存在的目的都是在不同的应用场景,尽可能高效地增删查改。
而各种数据结构的遍历 + 访问无非两种形式:线性的和非线性的。线性就是 for/while 迭代为代表,非线性就是递归为代表。其具体框架如下:
数组遍历框架,典型的线性迭代结构(for循环)
链表遍历框架,兼具迭代和递归结构
二叉树遍历框架,典型的非线性递归遍历结构
而二叉树框架可以扩展为 N 叉树的遍历框架,N 叉树的遍历又可以扩展为图的遍历,因为图就是好几 N 叉棵树的结合体。你说图是可能出现环的?这个很好办,用个布尔数组 visited 做标记就行了。
根据具体问题在框架上添加代码就行了
1、先学习像数组、链表这种基本数据结构的常用算法
2、学会基础算法之后,不要急着上来就刷回溯算法、动态规划这类笔试常考题,而应该先刷二叉树,先刷二叉树,先刷二叉树。因为二叉树是最容易培养框架思维的,而且大部分算法技巧,本质上都是树的遍历问题。
最多了就会发现,任何题只要涉及递归的问题,都是树的问题
3、再去看回溯、动规、分治等算法专题,对思路的理解就会更加深刻。
如果要让我一句话总结,我想说算法的本质就是「穷举」。
但是,你千万不要觉得穷举这个事儿很简单,穷举有两个关键难点:无遗漏(如何穷举)、无冗余(如何聪明地穷举)。
什么算法的难点在「如何穷举」呢?一般是递归类问题,最典型的就是动态规划系列问题。
什么算法的难点在「如何聪明地穷举」呢?一些耳熟能详的非递归算法技巧,都可以归在这一类。
单链表常考的技巧就是双指针。数组常用的技巧有很大一部分还是双指针相关的技巧,说白了是教你如何聪明地进行穷举。
首先说二分搜索技巧,可以归为两端向中心的双指针。
再说说 滑动窗口算法技巧,典型的快慢双指针,快慢指针中间就是滑动的「窗口」,主要用于解决子串问题。但是,就好像二分搜索只能运用在有序数组上一样,滑动窗口也是有其限制的,就是你必须明确的知道什么时候应该扩大窗口,什么时候该收缩窗口。
还有回文串相关技巧,如果判断一个串是否是回文串,使用双指针从两端向中心检查,如果寻找回文子串,就从中心向两端扩散。
最后说说前缀和技巧 和 差分数组技巧。如果频繁地让你计算子数组的和,每次用 for 循环去遍历肯定没问题,但前缀和技巧预计算一个 preSum 数组,就可以避免循环。类似的,如果频繁地让你对子数组进行增减操作,也可以每次用 for 循环去操作,但差分数组技巧维护一个 diff 数组,也可以避免循环。
二叉树题目的递归解法可以分两类思路,第一类是遍历一遍二叉树得出答案,第二类是通过分解问题计算出答案,这两类思路分别对应着 回溯算法核心框架 和 动态规划核心框架
更进一步,图论相关的算法也是二叉树算法的延续。
二叉树解题的思维模式分两类:
1、是否可以通过遍历一遍二叉树得到答案?如果可以,用一个 traverse 函数配合外部变量来实现,这叫「遍历」的思维模式。
2、是否可以定义一个递归函数,通过子问题(子树)的答案推导出原问题的答案?如果可以,写出这个递归函数的定义,并充分利用这个函数的返回值,这叫「分解问题」的思维模式。
无论使用哪种思维模式,你都需要思考:
如果单独抽出一个二叉树节点,它需要做什么事情?需要在什么时候(前/中/后序位置)做?其他的节点不用你操心,递归函数会帮你在所有节点上执行相同的操作。
如果你告诉我,快速排序就是个二叉树的前序遍历,归并排序就是个二叉树的后序遍历,那么我就知道你是个算法高手了。
快速排序的逻辑是,若要对 nums[lo..hi]
进行排序,我们先找一个分界点 p,通过交换元素使得 nums[lo..p-1]
都小于等于 nums[p]
,且 nums[p+1..hi]
都大于 nums[p]
,然后递归地去 nums[lo..p-1]
和 nums[p+1..hi]
中寻找新的分界点,最后整个数组就被排序了。
void sort(vector<int> nums, int lo, int hi) {
/****** 前序遍历位置 ******/
// 通过交换元素构建分界点 p
int p = partition(nums, lo, hi);
/************************/
sort(nums, lo, p - 1);
sort(nums, p + 1, hi);
}
再说说归并排序的逻辑,若要对 nums[lo…hi] 进行排序,我们先对 nums[lo…mid] 排序,再对 nums[mid+1…hi] 排序,最后把这两个有序的子数组合并,整个数组就排好序了。
// 定义:排序 nums[lo..hi]
void sort(vector<int> nums, int lo, int hi) {
int mid = (lo + hi) / 2;
// 排序 nums[lo..mid]
sort(nums, lo, mid);
// 排序 nums[mid+1..hi]
sort(nums, mid + 1, hi);
/****** 后序位置 ******/
// 合并 nums[lo..mid] 和 nums[mid+1..hi]
merge(nums, lo, mid, hi);
/*********************/
}
二叉树这种结构无非就是二叉链表,所谓前序位置,就是刚进入一个节点(元素)的时候,后序位置就是即将离开一个节点(元素)的时候。
因此倒序打印一条单链表上所有节点的值可以用后序位置完成。
前中后序是遍历二叉树过程中处理每一个节点的三个特殊时间点,绝不仅仅是三个顺序不同的 List
其中,中序位置的代码在一个二叉树节点左子树都遍历完,即将开始遍历右子树的时候执行。
二叉树的所有问题,就是让你在前中后序位置注入巧妙的代码逻辑,去达到自己的目的,你只需要单独思考每一个节点应该做什么,其他的不用你管,抛给二叉树遍历框架,递归会在所有节点上做相同的操作。
二叉树题目的递归解法可以分两类思路,第一类是遍历一遍二叉树得出答案,第二类是通过分解问题计算出答案,这两类思路分别对应着 回溯算法核心框架 和 动态规划核心框架(前面提到过)。以求二叉树的最大深度为例子,
1、遍历一遍二叉树,用一个外部变量记录每个节点所在的深度,取最大值就可以得到最大深度,这就是遍历二叉树计算答案的思路。–常需要构建辅助函数。
2、一棵二叉树的最大深度可以通过子树的最大高度推导出来,这就是分解问题计算答案的思路。–常用单纯题目所给的递归。
1、是否可以通过遍历一遍二叉树得到答案?如果可以,用一个辅助函数配合外部变量来实现。
2、是否可以定义一个递归函数,通过子问题(子树)的答案推导出原问题的答案?如果可以,写出这个递归函数的定义,并充分利用这个函数的返回值。
3、无论使用哪一种思维模式,你都要明白二叉树的每一个节点需要做什么,需要在什么时候(前中后序)做。
4、遇到子树问题,首先想到的是给函数设置返回值,然后在后序位置做文章。(会在标题4处进行解释)
前序位置是刚刚进入节点的时刻,后序位置是即将离开节点的时刻。但这里面大有玄妙,意味着前序位置的代码只能从函数参数中获取父节点传递来的数据,而后序位置的代码不仅可以获取参数数据,还可以获取到子树通过函数返回值传递回来的数据。
现在给你一棵二叉树,我问你两个简单的问题:
1、如果把根节点看做第 1 层,如何打印出每一个节点所在的层数?
前序遍历即可
2、如何打印出每个节点的左右子树各有多少节点?
后序遍历+cout-------return左右子树的和+1
因此,一旦你发现题目和子树有关,那大概率要给函数设置合理的定义和返回值,在后序位置写代码了。
【针对上文总结】**遇到子树问题,首先想到的是给函数设置返回值,然后在后序位置做文章。**反过来,如果你写出了类似一开始的那种递归套递归的解法,大概率也需要反思是不是可以通过后序遍历优化了。
vector<vector<int>> levelOrder(TreeNode* root) {
vector <vector <int>> ret;
if (!root) {
return ret;
}
queue <TreeNode*> q;
q.push(root);
// 从上到下遍历二叉树的每一层
while (!q.empty()) {
int currentLevelSize = q.size();
// 从左到右遍历每一层的每个节点
for (int i = 1; i <= currentLevelSize; ++i) {
auto node = q.front();
q.pop_front();
// 将下一层节点放入队列
if (node->left) q.push(node->left);
if (node->right) q.push(node->right);
}
}
return ret;
}
};
前文 BFS 算法框架 就是从二叉树的层序遍历扩展出来的,常用于求无权图的最短路径问题。
动态规划问题的一般形式就是求最值。动态规划其实是运筹学的一种最优化方法,只不过在计算机问题上应用比较多,比如说让你求最长递增子序列呀,最小编辑距离呀等等。
求解动态规划的核心问题是穷举。
动态规划三要素:重叠子问题、最优子结构、状态转移方程
其中,写出状态转移方程是最困难的,我总结的一个思维框架,辅助你思考状态转移方程:
明确 base case -> 明确「状态」-> 明确「选择」 -> 定义 dp 数组/函数的含义。
找个三个玩意:状态是啥?选择是啥?(列出状态转移方程)base case是啥?
# 自顶向下递归的动态规划
def dp(状态1, 状态2, ...):
for 选择 in 所有可能的选择:
# 此时的状态已经因为做了选择而改变
result = 求最值(result, dp(状态1, 状态2, ...))
return result
# 自底向上迭代的动态规划
# 初始化 base case
dp[0][0][...] = base case
# 进行状态转移
for 状态1 in 状态1的所有取值:
for 状态2 in 状态2的所有取值:
for ...
dp[状态1][状态2][...] = 求最值(选择1,选择2...)
当时用简单递归来实现时,
int fib(int N) {
if (N == 1 || N == 2) return 1;
return fib(N - 1) + fib(N - 2);
}
等效于下图
观察递归树,很明显发现了算法低效的原因:存在大量重复计算,比如 f(18) 被计算了两次,而且你可以看到,以 f(18) 为根的这个递归树体量巨大,多算一遍,会耗费巨大的时间。更何况,还不止 f(18) 这一个节点被重复计算,所以这个算法及其低效。
其中还涉及到一个知识点:递归算法的时间复杂度怎么计算?
就是用子问题个数乘以解决一个子问题需要的时间。
这就是动态规划问题的第一个性质:重叠子问题。
每次算出某个子问题的答案后别急着返回,先记到「备忘录」里再返回;每次遇到一个子问题先去「备忘录」里查一查,如果发现之前已经解决过这个问题了,直接把答案拿出来用,不要再耗时去计算了。可以使用哈希表来当备忘录!
带「备忘录」的递归算法,把一棵存在巨量冗余的递归树通过「剪枝」,改造成了一幅不存在冗余的递归图,极大减少了子问题(即递归图中节点)的个数。
实际上,这种解法和常见的动态规划解法已经差不多了,只不过这种解法是「自顶向下」进行「递归」求解,我们更常见的动态规划代码是「自底向上」进行「递推」求解。
有了上一步「备忘录」的启发,我们可以把这个「备忘录」独立出来成为一张表,通常叫做 DP table,在这张表上完成「自底向上」的推算岂不美哉!
int fib(int n) {
if (n == 0) return 0;
vector<int>dp(n+1,0);
// base case
dp[0] = 0; dp[1] = 1;
// 状态转移
for (int i = 2; i <= n; ++i) {
dp[i] = dp[i - 1] + dp[i - 2];
}
return dp[n];
}
画个图就很好理解了,而且你发现这个 DP table 特别像之前那个「剪枝」后的结果,只是反过来算而已。实际上,带备忘录的递归解法中的「备忘录」,最终完成后就是这个 DP table,所以说这两种解法其实是差不多的,大部分情况下,效率也基本相同。
而状态转移方程,也就是为了听起来高端。如本题:
f(n) 的函数参数会不断变化,所以你把参数 n 想做一个状态,这个状态 n 是由状态 n - 1 和状态 n - 2 转移(相加)而来,这就叫状态转移,仅此而已。其实状态转移方程直接代表着暴力解法。
这个例子的最后,讲一个细节优化。根据斐波那契数列的状态转移方程,当前状态只和之前的两个状态有关,其实并不需要那么长的一个 DP table 来存储所有的状态,只要想办法存储之前的两个状态就行了。
int fib(int n) {
if (n == 0 || n == 1) {
// base case
return n;
}
// 分别代表 dp[i - 1] 和 dp[i - 2]
int dp_i_1 = 1, dp_i_2 = 0;
for (int i = 2; i <= n; ++i) {
// dp[i] = dp[i - 1] + dp[i - 2];
int dp_i = dp_i_1 + dp_i_2;
// 滚动更新
dp_i_2 = dp_i_1;
dp_i_1 = dp_i;
}
return dp_i_1;
}
这一般是动态规划问题的最后一步优化,如果我们发现每次状态转移只需要 DP table 中的一部分,那么可以尝试缩小 DP table 的大小,只记录必要的数据,从而降低空间复杂度。
由于本题严格来讲不算DP问题(因为不涉及求最值),所以没有涉及最优子结构,下面将会涉及!!
【题目】给你 k 种面值的硬币,面值分别为 c1, c2 … ck,每种硬币的数量无限,再给一个总金额 amount,问你最少需要几枚硬币凑出这个金额,如果不可能凑出,算法返回 -1 。算法的函数签名如下:
// coins 中是可选硬币面值,amount 是目标金额
int coinChange(vector<int> coins, int amount);
首先,这个问题是动态规划问题,因为它具有「最优子结构」的。要符合「最优子结构」,子问题间必须互相独立。
那么,既然知道了这是个动态规划问题,就要思考如何列出正确的状态转移方程?
1、确定 base case,这个很简单,显然目标金额 amount 为 0 时算法返回 0,因为不需要任何硬币就已经凑出目标金额了。
2、确定「状态」,也就是原问题和子问题中会变化的变量。由于硬币数量无限,硬币的面额也是题目给定的,只有目标金额会不断地向 base case 靠近,所以唯一的「状态」就是目标金额 amount。
3、确定「选择」,也就是导致「状态」产生变化的行为。目标金额为什么变化呢,因为你在选择硬币,你每选择一枚硬币,就相当于减少了目标金额。所以说所有硬币的面值,就是你的「选择」。
4、明确 dp 函数/数组的定义。我们这里讲的是自顶向下的解法,所以会有一个递归的 dp 函数,一般来说函数的参数就是状态转移中会变化的量,也就是上面说到的「状态」;函数的返回值就是题目要求我们计算的量。就本题来说,状态只有一个,即「目标金额」,题目要求我们计算凑出目标金额所需的最少硬币数量。
所以我们可以这样定义 dp 函数:dp(n) 表示,输入一个目标金额 n,返回凑出目标金额 n 所需的最少硬币数量。
搞清楚上面这几个关键点,解法的伪码就可以写出来了:
// 伪码框架
int coinChange(vector<int>coins, int amount) {
// 题目要求的最终结果是 dp(amount)
return dp(coins, amount)
}
// 定义:要凑出金额 n,至少要 dp(coins, n) 个硬币
int dp(vector<int> coins, int n) {
// 做选择,选择需要硬币最少的那个结果
for (int coin : coins) {
res = min(res, 1 + dp(n - coin))
}
return res
}
根据伪码便可以得到真正的代码
int coinChange(vector<int> coins, int amount) {
// 题目要求的最终结果是 dp(amount)
return dp(coins, amount)
}
// 定义:要凑出金额 n,至少要 dp(coins, n) 个硬币
int dp(vector<int> coins, int amount) {
// base case
if (amount == 0) return 0;
if (amount < 0) return -1;
int res = INT_MAX;
for (auto coin : coins) {
// 计算子问题的结果
int subProblem = dp(coins, amount - coin);
// 子问题无解则跳过
if (subProblem == -1) continue;
// 在子问题中选择最优解,然后加一
res = min(res, subProblem + 1);
}
return res == INT_MAX ? -1 : res;
}
类似之前斐波那契数列的例子,只需要稍加修改,就可以通过备忘录消除子问题:
class Solution {
public:
int coinChange(vector<int>& coins, int amount) {
unordered_map<int,int>record;
// 题目要求的最终结果是 dp(amount)
return find(coins,amount,record);
}
// 定义:要凑出金额 n,至少要 dp(coins, n) 个硬币
int find(vector<int>& coins, int num, unordered_map<int,int>& record) {
if(num == 0) return 0;
if(num < 0) return -1;
// 查备忘录,防止重复计算----添加
if(record.count(num)) return record[num];
int ans = INT_MAX;
for(auto coin:coins)
{
// 计算子问题的结果
int sub_solv = find(coins,num-coin,record);
// 子问题无解则跳过
if(sub_solv == -1) continue;
// 在子问题中选择最优解,然后加一
ans = min(ans,sub_solv+1);
}
if(ans == INT_MAX) ans = -1;
record[num] = ans;
return ans;
}
};
我们也可以自底向上使用 dp table 来消除重叠子问题,关于「状态」「选择」和 base case 与之前没有区别,dp 数组的定义和刚才 dp 函数类似,也是把「状态」,也就是目标金额作为变量。不过 dp 函数体现在函数参数,而 dp 数组体现在数组索引:
dp 数组的定义:当目标金额为 i 时,至少需要 dp[i] 枚硬币凑出。
根据我们文章开头给出的动态规划代码框架可以写出如下解法:
int coinChange(int[] coins, int amount) {
vector<int>dp(n+1,n+1);
// 数组大小为 amount + 1,初始值也为 amount + 1
// base case
dp[0] = 0;
// 外层 for 循环在遍历所有状态的所有取值
for (int i = 0; i < dp.size(); ++i)
{
// 内层 for 循环在求所有选择的最小值
for (int coin : coins)
{
// 子问题无解,跳过
if (i - coin < 0)
{
continue;
}
dp[i] = min(dp[i], 1 + dp[i - coin]);
}
}
return (dp[amount] == amount + 1) ? -1 : dp[amount];
}
下面是图解
列出状态转移方程,就是在解决“如何穷举”的问题。之所以说它难,一是因为很多穷举需要递归实现,二是因为有的问题本身的解空间复杂,不那么容易穷举完整。
备忘录、DP table 就是在追求“如何聪明地穷举”。用空间换时间的思路,是降低时间复杂度的不二法门,除此之外,试问,还能玩出啥花活?
本文解决几个问题:
回溯算法是什么?解决回溯算法相关的问题有什么技巧?如何学习回溯算法?回溯算法代码是否有规律可循?
解决一个回溯问题,实际上就是一个决策树的遍历过程。
站在回溯树的一个节点上,你只需要思考 3 个问题:
1、路径:也就是已经做出的选择。
2、选择列表:也就是你当前可以做的选择。
3、结束条件:也就是到达决策树底层,无法再做选择的条件。
找三个玩意:选择列表是啥?路径是啥?结束条件是啥?
vectr<int>result;
def backtrack(路径, 选择列表):
if 满足结束条件:
result.push_back(路径)
return
for 选择 in 选择列表:
//做选择
将该选择从选择列表移除
路径.puah_back(选择)
//进入递归
backtrack(路径, 选择列表)
//撤销选择
路径.pop_back(选择)
将该选择再加入选择列表
其核心就是 for 循环里面的递归,在递归调用之前「做选择」,在递归调用之后「撤销选择」,特别简单。
写 backtrack 函数时,需要维护走过的「路径」和当前可以做的「选择列表」,当触发「结束条件」时,将「路径」记入结果集。
什么叫做选择和撤销选择呢,这个框架的底层原理是什么呢?下面我们就通过「全排列」这个问题来解开之前的疑惑
力扣第 46 题「 全排列」就是给你输入一个数组 nums,让你返回这些数字的全排列。
PS:我们这次讨论的全排列问题不包含重复的数字,包含重复数字的扩展场景我在后文 回溯算法秒杀排列组合子集的九种题型 中讲解。
假设1,2,3做排列:我们先画出决策树
只要从根遍历这棵树,记录路径上的数字,其实就是所有的全排列。我们不妨把这棵树称为回溯算法的「决策树」。
为啥说这是决策树呢,因为你在每个节点上其实都在做决策。比如说你站在下图的红色节点上,可以选择 1 那条树枝,也可以选择 3 那条树枝。
因此,[2] 就是「路径」,记录你已经做过的选择;[1,3] 就是「选择列表」,表示你当前可以做出的选择;「结束条件」就是遍历到树的底层叶子节点,这里也就是选择列表为空的时候。
我们定义的 backtrack 函数其实就像一个指针,在这棵树上游走,同时要正确维护每个节点的属性,每当走到树的底层,其「路径」就是一个全排列。
而想要维护,就要在两个特殊时间点搞点动作—而其正好对应的前序遍历和后序遍历
我们只要在递归之前做出选择,在递归之后撤销刚才的选择,就能正确得到每个节点的选择列表和路径。下面看全排列的代码
vector<vector<int>> res;
/* 主函数,输入一组不重复的数字,返回它们的全排列 */
vector<vector<int>> permute(vector<int> nums) {
// 记录「路径」
vector<int> track;
// 「路径」中的元素会被标记为 true,避免重复使用
int n = nums.size();
vector<bool> used(n);
backtrack(nums, track, used);
return res;
}
// 路径:记录在 track 中
// 选择列表:nums 中不存在于 track 的那些元素(used[i] 为 false)
// 结束条件:nums 中的元素全都在 track 中出现
void backtrack(vector<int> nums, vector<int> track, vector<bool> used) {
// 触发结束条件
if (track.size() == nums.size()) {
res.push_back(track);
return;
}
for (int i = 0; i < nums.size(); ++i) {
// 排除不合法的选择(例如不允许重复)
if (used[i]) {
// nums[i] 已经在 track 中,跳过
continue;
}
// 做选择
track.push_back(nums[i]);
used[i] = true;
// 进入下一层决策树
backtrack(nums, track, used);
// 取消选择
track.pop_back();
used[i] = false;
}
}
我们这里稍微做了些变通,没有显式记录「选择列表」,而是通过 used 数组排除已经存在 track 中的元素,从而推导出当前的选择列表:
【总结】回溯算法的一个特点,不像动态规划存在重叠子问题可以优化,回溯算法就是纯暴力穷举,复杂度一般都很高。
【题目】力扣第 51 题「 N 皇后」就是这个经典问题,简单解释一下:给你一个 N×N 的棋盘,让你放置 N 个皇后,使得它们不能互相攻击。
PS:皇后可以攻击同一行、同一列、左上左下右上右下四个方向的任意单位。
这个问题本质上跟全排列问题差不多,决策树的每一层表示棋盘上的每一行;每个节点可以做出的选择是,在该行的任意一列放置一个皇后。这个也可以直接套用回溯模板
vector<vector<string>> res;
/* 输入棋盘边长 n,返回所有合法的放置 */
vector<vector<string>> solveNQueens(int n) {
// '.' 表示空,'Q' 表示皇后,初始化空棋盘。
vector<string> board(n, string(n, '.'));
backtrack(board, 0);
return res;
}
// 路径:board 中小于 row 的那些行都已经成功放置了皇后
// 选择列表:第 row 行的所有列都是放置皇后的选择
// 结束条件:row 超过 board 的最后一行
void backtrack(vector<string>& board, int row) {
// 触发结束条件
if (row == board.size()) {
res.push_back(board);
return;
}
int n = board[row].size();
for (int col = 0; col < n; col++) {
// 排除不合法选择
if (!isValid(board, row, col)) {
continue;
}
// 做选择
board[row][col] = 'Q';
// 进入下一行决策
backtrack(board, row + 1);
// 撤销选择
board[row][col] = '.';
}
}
而其中的isVaild其实也就是跟筛选的函数,也很简单
/* 是否可以在 board[row][col] 放置皇后? */
bool isValid(vector<string>& board, int row, int col) {
int n = board.size();
// 检查列是否有皇后互相冲突
for (int i = 0; i <= row; i++) {
if (board[i][col] == 'Q')
return false;
}
// 检查右上方是否有皇后互相冲突
for (int i = row - 1, j = col + 1;
i >= 0 && j < n; i--, j++) {
if (board[i][j] == 'Q')
return false;
}
// 检查左上方是否有皇后互相冲突---因为皇后是一行一行从上往下放的,所以左下方,右下方和正下方不用检查(还没放皇后)
for (int i = row - 1, j = col - 1;
i >= 0 && j >= 0; i--, j--) {
if (board[i][j] == 'Q')
return false;
}
return true;
}
如果直接给你这么一大段解法代码,可能是懵逼的。但是现在明白了回溯算法的框架套路,还有啥难理解的呢?无非是改改做选择的方式,排除不合法选择的方式而已,只要框架存于心,你面对的只剩下小问题了。(确实!)
有的时候,我们并不想得到所有合法的答案,只想要一个答案,怎么办呢?比如解数独的算法,找所有解法复杂度太高,只要找到一种解法就可以。
(只需要小改一下,在递归处改成判断找到一个就返回就行,这样for 循环的后续递归穷举都会被阻断。)
// 函数找到一个答案后就返回 true
bool backtrack(vector<string>& board, int row) {
// 触发结束条件
if (row == board.size()) {
res.push_back(board);
return true;
}
...
for (int col = 0; col < n; col++) {
...
board[row][col] = 'Q';
if (backtrack(board, row + 1))
return true;
board[row][col] = '.';
}
return false;
}
BFS 的核心思想应该不难理解的,就是把一些问题抽象成图,从一个点开始,向四周开始扩散。一般来说,我们写 BFS 算法都是用**「队列」**这种数据结构,每次将一个节点周围的所有节点加入队列。
BFS 相对 DFS 的最主要的区别是:BFS 找到的路径一定是最短的,但代价就是空间复杂度可能比 DFS 大很多
【BFS 出现的常见场景】问题的本质就是让你在一幅「图」中找到从起点 start 到终点 target 的最近距离,这个例子听起来很枯燥,但是 BFS 算法问题其实都是在干这个事儿 。
这个广义的描述可以有各种变体,比如走迷宫,有的格子是围墙不能走,从起点到终点的最短距离是多少?如果这个迷宫带「传送门」可以瞬间传送呢?
再比如说两个单词,要求你通过某些替换,把其中一个变成另一个,每次只能替换一个字符,最少要替换几次?
再比如说连连看游戏,两个方块消除的条件不仅仅是图案相同,还得保证两个方块之间的最短连线不能多于两个拐点。你玩连连看,点击两个坐标,游戏是如何判断它俩的最短连线有几个拐点的?
再比如……
净整些花里胡哨的,这些问题都没啥奇技淫巧,本质上就是一幅「图」,让你从一个起点,走到终点,问最短路径。这就是 BFS 的本质,框架搞清楚了直接默写就好。
// 计算从起点 start 到终点 target 的最近距离
int BFS(Node start, Node target) {
deque<Node> q; // 核心数据结构
set<Node> visited; // 避免走回头路
q.push_back(start); // 将起点加入队列
visited.insert(start);
int step = 0; // 记录扩散的步数
while (!q.empty()) {
int sz = q.size();//别小看这一步,下面的时候q.size就会变了,因此必须提前记录下来!!
/* 将当前队列中的所有节点向四周扩散 */
for (int i = 0; i < sz; i++) {
Node cur = q.front();
q.pop_front();
/* 划重点:这里判断是否到达终点 */
if (cur is target)
return step;
/* 将 cur 的相邻节点加入队列 */
for (Node x : cur.adj()) {
if (!visited.count(x)) {
q.push_back(x);
visited.insert(x);
}
}
}
/* 划重点:更新步数在这里 */
step++;
}
}
队列 q 就不说了,BFS 的核心数据结构;cur.adj() 泛指 cur 相邻的节点,比如说二维数组中,cur 上下左右四面的位置就是相邻节点;visited 的主要作用是防止走回头路,大部分时候都是必须的,但是像一般的二叉树结构,没有子节点到父节点的指针,不会走回头路就不需要 visited。
怎么套到 BFS 的框架里呢?首先明确一下起点 start 和终点 target 是什么,怎么判断到达了终点?
显然起点就是 root 根节点,终点就是最靠近根节点的那个「叶子节点」嘛,叶子节点就是两个子节点都是 null 的节点:
if (cur->left == null && cur->right == null)
// 到达叶子节点
那么,按照我们上述的框架稍加改造来写解法即可:
int minDepth(TreeNode* root) {
if (root == nullptr) return 0;
deque<TreeNode*> q;
q.push_back(root);
// root 本身就是一层,depth 初始化为 1
int depth = 1;
while (!q.empty()) {
int sz = q.size();
/* 将当前队列中的所有节点向四周扩散 */
for (int i = 0; i < sz; ++i) {
TreeNode* cur = q.front();
q.pop_front();
/* 判断是否到达终点 */
if (cur->left == nullptr && cur->right == nullptr)
return depth;
/* 将 cur 的相邻节点加入队列 */
if (cur->left != null)
q.push_back(cur->left);
if (cur.right != null)
q.push_back(cur->right);
}
/* 这里增加步数 */
depth++;
}
return depth;
}
具体实现的啥操作、图像等都可以去三中的5去查看。下面针对两个问题进行回答:
1、为什么 BFS 可以找到最短距离,DFS 不行吗?
DFS 实际上是靠递归的堆栈记录走过的路径,你要找到最短路径,肯定得把二叉树中所有树杈都探索完才能对比出最短的路径有多长对不对?而 BFS 借助队列做到一次一步「齐头并进」,是可以在不遍历完整棵树的条件下找到最短距离的。形象点说,DFS 是线,BFS 是面;
2、既然 BFS 那么好,为啥 DFS 还要存在?
BFS 可以找到最短距离,但是空间复杂度高,而 DFS 的空间复杂度较低。
由此观之,BFS 还是有代价的,一般来说在找最短路径的时候使用 BFS,其他时候还是 DFS 使用得多一些(主要是递归代码好写)。
现在的难点就在于,不能出现 deadends,应该如何计算出最少的转动次数呢?
第一步,我们不管所有的限制条件,不管 deadends 和 target 的限制,就思考一个问题:如果让你设计一个算法,穷举所有可能的密码组合,你怎么做?
穷举呗,再简单一点,如果你只转一下锁,有几种可能?总共有 4 个位置,每个位置可以向上转,也可以向下转,也就是有 8 种可能对吧。
比如说从 “0000” 开始,转一次,可以穷举出 “1000”, “9000”, “0100”, “0900”… 共 8 种密码。然后,再以这 8 种密码作为基础,对每个密码再转一下,穷举出所有可能…
这就可以抽象成一幅图,每个节点有 8 个相邻的节点,又让你求最短距离,这不就是典型的 BFS 嘛,框架就可以派上用场了,先写出一个「简陋」的 BFS 框架代码再说别的:
// 将 s[j] 向上拨动一次
string plusOne(string s, int j) {
if (s[j] == '9')
s[j] = '0';
else
s[j] += 1;
return s;
}
// 将 s[i] 向下拨动一次
String minusOne(string s, int j) {
if (s[j] == '0')
s[j] = '9';
else
s[j] -= 1;
return s;
}
// BFS 框架,打印出所有可能的密码
void BFS(string target) {
deque<string> q;
q.push_back({"0000"});
while (!q.empty()) {
int sz = q.size();
/* 将当前队列中的所有节点向周围扩散 */
for (int i = 0; i < sz; ++i) {
string cur = q.front();
q.pop_front();
/* 判断是否到达终点 */
cout << cur <<endl;
/* 将一个节点的相邻节点加入队列 */
for (int j = 0; j < 4; ++j) {
string up = plusOne(cur, j);
string down = minusOne(cur, j);
q.push_back(up);
q.push_back(down);
}
}
/* 在这里增加步数 */
}
return;
}
这段 BFS 代码已经能够穷举所有可能的密码组合了,但是显然不能完成题目,有如下问题需要解决:
1、会走回头路。比如说我们从 “0000” 拨到 “1000”,但是等从队列拿出 “1000” 时,还会拨出一个 “0000”,这样的话会产生死循环。
2、没有终止条件,按照题目要求,我们找到 target 就应该结束并返回拨动的次数。
3、没有对 deadends 的处理
只要按照 BFS 框架在对应的位置稍作修改即可修复这些问题:
int openLock(string target, vector<string> deadends) {
// 记录需要跳过的死亡密码
unordered_set<string>deads;
for(auto s:deadends){
deads.insert(s);}
// 记录已经穷举过的密码,防止走回头路
unordered_set<string>visited;
deque<string> q;
// 从起点开始启动广度优先搜索
int step = 0;
q.push_back({"0000"});
visited.insert({"0000"});
while (!q.empty()) {
int sz = q.size();
/* 将当前队列中的所有节点向周围扩散 */
for (int i = 0; i < sz; ++i) {
string cur = q.front();
q.pop_front();
/* 判断是否到达终点 */
if(deads.count(cur)) continue;
if(cur == target) return step;
/* 将一个节点的相邻节点加入队列 */
for (int j = 0; j < 4; ++j)
{
string up = plusOne(cur, j);
if(!visited.count(up))
{
q.push_back(up);
visited.push_back(up);
}
string down = minusOne(cur, j);
if(!visited.count(down))
{
q.push_back(down);
visited.push_back(down);
}
}
}
/* 在这里增加步数 */
++step;
}
return -1;
}
有一个比较小的优化:可以不需要 dead 这个哈希集合,可以直接将这些元素初始化到 visited 集合中,效果是一样的,可能更加优雅一些。
BFS 算法还有一种稍微高级一点的优化思路:双向 BFS,可以进一步提高算法的效率。
传统的 BFS 框架就是从起点开始向四周扩散,遇到终点时停止;而双向 BFS 则是从起点和终点同时开始扩散,当两边有交集的时候停止。
不过,双向 BFS 也有局限,因为你必须知道终点在哪里。比如我们刚才讨论的二叉树最小高度的问题,你一开始根本就不知道终点在哪里,也就无法使用双向 BFS;但是第二个密码锁的问题,是可以使用双向 BFS 算法来提高效率的,代码稍加修改即可:
int openLock(string target, vector<string> deadends) {
unordered_set<string>deads;
for(auto s:deadends){
deads.insert(s);}
unordered_set<string>visited;
// 用集合不用队列,可以快速判断元素是否存在
unordered_set<string> q1; //改变
unordered_set<string> q2; //改变
// 从起点开始启动广度优先搜索
int step = 0;
q1.insert({"0000"});
q2.insert({"0000"});
while (!q1.empty() && !q2.empty()) //改变
{
// 哈希集合在遍历的过程中不能修改,用 temp 存储扩散结果
unordered_set<string>temp;
/* 将 q1 中的所有节点向周围扩散 */
for (string cur : q1){
/* 判断是否到达终点 */
if(deads.count(cur)) continue;
// 为何能一直是p2呢,因为上面特别找了temp作交换,要学会!!
if(p2.count(cur)) return step;
visited.insert(cur);
/* 将一个节点的未遍历相邻节点加入集合 */
for (int j = 0; j < 4; ++j)
{
string up = plusOne(cur, j);
if(!visited.count(up))
{
temp.insert(up);
}
string down = minusOne(cur, j);
if(!visited.count(down))
{
temp.insert(down);
}
}
}
/* 在这里增加步数 */
++step;
// temp 相当于 q1---双向核心在这
// 这里交换 q1 q2,下一轮 while 就是扩散 q2
q1 = q2;
q2 = temp;
}
return -1;
}
双向 BFS 还是遵循 BFS 算法框架的,只是不再使用队列,而是使用 set 方便快速判断两个集合是否有交集。
另外的一个技巧点就是 while 循环的最后交换 q1 和 q2 的内容,所以只要默认扩散 q1 就相当于轮流扩散 q1 和 q2。
**无论传统 BFS 还是双向 BFS,无论做不做优化,从 Big O 衡量标准来看,时间复杂度都是一样的,**只能说双向 BFS 是一种 trick,算法运行的速度会相对快一点,掌握不掌握其实都无所谓。最关键的是把 BFS 通用框架记下来,反正所有 BFS 算法都可以用它套出解法。