layout: post
title: leetcode重点题目分类别记录(二)基本算法:二分,位图,回溯,动态规划,拓扑排序
description: leetcode重点题目分类别记录(二)基本算法:二分,位图,回溯,动态规划,拓扑排序
tag: 数据结构与算法
部分内容来自拉不拉东的算法小抄
二分查找本质上是排除法的思路,不断将搜索空间缩减为原来的一半,因此整体的时间复杂度为O(LogN),不同的题目需要注意ans的候选位置。
例如本题搜索插入位置,返回插入位置的索引:
如果mid处等于目标值,那么直接返回mid
如果mid处大于目标值,说明目标值应该插在mid左边,那么ans候选位置就是mid;
如果mid处小于目标值,说明目标值应该插入mid右边,那么ans候选位置就是mid+1;
int searchInsert(vector<int>& nums, int target) {
int left = 0, right = nums.size() - 1, ans = left;
while (left <= right) {
int mid = left + ((right - left) >> 1);
if (nums[mid] == target) return mid;
if (nums[mid] > target) {
// target在左边,更新右边界,mid处为可能的插入位置
ans = mid;
right = mid - 1;
} else {
// target 在右边,更新左边界,mid + 1处为可能的插入位置
left = mid + 1;
ans = left;
}
}
return ans;
}
int search(vector<int>& nums, int target) {
int left = 0, n = nums.size(), right = n - 1;
while (left <= right) {
int mid = left + ((right - left) >> 1);
if (nums[mid] == target) return mid;
if (nums[mid] >= nums[0]) { // 通过与nums[0]比较判断nums[mid]的落点在断点左边或右边
// 在断点左边这部分有序区间二分
if (target >= nums[0] && target < nums[mid]) right = mid - 1; // 如果目标值与中间值在同一部分有序区间,可以排除一半,并进入有序空间的排序
else left = mid + 1; // 即使不在同一部分,也可以缩减搜索整个区间的范围
} else {
// 在断点右边这部分有序区间二分
if (target > nums[mid] && target <= nums[right]) left = mid + 1;
else right = mid - 1;
}
}
return -1;
}
前缀和适用于很快的求解某个区间的累计和
preSum[i]记录前i个数的累计和,i从0开始,那么
preSum[right] - preSum[left] 就是左闭右开区间[left, right)的nums子数组和。
仿照一维的思路,用pre[i + 1][j + 1]表示从左上角到下标(i,j)位置表示的右下角位置间区域的累计和。
那么任意一个矩形区间的累计和可标记为:
注意:在下边的代码中,在计算累加时的方法为:
presum[i + 1][j + 1] = presum[i + 1][j] + presum[i][j + 1] + matrix[i][j] - presum[i][j];
左上角部分加了两次,要减去
而计算区间时,左上角部分减了两次要加回来:
presum[row2 + 1][col2 + 1] + presum[row1][col1] - presum[row1][col2 + 1] - presum[row2 + 1][col1];
class NumMatrix {
public:
vector<vector<int>> presum;
NumMatrix(vector<vector<int>>& matrix) {
int m = matrix.size(), n = matrix[0].size();
presum = vector<vector<int>>(m + 1, vector<int>(n + 1));
for (int i = 0; i < m; ++i) {
for (int j = 0; j < n; ++j) {
presum[i + 1][j + 1] = presum[i + 1][j] + presum[i][j + 1] + matrix[i][j] - presum[i][j];
}
}
}
int sumRegion(int row1, int col1, int row2, int col2) {
return presum[row2 + 1][col2 + 1] + presum[row1][col1] - presum[row1][col2 + 1] - presum[row2 + 1][col1];
}
};
差分数组的主要适用场景是频繁对原始数组的某个区间的元素进行增减。
差分数组的求法:
第一个数与nums[0],后边每个数等于该处下标 减去
前一个下标。
后减去前,第一个位置前面没有,故是本身
diff[0] = nums[0];
diff[i] = nums[i] - nums[i - 1];
可以发现,根据差分数组diff可以还原出nums数组,具体做法就是对差分数组diff求累计和
因为是求累计和,所以假定我们给diff数组i位置处增减一个元素值,那么它后边位置处都会增减该元素值。
具体的,假定你要让左闭右开区间[left, right)上的元素都加上k, 那么只需令差分数组diff[left] += k,diff[right] -= k;
我们可以将上述过程写成一个封装的类,输入一个nums数组,设置increament方法。
特别需要注意的是:
构建差分数组diff[]时,需要右减左,而恢复时是需要累加。
class Difference {
public:
vector<int> diff;
Difference(const vector<int> &nums){
diff = nums;
for (int i = 1; i < nums.size(); ++i) {
diff[i] -= nums[i - 1];
}
}
// 给左闭右开区间[i, j) 增加val,val可以是负数
void increment(int i, int j, int val) {
diff[i] += val;
if (j < diff.size()) diff[j] -= val;
}
vector<int> getResult() {
vector<int> res(diff);
for (int i = 1; i < res.size(); ++i) {
res[i] += res[i - 1];
}
return res;
}
};
分析题意,其实就是初始answer都是0,根据bookings,将对应区间都加上座位数。
可以直接利用上边写的差分数组类:
注意我们写的时候,是对左必右开区间,同时增加。
而题目的意思是左右闭区间,此外航班序号比索引号大1.
vector<int> corpFlightBookings(vector<vector<int>>& bookings, int n) {
vector<int> nums(n);
Difference d(nums);
for (int i = 0; i < bookings.size(); ++i) {
int x = bookings[i][0] - 1, y = bookings[i][1], val = bookings[i][2];
d.increment(x, y, val);
}
return d.getResult();
}
注意剪枝:
当前可选的数据范围为[i, n] 有 n - i + 1个
还需要的数据个数为 k - path.size();
可选范围必须大于等于所需的元素。
vector<vector<int>> combine(int n, int k) {
vector<vector<int>> ans;
vector<int> path;
function<void(int)> backtracking = [&](int index) {
if (path.size() == k) {
ans.push_back(path);
return;
}
for (int i = index; i <= n && path.size() + n - i + 1 >= k; ++i) {
path.push_back(i);
backtracking(i + 1);
path.pop_back();
}
};
backtracking(1);
return ans;
}
排序问题与组合问题最大的区别在于,前边的选择是否影响后边的选择
比如:1,2,3
如果第一步选择了2,那么后边可选的只剩下3
而排列中,第一步选择了2,后边还是可以选择1或3
对应在代码中,组合问题一般回溯到下一层传递的是 i + 1,i是当前选择的位置,而排序则是index + 1,按照 index即层序来更新,而非当前选择的位置i。
vector<vector<int>> permute(vector<int>& nums) {
vector<vector<int>> ans;
function<void(int)> dfs = [&] (int index) {
if (index == nums.size()) {
ans.push_back(nums);
return;
}
for (int i = index; i < nums.size(); ++i) {
swap(nums[i], nums[index]);
dfs(index + 1);
swap(nums[i], nums[index]);
}
};
dfs(0);
return ans;
}
有重复情况下的排列去重逻辑与组合一样。与组合不同的地方在于,每个位置都有可能添加到当前path的末尾,因此需要一个used数组,然后每次遍历整个nums。
vector<vector<int>> permuteUnique(vector<int>& nums) {
sort(nums.begin(), nums.end());
vector<vector<int>> ans;
vector<int> path;
vector<bool> used(nums.size(), false);
function<void()> dfs = [&] () {
if (path.size() == nums.size()) {
ans.push_back(path);
return;
}
for (int i = 0; i < nums.size(); ++i) {
if (i > 0 && nums[i] == nums[i - 1] && used[i - 1] == false) continue;
if (used[i] == false) {
path.push_back(nums[i]);
used[i] = true;
dfs();
path.pop_back();
used[i] = false;
}
}
};
dfs();
return ans;
}
分割问题的本质为确定分割线的位置可能;
根据一个起始的分割点,枚举所有可能的下一个分割点,如果当前分割出了回文串,进入更深层次的搜索。
class Solution {
public:
vector<vector<string>> partition(string s) {
vector<vector<string>> ans;
vector<string> path;
function<void(int)> dfs = [&](int index) {
if (index == s.size()) {
ans.push_back(path);
return;
}
for (int i = index; i < s.size(); ++i) {
string str = s.substr(index, i - index + 1);
if (isValid(str)) {
path.push_back(str);
dfs(i + 1);
path.pop_back();
}
}
};
dfs(0);
return ans;
}
bool isValid(const string &str) {
for (int i = 0, j = str.size() - 1; i <= j; ++i, --j) {
if (str[i] != str[j]) return false;
}
return true;
}
};
复原ip地址问题,同样是确定分割点,这里比较复杂的地方在于剪枝。
class Solution {
public:
vector<string> restoreIpAddresses(string s) {
vector<string> ans;
vector<string> path;
if (s.size() < 4 || s.size() > 12) return ans;
function<void(int)> dfs = [&](int index) {
// 根据当前位置和剩余的字符确定剩余部分是否能够构成一个有效的部分
int leftPart = 4 - path.size(), leftChar = s.size() - index;
if (leftChar < leftPart || leftChar > 3 * leftPart) return;
if (path.size() == 3) {
string leftStr = s.substr(index, leftChar);
if (isValid(leftStr)) {
string res;
for (string str : path) res += str + ".";
res += leftStr;
ans.push_back(res);
}
return;
}
// i < index + 3 保证了分割出的子串长度小于等于3
for (int i = index; i < s.size() && i < index + 3; ++i) {
string str = s.substr(index, i - index + 1);
if (isValid(str)) {
path.push_back(str);
dfs(i + 1);
path.pop_back();
}
}
};
dfs(0);
return ans;
}
bool isValid(const string &str) {
if (str.size() == 1) return true;
if (str[0] == '0') return false;
int num = 0;
for (char c : str) num = num * 10 + (c - '0');
return num <= 255;
}
};
子集问题与回溯中其他问题最大的区别在于,不需要设置回溯的终点,因为回溯路径上每一个节点都是最终所需的一个path;因此上来就需要将当前path加入到ans中,且不能return,选或者不选都是一种子集可能的情况。
vector<vector<int>> subsetsWithDup(vector<int>& nums) {
vector<vector<int>> ans;
vector<int> path;
sort(nums.begin(), nums.end());
vector<bool> used(nums.size(), false);
function<void(int)> dfs = [&] (int index) {
ans.push_back(path);
for (int i = index; i < nums.size(); ++i) {
if (i > 0 && nums[i] == nums[i - 1] && !used[i - 1]) continue;
path.push_back(nums[i]);
used[i] = true;
dfs(i + 1);
path.pop_back();
used[i] = false;
}
};
dfs(0);
return ans;
}
该题与子集2有相似的地方:每次需要取路径树上的节点,即选或者不选都是一种可能,回溯开始就考虑是否将path添加到ans中;
另一个相似的地方在于需要去重。
这一题去重的逻辑比子集稍微复杂一些,首先它也是在当前层去重,当前层如果已经选择了某个元素,当前层的后边节点就不可以在使用。
但是子集2中,不要求输出顺序,因此可以对其进行排序,把重复元素堆在一块儿。
这里则不行,需要使用一个单独的hash表来记录当前层使用了哪些元素。注意这里的hash表只对当前回溯过程中当前层使用,与used数组不同!!!
vector<vector<int>> findSubsequences(vector<int>& nums) {
vector<vector<int>> ans;
vector<int> path;
int n = nums.size();
function<void(int)> dfs = [&] (int index) {
if (path.size() > 1) ans.push_back(path);
int hash[201] = {0};
for (int i = index; i < n; ++i) {
if ((!path.empty() && path.back() > nums[i]) || hash[nums[i] + 100] == 1) continue;
path.push_back(nums[i]);
hash[nums[i] + 100] = 1;
dfs(i + 1);
path.pop_back();
}
};
dfs(0);
return ans;
}
子集问题实际上也可以使用上边这种在单层使用hash表记录的形式。
并且我认为这种形式实际上更加直观易懂!!!
vector<vector<int>> subsetsWithDup(vector<int>& nums) {
vector<vector<int>> ans;
vector<int> path;
sort(nums.begin(), nums.end());
function<void(int)> dfs = [&] (int index) {
ans.push_back(path);
int hash[21] = {0};
for (int i = index; i < nums.size(); ++i) {
if (hash[nums[i] + 10] == 1) continue;
path.push_back(nums[i]);
hash[nums[i] + 10] = 1;
dfs(i + 1);
path.pop_back();
}
};
dfs(0);
return ans;
}
广度优先搜索最经典的题目就是二叉树的层序遍历。
广度优先搜索的问题的本质就是让你在一幅图中找到从起点start
到终点target
的最近距离。
思路:每次有四个方向可以选择,使用bfs搜索,将非墙的位置加入到队列中。为了避免走重复的路,在加入队列后,将相应位置标记为墙。当来到边界位置处,边是最近的出口。为了记录距离,可以把位置距离起点的距离同时记录到队列中的节点。
int nearestExit(vector<vector<char>>& maze, vector<int>& entrance) {
int m = maze.size(), n = maze[0].size();
const int dx[4] = {1, -1, 0 , 0}, dy[4] = {0, 0, 1, -1}; // 偏移量数组
queue<tuple<int, int, int>> que; // 包含位置x,y和距离起点距离的节点队列
que.emplace(entrance[0], entrance[1], 0);
maze[entrance[0]][entrance[1]] = '+'; // 每次加入队列后,将位置改为墙,避免重复
while (!que.empty()) {
auto [x, y, d] = que.front();
que.pop();
for (int i = 0; i < 4; ++i) {
int cx = x + dx[i], cy = y + dy[i];
if (cx >= 0 && cx < m && cy >= 0 && cy < n && maze[cx][cy] == '.') { // 只有相邻位置可走才进行判断与加入队列的操作
if (cx == 0 || cx == m - 1 || cy == 0 || cy == n - 1) return d + 1; // 如果其中一个有效相邻位置为边缘处,说明找到出口了
que.emplace(cx, cy, d + 1);
maze[cx][cy] = '+'; // 每次加入队列后,将位置改为墙,避免重复
}
}
}
return -1;
}
class Solution {
public:
// 将i位置数字加一
string plusone(string s, int i) {
if (s[i] == '9') {
s[i] = '0';
return s;
}
++s[i];
return s;
}
// 将i位置数字减一
string minusone(string s, int i) {
if (s[i] == '0') {
s[i] = '9';
return s;
}
--s[i];
return s;
}
int openLock(vector<string>& deadends, string target) {
const string root = "0000";
if (target == root) return 0; // 如果目标值就是当前值,直接返回
unordered_set<string> visited;
for (string &str : deadends) visited.insert(str); // 死亡数字相当于已经访问过,不能再访问
queue<string> que;
if (!visited.count(root)) { // 只有非访问过的节点才能加入队列,入队即访问
que.push(root);
visited.insert(root);
}
int ans = 0;
while (!que.empty()) {
int sz = que.size();
for (int i = 0; i < sz; ++i) {
string cur = que.front();
que.pop();
for (int j = 0; j < 4; ++j) { // 四个数字轮流上下转动
string plus = plusone(cur, j);
if (plus == target) return ans + 1; // 如果转到target,返回ans + 1
if (!visited.count(plus)) { // 只有非访问过的节点才能加入队列,入队即访问
que.push(plus);
visited.insert(plus);
}
string minus = minusone(cur, j);
if (minus == target) return ans + 1;
if (!visited.count(minus)) { // 只有非访问过的节点才能加入队列,入队即访问
que.push(minus);
visited.insert(minus);
}
}
}
++ans;
}
return -1;
}
};
传统的 BFS 框架就是从起点开始向四周扩散,遇到终点时停止;而双向 BFS 则是从起点和终点同时开始扩散,当两边有交集的时候停止。
因为双向bfs需要从两头开始扩散,因此必须指定目标点位置!
传统bfs是从起点位置不断向周围扩散,直至扩散至目标处。
双向bfs从起点和终点两头扩散,看最终是否能够有交集。
邻接表和邻接矩阵是存储图结构的两种主要的方式方式:
邻接表很直观,把每个节点x的邻居都存到一个列表里,然后把x和这个列表关联起来,这样就可以通过一个节点x找到它的所有相邻节点。
邻接矩阵则是一个二维布尔数组,我们权且称为matrix,如果节点x和y是相连的,那么就把matrix[x][y]设为true,如果想找节点x的邻居,那么遍历一遍matrix[x][…]就够了。
代码形式如下:
// 邻接表
// graph[x] 存储 x 的所有邻居节点
vector<int> graph[];
// 邻接矩阵
// matrix[x][y] 记录 x 是否有一条指向 y 的边
bool matrix[][];
邻接表的优点在于占用空间少,因为邻接矩阵有很多空余空间浪费。但是邻接表无法快速判断两个节点是否相邻,而临界矩阵却可以。因此两种形式各有利弊,使用时需结合具体情况。
在无向图中,度
就是每个节点相连的边的条数,由于有向图的边是有方向的,因此每个节点的度
又被细分为出度
和入度
。
有时图中还需要存储每个节点与相邻节点间的权重(距离),对于这种加权图
:
如果采用邻接表
的形式存储,在储存邻居点标号的同时,还需记录对应的权重;
如果采用邻接矩阵
,那么matrix[x][y]不再是布尔值,而是一个int值,0表示没有连接,其他值表示权重。
代码形式如下:
vector<pair<int, int>> graph[];
// 邻接矩阵
// matrix[x][y] 记录 x 指向 y 的边的权重,0 表示不相邻
vector<vector<int>> matrix;
如果是无向图,其实等同于每个相邻节点是双向的。
如果连接无向图中的节点 x 和 y,把 matrix[x][y] 和 matrix[y][x] 都变成 true 不就行了;邻接表也是类似的操作,在 x 的邻居列表里添加 y,同时在 y 的邻居列表里添加 x。
把上面的技巧合起来,就变成了无向加权图。
图的遍历与多叉树是类似的,使用dfs遍历
图可能是包含环的,为了避免重复遍历,需要一个visited数组进行辅助,如果题目告诉了不包含环,那么可以去掉visited数组
void traverse(TreeNode root) {
if (root == null) return;
for (TreeNode child : root.children) {
printf("进入节点 %s", child);
traverse(child);
printf("离开节点 %s", child);
}
}
如果执行上边的遍历算法,会发现这种方式会把根节点遗漏,为了避免遗漏,我们应该将打印时机放在for循环外部:
void traverse(TreeNode root) {
if (root == null) return;
printf("进入节点 %s", root);
for (TreeNode child : root.children) {
traverse(child);
}
printf("离开节点 %s", root);
}
一般我们用onPath数组来记录一次dfs遍历过程中经过的节点,用visited数组来记录所有遍历过程是否经过是否经过某个点。
onPath与visited在代码实现上的区别在于,onPath类似回溯,有撤销操作,而visited则没有撤销!!!
类比贪吃蛇游戏,visited 记录蛇经过过的格子,而 onPath 仅仅记录蛇身。onPath 用于判断是否成环,类比当贪吃蛇自己咬到自己(成环)的场景。
vector<bool> onPath;
vector<bool> visited;
void traverse(vector<int>* graph, int s) {
// 将节点 s 标记为已遍历
visited[s] = true;
// 开始遍历节点 s
onPath[s] = true;
for (int t : graph[s]) {
traverse(graph, t);
}
// 节点 s 遍历完成
onPath[s] = false; // onPath有回撤!!!
}
vector<vector<int>> allPathsSourceTarget(vector<vector<int>>& graph) {
vector<vector<int>> ans;
vector<int> path;
int n = graph.size();
// 根据遍历起始点,搜索下一步可以到达的位置
function<void(int)> traverse = [&] (int cur) {
// 记录
path.push_back(cur);
if (cur == n - 1) {
ans.push_back(path);
}
for (int c : graph[cur]) traverse(c);
// 撤销当前的记录
path.pop_back();
};
traverse(0);
return ans;
}
二分图的定义:
二分图的顶点集可分割为两个互不相交的子集,图中每条边依附的两个顶点都分属于这两个子集,且两个子集内的顶点不相邻。
从定义上不太好理解,但可以用双色问题
来等同于二分图问题。
给你一幅「图」,请你用两种颜色将图中的所有顶点着色,且使得任意一条边的两个端点的颜色都不相同,你能做到吗?
如果能,那么这幅图就是二分图,反之则不是。
从简单实用的角度来看,二分图结构在某些场景可以更高效地存储数据。
比如说我们需要一种数据结构来储存电影和演员之间的关系:某一部电影肯定是由多位演员出演的,且某一位演员可能会出演多部电影。
既然是存储映射关系,最简单的不就是使用哈希表嘛,我们可以使用一个 HashMap
但是如果给出一个演员的名字,我们想快速得到该演员演出的所有电影,怎么办呢?这就需要「反向索引」,对之前的哈希表进行一些操作,新建另一个哈希表,把演员作为键,把电影列表作为值。
显然,如果用哈希表存储,需要两个哈希表分别存储「每个演员到电影列表」的映射和「每部电影到演员列表」的映射。但如果用「图」结构存储,将电影和参演的演员连接,很自然地就成为了一幅二分图:
每个电影节点的相邻节点就是参演该电影的所有演员,每个演员的相邻节点就是该演员参演过的所有电影,非常方便直观。
其实生活中不少实体的关系都能自然地形成二分图结构,所以在某些场景下图结构也可以作为存储键值对的数据结构(符号表)。
判定二分图的算法很简单,就是用代码解决「双色问题」。
说白了就是遍历一遍图,一边遍历一边染色,看看能不能用两种颜色给所有节点染色,且相邻节点的颜色都不相同。
// 二叉树遍历框架
void traverse(TreeNode* root) {
if (root == nullptr) return;
traverse(root->left);
traverse(root->right);
}
// 多叉树遍历框架
void traverse(Node* root) {
if (root == nullptr) return;
for (Node* child : root->children)
traverse(child);
}
// 图遍历框架
vector<bool> visited;
void traverse(Graph* graph, int v) {
// 防止走回头路进入死循环
if (visited[v]) return;
// 前序遍历位置,标记节点 v 已访问
visited[v] = true;
for (Vertex neighbor : graph->neighbors(v))
traverse(graph, neighbor);
}
因为图中可能存在环,所以用 visited
数组防止走回头路。
这里可以看到我习惯把 return 语句都放在函数开头,因为一般 return 语句都是 base case,集中放在一起可以让算法结构更清晰。
回顾一下二分图怎么判断,其实就是让 traverse 函数一边遍历节点,一边给节点染色,尝试让每对相邻节点的颜色都不一样。
所以,判定二分图的代码逻辑可以这样写:
// 图遍历框架
void traverse(Graph graph, bool visited[], int v) {
visited[v] = true;
// 遍历节点 v 的所有相邻节点 neighbor
for (int neighbor : graph.neighbors(v)) {
if (!visited[neighbor]) {
// 相邻节点 neighbor 没有被访问过
// 那么应该给节点 neighbor 涂上和节点 v 不同的颜色
traverse(graph, visited, neighbor);
} else {
// 相邻节点 neighbor 已经被访问过
// 那么应该比较节点 neighbor 和节点 v 的颜色
// 若相同,则此图不是二分图
}
}
}
bool isBipartite(vector<vector<int>>& graph) {
int n = graph.size();
vector<bool> visit(n, false), color(n, false);
bool ans = true;
function<void(int)> dfs = [&](int cur) {
if (!ans) return; // 如果已经非二分图了,直接返回,不需要再遍历了
if (visit[cur]) return; // 遍历过的跳过,避免环路重复遍历
visit[cur] = true;
for (int nb : graph[cur]) {
if (!visit[nb]) {
// 如果相邻节点没有被访问过,那么给neighbor涂上与cur不一样的颜色
color[nb] = !color[cur];
dfs(nb); // 接着遍历
} else {
// 如果相邻节点访问过了,判断它与cur是否颜色不一样,如果一样说明不是二分图
if (color[cur] == color[nb]) {
ans = false;
return;
}
}
}
};
// 因为图不一定是连通的,可能存在多个子图,所以要把每个节点都作为起点进行一次遍历,如果任意一个子图不是二分图,整个图都不算是二分图
for (int v = 0; v < n; ++v) {
if (!visit[v]) {
dfs(v);
}
if (!ans) return false; // 如果从某个节点处开始遍历,发现已经出现非二分图了,直接返回false
}
return true;
}
思路:根据dislike构建图,判断是否可以染色为二分图。
class Solution {
public:
bool ok = true;
vector<bool> color;
vector<bool> visited;
vector<vector<int>> buildGraph(int n, vector<vector<int>> &dislikes) {
// 图节点编号1,……n
vector<vector<int>> graph(n + 1);
for (auto edge : dislikes) {
int v = edge[0], w = edge[1];
// 无向图相当于双向图
graph[v].push_back(w);
graph[w].push_back(v);
}
return graph;
}
void traverse(vector<vector<int>> &graph, int v) {
if (!ok) return; // 如果已经不符合二分图,没必要再递归
visited[v] = true;
for (int w : graph[v]) {
// 如果该相邻节点没有遍历过
if (!visited[w]) {
color[w] = !color[v]; // 染上不同的颜色
traverse(graph, w); // dfs递归
} else {
// 如果相邻节点已经遍历过,检查染色是否合理
if (color[w] == color[v]) {
ok = false;
return;
}
}
}
}
bool possibleBipartition(int n, vector<vector<int>>& dislikes) {
color.resize(n + 1);
visited.resize(n + 1);
vector<vector<int>> graph = buildGraph(n, dislikes);
for (int i = 1; i <= n; ++i) {
if (!visited[i]) {
traverse(graph, i);
}
}
return ok;
}
};
看到依赖问题,首先想到的就是把问题转化成「有向图」这种数据结构,只要图中存在环,那就说明存在循环依赖。
思路:构图,判断成环
注意:
1、我们用onpath记录一次深度优先遍历过程中的点,如果出现重复,说明成环。
2、前面图的遍历过程中,我们直接在访问的地方跳过遍历,但是在环检测的代码,visited的判断必须放在环检测的后边!!!
class Solution {
public:
bool canFinish(int numCourses, vector<vector<int>>& prerequisites) {
vector<bool> cnt(numCourses, false);
// 构图
vector<vector<int>> graph(numCourses);
for (auto edge : prerequisites) {
int cur = edge[0], pre = edge[1];
graph[pre].push_back(cur);
}
bool hasCircle = false;
vector<bool> visited(numCourses), onpath(numCourses);
function<void(int)> traverse = [&](int cur) {
if (hasCircle) return; // 如果已经检测到环,直接返回
// 说明有环
if (onpath[cur]) {
hasCircle = true;
return;
}
// 判断环必须在visited判断之前!!!
if (visited[cur]) return; // 如果访问过直接访问
visited[cur] = true;
onpath[cur] = true;
for (int w : graph[cur]) {
traverse(w);
}
onpath[cur] = false; // 撤销
};
for (int i = 0; i < numCourses; ++i) {
if (hasCircle) return false;
}
return true;
}
};
课程表②还需要我们返回合理的上课顺序。
这就需要利用到拓扑排序:
拓扑排序(Topological Sorting):
拓扑排序是对DAG(有向无环图)上的节点进行排序,使得对于每一条有向边 u→ v , u 都在 v 之前出现。简单地说,是在不破坏节点先后顺序的前提下,把DAG拉成一条链。如果以游戏中的科技树(虽然名字带树,其实常常不是树而只是DAG)举例,拓扑排序就是找到一种可能的点科技树的顺序。
直观地说就是,让你把一幅图「拉平」,而且这个「拉平」的图里面,所有箭头方向都是一致的,比如上图所有箭头都是朝右的。
那么如何实现拓扑排序呢?
其实特别简单:
将后序遍历的结果进行反转,就是拓扑排序的结果!!!
以二叉树的后序遍历为例:
二叉树的后序遍历是什么时候?遍历完左右子树之后才会执行后序遍历位置的代码。换句话说,当左右子树的节点都被装到结果列表里面了,根节点才会被装进去。
后序遍历的这一特点很重要,之所以拓扑排序的基础是后序遍历,是因为一个任务必须等到它依赖的所有任务都完成之后才能开始开始执行。
你把二叉树理解成一幅有向图,边的方向是由父节点指向子节点,那么就是下图这样:
但这样的结果保证的是先2、3然后有1,即孩子节点必定在父节点前边,即父节点依赖于孩子节点。
显然拓扑排序的要求是反过来的。我们需要保证孩子节点依赖于父节点,那么直接逆序即可实现
回到题目,我们先利用课程表①中的检测环的算法,判断是否有环,如果没有环,再利用拓扑排序的思路,后序遍历记录,反转后序遍历结果,输出正确的学习顺序。
class Solution {
public:
vector<int> findOrder(int numCourses, vector<vector<int>>& prerequisites) {
vector<vector<int>> graph(numCourses);
for (auto edge : prerequisites) {
int cur = edge[0], pre = edge[1];
graph[pre].push_back(cur);
}
vector<bool> onpath(numCourses), visited(numCourses);
vector<int> postorder;
bool hasCircle = false;
function<void(int)> dfs = [&] (int cur) {
if (hasCircle) return;
if (onpath[cur]) {
hasCircle = true;
return;
}
if (visited[cur]) return;
onpath[cur] = true;
visited[cur] = true;
for (int w : graph[cur]) {
dfs(w);
}
postorder.push_back(cur);
onpath[cur] = false;
};
for (int i = 0; i < numCourses; ++i) {
if (!visited[i]) dfs(i);
if (hasCircle) return {};
}
reverse(postorder.begin(), postorder.end());
return postorder;
}
};
BFS算法借助indegree(入度)
数组记录每个节点的入度
,也可以实现图中的环检测与输出拓扑排序结果的算法。
先总结BFS算法的思路:
首先入度为0的节点被加入队列:
开始执行 BFS 循环,从队列中弹出一个节点,减少相邻节点的入度,同时将新产生的入度为 0 的节点加入队列:
继续从队列弹出节点,并减少相邻节点的入度,这一次没有新产生的入度为 0 的节点:
继续从队列弹出节点,并减少相邻节点的入度,同时将新产生的入度为 0 的节点加入队列:
继续弹出节点,直到队列为空:
这时候,所有节点都被遍历过一遍,也就说明图中不存在环。
反过来说,如果按照上述逻辑执行 BFS 算法,存在节点没有被遍历,则说明成环。
比如下面这种情况,队列中最初只有一个入度为 0 的节点:
当弹出这个节点并减小相邻节点的入度之后队列为空,但并没有产生新的入度为 0 的节点加入队列,所以 BFS 算法终止:
BFS版本的拓扑排序算法,节点的遍历顺序就是拓扑排序的结果。
class Solution {
public:
vector<int> findOrder(int numCourses, vector<vector<int>>& prerequisites) {
vector<vector<int>> graph(numCourses);
vector<int> indegree(numCourses);
// 构图,记录入度
for (auto edge : prerequisites) {
graph[edge[1]].push_back(edge[0]);
++indegree[edge[0]];
}
// 初始化队列,将入度为0的加入
queue<int> que;
for (int i = 0; i < numCourses; ++i) if (indegree[i] == 0) que.push(i);
vector<int> ans;
while (!que.empty()) {
// 弹出队头时,记录到ans
int cur = que.front();
que.pop();
ans.push_back(cur);
// 遍历它的相邻节点,入度减一,如果减到0,入队
for (int w : graph[cur]) {
--indegree[w];
if (indegree[w] == 0) que.push(w);
}
}
// 如果记录的ans大小与节点个数相同,无环,否则说明有环,返回空。
return ans.size() == numCourses ? ans : vector<int>{};
}
};
并查集(Union-Find)算法是一个专门针对「动态连通性」的算法
简单说,动态连通性其实可以抽象成给一幅图连线。比如下面这幅图,总共有 10 个节点,他们互不相连,分别用 0~9 标记:
我们的union-find算法主要实现下边这两个API:
class UF {
public:
// 将p和q连接
void union (int p, int q);
// 判断p和q是否连通
bool connected(int p, int q);
/* 返回图中有多少个连通分量
int count();
};
这里所说的「连通」是一种等价关系,也就是说具有如下三个性质:
1、自反性:节点 p 和 p 是连通的。
2、对称性:如果节点 p 和 q 连通,那么 q 和 p 也连通。
3、传递性:如果节点 p 和 q 连通,q 和 r 连通,那么 p 和 r 也连通。
比如说之前那幅图,0~9 任意两个不同的点都不连通,调用 connected 都会返回 false,连通分量为 10 个。
如果现在调用 union(0, 1),那么 0 和 1 被连通,连通分量降为 9 个。
再调用 union(1, 2),这时 0,1,2 都被连通,调用 connected(0, 2) 也会返回 true,连通分量变为 8 个。
判断这种「等价关系」非常实用,比如说编译器判断同一个变量的不同引用,比如社交网络中的朋友圈计算等等。
怎么用森林来表示连通性呢?我们设定树的每个节点有一个指针指向其父节点,如果是根节点的话,这个指针指向自己。比如说刚才那幅 10 个节点的图,一开始的时候没有相互连通,就是这样:
class UF{
private:
int count;
// 节点x的父节点是parent[x]
int* parent;
public:
UF(int n) {
this->count = n;
parent = new int[n];
for (int i = 0; i < n; ++i) {
// 父节点初始指向自己
parent[i] = i;
}
}
};
如果两个节点被连通,则让其中的(任意)一个节点的根节点接到另一个节点的根节点上:
class UF {
// 为了节约篇幅,省略上文给出的代码部分...
public:
void union(int p, int q) {
int rootP = find(p);
int rootQ = find(q);
if (rootP == rootQ)
return;
// 将两棵树合并为一棵
parent[rootP] = rootQ;
// parent[rootQ] = rootP 也一样
count--; // 两个分量合二为一,总分量减一
}
/* 返回某个节点 x 的根节点 */
int find(int x) {
// 根节点的 parent[x] == x
while (parent[x] != x)
x = parent[x];
return x;
}
/* 返回当前的连通分量个数 */
int count() {
return count;
}
};
这样,如果节点 p 和 q 连通的话,它们一定拥有相同的根节点:
connected函数可以写为:
class UF {
private:
// 省略上文给出的代码部分...
public:
bool connected(int p, int q) {
int rootP = find(p);
int rootQ = find(q);
return rootP == rootQ;
}
};
至此,Union-Find 算法就基本完成了。
算法的负责度主要是find
函数向上寻根造成的,我们可能习惯性地认为树的高度就是 logN
,但这并不一定。logN
的高度只存在于平衡二叉树,对于一般的树可能出现极端不平衡的情况,使得「树」几乎退化成「链表」,树的高度最坏情况下可能变成 N
。
我们一开始就是简单粗暴的把 p 所在的树接到 q 所在的树的根节点下面,那么这里就可能出现「头重脚轻」的不平衡状况,比如下面这种局面:
长此以往,树可能生长得很不平衡。我们其实是希望,小一些的树接到大一些的树下面,这样就能避免头重脚轻,更平衡一些
。解决方法是额外使用一个 size 数组,记录每棵树包含的节点数,我们不妨称为「重量」:
class UF {
private:
int count;
int* parent;
// 新增一个数组记录树的“重量”
int* size;
public:
UF(int n) {
this->count = n;
parent = new int[n];
// 最初每棵树只有一个节点
// 重量应该初始化 1
size = new int[n];
for (int i = 0; i < n; i++) {
parent[i] = i;
size[i] = 1;
}
}
/* 其他函数 */
};
比如说 size[3] = 5 表示,以节点 3 为根的那棵树,总共有 5 个节点。这样我们可以修改一下 union 方法:
class UF {
private:
// 为了节约篇幅,省略上文给出的代码部分...
public:
void union(int p, int q) {
int rootP = find(p);
int rootQ = find(q);
if (rootP == rootQ)
return;
// 小树接到大树下面,较平衡
if (size[rootP] > size[rootQ]) {
parent[rootQ] = rootP;
size[rootP] += size[rootQ];
} else {
parent[rootP] = rootQ;
size[rootQ] += size[rootP];
}
count--;
}
};
这样,通过比较树的重量,就可以保证树的生长相对平衡,树的高度大致在 logN 这个数量级,极大提升执行效率。
此时,find , union , connected 的时间复杂度都下降为 O(logN),即便数据规模上亿,所需时间也非常少。
这步优化虽然代码很简单,但原理非常巧妙。
其实我们并不在乎每棵树的结构长什么样,只在乎根节点。
因为无论树长啥样,树上的每个节点的根节点都是相同的,所以能不能进一步压缩每棵树的高度,使树高始终保持为常数?
这样每个节点的父节点就是整棵树的根节点,find 就能以 O(1) 的时间找到某一节点的根节点,相应的,connected 和 union 复杂度都下降为 O(1)。
要做到这一点主要是修改 find 函数逻辑,非常简单,但你可能会看到两种不同的写法。
第一种是在 find 中加一行代码:
class UF {
// 为了节约篇幅,省略上文给出的代码部分...
private:
int find(int x) {
while (parent[x] != x) {
// 这行代码进行路径压缩
parent[x] = parent[parent[x]];
x = parent[x];
}
return x;
}
};
用语言描述就是,每次 while 循环都会把一对儿父子节点改到同一层,这样每次调用 find 函数向树根遍历的同时,顺手就将树高缩短了一层。
路径压缩的第二种写法是这样:
class UF {
// 为了节约篇幅,省略上文给出的代码部分...
// 第二种路径压缩的 find 方法
public:
int find(int x) {
if (parent[x] != x) {
parent[x] = find(parent[x]);
}
return parent[x];
}
};
至此完整的Union Find算法是如下实现的:
class UF {
private:
// 连通分量个数
int count;
// 存储每个节点的父节点
int *parent;
public:
// n 为图中节点的个数
UF(int n) {
this->count = n;
parent = new int[n];
for (int i = 0; i < n; i++) {
parent[i] = i;
}
}
// 将节点 p 和节点 q 连通
void union_(int p, int q) {
int rootP = find(p);
int rootQ = find(q);
if (rootP == rootQ)
return;
parent[rootQ] = rootP;
// 两个连通分量合并成一个连通分量
count--;
}
// 判断节点 p 和节点 q 是否连通
bool connected(int p, int q) {
int rootP = find(p);
int rootQ = find(q);
return rootP == rootQ;
}
int find(int x) {
if (parent[x] != x) {
parent[x] = find(parent[x]);
}
return parent[x];
}
// 返回图中的连通分量个数
int count_() {
return count;
}
};
思路:先将所有==
的方程用并查集的方式联合unite
,然后遍历!=
的方程,查看是否是连接的connected为true
。
class Solution {
public:
class UnionFind {
private:
vector<int> parent;
public:
UnionFind() {
parent.resize(26);
iota(parent.begin(), parent.end(), 0); // 从0开始,递增赋值
}
int find(int index) {
if (index != parent[index]) {
parent[index] = find(parent[index]);
}
return parent[index];
}
void unite(int p, int q) {
parent[find(p)] = find(q); // 将q的根挂在p的根下边
}
bool connected(int p, int q) {
return find(p) == find(q);
}
};
bool equationsPossible(vector<string>& equations) {
UnionFind uf;
for (const string& str : equations) {
if (str[1] == '=') {
int index1 = str[0] - 'a';
int index2 = str[3] - 'a';
uf.unite(index1, index2);
}
}
for (const string& str : equations) {
if (str[1] == '!') {
int index1 = str[0] - 'a';
int index2 = str[3] - 'a';
if (uf.connected(index1, index2)) return false;
}
}
return true;
}
};