数据结构之图(内含Java代码实现)

// 构造一个含有V个顶点的图,但是不含边

Graph(int V) {

adj = new ArrayList[V];

for (int i = 0; i < V; i++) {

adj[i] = new ArrayList();

}

this.V = V;

}

/**

  • @return 返回顶点的数量

*/

int V(){

return V;

}

/**

  • @return 返回边的数量

*/

int E(){

return E;

}

/**

  • 在图中添加一条边v-w

  • @param v

  • @param w

*/

abstract void addEdge(int v, int w);

/**

  • 获得与v相邻的所有顶点

  • @param v

  • @return

*/

abstract Iterable adj(int v);

/**

  • 与结点s相连通的所有结点

  • @param s

  • @return

*/

abstract Iterablesearch(int s);

/**

  • 是否存在S结点到V结点的路径

  • @param s

  • @param v

  • @return

*/

abstract boolean hasPathTo(int s,int v);

/**

  • 找出s到v结点的路径

  • @param s

  • @param v

  • @return

*/

abstract Iterable pathTo(int s,int v);

/**

  • 便于进行打印

  • @return

*/

@Override

public String toString() {

String s = “Graph{” +

“V=” + V +

“, E=” + E +

‘}’;

for (int v=0;v<V;v++){

s += (v+“:”);

for (int w :this.adj(v)) {

s += w+" ";

}

s+= “\n”;

}

return s;

}

}

大家可能发现,上面的数据结构设计的不是很严谨,比如说结点都是使用了Int数据类型,而没有使用泛型。同样,这些方法不一定全部在一个类中实现,可能会进行分离。

首先让我们来实现较为简单的几个函数。

@Override

void addEdge(int v, int w) {

adj[v].add(w);

adj[w].add(v);

this.E ++;

}

@Override

Iterable adj(int v) {

return adj[v];

}

接下来我们需要实现的就是众所周知的搜索函数了(因为深度优先搜索和广度有限搜索应该算大名鼎鼎的算法了吧)。我们想知道途中有哪一些的点,使用不同的算法会产生不同的作用效果。

深度优先搜索

深度优先搜索类似i走迷宫,一条路走到黑,如果发现这条路走不通,就在前一个路口继续向前走。就像下面这样(图片节选自《算法第四版》)

那么算法中,我们需要解决什么问题呢?我们可以通过adj函数得到结点的相邻结点,但是如果我们如何保证结点已经被我们访问过了,我们就需要一个标志mark,这个标志代表着这个结点是否已经被访问过。(HashSet这种数据结构也可以做到这种事情)。步骤如下:

  • 将被访问的结点标记为已访问

  • 递归地访问它的所有没有被标记过的邻居结点

/**

  • 无向图的深度优先搜索

  • @author xiaohui

*/

public class DepthFirstSearch {

private boolean[] marked;

private int count;

public DepthFirstSearch(UndirGraph graph,int s){

marked = new boolean[graph.V()];

dfs(graph,s);

}

private void dfs(UndirGraph graph, int s) {

marked[s] = true;

count++;

for (int v:graph.adj(s)){

if (!marked[v]){

dfs(graph,v);

}

}

}

public boolean getMarked(int w) {

return marked[w];

}

public int getCount() {

return count;

}

}

大家可以有上面的代码可以i很简单的知道,获得与s相同的结点,只需要对dfs进行递归即可,并将结点的marked标志设置为true即可。现在我们就可以完善search函数了。

Iterable search(int s) {

DepthFirstSearch dfs = new DepthFirstSearch(this,s);

List list = new ArrayList(dfs.getCount());

for (int i=0;i<this.V();i++) {

if (dfs.getMarked(i)){

list.add(i);

}

}

return list;

}

在上面的深度优先搜索的算法,其实还有一个应用,那就是寻找路径的问题,也就是说,通过深度优先算法,我们可以知道A结点和X结点是否存在一条路径,如果有,则输出路径。

/**

  • @author xiaohui

  • 通过深度优先搜索寻找路径

*/

