极客时间-数据结构与算法之美(七)

43 | 拓扑排序:如何确定代码源文件的编译依赖关系?

编译器通过分析源文件或者程序员事先写好的编译配置文件(比如 Makefile 文件),来获取这种局部的依赖关系。那编译器又该如何通过源文件两两之间的局部依赖关系,确定一个全局的编译顺序呢?

 

算法解析

这个问题的解决思路与“图”这种数据结构的一个经典算法“拓扑排序算法”有关。那什么是拓扑排序呢?

可以把源文件与源文件之间的依赖关系,抽象成一个有向图。每个源文件对应图中的一个顶点,源文件之间的依赖关系就是顶点之间的边。

如果 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)。循环执行上面的过程,直到所有的顶点都被输出。最后输出的序列,就是满足局部依赖关系的拓扑排序。

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 代码中可以看出来,每个顶点被访问了一次,每个边也都被访问了一次,所以,Kahn 算法的时间复杂度就是 O(V+E)(V 表示顶点个数,E 表示边的个数)。

DFS 算法的时间复杂度。每个顶点被访问两次,每条边都被访问一次,所以时间复杂度也是 O(V+E)。

注意,这里的图可能不是连通的,有可能是有好几个不连通的子图构成,所以,E 并不一定大于 V,两者的大小关系不确定。所以,在表示时间复杂度的时候,V、E 都要考虑在内。

总结引申

凡是需要通过局部顺序来推导全局顺序的,一般都能用拓扑排序来解决。除此之外,拓扑排序还能检测图中环的存在。对于 Kahn 算法来说,如果最后输出出来的顶点个数,少于图中顶点个数,图中还有入度不是 0 的顶点,那就说明,图中存在环。

关于图中环的检测,在递归那一节讲过一个例子,在查找最终推荐人的时候,可能会因为脏数据,造成存在循环推荐,比如,用户 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);
}
​

如果想要知道,数据库中的所有用户之间的推荐关系了,有没有存在环的情况。这个问题,就需要用到拓扑排序算法了。把用户之间的推荐关系,从数据库中加载到内存中,然后构建成今天讲的这种有向图数据结构,再利用拓扑排序,就可以快速检测出是否存在环了。

课后思考

  1. 在今天的讲解中,用图表示依赖关系的时候,如果 a 先于 b 执行,就画一条从 a 到 b 的有向边;反过来,如果 a 先于 b,画一条从 b 到 a 的有向边,表示 b 依赖 a,那今天讲的 Kahn 算法和 DFS 算法还能否正确工作呢?如果不能,应该如何改造一下呢?

    • a先于b执行,也就说b依赖于a,b指向a,这样构建有向无环图时,要找到出度为0的顶点,然后删除。dfs算法直接用正邻接表即可。kahn算法可以和上面的类似通过构建逆临接表找出入度为0的节点,其余都一样。

  2. 今天讲了两种拓扑排序算法的实现思路,Kahn 算法和 DFS 深度优先搜索算法,如果换做 BFS 广度优先搜索算法,还可以实现吗?

    • BFS也能实现,因为遍历只是实现拓扑排序的一个“辅助手段”,本质上是帮助找到优先执行的顶点。其实与DFS一样,BFS也是从某个节点开始,找到所有与其相连通的节点。区别在于BFS是一层一层找(递归函数在for循环外),DFS是先一杆子插到底,再回来插第二条路、第三条路等等(递归函数在for循环内)。

      这里BFS和Kahn算法基本可以说是一样的,本身Kahn贪婪算法运用queue实现的过程就是一个典型的BFS范式。采用BFS就应该按照入度一层一层遍历,一层遍历完了的同时把下一层的顶点push进入queue中。找入度为0的节点放入queue再取出找到它的邻接节点入度减1,如果减1后等于0再放入queue。依此类推。

      力扣 leetcode原题

44 | 最短路径:地图软件是如何计算出最优出行路径的?

深度优先搜索和广度优先搜索。这两种算法主要是针对无权图的搜索算法。针对有权图,也就是每条边都有一个权重,该如何计算两点之间的最短路径(经过的边的权重和最小)呢?今天,就从地图软件的路线规划问题讲起,看看常用的最短路径算法(Shortest Path Algorithm)。

算法解析

刚提到的最优问题包含三个:最短路线、最少用时和最少红绿灯。我先最简单的,最短路线。

把每个岔路口看作一个顶点,岔路口与岔路口之间的路看作一条边,路的长度就是边的权重。如果路是单行道,就在两个顶点之间画一条有向边;如果路是双行道,就在两个顶点之间画两条方向不同的边。这样,整个地图就被抽象成一个有向有权图。要求解的问题就转化为,在一个有向有权图中,求两个顶点间的最短路径。

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 算法了。

// 因为 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 都初始化为无穷大。把起始顶点的 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 数组的作用是为了还原最短路径,它记录每个顶点的前驱顶点。最后,通过递归的方式,将这个路径打印出来。这个跟在图的搜索中讲的打印路径方法一样。

inqueue 数组是为了避免将一个顶点多次添加到优先级队列中。更新了某个顶点的 dist 值之后,如果这个顶点已经在优先级队列中了,就不要再将它重复添加进去了。

Dijkstra 算法的时间复杂度是多少?

在刚刚的代码中,最复杂就是 while 循环嵌套 for 循环。while 循环最多会执行 V 次(V 表示顶点的个数),而内部的 for 循环的执行次数不确定,跟每个顶点的相邻边的个数有关,分别记作 E0,E1,……,E(V-1)。如果把这 V 个顶点的边都加起来,最大也不会超过图中所有边的个数 E。

for 循环内部的代码涉及从优先级队列取数据、往优先级队列中添加数据、更新优先级队列中的数据。优先级队列是用堆来实现的,堆中的操作,时间复杂度都是 O(logV)(堆中的元素个数不会超过顶点的个数 V)。所以,综合这两部分,再利用乘法原则,整个代码的时间复杂度就是 O(E*logV)

从理论上讲,用 Dijkstra 算法可以计算出两点之间的最短路径。但是,对于一个超级大地图来说,岔路口、道路都非常多,对应到图这种数据结构上来说,就有非常多的顶点和边。如果为了计算两点之间的最短路径,在一个超级大图上动用 Dijkstra 算法,遍历所有的顶点和边,会非常耗时。

对于软件开发工程师来说,经常要根据问题的实际背景,对解决方案权衡取舍。类似出行路线这种工程上的问题,没有必要非得求出个绝对最优解。很多时候,为了兼顾执行效率,只需要计算出一个可行的次优解就可以了

虽然地图很大,但是两点之间的最短路径或者说较好的出行路径,并不会很“发散”,只会出现在两点之间和两点附近的区块内。所以可以在整个大地图上,划出一个小的区块,这个小区块恰好可以覆盖住两个点,但又不会很大。只需要在这个小区块内部运行 Dijkstra 算法,这样就可以避免遍历整个大图,也就大大提高了执行效率。

对于两点之间距离较远的路线规划,可以把北京海淀区或者北京看作一个顶点,把上海黄浦区或者上海看作一个顶点,先规划大的出行路线。比如,如何从北京到上海,必须要经过某几个顶点,或者某几条干道,然后再细化每个阶段的小路线。

这样,最短路径问题就解决了。再来看另外两个问题,最少时间和最少红绿灯。

最短路径的时候,每条边的权重是路的长度。在计算最少时间的时候,算法还是不变,只需要把边的权重,从路的长度变成经过这段路所需要的时间。不过,这个时间会根据拥堵情况时刻变化。

