Acwing 第三章模板及详解(搜索与图论)

一、DFS与BFS

二、树与图的遍历:拓扑排序

三、最短路

四、最小生成树

五、二分图:染色法、匈牙利算法
 

一、DFS与BFS


概述
DFS:深度优先搜索(Depth-First-Search)

BFS:宽度优先搜索(Breadth-First-Search)

DFS和BFS的对比

DFS使用栈(stack)来实现,BFS使用队列(queue)来实现

DFS所需要的空间是树的高度h,而BFS需要的空间是2h (DFS的空间复杂度较低)

DFS不具有最短路的特性,BFS具有最短路的特性

通常来说,求“最短”的操作,都可以用BFS来做,而其他一些奇怪的操作,或者对空间复杂度要求比较高,则用DFS来做

DFS
DFS中的2个重要概念:

回溯:回溯的时候,一定要记得恢复现场
剪枝:提前判断某个分支一定不合法,直接剪掉该分支

#include<bits/stdc++.h>
using namespace std;
const int N = 10;

int n;
int path[N];//记录所有的搜索路径
bool st[N];//记录这些点有没有被用过,1表示是,0表示否

void dfs(int u) // 第u层
{
    if(u == n)//从0开始作为第一层,当搜索完最后一层,就输出这条路径并结束递归
    {
        for(int i=0;i<n;i++)
        {
            printf("%d ",path[i]);
        }
        puts("");
        return;
    }
    for(int i=1;i<=n;i++)
    {
        if(!st[i])
        {
            path[u] = i;//写入路径记录
            st[i] = true;//更新状态为已使用
            dfs(u+1);//给下一层找数
            //----------------------------------下一层递归结束,此时该恢复状态了
            st[i] = false;//更新状态为未使用
            path[u] = 0;//清空该层路径记录
        }
    }
}

int main()
{
    cin>>n;
    dfs(0);
    return 0;
}


(1) 深度优先遍历 

int dfs(int u)
{
    st[u] = true; // st[u] 表示点u已经被遍历过

    for (int i = h[u]; i != -1; i = ne[i])
    {
        int j = e[i];
        if (!st[j]) dfs(j);
    }
}


(2) 宽度优先遍历

queue<int> q;
st[1] = true; // 表示1号点已经被遍历过
q.push(1);

while (q.size())
{
    int t = q.front();
    q.pop();

    for (int i = h[t]; i != -1; i = ne[i])
    {
        int j = e[i];
        if (!st[j])
        {
            st[j] = true; // 表示点j已经被遍历过
            q.push(j);
        }
    }
}


二、树与图的遍历:拓扑排序
首先,树是一种特殊的图(无环连通图)。所以,这里只说图的存储即可。

首先,图分为2种,有向图和无向图。

有向图中2个点之间的边是有方向的,比如a -> b,则只能从a点走到b点,无法从b点走到a点。

无向图中2个点之间的边是没有方向的,比如a - b,则可以从a走到b,也可以从b走到a。

通常,我们可以将无向图看成有向图。比如上面,对a到b之间的边,我们可以建立两条边,分别是a到b的,和b到a的。

所以,我们只需要考虑,有向图如何存储,即可。通常有2种存储方式

邻接矩阵

用一个二维数组来存,比如g[a,b]存储的就是a到b的边。邻接矩阵无法存储重复边,比如a到b之间有2条边,则存不了。(用的较少,因为这种方式比较浪费空间,对于有n个点的图,需要n2的空间,这种存储方式适合存储稠密图)

邻接表

使用单链表来存。对于有n个点的图,我们开n个单链表,每个节点一个单链表。单链表上存的是该节点的邻接点(用的较多)

树和图遍历有2种方式,深度优先遍历和宽度优先遍历。

我们只需要考虑有向图的遍历即可。

树与图的深度优先遍历
由于遍历时,每个点最多只会被遍历一次,所以深度优先遍历的时间复杂度是O(n+m),

n是节点数,m是边数

树的重心:

(深度优先遍历,可以算出每个子树所包含的节点个数)

#include<bits/stdc++.h>
using namespace std;
const int N = 1e5 + 10;
int e[2 * N], ne[2 * N], idx; // 单链表
int h[N]; // 用邻接表, 来存树
int n, ans = N;
bool visited[N];
void add(int a, int b) 
{
    e[idx] = b;
    ne[idx] = h[a];
    h[a] = idx++;
}

// 返回节点x所在子树的节点个数
int dfs(int x) 
{
    visited[x] = true;
    // sum 用来存 x 为根节点的子树的节点个数
    // res 来存, x 的子树的节点个数的最大值
    int sum = 1, res = 0;
    for(int i = h[x]; i != -1; i = ne[i]) 
    {
        int j = e[i]; // 取节点
        if(visited[j]) continue;
        int s = dfs(j);
        res = max(res, s);
        sum += s;
    }
    res = max(res, n - sum); 
    ans = min(ans, res);
    return sum;
}
int main() 
{
    memset(h, -1, sizeof h);
    scanf("%d", &n);
    for(int i = 1; i <= n - 1; i++) 
    {
        int a, b;
        scanf("%d%d", &a, &b);
        add(a, b);
        add(b, a);
    }
    dfs(1);
    printf("%d", ans);
    return 0;
}