public class DepthFirstSearchPath {

private boolean[] marked;

/**

  • 从起点到一个顶点的已知路径上面的最后一个顶点,例如:

  • 0-3-4-5-6 则 edgeTo[6] = 5

*/

private int[] edgeTo;

/**

  • 起点

*/

private final int s;

/**

  • 在graph中找出起点为s的路径

  • @param graph

  • @param s

*/

public DepthFirstSearchPath(Graph graph,int s) {

marked = new boolean[graph.V()];

this.s = s;

edgeTo = new int[graph.V()];

dfs(graph,s);

}

private void dfs(Graph graph, int s) {

marked[s] = true;

for (int v:graph.adj(s)){

if (!marked[v]){

edgeTo[v] = s;

dfs(graph,v);

}

}

}

/**

  • v的顶点是否可达,也就是说是否存在s到v的路径

  • @param v

  • @return

*/

public boolean hasPathTo(int v){

return marked[v];

}

/**

  • 返回s到v的路径

  • @param v

  • @return

*/

public Iterable pathTo(int v){

if (!hasPathTo(v)){

return null;

}

Stack path = new Stack<>();

for (int x = v;x!=s;x = edgeTo[x]){

path.push(x);

}

path.push(s);

return path;

}

在上面的算法中, 我们首先进行深度优先遍历将每个结点是否被遍历保存到marked[]数组中,然后,在edgeTo[]数组我们保存了进行深度遍历中被遍历结点的上一个结点,示意图如下图所示(图片节选自《算法》):

现在我们可以补全上文中的一些函数了。

/**

  • 是否存在S结点到V结点的路径

  • @param s

  • @param v

  • @return

*/

@Override

boolean hasPathTo(int s, int v) {

DepthFirstSearchPath dfsPath = new DepthFirstSearchPath(this,s);

return dfsPath.hasPathTo(v);

}

/**

  • 找出s到v结点的路径

  • @param s

  • @param v

  • @return

*/

@Override

Iterable pathTo(int s, int v) {

DepthFirstSearchPath dfsPath = new DepthFirstSearchPath(this,s);

return dfsPath.pathTo(v);

}

通过深度优先搜索,我们可以得到s结点的路径,那么深度优先搜索还有什么用法呢?其中有一个用法就是寻找出一幅图的所有连通分量。

public class CC {

private boolean[] marked;

/**

  • id代表结点所属的连通分量为哪一个,例如:

  • id[1] =0,id[3]=1

  • 代表1结点属于0连通分量,3结点属于1连通分量

*/

private int[] id;

/**

  • count代表连通分量的表示,0,1……

*/

private int count;

public CC(Graph graph) {

marked = new boolean[graph.V()];

id = new int[graph.V()];

for (int s=0;s<graph.V();s++){

if (!marked[s]){

count++;

dfs(graph,s);

}

}

}

private void dfs(Graph graph,int v) {

marked[v] = true;

id[v] = count;

for (int w:graph.adj(v)) {

if (!marked[w]){

dfs(graph,w);

}

}

}

/**

  • v和w是否属于同一连通分量

  • @param v

  • @param w

  • @return

*/

public boolean connected(int v,int w){

return id[v]==id[w];

}

/**

  • 获得连通分量的数量

  • @return

*/

public int getCount() {

return count;

}

/**

  • 结点属于哪一个连通分量

  • @param w

  • @return

*/

public int id(int w){

return id[w];

}

}

在下图中,有三个连通分量。

说完深度优先搜索,我们可以来说一说广度优先搜索算法了。在前面的深度优先搜索中,我们将深度优先搜索算法比喻成迷宫,它可以带我们从一个结点走到另外一个结点(也就是寻找路径问题),但是如果我们需要去解决_最短路径_的问题,使用深度优先搜索能不能解决呢?答案是不能,我们可以想一想,使用深度优先搜索,我们是一条道走到“黑”,有可能离开始结点最近的结点反而还有可能最后遍历。但是广度优先遍历却可以解决这个问题。

广度优先遍历

广度优先的算法在迷宫中类似这样:我们先遍历开始结点的相邻结点并将结点,然后按照与起点的距离的顺序来遍历所有的顶点。在前面的深度优先遍历中,我们使用了隐式的栈【LIFO】(递归)来进行保存结点,而在广度优先遍历中,我们将使用显式的队列(FIFO)来保存结点。

进行广度优先遍历的算法步骤如下:

先将起点加入队列,然后重复以下步骤:

  • 取队列中的下一个顶点v并标记它

