算法与数据结构—图

如何存储微博、微信等这些社交网络的好友关系

顶点(vertex):图中的元素我们就叫作顶点(vertex) :图中的一个顶点可以与任意其他顶点建立连接关系。我们把这种建立的关系叫作边(edge)。 (degree),就是跟顶点相连接的边的条数。 有向图:图的边有方向,比如用户A关注了用户B,但是B没关注A

  • 入度:指向点的边数,如微博的粉丝数
  • 出度:从该点指出去的边数,如微博的关注数

无向图:边没有方向 带权图:图的每条边带有权重。比如QQ记录两个好友间的亲密度。

图存储

邻接矩阵存储

邻接矩阵的底层依赖一个二维数组。有n个点,就是n行n列的矩阵,对于无向图来说,如果顶点 i 与顶点 j 之间有边,就将 A[i][j] 和 A[j][i] 标记为 1; 对于有向图来说,如果顶点 i 到顶点 j 之间,有一条箭头从顶点 i 指向顶点 j 的边,就将 A[i][j] 标记为 1。 带权重的就把1乘以权重 特点:简单、直观,但是比较浪费存储空间

邻接表存储

有点像散列表;顶点位于一个表(数组)中,每个顶点对应一条链表,链表中存储的是与这个顶点相连接的其他顶点。有向图只存指向的点。 邻接表中存储微博关注数没问题,但是获取粉丝数就比较麻烦。逆邻接表存储粉丝的点 特点:节省空间,但是耗时(也可以对链表进行改造成树来提高搜寻效率)

广度优先搜索算法(BFS)

即先查找离起始顶点最近的,然后是次近的,依次往外搜索。 从起点开始,一层一层往外推进搜索(类似波纹扩展开)。遍历所有的边,时间复杂度为O(E), E为图的边数。

深度优先搜索算法(DFS)

利用回溯算法的思想,(当发现路走不通时可以反悔,回去重新选择,而回溯的本质其实是枚举);DFS每条边最多被访问两次,一次遍历一次回退。时间复杂度为O(E), E为图的边数。

内存主要是 visited、prev 数组和递归调用栈。isited、prev 数组的大小跟顶点的个数 V 成正比,递归调用栈的最大深度不会超过顶点的个数,总的空间复杂度就是 O(V)。

import java.util.LinkedList;
import java.util.Queue;

//无向图
public class Graph {
	private int v; //顶点数
	private LinkedList<Integer> adj[]; //邻接表,存储相邻节点
	boolean found = false;
	
	public Graph(int v) {
		this.v = v;
		adj = new LinkedList[v];
		for (int i = 0; i < v; i++) {
			adj[i] = new LinkedList<>();
		}
	}
	
	public void addEdge(int s, int t) {
		adj[s].add(t);
		adj[t].add(s);
	}
	
	//广度优先搜索, 从顶点s开始到t的路径
	public void bfs(int s, int t) {
		if (s == t) return;
		
		//记录已访问点解, visited[s]=true;表示s已访问过
		boolean[] visited = new boolean[v];		
		Queue<Integer> queue = new LinkedList<>();	//记录已访问,但是还需要通过该点往后搜索的点
		queue.add(s);
		int[] prev = new int[v];	//用于记录到达该节点的来源节点
		
		for (int i = 0; i < v; i++) {//初始化
			prev[i] = -1;
		}
		
		while (queue.size() != 0) {
			int w = queue.poll();	//从待访问的队列中取出一个点
			for (int i = 0; i < adj[w].size(); i++) {
				int q = adj[w].get(i);	//遍历w点的邻接点表
				if (!visited[q]) {
					
					prev[q] = w;	//记录来到p点的前一个点w
					if (q == t) {
						//找到时,打印出来
						print(prev, s, t);
						return;
					}
					visited[q] = true;	//更新q点的访问状态
					queue.add(q);
				}
			}
		}
		
	}
	
	//深度优先遍历
	public void dfs(int s, int t) {
		found = false;
		boolean[] visited = new boolean[v];
		int[] prev = new int[v];	//用于记录到达该节点的来源节点
		for (int i = 0; i < v; i++) {//初始化
			prev[i] = -1;
		}
		recurDfs(s, t, visited, prev);
		print(prev, s, t);
	}
	
	//递归遍历(深度搜索)
	private void recurDfs(int w, int t, boolean[] visited, int[] prev) {
		if (found) return;	//退出递归条件
		if (w == t) {
			found = true;
		}
		visited[w] = true;
		for (int i = 0; i < adj[w].size(); i++) {
			int q = adj[w].get(i);
			if (!visited[q]) {
				prev[q] = w;
				recurDfs(q, t, visited, prev);
			}
		}
	}
	
	//递归打印s到t的路径
	private void print(int[] prev, int s, int t) {
		if (s == t) {
			System.out.print(s);
			return;
		}
		print(prev, s, prev[t]);
		System.out.print(", ");
		System.out.print(t);
	}
	
}

拓扑排序

多个任务,有的任务之间有依赖关系,怎么排序 任务A依赖B,B依赖C,D依赖B,排序为:CBAD或者CBDA。 通过有向图来建模,任务对应一个顶点,如果任务A依赖B,则顶点A指向顶点B。最终建成一个有向无环图,有环则拓扑排序无法工作。如果顶点的入度为0,则表示顶点没有依赖,可以先执行。 拓扑排序就是基于有向无环图的一个算法

