首页
/ Hello 算法:算法效率评估指南——为什么“能跑”不等于“够好”,从实际测试到渐近复杂度分析

Hello 算法:算法效率评估指南——为什么“能跑”不等于“够好”,从实际测试到渐近复杂度分析

2026-09-07 17:51:32作者:俞予舒Fleming

算法效率评估是《Hello 算法》计算复杂度章节的开篇内容。本文基于仓库中 性能评估章节原文 及其简体中文镜像 docs/chapter_computational_complexity/performance_evaluation.md 展开,回答一个贯穿全书的根本问题:在算法“能解出正确答案”之后,我们凭什么说一个算法比另一个算法更好?读完本文,你将掌握算法设计的两层目标、时间/空间效率两个评价维度,理解“实际测试”这一直觉方法为何不可靠,以及“渐近复杂度分析(复杂度分析)”为何能成为衡量算法效率的标准“标尺”,并能将本仓库中 time_complexity.*worst_best_time_complexity.* 等源码示例与实际章节内容相互印证。

为什么需要评估算法效率:先求解,再求优

原文档开门见山地指出:算法设计过程中,我们先后追求两个层面的目标,且顺序不能颠倒:

  1. 找到问题解法:算法需要在规定的输入范围内,可靠地求得问题的正确解。这是底线,解不出正确结果的方法没有资格进入后续讨论。
  2. 寻求最优解法:同一个问题往往存在多种解法,在都能正确求解的前提下,我们希望选出“尽可能高效”的那个算法。

第二层目标成立的前提是第一层已经达成——在能够解决问题的前提下,算法效率才成为衡量算法优劣的主要评价指标。这解释了为什么《Hello 算法》每个数据结构章节(如 docs/chapter_searching/two_sum.md 中“暴力枚举 vs 哈希查找”的对比)都在讨论“同题多解”时额外引入效率维度:正确答案只是入场券,运行得快、占得少才是加分项。

而效率本身包含两个正交的维度:

  • 时间效率:算法运行时间的长短。
  • 空间效率:算法占用内存空间的大小。

把两个维度合在一起,就是全书设计数据结构与算法时反复强调的目标——“既快又省”(fast and memory-efficient)。要达成这一目标,前提是有一套可靠的效率评估方法,否则我们无法把 A、B 两个候选算法放到同一把“尺子”下比较,也无法在优化过程中判断改动是否真的变好了。

效率评估的两条路径:实际测试与理论估算

针对“如何评估效率”,原文档给出了两种方法论:

评估方式 核心做法 回答的问题
实际测试 在真实机器上运行算法,监控记录运行时间与内存占用 这个算法在我的机器上到底跑多久、占多少内存?
理论估算(复杂度分析) 不运行代码,仅通过数学计算推导资源消耗与输入规模的关系 当输入规模 nn 增大时,时间/空间开销按什么“趋势”增长?

其中“实际测试”最符合直觉,最容易上手;但正因为它依赖具体运行环境与具体输入,反而暴露出两个难以克服的硬伤。下面分别展开。

实际测试:直观、真实,但难以消除的两大干扰

假设现在有算法 A 与算法 B,都能求解同一问题。最直接的办法就是找一台计算机,分别运行两个算法,并监控它们的运行时间和内存占用。这种评估方式能反映真实运行情况,但原文档提醒我们,它存在相当大的局限性,集中体现在两方面。

局限一:难以排除测试环境的干扰因素

硬件配置会直接左右算法的性能表现,例如:

  • 如果某个算法并行度较高,它在多核 CPU 上运行会明显更快;
  • 如果某个算法内存操作密集(反复访问大数组),它配备高性能内存时会表现更好。

也就是说,同一算法在不同机器上的测试结果可能不一致。若要得出可复现的结论,就需要在各种机器上分别测试并统计平均效率——而这对绝大多数开发者来说是不现实的(原文档明确指出 “这是不现实的”)。