树与图的存储

树是一种特殊的图,与图的存储方式相同。
对于无向图中的边ab,存储两条有向边a->b, b->a。
因此我们可以只考虑有向图的存储。

(1) 邻接矩阵:g[a][b] 存储边a->b

(2) 邻接表:

// 对于每个点k,开一个单链表,存储k所有可以走到的点。h[k]存储这个单链表的头结点
int h[N], e[N], ne[N], idx;

// 添加一条边a->b
void add(int a, int b)
{
    e[idx] = b, ne[idx] = h[a], h[a] = idx ++ ;
}

// 初始化
idx = 0;
memset(h, -1, sizeof h);
树与图的遍历
时间复杂度 O(n+m)O(n+m), nn 表示点数,mm 表示边数

时间复杂度 O(n+m), nn 表示点数,mm 表示边数

bool topsort()
{
    int hh = 0, tt = -1;

    // d[i] 存储点i的入度
    for (int i = 1; i <= n; i ++ )
        if (!d[i])
            q[ ++ tt] = i;

    while (hh <= tt)
    {
        int t = q[hh ++ ];

        for (int i = h[t]; i != -1; i = ne[i])
        {
            int j = e[i];
            if (-- d[j] == 0)
                q[ ++ tt] = j;
        }
    }

    // 如果所有点都入队了,说明存在拓扑序列;否则不存在拓扑序列。
    return tt == n - 1;
}

拓扑排序
图的宽度优先搜索的应用,求拓扑序(拓扑序是针对有向图的)

什么是拓扑序:将一个图的很多节点,排成一个序列,使得图中的所有边,都是从前面的节点,指向后面的节点。则这样一个节点的排序,称为一个拓扑序。

若图中有环,则一定不存在拓扑序。

可以证明,一个有向无环图,一定存在一个拓扑序列。有向无环图,又被称为拓扑图。

对于每个节点,存在2个属性,入度和出度。

入度,即,有多少条边指向自己这个节点。

出度,即,有多少条边从自己这个节点指出去。

所有入度为0的点,可以排在当前最前面的位置。

算法流程:

将所有入度为0的点入队。
while(queue非空) 
{
    t = queue.pop(); // 获取队头
    枚举t的全部出边 t->j
      删掉边t->j, j节点的入度减一
      if(j的入度为0) 将j入队
}

一个有向无环图,一定存在至少一个入度为0的点

三、最短路

 常见的最短路问题,一般分为两大类:

单源最短路
多源汇最短路

在最短路问题中,源点也就是起点,汇点也就是终点。
单源最短路:指的是求一个点,到其他所有点的最短距离。(起点是固定的,单一的)

根据是否存在权重为负数的边,又分为两种情况:

所有边的权重都是正数,通常有两种算法

朴素Dijkstra:时间复杂度O(n2),其中n是图中点的个数,m是边的个数

堆优化版的Dijkstra:时间复杂度O(mlogn)

两者孰优孰劣,取决于图的疏密程度(取决于点数n,与边数m的大小关系)。当是稀疏图(n和m是同一级别)时,可能堆优化版的Dijkstra会好一些。当是稠密图时(m和n2是同一级别),使用朴素Dijkstra会好一些。

存在权重为负数的边通常有两种算法

Bellman-Ford:时间复杂度O(nm)

SPFA:时间复杂度一般是O(m),最差O(nm),是前者的优化版,但有的情况无法使用SPFA,只能使用前者,比如要求最短路不超过k条边,此时只能用Bellman-Ford

多源汇最短路:求多个起点到其他点的最短路。(起点不是固定的,而是多个)

Floyd算法:(时间复杂度O(n3))

最短路问题的核心在于,把问题抽象成一个最短路问题,并建图。图论相关的问题,不侧重于算法原理,而侧重于对问题的抽象。

Dijkstra基于贪心,Floyd基于动态规划,Bellman-Ford基于离散数学。

算法的选用:通常来说,单源最短路的,如果没有负权重的边,用Dijkstra,有负权重边的,通常用SPFA,极少数用Bellman-Ford;多源最短路的,用Floyd。


朴素Dijkstra

时间复杂是 O(n2+m)O(n2+m), nn 表示点数,mm 表示边数


假设图中一共有n个点,下标为1~n。下面所说的某个点的距离,都是指该点到起点(1号点)的距离。

算法步骤如下,用一个集合s来存放最短距离已经确定的点。

初始化距离,d[1] = 0, d[i] = +∞。即,将起点的距离初始化为0,而其余点的距离当前未确定,用正无穷表示。

循环

每次从距离已知的点中,选取一个不在s集合中,且距离最短的点(这一步可以用小根堆来优化),遍历该点的所有出边,更新这些出边所连接的点的距离。并把该次选取的点加入到集合s中,因为该点的最短距离此时已经确定。

当所有点都都被加入到s中,表示全部点的最短距离都已经确定完毕

注意某个点的距离已知,并不代表此时这个点的距离就是最终的最短距离。在后续的循环中,可能用一条更短距离的路径,去更新。

int g[N][N];  // 存储每条边
int dist[N];  // 存储1号点到每个点的最短距离
bool st[N];   // 存储每个点的最短路是否已经确定

