Ruby 课程计算机科学篇:Big O 与时间复杂度完整指南
2026/9/16 12:10:16 网站建设 项目流程

Ruby 课程计算机科学篇:Big O 与时间复杂度完整指南

【免费下载链接】curriculumThe open curriculum for learning web development项目地址: https://gitcode.com/GitHub_Trending/cu/curriculum

时间复杂度是衡量算法效率的核心工具。本篇文章基于开源课程 cu/curriculum(The Odin Project 风格的 Web 开发开放课程)中 Ruby 计算机科学模块的《Time Complexity》一课,系统讲解为什么不能靠"秒表计时"衡量代码快慢、如何用步骤计数法分析算法、Big O / Omega / Theta 三种渐近记号的区别,以及 O(1)、O(log N)、O(N)、O(N log N)、O(n²)、O(n³)、O(2ⁿ)、O(N!) 八种常见复杂度的判定方法。学完本文,你将能够对任意 Ruby 代码(循环、嵌套循环、递归、二分查找、排序算法)进行复杂度分析,并为后续的哈希表、二叉搜索树、BFS/DFS 等数据结构与算法课程奠定分析基础。

为什么要关心代码的效率

写代码到一定阶段,你会从"让代码能跑"过渡到"让代码好读、好维护"。可读性和可维护性固然重要——你读代码的时间很可能不亚于写代码的时间——但还有一个同样关键的维度:效率(Efficiency)。你需要理解自己写出的代码将如何运行,理解每一个选择(数据结构、算法、写法)如何影响性能,才能在需求面前选出正确的数据结构与算法。

在编程中,衡量代码效率有两种方式:

  • 时间复杂度(Time Complexity):衡量算法运行所需的步骤数如何随输入规模变化。
  • 空间复杂度(Space Complexity):衡量算法运行所需的内存如何随输入规模变化。这部分内容在课程中由 space_complexity.md 一课专门讲解。

本文聚焦时间维度。

为什么不能用"运行时间"衡量效率

先看课程给出的第一个例子:打印 1 到 10 之间的所有奇数。

def odd_numbers_less_than_ten current_number = 1 while current_number < 10 if current_number % 2 != 0 puts current_number end current_number += 1 end end

在终端运行它会输出13579,整个过程可能只花了不到一秒。但如果再运行一次,耗时可能相同、也可能更快或更慢——取决于此刻计算机还在忙什么;换一台电脑运行,结果又会不同。

这就是关键结论:永远不要用实际执行时间来衡量一个算法的效率。执行时间受硬件、系统负载、语言实现等无关因素干扰,无法作为可比较的度量。

步骤计数法:算法效率的度量方式

正确的度量方式是数"步骤":一个算法完成同样的任务需要 5 步,另一个需要 20 步,那么在同一台计算机上,5 步的算法永远比 20 步的快。

回到odd_numbers_less_than_ten,逐条数它的步骤:

  1. 把数字 1 赋给变量current_number:1 步。
  2. 循环的每次迭代:
    • 比较current_number是否小于 10:1 步;
    • 检查current_number是否为奇数:1 步;
    • 若是奇数则输出到终端:每 2 次迭代 1 步;
    • current_number += 1:1 步。
  3. 退出循环前,最后一次比较current_number是否不再小于 10:1 步。

汇总:每次迭代约 3 步,循环 9 次,共 27 步;输出操作约 5 步(9 次迭代中约一半);加上变量初始赋值 1 步、退出条件比较 1 步。总计 27 + 5 + 1 + 1 =34 步

这个数字本身是有用的信息,但对比较算法没有帮助。为什么?把方法稍作修改,接受一个参数而不是写死 10:

def odd_numbers(max_number) current_number = 1 while current_number < max_number if current_number % 2 != 0 puts current_number end current_number += 1 end end

现在步骤数是多少?答案取决于传入的max_number。传 10 时是 34 步,传其他值步数就变了。不存在一个固定的数字可以用来衡量这段代码的效率,因为它随外部输入变化。

我们真正想衡量的,是当数据变化时,算法的步骤数如何变化——这才能回答"代码能否规模化(scale)"的问题。这正是渐近记号(Asymptotic Notations)要解决的事情。

渐近记号:Big O、Omega 与 Theta

渐近记号用来描述算法的运行时间。由于运行时间随输入不同而不同,存在三种常见记号,从不同角度测量:

记号含义场景
Big O(大 O)算法的上界最坏情况(worst-case)下算法如何表现
Omega(大 Ω)算法的下界最好情况(best-case)下算法如何表现
Theta(大 Θ)同时包含上界与下界平均情况(average-case)下的复杂度

Big O 是最常被引用的记号,因为你需要确保任何代码的最坏情况都能在输入增长时保持可扩展。后面列出的八种复杂度记号同样适用于 Omega 与 Theta,区别只在于它们衡量效率的角度不同。

什么是 Big O

Big O 提供了一种一致的方法来衡量算法效率:它度量当输入增长时算法运行时间的变化趋势,从而让你能直接比较两个算法的性能并选出更优者。

需要澄清的是,Big O 并不是一段"把你的算法放进去就能算出效率"的代码。你需要自己测量"步骤数如何随数据增长而变化",再据此套用对应的 Big O 记号。在多数情况下,你使用的数据结构其常见操作的复杂度是已知的(例如哈希表的增删查、数组的按下标访问),这时就很容易判断它随输入变化会如何扩展。

八种常见 Big O 复杂度(按速度从快到慢)

课程给出了最常用 Big O 记号的速查表:

记号名称
O(1)常数复杂度(Constant Complexity)
O(log N)对数复杂度(Logarithmic Complexity)
O(N)线性复杂度(Linear Complexity)
O(N log N)N × log N 复杂度
O(n²)平方复杂度(Quadratic Complexity)
O(n³)立方复杂度(Cubic Complexity)
O(2ⁿ)指数复杂度(Exponential Complexity)
O(N!)阶乘复杂度(Factorial Complexity)

下面逐一拆解每种复杂度,并给出 Ruby 示例与判定方法。

O(1):常数复杂度

用数组来理解常数复杂度:

arr = [1, 2, 3, 4, 5]

想取索引 2 处的元素,用arr[2]一次即可拿到3,只需 1 步。把数组翻倍:

arr = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]

arr[7]依然 1 步返回8。数组无论多大,按下标访问任意元素都是 1 步——恒定的,所以是 O(1)。按单步完成查找已经是时间复杂度能到达的最好情况。

这里有一个 Big O 的常见"陷阱":真的是 1 步吗?严格来说不是——计算机需要先找到数组在内存中的位置,再从首元素跳到参数指定的索引,至少是几步。所以写成O(1 + 2(steps))也不能说错。但这 2 步只是附带开销(incidental):数组有 10,000 个元素时,它依然是同样的步数。Big O 不关心这类常数,因为它们在数据规模变化时不提供任何复杂度增长的信息,所以在 Big O 中会被丢弃。Big O 只关心算法复杂度相对于输入规模的关系。

O(log N):对数复杂度

对数复杂度的含义是:数据翻倍时,算法步骤数只增加 1。从 5,000 个元素增长到 10,000 个元素只多 1 步,扩展性非常好。

最典型的 O(log N) 算法是二分查找(Binary Search)。它只适用于有序数组。假设有序数组:

arr = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]

要确认数组里是否包含数字7,二分查找用公式计算中间索引:

middle_index = (start_index + end_index) / 2

其中start_index初始为 0,end_index初始为 9(10 个元素的数组)。中间元素是索引 4 处的5

因为数组有序且7 > 5,可以排除5及其左侧所有元素(它们都小于 7):

arr = [-, -, -, -, -, 6, 7, 8, 9, 10]

仅仅 1 步就排除了一半数组。接着用新的start_indexend_index重算中间索引,此时中间索引是 7,该位置是8。由于7 < 8,排除8及其右侧所有元素:

arr = [6, 7, -, -, -]

重复这个过程直到数组只剩 1 个元素:若该元素匹配要找的数则找到,否则说明该数不在数组中。

下表总结了数组大小翻倍时,Big O 意义上需要多少步才能收敛到 1 个元素:

规模步数
11
22
43
84
165
326

这正是对数增长的威力:规模每翻倍一次,只需多 1 步。O(log N) 复杂度在课程后续的二叉搜索树课程 project_binary_search_trees.md 中还会反复出现——该课程明确指出,二叉搜索树的插入/删除可以达到 O(log n),相比数组的同类操作是显著的性能提升。

O(N):线性复杂度

线性复杂度最容易理解:元素数量增长多少,步骤数就以同样的速率增长多少。每次遍历数组都是线性复杂度的例子。5 个元素的数组用 5 步遍历完,10 个元素用 10 步。凡是 Big O 为 O(N) 的算法,其步骤数都会与数据结构中的元素数量同步增长。