  • 将与v相邻的所有未被标记过的结点加入队列

package graph.undir;

import java.util.LinkedList;

import java.util.Queue;

import java.util.Stack;

/**

  • @author xiaohui

  • 广度优先遍历

*/

public class BreadthFirstSearch {

private boolean[] marked;

private final int s;

private int[] edgeTo;

public BreadthFirstSearch(Graph graph,int s) {

this.s = s;

this.marked = new boolean[graph.V()];

this.edgeTo = new int[graph.V()];

bfs(graph,s);

}

private void bfs(Graph graph, int s) {

Queue queue = new LinkedList<>();

marked[s] = true;

// 将s加入队列中

queue.offer(s);

while(!queue.isEmpty()){

// 从队列中删除结点

int v = queue.poll();

for (int w: graph.adj(v)) {

if (!marked[w]){

edgeTo[w] = v;

marked[w] = true;

queue.offer(w);

}

}

}

}

public boolean hasPathTo(int v){

return marked[v];

}

public Iterable pathTo(int v){

if (hasPathTo(v)){

return null;

}

Stack path = new Stack<>();

for (int i = v; i != s; i = edgeTo[i]) {

path.push(i);

}

path.push(s);

return path;

}

}

对于从s可达的任意顶点v,广度优先搜索都能找到一条从s到v的最短路径。下面是进行广度优先遍历的情况图:

在这里我们可以思考一下如何使用广度优先搜索或者深度优先搜索解决这两个问题:

  • 图G是无环图吗?(假设不存在自环或者平行边)

  • 图G是二分图吗?

在上面两个问题的解决方法很简单。

第一个问题中,我们可以这样思考:在进行搜索的时候,如果A结点的邻居结点B已经被被标记了,但是如果在B结点中,它的邻居结点C已经被标记了,但是如果邻居结点C并不是结点A,那么这幅图就是一个有环图。道理很简单,在前面我们知道,通过一个已经被标记的结点,我们肯定可以通过该节点回到起点s,那么C结点有一条路径回到起点,A结点也有一条路径回到起点,而B结点将A和C结点连接起来了,形成了一个环。

第二个问题中,和第一个问题很类似,在C结点中,如果C结点的颜色不和A结点一样(则和B结点一样),那么该图一定不会是一个二分图。

有向图

在有向图中,边是单边的,也就是说,边是由一个结点指向另外一个结点, 两个结点的邻接性是单向的。在一幅有向图中,一个顶点的出度为该顶点指出的边的总数,入度为指向该顶点的边的总数。在一幅有向图中间存在4种关系:

A->B,A<-B,A B(没有边相连接),A->B A<-B

  • 有向路径:由一系列顶点组成,对于其中的每一个顶点都存在一条有向边从它指向序列中的下一个顶点。