// 求1号点到n号点的最短路,如果不存在则返回-1
int dijkstra()
{
    memset(dist, 0x3f, sizeof dist);
    dist[1] = 0;

    for (int i = 0; i < n - 1; i ++ )
    {
        int t = -1;     // 在还未确定最短路的点中,寻找距离最小的点
        for (int j = 1; j <= n; j ++ )
            if (!st[j] && (t == -1 || dist[t] > dist[j]))
                t = j;

        // 用t更新其他点的距离
        for (int j = 1; j <= n; j ++ )
            dist[j] = min(dist[j], dist[t] + g[t][j]);

        st[t] = true;
    }

    if (dist[n] == 0x3f3f3f3f) return -1;
    return dist[n];
}

堆优化版dijkstra 

时间复杂度 O(mlogn)O(mlogn), nn 表示点数,mm 表示边数


堆可以自己手写(用数组模拟),也可以使用现成的(C++的STL提供了priority_queue,Java的JDK中提供了PriorityQueue)
特别注意,插入堆的操作,由于更新距离时,可能对一些距离已知的点进行更新(更新为更小的距离),此时不能因为这个点已经在堆中就不进行插入了,因为其距离已经变了,堆中原有的节点已经无效了,按理说,应该修改堆中对应节点的距离值,然后做调整,实际上,可以直接插入一个新的节点(此时对于同一个节点,堆中有两份),但没有关系,堆中的重复节点不会影响最终的结果。
 

typedef pair<int, int> PII;

int n;      // 点的数量
int h[N], w[N], e[N], ne[N], idx;       // 邻接表存储所有边
int dist[N];        // 存储所有点到1号点的距离
bool st[N];     // 存储每个点的最短距离是否已确定

// 求1号点到n号点的最短距离,如果不存在,则返回-1
int dijkstra()
{
    memset(dist, 0x3f, sizeof dist);
    dist[1] = 0;
    priority_queue<PII, vector<PII>, greater<PII>> heap;
    heap.push({0, 1});      // first存储距离,second存储节点编号

    while (heap.size())
    {
        auto t = heap.top();
        heap.pop();

        int ver = t.second, distance = t.first;

        if (st[ver]) continue;
        st[ver] = true;

        for (int i = h[ver]; i != -1; i = ne[i])
        {
            int j = e[i];
            if (dist[j] > distance + w[i])
            {
                dist[j] = distance + w[i];
                heap.push({dist[j], j});
            }
        }
    }

    if (dist[n] == 0x3f3f3f3f) return -1;
    return dist[n];
}

手写堆:

#include <iostream>
#include <cstring>
using namespace std;

const int N = 1e6, INF = 0x3f3f3f3f;

int h[N], e[N], w[N], ne[N], idx;

int d[N];

int hPos[N], hDis[N], hSize;

bool st[N];

int n, m, x, y, z;

void add(int x, int y, int z) 
{
	e[idx] = y;
	w[idx] = z;
	ne[idx] = h[x];
	h[x] = idx++;
}

void heap_swap(int i, int j) 
{
	swap(hPos[i], hPos[j]);
	swap(hDis[i], hDis[j]);
}

void up(int pos)
{
	while(pos > 1 && hDis[pos / 2] > hDis[pos])
    {
		heap_swap(pos / 2, pos);
		pos /= 2;
	}
}

void down(int pos)
{
	int mx = pos;
	if(2 * pos <= hSize && hDis[2 * pos] < hDis[mx]) mx = 2 * pos;
	if(2 * pos + 1 <= hSize && hDis[2 * pos + 1] < hDis[mx]) mx = 2 * pos + 1;
	if(mx != pos) 
    {
		heap_swap(mx, pos);
		down(mx);
	}
}

void insert_to_heap(int x, int dis) 
{
	hSize++;
	hPos[hSize] = x;
	hDis[hSize] = dis;
	up(hSize);
}

void dijkstra() 
{
	d[1] = 0;
	insert_to_heap(1, 0);

	// 当堆非空时,执行
	while(hSize > 0) 
    {
		int x = hPos[1];
		heap_swap(1, hSize--);
		down(1);
		if(st[x]) continue; // 由于堆中可能存在重复的节点, 需要判断, 否则会超时
		st[x] = true; // 拿出来后, 该点的距离就已经确定
		for(int j = h[x]; j != -1; j = ne[j]) 
        {
			int u = e[j];
			if(d[u] > d[x] + w[j]) 
            {
				d[u] = d[x] + w[j];
				insert_to_heap(u, d[u]);
			}
		}
	}
}

int main() 
{
	memset(h, -1, sizeof h);
	memset(d, 0x3f, sizeof d);
	scanf("%d%d", &n, &m);
	while(m--) 
    {
		scanf("%d%d%d", &x, &y, &z);
		add(x, y, z);
	}
	dijkstra();
	if(d[n] == INF) printf("-1");
	else printf("%d", d[n]);
}

Bellman-Ford算法 

算法思路:

循环n次

每次循环,遍历图中所有的边。对每条边(a, b, w),(指的是从a点到b点,权重是w的一条边)更新d[b] = min(d[b], d[a] + w)

可以定义一个类,或者C++里面的结构体,存储a,b,w。表示存在一条边a点指向b点,权重为w)。则遍历所有边时,只要遍历全部的结构体数组即可