课程中反复出现的odd_numbers方法就是 O(N) 的典型:输入规模增大,步骤数以相同速率增加。

O(N log N):N × log N 复杂度

这个记号的意思是:算法一开始是 O(log N)(如二分查找那样反复把数组对半切分),但每一半都要再被一个 O(N) 复杂度的算法处理,于是得到 O(N log N)。

最典型的例子就是课程上一节项目课中实现的归并排序(Merge Sort)——它在 project_recursion.md 中通过递归"分而治之"把排序问题不断拆分为更小的子问题,再按序合并回去。归并排序正是 O(N log N) 复杂度的代表作:拆分阶段是对数级,而每一层的合并操作整体是线性的。

O(n²):平方复杂度

平方复杂度很常见:在一个数据集上循环,循环体内又对整个数据集循环一次(嵌套循环)。例如数组有 3 个元素时,嵌套循环需要 3² = 9 个子步骤;加 1 个元素几乎让工作量翻倍到 4² = 16;5 个元素是 5² = 25;把数组翻倍到 10 个元素,子步骤从 25 暴涨到 100——工作量变成原来的 4 倍!

# 双层嵌套循环的典型形态 arr.each do |i| arr.each do |j| # 每对 (i, j) 都是一个子步骤 end end

平方复杂度提示:输入翻倍,工作量变成 4 倍。

O(n³):立方复杂度

立方复杂度对应三重嵌套循环。对 n 个元素的数组,增加 1 个元素意味着多一层外层循环、一层中层循环和一层最内层循环,总子步骤为 n³。3 个元素需要 3³ = 27 个子步骤;4 个元素是 4³ = 64,增加一个元素就翻了一倍多;5 个元素是 5³ = 125;数组翻倍到 10 个元素需要 10³ = 1000 个子步骤,是原来的 8 倍;100 个元素则需要 1,000,000 个子步骤。

O(2ⁿ):指数复杂度

指数复杂度的含义是:每增加一个数据项,步骤数相对之前翻倍。下表展示了它失控的速度:

规模步数
12
24
38
416
532
664
7128
8256
9512
101024

能避免就尽量避免,否则你处理不了多少数据就会卡死。

O(N!):阶乘复杂度

一个数的阶乘是 1 到该数之间所有整数的乘积,例如 4! = 4 × 3 × 2 × 1 = 24。当你需要计算排列或组合时会遇到阶乘复杂度:给定一个数组,穷举它能组成的所有组合就是阶乘复杂度。少量元素时还可控,但每增加一个数据项,跳跃幅度都极其惊人:3! = 6,4! = 24,10! = 3,628,800。可以看到规模稍微一大,计算量立刻失控。

Big O 之外的衡量方式:Omega 与 Theta

如果 Big O 给出的是最坏情况,那还有哪些替代记号?

Big Ω(Omega 记号)

Omega 记号给出算法最好情况的复杂度。看课程中的例子:

def find_value(arr) arr.each do |item| return item if item == 1 end end

最坏情况(Big O)发生在要找的值不在数组中、或是数组最后一个元素时,算法需要遍历每个元素,复杂度为 O(N)——输入规模翻倍,最坏情况下的迭代次数也翻倍。

但在最好情况下,要找的值是数组的第一个元素,算法只需 1 步,复杂度为 Ω(1),这就是它的 Omega 复杂度。

Omega 记号被认为没那么有用,因为目标值很少恰好是数据结构中的第一个元素,它无法告诉我们算法在实际中如何扩展。

Big Θ(Theta 记号)

Omega 衡量最好情况、Big O 衡量最坏情况,而 Theta 试图给出精确值,或在窄的上界与下界之间给出有用的范围。

如果一段代码遍历数组中的每个元素,那么无论数组多大,最好情况和最坏情况都是 O(N) 时间,可以确定它在所有场景下的精确性能是 Θ(N)。对于其他算法,Theta 可能同时代表不同复杂度的下界与上界。这里不再深入,因为 Big O 是描述一般算法时间复杂度最常用的记号。

为什么用 Big O(最坏情况)

理解了三种记号之后,选择最坏情况来衡量算法效率的理由就清楚了:用最坏情况能确保算法在所有结果下都可扩展。如果一个算法可能以常数时间运行、但最坏情况下是线性时间,那么只有最坏情况也能扛住,它才能随输入增长而扩展。你必须确信:当输入突然从 10 个变成一百万个时,代码不会卡死、不会让用户干等。

