深度优先搜索
深度优先搜索(depth-first seach,DFS)在搜索到一个新的节点时,立即对该新节点进行遍
历;因此遍历需要用先入后出的栈来实现,也可以通过与栈等价的递归来实现。对于树结构而言,
由于总是对新节点调用遍历,因此看起来是向着“深”的方向前进。 考虑如下一颗简单的树。我们从1号节点开始遍历,假如遍历顺序是从左子节点到右子节点,
那么按照优先向着“深”的方向前进的策略,假如我们使用递归实现,我们的遍历过程为 1(起 始节点)->2(遍历更深一层的左子节点)->4(遍历更深一层的左子节点)->2(无子节点,返回 父结点)->1(子节点均已完成遍历,返回父结点)->3(遍历更深一层的右子节点)->1(无子节 点,返回父结点)-> 结束程序(子节点均已完成遍历)。如果我们使用栈实现,我们的栈顶元素 的变化过程为 1->2->4->3。
深度优先搜索也可以用来检测环路:记录每个遍历过的节点的父节点,若一个节点被再次遍历且父节点不同,则说明有环。我们也可以用之后会讲到的拓扑排序判断是否有环路,若最后存 在入度不为零的点,则说明有环。
有时我们可能会需要对已经搜索过的节点进行标记,以防止在遍历时重复搜索某个节点,这 种做法叫做状态记录或记忆化(memoization)。
题目描述
给定一个二维的 0-1 矩阵,其中 0 表示海洋,1 表示陆地。单独的或相邻的陆地可以形成岛 屿,每个格子只与其上下左右四个格子相邻。求最大的岛屿面积。
输入输出样例
输入是一个二维数组,输出是一个整数,表示最大的岛屿面积。
Input:
[[1,0,1,1,0,1,0,1],
[1,0,1,1,0,1,1,1],
[0,0,0,0,0,0,0,1]]
Output: 6
最大的岛屿面积为 6,位于最右侧。
此题是十分标准的搜索题,我们可以拿来练手深度优先搜索。一般来说,深度优先搜索类型 的题可以分为主函数和辅函数,主函数用于遍历所有的搜索位置,判断是否可以开始搜索,如果 可以即在辅函数进行搜索。辅函数则负责深度优先搜索的递归调用。当然,我们也可以使用栈
(stack)实现深度优先搜索,但因为栈与递归的调用原理相同,而递归相对便于实现,因此刷题时 笔者推荐使用递归式写法,同时也方便进行回溯(见下节)。不过在实际工程上,直接使用栈可 能才是最好的选择,一是因为便于理解,二是更不易出现递归栈满的情况。我们先展示使用栈的 写法。
vector<int> direction{-1, 0, 1, 0, -1};
int maxAreaOfIsland(vector<vector<int>>& grid) {
int m = grid.size(), n = m? grid[0].size(): 0, local_area, area = 0, x, y;
for (int i = 0; i < m; ++i) {
for (int j = 0; j < n; ++j) {
if (grid[i][j]) {
local_area = 1;
grid[i][j] = 0;
stack<pair<int, int>> island;
island.push({i, j});
while (!island.empty()) {
auto [r, c] = island.top();
island.pop();
for (int k = 0; k < 4; ++k) {
x = r + direction[k], y = c + direction[k+1];
if (x >= 0 && x < m && y >= 0 && y < n && grid[x][y] == 1) {
grid[x][y] = 0;
++local_area;
island.push({x, y});
}
}
}
area = max(area, local_area);
}
}
}
return area;
}
这里我们使用了一个小技巧,对于四个方向的遍历,可以创造一个数组 [-1, 0, 1, 0, -1],每相 邻两位即为上下左右四个方向之一。
在辅函数里,一个一定要注意的点是辅函数内递归搜索时,边界条件的判定。边界判定一般 有两种写法,一种是先判定是否越界,只有在合法的情况下才进行下一步搜索(即判断放在调用 递归函数前);另一种是不管三七二十一先进行下一步搜索,待下一步搜索开始时再判断是否合 法(即判断放在辅函数第一行)。我们这里分别展示这两种写法。
第一种递归写法为:
vector<int> direction{-1, 0, 1, 0, -1};
// 主函数
int maxAreaOfIsland(vector<vector<int>>& grid) {
if (grid.empty() || grid[0].empty()) return 0;
int max_area = 0;
for (int i = 0; i < grid.size(); ++i) {
for (int j = 0; j < grid[0].size(); ++j) {
if (grid[i][j] == 1) {
max_area = max(max_area, dfs(grid, i, j));
}
} }
return max_area;
}
// 辅函数
int dfs(vector<vector<int>>& grid, int r, int c) {
if (grid[r][c] == 0) return 0;
grid[r][c] = 0;
int x, y, area = 1;
for (int i = 0; i < 4; ++i) {
x = r + direction[i], y = c + direction[i+1];
if (x >= 0 && x < grid.size() && y >= 0 && y < grid[0].size()) {
area += dfs(grid, x, y);
}
}
return area;
}
回溯法(backtracking)是优先搜索的一种特殊情况,又称为试探法,常用于需要记录节点状态的深度优先搜索。通常来说,排列、组合、选择类问题使用回溯法比较方便。 顾名思义,回溯法的核心是回溯。在搜索到某一节点的时候,如果我们发现目前的节点(及 其子节点)并不是需求目标时,我们回退到原来的节点继续搜索,并且把在目前节点修改的状态还原。这样的好处是我们可以始终只对图的总状态进行修改,而非每次遍历时新建一个图来储存状态。在具体的写法上,它与普通的深度优先搜索一样,都有 [修改当前节点状态]→[递归子节 点] 的步骤,只是多了回溯的步骤,变成了 [修改当前节点状态]→[递归子节点]→[回改当前节点 状态]。
没有接触过回溯法的读者可能会不明白我在讲什么,这也完全正常,希望以下几道题可以让 您理解回溯法。如果还是不明白,可以记住两个小诀窍,一是按引用传状态,二是所有的状态修改在递归完成后回改。
回溯法修改一般有两种情况,一种是修改最后一位输出,比如排列组合;一种是修改访问标记,比如矩阵里搜字符串。
题目描述
给定一个无重复数字的整数数组,求其所有的排列方式。
输入输出样例
Input: [1,2,3]
Output: [[1,2,3], [1,3,2], [2,1,3], [2,3,1], [3,2,1], [3,1,2]]
输入是一个一维整数数组,输出是一个二维数组,表示输入数组的所有排列方式。
可以以任意顺序输出,只要包含了所有排列方式即可。
题解
怎样输出所有的排列方式呢?对于每一个当前位置 i,我们可以将其于之后的任意位置交换, 然后继续处理位置 i+1,直到处理到最后一位。为了防止我们每此遍历时都要新建一个子数组储 存位置 i 之前已经交换好的数字,我们可以利用回溯法,只对原数组进行修改,在递归完成后再 修改回来。
我们以样例 [1,2,3] 为例,按照这种方法,我们输出的数组顺序为 [[1,2,3], [1,3,2], [2,1,3], [2,3,1], [3,2,1], [3,1,2]],可以看到所有的排列在这个算法中都被考虑到了。
怎样输出所有的排列方式呢?对于每一个当前位置 i,我们可以将其于之后的任意位置交换, 然后继续处理位置 i+1,直到处理到最后一位。为了防止我们每此遍历时都要新建一个子数组储 存位置 i 之前已经交换好的数字,我们可以利用回溯法,只对原数组进行修改,在递归完成后再 修改回来。
我们以样例 [1,2,3] 为例,按照这种方法,我们输出的数组顺序为 [[1,2,3], [1,3,2], [2,1,3], [2,3,1], [3,2,1], [3,1,2]],可以看到所有的排列在这个算法中都被考虑到了。
// 主函数
vector<vector<int>> permute(vector<int>& nums) {
vector<vector<int>> ans;
backtracking(nums, 0, ans);
return ans;
}
// 辅函数
void backtracking(vector<int> &nums, int level, vector<vector<int>> &ans) {
if (level == nums.size() - 1) {
ans.push_back(nums);
return;
}
for (int i = level; i < nums.size(); i++) {
swap(nums[i], nums[level]); // 修改当前节点状态 backtracking(nums, level+1, ans); // 递归子节点 swap(nums[i], nums[level]); // 回改当前节点状态
} }
广度优先搜索(breadth-first search,BFS)不同与深度优先搜索,它是一层层进行遍历的,因此需要用先入先出的队列而非先入后出的栈进行遍历。由于是按层次进行遍历,广度优先搜索时按照“广”的方向进行遍历的,也常常用来处理最短路径等问题。 考虑如下一颗简单的树。我们从1号节点开始遍历,假如遍历顺序是从左子节点到右子节点,那么按照优先向着“广”的方向前进的策略,队列顶端的元素变化过程为 [1]->[2->3]->[4],其中 方括号代表每一层的元素。这里要注意,深度优先搜索和广度优先搜索都可以处理可达性问题,即从一个节点开始是否能达到另一个节点。因为深度优先搜索可以利用递归快速实现,很多人会习惯使用深度优先搜索 刷此类题目。实际软件工程中,笔者很少见到递归的写法,因为一方面难以理解,另一方面可能 产生栈溢出的情况;而用栈实现的深度优先搜索和用队列实现的广度优先搜索在写法上并没有太 大差异,因此使用哪一种搜索方式需要根据实际的功能需求来判断。
题目描述
给定一个二维 0-1 矩阵,其中 1 表示陆地,0 表示海洋,每个位置与上下左右相连。已知矩 阵中有且只有两个岛屿,求最少要填海造陆多少个位置才可以将两个岛屿相连。
输入输出样例
输入是一个二维整数数组,输出是一个非负整数,表示需要填海造陆的位置数。
Input:
[[1,1,1,1,1],
[1,0,0,0,1],
[1,0,1,0,1],
[1,0,0,0,1],
[1,1,1,1,1]]
Output: 1
题解
本题实际上是求两个岛屿间的最短距离,因此我们可以先通过任意搜索方法找到其中一个岛 屿,然后利用广度优先搜索,查找其与另一个岛屿的最短距离。
vector<int> direction{-1, 0, 1, 0, -1};
// 主函数
int shortestBridge(vector<vector<int>>& grid) {
int m = grid.size(), n = grid[0].size(); queue<pair<int, int>> points;
// dfs寻找第一个岛屿,并把1全部赋值为2 bool flipped = false;
for (int i = 0; i < m; ++i) {
if (flipped) break;
for (int j = 0; j < n; ++j) {
if (grid[i][j] == 1) {
dfs(points, grid, m, n, i, j);
flipped = true;
break;
} }
}
// bfs寻找第二个岛屿,并把过程中经过的0赋值为2 int x, y;
int level = 0;
while (!points.empty()){
++level;
int n_points = points.size();
while (n_points--) {
auto [r, c] = points.front();
points.pop();
for (int k = 0; k < 4; ++k) {
x = r + direction[k], y = c + direction[k+1];
if (x >= 0 && y >= 0 && x < m && y < n) {
if (grid[x][y] == 2) {
continue;
}
if (grid[x][y] == 1) {
return level;
}
points.push({x, y});
grid[x][y] = 2;
}
} }
}
return 0; }
// 辅函数
void dfs(queue<pair<int, int>>& points, vector<vector<int>>& grid, int m, int n
, int i, int j) {
if (i < 0 || j < 0 || i == m || j == n || grid[i][j] == 2) {
return; }
if (grid[i][j] == 0) {
points.push({i, j});
return;
}
grid[i][j] = 2;
dfs(points, grid, m, n, i - 1, j);
dfs(points, grid, m, n, i + 1, j);
dfs(points, grid, m, n, i, j - 1);
dfs(points, grid, m, n, i, j + 1);
}