循环的次数的含义:假设循环了k次,则表示,从起点,经过不超过k条边,走到每个点的最短距离。

该算法能够保证,在循环n次后,对所有的边(a, b, w),都满足d[b] <= d[a] + w。这个不等式被称为三角不等式。上面的更新操作称为松弛操作。

该算法适用于有负权边的情况。

注意:如果有负权回路的话,最短路就不一定存在了。(注意是不一定存在)。当这个负权回路处于1号点到n号点的路径上,则每沿负权回路走一圈,距离都会减少,则可以无限走下去,1到n的距离就变得无限小(负无穷),此时1号点到n号点的最短距离就不存在。而如果负权回路不在1号点到n号点的路径上,则1到n的最短距离仍然存在。

该算法可以求出来,图中是否存在负权回路。如果迭代到第n次,还会进行更新,则说明存在一条最短路,路径上有n条边,n条边则需要n + 1个点,而由于图中一共只有n个点,所以这n + 1个点中一定有2个点是同一个点,则说明这条路径上有环;有环,并且此次进行了更新,说明这个环的权重是负的(只有更新后总的距离变得更小,才会执行更新)。

但求解负权回路,通常用SPFA算法,而不用Bellman-Ford算法,因为前者的时间复杂度更低。

由于循环了n次,每次遍历所有边(m条边)。故Bellman-Ford算法的时间复杂度是O(n×m),nn 表示点数,mm 表示边数
注意在模板题中需要对下面的模板稍作修改,加上备份数组,详情见模板题。

模板:

int n, m;       // n表示点数,m表示边数
int dist[N];        // dist[x]存储1到x的最短路距离

struct Edge     // 边,a表示出点,b表示入点,w表示边的权重
{
    int a, b, w;
}edges[M];

// 求1到n的最短路距离,如果无法从1走到n,则返回-1。
int bellman_ford()
{
    memset(dist, 0x3f, sizeof dist);
    dist[1] = 0;

    // 如果第n次迭代仍然会松弛三角不等式,就说明存在一条长度是n+1的最短路径,由抽屉原理,路径中至少存在两个相同的点,说明图中存在负权回路。
    for (int i = 0; i < n; i ++ )
    {
        for (int j = 0; j < m; j ++ )
        {
            int a = edges[j].a, b = edges[j].b, w = edges[j].w;
            if (dist[b] > dist[a] + w)
                dist[b] = dist[a] + w;
        }
    }

    if (dist[n] > 0x3f3f3f3f / 2) return -1;
    return dist[n];
}

SPFA 算法(队列优化的Bellman-Ford算法) 


若要使用SPFA算法,一定要求图中不能有负权回路。只要图中没有负权回路,都可以用SPFA,这个算法的限制是比较小的。

SPFA其实是对Bellman-Ford的一种优化。

它优化的是这一步:d[b] = min(d[b], d[a] + w)

我们观察可以发现,只有当d[a]变小了,才会在下一轮循环中更新d[b]

考虑用BFS来做优化。用一个队列queue,来存放距离变小的节点。

(和Dijkstra很像)

SPFA的好处:能解决无负权边的问题,也能解决有负权边的问题,并且效率还比较高。但是当需要求在走不超过k条边的最短路问题上,就只能用Bellman-Ford算法了。

时间复杂度 平均情况下 O(m),最坏情况下 O(nm), nn 表示点数,mm 表示边数

模板:

int n;      // 总点数
int h[N], w[N], e[N], ne[N], idx;       // 邻接表存储所有边
int dist[N];        // 存储每个点到1号点的最短距离
bool st[N];     // 存储每个点是否在队列中

// 求1号点到n号点的最短路距离,如果从1号点无法走到n号点则返回-1
int spfa()
{
    memset(dist, 0x3f, sizeof dist);
    dist[1] = 0;

    queue<int> q;
    q.push(1);
    st[1] = true;

    while (q.size())
    {
        auto t = q.front();
        q.pop();

        st[t] = false;

        for (int i = h[t]; i != -1; i = ne[i])
        {
            int j = e[i];
            if (dist[j] > dist[t] + w[i])
            {
                dist[j] = dist[t] + w[i];
                if (!st[j])     // 如果队列中已存在j,则不需要将j重复插入
                {
                    q.push(j);
                    st[j] = true;
                }
            }
        }
    }

    if (dist[n] == 0x3f3f3f3f) return -1;
    return dist[n];
}

spfa判断图中是否存在负环 
时间复杂度是 O(nm), nn 表示点数,mm 表示边数

int n;      // 总点数
int h[N], w[N], e[N], ne[N], idx;       // 邻接表存储所有边
int dist[N], cnt[N];        // dist[x]存储1号点到x的最短距离,cnt[x]存储1到x的最短路中经过的点数
bool st[N];     // 存储每个点是否在队列中