相同复杂度的算法:为什么常数会被丢弃

两个算法复杂度相同,就代表它们一样好吗?课程用两个代码示例回答这个问题。

第一个是我们已经见过的odd_numbers,时间复杂度 O(N):

def odd_numbers(max_number) current_number = 1 while current_number < max_number if current_number % 2 != 0 puts current_number end current_number += 1 end end

第二个改动很小——每次递增 2:

def odd_numbers(max_number) current_number = 1 while current_number < max_number if current_number % 2 != 0 puts current_number end current_number += 2 end end

对输入 n,第二个版本每次迭代跳过 2,步骤数约为原来的一半,可以写成 O(N/2)。但正如前面所说,Big O 追求的不是精确时间,而是时间随输入规模增长的关系。Big O 不关心常数,因为常数与"算法随输入如何扩展"无关——如果要在 O(N/2 + 5N) 与 O(N + 5/2N) 之间比较,那既不有趣也不方便。因此,两个算法的 Big O 效率都是 O(N),它们随输入增长的比例速率相同。

换个角度看:常数最终会变得无关紧要。看下面的对比(O(10N) 与 O(n²)):

NO(10N)O(n²)差距
1101
55025
1001,00010,00010 倍
1,00010,0001,000,000100 倍
10,000100,000100,000,0001,000 倍

所以当 N 达到 100 时,O(10N) 快于 O(n²)。但从实践角度看,对某些很小的输入集合,n² 算法反而可能比 N 算法更快(见上表前两行)。这也提醒你:在保证时间复杂度正确的前提下,尽量让代码本身写得高效(例如减少不必要的变量和遍历)。

复杂度分析在课程后续章节中的落地

时间复杂度分析不是孤立的理论,它贯穿整个计算机科学模块:

  • 哈希表(Hash Map):hash_map_data_structure.md 一课明确给出,哈希表的插入、检索、删除平均复杂度为 O(1),因为操作直接基于数组索引;最坏情况为 O(n),发生在所有数据哈希到同一个桶、需要遍历链表时。这正解释了为什么课程强调设计良好的哈希函数以减少冲突。
  • 二叉搜索树(BST):project_binary_search_trees.md 指出 BST 的插入/删除可达到 O(log n),并专门提醒不要用原始输入数组实现这些操作,否则会丢掉这一性能优势——这正是把本课的 O(log N) 分析应用于实际项目的实例。
  • 递归与分治:recursive_methods.md 与 project_recursion.md 中的归并排序是 O(N log N) 的经典实现,而递归深度过大会导致调用栈溢出——这既是时间复杂度的延伸,也涉及空间复杂度。
  • 数据结构的权衡:common_data_structures_algorithms.md 一课讨论栈、队列、链表以及 BFS/DFS 搜索算法时,处处以"操作耗时与内存占用"的权衡为出发点;project_linked_lists.md 中的链表操作(at(index)需要从头遍历)正是线性复杂度的实战案例。
  • 空间复杂度:时间与空间是一体两面,space_complexity.md 用与时间相同的 Big O 记号衡量内存使用,并引入了辅助空间分析(auxiliary space analysis)的概念。

知识自测

回顾本课的核心问题,检验自己是否掌握:

  • 什么是 Big O?它衡量的效率维度是什么?
  • 最常见的 Big O 记号有哪些,从快到慢如何排列?
  • 为什么要使用 Big O(最坏情况)来衡量算法?
  • 什么是 Big Omega,为什么它没那么有用?
  • 为什么 Big O 中常数不影响复杂度结论?
  • 为什么不能用实际运行时间衡量算法效率?
  • O(log N) 算法在数据翻倍时步骤数如何变化?

此外,课程的作业环节还推荐了外部资源,包括面向 Ruby 开发者的 Big-O 指南、Big-O 速查表(含各常见数据结构操作与排序算法的时间/空间复杂度对照),以及更深入的 Ruby 时间复杂度专题文章,可作为进阶阅读在完成本课后自行查阅。下一课将把同样的分析框架应用到空间维度,请继续学习 space_complexity.md。

【免费下载链接】curriculumThe open curriculum for learning web development项目地址: https://gitcode.com/GitHub_Trending/cu/curriculum

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询