Kahn 算法

际上用的是贪心算法思想 先从图中,找出一个入度为 0 的顶点(输出),并且把这个顶点从图中删除,并且此点可达的顶点入度减1,重复如此,直到所有的顶点都被输出。 怎么计算顶点的入度(以邻接表存储): 遍历邻接表统计。 时间复杂度O(V+E), V为顶点数,E为边数

import java.util.LinkedList;
//拓扑排序
public class Topology {
	public static void main(String[] args) {
		Topology t = new Topology();
		t.test();
	}
	
	public void test() {
		//{0,1,2,3,4,5,6,7,8} 8个任务
		Graph g = new Graph(9);
		//添加依赖关系
		g.addEdge(5, 2);
		g.addEdge(0, 2);
		g.addEdge(8, 2);
		g.addEdge(2, 6);
		g.addEdge(6, 4);
		g.addEdge(1, 3);
		g.addEdge(7, 3);
		g.addEdge(3, 5);
		topoSortByKahn(9, g);
	}
	
	//拓扑排序,Kahn算法  0->1->7->8->3->5->2->6->4
	public void topoSortByKahn(int v, Graph g) {
		//用于统计每个点的入度
		int[] inDegree = new int[v];
		for (int i = 0; i < v; i++) {
			//统计每个点的入度
			for (int j = 0; j < g.adj[i].size(); j++) {
				int w = g.adj[i].get(j);
				inDegree[w]++;
			}
		}
		
		//把入度为零的点放入队列
		LinkedList<Integer> queue = new LinkedList<>();
		for (int i = 0; i < v; i++) {
			if (inDegree[i] == 0)
				queue.add(i);
		}
		
		while (!queue.isEmpty()) {
			//删除入度为0的点
			int i = queue.remove();
			System.out.print("->" + i);
			//同时,把指向的点入度减1
			for (int j = 0; j < g.adj[i].size(); j++) {
				int w = g.adj[i].get(j);
				inDegree[w]--;
				if (inDegree[w] == 0)
					queue.add(w);
			}
		}
	}
		
	public class Graph {
		private int v;	//顶点数
		private LinkedList<Integer> adj[]; //邻接表实现图
		
		public Graph(int v) {
			this.v = v;
			adj = new LinkedList[v];	//初始化每个顶点
			for (int i = 0; i < v; i++) {
				adj[i] = new LinkedList<>();
			}
		}
		
		//任务s先于t执行
		//添加一个依赖(边)顶点s指向t,t的入度增加1,
		public void addEdge(int s, int t) {
			adj[s].add(t);
		}
		
	}
}

图的中环检测: 记录访问过的点,如果再次出现,说明出现了环。

最短路径

深度优先搜索和广度优先搜索主要应用无无权图的搜索。

要找出起点 s 到终点 t 的最短路径,最简单的方法是,过回溯穷举所有从 s 到达 t 的不同路径,找出最短的。但是回溯算法效太低。指数级。Dijkstra 算法在此基础之上,利用动态规划的思想,对回溯搜索进行了剪枝,对回溯搜索进行了剪枝,继续往外扩展搜索。动态规划相较于回溯搜索,只是换了一个实现思路,但它实际上也考察到了所有从起点到终点的路线,才能得到最优解。

地图计算线路:最短路线、最少用时和最少红绿灯。 解决问题需要先建模,即把问题抽象成具体的数据结构。

次优路径

Dijkstra 最短路径算法的执行耗时会很多。超级大的地图和海量的寻路请求,算法的执行效率太低。实际上,像出行路线规划、游戏寻路,一般都是在兼顾计算效率下的次优解。

A* 算法

A* 算法是对 Dijkstra 算法的优化和改造 Dijkstra 算法有点儿类似 BFS 算法,它每次找到跟起点最近的顶点,往外扩展。

要找出起点 s 到终点 t 的最短路径,最简单的方法是,过回溯穷举所有从 s 到达 t 的不同路径,找出最短的。但是回溯算法效太低。指数级。Dijkstra 算法在此基础之上,利用动态规划的思想,对回溯搜索进行了剪枝,对回溯搜索进行了剪枝,继续往外扩展搜索。动态规划相较于回溯搜索,只是换了一个实现思路,但它实际上也考察到了所有从起点到终点的路线,才能得到最优解。

Dijkstra 优先最近的点,层层外扩,甚至到能那个点在终点的反方向;而A* 算法是通过顶点到起点的距离,和顶点到终点的距离之和小的优先考察(还可以放优先级队列)。 由于顶点到终点的确切距离位置,我们近似评估计算,即用曼哈顿距离(即两点间横纵坐标差之和),之所以用它是因为欧几里得距离计算平方根开销大。

//曼哈顿距离计算
int hManhattan(Vertex v1, Vertex v2) { // Vertex 表示顶点,后面有定义
  return Math.abs(v1.x - v2.x) + Math.abs(v1.y - v2.y);
}

A* 算法是一旦遍历到终点就结束。Dijkstra 还是要考察所有的点和线路。

更新时间: