《C++性能优化指南》 linux版代码及原理解读 第五章


概述

当一个程序的执行时间需要很快的时候,但是实际的执行时间却远远的超出了预期时间好几个量级,这个时候进行优化的方式恐怕只能从算法的层面进行改进。大多数的优化方式对于性能的改善是线性的,但是更高效的算法有时候会使性能呈现指数的增长。
本章主要通过常见的几种排序和查找算法,对这个问题进行阐述。


一、算法的时间开销O(n)

首先,算法的时间开销O(n)并不是一个具体的概念,它只是一个抽象的数学函数,O(n)主要用来描述算法的预估时间开销的增长速度与数据规模(n)的关系。程序实际运行的时间并不是一个简单的O(n)就可以概括出来的,它会受到很多因素的影响,比如最基础的硬件的好坏可以决定电脑的运行速度的快慢,实时的系统负载能决定在相同的硬件条件下,当前的可用的性能,甚至不同的操作系统,以及对CPU的兼容性等等,都会决定一个程序实际的运行速度。但是忽略那些不方便对比的因素之外,时间开销只关注一个细节,就是开销和数据规模之间的关系。

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

用一个排序算法作为例子,n就代表要排序的所有的数据的数量大小,f(n)就是为了将所有的数据排序,而对其中的数据进行比较的次数。
如果用查找算法作为例子,n就代表要查找的数据,f(n)就是要差找到这个数据,所需要遍历的数据的数量。
下面概括介绍了一些常用算法的时间开销以及相对于程序运行时开销的倍数。

  • O( 1 1 1),即常量时间
    常量时间指的是数据的规模无所谓,算法都会在固定的时间内找到答案。这种算法的时间开销是所有算法中最少的。
  • O( l o g 2 ( n ) log_2(n) log2(n))
    比如二分查找算法,在一个排好序的数据中查找某一个数值,算法的每一步都会将数据划分成两部分。这种算法的开销很小,也比较现实一些,通常这种算法已经可以不用更加关注性能了。
  • O( n n n),即线性时间
    比如常规的遍历查找算法,将所有的数据从左到右一次遍历。线性时间的算法,时间开销的增长速度和数据规模的增长速度相同。这种算法不是很昂贵,但是当多个线性时间的算法合并到一起的时候,有可能时间开销会变成 n 2 n^2 n2,甚至更差。
  • O( n log ⁡ 2 ( n ) n\log_2(n) nlog2(n))
    快排是一种比较典型的时间复杂度是O( n log ⁡ 2 ( n ) n\log_2(n) nlog2(n))的算法,我们可以将快排想象成生成了一棵树,每一次和flag进行比较的时候,都会以flag为标志,生成左右两棵树,在生成一层树的时候,需要对所有的数据进行一次比较( n n n),而最终会生成的树的深度是 log ⁡ 2 ( n ) \log_2(n) log2(n),叠加上刚才每生成一层,需要对数据进行一次比较,最后的时间复杂度就是( n log ⁡ 2 ( n ) n\log_2(n) nlog2(n))。实际上这种算法的效率在真实的项目中也是可以的。
  • O( n 2 n^2 n2)、O( n 3 n^3 n3)等
    这种算法的时间开销的增长非常快,在小规模数据上的常规的解决方案或许可以采用这种方式,但是一旦数据量很大的时候,就需要对这种算法进行优化了。
  • O( 2 n 2^n 2n)
    这种算法的时间复杂度太大了,在使用这种算法的时候应该只在n很小的时候采用。在调度问题和行程规划问题上,比如旅行商问题,它的时间开销就是O( 2 n 2^n 2n),一般来说面对这种问题,有两种解决方法,一是使用一种无法确保最优解决方案的启发式算法,将解决方案限制在n很小的输入数据集上;或是找到其他方法加上与解决问题完全无关的值。

.算法的时间开销-最优情况、平均情况和最差情况

通常我们在讨论算法时间开销的时候,都会使用的是平均情况来讨论,比如我们之前讨论的快速排序,最坏的情况下时间复杂度是$n^2$,就是当树的深度和n差不多的时候,也就是当原始的数据集基本有序的时候,它就会不停的将flag值右边的数据划分到右子树中,这样就会生成一个类似于只有右子树-右字节点的树。所以,有可能数据的特性也会影响到算法的效果。