每经过一条边,就要经过一个红绿灯。关于最少红绿灯的出行方案,实际上,只需要把每条边的权值改为 1 即可,算法还是不变,可以继续使用前面讲的 Dijkstra 算法。不过,边的权值为 1,也就相当于无权图了,可以使用广度优先搜索算法。因为广度优先搜索算法计算出来的两点之间的路径,就是两点的最短路径。

总结引申

实际上,最短路径算法还有很多,比如 Bellford 算法、Floyd 算法等等。

这些算法实现思路非常经典,掌握了这些思路,可以拿来指导、解决其他问题。比如 Dijkstra 这个算法的核心思想,就可以拿来解决下面这个看似完全不相关的问题。

有一个翻译系统,只能针对单个词来做翻译。如果要翻译一整个句子,需要将句子拆成一个一个的单词,再丢给翻译系统。针对每个单词,翻译系统会返回一组可选的翻译列表,并且针对每个翻译打一个分,表示这个翻译的可信程度。

针对每个单词,从可选列表中,选择其中一个翻译,组合起来就是整个句子的翻译。每个单词的翻译的得分之和,就是整个句子的翻译得分。随意搭配单词的翻译,会得到一个句子的不同翻译。针对整个句子,希望计算出得分最高的前 k 个翻译结果,你会怎么编程来实现呢?

最简单的办法还是借助回溯算法,穷举所有的排列组合情况,然后选出得分最高的前 k 个翻译结果。但是,这样做的时间复杂度会比较高,是 O(mn),其中,m 表示平均每个单词的可选翻译个数,n 表示一个句子中包含多少个单词。

这个问题可以借助 Dijkstra 算法的核心思想,每个单词的可选翻译是按照分数从大到小排列的,所以 a0b0c0 是得分最高组合结果。把 a0b0c0及得分作为一个对象,放入到优先级队列中。

每次从优先级队列中取出一个得分最高的组合,并基于这个组合进行扩展。比如 a0b0c0 扩展后,会得到三个组合,a1b0c0、a0b1c0、a0b0c1。把扩展之后的组合,加到优先级队列中。重复这个过程,直到获取到 k 个翻译组合或者队列为空。

假设句子包含 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)),比之前的指数级时间复杂度降低了很多。

课后思考

  1. 在计算最短时间的出行路线中,如何获得通过某条路的时间呢?

    • 通过某条路的时间与①路长度②路况(是否平坦等)③拥堵情况④红绿灯个数等因素相关。获取这些因素后就可以建立一个回归模型(比如线性回归)来估算时间。其中①②④因素比较固定,容易获得。③是动态的,但也可以通过a.与交通部门合作获得路段拥堵情况;b.联合其他导航软件获得在该路段的在线人数;c.通过现在时间段正好在次路段的其他用户的真实情况等方式估算。

  2. 今天讲的出行路线问题,假设的是开车出行,那如果是公交出行呢?如果混合地铁、公交、步行,又该如何规划路线呢?

    • 混合公交、地铁和步行时:地铁时刻表是固定的,容易估算。公交虽然没那么准时,大致时间是可以估计的,步行时间受路拥堵状况小,基本与道路长度成正比,也容易估算。总之,感觉公交、地铁、步行,时间估算会比开车更容易,也更准确些。感觉每条道路应该还有限速,这个因素也要考察。

用小顶堆,就是为了确保每个阶段,堆顶的节点都是目前阶段的最短路径的节点。

LeetCode 上这道题: Network Delay Time - LeetCode 用到的就是Dijkstra 算法

实现了一下老师让自己实现的代码并做了测试 GitHub - zzmJava1/dijkstra: dijkstra算法

45 | 位图:如何实现网页爬虫中的URL去重功能?

爬虫的工作原理是,通过解析已经爬取页面中的网页链接,然后再爬取这些链接对应的网页。而同一个网页链接有可能被包含在多个页面中,这就会导致爬虫在爬取的过程中,重复爬取相同的网页。如果你是一名负责爬虫的工程师,你会如何避免这些重复的爬取呢?

最容易想到的方法就是,记录已经爬取的网页链接(也就是 URL),在爬取一个新的网页之前,拿它的链接,在已经爬取的网页链接列表中搜索。如果存在,那就说明这个网页已经被爬取过了;如果不存在,那就说明这个网页还没有被爬取过,可以继续去爬取。等爬取到这个网页之后,将这个网页的链接添加到已经爬取的网页链接列表了。

不过,该如何记录已经爬取的网页链接呢?需要用什么样的数据结构呢?

算法解析

这个问题要处理的对象是网页链接,也就是 URL,需要支持的操作有两个,添加一个 URL 和查询一个 URL。除了这两个功能性的要求之外,在非功能性方面,还要求这两个操作的执行效率要尽可能高。因为处理的是上亿的网页链接,内存消耗会非常大,所以在存储效率上,要尽可能地高效。

满足这些条件的数据结构有哪些呢?显然,散列表、红黑树、跳表这些动态数据结构,都能支持快速地插入、查找数据,但是对内存消耗方面,是否可以接受呢?

拿散列表来举例。假设要爬取 10 亿个网页,为了判重,把这 10 亿网页链接存储在散列表中。

假设一个 URL 的平均长度是 64 字节,那单纯存储这 10 亿个 URL,需要大约 60GB 的内存空间。因为散列表必须维持较小的装载因子,才能保证不会出现过多的散列冲突,导致操作的性能下降。而且,用链表法解决冲突的散列表,还会存储链表指针。所以,如果将这 10 亿个 URL 构建成散列表,那需要的内存空间会远大于 60GB,有可能会超过 100GB。

当然可以采用分治的思想,用多台机器(比如 20 台内存是 8GB 的机器)来存储这 10 亿网页链接。

散列表中添加、查找数据的时间复杂度已经是 O(1),还能有进一步优化的空间吗?实际上,时间复杂度并不能完全代表代码的执行时间。大 O 时间复杂度表示法,会忽略掉常数、系数和低阶,并且统计的对象是语句的频度。不同的语句,执行时间也是不同的。时间复杂度只是表示执行时间随数据规模的变化趋势,并不能度量在特定的数据规模下,代码执行时间的多少。

如果时间复杂度中原来的系数是 10,现在能够通过优化,将系数降为 1,那在时间复杂度没有变化的情况下,执行效率就提高了 10 倍。

如果用基于链表的方法解决冲突问题,散列表中存储的是 URL,那当查询的时候,通过哈希函数定位到某个链表之后,还需要依次比对每个链表中的 URL。这个操作是比较耗时的,主要有两点原因。

一方面,链表中的结点在内存中不是连续存储的,所以不能一下子加载到 CPU 缓存中,没法很好地利用到 CPU 高速缓存,所以数据访问性能方面会打折扣。

另一方面,链表中的每个数据都是 URL,而 URL 不是简单的数字,是平均长度为 64 字节的字符串。也就是说,要让待判重的 URL,跟链表中的每个 URL,做字符串匹配。显然,这样一个字符串匹配操作,比起单纯的数字比对,要慢很多。所以,基于这两点,执行效率方面肯定是有优化空间的。

实际上,如果要想内存方面有明显的节省,那就得换一种解决方案,也就是布隆过滤器

在讲布隆过滤器前,要先讲一下位图。因为,布隆过滤器是基于位图的,是对位图的一种改进。

有 1 千万个整数,整数的范围在 1 到 1 亿之间。如何快速查找某个整数是否在这 1 千万个整数中呢?

当然,这个问题还是可以用散列表来解决。不过,可以使用一种比较“特殊”的散列表,那就是位图。申请一个大小为 1 亿、数据类型为布尔类型的数组。将这 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。