  • 有向环: 为一条至少含有一条边且起点和终点相同的有向路径。

有向图详解

在有向图中,对代码需要进行一些改变,在addEdgeo函数中,我们不再是添加2条边,而是只是添加一条边,同时我们添加了一个reserve函数,目的是将边的方向进行翻转。

/**

  • 在图中添加一条边v-w

  • @param v

  • @param w

*/

@Override

void addEdge(int v, int w) {

adj[v].add(w);

E++;

}

/**

  • 遍历每一个结点,然后进行翻转

  • @return 返回翻转后的图

*/

public DiGraph reverse(){

DiGraph diGraph = new DiGraph(V);

for (int i = 0; i < V; i++) {

for (int w:adj(i)){

diGraph.addEdge(w,i);

}

}

return diGraph;

}

/**

  • 获得与v相邻的所有顶点

  • @param v

  • @return

*/

@Override

Iterableadj(int v) {

return adj[v];

}

路径问题

上面的代码还是比较简单的。在无向图中,我们研究了结点的可达性,使用深度优先算法来探究两个结点是否可达,而在有向图中,单点可达性:是否存在一条从s到达给定顶点v的有向路径。

/**

  • @author xiaohui

  • 有向图的深度优先算法

*/

public class DirectGraphDFS {

private boolean[] marked;

/**

  • 有向图的深度优先算法构造函数

  • @param diGraph

  • @param s 起点

*/

public DirectGraphDFS(DiGraph diGraph,int s) {

marked = new boolean[diGraph.V()];

dfs(diGraph,s);

}

/**

  • 深度递归算法

  • @param diGraph

  • @param v

*/

private void dfs(DiGraph diGraph, int v) {

marked[v] = true;

for (int w:diGraph.adj(v)) {

if (!marked[w]){

dfs(diGraph,v);

}

}

}

/**

  • 起点s可达到v吗

  • @param v

  • @return

*/

public boolean pathTo(int v){

return marked[v];

}

}

一文看懂javaGC这篇博客中,我们讨论了在Java虚拟机中,我们使用了可达性分析算法来判断一个对象是否已经死亡。在下图中灰色的方块代表的是可以被回收的对象。

同样,在无向图中,我们可以通过搜索来找出结点之间的路径,以及通过广度优先搜索来找出最短路径,同样,在有向图中我们同样能够做到这样。同样,在算法中,和前面的无向图之间的算法一毛一样,没什么改变。

调度问题

调度问题说起来很简单,就是先有鸡还是先有蛋的问题。一种应用广泛的模型就是给定一组任务并安排它们的执行顺序,其中顺序会有限制条件去限制(例如任务的执行的开始时间,也可能是任务的时耗)。其中最重要的一种条件叫优先级限制

优先级限制中,明确的指明了哪些任务必须在哪些任务之前完成,在有向图中,优先级限制下的调度问题等价于下面的问题:

**拓扑排序:**给定一幅有向图,将所有的顶点排序, 使得所有的有向边均从排在前面的元素指向排在后面的元素(或者说明无法做到这一点)

在下面的图是一个有向图进行拓扑排序后的结果。

在前面我们说了,必须明确任务的先后关系,那么如果如果任务关系形成了环状,比如说A要在B之前完成,B要在C之前完成,但是C要在A之前完成, 那么这个问题肯定是无解的。so,我们在进行拓扑排序之前得先判断有向图中间是否有环。(也就是说优先级限制下的调度问题等价于计算有向无环图的所有a丁丁的拓扑排序)

/**

  • 查找有向图中是否存在环

  • @author xiaohui

*/

public class DirectedCycle {

private boolean[] marked;

private int[] edgeTo;

/**

  • 有向环中所有顶点

*/

private Stack cycle;

/**

  • 顶点是否在递归调用栈上

*/

private boolean[] onStack;

public DirectedCycle(Graph graph) {

onStack = new boolean[graph.V()];

edgeTo = new int[graph.V()];

marked = new boolean[graph.V()];

for (int v=0;v<graph.V();v++){

if (!marked[v]){

dfs(graph,v);

}

}

}

private void dfs(Graph graph, int v) {

onStack[v] = true;

marked[v] = true;

for (int w:graph.adj(v)){

if (this.hasCycle()){

return;

}

else if(!marked[w]){

edgeTo[w] = v;

dfs(graph,w);

}

// 当它的邻居结点已经被标记时,且在同一个调用栈中。

else if (onStack[w]){

cycle = new Stack<>();

for (int x= v;x != w;x = edgeTo[x]){

cycle.push(x);

}

cycle.push(w);

cycle.push(v);

}

onStack[v] = false;

}

}

/**

  • 有向图中是否含有环

  • @return

*/

public boolean hasCycle(){

return cycle == null;

}

/**

  • 获得有向环中的顶点

  • @return

*/

public Iterable cycle(){

return this.cycle;

}

}

在这里我将着重解释下onStack这个数组的作用。我们可以回想一下我们在无向图中如果查找一个图中是否存在一个:我们通过查看结点的下一个结点是不是被标记的来判断的。之所以这样因为无向图是双向导通的,我们必然可以根据被标记的点回去,但是我们想想,有向图可以吗?显然是不行的,因为有向图是单向导通的。我们并不能通过已经被标记的结点又回到起点。因此,onStack的作用就在与这个地方。当某结点A邻居结点的onStack为true的时候,说明该邻居结点结点正处于递归的过程中,则该邻居结点能够通过递归得到结点A。而当onStack为false的时候则说明改邻居结点不能通过递归回到回到结点A

说完有向图中间的环的检测方法,我们就可以来讨论一下如何对有向图的顶点进行拓扑排序了。

实际上深度优先搜索也算得上是一种拓扑排序。在深度优先搜索中,我们能够保证每个顶点的访问顺序必定会符合拓扑排序的规律。根据递归的情况,下面有3中排序的规律:

