3. 代码级优化:算法与数据结构选择、减少不必要的计算、循环优化技巧、避免过度设计

聊到代码级优化,很多人第一反应是「把循环展开」、「用位运算代替除法」。嗯,这些技巧确实有用,但我想先说一句大实话:选错了算法和数据结构,后面再怎么微优化都是白搭。这就好比你要从北京到上海,选了骑自行车,然后拼命给自行车装涡轮增压——方向错了,努力白费。

这一章,我把自己这些年踩过的坑、总结的经验,掰开揉碎了讲给你听。咱们从四个维度来聊:算法与数据结构的选择、减少不必要的计算、循环优化技巧、以及避免过度设计。

3.1 算法与数据结构选择:选对了,事半功倍

我个人习惯,拿到一个性能问题,先不看代码细节,先问自己三个问题:

  • 数据量有多大?
  • 操作类型是什么?(读多写少?写多读少?还是随机访问?)
  • 有没有现成的、经过验证的方案?

举个例子。我在项目中遇到过这样一个场景:一个实时推荐系统,需要频繁地判断某个用户 ID 是否在黑名单里。最初的实现用的是 std::vector,每次查找都是 O(n) 的线性扫描。数据量小的时候还好,用户量一上来,延迟直接飙到几百毫秒。

我当时看了一眼,二话不说改成了 std::unordered_set,查找复杂度降到了 O(1)。改动就一行代码,性能提升了两个数量级。你想想看,这就是数据结构选择的威力。

核心原则: 选择数据结构时,先搞清楚你的「主要操作」是什么。查找多就用哈希表或平衡树;顺序访问多就用数组或链表;需要频繁插入删除就用链表或跳表。

下面这张图,是我自己整理的数据结构选择决策流程,你可以参考一下:

主要操作是什么? 查找为主 插入/删除为主 哈希表 O(1) 平衡树 O(log n) 链表 O(1) 跳表 O(log n)

再说算法。我记得有一次优化一个排序模块,数据量大概 10 万条,原来的实现用了冒泡排序。嗯,你没看错,冒泡排序。我当时差点没忍住笑出来。换成快速排序后,执行时间从 8 秒降到了 0.02 秒。这已经不是优化了,这是「降维打击」。

小提示: 别自己造轮子。C++ 的 std::sort、Java 的 Collections.sort、Python 的 sorted(),这些都是经过千锤百炼的实现,比你手写的快得多。

3.2 减少不必要的计算:少做,就是快

这个原则听起来简单,但实际做起来,很多人会忽略。我见过太多代码,里面充斥着「重复计算」和「提前计算」。说白了,就是做了很多没必要的事。

来看一个典型的例子:

// 不好的写法
for (int i = 0; i < vec.size(); i++) {
    if (vec[i] > 100) {
        process(vec[i], sqrt(vec[i]));  // 每次循环都算 sqrt
    }
}

// 优化后的写法
double threshold = 100.0;
for (int i = 0; i < vec.size(); i++) {
    double val = vec[i];
    if (val > threshold) {
        double sqrt_val = sqrt(val);  // 只算一次
        process(val, sqrt_val);
    }
}

你看,改动不大,但效果很明显。把循环不变的计算提到外面,把重复计算的结果缓存起来,这些都是基本功。

我在项目中遇到过更夸张的:一个图像处理算法,每次循环都重新计算一个查找表。那个查找表其实只跟图像分辨率有关,而分辨率在整个处理过程中是不变的。我把查找表的计算提到循环外面,性能提升了 40%。

注意: 减少计算不等于「偷工减料」。有些计算是必须的,不能为了性能牺牲正确性。比如浮点数比较,该用 epsilon 还是得用。

还有一个常见的优化点:延迟计算。说白了,就是「能不算就不算,实在要算再算」。比如一个对象的某个属性,可能在整个生命周期里都不会被访问到,那就没必要在构造时就计算好。等真正需要的时候再算,这叫「懒加载」。

3.3 循环优化技巧:小改动,大收益

循环是性能优化的「主战场」。程序 90% 的时间都花在 10% 的代码上,而这 10% 的代码里,循环占了绝大多数。