// 如果存在负环,则返回true,否则返回false。
bool spfa()
{
    // 不需要初始化dist数组
    // 原理:如果某条最短路径上有n个点(除了自己),那么加上自己之后一共有n+1个点,由抽屉原理一定有两个点相同,所以存在环。

    queue<int> q;
    for (int i = 1; i <= n; i ++ )
    {
        q.push(i);
        st[i] = true;
    }

    while (q.size())
    {
        auto t = q.front();
        q.pop();

        st[t] = false;

        for (int i = h[t]; i != -1; i = ne[i])
        {
            int j = e[i];
            if (dist[j] > dist[t] + w[i])
            {
                dist[j] = dist[t] + w[i];
                cnt[j] = cnt[t] + 1;
                if (cnt[j] >= n) return true;       // 如果从1号点到x的最短路中包含至少n个点(不包括自己),则说明存在环
                if (!st[j])
                {
                    q.push(j);
                    st[j] = true;
                }
            }
        }
    }

    return false;
}

Floyd算法


求解多源汇最短路问题,也能处理边权为负数的情况,但是无法处理存在负权回路的情况。

使用邻接矩阵来存储图。初始使用d[i][j]来存储这个图,存储所有的边

算法思路:三层循环

for(k = 1; k <= n; k++)

​ for(i = 1; i <= n; i++)

​ for(j = 1; j <= n; j++)

​ d[i,j] = min(d[i,j] , d[i,k] + d[k,j])

循环结束后,d[i][j]存的就是点i到j的最短距离。

原理是基于动态规划(具体原理在后续的动态规划章节再做详解)。

其状态表示是:d(k, i, j),从点i,只经过1 ~ k这些中间点,到达点j的最短距离
 

时间复杂度是 O(n3), nn 表示点数

模板:

初始化:
    for (int i = 1; i <= n; i ++ )
        for (int j = 1; j <= n; j ++ )
            if (i == j) d[i][j] = 0;
            else d[i][j] = INF;

// 算法结束后,d[a][b]表示a到b的最短距离
void floyd()
{
    for (int k = 1; k <= n; k ++ )
        for (int i = 1; i <= n; i ++ )
            for (int j = 1; j <= n; j ++ )
                d[i][j] = min(d[i][j], d[i][k] + d[k][j]);
}

总结

Dijkstra

只适用于边权为正的情况。是从点的维度出发,每次循环确定起点到某一个点的最短距离,循环n次,则确定全部n个点到起点的最短距离。其思想有点类似BFS或者贪心,每次选一个距离最小的点,很保守的一点点地往外扩张,而不像DFS一条路走到黑。

注意有一个集合s的概念,每轮循环确定了一个点的最短距离后,就把这个点加入集合s。集合s表示的就是当前哪些点已经确定了最短距离了。已经确定了的点,之后的循环就不再考虑它们了。

每次循环,从未确定最短距离的点中,找一个距离最小的点a,则点a的最短距离已经确定,将其加入集合s,随后用a点去更新其他点(准确的说是和a相连的那些点(a的出边))的距离,本轮循环结束。

实际代码中,我们用一个布尔类型的数组,来表示一个点是否加入了集合s

堆优化版的Dijkstra:优化的就是每轮循环,选择一个距离最小的点这一步,使用小根堆可以在O(1)的时间复杂度选出距离最小的点

在选取一个距离最小的点后,只需要更新这个点所有出边,连接的那些点即可。所以用邻接表来存储比较合适。而邻接表适合存储稀疏图。所以在稀疏图时,可以采用堆优化Dijkstra,使用邻接表来存储图。稠密图的情况,则选用朴素Dijkstra,使用邻接矩阵来存储图。

朴素Dijkstra的时间复杂度是O(n2),堆优化Dijkstra的时间复杂度是O(mlogn)

Bellman-Ford

适用于存在负权边的情况。是从边的维度出发。首先还是初始化所有点的距离为正无穷,然后初始化起点的距离为0。

循环n次(准确地说应该是n-1次,因为只需要走n-1条边就能从1号点走到n号点),每次循环遍历所有的边(一个边用[a, b, w]表示,a点到b点,权重为w),每次更新这条边右端点(b点)的距离。d[b] = min(d[b], d[a] + w)。

其实只有当d[a]不是正无穷时,才需要更新。如果无脑更新,则当d[a]是正无穷,且w是负数的话,d[b]会被更新为一个比正无穷稍小一点的数,在实际编码中可能出错。而由于d[a]为正无穷(a点不可达),则其实b点应该也保持为正无穷(b点也不可达)。

Bellman-Ford可以用来求解,经过不超过k条边的最短距离。因为每次迭代,都是把边往前走了一步(对于那些a点不可达的边[a, b, w],不更新d[b])。需要注意,更新距离数组d时,需要提前备份一下d,并使用上一轮的备份来进行更新。这是为了防止串联更新的情况。

串联更新,就比如有3个点,2条边,a -> b -> c,则第一次循环时,先根据[a, b]这条边更新了d[b],然后访问到[b ,c]这条边时又会更新d[c],但第一次循环时,只走出去一条边,是不应当走到c点的。但如果直接使用d数组来更新,则访问[b ,c]这条边时,由于先前访问[a, b]时更新了d[b],则这次使用了新的d[b],则会更新d[c]。

串联更新会影响【第k次循环,找到不超过k条边的最短距离】这一语义。虽然它不会影响最终的最短距离。

另外,更新时的条件判断还要注意,需要特别关注重边的情况,说明如下