  • 前序:在递归调用之前将顶点加入队列

  • 后序:在递归调用之后将顶点加入队列

  • 逆后序:在递归调用之后将顶点压入栈

有向图中基于深度优先搜索的顶点排序:

import java.util.LinkedList;

import java.util.Queue;

import java.util.Stack;

/**

  • 深度递归顶点排序

  • @author xiaohui

*/

public class DfsOrder {

private boolean[] marked;

/**

  • 前序

*/

private Queue pre;

/**

  • 后序

*/

private Queue post;

/**

  • 逆后序

*/

private Stack reversePost;

public DfsOrder(Graph graph) {

this.marked = new boolean[graph.V()];

this.pre = new LinkedList<>();

this.post = new LinkedList<>();

this.reversePost = new Stack<>();

for (int i = 0; i < graph.V(); i++) {

if(!marked[i]){

dfs(graph,i);

}

}

}

private void dfs(Graph graph, int v) {

pre.offer(v);

marked[v] = true;

for (int w:graph.adj(v)) {

if (!marked[w]){

dfs(graph,w);

}

}

post.offer(v);

reversePost.push(v);

}

public Iterable reversePost(){

return this.reversePost;

}

}

而在其中逆后序排序便是拓扑排序了。

强连通性

我们已经知道在有向图中,边是单向的。但是如果两个顶点是互相可达(类似无向图)的,就称他们为强连通的。如果一幅图中的任意两个顶点都是强连通的,就称这幅图是强连通的。

两个顶点是强连通的当且尽当它们都在一个普通的有向环中。:很简单的解释,在环中,两个结点都是互相可达的。

连通性有下面3个性质:

  1. 自反性:任意顶点和自己是强连通的

  2. 传递性:v和w是强连通,w和x是强连通的,则v和x是强连通的

  3. 对称性:v和w是强连通,则w和v也是强连通的。

强连通分量:

下面是一张有向图和它的强连通分量。每一个阴影块就是就是一个强连通分量。

以高中的生物知识来说,上面就是一个生态系统的能量流通图,在某些生物之间能量可以相互流通,这样就是一个强连通分量了,但是对于某些来说,只有对于生态系统只有输出并不会得到输入(比如说阳光),而有些只有输入没有输出(消费者? 不确定对不对,高中知识有点忘了)。

img

img

接下来我们需要去寻找强连通分量了。在无向图中,我们计算连通分量仅仅是在dfs算法中加了区区几行代码便完美地解决了连通分量的问题。那么,我们在有向图中应该怎么做呢?

在这里我们可以思考一下我们前面所说的强连通性的规律,以及我们在调度问题中如何检测环的算法来解决这个问题。

在这里有一个比较暴力的解决方法,对于某个结点V,我们得到在有向图中V可以到达的顶点,然后进行遍历,得到可到达V的顶点。然后呢,我们取他们的交集。这样就可以得到连通分量了。但是显而易见,这样的时间复杂度是O(n2)。找出可到达V的顶点的时间复杂度是O(n2),取并集的时间复杂度视数据结构而定,使用数组的话时间复杂度是O(n^2)。

总所周知,一般我们是不接受平方级别的时间复杂度的(比如说排序),而在无向图中,获得连通分量的时间复杂度仅仅为O(n),那么在有向图中间我们的解法是否可以像无向图一样美妙呢?

有一个算法叫做Kosaraju,非常的简洁,让我们来说一说这个算法的步骤,然后再来讨论它为什么要这样做?

  1. 将一幅图G进行反向也就是调用reverse()函数得到G2

  2. 将G2进行拓扑排序得到它的逆后序排序(也就是一个序列)。

  3. 然后对图进行深度优先搜索,进行深度搜索的顺序就是第2个步骤中的逆后序序列。

