This commit is contained in:
louzefeng
2024-07-09 18:38:56 +00:00
parent 8bafaef34d
commit bf99793fd0
6071 changed files with 1017944 additions and 0 deletions

View 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-&gt;b-&gt;c-&gt;a这样的循环依赖关系。因为图中一旦出现环拓扑排序就无法工作了。实际上拓扑排序本身就是基于有向无环图的一个算法。
```
public class Graph {
private int v; // 顶点的个数
private LinkedList&lt;Integer&gt; adj[]; // 邻接表
public Graph(int v) {
this.v = v;
adj = new LinkedList[v];
for (int i=0; i&lt;v; ++i) {
adj[i] = new LinkedList&lt;&gt;();
}
}
public void addEdge(int s, int t) { // s先于t边s-&gt;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 &lt; v; ++i) {
for (int j = 0; j &lt; adj[i].size(); ++j) {
int w = adj[i].get(j); // i-&gt;w
inDegree[w]++;
}
}
LinkedList&lt;Integer&gt; queue = new LinkedList&lt;&gt;();
for (int i = 0; i &lt; v; ++i) {
if (inDegree[i] == 0) queue.add(i);
}
while (!queue.isEmpty()) {
int i = queue.remove();
System.out.print(&quot;-&gt;&quot; + i);
for (int j = 0; j &lt; 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-&gt;t表示s依赖于tt先于s
LinkedList&lt;Integer&gt; inverseAdj[] = new LinkedList[v];
for (int i = 0; i &lt; v; ++i) { // 申请空间
inverseAdj[i] = new LinkedList&lt;&gt;();
}
for (int i = 0; i &lt; v; ++i) { // 通过邻接表生成逆邻接表
for (int j = 0; j &lt; adj[i].size(); ++j) {
int w = adj[i].get(j); // i-&gt;w
inverseAdj[w].add(i); // w-&gt;i
}
}
boolean[] visited = new boolean[v];
for (int i = 0; i &lt; v; ++i) { // 深度优先遍历图
if (visited[i] == false) {
visited[i] = true;
dfs(i, inverseAdj, visited);
}
}
}
private void dfs(
int vertex, LinkedList&lt;Integer&gt; inverseAdj[], boolean[] visited) {
for (int i = 0; i &lt; 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(&quot;-&gt;&quot; + vertex);
}
```
这个算法包含两个关键部分。
第一部分是**通过邻接表构造逆邻接表**。邻接表中边s-&gt;t表示s先于t执行也就是t要依赖s。在逆邻接表中边s-&gt;t表示s依赖于ts后于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&lt;Integer&gt; hashTable = new HashSet&lt;&gt;(); // 保存已经访问过的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>
欢迎留言和我分享,也欢迎点击“请朋友读”,把今天的内容分享给你的好友,和他一起讨论、学习。

