CategoryResourceRepost/极客时间专栏/数据结构与算法之美/高级篇/51 | 并行算法:如何利用并行处理提高算法的执行效率?.md
louzefeng d3828a7aee mod
2024-07-11 05:50:32 +00:00

66 lines
8.9 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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