  4. 在构造函数中,使用同一个dfs()函数调用被访问的顶点都在同一个强连通分量中间。

接下来是代码,大家会发现,代码特别的少

/**

  • 使用Kosaraju算法的得到强通分量

  • @author xiaohui

*/

public class DfsSCC {

private boolean[] marked;

private int[] id;

private int count = 0;

public DfsSCC(DiGraph graph) {

marked = new boolean[graph.V()];

id = new int[graph.V()];

DfsOrder order = new DfsOrder(graph.reverse());

for (int s:order.reversePost()){

dfs(graph,s);

count++;

}

}

private void dfs(DiGraph graph, int v) {

marked[v] = true;

id[v] = count;

for (int w:graph.adj(v)){

if (!marked[v]){

dfs(graph,w);

}

}

}

/**

  • 返回某结点强连通的id

  • @param v

  • @return

*/

public int id(int v){

return id[v];

}

/**

  • 判断v和w是否属于强连通

  • @param v

  • @param w

  • @return

*/

public boolean stronglyConnected(int v,int w){

return id[v]==id[w];

}

/**

  • 返回强连通分量的数目

  • @return

*/

public int cout(){

return count;

}

}

上面便是寻找强连通分量的代码,接下来我们要好好的思考一下为什么能够达到这种效果。

首先我们可以很很简单的知道,每个和s强连通的顶点v都会在构造函数dfs(graph,s)被访问到。接下来我们需要思考的是,为什么构造函数中dfs(graph,s)函数所到达的任意顶点v都必然是和s强连通的。

v是dfs(graph,s)达到的某个顶点,那么原图G中必然会有一条s到v的路径,现在我们只需要找到v到s的路径即可。等价于证明G2(G通过reverse()函数得到)有一条s到v的路径。

在这里我们可以想一想,v结点在拓扑排序中会不会出现在s结点的前面?当然不会!!(如果出现在前面,在dfs(graph,s)中就不会调用dfs(graph,v),因为v结点已经被标记了。)

因此现在我们已经确定了v结点在s结点的后面, 那么代表着什么呢?代表着在G2的深度优先遍历中,dfs(graph,v)调用结束绝逼在dfs(graph,s)之前调用(栈是先进后出),那么在图G2中就分为两种情况:

  1. dfs(graph,v)在_dfs(graph,s)调用_之前结束

  2. dfs(graph,v)在_dfs(graph,s)调用结束_之前结束

因为在图G中有一条s->v的路径,在图G2中有一条v->s的路径,则第一种情况不可能出现。则第二种情况说明了G2中有一条s->v的路线。则图G中有一条v->s的路径。

下面是一张过程示意图(左边是对G2进行逆后序排序,右边是根据排序的结果进行深度递归)

1565337476466

1565337476466

最小生成树(无向图)

在说最小生成树之前,我们得先来说说加权图。下图中便是一副加权无向图。加权图和图的区别在于它的每一条边都有一个权值,那么它有什么用呢?举个栗子:图我们可以应用在网络流量方面,那么每一条边的h权值可以代表某一时刻的网络质量,那么当流量进行选择的时候,肯定会选择质量好的那一路。(实际上网络流量选择要比这还复杂,因为还要考虑到负载均衡的情况。)

1565340262377

1565340262377

那么什么是最小生成树呢?

图的生成树是它的一棵含有其所有顶点的无环连通子图

一幅加权图的**最小生成树(MST)**是它的一棵权值(树的所有边的权值之和)最小的生成树。如上图的黑色边构成的无环连通子图。在这里我们规定:

  • 只考虑连通图:试想一下,如果不连通,我们又如何知道两个顶点之间的权值呢?

  • 边的权重:边的权值可以为正数,负数,0

  • 所有边的权只能都各不相同:相同的话,最小生成树就不唯一了

下面是生成树的一些性质:

  1. 用一条边连接树中的任意两个顶点都会产生一个新的环。