void bellman_ford() 
{
    // 循环k次
    for(int i = 0; i < k; i++) 
   {
        // 使用备份数组来进行更新, 以避免出现串联更新的情况, 串联更新会影响k次循环找到经过不超过k条边的最短距离的语义
        memcpy(tmp, d, sizeof d);
        // 每次枚举全部边
        for(int j = 0; j < m; j++) 
        {
            int a = edges[j].a, b = edges[j].b, w = edges[j].w;
            if(tmp[a] != INF && d[b] > tmp[a] + w) 
            {
                // 注意这里后面要用 d[b] > tmp[a] + w
                // 而不能用        tmp[b] > tmp[a] + w  
                // 当a和b存在重边时, 需要保证用到的是最短的那条边
                // 如果采用 tmp[b] > tmp[a] + w , 则无法保证这一点, 可自行验证
                d[b] = tmp[a] + w;
            }
        }
    }
}

Bellman-Ford也能用来判断图中是否存在负权回路。上面说了,实际只需要循环n-1次,若第n次循环仍然执行了距离的更新,则说明,从1号点到另一个点的最短距离,经过了n条边,n条边,则路径上有n+1个点,而由于一共只有n个点,所以这n+1个点中一定有2个点相同,则这条最短距离的路径上存在一个环,并且只有当距离变小时,才会执行更新,那么说明这个环的权重是负的。

SPFA

SPFA是对Bellman-Ford的优化。上面我们知道,Bellman-Ford是每次遍历所有边,并更新d[b] = min(d[b], d[a] + w)。

初始时所有点的距离都是正无穷的,而根据上面的式子,一条边的w是不变的,则只有当一条边的a点的距离d[a]变小了,下一轮循环才有可能会更新d[b]。于是我们只需要关注那些距离变小的点即可。

则采用类似BFS的思路,使用一个队列来保存那些距离变小的点。其代码思路相比Bellman-Ford而言,非常简单。所以通常对于存在负权边的最短路问题,通常直接用SPFA(甚至不存在负权边的问题,也可以用SPFA)。只有当类似于【求不超过k条边的最短路问题】时,才需要用Bellman-Ford。

**但是!SPFA无法用于存在负权回路的情况!**因为SPFA是用一个队列来存储距离变小的点,若存在负权回路,则队列永远不会为空,会无限循环。而Bellman-Ford由于只循环n次,即使有负权回路,也不会出现死循环。

SPFA代码

void spfa() 
{
    memset(d, 0x3f, sizeof d); // 距离初始化为正无穷
    d[1] = 0; // 起点距离为0
    q[++tt] = 1; // 起点距离变小, 入队
    while(tt >= hh) 
    { // 当队列不空
        int u = q[hh++]; // pop 队头
        // 遍历所有出边, 进行可能的更新
        for(int i = h[u]; i != -1; i = ne[i]) 
        {
            int j = e[i];
            if(d[j] > d[u] + w[i]) 
            { 
                // 一定要写这个判断, 因为d[a]变小后, 对于边[a, b, w], 不一定会保证d[b]也变小(可能b点有其他更短的路径)
                d[j] = d[u] + w[i];
                q[++tt] = j; // j点距离变小了, 入队
            }
        }
    }
}

四、最小生成树:


什么是最小生成树?首先,给定一个节点数是n,边数是m的无向连通图G。

则由全部的n个节点,和n-1条边构成的无向连通图被称为G的一颗生成树,在G的所有生成树中,边的权值之和最小的生成树,被称为G的最小生成树。

有两种常用算法:

Prim算法(普利姆)

    1.  朴素版Prim(时间复杂度O(n2),适用于稠密图)
    2.  堆优化版Prim(时间复杂度O(mlogn),适用于稀疏图)
Kruskal算法(克鲁斯卡尔)

适用于稀疏图,时间复杂度O(mlogm)

对于最小生成树问题,如果是稠密图,通常选用朴素版Prim算法,因为其思路比较简洁,代码比较短,如果是稀疏图,通常选用Kruskal算法,因为其思路比Prim简单清晰。堆优化版的Prim通常不怎么用。

对于最小生成树问题,如果是稠密图,通常选用朴素版Prim算法,因为其思路比较简洁,代码比较短,如果是稀疏图,通常选用Kruskal算法,因为其思路比Prim简单清晰。堆优化版的Prim通常不怎么用。

朴素版prim算法 

其算法流程如下

(其中用集合s表示,当前已经在连通块中的所有的点)

初始化距离, 将所有点的距离初始化为+∞

n次循环
      t <- 找到不在集合s中, 且距离最近的点
      用t来更新其他点到集合s的距离
      将t加入到集合s中
 

注意,一个点t到集合s的距离,指的是:若点t和集合s中的3个点有边相连。则点t到集合s的距离就是,t与3个点相连的边中,权重最小的那条边的权重。
时间复杂度是 O(n2+m)O(n2+m), nn 表示点数,mm 表示边数

模板:

int n;      // n表示点数
int g[N][N];        // 邻接矩阵,存储所有边
int dist[N];        // 存储其他点到当前最小生成树的距离
bool st[N];     // 存储每个点是否已经在生成树中