为了降低这种冲突概率,布隆过滤器的处理方法。那用多个哈希函数一块儿定位一个数据。

使用 K 个哈希函数,对同一个数字进行求哈希值,那会得到 K 个不同的哈希值,分别记作 X1,X2,…,XK。把这 K 个数字作为位图中的下标,将对应的 BitMap[X1],BitMap[X2],BitMap[X3],…,BitMap[XK] 都设置成 true,也就是说,用 K 个二进制位,来表示一个数字的存在。

当要查询某个数字是否存在的时候,用同样的 K 个哈希函数,对这个数字求哈希值,分别得到 Y1,Y2,Y3,…,YK。看这 K 个哈希值,对应位图中的数值是否都为 true,如果都是 true,则说明,这个数字存在,如果有其中任意一个不为 true,那就说明这个数字不存在。

对于两个不同的数字来说,经过一个哈希函数处理之后,可能会产生相同的哈希值。但是经过 K 个哈希函数处理之后,K 个哈希值都相同的概率就非常低了。尽管采用 K 个哈希函数之后,两个数字哈希冲突的概率降低了,但是,这种处理方式又带来了新的问题,那就是容易误判。

 

布隆过滤器的误判有一个特点,那就是,它只会对存在的情况有误判。如果某个数字经过布隆过滤器判断不存在,那说明这个数字真的不存在,不会发生误判;如果某个数字经过布隆过滤器判断存在,这个时候才会有可能误判,有可能并不存在。不过,只要调整哈希函数的个数、位图大小跟要存储数字的个数之间的比例,那就可以将这种误判的概率降到非常低。

尽管布隆过滤器会存在误判,但是,这并不影响它发挥大作用。很多场景对误判有一定的容忍度。比如我们今天要解决的爬虫判重这个问题,即便一个没有被爬取过的网页,被误判为已经被爬取,对于搜索引擎来说,也并不是什么大事情,是可以容忍的,毕竟网页太多了,搜索引擎也不可能 100% 都爬取到。

用布隆过滤器来记录已经爬取过的网页链接,假设需要判重的网页有 10 亿,那可以用一个 10 倍大小的位图来存储,也就是 100 亿个二进制位,换算成字节,那就是大约 1.2GB。用散列表判重,需要至少 100GB 的空间。相比来讲,布隆过滤器在存储空间的消耗上,降低了非常多。

布隆过滤器用多个哈希函数对同一个网页链接进行处理,CPU 只需要将网页链接从内存中读取一次,进行多次哈希计算,理论上讲这组操作是 CPU 密集型的。而在散列表的处理方式中,需要读取散列冲突拉链的多个网页链接,分别跟待判重的网页链接,进行字符串匹配。这个操作涉及很多内存数据的读取,所以是内存密集型的。CPU 计算可能是要比内存访问更快速的,所以,理论上讲,布隆过滤器的判重方式,更加快速。

总结引申

布隆过滤器非常适合这种不需要 100% 准确的、允许存在小概率误判的大规模判重场景。除了爬虫网页去重这个例子,还有比如统计一个大型网站的每天的 UV 数,也就是每天有多少用户访问了网站,可以使用布隆过滤器,对重复访问的用户,进行去重。

布隆过滤器的误判率,主要跟哈希函数的个数、位图的大小有关。当往布隆过滤器中不停地加入数据之后,位图中不是 true 的位置就越来越少了,误判率就越来越高了。所以,对于无法事先知道要判重的数据个数的情况,需要支持自动扩容的功能。

当布隆过滤器中,数据个数与位图大小的比例超过某个阈值的时候,就重新申请一个新的位图。后面来的新数据,会被放置到新的位图中。但是,如果要判断某个数据是否在布隆过滤器中已经存在,我们就需要查看多个位图,相应的执行效率就降低了一些。

位图、布隆过滤器应用广泛,很多编程语言都已经实现了。比如 Java 中的 BitSet 类就是一个位图,Redis 也提供了 BitMap 位图类,Google 的 Guava 工具包提供了 BloomFilter 布隆过滤器的实现。

课后思考

  1. 假设有 1 亿个整数,数据范围是从 1 到 10 亿,如何快速并且省内存地给这 1 亿个数据从小到大排序?

    • 传统的做法:1亿个整数,存储需要400M空间,排序时间复杂度最优 N×log(N) 使用位图算法:数字范围是1到10亿,用位图存储125M就够了,然后将1亿个数字依次添加到位图中,然后再将位图按下标从小到大输出值为1的下标,排序就完成了,时间复杂度为 N

    • 对于重复的数字 可以再维护一个小的散列表 记录出现次数超过1次的数据以及对应的个数

  2. 还记得在哈希函数(下)讲过的利用分治思想,用散列表以及哈希函数,实现海量图库中的判重功能吗?如果允许小概率的误判,那是否可以用今天的布隆过滤器来解决呢?用布隆过滤器需要多少台机器?

  • 用位图的话,一个机器

  • 这个char代码最好还是用图解比较好理解,纯代码看不懂。 我这里有另外一个位的图解计算过程,再去看代码,你就会秒懂 漫画:Bitmap算法 整合版

  • 将数字 A 的第 k 位设置为1:A = A | (1 << (k - 1)) 将数字 A 的第 k 位设置为0:A = A & ~(1 << (k - 1)) 检测数字 A 的第 k 位:A & (1 << (k - 1)) != 0 用于理解bitmap中代码

46 | 概率统计:如何利用朴素贝叶斯算法过滤垃圾短信?

如果你是一名手机应用开发工程师,让你实现一个简单的垃圾短信过滤功能以及骚扰电话拦截功能,该用什么样的数据结构和算法实现呢?

算法解析

1. 基于黑名单的过滤器

可以维护一个骚扰电话号码和垃圾短信发送号码的黑名单。这个黑名单的搜集,有很多途径,比如,从一些公开的网站上下载,也可以通过类似“360 骚扰电话拦截”的功能,通过用户自主标记骚扰电话来收集。对于被多个用户标记,并且标记个数超过一定阈值的号码,就可以定义为骚扰电话,并将它加入到黑名单中。

如果黑名单中的电话号码不多的话,可以使用散列表、二叉树等动态数据结构来存储,对内存的消耗并不会很大。如果把每个号码看作一个字符串,并且假设平均长度是 16 个字节,那存储 50 万个电话号码,大约需要 10MB 的内存空间。这点内存的消耗是可以接受的。

但是,如果黑名单中的电话号码很多呢?比如有 500 万个。这个时候,如果再用散列表存储,就需要大约 100MB 的存储空间。为了实现一个拦截功能,耗费用户如此多的手机内存,这显然有点儿不合理。

布隆过滤器最大的特点就是比较省存储空间,如果要存储 500 万个手机号码,把位图大小设置为 10 倍数据大小,那也只需要使用 5000 万个二进制位(5000 万 bits),换算成字节,也就是不到 7MB 的存储空间。比起散列表的解决方案,内存的消耗减少了很多。

实际上,还有一种时间换空间的方法,可以将内存的消耗优化到极致。

可以把黑名单存储在服务器端上,把过滤和拦截的核心工作,交给服务器端来做。手机端只负责将要检查的号码发送给服务器端,服务器端通过查黑名单,判断这个号码是否应该被拦截,并将结果返回给手机端。

用这个解决思路完全不需要占用手机内存。不过,网络延迟就会导致处理速度降低。而且,这个方案还有个硬性要求,那就是只有在联网的情况下,才能正常工作。

不过,布隆过滤器会有判错的概率!如果它把一个重要的电话或者短信,当成垃圾短信或者骚扰电话拦截了,对于用户来说,这是无法接受的。