View 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&lt;Edge&gt; adj[]; // 邻接表
private int v; // 顶点个数
public Graph(int v) {
this.v = v;
this.adj = new LinkedList[v];
for (int i = 0; i &lt; v; ++i) {
this.adj[i] = new LinkedList&lt;&gt;();
}
}
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 &lt; 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 &lt; adj[minVertex.id].size(); ++i) {
Edge e = adj[minVertex.id].get(i); // 取出一条minVetex相连的边
Vertex nextVertex = vertexes[e.tid]; // minVertex--&gt;nextVertex
if (minVertex.dist + e.w &lt; 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(&quot;-&gt;&quot; + 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循环的执行次数不确定跟每个顶点的相邻边的个数有关我们分别记作E0E1E2……E(V-1)。如果我们把这V个顶点的边都加起来最大也不会超过图中所有边的个数EE表示边的个数
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>
欢迎留言和我分享,也欢迎点击“请朋友读”,把今天的内容分享给你的好友,和他一起讨论、学习。

View 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 &gt; nbits) return;
int byteIndex = k / 16;
int bitIndex = k % 16;
bytes[byteIndex] |= (1 &lt;&lt; bitIndex);
}
public boolean get(int k) {
if (k &gt; nbits) return false;
int byteIndex = k / 16;
int bitIndex = k % 16;
return (bytes[byteIndex] &amp; (1 &lt;&lt; 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>
欢迎留言和我分享,也欢迎点击“请朋友读”,把今天的内容分享给你的好友,和他一起讨论、学习。

View 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)。
基于这条独立事件发生概率的计算公式我们可以把PW1W2W3Wn同时出现在一条短信中 | 短信是垃圾短信)分解为下面这个公式:
<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的时候我们也就不需要这个值。
## 总结引申
今天,我们讲了基于黑名单、规则、概率统计三种垃圾短信的过滤方法,实际上,今天讲的这三种方法,还可以应用到很多类似的过滤、拦截的领域,比如垃圾邮件的过滤等等。
在讲黑名单过滤的时候,我讲到布隆过滤器可能会存在误判情况,可能会导致用户投诉。实际上,我们可以结合三种不同的过滤方式的结果,对同一个短信处理,如果三者都表明这个短信是垃圾短信,我们才把它当作垃圾短信拦截过滤,这样就会更精准。
当然,在实际的工程中,我们还需要结合具体的场景,以及大量的实验,不断去调整策略,权衡垃圾短信判定的**准确率**(是否会把不是垃圾的短信错判为垃圾短信)和**召回率**(是否能把所有的垃圾短信都找到),来实现我们的需求。
## 课后思考
关于垃圾短信过滤和骚扰电话的拦截,我们可以一块儿头脑风暴一下,看看你还有没有其他方法呢?
欢迎留言和我分享,也欢迎点击“请朋友读”,把今天的内容分享给你的好友,和他一起讨论、学习。

View 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。欧几里得距离是用来计算两个向量之间的距离的。这个概念中有两个关键词向量和距离我来给你解释一下。
一维空间是一条线我们用123……这样单个的数来表示一维空间中的某个位置二维空间是一个面我们用134222……这样的两个数来表示二维空间中的某个位置三维空间是一个立体空间我们用135317243……这样的三个数来表示三维空间中的某个位置。一维、二维、三维应该都不难理解那更高维中的某个位置该如何表示呢
类比一维、二维、三维的表示方法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里最典型的一类问题。之所以讲这部分内容主要还是想给你展示算法的强大之处利用简单的向量空间的欧几里得距离就能解决如此复杂的问题。不过今天我只给你讲解了基本的理论实践中遇到的问题还有很多比如冷启动问题产品初期积累的数据不多不足以做推荐等等。这些更加深奥的内容你可以之后自己在实践中慢慢探索。
## 课后思考
关于今天讲的推荐算法,你还能想到其他应用场景吗?
欢迎留言和我分享,也欢迎点击“请朋友读”,把今天的内容分享给你的好友,和他一起讨论、学习。

View 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 &gt; 1234 and id &lt; 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>
欢迎留言和我分享,也欢迎点击“请朋友读”,把今天的内容分享给你的好友,和他一起讨论、学习。

View 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算法它每次找到跟起点最近的顶点往外扩展。这种往外扩展的思路其实有些盲目。为什么这么说呢我举一个例子来给你解释一下。下面这个图对应一个真实的地图每个顶点在地图中的位置我们用一个二维坐标xy来表示其中x表示横坐标y表示纵坐标。
<img src="https://static001.geekbang.org/resource/image/11/dd/11840cc13071fe2da67675338e46cadd.jpg" alt="">
在Dijkstra算法的实现思路中我们用一个优先级队列来记录已经遍历到的顶点以及这个顶点与起点的路径长度。顶点与起点路径长度越小就越先被从优先级队列中取出来扩展从图中举的例子可以看出尽管我们找的是从s到t的路线但是最先被搜索到的顶点依次是123。通过肉眼来观察这个搜索方向跟我们期望的路线方向s到t是从西向东是反着的路线搜索的方向明显“跑偏”了。
之所以会“跑偏”那是因为我们是按照顶点与起点的路径长度的大小来安排出队列顺序的。与起点越近的顶点就会越早出队列。我们并没有考虑到这个顶点到终点的距离所以在地图中尽管123三个顶点离起始顶点最近但离终点却越来越远。
如果我们综合更多的因素,把这个顶点到终点可能还要走多远,也考虑进去,综合来判断哪个顶点该先出队列,那是不是就可以避免“跑偏”呢?
当我们遍历到某个顶点的时候从起点走到这个顶点的路径长度是确定的我们记作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算法中的定义稍微有点儿区别多了xy坐标以及刚刚提到的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 &lt; adj[minVertex.id].size(); ++i) {
Edge e = adj[minVertex.id].get(i); // 取出一条minVetex相连的边
Vertex nextVertex = vertexes[e.tid]; // minVertex--&gt;nextVertex
if (minVertex.dist + e.w &lt; 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*算法来更快速地找到一个走出去的路线呢?如果可以,请具体讲讲该怎么来做;如果不可以,请说说原因。
欢迎留言和我分享,也欢迎点击“请朋友读”,把今天的内容分享给你的好友,和他一起讨论、学习。

View 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+树、跳表之外,位图和布隆过滤器这两个数据结构,也可以用于索引中,辅助存储在磁盘中的索引,加速数据查找的效率。我们来看下,具体是怎么做的?
我们知道,**布隆过滤器**有一定的判错率。但是,我们可以规避它的短处,发挥它的长处。尽管对于判定存在的数据,有可能并不存在,但是对于判定不存在的数据,那肯定就不存在。而且,布隆过滤器还有一个更大的特点,那就是内存占用非常少。我们可以针对数据,构建一个布隆过滤器,并且存储在内存中。当要查询数据的时候,我们可以先通过布隆过滤器,判定是否存在。如果通过布隆过滤器判定数据不存在,那我们就没有必要读取磁盘中的索引了。对于数据不存在的情况,数据查询就更加快速了。
实际上,有序数组也可以被作为索引。如果数据是静态的,也就是不会有插入、删除、更新操作,那我们可以把数据的关键词(查询用的)抽取出来,组织成有序数组,然后利用二分查找算法来快速查找数据。
## 总结引申
今天这节算是一节总结课。我从索引这个非常常用的技术方案,给你展示了散列表、红黑树、跳表、位图、布隆过滤器、有序数组这些数据结构的应用场景。学习完这节课之后,不知道你对这些数据结构以及索引,有没有更加清晰的认识呢?
从这一节内容中,你应该可以看出,架构设计离不开数据结构和算法。要想成长为一个优秀的业务架构师、基础架构师,数据结构和算法的根基一定要打稳。因为,那些看似很惊艳的架构设计思路,实际上,都是来自最常用的数据结构和算法。
## 课后思考
你知道基础系统、中间件、开源软件等系统中,有哪些用到了索引吗?这些系统的索引是如何实现的呢?
欢迎留言和我分享,也欢迎点击“请朋友读”,把今天的内容分享给你的好友,和他一起讨论、学习。

View 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个任务为了提高执行的效率我们希望能并行执行任务但是各个任务之间又有一定的依赖关系如何根据依赖关系找出可以并行执行的任务
欢迎留言和我分享,也欢迎点击“请朋友读”,把今天的内容分享给你的好友,和他一起讨论、学习。