优化算法

优化算法

多数优化方法的性能改善效果是线性的,但是使用更高效的算法替换低效算法可以使性能呈现指数增长。设计高效算法是许多计算机科学教科书和博士学术论文的主题。计算机科学家之所以研究重要的算法和数据结构,是因为它们是展示如何优化代码的典型示例。

算法的时间开销

算法的时间开销是一个抽象的数学函数,它描述了随着输入数据规模的增加,算法的时间开销会如何增长。时间开销通常用大O标记表示,例如\(O(f(n))\),其中\(n\)是某个会显著影响输入数据规模的因素,\(f(n)\)描述的是一个算法对规模为\(n\)的输入数据执行了多少次显著的操作。通常,\(f(n)\)被简化为仅表示增长最快的因素,因为对于很大的\(n\)来说,这个因素决定了\(f(n)\)的值。

最优情况、平均情况和最差情况的时间开销

通常的大O标记假设算法对任意输入数据集的运行时间是相同的。不过,有些算法对输入数据的特性非常敏感,例如,它们在按照某种顺序排序的输入数据上的运行速度,可能比在其他规模相同但顺序不同的输入数据上的运行速度上要快。当考虑在有严格的性能需求的代码中使用哪种算法时,非常重要的一点是必须知道该算法是否有最差情况。

有些算法在最优情况下同样也具有最优时间开销,例如,对那些已经排序完成的输入数据集进行排序时的时间开销会较小。当输入数据集具有某些可以利用的特性时,选择一种在最优情况下具有最佳性能的算法可以减少程序的运行时间。

摊销时间开销

摊销时间开销表示在大量输入数据上的平均时间开销。例如,向堆中插入一个元素的时间复杂度是\(O(log_2n)\),那么如果每次插入一个元素,构建整个堆的时间就是\(O(nlog_2n)\)。不过构建堆的最高效方法的时间开销是\(O(n)\),这意味着该方法插入每个元素的摊销时间复杂度是\(O(1)\)。但是最高效的算法并不会每次只插入一个元素。它会使用分治法(divide-and-conquer algorithm)将所有数据插入到依次增大的子堆中。

最显著的摊销时间开销,发生在当某些独立的操作很快而其他操作很慢时。例如,将一个字符添加到std::string中的摊销时间开销是一个常量,但这其中包含了一次对内存管理器的调用所占用的部分时间。如果这个字符串很短,那么可能几乎每次在添加字符的时候都需要调用内存管理器。只有当程序再添加了数千个或是数百万个字符后,摊销时间开销才会变小。

其他开销

有时候,通过保存中间结果可以提高算法的速度。因此这种算法不仅有时间开销,还有额外的存储开销。例如,我们所熟知的遍历二叉树的递归算法的时间开销是线性的,但是在递归过程中还会发生额外的\(log_2n\)的栈空间存储开销。需要大量存储空间开销的算法可能不适用于内存容量很小的运行环境。

另外还有一些算法是在进行并行计算时会更快,但是需要购买相应数量的处理器来获取理论上的速度提升。在普通的计算机上,处理器的数量很少,也是固定的。因此,对于那些需要多于\(log_2n\)个处理器的算法来说,使用普通计算机不合适。这些算法可能适用于为特殊用途构建的硬件或是图形处理器上。

优化查找和排序的工具箱

  • 用平均时间开销更低的算法替换平均时间开销较大的算法。
  • 加深对数据的理解。然后根据数据的特性选择具有最优时间开销的算法,避免使用那些针对这些数据特性有较差时间开销的算法。
  • 调整算法来线性地提高其性能。

高效查找算法

查找算法的时间开销

  • 线性查找算法的时间开销为\(O(n)\),它的开销虽然大,却极其常用。它可以用于无序表。如果允许改变表,一种将每次查找结果都移动至表头的线性查找算法在某些情况下可能会有更高的性能。
  • 二分查找算法的时间开销是\(O(log_2n)\),效率更高,但它并不是可能的最好的查找算法。二分查找算法要求表已经按照查找关键字排序完成。
  • 插补查找(interpolation search)与二分查找类似,也是将有序表分成两个部分,不过它用到了查找关键字的一些其他特性来改善分块性能。当查找关键字均匀分布时,插补查找的性能可以达到\(O(log log n)\)。如果表很大或是测试表项的成本很高,这种改善效果是非常显著的。
  • 通过散列法,即将查找关键字转换为散列表中的数组索引,是可以以平均\(O(1)\)的时间找出一条记录的。散列法无法工作于键值对的链表上,它需要一种特殊结构的表。散列法在最差情况下的性能是\(O(n)\),而且它所需要的散列表项的数量可能比要查找的记录的数量多。不过,当表的内容是固定时,就不会发生最差情况了。