2. 基于规则的过滤器

如果某个垃圾短信发送者的号码并不在黑名单中,那这种方法就没办法拦截了。

对于垃圾短信来说,还可以通过短信的内容,来判断某条短信是否是垃圾短信。预先设定一些规则,如果某条短信符合这些规则,就可以判定它是垃圾短信。比如下面这几个:

  • 短信中包含特殊单词(或词语),比如一些非法、淫秽、反动词语等;

  • 短信发送号码是群发号码,非我们正常的手机号码,比如 +60389585;

  • 短信中包含回拨的联系方式,比如手机号码、微信、QQ、网页链接等,因为群发短信的号码一般都是无法回拨的;

  • 短信格式花哨、内容很长,比如包含各种表情、图片、网页链接等;

  • 符合已知垃圾短信的模板。垃圾短信一般都是重复群发,对于已经判定为垃圾短信的短信,可以抽象成模板,将获取到的短信与模板匹配,一旦匹配,就可以判定为垃圾短信。

当然,如果短信只是满足其中一条规则,如果就判定为垃圾短信,那会存在比较大的误判的情况。可以综合多条规则进行判断。比如,满足 2 条以上才会被判定为垃圾短信;或者每条规则对应一个不同的得分,满足哪条规则,就累加对应的分数,某条短信的总得分超过某个阈值,才会被判定为垃圾短信。

不过,只是给出了一些制定规则的思路,具体落实到执行层面,还有很多细节需要处理。比如,第一条规则中,该如何定义特殊单词;第二条规则中,该如何定义什么样的号码是群发号码等等。这里只讲一下,如何定义特殊单词?

可以基于概率统计的方法,借助计算机强大的计算能力,找出哪些单词最常出现在垃圾短信中,将这些最常出现的单词,作为特殊单词,用来过滤短信。

不过这种方法的前提是,有大量的样本数据,也就是说,要有大量的短信(比如 1000 万条短信),并且还要求,每条短信都做好了标记,它是垃圾短信还是非垃圾短信。

对这 1000 万条短信,进行分词处理(借助中文或者英文分词算法),去掉“的、和、是”等没有意义的停用词,得到 n 个不同的单词。针对每个单词,统计有多少个垃圾短信出现了这个单词,有多少个非垃圾短信会出现这个单词,进而求出每个单词出现在垃圾短信中的概率,以及出现在非垃圾短信中的概率。如果某个单词出现在垃圾短信中的概率,远大于出现在非垃圾短信中的概率,那就把这个单词作为特殊单词,用来过滤垃圾短信。

3. 基于概率统计的过滤器

基于规则的过滤器,看起来很直观,也很好理解,但是它也有一定的局限性。一方面,这些规则受人的思维方式局限,规则未免太过简单;另一方面,垃圾短信发送者可能会针对规则,精心设计短信,绕过这些规则的拦截。对此,再来看一种更加高级的过滤方式,基于概率统计的过滤方式。

这种基于概率统计的过滤方式,基础理论是基于朴素贝叶斯算法。什么是朴素贝叶斯算法?

基于概率统计的过滤器,是基于短信内容来判定是否是垃圾短信。而计算机没办法像人一样理解短信的含义。所以,需要把短信抽象成一组计算机可以理解并且方便计算的特征项,用这一组特征项代替短信本身,来做垃圾短信过滤。

可以通过分词算法,把一个短信分割成 n 个单词。这 n 个单词就是一组特征项,全权代表这个短信。因此,就变成了判定同时包含这几个单词的短信是否是垃圾短信。

尽管有大量的短信样本,但是没法通过样本数据统计得到这个概率。样本的数量再大,毕竟也是有限的,样本中不会有太多同时包含 W1,W2,W3,…,Wn的短信的,甚至很多时候,样本中根本不存在这样的短信。没有样本,也就无法计算概率。

通过朴素贝叶斯公式,将这个概率的求解,分解为其他三个概率的求解。

 

P(W1,W2,W3,…,Wn 同时出现在一条短信中 | 短信是垃圾短信)这个概率照样无法通过样本来统计得到。但是可以基于下面这条著名的概率规则来计算。

独立事件发生的概率计算公式:P(A * B) = P(A)*P(B)

基于这条独立事件发生概率的计算公式,我们可以把 P(W1,W2,W3,…,Wn 同时出现在一条短信中 | 短信是垃圾短信)分解为下面这个公式:

 

其中,P(Wi 出现在短信中 | 短信是垃圾短信)表示垃圾短信中包含 Wi这个单词的概率有多大。这个概率值通过统计样本很容易就能获得。P(W1,W2,W3,…,Wn 同时出现在一条短信中 | 短信是垃圾短信)这个概率值,就计算出来了。

P(短信是垃圾短信)表示短信是垃圾短信的概率,把样本中垃圾短信的个数除以总样本短信个数,就是短信是垃圾短信的概率。

不过,P(W1,W2,W3,…,Wn 同时出现在一条短信中)这个概率还是不好通过样本统计得到,因为样本空间有限。不过,没必要非得计算这一部分的概率值。为什么这么说呢?

实际上,可以分别计算同时包含 W1,W2,W3,…,Wn 这 n 个单词的短信,是垃圾短信和非垃圾短信的概率。假设它们分别是 p1 和 p2。并不需要单纯地基于 p1 值的大小来判断是否是垃圾短信,而是通过对比 p1 和 p2 值的大小,来判断一条短信是否是垃圾短信。更细化一点讲,那就是,如果 p1 是 p2 的很多倍(比如 10 倍),才确信这条短信是垃圾短信。

 

 

基于这两个概率的倍数来判断是否是垃圾短信的方法,就可以不用计算 P(W1,W2,W3,…,Wn同时出现在一条短信中)这一部分的值了。

总结引申

基于黑名单、规则、概率统计三种垃圾短信的过滤方法,可以应用到很多类似的过滤、拦截的领域,比如垃圾邮件的过滤等等。

在讲黑名单过滤的时候,讲到布隆过滤器可能会存在误判情况,可能会导致用户投诉。实际上,可以结合三种不同的过滤方式的结果,对同一个短信处理,如果三者都表明这个短信是垃圾短信,才把它当作垃圾短信拦截过滤,这样就会更精准。

当然,在实际的工程中,还需要结合具体的场景,以及大量的实验,不断去调整策略,权衡垃圾短信判定的准确率(是否会把不是垃圾的短信错判为垃圾短信)和召回率(是否能把所有的垃圾短信都找到),来实现需求。

课后思考

关于垃圾短信过滤和骚扰电话的拦截,还有没有其他方法呢?

  • 对于短信文本,机器学习尤其是 NLP 方向的很多算法可用于 anti-spam。文本分类任务,特征工程做得稍用心的话,判别式模型(典型如 logistic regression)的效果通常好于生成式模型(典型如 naive-bayes)。 对于电话号码数字,感觉用正则或定时拉取黑名单比 ml 模型简单可靠。

  • 这种分类过滤,最好的可能是机器学习,通过大量的垃圾短信样本来训练特征,最后可以达到过滤短信和邮件的目的,而且这种方法应该效果更好,至于电话拦截,实际上就是电话号码黑名单的问题,用布隆过滤器可以满足通用场景,一般实际场景中,对于这种电话是提示谨慎接听,但是可以本地和云端结合处理,解决部分的误报问题,当判断是黑名单的时候再去云端查,确认是否是真的黑名单。这样用布隆过滤器+云端也是一种方式

  • 过滤法基于经验判断,难以确保及时性。基于内容规则的过滤法容易被针对,而且动态调整规则的成本较高。基于朴素贝叶斯算法的内容概率过滤法,既可以确保及时性,又能够较好的基于实际情况的变化而变化,具备初步智能特性。因为贝叶斯方法是基于先验判断,然后根据现实反馈动态调整判断的算法。 当绝对值不好计算时,可以结合场景需要,合理使用相对值代替绝对值,以简化计算难度、消除无法计算的因子。

  • 轻松带你搞懂朴素贝叶斯分类算法 - 简书 找到另外一个相亲的例子

  • 朴素贝叶斯模型的一个基本假设是条件独立性,即假定w1, w2, ..., wn之间相互独立。这是一个较强的假设,正是这一假设,使朴素贝叶斯的学习与预测大为简化,且易于实现,其缺点是分类的准确率不一定高。

