优化代码速度/复杂度优化顺序
通常,算法具有渐近计算复杂度。假设输入的大小为 N,我们可以说算法将在 O(N)、O(N^2)、O(N^3)、O(N*log(N)) 等时间内完成。这意味着它是一个输入大小的特定数学表达式,并且算法在它的两个因子之间完成。
通常,程序底层算法的复杂度越低,运行速度越快,并且随着输入规模的扩大,可扩展性越好。因此,我们通常应该寻求更有效的算法来降低复杂度。
假设我们要在包含N个项目的集合中查找特定项目。一个朴素的查找算法是逐个遍历所有项目,看看它们是否与该项目匹配。然后,当我们找到该项目时,我们可以停止,或者如果我们没有找到它,则声明它没有找到。
这被称为线性搜索,平均(和最坏情况)复杂度为 O(N)。现在,如果我们打算多次进行这种朴素的查找,那么每次查找的成本将是 O(N)。这通常是不可接受的。
更好的方法是使用更有效的查找方法。例如,二分搜索 是 O(log(N))。它假设我们将现有项目保存在排序数组中,或者保存在平衡树中。哈希表 是一种启发式方法,通过精心设计其底层参数,可以提供平均 O(1) 的查找,但 O(log(N)) 通常已经足够好。
弗利塞尔求解器 是一个用ANSI C 编写的库,它可以解决弗利塞尔 和类似的“完全知识”纸牌游戏。弗利塞尔求解器从一开始就使用了传统的游戏人工智能 启发式方法,如深度优先搜索 和最佳优先搜索。因此,需要维护之前遇到的棋盘位置(或“状态”)的集合,以避免重复检查。
求解器的第一个版本(用Perl 编写)使用了一个数组上的线性搜索。这证明对于解决即使是最基本的游戏也是太慢了。之后,程序用 C 重新实现,并使用了一个排序数组,其中“排序边距”使用 ANSI C 的qsort 函数 进行排序,该函数执行快速排序 算法,或其他有效的排序算法,平均复杂度为 O(N*log(N)),使程序的平均查找时间为 O(log(N)),累积增加时间在 O(N*log(N)) 和 O(N^2) 之间。
后来的版本可选地使用了两个平衡二叉树 库:GNU libavl 和libredblack,它们的最大查找和插入时间为 O(log(N)),累积运行时间为 O(N*log(N))。后来,有时用 C 语言编码了一个自定义哈希表,它的运行速度甚至比平衡二叉树略快,并且具有平均 O(N) 的运行时间。这个哈希表后来通过微优化进一步优化。
诸如快速排序、归并排序 或堆排序 等普通的比较排序算法的运行时间为 O(N*log(N))。这比“插入排序”或“冒泡排序”等朴素的比较排序算法要好得多,它们的运行时间为 O(N^2)。但是,在某些情况下,我们也可以改进 O(N*log(N))。
其中一种情况是,如果所讨论的键是整数,或者可以映射到某个范围内的整数。在这种情况下,我们可以使用“计数排序” 等算法,以大约 O(N) 的时间进行排序。如果我们在基数中有多个这样的“位”(只要它们的数目保持不变),我们也可以尝试使用“基数排序” 以及计数排序。
另一方面,如果要排序的元素数量很少,那么有时使用插入排序比归并排序或快速排序更好,因为后者的算法开销很大。
在他的文章“回到基础” 中,乔尔·斯波斯基说明了一种常见的(也是不必要的)模式,它会增加程序的复杂度。本质上,当一个人在 C 中做
char string[1000]; strcpy (string, "One "); strcat (string, "Two "); strcat (string, "Three "); strcat (string, "Four "); . . .
等等,那么 strcat 调用将一遍又一遍地从字符串的开头开始,并寻找(不断增加的)终点。因此,附加 N 个每个长度有限的字符串的复杂度变为 O(N^2),而不是 O(N)。
消除代码中的这种问题实现可以带来显著的提速。
需要注意的是,一些算法虽然具有比等效算法更低的 Big-O 表示法,但要么过于复杂而无法有效实现,要么具有巨大的运行时间因子,使其不适用于大多数合理的数据集,或者两者兼而有之。例如,在 90 年代发现了一种在线性时间 (O(N)) 内查找数组中位数(= 中间元素)的算法,但它非常复杂并且具有非常大的线性因子,因此高效的 O(N*Log(N)) 排序通常会更快。而且,我们经常对中位数感兴趣以优化排序,因此从一开始就不使用这种算法是有意义的。