// 如果图不连通,则返回INF(值是0x3f3f3f3f), 否则返回最小生成树的树边权重之和
int prim()
{
    memset(dist, 0x3f, sizeof dist);

    int res = 0;
    for (int i = 0; i < n; i ++ )
    {
        int t = -1;
        for (int j = 1; j <= n; j ++ )
            if (!st[j] && (t == -1 || dist[t] > dist[j]))
                t = j;

        if (i && dist[t] == INF) return INF;

        if (i) res += dist[t];
        st[t] = true;

        for (int j = 1; j <= n; j ++ ) dist[j] = min(dist[j], g[t][j]);
    }

    return res;
}

Kruskal算法 

基本思路:

先将所有边,按照权重,从小到大排序

从小到大枚举每条边(a,b,w)

若a,b不连通,则将这条边,加入集合中(将a点和b点连接起来)

Kruskal算法初始时,相当于所有点都是独立的,没有任何边。

Kruskal不需要用邻接表或者邻接矩阵来存图,只需要存所有边就可以了

时间复杂度是 O(mlogm)O(mlogm), nn 表示点数,mm 表示边数

模板:

int n, m;       // n是点数,m是边数
int p[N];       // 并查集的父节点数组

struct Edge     // 存储边
{
    int a, b, w;

    bool operator< (const Edge &W)const
    {
        return w < W.w;
    }
}edges[M];

int find(int x)     // 并查集核心操作
{
    if (p[x] != x) p[x] = find(p[x]);
    return p[x];
}

int kruskal()
{
    sort(edges, edges + m);

    for (int i = 1; i <= n; i ++ ) p[i] = i;    // 初始化并查集

    int res = 0, cnt = 0;
    for (int i = 0; i < m; i ++ )
    {
        int a = edges[i].a, b = edges[i].b, w = edges[i].w;

        a = find(a), b = find(b);
        if (a != b)     // 如果两个连通块不连通,则将这两个连通块合并
        {
            p[a] = b;
            res += w;
            cnt ++ ;
        }
    }

    if (cnt < n - 1) return INF;
    return res;
}

总结
与最短路的算法思想类似,求解最小生成树的算法,也是分别从点的维度与边的维度出发

Prim

思想有点类似最短路中的Dijkstra。从点的维度出发,使用一个集合s来表示当前已纳入连通块的点。用d[i]来表示节点i与集合s的距离。注意,一个节点i与一个连通块的距离,指的是,这个节点到连通块中任意点的边权的最小值。

假设连通块中已经有了3个点a, b, c,现在有一个点d,它与a有一条边相连,且边权为4,与b的边权为6,与c的边权为2。则d与这个连通块的距离就是2。

初始时,连通块s中没有点,为空。所有点到连通块s的距离初始化为+∞。我们先随便选一个点(比如1号点),将d[1]设为0,表示这个点到连通块s的距离为0,从这个点开始扩展,建立最小生成树。

循环n次,每次选取一个与连通块距离最小的点,将该点加入连通块s,并通过这个点的出边,去更新其他点到连通块的距离。

即,每次选择一个点,加入到最小生成树。每次选择哪个点呢,选择与当前连通块距离最小的点。

一个点的加入伴随着一条边的加入(除了第一个点)。

所以最终只要加入了n-1条边,就表明n个点已经全部加入了连通块,最小生成树构建完成。

实际代码中,也是通过一个布尔数组,来维护每个点是否已计入连通块。

其实某个点i到连通块的距离d[i],一定是这个点到连通块中某个点的边,且一定是边权最小的那个边。所以这样就能保证,每次迭代加入一个点到连通块中,都是以最小的代价(最小权重的边)将这个点加入到连通块中的。所以最终就能保证得到的连通块是最小生成树。

Kruskal

从边的维度出发,先将全部边按照权重从小到大排序。然后遍历全部边,每次查看这条边的左右两个端点,是否处于同一个连通块。若不是,则将这条边加入到最小生成树中,并把两个端点放入到同一连通块。这样,当遍历完全部的边之后,会尽可能地将所有点加入到同一个连通块中。且每次是仅当两个点不在连通块时,才将这条边加入,而边是从小到大排序的。这样就能保证尽可能以小的代价,来构造生成树。所以最终得到的就是最小生成树。

注意kruskal的算法流程中,为了确定2个点是否处于同一连通块,需要用到并查集。

五、二分图:染色法、匈牙利算法

二分图指的是,可以将一个图中的所有点,分成左右两部分,使得图中的所有边,都是从左边集合中的点,连到右边集合中的点。而左右两个集合内部都没有边。图示如下

这一节讲了2部分内容,分别是染色法匈牙利算法

图论中的一个重要性质:一个图是二分图,当且仅当图中不含奇数环

奇数环,指的是这个环中边的个数是奇数。(环中边的个数和点的个数是相同的)

在一个环中,假设共有4个点(偶数个),由于二分图需要同一个集合中的点不能互相连接。

则1号点属于集合A,1号点相连的2号点就应当属于集合B,2号点相连的3号点应当属于集合A,3号点相连的4号点应当属于集合B。4号点相连的1号点应当属于集合A。这样是能够二分的。

而若环中点数为奇数,初始时预设1号点属于集合A,绕着环推了一圈后,会发现1号点应当属于集合B。这就矛盾了。所以存在奇数环的话,这个图一定无法二分。