47 | 向量空间:如何实现一个简单的音乐推荐系统?

算法解析

  • 找到跟你口味偏好相似的用户,把他们爱听的歌曲推荐给你;

  • 找出跟你喜爱的歌曲特征相似的歌曲,把这些歌曲推荐给你。接下来,我就分别讲解一下这两种思路的具体实现方法。

1. 基于相似用户做推荐

如何找到跟你口味偏好相似的用户呢?把跟你听类似歌曲的人,看做口味相似的用户。

只需要遍历所有的用户,对比每个用户跟你共同喜爱的歌曲个数,并且设置一个阈值,如果你和某个用户共同喜爱的歌曲个数超过这个阈值,就把这个用户看作跟你口味相似的用户,把这个用户喜爱但你还没听过的歌曲,推荐给你。

不过,如何知道用户喜爱哪首歌曲呢?也就是说,如何定义用户对某首歌曲的喜爱程度呢?

实际上,可以通过用户的行为,来定义这个喜爱程度。给每个行为定义一个得分,得分越高表示喜爱程度越高。

有了一个用户对歌曲的喜爱程度的对应表之后,如何来判断两个用户是否口味相似呢?

这里的相似度度量,可以使用另外一个距离,那就是欧几里得距离。欧几里得距离是用来计算两个向量之间的距离的。这个概念中有两个关键词,向量和距离。

那如何计算两个向量之间的距离呢?还是可以类比到二维、三维空间中距离的计算方法。通过类比,就可以得到两个向量之间距离的计算公式。这个计算公式就是欧几里得距离的计算公式:

 

把每个用户对所有歌曲的喜爱程度,都用一个向量表示。计算出两个向量之间的欧几里得距离,作为两个用户的口味相似程度的度量。

2. 基于相似歌曲做推荐

但是,如果用户是一个新用户,还没有收集到足够多的行为数据,这个时候该如何推荐呢?现在再来看另外一种推荐方法,基于相似歌曲的推荐方法,也就是说,如果某首歌曲跟你喜爱的歌曲相似,就把它推荐给你。

如何判断两首歌曲是否相似呢?对于计算机来说,判断两首歌曲是否相似,那就需要通过量化的数据来表示了。应该通过什么数据来量化两个歌曲之间的相似程度呢?

最容易想到的是,对歌曲定义一些特征项,比如是伤感的还是愉快的等等。类似基于相似用户的推荐方法,给每个歌曲的每个特征项打一个分数,这样每个歌曲就都对应一个特征项向量。可以基于这个特征项向量,来计算两个歌曲之间的欧几里得距离。欧几里得距离越小,表示两个歌曲的相似程度越大。

但是,要实现这个方案,需要有一个前提,那就是能够找到足够多,并且能够全面代表歌曲特点的特征项,除此之外,还要人工给每首歌标注每个特征项的得分。对于收录了海量歌曲的音乐 App 来说,这显然是一个非常大的工程。此外,人工标注有很大的主观性,也会影响到推荐的准确性。

既然基于歌曲特征项计算相似度不可行,那就换一种思路。对于两首歌,如果喜欢听的人群都是差不多的,那侧面就可以反映出,这两首歌比较相似。

基于相似用户的推荐方法中,针对每个用户,将对各个歌曲的喜爱程度作为向量。基于相似歌曲的推荐思路中,针对每个歌曲,将每个用户的打分作为向量。

有了每个歌曲的向量表示,通过计算向量之间的欧几里得距离,来表示歌曲之间的相似度。欧几里得距离越小,表示两个歌曲越相似。然后,就在用户已经听过的歌曲中,找出他喜爱程度较高的歌曲。然后,找出跟这些歌曲相似度很高的其他歌曲,推荐给他。

总结引申

实际上,这个问题是推荐系统里最典型的一类问题。实践中遇到的问题还有很多,比如冷启动问题,产品初期积累的数据不多,不足以做推荐等等。

课后思考

关于今天讲的推荐算法,你还能想到其他应用场景吗?

  • 推荐系统是典型的机器学习应用场景。其核心就是通过算法得到用户偏好向量以及内容向量,两个向量的内积即为用户对内容的的评分预测(即用户对某内容的喜好程度)。推荐学习算法本质上就是学习这两个向量的过程。 通常有两种方法:

    1. 已知内容向量,学习用户偏好向量的方法就是基于内容的推荐算法(content-based);

    2. 用户偏好向量和内容向量都未知,则适合使用联合过滤算法(collaborative filtering)同时学习两个向量。

  • 推荐可以看成一种选优,所以思维上可以跳出“推荐”两个字,进而扩展“相似”“热门”等等这类场景 例如搜索引擎关键词拼写错误的推荐词,导航app的推荐路径,电商的热门商品等,都可以用上推荐算法

48 | B+树:MySQL数据库索引是如何实现的?

数据库索引是如何实现的呢?底层使用的是什么数据结构和算法呢?

算法解析

1. 解决问题的前提是定义清楚问题

假设要解决的问题,只包含这样两个常用的需求:

  • 根据某个值查找数据,比如 select * from user where id=1234;

  • 根据区间值来查找某些数据,比如 select * from user where id > 1234 and id < 2345。

除了这些功能性需求之外,这种问题往往还会涉及一些非功能性需求,比如安全、性能、用户体验等等。对于非功能性需求,着重考虑性能方面的需求。性能方面的需求,主要考察时间和空间两方面,也就是执行效率和存储空间

在执行效率方面,希望通过索引,查询数据的效率尽可能的高;在存储空间方面,希望索引不要消耗太多的内存空间。

2. 尝试用学过的数据结构解决这个问题

支持快速查询、插入等操作的动态数据结构,已经学习过散列表、平衡二叉查找树、跳表。

散列表的查询性能很好,时间复杂度是 O(1)。但是,散列表不能支持按照区间快速查找数据。所以,散列表不能满足需求。

尽管平衡二叉查找树查询的性能也很高,时间复杂度是 O(logn)。而且,对树进行中序遍历,还可以得到一个从小到大有序的数据序列,但这仍然不足以支持按照区间快速查找数据。

跳表是在链表之上加上多层索引构成的。它支持快速地插入、查找、删除数据,对应的时间复杂度是 O(logn)。并且,跳表也支持按照区间快速地查找数据。只需要定位到区间起点值对应在链表中的结点,然后从这个结点开始,顺序遍历链表,直到区间终点对应的结点为止,这期间遍历得到的数据就是满足区间值的数据。这样看来,跳表是可以解决这个问题。实际上,数据库索引所用到的数据结构跟跳表非常相似,叫作 B+ 树。不过,它是通过二叉查找树演化过来的,而非跳表。

3. 改造二叉查找树来解决这个问题