当n很小时,所有算法的时间开销都一样

高效排序算法

  • 最佳排序算法的时间开销是\(O(nlog_2n)\)?并不是,只有成对地比较输入值的算法才是这样的。基数排序算法的时间开销是\(O(nlog_rn)\),其中r是基数,即排序桶的个数。而且,如果要排序的关键字属于某个特定的集合,例如从1到n的连续整数,Flash Sort的排序时间开销是\(O(n)\)
  • 快速排序算法是一种经常被实现和使用的算法,它的最差情况下的性能是\(O(n^2)\)。没有可靠的方法可以避免最差情况,而且它的原生实现方式的效率很差。
  • 有些排序算法,包括插入排序算法,虽然并非非常适合用于随机数据,但在几乎排序完成的数据集上却具有非常棒的线性效率。其他排序算法在已经排序完成的数据集上会出现最差情况的性能。如果通常数据都是已经排序完成或是几乎排序完成的,那么利用这些额外的数据特性可以帮助我们选择一种在有序表上具有更高性能的排序算法。

排序算法的时间开销

排序算法 最好情况 平均情况 最差情况 空间需求 最好/最差情况的注意点
插入排序 \(n\) \(n^2\) \(n^2\) 1 最好情况出现在数据集已经排序完成时
快速排序 \(nlog_2n\) \(nlog_2n\) \(n^2\) \(log_2n\) 最差情况出现在数据集已经排序完成时
或是支点元素的原生选择(第一个/最后一个)
归并排序 \(nlog_2n\) \(nlog_2n\) \(nlog_2n\) 1
树形排序 \(nlog_2n\) \(nlog_2n\) \(nlog_2n\) \(n\)
堆排序 \(nlog_2n\) \(nlog_2n\) \(nlog_2n\) 1
Timsort \(n\) \(nlog_2n\) \(nlog_2n\) \(n\) 最好情况出现在当前数据集已经排序完成时
内省排序 \(nlog_2n\) \(nlog_2n\) \(nlog_2n\) 1

Timsort是一种相对较新的混合型排序算法,现在已经成为Python语言的标准排序算法了。

内省排序(introsort)算法是快速排序和堆排序的混合形式。内省排序首先以快速排序算法开始进行排序,但是当输入数据集导致快速排序的递归深度太深时,会切换为堆排序。自C++11开始,内省排序已经成为了std::sort()的优先实现。

Flash Sort对于抽取自某种概率分布的数据,它的性能达到了\(O(n)\)。与基数排序类似,都是基于概率分布的包分为将数据排序至桶中。Flash Sort的一个简单的适用场景是当数据元素均匀分布时。

优化模式

预计算

可以在程序早期,例如设计、编译、链接时,通过在热点代码前执行计算来讲计算从热点部分中移除。预计算仅当被计算的值不依赖于上下文时才适用。

延迟计算

通过在真正需要执行计算时才执行计算,可以将计算从某些代码路径中移除。

批量处理

每次对多个元素一起进行计算,而不是一次只对一个元素进行计算。

缓存

通过保存和复用昂贵计算的结果来减少计算量,而不是重复进行计算。

特化

通过移除未使用的共性来减少计算量。

提高处理量

通过一次处理一大组数据来减少循环处理的开销。

提示

通过在代码中加入可能会改善性能的提示来减少计算量。

优化期待路径

以期待频率从高到低的顺序对输入数据或是运行时发生的事件进行测试。例如,条件控制语句中,若有多个else if条件,发生几率最大的情况应写在前面。

散列法

计算可变长度字符串等大型数据结构的压缩数值映射(散列值)。在进行比较时,用散列值代替数据结构可以提高性能。

双重检查

通过先进行一项开销不大的检查,然后只在必要时才进行另外一项开销昂贵的检查来减少计算量。

posted @ 2020-04-15 19:20  睿阳  阅读(675)  评论(0编辑  收藏  举报