刷题心得04 图论基础和一些经典算法

         该系列博客旨在记录我的刷题心得和一些解题技巧,题目全部来源于力扣,一些技巧和方法参考过力扣上的题解和labuladong大佬的文章。虽然说这些内容主要是写给我自己看的,但也欢迎大家发表自己新颖的解法和不一样的观点。


目录

一、图的存储

二、图的遍历

1.DFS

         2.BFS

三、拓补排序

四、二分图

1.二分图的判断

五、并查集

六、最短路算法

1.单源无权图

2.单源有权图——Dijkstra算法

        3.多源最短路——Floyd算法

 七、最小生成树

1.定义

2.Prim算法

3.Kruskal算法


一、图的存储

        图的常见存储方式有两种:邻接矩阵和邻接表。邻接矩阵适用于存储稠密图,而且能以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,说明图不连通。

例题:1584.连接所有点的最小费用

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;
    }

  • 2
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

vio_gram

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值