当前位置: 首页 > news >正文

Big-O 表示法简介(基础入门)

我们在学习计算机科学时会学到算法中的大O符号。

事实上,我们大多数人只是简单地理解为:

越接近O(1),复杂度越低,计算越快,越好;越接近O(n!),复杂度越高,计算越慢,越不好。

那么,大O的定义是什么?它又是怎么产生的呢?让我们来探讨一下。

大O符号的起源

大O符号最早由德国数学家**保罗·巴赫曼(Paul Bachmann)**于1894年在他的著作《解析数论》中首次提出。巴赫曼引入这个符号是为了比较和近似表达函数的增长率。后来,**埃德蒙·兰道(Edmund Landau)**进一步完善并推广了这一概念。兰道将其称为“兰道符号(Landau notation)”,并在计算机科学中作为计算算法复杂度的强大工具被广泛使用。

此外,大O符号还有其他变体,比如表示下界的“大Ω”符号,以及同时表示上下界的“大Θ”符号。

(华盛顿大学CSE 373讲义)

当有两个函数f(n)和g(n)时,f(n)=O(g(n))意味着对于足够大的n,存在一个正常数C,使得:

∣f(n)∣≤C⋅∣g(n)∣

也就是说,f(n)可以用g(n)的常数倍来作为上界(upper bound)。

举个例子:

如果f(n)=3n²+2n+1,则f(n)是O(n²)。

因为只考虑最高次项n²,并忽略常数系数。

那么,如果f(n)=2n +10呢?

聪明的人可能已经猜到了,f(n) = O(n)。

总之,大O符号是用来表示函数的渐进增长率(asymptotic growth rate)的。

如果觉得这有点难理解,简单来说就是:

随着输入规模增大,函数复杂度的增长情况。

(img ref:EP132: Big O Notation 101: The Secret to Writing Efficient Algorithms )

Big-O的概念就是这样,通常我们会用Big-O来计算两种复杂度:

时间复杂度(Time Complexity)
  • 表示随着输入规模增大,算法执行所需时间(操作次数)的增长率。
常见的时间复杂度
  • O(1)→ 常数时间(Constant Time)

    • 示例:通过索引访问数组元素(如arr[2]),或在哈希表中查找键值(无冲突的情况下)。
    • 解释:与输入规模(n)无关,操作一步完成。
  • O(log n)→ 对数时间(Logarithmic Time)

    • 示例:二分查找、平衡二叉搜索树(BST)中查找节点。
    • 解释:每次操作将输入规模减半(例如不断分割数据进行查找)。
  • O(n)→ 线性时间(Linear Time)

    • 示例:遍历数组的所有元素,链表中查找特定值。
    • 解释:操作时间随输入规模(n)线性增长。
  • O(n log n)→ 线性对数时间(Linearithmic Time)

    • 示例:归并排序(Merge Sort)、快速排序(Quick Sort)、堆排序(Heap Sort)。
    • 解释:采用分治策略(Divide and Conquer),将n个元素反复分成两半(log n),然后合并(n)。
  • O(n²)→ 平方时间(Quadratic Time)

    • 示例:嵌套循环检查数组的所有组合(如冒泡排序、选择排序),图中所有节点对的最短路径计算(Floyd-Warshall算法)。
    • 解释:输入规模增大时,操作时间呈平方级爆炸式增长。
  • O(2ⁿ)→ 指数时间(Exponential Time)

    • 示例:斐波那契数列的朴素递归实现(重复计算多),生成所有子集(Subset)。
    • 解释:即使输入规模较小,操作时间也呈指数级增长,不实用。
空间复杂度(Space Complexity)
  • 表示随着输入规模增大,算法使用的内存增长情况。
常见的空间复杂度
  • O(1)→ 常数空间(Constant Space)

    • 示例:单个变量使用(如循环中的索引i),与输入规模无关的固定大小变量(如int a = 10)。
    • 解释:与输入规模(n)无关,使用固定内存。
  • O(log n)→ 对数空间(Logarithmic Space)

    • 示例:平衡二叉搜索树(BST)的递归遍历(递归调用栈深度),快速排序的平均空间复杂度(分治时栈深度)。
    • 解释:递归或分治算法中,栈/内存使用量与输入规模的对数成正比。
  • O(n)→ 线性空间(Linear Space)

    • 示例:存储输入数组的副本(如newArr = arr.slice()),图的邻接表表示(与节点数成正比的内存)。
    • 解释:内存使用量随输入规模(n)线性增长。
  • O(n²)→ 平方空间(Quadratic Space)

    • 示例:图的邻接矩阵表示(n x n矩阵),动态规划(DP)的二维表(如最长公共子序列)。
    • 解释:内存使用量随输入规模的平方增长,处理大规模数据时效率低下。
  • O(2ⁿ)→ 指数空间(Exponential Space)

    • 示例:存储所有子集(Subset),递归斐波那契的最差空间复杂度(重复调用栈)。
    • 解释:即使输入规模较小,内存使用量也呈指数级增长。

两者都用大O符号表示,并假设最坏情况(Worst Case)来分类性能。

区别在于:

  • 时间复杂度关注速度,而空间复杂度关注内存使用。
权衡(Trade-off)
  • 快速算法可能消耗更多内存(如动态规划),
  • 或者节省内存但增加时间消耗(如递归与迭代对比)。

例如,使用更多内存来降低时间复杂度的一个例子是哈希表。

时间复杂度表 (Time Complexity Table)

排序算法

最佳情况 (Best)

平均情况 (Average)最差情况 (Worst)

插入排序 (Insertion Sort)

O(n)

O(n²)

O(n²)

选择排序 (Selection Sort)

O(n²)

O(n²)

O(n²)

冒泡排序 (Bubble Sort)

O(n)

O(n²)

O(n²)

归并排序 (Merge Sort)

O(nlogn)

O(nlogn)

O(nlogn)

快速排序 (Quick Sort)

O(nlogn)

O(nlogn)

O(n²)

堆排序 (Heap Sort)

O(nlogn)

O(nlogn)

O(nlogn)

希尔排序 (Shell Sort)

O(nlogn)

O(n^1.3)

O(n²)

计数排序 (Counting Sort)

O(n+k)

O(n+k)

O(n+k)

基数排序 (Radix Sort)

O(nk)

O(nk)

O(nk)

桶排序 (Bucket Sort)

O(n+k)

O(n+k)

O(n²)

1. 插入排序 (Insertion Sort)

最佳情况 (Best):
O(n)
当数据已经排序时,每个元素只需比较而无需移动位置。
例如: [1, 2, 3, 4] 只需比较,无需插入。

平均情况 (Average):
O(n²)
当数据随机分布时,每个元素平均需要移动一半的位置。

最坏情况 (Worst):
O(n²)
当数据完全逆序时,每个元素需要与所有前面的元素进行比较和移动。
例如: [4, 3, 2, 1] 中,每个元素最多需要 n 次比较和移动。


2. 选择排序 (Selection Sort)

最佳、平均、最坏情况:
均为 O(n²)
原因:无论输入数据的状态如何,每一步都需要在剩余数组中找到最小值(或最大值),因此比较次数始终固定。
即使在最佳情况下,交换次数减少,但比较次数不变。


3. 冒泡排序 (Bubble Sort)

最佳情况 (Best):
O(n)
当数据已经排序时,一次扫描后如果没有发生交换即可提前结束。
例如: [1, 2, 3, 4] 不会发生交换,因此只需一次完整扫描后结束。

平均情况 (Average):
O(n²)
在随机数据中,每个元素平均需要移动到一半的位置。

最坏情况 (Worst):
O(n²)
当数据完全逆序时,每个元素最多需要 n 次比较和交换。
例如: [4, 3, 2, 1] 中,每个元素需要逐个移动。


4. 归并排序 (Merge Sort)

最佳、平均、最坏情况:
均为 O(n log n)
原因:归并排序通过分治法(Divide and Conquer)将数据分为两部分,并在合并过程中始终保持相同的工作量。
无论输入数据的状态如何,分治和合并过程都以相同的方式进行。