我总结了几个实用的循环优化技巧:

  1. 减少循环内的函数调用——函数调用有开销,尤其是虚函数。能内联的就内联,能提到外面的就提到外面。
  2. 使用局部变量——把循环中频繁访问的成员变量或全局变量,先赋值给局部变量。局部变量在栈上,访问速度更快。
  3. 循环展开——手动或让编译器帮你展开循环,减少循环控制的开销。不过别过度,展开太多会导致指令缓存溢出。
  4. 消除循环依赖——如果循环的每次迭代都依赖上一次的结果,那就很难并行化。想办法打破这种依赖。

来看一个具体的例子:

// 优化前
for (int i = 0; i < n; i++) {
    data[i] = data[i] * factor + offset;
}

// 优化后(循环展开 + 局部变量)
int local_factor = factor;
int local_offset = offset;
int i = 0;
for (; i + 3 < n; i += 4) {
    data[i]   = data[i]   * local_factor + local_offset;
    data[i+1] = data[i+1] * local_factor + local_offset;
    data[i+2] = data[i+2] * local_factor + local_offset;
    data[i+3] = data[i+3] * local_factor + local_offset;
}
for (; i < n; i++) {
    data[i] = data[i] * local_factor + local_offset;
}

我曾经优化过一个视频编码模块,里面有个三层嵌套循环。最内层循环每次都要访问一个全局配置表。我把配置表指针提到最外层,用局部变量传递进去,性能提升了 15%。

一个小技巧: 写循环的时候,尽量让循环体「扁平」。嵌套层次越深,分支预测失败的概率越大,性能越差。能合并的循环就合并,能用一维数组代替二维数组的就代替。

3.4 避免过度设计:简单,就是最好的优化

这一点,我想多说几句。很多开发者,包括年轻时的我,都有一种「设计癖」——总觉得代码写得越复杂越高级,恨不得把所有设计模式都用上。结果呢?代码是「优雅」了,性能却惨不忍睹。

我记得有一次 review 一个同事的代码,他用了一个抽象工厂模式来创建不同的日志处理器。整个调用链有 5 层虚函数,每次写日志都要经过 5 次间接跳转。我问他:「你这里真的需要多态吗?是不是一个简单的 if-else 就够了?」他想了想,改成 if-else 后,日志写入性能提升了 3 倍。

过度设计带来的性能问题,主要体现在:

  • 虚函数开销——每次调用都要查虚函数表,无法内联
  • 对象创建销毁——过多的对象创建和销毁,增加 GC 压力或内存碎片
  • 间接跳转——函数指针、回调、消息传递,都会破坏 CPU 的分支预测
  • 不必要的抽象——为了「可扩展性」而引入的抽象层,往往永远用不到
我的原则: 先写出能工作的简单版本,然后通过性能分析工具找到瓶颈,再针对性地优化。不要一开始就想着「万一以后要扩展怎么办」——你连现在的性能都还没搞定呢。

我曾经接手过一个项目,代码写得「非常优雅」——用了 7 种设计模式,类图画了满满一黑板。但性能测试一跑,惨不忍睹。我花了三天时间,把核心路径上的所有抽象层全部去掉,用最直接的方式重写。性能提升了 10 倍,代码量减少了 60%。

你想想看,用户在乎的是你的代码用了什么设计模式吗?不,用户只在乎你的程序跑得快不快、稳不稳定。

避坑指南: 我曾经在一个高并发系统中,为了「代码复用」把两个完全不同的业务逻辑塞进了同一个模板类里。结果每次调用都要做大量的类型判断和分支跳转。后来拆成两个独立的类,性能直接翻倍。记住:复用不是目的,性能才是。

小结

这一章我们聊了四个方面的优化:

优化方向 核心思想 常见手段
算法与数据结构 选对工具 哈希表、平衡树、快速排序等
减少计算 少做无用功 循环不变式外提、延迟计算、缓存
循环优化 压榨循环效率 展开、局部变量、消除依赖
避免过度设计 保持简单 减少抽象层、避免不必要的多态

最后送大家一句话:最好的优化,是那些你不需要做的优化。选对了方向,写对了代码,性能自然就上去了。下一章,咱们聊聊内存优化——这可是个「深水区」,准备好了吗?