为了让二叉查找树支持按照区间来查找数据,可以对它进行改造:树中的节点并不存储数据本身,而是只是作为索引。除此之外,把每个叶子节点串在一条链表上,链表中的数据是从小到大有序的。

 

改造之后,如果要求某个区间的数据。只需要拿区间的起始值,在树中进行查找,当查找到某个叶子节点之后,再顺着链表往后遍历,直到链表中的结点数据值大于区间的终止值为止。所有遍历到的数据,就是符合区间值的所有数据。

但是,要为几千万、上亿的数据构建索引,如果将索引存储在内存中,尽管内存访问的速度非常快,查询的效率非常高,但是,占用的内存会非常多。

可以借助时间换空间的思路,把索引存储在硬盘中,而非内存中。硬盘是一个非常慢速的存储设备。通常内存的访问速度是纳秒级别的,而磁盘访问的速度是毫秒级别的。将索引存储在硬盘中的方案,减少了内存消耗,但是在数据查找的过程中,需要读取磁盘中的索引,因此数据查询效率就相应降低很多。

二叉查找树,经过改造之后,支持区间查找的功能就实现了。不过,为了节省内存,如果把树存储在硬盘中,那么每个节点的读取(或者访问),都对应一次磁盘 IO 操作。树的高度就等于每次查询数据时磁盘 IO 操作的次数。

比起内存读写操作,磁盘 IO 操作非常耗时,所以优化的重点就是尽量减少磁盘 IO 操作,也就是,尽量降低树的高度。那如何降低树的高度呢?

如果把索引构建成 m 叉树,高度比二叉树要小。磁盘 IO 变少了,查找数据的效率也就提高了。

如果将 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 操作。

尽管索引可以提高数据库的查询效率,但是,索引有利也有弊,它也会让写入数据的效率下降。

数据的写入过程,会涉及索引的更新,这是索引导致写入变慢的主要原因。

对于一个 B+ 树来说,m 值是根据页的大小事先计算好的,也就是说,每个节点最多只能有 m 个子节点。在往数据库中写入数据的过程中,就有可能使索引中某些节点的子节点个数超过 m,这个节点的大小超过了一个页的大小,读取这样一个节点,就会导致多次磁盘 IO 操作。

只需要将这个节点分裂成两个节点。但是,节点分裂之后,其上层父节点的子节点个数就有可能超过 m 个。不过可以将父节点也分裂成两个节点。这种级联反应会从下往上,一直影响到根节点。这个分裂过程,可以结合着下面这个图一块看(图中的 B+ 树是一个三叉树。限定叶子节点中,数据的个数超过 2 个就分裂节点;非叶子节点中,子节点的个数超过 3 个就分裂节点)。

 

正是因为要时刻保证 B+ 树索引是一个 m 叉树,所以,索引的存在会导致数据库写入的速度降低。实际上,不光写入数据会变慢,删除数据也会变慢。这是为什么呢?

在删除某个数据的时候,也要对应的更新索引节点。这个处理思路有点类似跳表中删除数据的处理思路。频繁的数据删除,就会导致某些结点中,子节点的个数变得非常少,长此以往,如果每个节点的子节点都比较少,势必会影响索引的效率。

可以设置一个阈值。在 B+ 树中,这个阈值等于 m/2。如果某个节点的子节点个数小于 m/2,就将它跟相邻的兄弟节点合并。不过,合并之后结点的子节点个数有可能会超过 m。针对这种情况,可以借助插入数据时候的处理方法,再分裂节点。

B+ 树的结构和操作,跟跳表非常类似。理论上讲,对跳表稍加改造,也可以替代 B+ 树,作为数据库的索引实现的。

总结引申

数据库索引实现,依赖的底层数据结构,B+ 树。它通过存储在磁盘的多叉树结构,做到了时间、空间的平衡,既保证了执行效率,又节省了内存。

B+ 树的特点:

  • 每个节点中子节点的个数不能超过 m,也不能小于 m/2;

  • 根节点的子节点个数可以不超过m/2,这是一个例外;

  • m 叉树只存储索引,并不真正存储数据,这个有点儿类似跳表;

  • 通过链表将叶子节点串联在一起,这样可以方便按区间查找;

  • 一般情况,根节点会被存储在内存中,其他节点存储在磁盘中。

除了 B+ 树,可能还听说过 B 树、B- 树,实际上,B- 树就是 B 树,英文翻译都是 B-Tree,这里的“-”并不是相对 B+ 树中的“+”,而只是一个连接符。而 B 树实际上是低级版的 B+ 树,或者说 B+ 树是 B 树的改进版。B 树跟 B+ 树的不同点主要集中在这几个地方

  • B+ 树中的节点不存储数据,只是索引,而 B 树中的节点存储数据;

  • B 树中的叶子节点并不需要链表来串联。

也就是说,B 树只是一个每个节点的子节点个数不能小于 m/2 的 m 叉树。

课后思考

  1. B+ 树中,将叶子节点串起来的链表,是单链表还是双向链表?为什么?

    • 链表是双向链表,用以支持前后遍历,对于区间查找,既需要支持大于某个值的查找(向右遍历),也需要支持小于某个值的查找(向左遍历)。

    • 对于B+tree叶子节点,是用双向链表还是用单链表,得从具体的场景思考。大部分同学在开发中遇到的数据库查询,都遇到过升序或降序问题,即类似这样的sql: select name,age, ... from where uid > startValue and uid < endValue order by uid asc(或者desc),此时,数据底层实现有两种做法: 1)保证查出来的数据就是用户想要的顺序 2)不保证查出来的数据的有序性,查出来之后再排序 以上两种方案,肯定选第一种,因为第二种做法浪费了时间。那如何能保证查询出来的数据就是有序的呢?只能选择双向链表了。双向链表,多出来了一倍的指针,不是会多占用空间嘛? 答案是肯定的。可是,数据库索引本身都已经在磁盘中了,对于磁盘来说,这点空间已经微不足道了,用这点空间换来时间肯定划算呀。

  2. 对平衡二叉查找树进行改造,将叶子节点串在链表中,就支持了按照区间来查找数据。在散列表(下)讲到,散列表也经常跟链表一块使用,如果把散列表中的结点,也用链表串起来,能否支持按照区间查找数据呢?

    • 可以支持区间查询。java中linkedHashMap就是链表+HashMap的组合,用于实现缓存的lru算法比较方便,不过要支持区间查询需要在插入时维持链表的有序性,复杂度O(n)。效率比跳表和b+tree差

  • B+tree理解起来真不难,抓住几个要点就可以了:

  1. 理解二叉查找树

  2. 理解二叉查找树会出现不平衡的问题(红黑树理解了,对于平衡性这个关键点就理解了)

  3. 磁盘IO访问太耗时

  4. 当然,链表知识跑不了 —— 别小瞧这个简单的数据结构,它是链式结构之母

  5. 最后,要知道典型的应用场景:数据库的索引结构的设计

  • B+树和跳表很像,都是双向链表+索引的结构,数据都放在最下边,利用二分查找进行有序数列查找,区别是啥?区别在索引: 1.高度:同数量级的数据,跳表索引的高度会很高,IO读取次数多,影响查询性能 2.页空间浪费:mysql默认页空间16K,跳表默认一个节点只存一个数,其他空间都浪费了

49| 搜索:如何用A*搜索算法实现游戏中的寻路功能?

当人物处于游戏地图中的某个位置的时候,我们用鼠标点击另外一个相对较远的位置,人物就会自动地绕过障碍物走过去。玩过这么多游戏,不知你是否思考过,这个功能是怎么实现的呢?

算法解析