.摊销时间开销

摊销时间开销表示在大量输入数据上的平均时间开销。例如,向堆中插入一个元素的时间复杂度是O(log2n),那么如果每次插入一个元素,构建整个堆的时间就是O(n log2n)。不过,构建堆的最高效方法的时间开销是O(n),这意味着该方法插入每个元素的摊销时间复杂度是O(1)。但是最高效的算法并不会每次只插入一个元素。它会使用分治法算法(divide-and-conquer algorithm)将所有数据插入到依次增大的子堆中。最显著的摊销时间开销,发生在当某些独立的操作很快而其他操作很慢时。例如,将一个字符添加到std::string中的摊销时间开销是一个常量,但这其中包含了一次对内存管理器的调用所占用的部分时间。如果这个字符串很短,那么可能几乎每次在添加字符的时候都需要调用内存管理器。只有当程序再添加了数千个或是数百万个字符后,摊销时间开销才会变小。

.其他开销

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

另外还有一些算法在进行并行计算时会更快,但是需要购买相应数量的处理器来获取理论上的速度提升。在普通的计算机上,处理器的数量很少,也是固定的。因此,对于那些需要多于log2n个处理器的算法来说,使用普通计算机不合适。这些算法可能适用于为特殊用途构建的硬件或是图形处理器上。不过遗憾的是,由于篇幅限制,本书将不会讲解如何设计并行算法。

二、优化查找和排序的方式

      在优化查找和排序算法上,有以下几种常用的方式或思路:

  • 用平均时间开销更低的算法替换平均时间开销较大的算法。
  • 加深对数据的理解(例如,知道数据是已经排序完成的或是几乎排序完成的),然后根据数据的特性选择具有最优时间开销的算法,避免使用那些针对这些数据特性有较差时间开销的算法。比如之前讨论过的,在基本已经排好序的数据上使用快排算法可能时间复杂度能到达O( n 2 n^2 n2),但是在这种数据上使用冒泡排序,或者直接插入排序,它们的时间复杂度可能会达到O( n n n)。
  • 在已经选好了算法的基础上,调整算法,来线性的提升性能。

三、高效的查找算法

.查找算法的时间开销

  • 线性查找算法的时间开销为O(n),它的开销虽然大,却极其常用。它可以用于无序表。即使无法对表中的关键字进行排序,只要能够比较关键字是否相等,即可使用它。对于有序表,线性查找算法可以在查找完表中的所有元素之前结束。虽然它的时间开销仍然是O(n),但是平均速度的确比原来快了一倍。如果允许改变表,一种将每次查找结果都移动至表头的线性查找算法在某些情况下可能会有更高的性能。例如,每次在表达式中用到标识符时,都会去查找编译器中的符号表。如果程序中有很多形如i=
    i + 1;的表达式,这项优化就可以使线性算法有用武之地了。
  • 二分查找算法的时间开销是O(log2n),效率更高,但它并不是可能的最好的查找算法。二分查找算法要求表已经按照查找关键字排序完成,不仅需要可以比较查找关键字是否相等,还需要可以比较它们之间的大小关系。在查找和排序世界中,二分查找是最常用的算法。它是一种分治法算法,通过将待排序元素的关键字与位于表中间的元素的关键字进行比较,来决定该元素究竟是排在中间元素之前还是之后,不断地将表一分为二。
  • 插补查找(interpolation search)与二分查找类似,也是将有序表分为两部分,不过它用到了查找关键字的一些其他特性来改善分块性能。当查找关键字均匀分布时,插补查找的性能可以达到非常高效的O(log
    log n)
    。如果表很大或是测试表项的成本很高(例如当在一个旋转盘上时),这种改善效果是非常显著的。不过,插补查找仍然不是可能的最快的查找算法。
  • 通过散列法,即将查找关键字转换为散列表中的数组索引,是可以以平均O(1)的时间找出一条记录的。散列法无法工作于键值对的链表上,它需要一种特殊结构的表。它只需要比较散列表项是否相等即可。散列法在最差情况下的性能是O(n),而且它所需要的散列表项的数量可能比要查找的记录的数量多。不过,当表的内容是固定时(例如月份的名字或是编程语言的关键字),就不会发生最差情况了。