5. 快速排序 (Quick Sort)

最佳情况 (Best):
O(n log n)
当每次选择的基准值(Pivot)都能将数据均匀划分时,达到平衡划分。
例如: [4, 1, 3, 2, 6, 5] 中,基准值为中间值时。

平均情况 (Average):
O(n log n)
大多数情况下,基准值能选择在较为平衡的位置,从而保持 O(n log n) 的性能。

最坏情况 (Worst):
O(n²)
当基准值总是选择为最小值或最大值时,导致不平衡划分。
例如: [1, 2, 3, 4, 5] 已经排序的数据中,若选择第一个元素为基准值,则每次只划分一侧。


6. 堆排序 (Heap Sort)

最佳、平均、最坏情况:
均为 O(n log n)
原因:堆排序利用堆数据结构提取数据时,始终保持相同的工作量。
无论输入数据的状态如何,堆的构建和提取过程都以相同方式进行。


7. 希尔排序 (Shell Sort)

最佳情况 (Best):
O(n log n)
当间隔(Gap)设置合理且数据接近排序状态时效率较高。

平均情况 (Average):
O(n^1.3)
一般情况下,随着间隔逐渐减小,数据逐步排序。

最坏情况 (Worst):
O(n²)
当间隔设置不合理或数据完全逆序时效率较低。


8. 计数排序 (Counting Sort)

最佳、平均、最坏情况:
均为 O(n + k)
原因:当数据范围(k)较小时,直接通过计算对数据进行排序,因此无论输入数据状态如何,性能相同。
但当 k 较大时,内存使用量会增加。


9. 基数排序 (Radix Sort)

最佳、平均、最坏情况:
均为 O(nk)
原因:按位排序时,工作量与数据大小(n)和位数(k)成正比。
无论输入数据状态如何,性能相同。


10. 桶排序 (Bucket Sort)

最佳情况 (Best):
O(n)
当数据均匀分布且桶内无需排序时。

平均情况 (Average):
O(n + k)
当数据大致均匀分布,但桶内需要少量排序时。

最坏情况 (Worst):
O(n²)
当数据集中在一个桶中时,该桶内的排序成本显著增加。

空间复杂度表 (Space Complexity Table)

排序算法

最佳情况 (Best)

平均情况 (Average)

最差情况 (Worst)

插入排序 (Insertion Sort)

O(1)

O(1)

O(1)

选择排序 (Selection Sort)

O(1)

O(1)

O(1)

冒泡排序 (Bubble Sort)

O(1)

O(1)

O(1)

归并排序 (Merge Sort)

O(n)

O(n)

O(n)

快速排序 (Quick Sort)

O(logn)

O(logn)

O(n)

堆排序 (Heap Sort)

O(1)

O(1)

O(1)

希尔排序 (Shell Sort)

O(1)

O(1)

O(1)

计数排序 (Counting Sort)

O(k)

O(k)

O(k)

基数排序 (Radix Sort)

O(n+k)

O(n+k)

O(n+k)

桶排序 (Bucket Sort)

O(n+k)

O(n+k)

O(n²)

1. 插入排序、选择排序、冒泡排序、堆排序、希尔排序

这些都是原地(In-place)排序算法,几乎不需要额外的内存。
空间复杂度始终为O(1)


2. 归并排序

需要额外的数组来存储分治后的子数组。
空间复杂度始终为O(n),这是归并排序的主要缺点之一。


3. 快速排序

由于递归调用栈的存在,需要额外的内存。
平均情况下需要O(logn)的空间,而在最坏情况下可能增加到O(n)


4. 计数排序

根据数据范围(k)需要额外的内存。
空间复杂度为O(k)


5. 基数排序

按照每个位数进行排序,因此需要与数据大小(n)和范围(k)成比例的额外内存。
空间复杂度为O(n+k)


6. 桶排序

将数据分配到桶中存储,因此需要与数据大小和桶的数量成比例的额外内存。
空间复杂度为O(n+k)