  2. 从树中任意删除一条边都将会得到两棵独立的树。

如下图:

根据上面的两个性质,我们可以将图中所有的顶点切分为两个非空且不重叠的两个集合。而其中横切边是一条连接两个属于不同集合的顶点的边。

切分定理:把加权图中的所有顶点分为集合、检查横跨两个集合的所有边并识别哪条边应属于图的最小生成树。

当然,在切分中,我们会得到一条权重最小的边(这条边必然属于最小生成树的边),但是并不代表着其它的边就不属于最小生成树。

最小生成树的贪心算法

切分定理是解决最小生成树问题的所有算法的基础。而这些算法都是贪心算法的特殊情况:使用切分定理找到一条边,然后不断的切分,直到找出所有的最小生成树的所有边。

最小生成树的贪心算法:我们将含有V个顶点的加权连通图的最小生成树的边标记为黑色(初始状态边是灰色的),找到一种切分,它产生的横切边均不为黑色,然后权重最小的横切变标记为黑色。反复,直到标记了V-1条黑色边为止。

下面是一个贪心最小生成树算法的图:

因为有权无向图的边发生了改变,所以定义数据结构的代码也得发生改变。

加权无向图的数据结构

带权重的边的数据类型

/**

  • 定义一条边的数据类型结构

*/

public class Edge implements Comparable {

/**

  • 一条边的某一个顶点

*/

private final int v;

/**

  • 一条边的另外一个顶点

*/

private final int w;

/**

  • 边的权重

*/

private final double weight;

public Edge(int v, int w, double weight) {

this.v = v;

this.w = w;

this.weight = weight;

}

public double weight(){

return weight;

}

/**

  • 得到边的某一个顶点

  • @return

*/

public int either(){

return v;

}

/**

  • 通过某一个顶点得到边的另外一个顶点

  • @param vertex

  • @return

*/

public int other(int vertex){

if(vertex == w){

return v;

}else if(vertex==v){

return w;

}else{

throw new RuntimeException(“没有这一条边”);

}

}

/**

  • 边进行比较

  • @param o

  • @return

*/

@Override

public int compareTo(Edge o) {

if (this.weight() > o.weight()){

return 1;

}else if (this.weight() < o.weight()){

return -1;

}

return 0;

}

@Override

public String toString() {

return “Edge{” +

“v=” + v +

“, w=” + w +

“, weight=” + weight +

‘}’;

}

}

加权无向图的数据类型:

import java.util.ArrayList;

import java.util.List;

/**

  • 加权无向图的数据结构

*/

public class EdgeWeightedGraph {

/**

  • 顶点总数

*/

private final int V;

/**

  • 边的总数

*/

private int E;

/**

*/

private List[] adj;

public EdgeWeightedGraph(int V)

{

this.V = V;

this.E = 0;

adj = new ArrayList[V];

for (int i = 0; i < V; i++) {

adj[i] = new ArrayList();

}

}

public int V() {

return V;

}

public int E() {

return E;

}

public void addEdge(Edge e) {

int v = e.either(), w = e.other(v);

adj[v].add(e);

adj[w].add(e);

E++;

}

public Iterable adj(int v) {

return adj[v];

}

/**

  • 获取图中的所有边

  • @return

*/

public Iterable edges(){

List list = new ArrayList<>();

for (int i = 0; i < V; i++) {

for (Edge e:adj[i]){

/**

  • 如果i和j为一条边e,那么adj[i] = e;adj[j] = e;这两条边是一样的,所以我们需要去除一条边

*/

if (e.other(i)>i){

list.add(e);

}

}

}

return list;

}

}

在定义好数据结构后,我们就可以开始来说一下生成最小树的算法了

最小生成树的算法

对于最小生成树有两种常用的算法,普里姆算法(Prim算法)和克鲁斯卡尔算法(Kruskal算法)。这两种算法都是基于贪心算法的算法。首先让我们来说一下Kruskal算法,这个比较简单。

Kruskal算法

Kruskal算法很简单,首先我们得到所有的边,然后根据边的权重对边进行排序(从小到大)。然后我们将边根据顺序加入最小生成树中(必须保证加入的边不会与已经加入的边构成环)

现在这个问题就分成了两个部分:

  1. 如何排序——使用排序算法即可(使用堆排序),使用优先队列

  2. 如何检测回路。

我们来着重讨论第二点,如何检测回路

如何检测回路,我们可以使用union-find算法。首先我们说一下这个的原理:

首先我们有N个独立分散的点,如果我们将点用线段进行连接,如何避免成环。我们可以这样想,,像树一样,有根节点,如果两个结点的根节点是一样的,那么毋庸置疑,将两个结点进行连接肯定会成环。

其中,这个算法有3种写法:

  • quick-find算法。

  • quick-union算法。

