算法学习的理想与现实差距

算法,以及数据结构,在计算世界中几乎无处不在:从一次普通的函数调用,到计算机网络中的路由与负载均衡。在足够理论化的视角下,算法首先是一个逻辑模型,而具体代码只是它在某种程序设计语言中的翻译结果;也有更激进的讨论会以汇编作为统一基准,以尽可能排除实现层面的噪声,不过更通常的表达方式仍是伪代码——顺序、循环、条件已经足以承载相当强的表达力。

我们对算法的一般印象,往往集中在渐进复杂度分析上,尤其是 RAM 模型下的渐进复杂度分析;这个简单而强大的模型有效指导了数十年的算法设计,并在 2010s 之后的多核时代仍然表现出相当的稳健性,至今也是本科教学的核心任务之一。不过,纯粹的形式模型往往只是现实的投影,是一种选择性的反映;作为逻辑模型的算法,在现实中同样需要被审慎地限定适用范围。以渐进复杂度分析为例,它反映的是理想模型下算法耗时的增长趋势,但在某些场景中,它并不总能与实际表现严格吻合。

最直接的差异来自设备 I/O。一次磁盘 I/O 就足以迫使算法设计走向完全不同的方向:如果我们采用的模型只是一个计算单元、一个读写针头和一条无限纸带,那么 B 树相较于二叉树(即便后者通常带有平衡条件)未必会表现出压倒性的性能优势;但在数据库系统中,B 树能够显著减少查询次数,根节点甚至可以常驻主存,并且天然支持区间访问,因此获得了极其巨大的工程优势。

另一个值得考量的问题是缓存。L1 缓存命中的诱惑几乎无法忽视:例如,相比于分离链表法散列表(也就是竞赛中常见的拉链法),开放定址散列表由于避免了若干次指针访问,在某些场景中完全可能更快。尤其是线性探测,虽然聚集问题相当明显,却又会带来 CPU 预取的优势。

其次是复杂的并发环境。某些在串行模式下表现良好的算法与数据结构,一旦进入并发场景,简单加锁便可能引入锁竞争,并显著拖慢性能;内存顺序的合理配置也会构成不小的挑战。更极端的情形,则是试图实现无锁数据结构的种种技巧。并发编程本就处在相当暧昧的地带,实际可靠性往往依赖一系列原语提供担保,因此对 lock-free 的渴望固然理想主义十足,落到操作层面却常常骨感得近乎残酷。

更多时候,算法性能并不是唯一指标。维护一个动态演化的系统,远比做出一个原型困难得多;正如《计算机程序的构造和解释》第一版前言所直言:“程序必须写得能够供人们阅读,偶尔地去供机器执行。”如果为了优化某个算法而显著牺牲整体可读性,甚至使代码对他人近乎不可读,那么取舍就必须回到团队状况与实际需求:我们不需要为了微小收益支付远超其价值的成本。这种情况虽然并不常见(因为多数时候不过是在沿用前人的算法及算法设计思想),但每一次优化仍都值得保持警惕。

现实中的算法处理,并不像算法题那样理想化;优化目标也未必总是性能。正如实时系统首先关心的往往是用户体感,而不是吞吐量,更广泛的工程语境中,我们面对的其实是取舍与权衡。在算法题中,一题多解或许是一种训练思维的方式;但在现实中,选择哪一个解本身就是充分困难的挑战。由此会引出更深一层的问题:如果从架构视野审视,核心要义并不在于在一个理想化环境中识别模式,然后挑选自己掌握的算法将其解决。

每个领域都有大量适配自身语境的算法,所谓最强、最高效的算法从来不存在。学习算法,不在于积累多少种算法以覆盖多少类问题,而在于把握算法设计模式:很多算法之所以频繁出现在算法竞赛中,并不一定因为它们最适合真实使用,而是因为它们最适合被组织成竞赛题。以红黑树为例,我日常使用它的频率绝对高于动态规划,毕竟每一次 std::setstd::map 的操作背后都是红黑树实现。当某个标准本身成为优化目标时,就尤其值得警惕;工业时代以来的标准化教育,实质上就是设立一个标准器件,再按照个体与标准器件的接近程度加以评估——倘若比拼的是这种针对某一类任务的泛化能力,我并不相信人类能够拼得过大语言模型。