But, 这篇文章并不是为了从数学角度整理排序算法的Big-O,而是为了说明Big-O的局限性。

所有模型都是错的,但有些是有用的。
——乔治·爱德华·佩尔汉姆·博克斯(George Edward Pelham Box),数学家

Big-O 是一种简化的复杂度模型,无法包含现实中的所有变量。
它只是用来快速比较算法效率的一种方式。

那么,让我们来看一个例子。

Big-O的局限性

1. Big-O基于最坏情况(Worst Case)
using System; using System.Diagnostics; class QuickSortExample { // 最坏情况(已排序数组) static int[] QuickSortWorst(int[] arr) { if (arr.Length <= 1) return arr; int pivot = arr[0]; // 选择第一个元素作为基准点(引发最坏情况) var left = Array.FindAll(arr, x => x <= pivot); var right = Array.FindAll(arr, x => x > pivot); return Concatenate(QuickSortWorst(left), pivot, QuickSortWorst(right)); } // 平均情况(随机基准点) static int[] QuickSortAvg(int[] arr) { if (arr.Length <= 1) return arr; int pivot = arr[arr.Length / 2]; // 选择中间元素作为基准点 var left = Array.FindAll(arr, x => x < pivot); var middle = Array.FindAll(arr, x => x == pivot); var right = Array.FindAll(arr, x => x > pivot); return Concatenate(QuickSortAvg(left), middle, QuickSortAvg(right)); } // 数组连接辅助函数 static int[] Concatenate(int[] left, int pivot, int[] right) { var result = new int[left.Length + 1 + right.Length]; Array.Copy(left, result, left.Length); result[left.Length] = pivot; Array.Copy(right, 0, result, left.Length + 1, right.Length); return result; } static void Main() { int[] arrSorted = new int[1000]; // 已排序数组(最坏情况) for (int i = 0; i < arrSorted.Length; i++) arrSorted[i] = i; int[] arrRandom = new int[1000]; // 随机数组 Random rand = new Random(); for (int i = 0; i < arrRandom.Length; i++) arrRandom[i] = rand.Next(1000); // 测量最坏情况的时间 var stopwatch = Stopwatch.StartNew(); QuickSortWorst(arrSorted); Console.WriteLine($"最坏情况: {stopwatch.Elapsed.TotalMilliseconds}ms"); // 测量平均情况的时间 stopwatch.Restart(); QuickSortAvg(arrRandom); Console.WriteLine($"平均情况: {stopwatch.Elapsed.TotalMilliseconds}ms"); } }

例如,快速排序在平均情况下是O(n log n),但在最坏情况下是O(n²)。根据Big-O的定义,它会被标记为O(n²)。然而,在实际编程中,快速排序的表现往往优于理论上的最坏情况。

此外,O(n²)的算法在某些情况下可能比O(n log n)更快,尤其是在小规模数据中,由于缓存局部性(cache locality)的原因。

2. 忽略常数因子(Constant Factor)
using System; using System.Diagnostics; class ConstantCoefficientExample { static int F(int n) { return 100 * n + 1000; // O(n) } static int G(int n) { return (int)(0.1 * n * n); // O(n²) } static void Main() { int n = 10; // 小输入规模 var stopwatch = Stopwatch.StartNew(); F(n); Console.WriteLine($"O(n) 函数执行时间: {stopwatch.Elapsed.TotalMilliseconds}ms"); stopwatch.Restart(); G(n); Console.WriteLine($"O(n²) 函数执行时间: {stopwatch.Elapsed.TotalMilliseconds}ms"); } }

例如,如果有两个函数f(n)=100n+1000和g(n)=0.1n²,Big-O会将它们分别归类为O(n)和O(n²)。但在小规模n的情况下,g(n)可能会更快。

3. 相同复杂度也可能有性能差异

快速排例代码 (QuickSort Example)

using System; using System.Diagnostics; class QuickSortExample { static void QuickSort(int[] arr, int left, int right) { if (left < right) { int pivotIndex = Partition(arr, left, right); QuickSort(arr, left, pivotIndex - 1); // 对左子数组进行排序 QuickSort(arr, pivotIndex + 1, right); // 对右子数组进行排序 } } static int Partition(int[] arr, int left, int right) { int pivot = arr[right]; // 选择基准点(这里选择最后一个元素) int i = left - 1; for (int j = left; j < right; j++) { if (arr[j] < pivot) { i++; Swap(arr, i, j); } } Swap(arr, i + 1, right); // 将基准点移动到正确位置 return i + 1; } static void Swap(int[] arr, int i, int j) { int temp = arr[i]; arr[i] = arr[j]; arr[j] = temp; } static void Main() { int[] arr = { 3, 6, 8, 10, 1, 2, 1 }; Console.WriteLine("排序前: " + string.Join(", ", arr)); var stopwatch = Stopwatch.StartNew(); QuickSort(arr, 0, arr.Length - 1); Console.WriteLine($"快速排序时间: {stopwatch.Elapsed.TotalMilliseconds}ms"); Console.WriteLine("排序后: " + string.Join(", ", arr)); } }

堆排序示例代码 (HeapSort Example)

using System; using System.Diagnostics; class HeapSortExample { static void HeapSort(int[] arr) { int n = arr.Length; // 构建最大堆 for (int i = n / 2 - 1; i >= 0; i--) Heapify(arr, n, i); // 从堆中逐个提取元素 for (int i = n - 1; i > 0; i--) { Swap(arr, 0, i); // 将根节点(最大值)与最后一个元素交换 Heapify(arr, i, 0); // 缩小堆大小并重新构建堆 } } static void Heapify(int[] arr, int n, int i) { int largest = i; // 父节点 int left = 2 * i + 1; // 左子节点 int right = 2 * i + 2; // 右子节点 // 如果左子节点更大 if (left < n && arr[left] > arr[largest]) largest = left; // 如果右子节点更大 if (right < n && arr[right] > arr[largest]) largest = right; // 如果最大值不是父节点 if (largest != i) { Swap(arr, i, largest); Heapify(arr, n, largest); // 递归地调整子树 } } static void Swap(int[] arr, int i, int j) { int temp = arr[i]; arr[i] = arr[j]; arr[j] = temp; } static void Main() { int[] arr = { 3, 6, 8, 10, 1, 2, 1 }; Console.WriteLine("排序前: " + string.Join(", ", arr)); var stopwatch = Stopwatch.StartNew(); HeapSort(arr); Console.WriteLine($"堆排序时间: {stopwatch.Elapsed.TotalMilliseconds}ms"); Console.WriteLine("排序后: " + string.Join(", ", arr)); } }

排序比较代码 (SortComparison)

using System; using System.Diagnostics; class SortComparison { static void Main() { int[] arr = new int[100000]; Random rand = new Random(); for (int i = 0; i < arr.Length; i++) arr[i] = rand.Next(100000); // 测量快速排序的时间 var stopwatch = Stopwatch.StartNew(); QuickSort((int[])arr.Clone(), 0, arr.Length - 1); Console.WriteLine($"快速排序时间: {stopwatch.Elapsed.TotalMilliseconds}ms"); // 测量堆排序的时间 stopwatch.Restart(); HeapSort((int[])arr.Clone()); Console.WriteLine($"堆排序时间: {stopwatch.Elapsed.TotalMilliseconds}ms"); } static void QuickSort(int[] arr, int left, int right) { if (left < right) { int pivotIndex = Partition(arr, left, right); QuickSort(arr, left, pivotIndex - 1); QuickSort(arr, pivotIndex + 1, right); } } static int Partition(int[] arr, int left, int right) { int pivot = arr[right]; int i = left - 1; for (int j = left; j < right; j++) { if (arr[j] < pivot) { i++; Swap(arr, i, j); } } Swap(arr, i + 1, right); return i + 1; } static void HeapSort(int[] arr) { int n = arr.Length; for (int i = n / 2 - 1; i >= 0; i--) Heapify(arr, n, i); for (int i = n - 1; i > 0; i--) { Swap(arr, 0, i); Heapify(arr, i, 0); } } static void Heapify(int[] arr, int n, int i) { int largest = i; int left = 2 * i + 1; int right = 2 * i + 2; if (left < n && arr[left] > arr[largest]) largest = left; if (right < n && arr[right] > arr[largest]) largest = right; if (largest != i) { Swap(arr, i, largest); Heapify(arr, n, largest); } } static void Swap(int[] arr, int i, int j) { int temp = arr[i]; arr[i] = arr[j]; arr[j] = temp; } }

例如,快速排序和堆排序的理论复杂度相同,但由于堆排序的常数因子较大且缓存效率较低,因此在实际应用中通常比快速排序慢。

此外,输入数据的特性、错误数据、输入规模等因素都会导致实际表现与Big-O不符。

硬件环境和缓存局部性等问题也会导致性能差异。

正如我之前的文章提到的,即使是相同的复杂度,仅仅是因为双重循环是以行优先还是列优先的方式进行,缓存未命中率的不同也会导致速度差异。


结论

Big-O是一个非常强大的工具,但它并不是万能的。我们不能盲目崇拜理论,认为它可以解决一切问题。现实世界的问题总是复杂多样的,理论只是现实的一部分抽象。

因此,我们应该将理论作为解决问题的指南针,但也要注意观察周围的“风景”,确保我们走在正确的道路上。

http://www.cnnetsun.cn/news/1794138.html

相关文章:

  • Beyond All Reason多人对战攻略:团队协作与战术配合的黄金法则
  • React Native Collapsible实战案例:从电商应用到社交平台的完整实现
  • 快速上手LexikJWTAuthenticationBundle:10分钟搭建安全API认证系统
  • CatGFX:ESP32驱动CAT热敏打印机的Adafruit GFX兼容库
  • MSGEQ7音频频谱芯片驱动设计与抗干扰实践
  • SecretFlow机器学习算法库:线性模型、决策树、朴素贝叶斯全解析
  • 大模型风口来袭!揭秘AI四大热门方向及高薪就业前景
  • OpenClaw多用户场景:为团队成员分配不同Kimi-VL-A3B-Thinking使用权限
  • 聊一聊 C# 中的闭包陷阱:foreach 循环的坑你还记得吗?募
  • OpenClaw个人知识库:Qwen3-14b_int4_awq自动标注与关联文档
  • [特殊字符] 第88课:目标和
  • 从人脑自幼年成长到成熟的过程看机器脑和ai的演进:一切都已经无法改变了吗?(4)
  • OpenClaw对接Qwen2.5-VL-7B图文模型:5步实现本地自动化图文处理
  • UE4SS技术指南:从入门到精通的Mod开发系统
  • 如何实现SQL字段值联动修改_通过触发器处理相关联字段
  • 零代码自动化:用Gemma-3-12b-it为OpenClaw定制个人技能库
  • 和AI一起搞事情#:边剥龙虾边做个中医技能来起号牙
  • OpenClaw性能白皮书:百川2-13B-4bits量化模型在自动化任务中的表现
  • cka-2026-ConfigMap
  • CSS如何使用Sass mixin简化浏览器前缀_封装兼容性处理函数
  • VEML7700光传感器库深度解析:嵌入式低功耗光感开发实战
  • Fish-Speech-1.5新手入门:无需代码,WebUI界面快速生成语音
  • padbuster使用教程
  • OpenClaw+千问3.5-9B低成本方案:自建AI助手替代高价SaaS服务
  • 好用的山东蜂窝卤煮锅推荐
  • Ripgrep (rg): 现代化的命令行搜索工具
  • 十分钟快速体验:OpenClaw镜像预装Qwen3-14B云端demo
  • 2026年4月武汉围挡厂家TOP8推荐
  • 2026年中国卧螺离心机行业技术服务力TOP5品牌
  • 科研党福音:OpenClaw+Qwen3-14B自动整理文献笔记实战