人物的起点就是他当下所在的位置,终点就是鼠标点击的位置。需要在地图中,找一条从起点到终点的路径。这条路径要绕过地图中所有障碍物,并且要是最短路径。

如果图非常大,那 Dijkstra 最短路径算法的执行耗时会很多。在真实的软件开发中,面对的是超级大的地图和海量的寻路请求,算法的执行效率太低,这显然是无法接受的。

实际上,像出行路线规划、游戏寻路,这些真实软件开发中的问题,在权衡路线规划质量和执行效率的情况下,只需要寻求一个次优解就足够了。那如何快速找出一条接近于最短路线的次优路线呢?

这个快速的路径规划算法,就是A* 算法。实际上,A* 算法是对 Dijkstra 算法的优化和改造。

Dijkstra 算法有点儿类似 BFS 算法,它每次找到跟起点最近的顶点,往外扩展。这种往外扩展的思路,其实有些盲目。

在 Dijkstra 算法的实现思路中,用一个优先级队列,来记录已经遍历到的顶点以及这个顶点与起点的路径长度。顶点与起点路径长度越小,就越先被从优先级队列中取出来扩展,按照顶点与起点的路径长度的大小,来安排出队列顺序的。与起点越近的顶点,就会越早出队列。并没有考虑到这个顶点到终点的距离,所以,在地图中,尽管顶点离起始顶点最近,但离终点却越来越远。

如果综合更多的因素,把这个顶点到终点可能还要走多远,也考虑进去,综合来判断哪个顶点该先出队列,那是不是就可以避免“跑偏”呢?

当遍历到某个顶点的时候,从起点走到这个顶点的路径长度是确定的,记作 g(i)。但是,从这个顶点到终点的路径长度,是未知的。虽然确切的值无法提前知道,但是可以用其他估计值来代替。

这里可以通过这个顶点跟终点之间的直线距离,也就是欧几里得距离,来近似地估计这个顶点跟终点的路径长度(注意:路径长度跟直线距离是两个概念)。把这个距离记作 h(i),专业的叫法是启发函数。因为欧几里得距离的计算公式,会涉及比较耗时的开根号计算,所以,一般通过另外一个更加简单的距离计算公式,那就是曼哈顿距离。曼哈顿距离是两点之间横纵坐标的距离之和。

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) 的专业叫法是估价函数

在 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 点区别:

  • 优先级队列构建的方式不同。A* 算法是根据 f 值(也就是刚刚讲到的 f(i)=g(i)+h(i))来构建优先级队列,而 Dijkstra 算法是根据 dist 值(也就是刚刚讲到的 g(i))来构建优先级队列;

  • A* 算法在更新顶点 dist 值的时候,会同步更新 f 值;

  • 循环结束的条件也不一样。Dijkstra 算法是在终点出队列的时候才结束,A* 算法是一旦遍历到终点就结束。

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 的不同路径,然后对比找出最短的那个。不过很显然,回溯算法的执行效率非常低,是指数级的。

Dijkstra 算法在此基础之上,利用动态规划的思想,对回溯搜索进行了剪枝,只保留起点到某个顶点的最短路径,继续往外扩展搜索。动态规划相较于回溯搜索,只是换了一个实现思路,但它实际上也考察到了所有从起点到终点的路线,所以才能得到最优解。

A* 算法之所以不能像 Dijkstra 算法那样,找到最短路径,主要原因是两者的 while 循环结束条件不一样。Dijkstra 算法是在终点出队列的时候才结束,A* 算法是一旦遍历到终点就结束。对于 Dijkstra 算法来说,当终点出队列的时候,终点的 dist 值是优先级队列中所有顶点的最小值,即便再运行下去,终点的 dist 值也不会再被更新了。对于 A* 算法来说,一旦遍历到终点,就结束 while 循环,这个时候,终点的 dist 值未必是最小值。

A* 算法利用贪心算法的思路,每次都找 f 值最小的顶点出队列,一旦搜索到终点就不在继续考察其他顶点和路线了。所以,它并没有考察所有的路线,也就不可能找出最短路径了。

如何借助 A* 算法解决今天的游戏寻路问题?

把整个地图分割成一个一个的小方块。在某一个方块上的人物,只能往上下左右四个方向的方块上移动。可以把每个方块看作一个顶点。两个方块相邻,就在它们之间,连两条有向边,并且边的权值都是 1。所以,这个问题就转化成了,在一个有向有权图中,找某个顶点到另一个顶点的路径问题。将地图抽象成边权值为 1 的有向图之后,就可以套用 A* 算法,来实现游戏中人物的自动寻路功能了。

总结引申

A* 算法属于一种启发式搜索算法。实际上,启发式搜索算法并不仅仅只有 A* 算法,还有很多其他算法,比如 IDA* 算法、蚁群算法、遗传算法、模拟退火算法等。

启发式搜索算法利用估价函数,避免“跑偏”,贪心地朝着最有可能到达终点的方向前进。这种算法找出的路线,并不是最短路线。但是,实际的软件开发中的路线规划问题,往往并不需要非得找最短路线。所以,鉴于启发式搜索算法能很好地平衡路线质量和执行效率,它在实际的软件开发中的应用更加广泛。实际上,在第 44 节中,地图 App 中的出行路线规划问题,也可以利用启发式搜索算法来实现。

课后思考

之前讲的“迷宫问题”是否可以借助 A* 算法来更快速地找到一个走出去的路线呢?如果可以,请具体讲讲该怎么来做;如果不可以,请说说原因。

  • 迷宫问题应该也是可以借助A* 算法。 首先建模让其能够使用A* 算法,迷宫跟游戏地图还是有区别,对于迷宫的每个拐角抽象成一个顶点,相邻拐点之间的距离作为边;然后画一个(x,y)的坐标计算出每个点的坐标,这样就抽象成图了,之后就可以使用A*算法快速的求解一条出路

  • 可以。迷宫问题原型是个二维数组 a[n] [m],0代表可以走通,1代表走不通; 第一步:先把二维数组转化带序号的二维数组 b[n] [m],a[i] [j] 等于0,在b[n] [m] 用序号表示,比如:a[0] [1] = 0,a[1] [1] = 0,那么 b[0] [1] = 1,b[1] [1] = 2;依次类推;

    第二步:把数组b转化成图结构;因为“A* 算法”实际是一种针对“图”的算法;比如 b[0] [1] = 1,b[1] [1] = 2,b[0] [1]跟 b[1] [1] 是通的,就建立 1->2、2->1 的有向边; 第三步:给每个图的顶点构建坐标系,因为每一步的权重都是一样的,所以构建坐标系的时候直接用二维数组的下标即可。比如:顶点1 的坐标系 {0, 1};顶点2 的坐标系 {1, 1}

    至此,”迷宫问题“就转化成了“图的路径问题”,带入“A*算法”即可

50 | 索引:如何在海量数据中快速查找某个数据?

类似 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 中的有序集合,就是用跳表来构建的。

位图和布隆过滤器这两个数据结构,也可以用于索引中,辅助存储在磁盘中的索引,加速数据查找的效率。

布隆过滤器有一定的判错率。尽管对于判定存在的数据,有可能并不存在,但是对于判定不存在的数据,那肯定就不存在。而且,布隆过滤器还有一个更大的特点,那就是内存占用非常少。可以针对数据,构建一个布隆过滤器,并且存储在内存中。当要查询数据的时候,可以先通过布隆过滤器,判定是否存在。如果通过布隆过滤器判定数据不存在,那就没有必要读取磁盘中的索引了。对于数据不存在的情况,数据查询就更加快速了。

