mirror of
https://github.com/cheetahlou/CategoryResourceRepost.git
synced 2025-11-17 14:43:42 +08:00
del
This commit is contained in:
186
极客时间专栏/geek/数据结构与算法之美/高级篇/43 | 拓扑排序:如何确定代码源文件的编译依赖关系?.md
Normal file
186
极客时间专栏/geek/数据结构与算法之美/高级篇/43 | 拓扑排序:如何确定代码源文件的编译依赖关系?.md
Normal file
@@ -0,0 +1,186 @@
|
||||
<audio id="audio" title="43 | 拓扑排序:如何确定代码源文件的编译依赖关系?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/44/22/44e0749ca489439bdf2e45385f17f422.mp3"></audio>
|
||||
|
||||
从今天开始,我们就进入了专栏的高级篇。相对基础篇,高级篇涉及的知识点,都比较零散,不是太系统。所以,我会围绕一个实际软件开发的问题,在阐述具体解决方法的过程中,将涉及的知识点给你详细讲解出来。
|
||||
|
||||
所以,相较于基础篇“**开篇问题-知识讲解-回答开篇-总结-课后思考**”这样的文章结构,高级篇我稍作了些改变,大致分为这样几个部分:“**问题阐述-算法解析-总结引申-课后思考**”。
|
||||
|
||||
好了,现在,我们就进入高级篇的第一节,如何确定代码源文件的编译依赖关系?
|
||||
|
||||
我们知道,一个完整的项目往往会包含很多代码源文件。编译器在编译整个项目的时候,需要按照依赖关系,依次编译每个源文件。比如,A.cpp依赖B.cpp,那在编译的时候,编译器需要先编译B.cpp,才能编译A.cpp。
|
||||
|
||||
编译器通过分析源文件或者程序员事先写好的编译配置文件(比如Makefile文件),来获取这种局部的依赖关系。**那编译器又该如何通过源文件两两之间的局部依赖关系,确定一个全局的编译顺序呢?**
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/52/3b/5247b6639e98419a1963cecd8f12713b.jpg" alt="">
|
||||
|
||||
## 算法解析
|
||||
|
||||
这个问题的解决思路与“图”这种数据结构的一个经典算法“拓扑排序算法”有关。那什么是拓扑排序呢?这个概念很好理解,我们先来看一个生活中的拓扑排序的例子。
|
||||
|
||||
我们在穿衣服的时候都有一定的顺序,我们可以把这种顺序想成,衣服与衣服之间有一定的依赖关系。比如说,你必须先穿袜子才能穿鞋,先穿内裤才能穿秋裤。假设我们现在有八件衣服要穿,它们之间的两两依赖关系我们已经很清楚了,那如何安排一个穿衣序列,能够满足所有的两两之间的依赖关系?
|
||||
|
||||
这就是个拓扑排序问题。从这个例子中,你应该能想到,在很多时候,拓扑排序的序列并不是唯一的。你可以看我画的这幅图,我找到了好几种满足这些局部先后关系的穿衣序列。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/c2/bd/c26d0f472d9a607c0c4eb688c01959bd.jpg" alt="">
|
||||
|
||||
弄懂了这个生活中的例子,开篇的关于编译顺序的问题,你应该也有思路了。开篇问题跟这个问题的模型是一样的,也可以抽象成一个拓扑排序问题。
|
||||
|
||||
拓扑排序的原理非常简单,我们的重点应该放到拓扑排序的实现上面。
|
||||
|
||||
我前面多次讲过,算法是构建在具体的数据结构之上的。针对这个问题,我们先来看下,如何将问题背景抽象成具体的数据结构?
|
||||
|
||||
我们可以把源文件与源文件之间的依赖关系,抽象成一个有向图。每个源文件对应图中的一个顶点,源文件之间的依赖关系就是顶点之间的边。
|
||||
|
||||
如果a先于b执行,也就是说b依赖于a,那么就在顶点a和顶点b之间,构建一条从a指向b的边。而且,这个图不仅要是有向图,还要是一个有向无环图,也就是不能存在像a->b->c->a这样的循环依赖关系。因为图中一旦出现环,拓扑排序就无法工作了。实际上,拓扑排序本身就是基于有向无环图的一个算法。
|
||||
|
||||
```
|
||||
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<>();
|
||||
}
|
||||
}
|
||||
|
||||
public void addEdge(int s, int t) { // s先于t,边s->t
|
||||
adj[s].add(t);
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
数据结构定义好了,现在,我们来看,**如何在这个有向无环图上,实现拓扑排序**?
|
||||
|
||||
拓扑排序有两种实现方法,都不难理解。它们分别是**Kahn算法**和**DFS深度优先搜索算法**。我们依次来看下它们都是怎么工作的。
|
||||
|
||||
### 1.Kahn算法
|
||||
|
||||
Kahn算法实际上用的是贪心算法思想,思路非常简单、好懂。
|
||||
|
||||
定义数据结构的时候,如果s需要先于t执行,那就添加一条s指向t的边。所以,如果某个顶点入度为0, 也就表示,没有任何顶点必须先于这个顶点执行,那么这个顶点就可以执行了。
|
||||
|
||||
我们先从图中,找出一个入度为0的顶点,将其输出到拓扑排序的结果序列中(对应代码中就是把它打印出来),并且把这个顶点从图中删除(也就是把这个顶点可达的顶点的入度都减1)。我们循环执行上面的过程,直到所有的顶点都被输出。最后输出的序列,就是满足局部依赖关系的拓扑排序。
|
||||
|
||||
我把Kahn算法用代码实现了一下,你可以结合着文字描述一块看下。不过,你应该能发现,这段代码实现更有技巧一些,并没有真正删除顶点的操作。代码中有详细的注释,你自己来看,我就不多解释了。
|
||||
|
||||
```
|
||||
public void topoSortByKahn() {
|
||||
int[] inDegree = new int[v]; // 统计每个顶点的入度
|
||||
for (int i = 0; i < v; ++i) {
|
||||
for (int j = 0; j < adj[i].size(); ++j) {
|
||||
int w = adj[i].get(j); // i->w
|
||||
inDegree[w]++;
|
||||
}
|
||||
}
|
||||
LinkedList<Integer> queue = new LinkedList<>();
|
||||
for (int i = 0; i < v; ++i) {
|
||||
if (inDegree[i] == 0) queue.add(i);
|
||||
}
|
||||
while (!queue.isEmpty()) {
|
||||
int i = queue.remove();
|
||||
System.out.print("->" + i);
|
||||
for (int j = 0; j < adj[i].size(); ++j) {
|
||||
int k = adj[i].get(j);
|
||||
inDegree[k]--;
|
||||
if (inDegree[k] == 0) queue.add(k);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
### 2.DFS算法
|
||||
|
||||
图上的深度优先搜索我们前面已经讲过了,实际上,拓扑排序也可以用深度优先搜索来实现。不过这里的名字要稍微改下,更加确切的说法应该是深度优先遍历,遍历图中的所有顶点,而非只是搜索一个顶点到另一个顶点的路径。
|
||||
|
||||
关于这个算法的实现原理,我先把代码贴在下面,下面给你具体解释。
|
||||
|
||||
```
|
||||
public void topoSortByDFS() {
|
||||
// 先构建逆邻接表,边s->t表示,s依赖于t,t先于s
|
||||
LinkedList<Integer> inverseAdj[] = new LinkedList[v];
|
||||
for (int i = 0; i < v; ++i) { // 申请空间
|
||||
inverseAdj[i] = new LinkedList<>();
|
||||
}
|
||||
for (int i = 0; i < v; ++i) { // 通过邻接表生成逆邻接表
|
||||
for (int j = 0; j < adj[i].size(); ++j) {
|
||||
int w = adj[i].get(j); // i->w
|
||||
inverseAdj[w].add(i); // w->i
|
||||
}
|
||||
}
|
||||
boolean[] visited = new boolean[v];
|
||||
for (int i = 0; i < v; ++i) { // 深度优先遍历图
|
||||
if (visited[i] == false) {
|
||||
visited[i] = true;
|
||||
dfs(i, inverseAdj, visited);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void dfs(
|
||||
int vertex, LinkedList<Integer> inverseAdj[], boolean[] visited) {
|
||||
for (int i = 0; i < inverseAdj[vertex].size(); ++i) {
|
||||
int w = inverseAdj[vertex].get(i);
|
||||
if (visited[w] == true) continue;
|
||||
visited[w] = true;
|
||||
dfs(w, inverseAdj, visited);
|
||||
} // 先把vertex这个顶点可达的所有顶点都打印出来之后,再打印它自己
|
||||
System.out.print("->" + vertex);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这个算法包含两个关键部分。
|
||||
|
||||
第一部分是**通过邻接表构造逆邻接表**。邻接表中,边s->t表示s先于t执行,也就是t要依赖s。在逆邻接表中,边s->t表示s依赖于t,s后于t执行。为什么这么转化呢?这个跟我们这个算法的实现思想有关。
|
||||
|
||||
第二部分是这个算法的核心,也就是**递归处理每个顶点**。对于顶点vertex来说,我们先输出它可达的所有顶点,也就是说,先把它依赖的所有的顶点输出了,然后再输出自己。
|
||||
|
||||
到这里,用Kahn算法和DFS算法求拓扑排序的原理和代码实现都讲完了。我们来看下,**这两个算法的时间复杂度分别是多少呢?**
|
||||
|
||||
从Kahn代码中可以看出来,每个顶点被访问了一次,每个边也都被访问了一次,所以,Kahn算法的时间复杂度就是O(V+E)(V表示顶点个数,E表示边的个数)。
|
||||
|
||||
DFS算法的时间复杂度我们之前分析过。每个顶点被访问两次,每条边都被访问一次,所以时间复杂度也是O(V+E)。
|
||||
|
||||
注意,这里的图可能不是连通的,有可能是有好几个不连通的子图构成,所以,E并不一定大于V,两者的大小关系不确定。所以,在表示时间复杂度的时候,V、E都要考虑在内。
|
||||
|
||||
## 总结引申
|
||||
|
||||
在基础篇中,关于“图”,我们讲了图的定义和存储、图的广度和深度优先搜索。今天,我们又讲了一个关于图的算法,拓扑排序。
|
||||
|
||||
拓扑排序应用非常广泛,解决的问题的模型也非常一致。凡是需要通过局部顺序来推导全局顺序的,一般都能用拓扑排序来解决。除此之外,拓扑排序还能检测图中环的存在。对于Kahn算法来说,如果最后输出出来的顶点个数,少于图中顶点个数,图中还有入度不是0的顶点,那就说明,图中存在环。
|
||||
|
||||
关于图中环的检测,我们在[递归](https://time.geekbang.org/column/article/41440)那一节讲过一个例子,在查找最终推荐人的时候,可能会因为脏数据,造成存在循环推荐,比如,用户A推荐了用户B,用户B推荐了用户C,用户C又推荐了用户A。如何避免这种脏数据导致的无限递归?这个问题,我当时留给你思考了,现在是时候解答了。
|
||||
|
||||
实际上,这就是环的检测问题。因为我们每次都只是查找一个用户的最终推荐人,所以,我们并不需要动用复杂的拓扑排序算法,而只需要记录已经访问过的用户ID,当用户ID第二次被访问的时候,就说明存在环,也就说明存在脏数据。
|
||||
|
||||
```
|
||||
HashSet<Integer> hashTable = new HashSet<>(); // 保存已经访问过的actorId
|
||||
long findRootReferrerId(long actorId) {
|
||||
if (hashTable.contains(actorId)) { // 存在环
|
||||
return;
|
||||
}
|
||||
hashTable.add(actorId);
|
||||
Long referrerId =
|
||||
select referrer_id from [table] where actor_id = actorId;
|
||||
if (referrerId == null) return actorId;
|
||||
return findRootReferrerId(referrerId);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
如果把这个问题改一下,我们想要知道,数据库中的所有用户之间的推荐关系了,有没有存在环的情况。这个问题,就需要用到拓扑排序算法了。我们把用户之间的推荐关系,从数据库中加载到内存中,然后构建成今天讲的这种有向图数据结构,再利用拓扑排序,就可以快速检测出是否存在环了。
|
||||
|
||||
## 课后思考
|
||||
|
||||
<li>
|
||||
在今天的讲解中,我们用图表示依赖关系的时候,如果a先于b执行,我们就画一条从a到b的有向边;反过来,如果a先于b,我们画一条从b到a的有向边,表示b依赖a,那今天讲的Kahn算法和DFS算法还能否正确工作呢?如果不能,应该如何改造一下呢?
|
||||
</li>
|
||||
<li>
|
||||
我们今天讲了两种拓扑排序算法的实现思路,Kahn算法和DFS深度优先搜索算法,如果换做BFS广度优先搜索算法,还可以实现吗?
|
||||
</li>
|
||||
|
||||
欢迎留言和我分享,也欢迎点击“请朋友读”,把今天的内容分享给你的好友,和他一起讨论、学习。
|
||||
204
极客时间专栏/geek/数据结构与算法之美/高级篇/44 | 最短路径:地图软件是如何计算出最优出行路径的?.md
Normal file
204
极客时间专栏/geek/数据结构与算法之美/高级篇/44 | 最短路径:地图软件是如何计算出最优出行路径的?.md
Normal file
@@ -0,0 +1,204 @@
|
||||
<audio id="audio" title="44 | 最短路径:地图软件是如何计算出最优出行路径的?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/f3/45/f321bf8f9180b31bb0e7ec97cd9a9445.mp3"></audio>
|
||||
|
||||
基础篇的时候,我们学习了图的两种搜索算法,深度优先搜索和广度优先搜索。这两种算法主要是针对无权图的搜索算法。针对有权图,也就是图中的每条边都有一个权重,我们该如何计算两点之间的最短路径(经过的边的权重和最小)呢?今天,我就从地图软件的路线规划问题讲起,带你看看常用的**最短路径算法**(Shortest Path Algorithm)。
|
||||
|
||||
像Google地图、百度地图、高德地图这样的地图软件,我想你应该经常使用吧?如果想从家开车到公司,你只需要输入起始、结束地址,地图就会给你规划一条最优出行路线。这里的最优,有很多种定义,比如最短路线、最少用时路线、最少红绿灯路线等等。**作为一名软件开发工程师,你是否思考过,地图软件的最优路线是如何计算出来的吗?底层依赖了什么算法呢?**
|
||||
|
||||
## 算法解析
|
||||
|
||||
我们刚提到的最优问题包含三个:最短路线、最少用时和最少红绿灯。我们先解决最简单的,最短路线。
|
||||
|
||||
解决软件开发中的实际问题,最重要的一点就是**建模**,也就是将复杂的场景抽象成具体的数据结构。针对这个问题,我们该如何抽象成数据结构呢?
|
||||
|
||||
我们之前也提到过,图这种数据结构的表达能力很强,显然,把地图抽象成图最合适不过了。我们把每个岔路口看作一个顶点,岔路口与岔路口之间的路看作一条边,路的长度就是边的权重。如果路是单行道,我们就在两个顶点之间画一条有向边;如果路是双行道,我们就在两个顶点之间画两条方向不同的边。这样,整个地图就被抽象成一个有向有权图。
|
||||
|
||||
具体的代码实现,我放在下面了。于是,我们要求解的问题就转化为,在一个有向有权图中,求两个顶点间的最短路径。
|
||||
|
||||
```
|
||||
public class Graph { // 有向有权图的邻接表表示
|
||||
private LinkedList<Edge> adj[]; // 邻接表
|
||||
private int v; // 顶点个数
|
||||
|
||||
public Graph(int v) {
|
||||
this.v = v;
|
||||
this.adj = new LinkedList[v];
|
||||
for (int i = 0; i < v; ++i) {
|
||||
this.adj[i] = new LinkedList<>();
|
||||
}
|
||||
}
|
||||
|
||||
public void addEdge(int s, int t, int w) { // 添加一条边
|
||||
this.adj[s].add(new Edge(s, t, w));
|
||||
}
|
||||
|
||||
private class Edge {
|
||||
public int sid; // 边的起始顶点编号
|
||||
public int tid; // 边的终止顶点编号
|
||||
public int w; // 权重
|
||||
public Edge(int sid, int tid, int w) {
|
||||
this.sid = sid;
|
||||
this.tid = tid;
|
||||
this.w = w;
|
||||
}
|
||||
}
|
||||
// 下面这个类是为了dijkstra实现用的
|
||||
private class Vertex {
|
||||
public int id; // 顶点编号ID
|
||||
public int dist; // 从起始顶点到这个顶点的距离
|
||||
public Vertex(int id, int dist) {
|
||||
this.id = id;
|
||||
this.dist = dist;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
想要解决这个问题,有一个非常经典的算法,最短路径算法,更加准确地说,是**单源最短路径算法**(一个顶点到一个顶点)。提到最短路径算法,最出名的莫过于Dijkstra算法了。所以,我们现在来看,Dijkstra算法是怎么工作的。
|
||||
|
||||
这个算法的原理稍微有点儿复杂,单纯的文字描述,不是很好懂。所以,我还是结合代码来讲解。
|
||||
|
||||
```
|
||||
// 因为Java提供的优先级队列,没有暴露更新数据的接口,所以我们需要重新实现一个
|
||||
private class PriorityQueue { // 根据vertex.dist构建小顶堆
|
||||
private Vertex[] nodes;
|
||||
private int count;
|
||||
public PriorityQueue(int v) {
|
||||
this.nodes = new Vertex[v+1];
|
||||
this.count = v;
|
||||
}
|
||||
public Vertex poll() { // TODO: 留给读者实现... }
|
||||
public void add(Vertex vertex) { // TODO: 留给读者实现...}
|
||||
// 更新结点的值,并且从下往上堆化,重新符合堆的定义。时间复杂度O(logn)。
|
||||
public void update(Vertex vertex) { // TODO: 留给读者实现...}
|
||||
public boolean isEmpty() { // TODO: 留给读者实现...}
|
||||
}
|
||||
|
||||
public void dijkstra(int s, int t) { // 从顶点s到顶点t的最短路径
|
||||
int[] predecessor = new int[this.v]; // 用来还原最短路径
|
||||
Vertex[] vertexes = new Vertex[this.v];
|
||||
for (int i = 0; i < this.v; ++i) {
|
||||
vertexes[i] = new Vertex(i, Integer.MAX_VALUE);
|
||||
}
|
||||
PriorityQueue queue = new PriorityQueue(this.v);// 小顶堆
|
||||
boolean[] inqueue = new boolean[this.v]; // 标记是否进入过队列
|
||||
vertexes[s].dist = 0;
|
||||
queue.add(vertexes[s]);
|
||||
inqueue[s] = true;
|
||||
while (!queue.isEmpty()) {
|
||||
Vertex minVertex= queue.poll(); // 取堆顶元素并删除
|
||||
if (minVertex.id == t) break; // 最短路径产生了
|
||||
for (int i = 0; i < adj[minVertex.id].size(); ++i) {
|
||||
Edge e = adj[minVertex.id].get(i); // 取出一条minVetex相连的边
|
||||
Vertex nextVertex = vertexes[e.tid]; // minVertex-->nextVertex
|
||||
if (minVertex.dist + e.w < nextVertex.dist) { // 更新next的dist
|
||||
nextVertex.dist = minVertex.dist + e.w;
|
||||
predecessor[nextVertex.id] = minVertex.id;
|
||||
if (inqueue[nextVertex.id] == true) {
|
||||
queue.update(nextVertex); // 更新队列中的dist值
|
||||
} else {
|
||||
queue.add(nextVertex);
|
||||
inqueue[nextVertex.id] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 输出最短路径
|
||||
System.out.print(s);
|
||||
print(s, t, predecessor);
|
||||
}
|
||||
|
||||
private void print(int s, int t, int[] predecessor) {
|
||||
if (s == t) return;
|
||||
print(s, predecessor[t], predecessor);
|
||||
System.out.print("->" + t);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
我们用vertexes数组,记录从起始顶点到每个顶点的距离(dist)。起初,我们把所有顶点的dist都初始化为无穷大(也就是代码中的Integer.MAX_VALUE)。我们把起始顶点的dist值初始化为0,然后将其放到优先级队列中。
|
||||
|
||||
我们从优先级队列中取出dist最小的顶点minVertex,然后考察这个顶点可达的所有顶点(代码中的nextVertex)。如果minVertex的dist值加上minVertex与nextVertex之间边的权重w小于nextVertex当前的dist值,也就是说,存在另一条更短的路径,它经过minVertex到达nextVertex。那我们就把nextVertex的dist更新为minVertex的dist值加上w。然后,我们把nextVertex加入到优先级队列中。重复这个过程,直到找到终止顶点t或者队列为空。
|
||||
|
||||
以上就是Dijkstra算法的核心逻辑。除此之外,代码中还有两个额外的变量,predecessor数组和inqueue数组。
|
||||
|
||||
predecessor数组的作用是为了还原最短路径,它记录每个顶点的前驱顶点。最后,我们通过递归的方式,将这个路径打印出来。打印路径的print递归代码我就不详细讲了,这个跟我们在图的搜索中讲的打印路径方法一样。如果不理解的话,你可以回过头去看下那一节。
|
||||
|
||||
inqueue数组是为了避免将一个顶点多次添加到优先级队列中。我们更新了某个顶点的dist值之后,如果这个顶点已经在优先级队列中了,就不要再将它重复添加进去了。
|
||||
|
||||
看完了代码和文字解释,你可能还是有点懵,那我就举个例子,再给你解释一下。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/e2/a9/e20907173c458fac741e556c947bb9a9.jpg" alt="">
|
||||
|
||||
理解了Dijkstra的原理和代码实现,我们来看下,**Dijkstra算法的时间复杂度是多少?**
|
||||
|
||||
在刚刚的代码实现中,最复杂就是while循环嵌套for循环那部分代码了。while循环最多会执行V次(V表示顶点的个数),而内部的for循环的执行次数不确定,跟每个顶点的相邻边的个数有关,我们分别记作E0,E1,E2,……,E(V-1)。如果我们把这V个顶点的边都加起来,最大也不会超过图中所有边的个数E(E表示边的个数)。
|
||||
|
||||
for循环内部的代码涉及从优先级队列取数据、往优先级队列中添加数据、更新优先级队列中的数据,这样三个主要的操作。我们知道,优先级队列是用堆来实现的,堆中的这几个操作,时间复杂度都是O(logV)(堆中的元素个数不会超过顶点的个数V)。
|
||||
|
||||
所以,综合这两部分,再利用乘法原则,整个代码的时间复杂度就是O(E*logV)。
|
||||
|
||||
弄懂了Dijkstra算法,我们再来回答之前的问题,如何计算最优出行路线?
|
||||
|
||||
从理论上讲,用Dijkstra算法可以计算出两点之间的最短路径。但是,你有没有想过,对于一个超级大地图来说,岔路口、道路都非常多,对应到图这种数据结构上来说,就有非常多的顶点和边。如果为了计算两点之间的最短路径,在一个超级大图上动用Dijkstra算法,遍历所有的顶点和边,显然会非常耗时。那我们有没有什么优化的方法呢?
|
||||
|
||||
做工程不像做理论,一定要给出个最优解。理论上算法再好,如果执行效率太低,也无法应用到实际的工程中。**对于软件开发工程师来说,我们经常要根据问题的实际背景,对解决方案权衡取舍。类似出行路线这种工程上的问题,我们没有必要非得求出个绝对最优解。很多时候,为了兼顾执行效率,我们只需要计算出一个可行的次优解就可以了**。
|
||||
|
||||
有了这个原则,你能想出刚刚那个问题的优化方案吗?
|
||||
|
||||
虽然地图很大,但是两点之间的最短路径或者说较好的出行路径,并不会很“发散”,只会出现在两点之间和两点附近的区块内。所以我们可以在整个大地图上,划出一个小的区块,这个小区块恰好可以覆盖住两个点,但又不会很大。我们只需要在这个小区块内部运行Dijkstra算法,这样就可以避免遍历整个大图,也就大大提高了执行效率。
|
||||
|
||||
不过你可能会说了,如果两点距离比较远,从北京海淀区某个地点,到上海黄浦区某个地点,那上面的这种处理方法,显然就不工作了,毕竟覆盖北京和上海的区块并不小。
|
||||
|
||||
我给你点提示,你可以现在打开地图App,缩小放大一下地图,看下地图上的路线有什么变化,然后再思考,这个问题该怎么解决。
|
||||
|
||||
对于这样两点之间距离较远的路线规划,我们可以把北京海淀区或者北京看作一个顶点,把上海黄浦区或者上海看作一个顶点,先规划大的出行路线。比如,如何从北京到上海,必须要经过某几个顶点,或者某几条干道,然后再细化每个阶段的小路线。
|
||||
|
||||
这样,最短路径问题就解决了。我们再来看另外两个问题,最少时间和最少红绿灯。
|
||||
|
||||
前面讲最短路径的时候,每条边的权重是路的长度。在计算最少时间的时候,算法还是不变,我们只需要把边的权重,从路的长度变成经过这段路所需要的时间。不过,这个时间会根据拥堵情况时刻变化。如何计算车通过一段路的时间呢?这是一个蛮有意思的问题,你可以自己思考下。
|
||||
|
||||
每经过一条边,就要经过一个红绿灯。关于最少红绿灯的出行方案,实际上,我们只需要把每条边的权值改为1即可,算法还是不变,可以继续使用前面讲的Dijkstra算法。不过,边的权值为1,也就相当于无权图了,我们还可以使用之前讲过的广度优先搜索算法。因为我们前面讲过,广度优先搜索算法计算出来的两点之间的路径,就是两点的最短路径。
|
||||
|
||||
不过,这里给出的所有方案都非常粗糙,只是为了给你展示,如何结合实际的场景,灵活地应用算法,让算法为我们所用,真实的地图软件的路径规划,要比这个复杂很多。而且,比起Dijkstra算法,地图软件用的更多的是类似A*的启发式搜索算法,不过也是在Dijkstra算法上的优化罢了,我们后面会讲到,这里暂且不展开。
|
||||
|
||||
## 总结引申
|
||||
|
||||
今天,我们学习了一种非常重要的图算法,**Dijkstra最短路径算法**。实际上,最短路径算法还有很多,比如Bellford算法、Floyd算法等等。如果感兴趣,你可以自己去研究。
|
||||
|
||||
关于Dijkstra算法,我只讲了原理和代码实现。对于正确性,我没有去证明。之所以这么做,是因为证明过程会涉及比较复杂的数学推导。这个并不是我们的重点,你只要掌握这个算法的思路就可以了。
|
||||
|
||||
这些算法实现思路非常经典,掌握了这些思路,我们可以拿来指导、解决其他问题。比如Dijkstra这个算法的核心思想,就可以拿来解决下面这个看似完全不相关的问题。这个问题是我之前工作中遇到的真实的问题,为了在较短的篇幅里把问题介绍清楚,我对背景做了一些简化。
|
||||
|
||||
我们有一个翻译系统,只能针对单个词来做翻译。如果要翻译一整个句子,我们需要将句子拆成一个一个的单词,再丢给翻译系统。针对每个单词,翻译系统会返回一组可选的翻译列表,并且针对每个翻译打一个分,表示这个翻译的可信程度。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/91/67/91b68e47e0d8521cb3ce66bb9827c767.jpg" alt="">
|
||||
|
||||
针对每个单词,我们从可选列表中,选择其中一个翻译,组合起来就是整个句子的翻译。每个单词的翻译的得分之和,就是整个句子的翻译得分。随意搭配单词的翻译,会得到一个句子的不同翻译。针对整个句子,我们希望计算出得分最高的前k个翻译结果,你会怎么编程来实现呢?
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/76/53/769cab20f6a50c0b7a4ed571c9f28a53.jpg" alt="">
|
||||
|
||||
当然,最简单的办法还是借助回溯算法,穷举所有的排列组合情况,然后选出得分最高的前k个翻译结果。但是,这样做的时间复杂度会比较高,是O(m^n),其中,m表示平均每个单词的可选翻译个数,n表示一个句子中包含多少个单词。这个解决方案,你可以当作回溯算法的练习题,自己编程实现一下,我就不多说了。
|
||||
|
||||
实际上,这个问题可以借助Dijkstra算法的核心思想,非常高效地解决。每个单词的可选翻译是按照分数从大到小排列的,所以$a_{0}b_{0}c_{0}$肯定是得分最高组合结果。我们把$a_{0}b_{0}c_{0}$及得分作为一个对象,放入到优先级队列中。
|
||||
|
||||
我们每次从优先级队列中取出一个得分最高的组合,并基于这个组合进行扩展。扩展的策略是每个单词的翻译分别替换成下一个单词的翻译。比如$a_{0}b_{0}c_{0}$扩展后,会得到三个组合,$a_{1}b_{0}c_{0}$、$a_{0}b_{1}c_{0}$、$a_{0}b_{0}c_{1}$。我们把扩展之后的组合,加到优先级队列中。重复这个过程,直到获取到k个翻译组合或者队列为空。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/e7/6c/e71f307ca575d364ba2b23a022779f6c.jpg" alt="">
|
||||
|
||||
我们来看,这种实现思路的时间复杂度是多少?
|
||||
|
||||
假设句子包含n个单词,每个单词平均有m个可选的翻译,我们求得分最高的前k个组合结果。每次一个组合出队列,就对应着一个组合结果,我们希望得到k个,那就对应着k次出队操作。每次有一个组合出队列,就有n个组合入队列。优先级队列中出队和入队操作的时间复杂度都是O(logX),X表示队列中的组合个数。所以,总的时间复杂度就是O(k*n*logX)。那X到底是多少呢?
|
||||
|
||||
k次出入队列,队列中的总数据不会超过k*n,也就是说,出队、入队操作的时间复杂度是O(log(k*n))。所以,总的时间复杂度就是O(k*n*log(k*n)),比之前的指数级时间复杂度降低了很多。
|
||||
|
||||
## 课后思考
|
||||
|
||||
<li>
|
||||
在计算最短时间的出行路线中,如何获得通过某条路的时间呢?这个题目很有意思,我之前面试的时候也被问到过,你可以思考看看。
|
||||
</li>
|
||||
<li>
|
||||
今天讲的出行路线问题,我假设的是开车出行,那如果是公交出行呢?如果混合地铁、公交、步行,又该如何规划路线呢?
|
||||
</li>
|
||||
|
||||
欢迎留言和我分享,也欢迎点击“请朋友读”,把今天的内容分享给你的好友,和他一起讨论、学习。
|
||||
131
极客时间专栏/geek/数据结构与算法之美/高级篇/45 | 位图:如何实现网页爬虫中的URL去重功能?.md
Normal file
131
极客时间专栏/geek/数据结构与算法之美/高级篇/45 | 位图:如何实现网页爬虫中的URL去重功能?.md
Normal file
@@ -0,0 +1,131 @@
|
||||
<audio id="audio" title="45 | 位图:如何实现网页爬虫中的URL去重功能?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/fb/db/fb7ed125488ca7bdfba1fbabb1a864db.mp3"></audio>
|
||||
|
||||
网页爬虫是搜索引擎中的非常重要的系统,负责爬取几十亿、上百亿的网页。爬虫的工作原理是,通过解析已经爬取页面中的网页链接,然后再爬取这些链接对应的网页。而**同一个网页链接有可能被包含在多个页面中,这就会导致爬虫在爬取的过程中,重复爬取相同的网页。如果你是一名负责爬虫的工程师,你会如何避免这些重复的爬取呢?**
|
||||
|
||||
最容易想到的方法就是,我们记录已经爬取的网页链接(也就是URL),在爬取一个新的网页之前,我们拿它的链接,在已经爬取的网页链接列表中搜索。如果存在,那就说明这个网页已经被爬取过了;如果不存在,那就说明这个网页还没有被爬取过,可以继续去爬取。等爬取到这个网页之后,我们将这个网页的链接添加到已经爬取的网页链接列表了。
|
||||
|
||||
思路非常简单,我想你应该很容易就能想到。不过,我们该如何记录已经爬取的网页链接呢?需要用什么样的数据结构呢?
|
||||
|
||||
## 算法解析
|
||||
|
||||
关于这个问题,我们可以先回想下,是否可以用我们之前学过的数据结构来解决呢?
|
||||
|
||||
这个问题要处理的对象是网页链接,也就是URL,需要支持的操作有两个,添加一个URL和查询一个URL。除了这两个功能性的要求之外,在非功能性方面,我们还要求这两个操作的执行效率要尽可能高。除此之外,因为我们处理的是上亿的网页链接,内存消耗会非常大,所以在存储效率上,我们要尽可能地高效。
|
||||
|
||||
我们回想一下,满足这些条件的数据结构有哪些呢?显然,散列表、红黑树、跳表这些动态数据结构,都能支持快速地插入、查找数据,但是在内存消耗方面,是否可以接受呢?
|
||||
|
||||
我们拿散列表来举例。假设我们要爬取10亿个网页(像Google、百度这样的通用搜索引擎,爬取的网页可能会更多),为了判重,我们把这10亿网页链接存储在散列表中。你来估算下,大约需要多少内存?
|
||||
|
||||
假设一个URL的平均长度是64字节,那单纯存储这10亿个URL,需要大约60GB的内存空间。因为散列表必须维持较小的装载因子,才能保证不会出现过多的散列冲突,导致操作的性能下降。而且,用链表法解决冲突的散列表,还会存储链表指针。所以,如果将这10亿个URL构建成散列表,那需要的内存空间会远大于60GB,有可能会超过100GB。
|
||||
|
||||
当然,对于一个大型的搜索引擎来说,即便是100GB的内存要求,其实也不算太高,我们可以采用分治的思想,用多台机器(比如20台内存是8GB的机器)来存储这10亿网页链接。这种分治的处理思路,我们讲过很多次了,这里就不详细说了。
|
||||
|
||||
对于爬虫的URL去重这个问题,刚刚讲到的分治加散列表的思路,已经是可以实实在在工作的了。不过,**作为一个有追求的工程师,我们应该考虑,在添加、查询数据的效率以及内存消耗方面,是否还有进一步的优化空间呢?**
|
||||
|
||||
你可能会说,散列表中添加、查找数据的时间复杂度已经是O(1),还能有进一步优化的空间吗?实际上,我们前面也讲过,时间复杂度并不能完全代表代码的执行时间。大O时间复杂度表示法,会忽略掉常数、系数和低阶,并且统计的对象是语句的频度。不同的语句,执行时间也是不同的。时间复杂度只是表示执行时间随数据规模的变化趋势,并不能度量在特定的数据规模下,代码执行时间的多少。
|
||||
|
||||
如果时间复杂度中原来的系数是10,我们现在能够通过优化,将系数降为1,那在时间复杂度没有变化的情况下,执行效率就提高了10倍。对于实际的软件开发来说,10倍效率的提升,显然是一个非常值得的优化。
|
||||
|
||||
如果我们用基于链表的方法解决冲突问题,散列表中存储的是URL,那当查询的时候,通过哈希函数定位到某个链表之后,我们还需要依次比对每个链表中的URL。这个操作是比较耗时的,主要有两点原因。
|
||||
|
||||
一方面,链表中的结点在内存中不是连续存储的,所以不能一下子加载到CPU缓存中,没法很好地利用到CPU高速缓存,所以数据访问性能方面会打折扣。
|
||||
|
||||
另一方面,链表中的每个数据都是URL,而URL不是简单的数字,是平均长度为64字节的字符串。也就是说,我们要让待判重的URL,跟链表中的每个URL,做字符串匹配。显然,这样一个字符串匹配操作,比起单纯的数字比对,要慢很多。所以,基于这两点,执行效率方面肯定是有优化空间的。
|
||||
|
||||
对于内存消耗方面的优化,除了刚刚这种基于散列表的解决方案,貌似没有更好的法子了。实际上,如果要想内存方面有明显的节省,那就得换一种解决方案,也就是我们今天要着重讲的这种存储结构,**布隆过滤器**(Bloom Filter)。
|
||||
|
||||
在讲布隆过滤器前,我要先讲一下另一种存储结构,**位图**(BitMap)。因为,布隆过滤器本身就是基于位图的,是对位图的一种改进。
|
||||
|
||||
我们先来看一个跟开篇问题非常类似、但比那个稍微简单的问题。**我们有1千万个整数,整数的范围在1到1亿之间。如何快速查找某个整数是否在这1千万个整数中呢?**
|
||||
|
||||
当然,这个问题还是可以用散列表来解决。不过,我们可以使用一种比较“特殊”的散列表,那就是位图。我们申请一个大小为1亿、数据类型为布尔类型(true或者false)的数组。我们将这1千万个整数作为数组下标,将对应的数组值设置成true。比如,整数5对应下标为5的数组值设置为true,也就是array[5]=true。
|
||||
|
||||
当我们查询某个整数K是否在这1千万个整数中的时候,我们只需要将对应的数组值array[K]取出来,看是否等于true。如果等于true,那说明1千万整数中包含这个整数K;相反,就表示不包含这个整数K。
|
||||
|
||||
不过,很多语言中提供的布尔类型,大小是1个字节的,并不能节省太多内存空间。实际上,表示true和false两个值,我们只需要用一个二进制位(bit)就可以了。**那如何通过编程语言,来表示一个二进制位呢?**
|
||||
|
||||
这里就要用到位运算了。我们可以借助编程语言中提供的数据类型,比如int、long、char等类型,通过位运算,用其中的某个位表示某个数字。文字描述起来有点儿不好理解,我把位图的代码实现写了出来,你可以对照着代码看下,应该就能看懂了。
|
||||
|
||||
```
|
||||
public class BitMap { // Java中char类型占16bit,也即是2个字节
|
||||
private char[] bytes;
|
||||
private int nbits;
|
||||
|
||||
public BitMap(int nbits) {
|
||||
this.nbits = nbits;
|
||||
this.bytes = new char[nbits/16+1];
|
||||
}
|
||||
|
||||
public void set(int k) {
|
||||
if (k > nbits) return;
|
||||
int byteIndex = k / 16;
|
||||
int bitIndex = k % 16;
|
||||
bytes[byteIndex] |= (1 << bitIndex);
|
||||
}
|
||||
|
||||
public boolean get(int k) {
|
||||
if (k > nbits) return false;
|
||||
int byteIndex = k / 16;
|
||||
int bitIndex = k % 16;
|
||||
return (bytes[byteIndex] & (1 << bitIndex)) != 0;
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
从刚刚位图结构的讲解中,你应该可以发现,位图通过数组下标来定位数据,所以,访问效率非常高。而且,每个数字用一个二进制位来表示,在数字范围不大的情况下,所需要的内存空间非常节省。
|
||||
|
||||
比如刚刚那个例子,如果用散列表存储这1千万的数据,数据是32位的整型数,也就是需要4个字节的存储空间,那总共至少需要40MB的存储空间。如果我们通过位图的话,数字范围在1到1亿之间,只需要1亿个二进制位,也就是12MB左右的存储空间就够了。
|
||||
|
||||
关于位图,我们就讲完了,是不是挺简单的?不过,这里我们有个假设,就是数字所在的范围不是很大。如果数字的范围很大,比如刚刚那个问题,数字范围不是1到1亿,而是1到10亿,那位图的大小就是10亿个二进制位,也就是120MB的大小,消耗的内存空间,不降反增。
|
||||
|
||||
这个时候,布隆过滤器就要出场了。布隆过滤器就是为了解决刚刚这个问题,对位图这种数据结构的一种改进。
|
||||
|
||||
还是刚刚那个例子,数据个数是1千万,数据的范围是1到10亿。布隆过滤器的做法是,我们仍然使用一个1亿个二进制大小的位图,然后通过哈希函数,对数字进行处理,让它落在这1到1亿范围内。比如我们把哈希函数设计成f(x)=x%n。其中,x表示数字,n表示位图的大小(1亿),也就是,对数字跟位图的大小进行取模求余。
|
||||
|
||||
不过,你肯定会说,哈希函数会存在冲突的问题啊,一亿零一和1两个数字,经过你刚刚那个取模求余的哈希函数处理之后,最后的结果都是1。这样我就无法区分,位图存储的是1还是一亿零一了。
|
||||
|
||||
为了降低这种冲突概率,当然我们可以设计一个复杂点、随机点的哈希函数。除此之外,还有其他方法吗?我们来看布隆过滤器的处理方法。既然一个哈希函数可能会存在冲突,那用多个哈希函数一块儿定位一个数据,是否能降低冲突的概率呢?我来具体解释一下,布隆过滤器是怎么做的。
|
||||
|
||||
我们使用K个哈希函数,对同一个数字进行求哈希值,那会得到K个不同的哈希值,我们分别记作$X_{1}$,$X_{2}$,$X_{3}$,…,$X_{K}$。我们把这K个数字作为位图中的下标,将对应的BitMap[$X_{1}$],BitMap[$X_{2}$],BitMap[$X_{3}$],…,BitMap[$X_{K}$]都设置成true,也就是说,我们用K个二进制位,来表示一个数字的存在。
|
||||
|
||||
当我们要查询某个数字是否存在的时候,我们用同样的K个哈希函数,对这个数字求哈希值,分别得到$Y_{1}$,$Y_{2}$,$Y_{3}$,…,$Y_{K}$。我们看这K个哈希值,对应位图中的数值是否都为true,如果都是true,则说明,这个数字存在,如果有其中任意一个不为true,那就说明这个数字不存在。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/94/ae/94630c1c3b7657f560a1825bd9d02cae.jpg" alt="">
|
||||
|
||||
对于两个不同的数字来说,经过一个哈希函数处理之后,可能会产生相同的哈希值。但是经过K个哈希函数处理之后,K个哈希值都相同的概率就非常低了。尽管采用K个哈希函数之后,两个数字哈希冲突的概率降低了,但是,这种处理方式又带来了新的问题,那就是容易误判。我们看下面这个例子。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d0/1a/d0a3326ef0037f64102163209301aa1a.jpg" alt="">
|
||||
|
||||
布隆过滤器的误判有一个特点,那就是,它只会对存在的情况有误判。如果某个数字经过布隆过滤器判断不存在,那说明这个数字真的不存在,不会发生误判;如果某个数字经过布隆过滤器判断存在,这个时候才会有可能误判,有可能并不存在。不过,只要我们调整哈希函数的个数、位图大小跟要存储数字的个数之间的比例,那就可以将这种误判的概率降到非常低。
|
||||
|
||||
尽管布隆过滤器会存在误判,但是,这并不影响它发挥大作用。很多场景对误判有一定的容忍度。比如我们今天要解决的爬虫判重这个问题,即便一个没有被爬取过的网页,被误判为已经被爬取,对于搜索引擎来说,也并不是什么大事情,是可以容忍的,毕竟网页太多了,搜索引擎也不可能100%都爬取到。
|
||||
|
||||
弄懂了布隆过滤器,我们今天的爬虫网页去重的问题,就很简单了。
|
||||
|
||||
我们用布隆过滤器来记录已经爬取过的网页链接,假设需要判重的网页有10亿,那我们可以用一个10倍大小的位图来存储,也就是100亿个二进制位,换算成字节,那就是大约1.2GB。之前我们用散列表判重,需要至少100GB的空间。相比来讲,布隆过滤器在存储空间的消耗上,降低了非常多。
|
||||
|
||||
那我们再来看下,利用布隆过滤器,在执行效率方面,是否比散列表更加高效呢?
|
||||
|
||||
布隆过滤器用多个哈希函数对同一个网页链接进行处理,CPU只需要将网页链接从内存中读取一次,进行多次哈希计算,理论上讲这组操作是CPU密集型的。而在散列表的处理方式中,需要读取散列值相同(散列冲突)的多个网页链接,分别跟待判重的网页链接,进行字符串匹配。这个操作涉及很多内存数据的读取,所以是内存密集型的。我们知道CPU计算可能是要比内存访问更快速的,所以,理论上讲,布隆过滤器的判重方式,更加快速。
|
||||
|
||||
## 总结引申
|
||||
|
||||
今天,关于搜索引擎爬虫网页去重问题的解决,我们从散列表讲到位图,再讲到布隆过滤器。布隆过滤器非常适合这种不需要100%准确的、允许存在小概率误判的大规模判重场景。除了爬虫网页去重这个例子,还有比如统计一个大型网站的每天的UV数,也就是每天有多少用户访问了网站,我们就可以使用布隆过滤器,对重复访问的用户进行去重。
|
||||
|
||||
我们前面讲到,布隆过滤器的误判率,主要跟哈希函数的个数、位图的大小有关。当我们往布隆过滤器中不停地加入数据之后,位图中不是true的位置就越来越少了,误判率就越来越高了。所以,对于无法事先知道要判重的数据个数的情况,我们需要支持自动扩容的功能。
|
||||
|
||||
当布隆过滤器中,数据个数与位图大小的比例超过某个阈值的时候,我们就重新申请一个新的位图。后面来的新数据,会被放置到新的位图中。但是,如果我们要判断某个数据是否在布隆过滤器中已经存在,我们就需要查看多个位图,相应的执行效率就降低了一些。
|
||||
|
||||
位图、布隆过滤器应用如此广泛,很多编程语言都已经实现了。比如Java中的BitSet类就是一个位图,Redis也提供了BitMap位图类,Google的Guava工具包提供了BloomFilter布隆过滤器的实现。如果你感兴趣,你可以自己去研究下这些实现的源码。
|
||||
|
||||
## 课后思考
|
||||
|
||||
<li>
|
||||
假设我们有1亿个整数,数据范围是从1到10亿,如何快速并且省内存地给这1亿个数据从小到大排序?
|
||||
</li>
|
||||
<li>
|
||||
还记得我们在[哈希函数(下)](https://time.geekbang.org/column/article/67388)讲过的利用分治思想,用散列表以及哈希函数,实现海量图库中的判重功能吗?如果我们允许小概率的误判,那是否可以用今天的布隆过滤器来解决呢?你可以参照我们当时的估算方法,重新估算下,用布隆过滤器需要多少台机器?
|
||||
</li>
|
||||
|
||||
欢迎留言和我分享,也欢迎点击“请朋友读”,把今天的内容分享给你的好友,和他一起讨论、学习。
|
||||
139
极客时间专栏/geek/数据结构与算法之美/高级篇/46 | 概率统计:如何利用朴素贝叶斯算法过滤垃圾短信?.md
Normal file
139
极客时间专栏/geek/数据结构与算法之美/高级篇/46 | 概率统计:如何利用朴素贝叶斯算法过滤垃圾短信?.md
Normal file
@@ -0,0 +1,139 @@
|
||||
<audio id="audio" title="46 | 概率统计:如何利用朴素贝叶斯算法过滤垃圾短信?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/59/de/5989105d9281caf8bce752c05dfa87de.mp3"></audio>
|
||||
|
||||
上一节我们讲到,如何用位图、布隆过滤器,来过滤重复的数据。今天,我们再讲一个跟过滤相关的问题,如何过滤垃圾短信?
|
||||
|
||||
垃圾短信和骚扰电话,我想每个人都收到过吧?买房、贷款、投资理财、开发票,各种垃圾短信和骚扰电话,不胜其扰。**如果你是一名手机应用开发工程师,让你实现一个简单的垃圾短信过滤功能以及骚扰电话拦截功能,该用什么样的数据结构和算法实现呢?**
|
||||
|
||||
## 算法解析
|
||||
|
||||
实际上,解决这个问题并不会涉及很高深的算法。今天,我就带你一块看下,如何利用简单的数据结构和算法,解决这种看似非常复杂的问题。
|
||||
|
||||
### 1.基于黑名单的过滤器
|
||||
|
||||
我们可以维护一个骚扰电话号码和垃圾短信发送号码的黑名单。这个黑名单的收集,有很多途径,比如,我们可以从一些公开的网站上下载,也可以通过类似“360骚扰电话拦截”的功能,通过用户自主标记骚扰电话来收集。对于被多个用户标记,并且标记个数超过一定阈值的号码,我们就可以定义为骚扰电话,并将它加入到我们的黑名单中。
|
||||
|
||||
如果黑名单中的电话号码不多的话,我们可以使用散列表、二叉树等动态数据结构来存储,对内存的消耗并不会很大。如果我们把每个号码看作一个字符串,并且假设平均长度是16个字节,那存储50万个电话号码,大约需要10MB的内存空间。即便是对于手机这样的内存有限的设备来说,这点内存的消耗也是可以接受的。
|
||||
|
||||
但是,如果黑名单中的电话号码很多呢?比如有500万个。这个时候,如果再用散列表存储,就需要大约100MB的存储空间。为了实现一个拦截功能,耗费用户如此多的手机内存,这显然有点儿不合理。
|
||||
|
||||
上一节我们讲了,布隆过滤器最大的特点就是比较省存储空间,所以,用它来解决这个问题再合适不过了。如果我们要存储500万个手机号码,我们把位图大小设置为10倍数据大小,也就是5000万,那也只需要使用5000万个二进制位(5000万bits),换算成字节,也就是不到7MB的存储空间。比起散列表的解决方案,内存的消耗减少了很多。
|
||||
|
||||
实际上,我们还有一种时间换空间的方法,可以将内存的消耗优化到极致。
|
||||
|
||||
我们可以把黑名单存储在服务器端上,把过滤和拦截的核心工作,交给服务器端来做。手机端只负责将要检查的号码发送给服务器端,服务器端通过查黑名单,判断这个号码是否应该被拦截,并将结果返回给手机端。
|
||||
|
||||
用这个解决思路完全不需要占用手机内存。不过,有利就有弊。我们知道,网络通信是比较慢的,所以,网络延迟就会导致处理速度降低。而且,这个方案还有个硬性要求,那就是只有在联网的情况下,才能正常工作。
|
||||
|
||||
基于黑名单的过滤器我就讲完了,不过,你可能还会说,布隆过滤器会有判错的概率呀!如果它把一个重要的电话或者短信,当成垃圾短信或者骚扰电话拦截了,对于用户来说,这是无法接受的。你说得没错,这是一个很大的问题。不过,我们现在先放一放,等三种过滤器都讲完之后,我再来解答。
|
||||
|
||||
### 2.基于规则的过滤器
|
||||
|
||||
刚刚讲了一种基于黑名单的垃圾短信过滤方法,但是,如果某个垃圾短信发送者的号码并不在黑名单中,那这种方法就没办法拦截了。所以,基于黑名单的过滤方式,还不够完善,我们再继续看一种基于规则的过滤方式。
|
||||
|
||||
对于垃圾短信来说,我们还可以通过短信的内容,来判断某条短信是否是垃圾短信。我们预先设定一些规则,如果某条短信符合这些规则,我们就可以判定它是垃圾短信。实际上,规则可以有很多,比如下面这几个:
|
||||
|
||||
<li>
|
||||
短信中包含特殊单词(或词语),比如一些非法、淫秽、反动词语等;
|
||||
</li>
|
||||
<li>
|
||||
短信发送号码是群发号码,非我们正常的手机号码,比如+60389585;
|
||||
</li>
|
||||
<li>
|
||||
短信中包含回拨的联系方式,比如手机号码、微信、QQ、网页链接等,因为群发短信的号码一般都是无法回拨的;
|
||||
</li>
|
||||
<li>
|
||||
短信格式花哨、内容很长,比如包含各种表情、图片、网页链接等;
|
||||
</li>
|
||||
<li>
|
||||
符合已知垃圾短信的模板。垃圾短信一般都是重复群发,对于已经判定为垃圾短信的短信,我们可以抽象成模板,将获取到的短信与模板匹配,一旦匹配,我们就可以判定为垃圾短信。
|
||||
</li>
|
||||
|
||||
当然,如果短信只是满足其中一条规则,如果就判定为垃圾短信,那会存在比较大的误判的情况。我们可以综合多条规则进行判断。比如,满足2条以上才会被判定为垃圾短信;或者每条规则对应一个不同的得分,满足哪条规则,我们就累加对应的分数,某条短信的总得分超过某个阈值,才会被判定为垃圾短信。
|
||||
|
||||
不过,我只是给出了一些制定规则的思路,具体落实到执行层面,其实还有很大的距离,还有很多细节需要处理。比如,第一条规则中,我们该如何定义特殊单词;第二条规则中,我们该如何定义什么样的号码是群发号码等等。限于篇幅,我就不一一详细展开来讲了。我这里只讲一下,如何定义特殊单词?
|
||||
|
||||
如果我们只是自己拍脑袋想,哪些单词属于特殊单词,那势必有比较大的主观性,也很容易漏掉某些单词。实际上,我们可以基于概率统计的方法,借助计算机强大的计算能力,找出哪些单词最常出现在垃圾短信中,将这些最常出现的单词,作为特殊单词,用来过滤短信。
|
||||
|
||||
不过这种方法的前提是,我们有大量的样本数据,也就是说,要有大量的短信(比如1000万条短信),并且我们还要求,每条短信都做好了标记,它是垃圾短信还是非垃圾短信。
|
||||
|
||||
我们对这1000万条短信,进行分词处理(借助中文或者英文分词算法),去掉“的、和、是”等没有意义的停用词(Stop words),得到n个不同的单词。针对每个单词,我们统计有多少个垃圾短信出现了这个单词,有多少个非垃圾短信会出现这个单词,进而求出每个单词出现在垃圾短信中的概率,以及出现在非垃圾短信中的概率。如果某个单词出现在垃圾短信中的概率,远大于出现在非垃圾短信中的概率,那我们就把这个单词作为特殊单词,用来过滤垃圾短信。
|
||||
|
||||
文字描述不好理解,我举个例子来解释一下。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/05/c0/05b9358cac3721e746bbfec8b705cdc0.jpg" alt="">
|
||||
|
||||
### 3.基于概率统计的过滤器
|
||||
|
||||
基于规则的过滤器,看起来很直观,也很好理解,但是它也有一定的局限性。一方面,这些规则受人的思维方式局限,规则未免太过简单;另一方面,垃圾短信发送者可能会针对规则,精心设计短信,绕过这些规则的拦截。对此,我们再来看一种更加高级的过滤方式,基于概率统计的过滤方式。
|
||||
|
||||
这种基于概率统计的过滤方式,基础理论是基于朴素贝叶斯算法。为了让你更好地理解下面的内容,我们先通过一个非常简单的例子来看下,什么是朴素贝叶斯算法?
|
||||
|
||||
假设事件A是“小明不去上学”,事件B是“下雨了”。我们现在统计了一下过去10天的下雨情况和小明上学的情况,作为样本数据。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/e8/32/e8a0bf4643453266c012e5384fc29932.jpg" alt="">
|
||||
|
||||
我们来分析一下,这组样本有什么规律。在这10天中,有4天下雨,所以下雨的概率P(B)=4/10。10天中有3天,小明没有去上学,所以小明不去上学的概率P(A)=3/10。在4个下雨天中,小明有2天没去上学,所以下雨天不去上学的概率P(A|B)=2/4。在小明没有去上学的3天中,有2天下雨了,所以小明因为下雨而不上学的概率是P(B|A)=2/3。实际上,这4个概率值之间,有一定的关系,这个关系就是朴素贝叶斯算法,我们用公式表示出来,就是下面这个样子。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/fb/cc/fbef6a760f916941bc3128c2d32540cc.jpg" alt="">
|
||||
|
||||
朴素贝叶斯算法是不是非常简单?我们用一个公式就可以将它概括。弄懂了朴素贝叶斯算法,我们再回到垃圾短信过滤这个问题上,看看如何利用朴素贝叶斯算法,来做垃圾短信的过滤。
|
||||
|
||||
基于概率统计的过滤器,是基于短信内容来判定是否是垃圾短信。而计算机没办法像人一样理解短信的含义。所以,我们需要把短信抽象成一组计算机可以理解并且方便计算的**特征项**,用这一组特征项代替短信本身,来做垃圾短信过滤。
|
||||
|
||||
我们可以通过分词算法,把一个短信分割成n个单词。这n个单词就是一组特征项,全权代表这个短信。因此,判定一个短信是否是垃圾短信这样一个问题,就变成了,判定同时包含这几个单词的短信是否是垃圾短信。
|
||||
|
||||
不过,这里我们并不像基于规则的过滤器那样,非黑即白,一个短信要么被判定为垃圾短信、要么被判定为非垃圾短息。我们使用概率,来表征一个短信是垃圾短信的可信程度。如果我们用公式将这个概率表示出来,就是下面这个样子:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/b8/e7/b8f76a5fd26f785055b78ffe08ccfbe7.jpg" alt="">
|
||||
|
||||
尽管我们有大量的短信样本,但是我们没法通过样本数据统计得到这个概率。为什么不可以呢?你可能会说,我只需要统计同时包含$W_{1}$,$W_{2}$,$W_{3}$,…,$W_{n}$这n个单词的短信有多少个(我们假设有x个),然后看这里面属于垃圾短信的有几个(我们假设有y个),那包含$W_{1}$,$W_{2}$,$W_{3}$,…,$W_{n}$这n个单词的短信是垃圾短信的概率就是y/x。
|
||||
|
||||
理想很丰满,但现实往往很骨感。你忽视了非常重要的一点,那就是样本的数量再大,毕竟也是有限的,样本中不会有太多同时包含$W_{1}$,$W_{2}$,$W_{3}$,…,$W_{n}$的短信的,甚至很多时候,样本中根本不存在这样的短信。没有样本,也就无法计算概率。所以这样的推理方式虽然正确,但是实践中并不好用。
|
||||
|
||||
这个时候,朴素贝叶斯公式就可以派上用场了。我们通过朴素贝叶斯公式,将这个概率的求解,分解为其他三个概率的求解。你可以看我画的图。那转化之后的三个概率是否可以通过样本统计得到呢?
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/39/ae/39c57b1a8a008e50a9f6cb8b7b9c9bae.jpg" alt="">
|
||||
|
||||
P($W_{1}$,$W_{2}$,$W_{3}$,…,$W_{n}$同时出现在一条短信中 | 短信是垃圾短信)这个概率照样无法通过样本来统计得到。但是我们可以基于下面这条著名的概率规则来计算。
|
||||
|
||||
>
|
||||
独立事件发生的概率计算公式:P(A*B) = P(A)*P(B)
|
||||
|
||||
|
||||
>
|
||||
如果事件A和事件B是独立事件,两者的发生没有相关性,事件A发生的概率P(A)等于p1,事件B发生的概率P(B)等于p2,那两个同时发生的概率P(A*B)就等于P(A)*P(B)。
|
||||
|
||||
|
||||
基于这条独立事件发生概率的计算公式,我们可以把P(W1,W2,W3,…,Wn同时出现在一条短信中 | 短信是垃圾短信)分解为下面这个公式:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/6c/f2/6c261a3f5312c515cf348cc59a5e73f2.jpg" alt="">
|
||||
|
||||
其中,P($W_{i}$出现在短信中 | 短信是垃圾短信)表示垃圾短信中包含$W_{i}$这个单词的概率有多大。这个概率值通过统计样本很容易就能获得。我们假设垃圾短信有y个,其中包含$W_{i}$的有x个,那这个概率值就等于x/y。
|
||||
|
||||
P($W_{1}$,$W_{2}$,$W_{3}$,…,$W_{n}$同时出现在一条短信中 | 短信是垃圾短信)这个概率值,我们就计算出来了,我们再来看下剩下两个。
|
||||
|
||||
P(短信是垃圾短信)表示短信是垃圾短信的概率,这个很容易得到。我们把样本中垃圾短信的个数除以总样本短信个数,就是短信是垃圾短信的概率。
|
||||
|
||||
不过,P($W_{1}$,$W_{2}$,$W_{3}$,…,$W_{n}$同时出现在一条短信中)这个概率还是不好通过样本统计得到,原因我们前面说过了,样本空间有限。不过,我们没必要非得计算这一部分的概率值。为什么这么说呢?
|
||||
|
||||
实际上,我们可以分别计算同时包含$W_{1}$,$W_{2}$,$W_{3}$,…,$W_{n}$这n个单词的短信,是垃圾短信和非垃圾短信的概率。假设它们分别是p1和p2。我们并不需要单纯地基于p1值的大小来判断是否是垃圾短信,而是通过对比p1和p2值的大小,来判断一条短信是否是垃圾短信。更细化一点讲,那就是,如果p1是p2的很多倍(比如10倍),我们才确信这条短信是垃圾短信。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/0f/2a/0f0369a955ee8d15bd7d7958829d5b2a.jpg" alt="">
|
||||
|
||||
基于这两个概率的倍数来判断是否是垃圾短信的方法,我们就可以不用计算P($W_{1}$,$W_{2}$,$W_{3}$,…,$W_{n}$同时出现在一条短信中)这一部分的值了,因为计算p1与p2的时候,都会包含这个概率值的计算,所以在求解p1和p2倍数(p1/p2)的时候,我们也就不需要这个值。
|
||||
|
||||
## 总结引申
|
||||
|
||||
今天,我们讲了基于黑名单、规则、概率统计三种垃圾短信的过滤方法,实际上,今天讲的这三种方法,还可以应用到很多类似的过滤、拦截的领域,比如垃圾邮件的过滤等等。
|
||||
|
||||
在讲黑名单过滤的时候,我讲到布隆过滤器可能会存在误判情况,可能会导致用户投诉。实际上,我们可以结合三种不同的过滤方式的结果,对同一个短信处理,如果三者都表明这个短信是垃圾短信,我们才把它当作垃圾短信拦截过滤,这样就会更精准。
|
||||
|
||||
当然,在实际的工程中,我们还需要结合具体的场景,以及大量的实验,不断去调整策略,权衡垃圾短信判定的**准确率**(是否会把不是垃圾的短信错判为垃圾短信)和**召回率**(是否能把所有的垃圾短信都找到),来实现我们的需求。
|
||||
|
||||
|
||||
|
||||
## 课后思考
|
||||
|
||||
关于垃圾短信过滤和骚扰电话的拦截,我们可以一块儿头脑风暴一下,看看你还有没有其他方法呢?
|
||||
|
||||
欢迎留言和我分享,也欢迎点击“请朋友读”,把今天的内容分享给你的好友,和他一起讨论、学习。
|
||||
78
极客时间专栏/geek/数据结构与算法之美/高级篇/47 | 向量空间:如何实现一个简单的音乐推荐系统?.md
Normal file
78
极客时间专栏/geek/数据结构与算法之美/高级篇/47 | 向量空间:如何实现一个简单的音乐推荐系统?.md
Normal file
@@ -0,0 +1,78 @@
|
||||
<audio id="audio" title="47 | 向量空间:如何实现一个简单的音乐推荐系统?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/e7/e2/e7642e15e8c038e7503c77487fb48ae2.mp3"></audio>
|
||||
|
||||
很多人都喜爱听歌,以前我们用MP3听歌,现在直接通过音乐App在线就能听歌。而且,各种音乐App的功能越来越强大,不仅可以自己选歌听,还可以根据你听歌的口味偏好,给你推荐可能会喜爱的音乐,而且有时候,推荐的音乐还非常适合你的口味,甚至会惊艳到你!如此智能的一个功能,你知道它是怎么实现的吗?
|
||||
|
||||
## 算法解析
|
||||
|
||||
实际上,要解决这个问题,并不需要特别高深的理论。解决思路的核心思想非常简单、直白,用两句话就能总结出来。
|
||||
|
||||
<li>
|
||||
找到跟你口味偏好相似的用户,把他们爱听的歌曲推荐给你;
|
||||
</li>
|
||||
<li>
|
||||
找出跟你喜爱的歌曲特征相似的歌曲,把这些歌曲推荐给你。
|
||||
</li>
|
||||
|
||||
接下来,我就分别讲解一下这两种思路的具体实现方法。
|
||||
|
||||
### 1.基于相似用户做推荐
|
||||
|
||||
如何找到跟你口味偏好相似的用户呢?或者说如何定义口味偏好相似呢?实际上,思路也很简单,我们把跟你听类似歌曲的人,看作口味相似的用户。你可以看我下面画的这个图。我用“1”表示“喜爱”,用“0”笼统地表示“不发表意见”。从图中我们可以看出,你跟小明共同喜爱的歌曲最多,有5首。于是,我们就可以说,小明跟你的口味非常相似。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/cc/06/cc24a9c98a93795c75d8ef7a5892c406.jpg" alt="">
|
||||
|
||||
我们只需要遍历所有的用户,对比每个用户跟你共同喜爱的歌曲个数,并且设置一个阈值,如果你和某个用户共同喜爱的歌曲个数超过这个阈值,我们就把这个用户看作跟你口味相似的用户,把这个用户喜爱但你还没听过的歌曲,推荐给你。
|
||||
|
||||
不过,刚刚的这个解决方案中有一个问题,我们如何知道用户喜爱哪首歌曲呢?也就是说,如何定义用户对某首歌曲的喜爱程度呢?
|
||||
|
||||
实际上,我们可以通过用户的行为,来定义这个喜爱程度。我们给每个行为定义一个得分,得分越高表示喜爱程度越高。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/93/a6/93c26a89303a748199528fdd998ebba6.jpg" alt="">
|
||||
|
||||
还是刚刚那个例子,我们如果把每个人对每首歌曲的喜爱程度表示出来,就是下面这个样子。图中,某个人对某首歌曲是否喜爱,我们不再用“1”或者“0”来表示,而是对应一个具体的分值。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/05/a9/056552502f1cf4fdf331488e0eed5fa9.jpg" alt="">
|
||||
|
||||
有了这样一个用户对歌曲的喜爱程度的对应表之后,如何来判断两个用户是否口味相似呢?
|
||||
|
||||
显然,我们不能再像之前那样,采用简单的计数来统计两个用户之间的相似度。还记得我们之前讲字符串相似度度量时,提到的编辑距离吗?这里的相似度度量,我们可以使用另外一个距离,那就是**欧几里得距离**(Euclidean distance)。欧几里得距离是用来计算两个向量之间的距离的。这个概念中有两个关键词,向量和距离,我来给你解释一下。
|
||||
|
||||
一维空间是一条线,我们用1,2,3……这样单个的数,来表示一维空间中的某个位置;二维空间是一个面,我们用(1,3)(4,2)(2,2)……这样的两个数,来表示二维空间中的某个位置;三维空间是一个立体空间,我们用(1,3,5)(3,1,7)(2,4,3)……这样的三个数,来表示三维空间中的某个位置。一维、二维、三维应该都不难理解,那更高维中的某个位置该如何表示呢?
|
||||
|
||||
类比一维、二维、三维的表示方法,K维空间中的某个位置,我们可以写作($X_{1}$,$X_{2}$,$X_{3}$,…,$X_{K}$)。这种表示方法就是**向量**(vector)。我们知道,二维、三维空间中,两个位置之间有距离的概念,类比到高纬空间,同样也有距离的概念,这就是我们说的两个向量之间的距离。
|
||||
|
||||
那如何计算两个向量之间的距离呢?我们还是可以类比到二维、三维空间中距离的计算方法。通过类比,我们就可以得到两个向量之间距离的计算公式。这个计算公式就是欧几里得距离的计算公式:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/f4/12/f4d1d906c076688a43380f82e47dce12.jpg" alt="">
|
||||
|
||||
我们把每个用户对所有歌曲的喜爱程度,都用一个向量表示。我们计算出两个向量之间的欧几里得距离,作为两个用户的口味相似程度的度量。从图中的计算可以看出,小明与你的欧几里得距离距离最小,也就是说,你俩在高维空间中靠得最近,所以,我们就断定,小明跟你的口味最相似。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/3e/89/3e145a3054c1abdea5d3f207d13e9b89.jpg" alt="">
|
||||
|
||||
### 2.基于相似歌曲做推荐
|
||||
|
||||
刚刚我们讲了基于相似用户的歌曲推荐方法,但是,如果用户是一个新用户,我们还没有收集到足够多的行为数据,这个时候该如何推荐呢?我们现在再来看另外一种推荐方法,基于相似歌曲的推荐方法,也就是说,如果某首歌曲跟你喜爱的歌曲相似,我们就把它推荐给你。
|
||||
|
||||
如何判断两首歌曲是否相似呢?对于人来说,这个事情可能会比较简单,但是对于计算机来说,判断两首歌曲是否相似,那就需要通过量化的数据来表示了。我们应该通过什么数据来量化两个歌曲之间的相似程度呢?
|
||||
|
||||
最容易想到的是,我们对歌曲定义一些特征项,比如是伤感的还是愉快的,是摇滚还是民谣,是柔和的还是高亢的等等。类似基于相似用户的推荐方法,我们给每个歌曲的每个特征项打一个分数,这样每个歌曲就都对应一个特征项向量。我们可以基于这个特征项向量,来计算两个歌曲之间的欧几里得距离。欧几里得距离越小,表示两个歌曲的相似程度越大。
|
||||
|
||||
但是,要实现这个方案,需要有一个前提,那就是我们能够找到足够多,并且能够全面代表歌曲特点的特征项,除此之外,我们还要人工给每首歌标注每个特征项的得分。对于收录了海量歌曲的音乐App来说,这显然是一个非常大的工程。此外,人工标注有很大的主观性,也会影响到推荐的准确性。
|
||||
|
||||
既然基于歌曲特征项计算相似度不可行,那我们就换一种思路。对于两首歌,如果喜欢听的人群都是差不多的,那侧面就可以反映出,这两首歌比较相似。如图所示,每个用户对歌曲有不同的喜爱程度,我们依旧通过上一个解决方案中定义得分的标准,来定义喜爱程度。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a3/ff/a324908e162a60efea4bd7c47c04a6ff.jpg" alt="">
|
||||
|
||||
你有没有发现,这个图跟基于相似用户推荐中的图几乎一样。只不过这里把歌曲和用户主次颠倒了一下。基于相似用户的推荐方法中,针对每个用户,我们将对各个歌曲的喜爱程度作为向量。基于相似歌曲的推荐思路中,针对每个歌曲,我们将每个用户的打分作为向量。
|
||||
|
||||
有了每个歌曲的向量表示,我们通过计算向量之间的欧几里得距离,来表示歌曲之间的相似度。欧几里得距离越小,表示两个歌曲越相似。然后,我们就在用户已经听过的歌曲中,找出他喜爱程度较高的歌曲。然后,我们找出跟这些歌曲相似度很高的其他歌曲,推荐给他。
|
||||
|
||||
## 总结引申
|
||||
|
||||
实际上,这个问题是**推荐系统**(Recommendation System)里最典型的一类问题。之所以讲这部分内容,主要还是想给你展示,算法的强大之处,利用简单的向量空间的欧几里得距离,就能解决如此复杂的问题。不过,今天,我只给你讲解了基本的理论,实践中遇到的问题还有很多,比如冷启动问题,产品初期积累的数据不多,不足以做推荐等等。这些更加深奥的内容,你可以之后自己在实践中慢慢探索。
|
||||
|
||||
## 课后思考
|
||||
|
||||
关于今天讲的推荐算法,你还能想到其他应用场景吗?
|
||||
|
||||
欢迎留言和我分享,也欢迎点击“请朋友读”,把今天的内容分享给你的好友,和他一起讨论、学习。
|
||||
182
极客时间专栏/geek/数据结构与算法之美/高级篇/48 | B+树:MySQL数据库索引是如何实现的?.md
Normal file
182
极客时间专栏/geek/数据结构与算法之美/高级篇/48 | B+树:MySQL数据库索引是如何实现的?.md
Normal file
@@ -0,0 +1,182 @@
|
||||
<audio id="audio" title="48 | B+树:MySQL数据库索引是如何实现的?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/e1/27/e1028d6ba7ecdeaff0ab1a61233ed127.mp3"></audio>
|
||||
|
||||
作为一个软件开发工程师,你对数据库肯定再熟悉不过了。作为主流的数据存储系统,它在我们的业务开发中,有着举足轻重的地位。在工作中,为了加速数据库中数据的查找速度,我们常用的处理思路是,对表中数据创建索引。那你是否思考过,**数据库索引是如何实现的呢?底层使用的是什么数据结构和算法呢?**
|
||||
|
||||
## 算法解析
|
||||
|
||||
思考的过程比结论更重要。跟着我学习了这么多节课,很多同学已经意识到这一点,比如Jerry银银同学。我感到很开心。所以,今天的讲解,我会尽量还原这个解决方案的思考过程,让你知其然,并且知其所以然。
|
||||
|
||||
### 1.解决问题的前提是定义清楚问题
|
||||
|
||||
如何定义清楚问题呢?除了对问题进行详细的调研,还有一个办法,那就是,通过**对一些模糊的需求进行假设,来限定<strong><strong>要**</strong>解决的问题的范围</strong>。
|
||||
|
||||
如果你对数据库的操作非常了解,针对我们现在这个问题,你就能把索引的需求定义得非常清楚。但是,对于大部分软件工程师来说,我们可能只了解一小部分常用的SQL语句,所以,这里我们假设要解决的问题,只包含这样两个常用的需求:
|
||||
|
||||
<li>
|
||||
根据某个值查找数据,比如select * from user where id=1234;
|
||||
</li>
|
||||
<li>
|
||||
根据区间值来查找某些数据,比如select * from user where id > 1234 and id < 2345。
|
||||
</li>
|
||||
|
||||
除了这些功能性需求之外,这种问题往往还会涉及一些非功能性需求,比如安全、性能、用户体验等等。限于专栏要讨论的主要是数据结构和算法,对于非功能性需求,我们着重考虑**性能方面**的需求。性能方面的需求,我们主要考察时间和空间两方面,也就是**执行效率和存储空间**。
|
||||
|
||||
在执行效率方面,我们希望通过索引,查询数据的效率尽可能地高;在存储空间方面,我们希望索引不要消耗太多的内存空间。
|
||||
|
||||
### 2.尝试用学过的数据结构解决这个问题
|
||||
|
||||
问题的需求大致定义清楚了,我们现在回想一下,能否利用已经学习过的数据结构解决这个问题呢?支持快速查询、插入等操作的动态数据结构,我们已经学习过散列表、平衡二叉查找树、跳表。
|
||||
|
||||
我们先来看**散列表**。散列表的查询性能很好,时间复杂度是O(1)。但是,散列表不能支持按照区间快速查找数据。所以,散列表不能满足我们的需求。
|
||||
|
||||
我们再来看**平衡二叉查找树**。尽管平衡二叉查找树查询的性能也很高,时间复杂度是O(logn)。而且,对树进行中序遍历,我们还可以得到一个从小到大有序的数据序列,但这仍然不足以支持按照区间快速查找数据。
|
||||
|
||||
我们再来看**跳表**。跳表是在链表之上加上多层索引构成的。它支持快速地插入、查找、删除数据,对应的时间复杂度是O(logn)。并且,跳表也支持按照区间快速地查找数据。我们只需要定位到区间起点值对应在链表中的结点,然后从这个结点开始,顺序遍历链表,直到区间终点对应的结点为止,这期间遍历得到的数据就是满足区间值的数据。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/49/65/492206afe5e2fef9f683c7cff83afa65.jpg" alt="">
|
||||
|
||||
这样看来,跳表是可以解决这个问题。实际上,数据库索引所用到的数据结构跟跳表非常相似,叫作B+树。不过,它是通过二叉查找树演化过来的,而非跳表。为了给你还原发明B+树的整个思考过程,所以,接下来,我还要从二叉查找树讲起,看它是如何一步一步被改造成B+树的。
|
||||
|
||||
### 3.改造二叉查找树来解决这个问题
|
||||
|
||||
为了让二叉查找树支持按照区间来查找数据,我们可以对它进行这样的改造:树中的节点并不存储数据本身,而是只是作为索引。除此之外,我们把每个叶子节点串在一条链表上,链表中的数据是从小到大有序的。经过改造之后的二叉树,就像图中这样,看起来是不是很像跳表呢?
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/25/f4/25700c1dc28ce094eed3ffac394531f4.jpg" alt="">
|
||||
|
||||
改造之后,如果我们要求某个区间的数据。我们只需要拿区间的起始值,在树中进行查找,当查找到某个叶子节点之后,我们再顺着链表往后遍历,直到链表中的结点数据值大于区间的终止值为止。所有遍历到的数据,就是符合区间值的所有数据。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/1c/cc/1cf179c03c702a6ef5b9336f5b1eaecc.jpg" alt="">
|
||||
|
||||
但是,我们要为几千万、上亿的数据构建索引,如果将索引存储在内存中,尽管内存访问的速度非常快,查询的效率非常高,但是,占用的内存会非常多。
|
||||
|
||||
比如,我们给一亿个数据构建二叉查找树索引,那索引中会包含大约1亿个节点,每个节点假设占用16个字节,那就需要大约1GB的内存空间。给一张表建立索引,我们需要1GB的内存空间。如果我们要给10张表建立索引,那对内存的需求是无法满足的。如何解决这个索引占用太多内存的问题呢?
|
||||
|
||||
我们可以借助时间换空间的思路,把索引存储在硬盘中,而非内存中。我们都知道,硬盘是一个非常慢速的存储设备。通常内存的访问速度是纳秒级别的,而磁盘访问的速度是毫秒级别的。读取同样大小的数据,从磁盘中读取花费的时间,是从内存中读取所花费时间的上万倍,甚至几十万倍。
|
||||
|
||||
这种将索引存储在硬盘中的方案,尽管减少了内存消耗,但是在数据查找的过程中,需要读取磁盘中的索引,因此数据查询效率就相应降低很多。
|
||||
|
||||
二叉查找树,经过改造之后,支持区间查找的功能就实现了。不过,为了节省内存,如果把树存储在硬盘中,那么每个节点的读取(或者访问),都对应一次磁盘IO操作。树的高度就等于每次查询数据时磁盘IO操作的次数。
|
||||
|
||||
我们前面讲到,比起内存读写操作,磁盘IO操作非常耗时,所以我们优化的重点就是尽量减少磁盘IO操作,也就是,尽量降低树的高度。那如何降低树的高度呢?
|
||||
|
||||
我们来看下,如果我们把索引构建成m叉树,高度是不是比二叉树要小呢?如图所示,给16个数据构建二叉树索引,树的高度是4,查找一个数据,就需要4个磁盘IO操作(如果根节点存储在内存中,其他节点存储在磁盘中),如果对16个数据构建五叉树索引,那高度只有2,查找一个数据,对应只需要2次磁盘操作。如果m叉树中的m是100,那对一亿个数据构建索引,树的高度也只是3,最多只要3次磁盘IO就能获取到数据。磁盘IO变少了,查找数据的效率也就提高了。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/69/59/69d4c48c1257dcb7dd6077d961b86259.jpg" alt=""><img src="https://static001.geekbang.org/resource/image/76/cc/769687f57190a826a8f6f82793491ccc.jpg" alt="">
|
||||
|
||||
如果我们将m叉树实现B+树索引,用代码实现出来,就是下面这个样子(假设我们给int类型的数据库字段添加索引,所以代码中的keywords是int类型的):
|
||||
|
||||
```
|
||||
/**
|
||||
* 这是B+树非叶子节点的定义。
|
||||
*
|
||||
* 假设keywords=[3, 5, 8, 10]
|
||||
* 4个键值将数据分为5个区间:(-INF,3), [3,5), [5,8), [8,10), [10,INF)
|
||||
* 5个区间分别对应:children[0]...children[4]
|
||||
*
|
||||
* m值是事先计算得到的,计算的依据是让所有信息的大小正好等于页的大小:
|
||||
* PAGE_SIZE = (m-1)*4[keywordss大小]+m*8[children大小]
|
||||
*/
|
||||
public class BPlusTreeNode {
|
||||
public static int m = 5; // 5叉树
|
||||
public int[] keywords = new int[m-1]; // 键值,用来划分数据区间
|
||||
public BPlusTreeNode[] children = new BPlusTreeNode[m];//保存子节点指针
|
||||
}
|
||||
|
||||
/**
|
||||
* 这是B+树中叶子节点的定义。
|
||||
*
|
||||
* B+树中的叶子节点跟内部节点是不一样的,
|
||||
* 叶子节点存储的是值,而非区间。
|
||||
* 这个定义里,每个叶子节点存储3个数据行的键值及地址信息。
|
||||
*
|
||||
* k值是事先计算得到的,计算的依据是让所有信息的大小正好等于页的大小:
|
||||
* PAGE_SIZE = k*4[keyw..大小]+k*8[dataAd..大小]+8[prev大小]+8[next大小]
|
||||
*/
|
||||
public class BPlusTreeLeafNode {
|
||||
public static int k = 3;
|
||||
public int[] keywords = new int[k]; // 数据的键值
|
||||
public long[] dataAddress = new long[k]; // 数据地址
|
||||
|
||||
public BPlusTreeLeafNode prev; // 这个结点在链表中的前驱结点
|
||||
public BPlusTreeLeafNode next; // 这个结点在链表中的后继结点
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
我稍微解释一下这段代码。
|
||||
|
||||
对于相同个数的数据构建m叉树索引,m叉树中的m越大,那树的高度就越小,那m叉树中的m是不是越大越好呢?到底多大才最合适呢?
|
||||
|
||||
不管是内存中的数据,还是磁盘中的数据,操作系统都是按页(一页大小通常是4KB,这个值可以通过getconfig PAGE_SIZE命令查看)来读取的,一次会读一页的数据。如果要读取的数据量超过一页的大小,就会触发多次IO操作。所以,我们在选择m大小的时候,要尽量让每个节点的大小等于一个页的大小。读取一个节点,只需要一次磁盘IO操作。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ea/30/ea4472fd7bb7fa948532c8c8ba334430.jpg" alt="">
|
||||
|
||||
尽管索引可以提高数据库的查询效率,但是,作为一名开发工程师,你应该也知道,索引有利也有弊,它也会让写入数据的效率下降。这是为什么呢?
|
||||
|
||||
数据的写入过程,会涉及索引的更新,这是索引导致写入变慢的主要原因。
|
||||
|
||||
对于一个B+树来说,m值是根据页的大小事先计算好的,也就是说,每个节点最多只能有m个子节点。在往数据库中写入数据的过程中,这样就有可能使索引中某些节点的子节点个数超过m,这个节点的大小超过了一个页的大小,读取这样一个节点,就会导致多次磁盘IO操作。我们该如何解决这个问题呢?
|
||||
|
||||
实际上,处理思路并不复杂。我们只需要将这个节点分裂成两个节点。但是,节点分裂之后,其上层父节点的子节点个数就有可能超过m个。不过这也没关系,我们可以用同样的方法,将父节点也分裂成两个节点。这种级联反应会从下往上,一直影响到根节点。这个分裂过程,你可以结合着下面这个图一块看,会更容易理解(图中的B+树是一个三叉树。我们限定叶子节点中,数据的个数超过2个就分裂节点;非叶子节点中,子节点的个数超过3个就分裂节点)。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/18/e0/1800bc80e1e05b32a042ff6873e6c2e0.jpg" alt="">
|
||||
|
||||
正是因为要时刻保证B+树索引是一个m叉树,所以,索引的存在会导致数据库写入的速度降低。实际上,不光写入数据会变慢,删除数据也会变慢。这是为什么呢?
|
||||
|
||||
我们在删除某个数据的时候,也要对应地更新索引节点。这个处理思路有点类似跳表中删除数据的处理思路。频繁的数据删除,就会导致某些节点中,子节点的个数变得非常少,长此以往,如果每个节点的子节点都比较少,势必会影响索引的效率。
|
||||
|
||||
我们可以设置一个阈值。在B+树中,这个阈值等于m/2。如果某个节点的子节点个数小于m/2,我们就将它跟相邻的兄弟节点合并。不过,合并之后节点的子节点个数有可能会超过m。针对这种情况,我们可以借助插入数据时候的处理方法,再分裂节点。
|
||||
|
||||
文字描述不是很直观,我举了一个删除操作的例子,你可以对比着看下(图中的B+树是一个五叉树。我们限定叶子节点中,数据的个数少于2个就合并节点;非叶子节点中,子节点的个数少于3个就合并节点。)。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/17/18/1730e34450dad29f062e76536622c918.jpg" alt="">
|
||||
|
||||
数据库索引以及B+树的由来,到此就讲完了。你有没有发现,B+树的结构和操作,跟跳表非常类似。理论上讲,对跳表稍加改造,也可以替代B+树,作为数据库的索引实现的。
|
||||
|
||||
B+树发明于1972年,跳表发明于1989年,我们可以大胆猜想下,跳表的作者有可能就是受了B+树的启发,才发明出跳表来的。不过,这个也无从考证了。
|
||||
|
||||
## 总结引申
|
||||
|
||||
今天,我们讲解了数据库索引实现,依赖的底层数据结构,B+树。它通过存储在磁盘的多叉树结构,做到了时间、空间的平衡,既保证了执行效率,又节省了内存。
|
||||
|
||||
前面的讲解中,为了一步一步详细地给你介绍B+树的由来,内容看起来比较零散。为了方便你掌握和记忆,我这里再总结一下B+树的特点:
|
||||
|
||||
<li>
|
||||
每个节点中子节点的个数不能超过m,也不能小于m/2;
|
||||
</li>
|
||||
<li>
|
||||
根节点的子节点个数可以不超过m/2,这是一个例外;
|
||||
</li>
|
||||
<li>
|
||||
m叉树只存储索引,并不真正存储数据,这个有点儿类似跳表;
|
||||
</li>
|
||||
<li>
|
||||
通过链表将叶子节点串联在一起,这样可以方便按区间查找;
|
||||
</li>
|
||||
<li>
|
||||
一般情况,根节点会被存储在内存中,其他节点存储在磁盘中。
|
||||
</li>
|
||||
|
||||
除了B+树,你可能还听说过B树、B-树,我这里简单提一下。实际上,B-树就是B树,英文翻译都是B-Tree,这里的“-”并不是相对B+树中的“+”,而只是一个连接符。这个很容易误解,所以我强调下。
|
||||
|
||||
而B树实际上是低级版的B+树,或者说B+树是B树的改进版。B树跟B+树的不同点主要集中在这几个地方:
|
||||
|
||||
<li>
|
||||
B+树中的节点不存储数据,只是索引,而B树中的节点存储数据;
|
||||
</li>
|
||||
<li>
|
||||
B树中的叶子节点并不需要链表来串联。
|
||||
</li>
|
||||
|
||||
也就是说,B树只是一个每个节点的子节点个数不能小于m/2的m叉树。
|
||||
|
||||
## 课后思考
|
||||
|
||||
<li>
|
||||
B+树中,将叶子节点串起来的链表,是单链表还是双向链表?为什么?
|
||||
</li>
|
||||
<li>
|
||||
我们对平衡二叉查找树进行改造,将叶子节点串在链表中,就支持了按照区间来查找数据。我们在[散列表(下)](https://time.geekbang.org/column/article/64858)讲到,散列表也经常跟链表一块使用,如果我们把散列表中的结点,也用链表串起来,能否支持按照区间查找数据呢?
|
||||
</li>
|
||||
|
||||
欢迎留言和我分享,也欢迎点击“请朋友读”,把今天的内容分享给你的好友,和他一起讨论、学习。
|
||||
147
极客时间专栏/geek/数据结构与算法之美/高级篇/49 | 搜索:如何用A*搜索算法实现游戏中的寻路功能?.md
Normal file
147
极客时间专栏/geek/数据结构与算法之美/高级篇/49 | 搜索:如何用A*搜索算法实现游戏中的寻路功能?.md
Normal file
@@ -0,0 +1,147 @@
|
||||
<audio id="audio" title="49 | 搜索:如何用A*搜索算法实现游戏中的寻路功能?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/f8/39/f8a8f468e8fb01aaf325815fee737e39.mp3"></audio>
|
||||
|
||||
魔兽世界、仙剑奇侠传这类MMRPG游戏,不知道你有没有玩过?在这些游戏中,有一个非常重要的功能,那就是人物角色自动寻路。**当人物处于游戏地图中的某个位置的时候,我们用鼠标点击另外一个相对较远的位置,人物就会自动地绕过障碍物走过去。玩过这么多游戏,不知你是否思考过,这个功能是怎么实现的呢?**
|
||||
|
||||
## 算法解析
|
||||
|
||||
实际上,这是一个非常典型的搜索问题。人物的起点就是他当下所在的位置,终点就是鼠标点击的位置。我们需要在地图中,找一条从起点到终点的路径。这条路径要绕过地图中所有障碍物,并且看起来要是一种非常聪明的走法。所谓“聪明”,笼统地解释就是,走的路不能太绕。理论上讲,最短路径显然是最聪明的走法,是这个问题的最优解。
|
||||
|
||||
不过,在[第44节](https://time.geekbang.org/column/article/76468)最优出行路线规划问题中,我们也讲过,如果图非常大,那Dijkstra最短路径算法的执行耗时会很多。在真实的软件开发中,我们面对的是超级大的地图和海量的寻路请求,算法的执行效率太低,这显然是无法接受的。
|
||||
|
||||
实际上,像出行路线规划、游戏寻路,这些真实软件开发中的问题,一般情况下,我们都不需要非得求最优解(也就是最短路径)。在权衡路线规划质量和执行效率的情况下,我们只需要寻求一个次优解就足够了。那**如何快速找出一条接近于最短路线的次优路线呢?**
|
||||
|
||||
这个快速的路径规划算法,就是我们今天要学习的**A*算法**。实际上,A*算法是对Dijkstra算法的优化和改造。如何将Dijkstra算法改造成A*算法呢?为了更好地理解接下来要讲的内容,我建议你先温习下第44节中的Dijkstra算法的实现原理。
|
||||
|
||||
Dijkstra算法有点儿类似BFS算法,它每次找到跟起点最近的顶点,往外扩展。这种往外扩展的思路,其实有些盲目。为什么这么说呢?我举一个例子来给你解释一下。下面这个图对应一个真实的地图,每个顶点在地图中的位置,我们用一个二维坐标(x,y)来表示,其中,x表示横坐标,y表示纵坐标。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/11/dd/11840cc13071fe2da67675338e46cadd.jpg" alt="">
|
||||
|
||||
在Dijkstra算法的实现思路中,我们用一个优先级队列,来记录已经遍历到的顶点以及这个顶点与起点的路径长度。顶点与起点路径长度越小,就越先被从优先级队列中取出来扩展,从图中举的例子可以看出,尽管我们找的是从s到t的路线,但是最先被搜索到的顶点依次是1,2,3。通过肉眼来观察,这个搜索方向跟我们期望的路线方向(s到t是从西向东)是反着的,路线搜索的方向明显“跑偏”了。
|
||||
|
||||
之所以会“跑偏”,那是因为我们是按照顶点与起点的路径长度的大小,来安排出队列顺序的。与起点越近的顶点,就会越早出队列。我们并没有考虑到这个顶点到终点的距离,所以,在地图中,尽管1,2,3三个顶点离起始顶点最近,但离终点却越来越远。
|
||||
|
||||
如果我们综合更多的因素,把这个顶点到终点可能还要走多远,也考虑进去,综合来判断哪个顶点该先出队列,那是不是就可以避免“跑偏”呢?
|
||||
|
||||
当我们遍历到某个顶点的时候,从起点走到这个顶点的路径长度是确定的,我们记作g(i)(i表示顶点编号)。但是,从这个顶点到终点的路径长度,我们是未知的。虽然确切的值无法提前知道,但是我们可以用其他估计值来代替。
|
||||
|
||||
这里我们可以通过这个顶点跟终点之间的直线距离,也就是欧几里得距离,来近似地估计这个顶点跟终点的路径长度(注意:路径长度跟直线距离是两个概念)。我们把这个距离记作h(i)(i表示这个顶点的编号),专业的叫法是**启发函数**(heuristic function)。因为欧几里得距离的计算公式,会涉及比较耗时的开根号计算,所以,我们一般通过另外一个更加简单的距离计算公式,那就是**曼哈顿距离**(Manhattan distance)。曼哈顿距离是两点之间横纵坐标的距离之和。计算的过程只涉及加减法、符号位反转,所以比欧几里得距离更加高效。
|
||||
|
||||
```
|
||||
int hManhattan(Vertex v1, Vertex v2) { // Vertex表示顶点,后面有定义
|
||||
return Math.abs(v1.x - v2.x) + Math.abs(v1.y - v2.y);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
原来只是单纯地通过顶点与起点之间的路径长度g(i),来判断谁先出队列,现在有了顶点到终点的路径长度估计值,我们通过两者之和f(i)=g(i)+h(i),来判断哪个顶点该最先出队列。综合两部分,我们就能有效避免刚刚讲的“跑偏”。这里f(i)的专业叫法是**估价函数**(evaluation function)。
|
||||
|
||||
从刚刚的描述,我们可以发现,A*算法就是对Dijkstra算法的简单改造。实际上,代码实现方面,我们也只需要稍微改动几行代码,就能把Dijkstra算法的代码实现,改成A*算法的代码实现。
|
||||
|
||||
在A*算法的代码实现中,顶点Vertex类的定义,跟Dijkstra算法中的定义,稍微有点儿区别,多了x,y坐标,以及刚刚提到的f(i)值。图Graph类的定义跟Dijkstra算法中的定义一样。为了避免重复,我这里就没有再贴出来了。
|
||||
|
||||
```
|
||||
private class Vertex {
|
||||
public int id; // 顶点编号ID
|
||||
public int dist; // 从起始顶点,到这个顶点的距离,也就是g(i)
|
||||
public int f; // 新增:f(i)=g(i)+h(i)
|
||||
public int x, y; // 新增:顶点在地图中的坐标(x, y)
|
||||
public Vertex(int id, int x, int y) {
|
||||
this.id = id;
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
this.f = Integer.MAX_VALUE;
|
||||
this.dist = Integer.MAX_VALUE;
|
||||
}
|
||||
}
|
||||
// Graph类的成员变量,在构造函数中初始化
|
||||
Vertex[] vertexes = new Vertex[this.v];
|
||||
// 新增一个方法,添加顶点的坐标
|
||||
public void addVetex(int id, int x, int y) {
|
||||
vertexes[id] = new Vertex(id, x, y)
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
A*算法的代码实现的主要逻辑是下面这段代码。它跟Dijkstra算法的代码实现,主要有3点区别:
|
||||
|
||||
<li>
|
||||
优先级队列构建的方式不同。A*算法是根据f值(也就是刚刚讲到的f(i)=g(i)+h(i))来构建优先级队列,而Dijkstra算法是根据dist值(也就是刚刚讲到的g(i))来构建优先级队列;
|
||||
</li>
|
||||
<li>
|
||||
A*算法在更新顶点dist值的时候,会同步更新f值;
|
||||
</li>
|
||||
<li>
|
||||
循环结束的条件也不一样。Dijkstra算法是在终点出队列的时候才结束,A*算法是一旦遍历到终点就结束。
|
||||
</li>
|
||||
|
||||
```
|
||||
public void astar(int s, int t) { // 从顶点s到顶点t的路径
|
||||
int[] predecessor = new int[this.v]; // 用来还原路径
|
||||
// 按照vertex的f值构建的小顶堆,而不是按照dist
|
||||
PriorityQueue queue = new PriorityQueue(this.v);
|
||||
boolean[] inqueue = new boolean[this.v]; // 标记是否进入过队列
|
||||
vertexes[s].dist = 0;
|
||||
vertexes[s].f = 0;
|
||||
queue.add(vertexes[s]);
|
||||
inqueue[s] = true;
|
||||
while (!queue.isEmpty()) {
|
||||
Vertex minVertex = queue.poll(); // 取堆顶元素并删除
|
||||
for (int i = 0; i < adj[minVertex.id].size(); ++i) {
|
||||
Edge e = adj[minVertex.id].get(i); // 取出一条minVetex相连的边
|
||||
Vertex nextVertex = vertexes[e.tid]; // minVertex-->nextVertex
|
||||
if (minVertex.dist + e.w < nextVertex.dist) { // 更新next的dist,f
|
||||
nextVertex.dist = minVertex.dist + e.w;
|
||||
nextVertex.f
|
||||
= nextVertex.dist+hManhattan(nextVertex, vertexes[t]);
|
||||
predecessor[nextVertex.id] = minVertex.id;
|
||||
if (inqueue[nextVertex.id] == true) {
|
||||
queue.update(nextVertex);
|
||||
} else {
|
||||
queue.add(nextVertex);
|
||||
inqueue[nextVertex.id] = true;
|
||||
}
|
||||
}
|
||||
if (nextVertex.id == t) { // 只要到达t就可以结束while了
|
||||
queue.clear(); // 清空queue,才能推出while循环
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// 输出路径
|
||||
System.out.print(s);
|
||||
print(s, t, predecessor); // print函数请参看Dijkstra算法的实现
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
**尽管A*算法可以更加快速地找到从起点到终点的路线,但是它并不能像Dijkstra算法那样,找到最短路线。这是为什么呢?**
|
||||
|
||||
要找出起点s到终点t的最短路径,最简单的方法是,通过回溯穷举所有从s到达t的不同路径,然后对比找出最短的那个。不过很显然,回溯算法的执行效率非常低,是指数级的。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/38/4a/38ebd9aab387669465226fc7f644064a.jpg" alt="">
|
||||
|
||||
Dijkstra算法在此基础之上,利用动态规划的思想,对回溯搜索进行了剪枝,只保留起点到某个顶点的最短路径,继续往外扩展搜索。动态规划相较于回溯搜索,只是换了一个实现思路,但它实际上也考察到了所有从起点到终点的路线,所以才能得到最优解。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ca/77/caad286fc67333b77e8ed5c85ce2e377.jpg" alt="">
|
||||
|
||||
A*算法之所以不能像Dijkstra算法那样,找到最短路径,主要原因是两者的while循环结束条件不一样。刚刚我们讲过,Dijkstra算法是在终点出队列的时候才结束,A*算法是一旦遍历到终点就结束。对于Dijkstra算法来说,当终点出队列的时候,终点的dist值是优先级队列中所有顶点的最小值,即便再运行下去,终点的dist值也不会再被更新了。对于A*算法来说,一旦遍历到终点,我们就结束while循环,这个时候,终点的dist值未必是最小值。
|
||||
|
||||
A*算法利用贪心算法的思路,每次都找f值最小的顶点出队列,一旦搜索到终点就不在继续考察其他顶点和路线了。所以,它并没有考察所有的路线,也就不可能找出最短路径了。
|
||||
|
||||
搞懂了A*算法,我们再来看下,**如何借助A*算法解决今天的游戏寻路问题?**
|
||||
|
||||
要利用A*算法解决这个问题,我们只需要把地图,抽象成图就可以了。不过,游戏中的地图跟第44节中讲的我们平常用的地图是不一样的。因为游戏中的地图并不像我们现实生活中那样,存在规划非常清晰的道路,更多的是宽阔的荒野、草坪等。所以,我们没法利用44节中讲到的抽象方法,把岔路口抽象成顶点,把道路抽象成边。
|
||||
|
||||
实际上,我们可以换一种抽象的思路,把整个地图分割成一个一个的小方块。在某一个方块上的人物,只能往上下左右四个方向的方块上移动。我们可以把每个方块看作一个顶点。两个方块相邻,我们就在它们之间,连两条有向边,并且边的权值都是1。所以,这个问题就转化成了,在一个有向有权图中,找某个顶点到另一个顶点的路径问题。将地图抽象成边权值为1的有向图之后,我们就可以套用A*算法,来实现游戏中人物的自动寻路功能了。
|
||||
|
||||
## 总结引申
|
||||
|
||||
我们今天讲的A*算法属于一种**启发式搜索算法**(Heuristically Search Algorithm)。实际上,启发式搜索算法并不仅仅只有A*算法,还有很多其他算法,比如IDA*算法、蚁群算法、遗传算法、模拟退火算法等。如果感兴趣,你可以自行研究下。
|
||||
|
||||
启发式搜索算法利用估价函数,避免“跑偏”,贪心地朝着最有可能到达终点的方向前进。这种算法找出的路线,并不是最短路线。但是,实际的软件开发中的路线规划问题,我们往往并不需要非得找最短路线。所以,鉴于启发式搜索算法能很好地平衡路线质量和执行效率,它在实际的软件开发中的应用更加广泛。实际上,在第44节中,我们讲到的地图App中的出行路线规划问题,也可以利用启发式搜索算法来实现。
|
||||
|
||||
## 课后思考
|
||||
|
||||
我们之前讲的“迷宫问题”是否可以借助A*算法来更快速地找到一个走出去的路线呢?如果可以,请具体讲讲该怎么来做;如果不可以,请说说原因。
|
||||
|
||||
欢迎留言和我分享,也欢迎点击“请朋友读”,把今天的内容分享给你的好友,和他一起讨论、学习。
|
||||
77
极客时间专栏/geek/数据结构与算法之美/高级篇/50 | 索引:如何在海量数据中快速查找某个数据?.md
Normal file
77
极客时间专栏/geek/数据结构与算法之美/高级篇/50 | 索引:如何在海量数据中快速查找某个数据?.md
Normal file
@@ -0,0 +1,77 @@
|
||||
<audio id="audio" title="50 | 索引:如何在海量数据中快速查找某个数据?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/d6/06/d61d6fff3446ffb16d238acdceee6c06.mp3"></audio>
|
||||
|
||||
在第48节中,我们讲了MySQL数据库索引的实现原理。MySQL底层依赖的是B+树这种数据结构。留言里有同学问我,那**类似Redis这样的Key-Value数据库中的索引,又是怎么实现的呢?底层依赖的又是什么数据结构呢?**
|
||||
|
||||
今天,我就来讲一下索引这种常用的技术解决思路,底层往往会依赖哪些数据结构。同时,通过索引这个应用场景,我也带你回顾一下,之前我们学过的几种支持动态集合的数据结构。
|
||||
|
||||
## 为什么需要索引?
|
||||
|
||||
在实际的软件开发中,业务纷繁复杂,功能千变万化,但是,万变不离其宗。如果抛开这些业务和功能的外壳,其实它们的本质都可以抽象为“对数据的存储和计算”。对应到数据结构和算法中,那“存储”需要的就是数据结构,“计算”需要的就是算法。
|
||||
|
||||
对于存储的需求,功能上无外乎增删改查。这其实并不复杂。但是,一旦存储的数据很多,那性能就成了这些系统要关注的重点,特别是在一些跟存储相关的基础系统(比如MySQL数据库、分布式文件系统等)、中间件(比如消息中间件RocketMQ等)中。
|
||||
|
||||
“如何节省存储空间、如何提高数据增删改查的执行效率”,这样的问题就成了设计的重点。而这些系统的实现,都离不开一个东西,那就是**索引**。不夸张地说,索引设计得好坏,直接决定了这些系统是否优秀。
|
||||
|
||||
索引这个概念,非常好理解。你可以类比书籍的目录来理解。如果没有目录,我们想要查找某个知识点的时候,就要一页一页翻。通过目录,我们就可以快速定位相关知识点的页数,查找的速度也会有质的提高。
|
||||
|
||||
## 索引的需求定义
|
||||
|
||||
索引的概念不难理解,我想你应该已经搞明白。接下来,我们就分析一下,在设计索引的过程中,需要考虑到的一些因素,换句话说就是,我们该如何定义清楚需求呢?
|
||||
|
||||
对于系统设计需求,我们一般可以从**功能性需求**和**非功能性需求**两方面来分析,这个我们之前也说过。因此,这个问题也不例外。
|
||||
|
||||
### 1.功能性需求
|
||||
|
||||
对于功能性需求需要考虑的点,我把它们大致概括成下面这几点。
|
||||
|
||||
**数据是格式化数据还是非格式化数据**?要构建索引的原始数据,类型有很多。我把它分为两类,一类是结构化数据,比如,MySQL中的数据;另一类是非结构化数据,比如搜索引擎中网页。对于非结构化数据,我们一般需要做预处理,提取出查询关键词,对关键词构建索引。
|
||||
|
||||
**数据是静态数据还是动态数据**?如果原始数据是一组静态数据,也就是说,不会有数据的增加、删除、更新操作,所以,我们在构建索引的时候,只需要考虑查询效率就可以了。这样,索引的构建就相对简单些。不过,大部分情况下,我们都是对动态数据构建索引,也就是说,我们不仅要考虑到索引的查询效率,在原始数据更新的同时,我们还需要动态地更新索引。支持动态数据集合的索引,设计起来相对也要更加复杂些。
|
||||
|
||||
**索引存储在内存还是硬盘**?如果索引存储在内存中,那查询的速度肯定要比存储在磁盘中的高。但是,如果原始数据量很大的情况下,对应的索引可能也会很大。这个时候,因为内存有限,我们可能就不得不将索引存储在磁盘中了。实际上,还有第三种情况,那就是一部分存储在内存,一部分存储在磁盘,这样就可以兼顾内存消耗和查询效率。
|
||||
|
||||
**单值查找还是区间查找**?所谓单值查找,也就是根据查询关键词等于某个值的数据。这种查询需求最常见。所谓区间查找,就是查找关键词处于某个区间值的所有数据。你可以类比MySQL数据库的查询需求,自己想象一下。实际上,不同的应用场景,查询的需求会多种多样。
|
||||
|
||||
**单关键词查找还是多关键词组合查找**?比如,搜索引擎中构建的索引,既要支持一个关键词的查找,比如“数据结构”,也要支持组合关键词查找,比如“数据结构 AND 算法”。对于单关键词的查找,索引构建起来相对简单些。对于多关键词查询来说,要分多种情况。像MySQL这种结构化数据的查询需求,我们可以实现针对多个关键词的组合,建立索引;对于像搜索引擎这样的非结构数据的查询需求,我们可以针对单个关键词构建索引,然后通过集合操作,比如求并集、求交集等,计算出多个关键词组合的查询结果。
|
||||
|
||||
实际上,不同的场景,不同的原始数据,对于索引的需求也会千差万别。我这里只列举了一些比较有共性的需求。
|
||||
|
||||
### 2.非功能性需求
|
||||
|
||||
讲完了功能性需求,我们再来看,索引设计的非功能性需求。
|
||||
|
||||
**不管是存储在内存中还是磁盘中,索引对存储空间的消耗不能过大**。如果存储在内存中,索引对占用存储空间的限制就会非常苛刻。毕竟内存空间非常有限,一个中间件启动后就占用几个GB的内存,开发者显然是无法接受的。如果存储在硬盘中,那索引对占用存储空间的限制,稍微会放宽一些。但是,我们也不能掉以轻心。因为,有时候,索引对存储空间的消耗会超过原始数据。
|
||||
|
||||
**在考虑索引查询效率的同时,我们还要考虑索引的维护成本**。索引的目的是提高查询效率,但是,基于动态数据集合构建的索引,我们还要考虑到,索引的维护成本。因为在原始数据动态增删改的同时,我们也需要动态地更新索引。而索引的更新势必会影响到增删改操作的性能。
|
||||
|
||||
## 构建索引常用的数据结构有哪些?
|
||||
|
||||
我刚刚从很宏观的角度,总结了在索引设计的过程中,需要考虑的一些共性因素。现在,我们就来看,对于不同需求的索引结构,底层一般使用哪种数据结构。
|
||||
|
||||
实际上,常用来构建索引的数据结构,就是我们之前讲过的几种支持动态数据集合的数据结构。比如,散列表、红黑树、跳表、B+树。除此之外,位图、布隆过滤器可以作为辅助索引,有序数组可以用来对静态数据构建索引。
|
||||
|
||||
我们知道,**散列表**增删改查操作的性能非常好,时间复杂度是O(1)。一些键值数据库,比如Redis、Memcache,就是使用散列表来构建索引的。这类索引,一般都构建在内存中。
|
||||
|
||||
**红黑树**作为一种常用的平衡二叉查找树,数据插入、删除、查找的时间复杂度是O(logn),也非常适合用来构建内存索引。Ext文件系统中,对磁盘块的索引,用的就是红黑树。
|
||||
|
||||
**B+树**比起红黑树来说,更加适合构建存储在磁盘中的索引。B+树是一个多叉树,所以,对相同个数的数据构建索引,B+树的高度要低于红黑树。当借助索引查询数据的时候,读取B+树索引,需要的磁盘IO次数会更少。所以,大部分关系型数据库的索引,比如MySQL、Oracle,都是用B+树来实现的。
|
||||
|
||||
**跳表**也支持快速添加、删除、查找数据。而且,我们通过灵活调整索引结点个数和数据个数之间的比例,可以很好地平衡索引对内存的消耗及其查询效率。Redis中的有序集合,就是用跳表来构建的。
|
||||
|
||||
除了散列表、红黑树、B+树、跳表之外,位图和布隆过滤器这两个数据结构,也可以用于索引中,辅助存储在磁盘中的索引,加速数据查找的效率。我们来看下,具体是怎么做的?
|
||||
|
||||
我们知道,**布隆过滤器**有一定的判错率。但是,我们可以规避它的短处,发挥它的长处。尽管对于判定存在的数据,有可能并不存在,但是对于判定不存在的数据,那肯定就不存在。而且,布隆过滤器还有一个更大的特点,那就是内存占用非常少。我们可以针对数据,构建一个布隆过滤器,并且存储在内存中。当要查询数据的时候,我们可以先通过布隆过滤器,判定是否存在。如果通过布隆过滤器判定数据不存在,那我们就没有必要读取磁盘中的索引了。对于数据不存在的情况,数据查询就更加快速了。
|
||||
|
||||
实际上,有序数组也可以被作为索引。如果数据是静态的,也就是不会有插入、删除、更新操作,那我们可以把数据的关键词(查询用的)抽取出来,组织成有序数组,然后利用二分查找算法来快速查找数据。
|
||||
|
||||
## 总结引申
|
||||
|
||||
今天这节算是一节总结课。我从索引这个非常常用的技术方案,给你展示了散列表、红黑树、跳表、位图、布隆过滤器、有序数组这些数据结构的应用场景。学习完这节课之后,不知道你对这些数据结构以及索引,有没有更加清晰的认识呢?
|
||||
|
||||
从这一节内容中,你应该可以看出,架构设计离不开数据结构和算法。要想成长为一个优秀的业务架构师、基础架构师,数据结构和算法的根基一定要打稳。因为,那些看似很惊艳的架构设计思路,实际上,都是来自最常用的数据结构和算法。
|
||||
|
||||
## 课后思考
|
||||
|
||||
你知道基础系统、中间件、开源软件等系统中,有哪些用到了索引吗?这些系统的索引是如何实现的呢?
|
||||
|
||||
欢迎留言和我分享,也欢迎点击“请朋友读”,把今天的内容分享给你的好友,和他一起讨论、学习。
|
||||
65
极客时间专栏/geek/数据结构与算法之美/高级篇/51 | 并行算法:如何利用并行处理提高算法的执行效率?.md
Normal file
65
极客时间专栏/geek/数据结构与算法之美/高级篇/51 | 并行算法:如何利用并行处理提高算法的执行效率?.md
Normal file
@@ -0,0 +1,65 @@
|
||||
<audio id="audio" title="51 | 并行算法:如何利用并行处理提高算法的执行效率?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/b7/13/b754a135eb40b85fc2yy4676aa1c0c13.mp3"></audio>
|
||||
|
||||
时间复杂度是衡量算法执行效率的一种标准。但是,时间复杂度并不能跟性能划等号。在真实的软件开发中,即便在不降低时间复杂度的情况下,也可以通过一些优化手段,提升代码的执行效率。毕竟,对于实际的软件开发来说,即便是像10%、20%这样微小的性能提升,也是非常可观的。
|
||||
|
||||
算法的目的就是为了提高代码执行的效率。那**当算法无法再继续优化的情况下,我们该如何来进一步提高执行效率呢**?我们今天就讲一种非常简单但又非常好用的优化方法,那就是并行计算。今天,我就通过几个例子,给你展示一下,**如何借助并行计算的处理思想对算法进行改造?**
|
||||
|
||||
## 并行排序
|
||||
|
||||
假设我们要给大小为8GB的数据进行排序,并且,我们机器的内存可以一次性容纳这么多数据。对于排序来说,最常用的就是时间复杂度为O(nlogn)的三种排序算法,归并排序、快速排序、堆排序。从理论上讲,这个排序问题,已经很难再从算法层面优化了。而利用并行的处理思想,我们可以很轻松地将这个给8GB数据排序问题的执行效率提高很多倍。具体的实现思路有下面两种。
|
||||
|
||||
**第一种是对归并排序并行化处理**。我们可以将这8GB的数据划分成16个小的数据集合,每个集合包含500MB的数据。我们用16个线程,并行地对这16个500MB的数据集合进行排序。这16个小集合分别排序完成之后,我们再将这16个有序集合合并。
|
||||
|
||||
**第二种是对快速排序并行化处理**。我们通过扫描一遍数据,找到数据所处的范围区间。我们把这个区间从小到大划分成16个小区间。我们将8GB的数据划分到对应的区间中。针对这16个小区间的数据,我们启动16个线程,并行地进行排序。等到16个线程都执行结束之后,得到的数据就是有序数据了。
|
||||
|
||||
对比这两种处理思路,它们利用的都是分治的思想,对数据进行分片,然后并行处理。它们的区别在于,第一种处理思路是,先随意地对数据分片,排序之后再合并。第二种处理思路是,先对数据按照大小划分区间,然后再排序,排完序就不需要再处理了。这个跟归并和快排的区别如出一辙。
|
||||
|
||||
这里我还要多说几句,如果要排序的数据规模不是8GB,而是1TB,那问题的重点就不是算法的执行效率了,而是数据的读取效率。因为1TB的数据肯定是存在硬盘中,无法一次性读取到内存中,这样在排序的过程中,就会有频繁地磁盘数据的读取和写入。如何减少磁盘的IO操作,减少磁盘数据读取和写入的总量,就变成了优化的重点。不过这个不是我们这节要讨论的重点,你可以自己思考下。
|
||||
|
||||
## 并行查找
|
||||
|
||||
我们知道,散列表是一种非常适合快速查找的数据结构。
|
||||
|
||||
如果我们是给动态数据构建索引,在数据不断加入的时候,散列表的装载因子就会越来越大。为了保证散列表性能不下降,我们就需要对散列表进行动态扩容。对如此大的散列表进行动态扩容,一方面比较耗时,另一方面比较消耗内存。比如,我们给一个2GB大小的散列表进行扩容,扩展到原来的1.5倍,也就是3GB大小。这个时候,实际存储在散列表中的数据只有不到2GB,所以内存的利用率只有60%,有1GB的内存是空闲的。
|
||||
|
||||
实际上,我们可以将数据随机分割成k份(比如16份),每份中的数据只有原来的1/k,然后我们针对这k个小数据集合分别构建散列表。这样,散列表的维护成本就变低了。当某个小散列表的装载因子过大的时候,我们可以单独对这个散列表进行扩容,而其他散列表不需要进行扩容。
|
||||
|
||||
还是刚才那个例子,假设现在有2GB的数据,我们放到16个散列表中,每个散列表中的数据大约是150MB。当某个散列表需要扩容的时候,我们只需要额外增加150*0.5=75MB的内存(假设还是扩容到原来的1.5倍)。无论从扩容的执行效率还是内存的利用率上,这种多个小散列表的处理方法,都要比大散列表高效。
|
||||
|
||||
当我们要查找某个数据的时候,我们只需要通过16个线程,并行地在这16个散列表中查找数据。这样的查找性能,比起一个大散列表的做法,也并不会下降,反倒有可能提高。
|
||||
|
||||
当往散列表中添加数据的时候,我们可以选择将这个新数据放入装载因子最小的那个散列表中,这样也有助于减少散列冲突。
|
||||
|
||||
## 并行字符串匹配
|
||||
|
||||
我们前面学过,在文本中查找某个关键词这样一个功能,可以通过字符串匹配算法来实现。我们之前学过的字符串匹配算法有KMP、BM、RK、BF等。当在一个不是很长的文本中查找关键词的时候,这些字符串匹配算法中的任何一个,都可以表现得非常高效。但是,如果我们处理的是超级大的文本,那处理的时间可能就会变得很长,那有没有办法加快匹配速度呢?
|
||||
|
||||
我们可以把大的文本,分割成k个小文本。假设k是16,我们就启动16个线程,并行地在这16个小文本中查找关键词,这样整个查找的性能就提高了16倍。16倍效率的提升,从理论的角度来说并不多。但是,对于真实的软件开发来说,这显然是一个非常可观的优化。
|
||||
|
||||
不过,这里还有一个细节要处理,那就是原本包含在大文本中的关键词,被一分为二,分割到两个小文本中,这就会导致尽管大文本中包含这个关键词,但在这16个小文本中查找不到它。实际上,这个问题也不难解决,我们只需要针对这种特殊情况,做一些特殊处理就可以了。
|
||||
|
||||
我们假设关键词的长度是m。我们在每个小文本的结尾和开始各取m个字符串。前一个小文本的末尾m个字符和后一个小文本的开头m个字符,组成一个长度是2m的字符串。我们再拿关键词,在这个长度为2m的字符串中再重新查找一遍,就可以补上刚才的漏洞了。
|
||||
|
||||
## 并行搜索
|
||||
|
||||
前面我们学习过好几种搜索算法,它们分别是广度优先搜索、深度优先搜索、Dijkstra最短路径算法、A*启发式搜索算法。对于广度优先搜索算法,我们也可以将其改造成并行算法。
|
||||
|
||||
广度优先搜索是一种逐层搜索的搜索策略。基于当前这一层顶点,我们可以启动多个线程,并行地搜索下一层的顶点。在代码实现方面,原来广度优先搜索的代码实现,是通过一个队列来记录已经遍历到但还没有扩展的顶点。现在,经过改造之后的并行广度优先搜索算法,我们需要利用两个队列来完成扩展顶点的工作。
|
||||
|
||||
假设这两个队列分别是队列A和队列B。多线程并行处理队列A中的顶点,并将扩展得到的顶点存储在队列B中。等队列A中的顶点都扩展完成之后,队列A被清空,我们再并行地扩展队列B中的顶点,并将扩展出来的顶点存储在队列A。这样两个队列循环使用,就可以实现并行广度优先搜索算法。
|
||||
|
||||
## 总结引申
|
||||
|
||||
上一节,我们通过实际软件开发中的“索引”这一技术点,回顾了之前学过的一些支持动态数据集合的数据结构。今天,我们又通过“并行算法”这个话题,回顾了之前学过的一些算法。
|
||||
|
||||
今天的内容比较简单,没有太复杂的知识点。我通过一些例子,比如并行排序、查找、搜索、字符串匹配,给你展示了并行处理的实现思路,也就是对数据进行分片,对没有依赖关系的任务,并行地执行。
|
||||
|
||||
并行计算是一个工程上的实现思路,尽管跟算法关系不大,但是,在实际的软件开发中,它确实可以非常巧妙地提高程序的运行效率,是一种非常好用的性能优化手段。
|
||||
|
||||
特别是,当要处理的数据规模达到一定程度之后,我们无法通过继续优化算法,来提高执行效率 的时候,我们就需要在实现的思路上做文章,利用更多的硬件资源,来加快执行的效率。所以,在很多超大规模数据处理中,并行处理的思想,应用非常广泛,比如MapReduce实际上就是一种并行计算框架。
|
||||
|
||||
## 课后思考
|
||||
|
||||
假设我们有n个任务,为了提高执行的效率,我们希望能并行执行任务,但是各个任务之间又有一定的依赖关系,如何根据依赖关系找出可以并行执行的任务?
|
||||
|
||||
欢迎留言和我分享,也欢迎点击“请朋友读”,把今天的内容分享给你的好友,和他一起讨论、学习。
|
||||
Reference in New Issue
Block a user