可以用染色法来判断一个图是否是二分图,使用深度优先遍历,从根节点开始把图中的每个节点都染色,每个节点要么是黑色要么是白色(2种),只要染色过程中没有出现矛盾,说明该图是一个二分图,否则,说明不是二分图。

 染色法判别二分图 

其中染色法是通过深度优先遍历实现
时间复杂度是 O(n+m), nn 表示点数,mm 表示边数

int n;      // n表示点数
int h[N], e[M], ne[M], idx;     // 邻接表存储图
int color[N];       // 表示每个点的颜色,-1表示未染色,0表示白色,1表示黑色

// 参数:u表示当前节点,c表示当前点的颜色
bool dfs(int u, int c)
{
    color[u] = c;
    for (int i = h[u]; i != -1; i = ne[i])
    {
        int j = e[i];
        if (color[j] == -1)
        {
            if (!dfs(j, !c)) return false;
        }
        else if (color[j] == c) return false;
    }

    return true;
}

bool check()
{
    memset(color, -1, sizeof color);
    bool flag = true;
    for (int i = 1; i <= n; i ++ )
        if (color[i] == -1)
            if (!dfs(i, 0))
            {
                flag = false;
                break;
            }
    return flag;
}

匈牙利算法

时间复杂度是 O(nm), nn 表示点数,mm 表示边数


匈牙利算法,是给定一个二分图,用来求二分图的最大匹配的。

给定一个二分图G,在G的一个子图M中,M的边集中的任意两条边,都不依附于同一顶点,则称M是一个匹配。就是每个点只会有一条边相连,没有哪一个点,同时连了多条边。

所有匹配中包含边数最多的一组匹配,被称为二分图的最大匹配。其边数即为最大匹配数

匈牙利算法的核心思想是:我们枚举左半边所节点,每次找到这个节点连接的所有右侧的节点,遍历这些节点,1、当右侧的节点没有和其他左侧节点配对时,直接将这个节点和此时的左边节点配对。则该左侧节点配对成功。2、当右侧节点已经和其他左侧节点配对了,则尝试给这个右侧节点所配对的左侧节点,找一个备用节点。如果这个右侧节点所配对的左侧节点有其他可选择的备用节点。则将这个左侧节点和其备用节点配对。然后将这个右侧节点和当前节点配对。如此找下去…

模板:

int n1, n2;     // n1表示第一个集合中的点数,n2表示第二个集合中的点数
int h[N], e[M], ne[M], idx;     // 邻接表存储所有边,匈牙利算法中只会用到从第一个集合指向第二个集合的边,所以这里只用存一个方向的边
int match[N];       // 存储第二个集合中的每个点当前匹配的第一个集合中的点是哪个
bool st[N];     // 表示第二个集合中的每个点是否已经被遍历过

bool find(int x)
{
    for (int i = h[x]; i != -1; i = ne[i])
    {
        int j = e[i];
        if (!st[j])
        {
            st[j] = true;
            if (match[j] == 0 || find(match[j]))
            {
                match[j] = x;
                return true;
            }
        }
    }

    return false;
}

// 求最大匹配数,依次枚举第一个集合中的每个点能否匹配第二个集合中的点
int res = 0;
for (int i = 1; i <= n1; i ++ )
{
    memset(st, false, sizeof st);
    if (find(i)) res ++ ;
}

小结

二分图相关的算法主要有2个

  • 染色法:是用来判断一个图是否是二分图的
  • 匈牙利算法:是用来求二分图的最大匹配的
  • 2
    点赞
  • 26
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
导数在数学中是一个非常重要的概念,其在机器学习和深度学习中也扮演着至关重要的角色。TensorFlow作为一款流行的深度学习框架,在其2.x版本中提供了丰富的导数计算函数,本文将对TensorFlow 2.x中的导数计算进行详细的解析。 首先,TensorFlow中导数计算的核心就是“tf.GradientTape”函数,该函数记录执行的操作,并自动构建一个对应的计算图。在计算图中,我们可以根据需要定义一系列输入张量或者变量,并用这些对象进行复杂的计算。之后,再通过“tape.gradient”函数来计算导数。比如,在线性回归的例子中,我们可以将设计矩阵X和标签向量y作为输入张量,然后定义参数张量w,并对其进行计算。最后,我们用“tape.gradient”函数对w进行求导,即可得到损失对其的梯度。 除了上述基本实现之外,TensorFlow 2.x中还提供了丰富的导数计算函数,比如“tf.gradients”函数、自动微分工具“tf.autodiff”、高阶导数函数“tf.hessians”、方向导数函数“tf.custom_gradient”等等。这些函数可以根据用户的需要实现对导数的计算、控制求导的方式、实现高阶导数计算等等。在实际使用中,我们可以根据具体的需求选择使用不同的导数计算函数,比如在求解梯度下降法的过程中,我们可以根据需要计算一阶或二阶导数,也可以选择自动微分工具来实现快速又可靠的导数计算。 总之,TensorFlow 2.x中的导数计算是一个非常重要的功能,在深度学习的应用中起着至关重要的作用。通过使用不同的导数计算方法,我们可以实现对复杂模型参数的优化、实现高阶导数计算、实现特殊的导数控制等等功能。因此,熟练掌握TensorFlow 2.x中的导数计算函数是每一位深度学习从业者必备的能力。

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值