更隐蔽的问题在于:即便在同一台机器上,CPU 调度、后台进程、缓存状态、系统负载都会让每次的“墙钟时间”(wall-clock time)产生抖动。仓库中 C++ 版 best/worst 复杂度示例 已经用代码暗示了这种“时间敏感性”:

// 使用系统时间生成随机种子(代码见 randomNumbers)
unsigned seed = chrono::system_clock::now().time_since_epoch().count();
shuffle(nums.begin(), nums.end(), default_random_engine(seed));

这段代码用系统时钟作为随机种子去打乱数组,随后每次运行查找数字 1 的位置都会落在不同的索引上。它本意是为展示“最好/最坏情况”的输入差异,但反过来也说明:真实的执行路径每次都在变,单次计时的可信度有限

局限二:展开完整测试非常耗费资源

算法效率并非一个固定的数字,而是随着输入数据量的变化而变化的函数。原文档给出了一个非常典型的现象:

在输入数据量较小时,算法 A 的运行时间比算法 B 短;而在输入数据量较大时,测试结果可能恰恰相反。

如果只测一两个输入规模就下结论,很可能得出与事实相反的评价;而要得到“有说服力的结论”,就必须对各种规模的输入数据逐一测试,这需要耗费大量的计算资源与时间。许多真实算法(如需要跨多数量级压测的排序、搜索类算法)根本无法在可接受的代价内做到“完整测试”。

为了直观体会“同一算法在不同输入上表现截然不同”,可以运行仓库中的多语言实现。以 C++ 版 worst_best_time_complexity.cpp 为例:

/* 查找数组 nums 中数字 1 所在索引 */
int findOne(vector<int> &nums) {
    for (int i = 0; i < nums.size(); i++) {
        // 当元素 1 在数组头部时,达到最佳时间复杂度 O(1)
        // 当元素 1 在数组尾部时,达到最差时间复杂度 O(n)
        if (nums[i] == 1)
            return i;
    }
    return -1;
}

同一个 findOne,在 1 恰好位于头部时 1 次比较即返回(O(1)),位于尾部时却要扫完整个数组(O(n))。Go 版测试用例 worst_best_time_complexity_test.go 连续执行 10 轮“打乱数组 → 查找”来展示这种逐次不同的结果。如果脱离输入分布去谈“这个算法花了 0.01 ms”,得到的将是一个没有普遍意义的孤例。

因此可以归纳出实际测试的两条结论:

  1. 结果依赖环境(硬件、语言运行时、系统负载),难以跨平台推广;
  2. 结果依赖输入规模,而穷举所有规模的成本高到不可行。

理论估算:渐近复杂度分析(复杂度分析)

既然实际测试的代价大、可移植性差,原文档给出了另一条路径:仅通过计算来估算算法效率。这种估算方法被称为渐近复杂度分析(asymptotic complexity analysis),简称复杂度分析

其核心定义可以概括为:

复杂度分析体现算法运行所需时间和空间资源与输入数据规模之间的关系,描述的是随着输入规模增加,执行所需时间和空间的变化趋势。

这一定义较长,原文档贴心地将其拆解为三个重点:

  1. “时间和空间资源”:分别对应 时间复杂度(time complexity)空间复杂度(space complexity)。这是后续两章的正式研究对象。
  2. “随着输入数据规模的增加”:复杂度刻画的不是“这个算法跑 3 秒”的绝对数值,而是“运行效率与输入数据规模 nn 之间的函数关系”。
  3. “时间和空间的增长趋势”:复杂度分析关注的是时间/空间“增长得快还是慢”——是常数级、线性级、平方级,还是指数级,而不是某个具体时刻的精确测量值。

复杂度分析为什么能替代实测

相比实际测试,复杂度分析在原文档中被明确归纳出三点优势:

  • 无需实际运行代码:它只做“纸上推演”,更加绿色节能,也无需准备测试机群与海量算力;
  • 独立于测试环境:分析结果基于数学模型,天然适用于所有运行平台,不存在“换台机器结论就变”的问题;
  • 能够体现不同数据量下的效率:尤其是大数据量下的算法性能——而这恰恰是实际测试最难覆盖、又最常决定算法选型的场景。