实际上,有序数组也可以被作为索引。如果数据是静态的,也就是不会有插入、删除、更新操作,那可以把数据的关键词(查询用的)抽取出来,组织成有序数组,然后利用二分查找算法来快速查找数据。

课后思考

基础系统、中间件、开源软件等系统中,有哪些用到了索引吗?这些系统的索引是如何实现的呢?

  • 索引的英文名字叫:index,在实际的编程中,index这个单词,到处可见。例如:数组的下标就是index 索引是用来辅助查找,用计算机专业术语叫:Addressing(寻址) 现实世界中,查找会存在两种场景:

    1. 从局部信息,查询与其相关的整体信息

    2. 从整体信息中查询局部信息 搜索引擎需要查询一个网页中是否存在某个关键词以及通过某个关键词查询包含它的所有网页。 正是因为计算机大部分工作都是在Addressing,所以,在计算机中,索引到处存在。小到操作系统虚拟内存到真实内存的映射,就是索引嘛,大到分布式系统、网络,都是这个原理。

  • es中的单排索引其实用了trie树,对每个需要索引的key维护了一个trie树,用于定位到这个key在文件中的位置,然后直接用有序列表直接去访问对应的documents ,区块链拿以太坊来说吧,存储用的leveldb,数据存储用的数据结构是帕特利夏树,是一种高级的trie树,很好的做了数据的压缩, 消息中间件像kafka这种,会去做持久化,每个partition都会有很多数据,会有大量数据存储在磁盘中,所以每个partition也会有个索引,方便去做快速访问

51 | 并行算法:如何利用并行处理提高算法的执行效率?

时间复杂度是衡量算法执行效率的一种标准。但是,时间复杂度并不能跟性能划等号。在真实的软件开发中,即便在不降低时间复杂度的情况下,也可以通过一些优化手段,提升代码的执行效率。毕竟,对于实际的软件开发来说,即便是像 10%、20% 这样微小的性能提升,也是非常可观的。

当算法无法再继续优化的情况下,该如何来进一步提高执行效率呢?那就是并行计算。

并行排序

假设要给大小为 8GB 的数据进行排序,并且,机器的内存可以一次性容纳这么多数据。对于排序来说,最常用的就是时间复杂度为 O(nlogn) 的三种排序算法,归并排序、快速排序、堆排序。从理论上讲,已经很难再从算法层面优化了。而利用并行的处理思想,可以将这个问题的执行效率提高很多倍。

第一种是对归并排序并行化处理。可以将这 8GB 的数据划分成 16 个小的数据集合,每个集合包含 500MB 的数据。用 16 个线程,并行地对这 16 个 500MB 的数据集合进行排序。这 16 个小集合分别排序完成之后,再将这 16 个有序集合合并。

第二种是对快速排序并行化处理。通过扫描一遍数据,找到数据所处的范围区间。把这个区间从小到大划分成 16 个小区间。将 8GB 的数据划分到对应的区间中。针对这 16 个小区间的数据,启动 16 个线程,并行地进行排序。等到 16 个线程都执行结束之后,得到的数据就是有序数据了。

对比这两种处理思路,它们利用的都是分治的思想,对数据进行分片,然后并行处理。它们的区别在于,第一种处理思路是,先随意地对数据分片,排序之后再合并。第二种处理思路是,先对数据按照大小划分区间,然后再排序,排完序就不需要再处理了。这个跟归并和快排的区别如出一辙。

如果要排序的数据规模不是 8GB,而是 1TB,那问题的重点就不是算法的执行效率了,而是数据的读取效率。因为 1TB 的数据肯定是存在硬盘中,无法一次性读取到内存中,这样在排序的过程中,就会有频繁地磁盘数据的读取和写入。如何减少磁盘的 IO 操作,减少磁盘数据读取和写入的总量,就变成了优化的重点。

并行查找

散列表是一种非常适合快速查找的数据结构。

如果是给动态数据构建索引,在数据不断加入的时候,散列表的装载因子就会越来越大。为了保证散列表性能不下降,就需要对散列表进行动态扩容。对如此大的散列表进行动态扩容,一方面比较耗时,另一方面比较消耗内存。

实际上,可以将数据随机分割成 k 份(比如 16 份),每份中的数据只有原来的 1/k,然后针对这 k 个小数据集合分别构建散列表。这样,散列表的维护成本就变低了。当某个小散列表的装载因子过大的时候,可以单独对这个散列表进行扩容,而其他散列表不需要进行扩容。

当要查找某个数据的时候,只需要通过 16 个线程,并行地在这 16 个散列表中查找数据。这样的查找性能,比起一个大散列表的做法,也并不会下降,反倒有可能提高。

当往散列表中添加数据的时候,可以选择将这个新数据放入装载因子最小的那个散列表中,这样也有助于减少散列冲突。

并行字符串匹配

在文本中查找某个关键词这样一个功能,可以通过字符串匹配算法来实现。学过的字符串匹配算法有 KMP、BM、RK、BF 等。当在一个不是很长的文本中查找关键词的时候,这些字符串匹配算法中的任何一个,都可以表现得非常高效。但是,如果处理的是超级大的文本,那处理的时间可能就会变得很长,那有没有办法加快匹配速度呢?

可以把大的文本,分割成 k 个小文本。假设 k 是 16,就启动 16 个线程,并行地在这 16 个小文本中查找关键词,这样整个查找的性能就提高了 16 倍。

不过,这里还有一个细节要处理,那就是原本包含在大文本中的关键词,被一分为二,分割到两个小文本中,这就会导致尽管大文本中包含这个关键词,但在这 16 个小文本中查找不到它。实际上,这个问题也不难解决,只需要针对这种特殊情况,做一些特殊处理就可以了。

假设关键词的长度是 m。在每个小文本的结尾和开始各取 m 个字符串。前一个小文本的末尾 m 个字符和后一个小文本的开头 m 个字符,组成一个长度是 2m 的字符串。再拿关键词,在这个长度为 2m 的字符串中再重新查找一遍,就可以补上刚才的漏洞了。

并行搜索

学习过好几种搜索算法,它们分别是广度优先搜索、深度优先搜索、Dijkstra 最短路径算法、A* 启发式搜索算法。对于广度优先搜索算法,也可以将其改造成并行算法。

广度优先搜索是一种逐层搜索的搜索策略。基于当前这一层顶点,可以启动多个线程,并行地搜索下一层的顶点。原来广度优先搜索的代码实现,是通过一个队列来记录已经遍历到但还没有扩展的顶点。现在,经过改造之后的并行广度优先搜索算法,需要利用两个队列来完成扩展顶点的工作。

假设这两个队列分别是队列 A 和队列 B。多线程并行处理队列 A 中的顶点,并将扩展得到的顶点存储在队列 B 中。等队列 A 中的顶点都扩展完成之后,队列 A 被清空,再并行地扩展队列 B 中的顶点,并将扩展出来的顶点存储在队列 A。这样两个队列循环使用,就可以实现并行广度优先搜索算法。

总结引申

通过一些例子,比如并行排序、查找、搜索、字符串匹配,展示了并行处理的实现思路,也就是对数据进行分片,对没有依赖关系的任务,并行地执行。

并行计算是一个工程上的实现思路,尽管跟算法关系不大,但是,在实际的软件开发中,它确实可以非常巧妙地提高程序的运行效率,是一种非常好用的性能优化手段。

特别是,当要处理的数据规模达到一定程度之后,无法通过继续优化算法,来提高执行效率的时候,就需要在实现的思路上做文章,利用更多的硬件资源,来加快执行的效率。所以,在很多超大规模数据处理中,并行处理的思想,应用非常广泛,比如 MapReduce 实际上就是一种并行计算框架。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值