  • 加权quick-union算法

我将介绍加权quick-union算法,因为这个在最最坏的情况下时间复杂度也只有lgN。

quick-union算法

/**

  • 加权quick-union算法

*/

public class WeightQuickUnionUF {

/**

  • 结点的父节点

*/

private int[] id;

/**

  • (由结点索引的)各个根节点所对应的根节点的大小

*/

private int[] sz;

/**

  • 连通分量的数量

*/

private int count;

/**

  • 进行初始化,初始化后其中每一个结点都是一个连通分量

  • 其中结点的父节点为自己本身

总结

机会是留给有准备的人,大家在求职之前应该要明确自己的态度,熟悉求职流程,做好充分的准备,把一些可预见的事情做好。

对于应届毕业生来说,校招更适合你们,因为绝大部分都不会有工作经验,企业也不会有工作经验的需求。同时,你也不需要伪造高大上的实战经验,以此让自己的简历能够脱颖而出,反倒会让面试官有所怀疑。

你在大学时期应该明确自己的发展方向,如果你在大一就确定你以后想成为Java工程师,那就不要花太多的时间去学习其他的技术语言,高数之类的,不如好好想着如何夯实Java基础。下图涵盖了应届生乃至转行过来的小白要学习的Java内容:

请转发本文支持一下

e e) {

int v = e.either(), w = e.other(v);

adj[v].add(e);

adj[w].add(e);

E++;

}

public Iterable adj(int v) {

return adj[v];

}

/**

  • 获取图中的所有边

  • @return

*/

public Iterable edges(){

List list = new ArrayList<>();

for (int i = 0; i < V; i++) {

for (Edge e:adj[i]){

/**

  • 如果i和j为一条边e,那么adj[i] = e;adj[j] = e;这两条边是一样的,所以我们需要去除一条边

*/

if (e.other(i)>i){

list.add(e);

}

}

}

return list;

}

}

在定义好数据结构后,我们就可以开始来说一下生成最小树的算法了

最小生成树的算法

对于最小生成树有两种常用的算法,普里姆算法(Prim算法)和克鲁斯卡尔算法(Kruskal算法)。这两种算法都是基于贪心算法的算法。首先让我们来说一下Kruskal算法,这个比较简单。

Kruskal算法

Kruskal算法很简单,首先我们得到所有的边,然后根据边的权重对边进行排序(从小到大)。然后我们将边根据顺序加入最小生成树中(必须保证加入的边不会与已经加入的边构成环)

现在这个问题就分成了两个部分:

  1. 如何排序——使用排序算法即可(使用堆排序),使用优先队列

  2. 如何检测回路。

我们来着重讨论第二点,如何检测回路

如何检测回路,我们可以使用union-find算法。首先我们说一下这个的原理:

首先我们有N个独立分散的点,如果我们将点用线段进行连接,如何避免成环。我们可以这样想,,像树一样,有根节点,如果两个结点的根节点是一样的,那么毋庸置疑,将两个结点进行连接肯定会成环。

其中,这个算法有3种写法:

  • quick-find算法。

  • quick-union算法。

  • 加权quick-union算法

我将介绍加权quick-union算法,因为这个在最最坏的情况下时间复杂度也只有lgN。

quick-union算法

/**

  • 加权quick-union算法

*/

public class WeightQuickUnionUF {

/**

  • 结点的父节点

*/

private int[] id;

/**

  • (由结点索引的)各个根节点所对应的根节点的大小

*/

private int[] sz;

/**

  • 连通分量的数量

*/

private int count;

/**

  • 进行初始化,初始化后其中每一个结点都是一个连通分量

  • 其中结点的父节点为自己本身

总结

机会是留给有准备的人,大家在求职之前应该要明确自己的态度,熟悉求职流程,做好充分的准备,把一些可预见的事情做好。

对于应届毕业生来说,校招更适合你们,因为绝大部分都不会有工作经验,企业也不会有工作经验的需求。同时,你也不需要伪造高大上的实战经验,以此让自己的简历能够脱颖而出,反倒会让面试官有所怀疑。

你在大学时期应该明确自己的发展方向,如果你在大一就确定你以后想成为Java工程师,那就不要花太多的时间去学习其他的技术语言,高数之类的,不如好好想着如何夯实Java基础。下图涵盖了应届生乃至转行过来的小白要学习的Java内容:

请转发本文支持一下

[外链图片转存中…(img-GCBz2uIC-1721190495079)]

[外链图片转存中…(img-88XvLhMu-1721190495080)]

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值