该系列博客旨在记录我的刷题心得和一些解题技巧,题目全部来源于力扣,一些技巧和方法参考过力扣上的题解和labuladong大佬的文章。虽然说这些内容主要是写给我自己看的,但也欢迎大家发表自己新颖的解法和不一样的观点。
目录
一、图的存储
图的常见存储方式有两种:邻接矩阵和邻接表。邻接矩阵适用于存储稠密图,而且能以O(1)的复杂度判断两个顶点是否相邻。邻接表适用于存储稀疏图,占用空间少,存各种图都很适合,除非有特殊需求。
二、图的遍历
1.DFS
DFS跟多叉树的前序/后序遍历如出一辙,都是先访问当前节点,再递归的遍历当前节点的相邻节点。下面给出两种形式的代码(假设图用邻接表存储):
void dfs(vector<vector<int>>& graph,int u){
if(visited[u])
return;
//在这里访问数据
visited[u]=true;
for(auto x:graph[u]){
dfs(graph,x);
}
}
void dfs(vector<vector<int>>& graph,int u){
//在这里访问数据
visited[u]=true;
for(auto x:graph[u]){
if(!visited[x])
dfs(graph,x);
}
}
第一个问题,visited数组是用来干什么的?它可以防止在遍历无向图和存在环的图时不会陷入死循环。只有当我们遍历有向无环图(即多叉树)时可以不用visited数组。
第二个问题,这两种形式有什么区别?最好用哪种?第一种形式与多叉树的遍历统一,也比较简洁美观,但最好用第二种形式,因为有时候我们需要在for循环内对那些未访问过的节点进行操作,如果用第一种,我们是不知道相邻的节点是否已访问的。这一点在下文二分图问题中会有具体体现。而且第二种形式 if 在 for 循环内与BFS是统一的。
2.BFS
同样的,BFS与多叉树的层序遍历类似,需要用队列辅助
void bfs(vector<vector<int>>& graph,int u){
queue<int> q;
q.push(u);
//在这里访问数据
visited[u]=true; //每次进队表示一次访问
while(!q.empty()){
int sz=q.size();
for(int i=0;i<sz;i++){
int cur=q.front();
q.pop();
for(auto x:graph[cur]){
if(!visited[x]){
q.push(x);
//在这里访问数据
visited[x]=true;
}
}
}
}
}
三、拓补排序
拓补排序的经典应用就是排课问题,如力扣 201.课程表 II
第一种从入度考虑,用一个indegree数组记录每个顶点的入度,在每次循环内寻找入度为0的顶点。那么如何寻找呢?如果每次遍历一遍indegree,那未必也太不“聪明”了。我们可以用栈或队列优化(拓补排序顺序不唯一,因此两者都可),当我们降低入度为0的顶点的相邻顶点的入度时,检查它的入度,降为0时放入栈或队列中。
class Solution {
int n;
vector<vector<int>> graph;
vector<int> indegree;
bool hasCycle=false;
vector<int> ans;
public:
vector<int> findOrder(int numCourses, vector<vector<int>>& prerequisites) {
n=numCourses;
indegree.resize(n);
buildGraph(prerequisites);
topSort(graph);
if(hasCycle)
return {};
else
return ans;
}
void buildGraph(vector<vector<int>>& prerequisites){
graph.resize(n);
for(auto& x:prerequisites){
graph[x[1]].emplace_back(x[0]);
++indegree[x[0]];
}
}
void topSort(vector<vector<int>>& graph){
queue<int> q;
int cnt=0;
for(int i=0;i<n;i++){
if(indegree[i]==0){
q.push(i);
++cnt;
ans.emplace_back(i);
}
}
while(!q.empty()){
int sz=q.size();
for(int i=0;i<sz;i++){
int cur=q.front();
q.pop();
for(auto next:graph[cur]){
if(--indegree[next]==0){
q.push(next);
++cnt;
ans.emplace_back(next);
}
}
}
}
hasCycle=(cnt!=n);
}
};
以上代码中并没有用到visited数组,程序也不会进入死循环,这是因为成环的顶点的入度始终不可能等于0,这时若要判断是否有环,只有用一个cnt遍历记录输出的顶点个数,若cnt与总个数n不相等,说明含有环。
第二种从出度考虑,寻找出度为0的顶点,然后逆向输出。如何寻找呢?是不是要用一个outdegree数组呢?其实还有一种更巧妙地方式,出度为0的顶点一定在图最深那个位置,因此使用dfs遍历一遍图,把后序遍历的结果逆序输出即可,因为要用dfs,所以必须有visited数组防止死循环,onPath数组来判断是否含有环。
class Solution {
int n;
vector<vector<int>> graph;
vector<bool> visited;
vector<bool> onPath;
bool hasCycle=false;
vector<int> postOrder;
public:
vector<int> findOrder(int numCourses, vector<vector<int>>& prerequisites) {
n=numCourses;
visited.resize(n,false);
onPath.resize(n,false);
buildGraph(prerequisites);
if(!canFinish()){
return {};
}else{
reverse(postOrder.begin(), postOrder.end());
return postOrder;
}
}
bool canFinish() {
for(int i=0;i<n;i++){
dfs(graph,i);
}
return !hasCycle;
}
void buildGraph(vector<vector<int>>& prerequisites){
graph.resize(n);
for(auto& x:prerequisites){
graph[x[1]].emplace_back(x[0]);
}
}
void dfs(vector<vector<int>>& graph,int u){
if(onPath[u]){
hasCycle=true;
return;
}
if(hasCycle || visited[u])
return;
visited[u]=true;
onPath[u]=true;
for(auto x:graph[u]){
dfs(graph,x);
}
postOrder.emplace_back(u);
onPath[u]=false;
}
};
四、二分图
二分图是什么?节点由两个集合组成,且两个集合内部没有边的图。换言之,若将每条边的两个端点分别染成黑色和白色,可以发现二分图中所以相邻顶点的颜色均不同。
1.二分图的判断
我们用color数组记录每个顶点的颜色,用visited数组代表当前节点是否已染过色,遍历一边图,若相邻顶点未染色,则把它染成不同的颜色;若已染色,则判断是否异色,若相同,则不是二分图。下面给出dfs和bfs的两种实现:
class Solution {
int n;
vector<bool> visited;
vector<bool> color;
bool isbipartite=true;
public:
bool isBipartite(vector<vector<int>>& graph) {
n=graph.size();
visited.resize(n);
color.resize(n);
for(int i=0;i<n;i++){
if(!visited[i])
dfs(graph,i);
}
return isbipartite;
}
void dfs(vector<vector<int>>& graph,int u){
if(!isbipartite)
return;
visited[u]=true;
for(auto next:graph[u]){
if(!visited[next]){
color[next]=!color[u];
dfs(graph,next);
}else{
if(color[next]==color[u]){
isbipartite=false;
return;
}
}
}
}
};
class Solution {
int n;
vector<bool> visited;
vector<bool> color;
bool isbipartite=true;
public:
bool isBipartite(vector<vector<int>>& graph) {
n=graph.size();
visited.resize(n);
color.resize(n);
for(int i=0;i<n;i++){
if(!visited[i])
bfs(graph,i);
}
return isbipartite;
}
void bfs(vector<vector<int>>& graph,int u){
if(!isbipartite)
return;
queue<int> q;
q.push(u);
visited[u]=true;
while(!q.empty()){
int sz=q.size();
for(int i=0;i<sz;i++){
int cur=q.front();
q.pop();
for(auto next:graph[cur]){
if(!visited[next]){
q.push(next);
color[next]=!color[cur];
visited[next]=true;
}else{
if(color[next]==color[cur]){
isbipartite=false;
return;
}
}
}
}
}
}
};
五、并查集
并查集支持两种操作:
- 合并(Union):合并两个元素所属集合(合并对应的树)
- 查询(Find):查询某个元素所属集合(查询对应的树的根节点),这可以用于判断两个元素是否属于同一集合,通常还会顺便压缩一下路径
void initialize(vector<int>& parent, int n){
parent.resize(n);
for(int i=0;i<26;i++){
parent[i]=i;
}
}
void Union(int p,int q){
int rootP=find(p),rootQ=find(q);
if(rootP==rootQ)
return;
parent[rootP]=rootQ;
}
bool isConnected(int p,int q){
return find(p)==find(q);
}
int find(int x){
if(parent[x]!=x){
parent[x]=find(parent[x]);
}
return parent[x];
}
再来说一种特殊的操作:删除。详情见这篇文章
这里附我的代码实现:
int index;
void initialize(vector<int>& parent, int n){
index=n;
for(int i=0;i<n;i++){
parent.emplace_back(index++);
}
for(int i=n;i<2*n;i++){
parent.emplace_back(i);
}
}
void del(int x){
parent[x]=index;
parent.emplace_back(index++);
}
//其他方法不变
六、最短路算法
1.单源无权图
单源无权图的最短路算法是 bfs 的改造,我们用 dist 数组记录给定的 s 顶点到各顶点的最短路径长度。dist[s] 初始化为0,其他初始化为正无穷INF(其他标志性的数如-1也可以)。对于当前访问顶点v的所有未访问的邻接点u,dist[u]=dist[v]+1
void Unweighted(vector<vector<int>> graph, int s){
queue<int> q;
q.push(s);
while(!q.empty()){
int v=q.front();
q.pop();
for(auto& u:graph[v]){
if(dist[u]==INF){
dist[u]=dist[v]+1;
q.push(u);
}
}
}
}
2.单源有权图——Dijkstra算法
Dijkstra算法同无权最短路径算法,用 dist 数组记录给定的 s 顶点到各顶点的最短路径长度。dist[s] 初始化为0,其他必须初始化为正无穷INF,每次选取一个顶点v ,它在所有未访问顶点中具有最小的dist[v](贪心思想),同时dist[v]是已知的(Dijkstra算法的前提是每次选取的dist[v]是递增的,若dist[v]未知,说明还存在一个更小的dist[x],这和dist[v]最小矛盾),然后更新v的所有邻接点u。在无权情况下dist[u]=dist[v]+1,在赋权情景下,若dist[v]+weight(v,u)是一个更小的值就更新dist[u]=dist[v]+weight(v,u)
class Vertix{
public:
int v,dist;
Vertix(){}
Vertix(int _v, int _dist){
v=_v;
dist=_dist;
}
bool operator < (const Vertix uv)const{
return dist > uv.dist;
}
};
int INF=0x3f3f3f3f;
vector<int> dist;
vector<bool> visited;
void Dijkstra(vector<vector<pair<int,int>>>& graph, int n, int s) { //把编号和权重作为pair
priority_queue<Vertix> pq;
dist.resize(n,INF);
dist[s]=0;
visited.resize(n);
pq.push(Vertix(s,dist[s]));
while(!pq.empty()){
Vertix cur=pq.top();
pq.pop();
if(visited[cur.v]) //跳过被重复入堆的顶点
continue;
visited[cur.v]=true;
for(auto& e:graph[cur.v]){
int u=e.first;
int w=e.second;
if(dist[cur.v]+w < dist[u]){
dist[u]=dist[cur.v]+w;
pq.push(Vertix(u,dist[u]));
}
}
}
}
接下来看一道有意思的题目:力扣 1631.最小体力消耗路径。这道题跟求最短路径有点像,不过它求的是路径权重的最大值的最小值。
其实我们可以把Dijkstra算法中的 dist 抽象为“成本”这一概念,只要在寻找最小成本路径的过程中,这个成本是递增的,那么Dijkstra算法就是正确的。在求最短路径中,"成本"是权重和,因为无负值边,所以成本是递增的;那么本题中,“成本”是路径上权重的最大值,显然成本也是递增的。所以可以用Dijstra算法解决
class Vertix{
public:
int x,y;
int effort;
Vertix(){}
Vertix(int _x, int _y, int _effort){
x=_x;
y=_y;
effort=_effort;
}
bool operator < (const Vertix v)const{
return effort > v.effort;
}
};
class Solution {
int row,col;
int INF=0x3f3f3f3f;
priority_queue<Vertix> pq;
public:
int minimumEffortPath(vector<vector<int>>& heights) {
row=heights.size();
col=heights[0].size();
int dist[row][col];
for(int i=0;i<row;i++){
for(int j=0;j<col;j++){
dist[i][j]=INF;
}
}
dist[0][0]=0;
pq.push(Vertix(0,0,0));
while(!pq.empty()){
Vertix curV=pq.top();
pq.pop();
if(curV.x==row-1 && curV.y==col-1)
return dist[row-1][col-1];
const vector<vector<int>>& neighbor=neighbors(curV.x, curV.y);
for(auto v:neighbor){
int maxEffort=max(curV.effort,abs(heights[v[0]][v[1]]-heights[curV.x][curV.y]));
if(dist[v[0]][v[1]]>maxEffort){
dist[v[0]][v[1]]=maxEffort;
pq.push(Vertix(v[0],v[1],dist[v[0]][v[1]]));
}
}
}
return dist[row-1][col-1];
}
int d[4][2]={{0,1},{0,-1},{-1,0},{1,0}};
vector<vector<int>> neighbors(int x, int y){
vector<vector<int>> res;
for(int i=0;i<4;i++){
int newX=x+d[i][0];
int newY=y+d[i][1];
if(newX<0 || newX>=row || newY<0 || newY>=col)
continue;
res.push_back({newX,newY});
}
return res;
}
};
再来看一道题:力扣 1514.概率最大的路径
前面说过,Dijkstra算法的正确性的前提是,在寻找最小成本路径的过程中,这个成本是递增的。其实反过来也是对的。本题在寻找最大概率路径的过程中,这个概率是递减的。因此只有稍微修改一下代码,把小堆顶换成大堆顶,把判断条件反过来就可以了。
class Vertix{
public:
int v;
double succprob;
Vertix(){}
Vertix(int _v, double _succprob){
v=_v;
succprob=_succprob;
}
bool operator < (const Vertix v)const{
return succprob < v.succprob;
}
};
class Solution {
vector<vector<pair<int,double>>> graph;
vector<double> dist;
vector<bool> visited;
priority_queue<Vertix> pq;
public:
double maxProbability(int n, vector<vector<int>>& edges, vector<double>& succProb, int start, int end) {
graph.resize(n);
dist.resize(n,0);
dist[start]=1;
visited.resize(n);
for(int i=0;i<edges.size();i++){
graph[edges[i][0]].emplace_back(pair<int,double>(edges[i][1],succProb[i]));
graph[edges[i][1]].emplace_back(pair<int,double>(edges[i][0],succProb[i]));
}
pq.push(Vertix(start,dist[start]));
while(!pq.empty()){
Vertix cur=pq.top();
pq.pop();
if(cur.v==end)
return dist[end];
if(visited[cur.v]) //重复入队情况
continue;
visited[cur.v]=true;
for(auto& x:graph[cur.v]){
if(dist[cur.v] * x.second > dist[x.first]){
dist[x.first]=dist[curV.v]*x.second;
pq.push(Vertix(x.first,dist[x.first]));
}
}
}
return dist[end];
}
};
3.多源最短路——Floyd算法
Floyd算法适用于任何图,不管有向无向,边权正负,但是最短路必须存在。(不能有个负环)代码也十分简洁,三个 for 循环即可搞定
for (k = 1; k <= n; k++) {
for (x = 1; x <= n; x++) {
for (y = 1; y <= n; y++) {
f[x][y] = min(f[x][y], f[x][k] + f[k][y]);
}
}
}
七、最小生成树
1.定义
一个无向图G的最小生成树是由该图的那些连接G的所有顶点的边构成的树,而且其总权重最低。
2.Prim算法
Prim算法是对点的贪心,该算法的基本思想是从一个结点开始,每次要选择距离最小的一个结点,不断加点。同Dijkstra算法的思想相同,用一个优先队列来存顶点,用 inMST 数组记录结点是否已在生成树中。同时,为了判断图是否连通,用 cnt 变量代表未在生成树的结点数量,若最后 cnt 不为0,说明图不连通。
class Vertix{
public:
int v;
int cost;
Vertix(){};
Vertix(int _v, int _cost){
v=_v;
cost=_cost;
}
bool operator < (const Vertix V)const{
return cost>V.cost;
}
};
class Solution {
vector<vector<pair<int,int>>> graph;
priority_queue<Vertix> pq;
vector<bool> inMST;
int cnt;
int ans=0;
public:
int minCostConnectPoints(vector<vector<int>>& points) {
int n=points.size();
graph.resize(n);
inMST.resize(n);
cnt=n;
for(int i=0;i<n;i++){
for(int j=i+1;j<n;j++){
graph[i].emplace_back(pair<int,int>(j,abs(points[i][0]-points[j][0])+abs(points[i][1]-points[j][1])));
graph[j].emplace_back(pair<int,int>(i,abs(points[i][0]-points[j][0])+abs(points[i][1]-points[j][1])));
}
}
pq.push(Vertix(0,0));
while(!pq.empty()){
Vertix cur=pq.top();
pq.pop();
if(inMST[cur.v])
continue;
inMST[cur.v]=true;
ans+=cur.cost;
cnt--;
if(cnt==0)
break;
for(auto& x:graph[cur.v]){
if(!inMST[x.first]) //不能有环
pq.push(Vertix(x.first,x.second));
}
}
return ans;
}
};
3.Kruskal算法
Kruskal算法是对边的贪心,该算法的基本思想是从小到大加入边。为了判断加入该边后是否会形成环,需要用并查集判断该边的两个端点是否已在最小生成树中。所以Kruskal算法是把森林合并成一颗树的过程。
同样以 1584.连接所有点的最小费用 为例
class Edge{
public:
int v,u;
int cost;
Edge(){}
Edge(int _v, int _u, int _cost){
v=_v;
u=_u;
cost=_cost;
}
bool operator < (const Edge e)const{
return cost<e.cost;
}
};
class Solution {
vector<Edge> edges;
vector<int> parent;
int ans=0;
int cnt;
public:
int minCostConnectPoints(vector<vector<int>>& points) {
int n=points.size();
cnt=n;
parent.resize(n);
for(int i=0;i<n;i++){
parent[i]=i;
}
for(int i=0;i<n;i++){
for(int j=i+1;j<n;j++){
edges.push_back(Edge(i,j,abs(points[i][0]-points[j][0])+abs(points[i][1]-points[j][1])));
}
}
sort(edges.begin(),edges.end());
for(auto& edge:edges){
if(!isConnected(edge.v,edge.u)){
Union(edge.v,edge.u);
ans+=edge.cost;
if(cnt==1)
break;
}
}
return ans;
}