四、高效排序算法

    在刚才的文章中讨论过,虽然说起排序算法我们就想到了快排,以为它会是最快的排序算法,但是实际上,一是由于算法的发展,更多的排序算法被发明了出来,比如桶排序、Flash Sort等,它们的速度不见得比快排慢,甚至在大量的数据集上速度可能会很快;另一个是由于数据的特性不一样,当针对一个几乎已经排好序的数据集的时候,快排的时间复杂度可能会达到O( n 2 n^2 n2),而这种情况下插入排序就会有一个O( n n n)的时间复杂度。
以下是几种算法的时间复杂度总结:

在这里插入图片描述
以下是查到的更多的算法时间复杂度以及空间复杂度:
图片来自https://www.interviewkickstart.com/learn/time-complexities-of-all-sorting-algorithms
更多的内容可以搜索 “Time and Space Complexities of Sorting Algorithms ” 查看更多数据。

.替换在最差情况下性能较差的排序算法

    如果认为快速排序算法总是具有优秀的性能,是非常天真的想法。你必须对输入数据集有所了解,特别是知道它是否已经排序完成;要么对算法的实现有所了解,知道它是否仔细地筛选了初始支点元素。
    如果你对输入数据集一无所知,那么归并排序、树形排序和堆排序都可以确保不会发生性能变得无法接受的最坏情况。

.利用输入数据集的已知特性

  • 之前我们讨论过了不同的数据集对不同的排序算法的影响,有时候期望很高的算法在一个基本已经排序完成的数据集上却有很糟糕的性能,这让人很难忍受。索性现在又有了更多的排序算法,能够补足某些算法在某一方面的缺陷。
  • Timsort是一种相对较新的混合型排序算法,它在输入数据集已经排序完成或是几乎排序完成时,性能也能达到O(n);而对于其他情况,最优性能则是O(n log2n)。Timsort现在已经成为Python语言的标准排序算法了。
  • 最近还出现了一种称为内省排序(introsort)的算法,它是快速排序和堆排序的混合形式。内省排序首先以快速排序算法开始进行排序,但是当输入数据集导致快速排序的递归深度太深时,会切换为堆排序。内省排序可以确保在最差情况下的时间开销是O(n log2n)的同时,利用了快速排序算法的高效实现来减少平均情况下的运行时间。自C++11开始,内省排序已经成为了std::sort()的优先实现。
  • 另外一种最近非常流行的算法是Flash Sort。对于抽取自某种概率分布的数据,它的性能非常棒,达到了O(n)。Flash Sort是与基数排序类似,都是基于概率分布的百分位将数据排序至桶中。Flash Sort的一个简单的适用场景是当数据元素均匀分布时。

五、优化模式

以下是几种比较有效的优化模式:
预计算
      可以在程序早期,例如设计时、编译时或是链接时,通过在热点代码前执行计算来将计算从热点部分中移除。
延迟计算
      通过在真正需要执行计算时才执行计算,可以将计算从某些代码路径上移除。
批量处理
      每次对多个元素一起进行计算,而不是一次只对一个元素进行计算。
缓存
      通过保存和复用昂贵计算的结果来减少计算量,而不是重复进行计算。
特化
      通过移除未使用的共性来减少计算量。
提高处理量
      通过一次处理一大组数据来减少循环处理的开销。
提示
      通过在代码中加入可能会改善性能的提示来减少计算量。
优化期待路径
      以期待频率从高到低的顺序对输入数据或是运行时发生的事件进行测试。
散列法
      计算可变长度字符串等大型数据结构的压缩数值映射(散列值)。在进行比较时,用散列值代替数据结构可以提高性能。
双重检查
      通过先进行一项开销不大的检查,然后只在必要时才进行另外一项开销昂贵的检查来减少计算量。