不难看出,复杂度分析在“可移植性”和“覆盖面”上对实际测试形成了互补乃至替代。需要强调的是,这并不意味着仓库和书里完全不做实测——例如 C++ 版时间复杂度示例 的 Driver Code 注释就写着“可以修改 n 运行,体会各种复杂度的操作数量变化趋势”,以操作计数而非秒表计时的方式观察增长。这正是理论估算思想的体现:统计“操作数量”随 nn 的变化,而不是统计墙钟秒数。

让我们看其中两个代表性函数,体会“数量级”是如何被构造出来的:

/* 常数阶:循环次数固定,与 n 无关 */
int constant(int n) {
    int count = 0;
    int size = 100000;
    for (int i = 0; i < size; i++)
        count++;
    return count;
}

/* 线性阶:循环次数与 n 成正比 */
int linear(int n) {
    int count = 0;
    for (int i = 0; i < n; i++)
        count++;
    return count;
}

同一份代码在 C 版Python 版Go 版 等多语言目录中均有等价实现。把 constantlinear 的返回计数值打印出来,无需任何计时器,就能看出:前者永远是固定值,后者随 nn 线性攀升——这正是复杂度分析“看趋势不看绝对值”的直观演示。

实测与估算的取舍小结

对比维度 实际测试 渐近复杂度分析
是否运行代码 需要 不需要
结果是否依赖机器 强依赖,难以推广 完全独立
对不同输入规模的覆盖 需逐一实测,成本高 一次推导覆盖全部规模
关注对象 具体耗时/内存数值 资源随规模 nn 的增长趋势
主要成本 计算资源、测试时间 数学推导能力

复杂度分析是全书的一把“标尺”

原文档将复杂度分析比喻为评价算法效率的**“标尺”(ruler)**:有了它,我们才能衡量执行某个算法所需的时空资源,并在不同算法之间做公平对比。这把标尺的作用贯穿全书——每当讨论某一数据结构或算法的特性(哈希表查找多快、红黑树平衡多少次旋转、动态规划表占多大空间)时,都绕不开对其运行速度与空间占用的分析。

同时,原文档也坦诚指出了复杂度概念的入门难度:

  • 复杂度本质上是个数学概念(描述增长趋势的函数表达),对初学者而言比较抽象;
  • 因此它可能“不太适合作为最先介绍的内容”;
  • 但若完全不了解它,讨论任何数据结构/算法时都会寸步难行。

基于这种两难,书中给出的学习建议是:在深入学习数据结构与算法之前,先对复杂度分析建立初步理解,至少能完成简单算法的复杂度分析;进一步的数学细节(大 OO 记号、常见阶数、最坏/最好/平均情况等)则交给同章节的后续文档循序渐进地展开。

在仓库中继续深入的方向

如果你想顺着这篇评估指南继续把“复杂度”学透,《Hello 算法》计算复杂度章节已经为你铺好了清晰的路径:

小结:从“能跑”到“够好”,先学会评估

总结本篇的核心脉络:算法设计的第一目标是正确求解,第二目标才是追求更优;而“更优”由时间效率空间效率两个维度共同定义。评估效率有“实际测试”与“理论估算”两条路径:前者直接真实,却受制于环境干扰与测试成本;后者即渐近复杂度分析,以“资源随输入规模的增长趋势”为度量对象,不跑代码、不挑机器、天然覆盖大数据量场景,因而成为比较算法、指导设计与优化的标准方法。

对初学者而言,先不要急于记住复杂的数学记号,而是像原文档建议的那样:把“看趋势、比数量级”当作第一直觉。带着这把“标尺”进入后续的每个数据结构和算法章节,你会发现几乎每一页讨论背后,都在回答同一个问题——这个方案,到底够不够“既快又省”。

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.14 K
2.75 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
857
1.35 K
docsdocs
暂无描述
Markdown
897
5.81 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
531
596
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
920
1.84 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.79 K
1.02 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.36 K
1.46 K
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
1.02 K
519
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
548
390