.预计算

      预计算是一种优化技巧。它并没有哪种固定的形式,通常来说,只要将部分计算从热点代码中移到热点代码之前执行。它的形式有很多中,比如将部分计算移动到不是很热点的部分,或者在程序的编译、链接、甚至程序设计阶段。
      有时候编译器可以对代码进行预计算,当然,预计算仅当被计算的值不依赖于上下文时才适用。比如如下这段代码:

int sec_per_day = 60 * 60 * 24;

但是下面这段代码就需要依赖程序中的变量了:

int sec_per_weekend = (date_end - date_beginning + 1) * 60 * 60 * 24;

      如果我们想要对这段代码进行预计算,要么我们就将 60 * 60 * 24 提取出来,要么我们就需要确定 (date_end - date_beginning + 1) 在程序中是一个变值还是固定的数值,如果是固定的数值我们可以直接使用结果来替换它。
      以下是预计算的几个例子:

  • C++编译器会使用编译器内建的相关性规则和运算符优先级,对常量表达式的值自动地进行预计算。编译器对上例中的sec_per_day的值进行预计算是没有问题的。
  • 编译器会在编译时评估调用模板函数时所用到的参数。如果参数是常量的话,编译器会生成高效代码。
  • 当设计人员可以观察到,例如,当在一段程序的上下文中,“周末”的概念总是两天,那么他可以在编写程序的时候预计算这个常量。

.延迟计算

      延迟计算的目的在于将计算推迟至更接近真正需要进行计算的地方。延迟计算带来了一些好处。如果没有必要在某个函数中的所有执行路径(if-then-else逻辑的所有分支)上都进行计算,那就只在需要结果的路径上进行计算。以下是延迟计算的例子。

写时复制COW
      写时复制是指当一个对象被复制时,并不复制它的动态成员变量,而是让两个实例共享动态变量。只在其中某个实例要修改该变量时,才会真正进行复制。

两段构建(two-part construction)
      当实例能够被静态地构建时,经常会缺少构建对象所需的信息。在构建对象时,我们并不是一气呵成,而是仅在构造函数中编写建立空对象的最低限度的代码。稍后,程序再调用该对象的初始化成员函数来完成构建。将初始化推迟至有足够的额外数据时,意味着被构建的对象总是高效的、扁平的数据结构。在某些情况下,检查延迟计算的值是否已经计算完成会产生额外的开销。这种开销与确保指向动态构建的类的指针是有效的开销相当。

.批量处理

批量处理的目标是收集多份工作,然后一起处理它们。批量处理可以用来移除重复的函数调用或是每次只处理一个项目时会发生的其他计算。当有更高效的算法可以处理所有输入数据时,也可以使用批量处理将计算推迟至有更多的计算资源可用时。举例如下。

  • 缓存输出是批量处理的一个典型例子。输出字符会一直被缓存,直至缓存满了或是程序遇到行尾(EOL)符或是文件末尾(EOF)符。相比于为每个字符都调用输出例程,将整个缓存传递给输出例程节省了性能开销。
  • 将一个未排序的数组转换为堆的最优方法是通过批量处理使用更高效算法的一个例子。将n个元素一个一个地插入到堆中的时间开销是O(n log2n),而一次性构建整个堆的开销则只有O(n)。
  • 多线程的任务队列是通过批量处理高效地利用计算资源的一个例子。
  • 在后台保存或更新是使用批量处理的一个例子。

.缓存

缓存指的是通过保存和复用昂贵计算的结果来减少计算量的方法。这样可以避免在每次需要计算结果时都重新进行计算。举例如下。

  • 就像用于解引数组元素的计算一样,编译器也会缓存短小的、重复的代码块的结果。当编译器发现了像a[i][j] = a[i][j] + c;这样的语句后会保存数组表达式,然后生成一段像这样的代码:auto p = &a[i][j]; *p = *p + c;。
  • 高速缓存指的是计算机中使处理器可以更快地访问那些需要频繁访问的内存地址的特殊电路。缓存是计算机硬件设计中的一个重要概念。在x86架构的PC的硬件和软件中有多级缓存。
  • 在每次需要知道C风格的字符串的长度时,都必须计算字符的数量,而std::string则会缓存字符串的长度,不会在每次需要时都进行计算。
  • 线程池缓存了那些创建开销很大的线程。
  • 动态规划是一项算法技术,通过计算子问题并缓存结果来提高具有递归关系的计算的速度。

.特化

特化与泛化相对。特化的目的在于移除在某种情况下不需要执行的昂贵的计算。通过移除那些导致计算变得昂贵的特性可以简化操作或是数据结构,但是在特定情况下,这是没有必要的。可以通过放松问题的限制或是对实现附加限制来实现这一点,例如,使动态变为静态,限制不受限制的条件,等等。举例如下。

  • 模板函数std::swap()的默认实现可能会复制它的参数。不过,开发人员可以基于对数据结构内部的了解提供一种更高效的特化实现。(当参数类型实现了移动构造函数时,C++11版本的std::swap()会使用移动语义提高效率。)
  • std::string可以动态地改变长度,容纳不定长度字符的字符串。它提供了许多操作来操纵字符串。如果只需要比较固定的字符串,那么使用C风格的数组或是指向字面字符串的指针以及一个比较函数会更加高效。

.提升处理量

提高处理量的目标是减少重复操作的迭代次数,削减重复操作带来的开销。这些策略如下。

  • 向操作系统请求大量输入数据或是或发送大量输出数据,来减少为少量内存块或是独立的数据项调用内核而产生的开销。提高处理量的副作用是,当程序崩溃,特别是在写数据时崩溃时,损失的数据量更大。对写日志文件等操作来说,这可能会是一个问题。
  • 在移动缓存或是清除缓存时,不要以字节为单位,而要以字或是长字为单位。这项优化仅在两块内存对齐至相同大小的边界时才能改善性能。
  • 以字或是长字来比较字符串。这项优化仅适用于大端计算机,不适用于小端的x86架构计算机。像这种依赖于计算机架构的技巧可能会非常危险,因为它们是不可移植的。
  • 在唤醒线程时执行更多的工作。在唤醒线程后,不要只让处理器执行一个工作单元后就放弃它,应当让它处理多个工作单元。这样可以节省重复唤醒线程的开销。
  • 不要在每次循环中都执行维护任务,而应当每循环10次或是100次再执行一次维护任务。

.提示

使用提示来减少计算量,可以达到减少单个操作的开销的目的。
例如,std::map中有一个重载的insert()成员函数,它有一个表示最优插入位置的可选参数。最优提示可以使插入操作的时间开销变为O(1),而不使用最优提示时的时间开销则是O( l o g 2 ( n ) log_2(n) log2(n))。

.优化期待路径

在有多个else-if分支的if-then-else代码块中,如果条件语句的编写顺序是随机的,那么每次执行经过if-then-else代码块时,都有大约一半的条件语句会被测试。如果有一种情况的发生几率是95%,而且首先对它进行条件测试,那么在95%的情况下都只会执行一次测试。

.散列法

大型数据结构或长字符串会被一种算法处理为一个称为散列值的整数值。通过比较两个输入数据的散列值,可以高效地判断出它们是否相等。如果散列值不同,那么这两个数据绝对不相等。如果散列值相等,那么输入数据可能相等。散列法可与双重检查一起使用,以优化条件判断处理的性能。通常,输入数据的散列值都会被缓存起来,这样就无需重复地计算散列值。

.双重检查

双重检查是指首先使用一种开销不大的检查来排除部分可能性,然后在必要时再使用一个开销很大的检查来测试剩余的可能性。举例如下。

  • 双重检查常与缓存同时使用。当处理器需要某个值时,首先会去检查该值是否在缓存中,如果不在,则从内存中获取该值或是通过一项开销大的计算来得到该值。
  • 当比较两个字符串是否相等时,通常需要对字符串中的字符逐一进行比较。不过,首先比较这两个字符串的长度可以很快地排除它们不相等的情况。
  • 双重检查可以用于散列法中。首先比较两个输入数据的散列值,可以高效地判断它们是否不相等。如果散列值不同,那么它们肯定不相等。只有当散列值相等时才需要逐字节地进行比较。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值