大O表示法 | 自在学
大O表示法
在编程的世界里,我们经常面临一个灵魂拷问:当用户从10个变成1000万个时,你的代码还能跑得动吗?
假设你正在整理一个图书馆:
如果只有10本书 ,你闭着眼睛乱放都能很快找到。
但如果有100万本书 ,如果没有一套科学的索引系统,找一本书可能要花上一辈子。
这就引入了我们今天的主角——大O表示法 。
到底什么是大O表示法?
简单来说,大O表示法(Big O Notation) 是程序员用来吐槽代码“效率”的一种通用语言。它不计算具体的秒数(因为不同的电脑运行速度不同),它计算的是趋势 。
随着输入数据量(n)的增加,程序的运行时间或占用内存会变慢/变大多少?
常见的时间复杂度(由快到慢)
为了方便理解,我们将常见的几种复杂度按“效率”从高到低排列:
O(1) - 常数时间 (最快)
含义: 不管数据有多少,耗时都一样。这是最理想的状态!
举例: 假设你知道朋友家的具体门牌号。无论这个城市有10个人还是1000万人,你都能直接导航到他家门口,不需要挨家挨户找。
#include <vector>
using namespace std ;
int getFirstElement ( const vector < int > & arr ) {
// 就像你有任意门的钥匙,直接到达目的地
return arr[ 0 ];
}
// 无论数组里有10个数据还是10亿个,这一步操作瞬间完成。
// 这就是O(1)的特征:时间不随输入规模变化
O(log n) - 对数时间 (非常快)
含义: 数据量翻倍,时间只增加一点点。通常出现在“二分查找”中。
举例: 猜数字游戏(1-100)。
你问:“是50吗?” 对方:“低了”。
你瞬间排除了1-50这一半的数字!每次都能排除一半的可能性,这效率极高。
#include <vector>
using namespace std ;
// 二分查找:经典的 O(log n)
int binarySearch ( const vector < int > & sortedArr , int target ) {
int left = 0 ;
int right = sortedArr. size () - 1 ;
while (left <= right) {
O(n) - 线性时间 (普通)
含义: 数据量增加几倍,耗时就增加几倍。中规中矩。
举例: 看书。书越厚,看完需要的时间就越长。如果你要在一本没目录的书里找一句话,最坏的情况必须从第一页翻到最后一页。
#include <vector>
using namespace std ;
int findElement ( const vector < int > & arr , int target ) {
// 必须老老实实从头走到尾
for ( int i = 0 ; i < arr. size (); i ++ ) {
if (arr[i] == target) {
return i;
O(n²) - 平方时间 (有点慢)
含义: 数据量增加,耗时呈指数级爆炸增长。通常涉及“双层循环”。
举例: 班级聚会握手。
班里有n个同学,每个人都要和其余n-1个同学握手。
如果是10人,握手次数还好;如果是1000人,握手次数就是百万级的!
#include <vector>
using namespace std ;
// 冒泡排序:典型的 O(n²)
void bubbleSort ( vector < int > & arr ) {
int n = arr. size ();
for ( int i = 0 ; i < n - 1 ; i ++ ) { // 外层:每个人都要轮一遍
for (
为什么这东西很重要?
假设你日后要做一个“商品搜索”功能,超市有100万件商品。
方案 A:暴力搜索 (O(n))
做法: 每次用户搜“苹果”,你都派人去仓库把100万件商品标签看一遍。
结果: 用户搜一次要等3秒。如果有1万个人同时搜,服务器直接爆炸。
方案 B:索引搜索 (O(1) ~ O(log n))
做法: 开业前先辛苦一点,建立一个“索引本”(比如 'P' 这一页专门记苹果在哪里)。
结果: 用户搜“苹果”,直接翻索引本,0.001秒出结果。
结论: 好的算法(低时间复杂度)能帮你省下巨额的服务器费用,还能让用户觉得你的软件“真快”!
可视化时间复杂度
空间复杂度:不仅要快,还要省
除了关心“快不快”(时间),我们还得关心“占地大不大”(内存)。这就是 空间复杂度 。
举例:搬家
空间 O(1): 原地整理。你不需要额外的箱子,直接在屋子里挪动家具。(省空间,比如冒泡排序)
空间 O(n): 复制一份。为了整理书架,你把书全部搬到另一个空房间里重新排。(费空间,比如归并排序)
示例对比:
// 🟢 省空间:选择排序
// 只用了一个临时变量 temp,无论数组多大,额外空间都是固定的 O(1)
void selectionSort ( vector < int > & arr ) {
// ... 直接在原数组 arr 上修改 ...
}
// 🔴 费空间:归并排序
// 需要创建新的 vector (left, right) 来暂存数据
// 数据越多,需要的额外内存越大 -> O(n)
vector < int > mergeSort ( const vector < int > & arr ) {
vector <int> left = ...; // 申请新内存
vector
在现在的计算机设备上,内存通常比较充足,所以有时候为了追求极致的速度(时间),我们会愿意牺牲一些内存(空间)。这就是传说中的**“空间换时间”**。
小练习
一个函数遍历数组一次,对每个元素执行常数时间操作,时间复杂度是?
A. O(1)
B. O(n)
C. O(n²)
D. O(log n)
一个循环,循环变量每次乘以2,直到达到n,时间复杂度是?
A. O(1)
B. O(n)
C. O(n²)
D. O(log n)
递归版本的斐波那契数列(无记忆化)的时间复杂度是?
A. O(1)
B. O(n)
C. O(2ⁿ)
D. O(n²)
4. 分析函数的时间复杂度练习
分析以下函数的时间复杂度:
函数A :sumArray 函数遍历数组一次,计算所有元素的和
函数B :mysteryFunction 函数中,循环变量每次乘以2(1, 2, 4, 8...)
要求:分析每个函数的时间复杂度,并说明原因
提示:函数A像把书从头翻到尾,函数B像切蛋糕每次切一半
#include <iostream>
#include <vector>
using namespace std ;
// 函数A:计算数组和
int sumArray ( const vector < int > & arr )
{
int sum = 0 ;
for ( int i = 0 ; i < arr. size (); i ++ ) // 循环 n 次
5. 斐波那契数列的陷阱练习
实现斐波那契数列的两个版本,对比它们的性能差异:
版本1(递归) :使用递归实现,无记忆化
版本2(动态规划) :使用循环和数组记录已计算的值
要求:实现两个版本,并分析它们的时间复杂度
测试:计算 fibonacci(10) 和 fibonacci(40),观察性能差异
#include <iostream>
#include <vector>
#include <chrono>
using namespace std ;
using namespace std :: chrono ;
// 版本1:递归版本(无记忆化)- 时间复杂度 O(2ⁿ)
long long fibonacciRecursive ( int n )
{
if (n <= 1 ) return n;
// 问题:会重复计算大量子问题
// 例如:fib(5) = fib(4) + fib(3)
// fib(4) = fib(3) + fib(2)
// fib(3) = fib(2) + fib(1)
int mid = (left + right) / 2 ; // 每次都砍一半!
if (sortedArr[mid] == target) {
return mid; // 找到了!
} else if (sortedArr[mid] < target) {
left = mid + 1 ; // 扔掉左边那一半
} else {
right = mid - 1 ; // 扔掉右边那一半
}
}
return - 1 ;
}
// 即使有100万个数据,最多也只需要找约20次!
}
}
return - 1 ;
}
// 数据量n越大,循环次数越多,成正比 -> O(n)
int
j
=
0
; j
<
n
-
i
-
1
; j
++
) {
// 内层:又要和剩下的人比一遍
if (arr[j] > arr[j + 1 ]) {
// 交换位置
int temp = arr[j];
arr[j] = arr[j + 1 ];
arr[j + 1 ] = temp;
}
}
}
}
// 循环套循环,n * n = n² 次操作。
// 数据量稍微大一点,程序就会卡死。
<int>
right
=
...;
// 申请新内存
return merge (left, right);
}
{
sum += arr[i]; // 每次循环执行常数时间操作
}
return sum;
}
// 时间复杂度:O(n)
// 原因:循环执行 n 次,每次循环是常数时间操作
// 就像把书从头翻到尾,需要翻 n 页
// 函数B:神秘函数
void mysteryFunction ( int n )
{
for ( int i = 1 ; i < n; i *= 2 ) // 注意 i 每次乘以 2
{
cout << i << " " ;
}
}
// 时间复杂度:O(log n)
// 原因:循环变量每次乘以 2(1, 2, 4, 8, 16...)
// 需要 log₂(n) 次循环才能达到 n
// 就像切蛋糕每次切一半,切 log₂(n) 次就切完了
int main ()
{
vector <int> arr = { 1 , 2 , 3 , 4 , 5 };
cout << "数组和: " << sumArray (arr) << endl;
cout << "MysteryFunction(16): " ;
mysteryFunction ( 16 ); // 输出: 1 2 4 8
cout << endl;
return 0 ;
}
数组和: 15
MysteryFunction(16): 1 2 4 8
函数A(SumArray) :
时间复杂度:O(n)
原因:单层循环遍历数组,执行 n 次,每次循环是常数时间操作
就像把书从头翻到尾,需要翻 n 页
函数B(MysteryFunction) :
时间复杂度:O(log n)
原因:循环变量每次乘以 2(1, 2, 4, 8, 16...),需要 log₂(n) 次循环才能达到 n
就像切蛋糕每次切一半,切 log₂(n) 次就切完了
这是对数时间复杂度的典型例子
// 可以看到 fib(3) 和 fib(2) 被重复计算了
return fibonacciRecursive (n - 1 ) + fibonacciRecursive (n - 2 );
}
// 版本2:动态规划版本 - 时间复杂度 O(n)
long long fibonacciDP ( int n )
{
if (n <= 1 ) return n;
// 用数组记录已计算的值,避免重复计算
vector <long long> dp (n + 1 );
dp[ 0 ] = 0 ;
dp[ 1 ] = 1 ;
for ( int i = 2 ; i <= n; i ++ )
{
dp[i] = dp[i - 1 ] + dp[i - 2 ];
}
return dp[n];
}
int main ()
{
// 测试小数值
cout << "计算 fibonacci(10):" << endl;
auto start1 = high_resolution_clock :: now ();
long long result1 = fibonacciRecursive ( 10 );
auto end1 = high_resolution_clock :: now ();
auto duration1 = duration_cast < milliseconds >(end1 - start1);
cout << "递归版本: " << result1 << ", 耗时: " << duration1. count () << "ms" << endl;
auto start2 = high_resolution_clock :: now ();
long long result2 = fibonacciDP ( 10 );
auto end2 = high_resolution_clock :: now ();
auto duration2 = duration_cast < milliseconds >(end2 - start2);
cout << "动态规划版本: " << result2 << ", 耗时: " << duration2. count () << "ms" << endl;
// 测试较大值(递归版本会很慢)
cout << " \n 计算 fibonacci(40):" << endl;
auto start3 = high_resolution_clock :: now ();
long long result3 = fibonacciDP ( 40 );
auto end3 = high_resolution_clock :: now ();
auto duration3 = duration_cast < milliseconds >(end3 - start3);
cout << "动态规划版本: " << result3 << ", 耗时: " << duration3. count () << "ms" << endl;
cout << " \n 注意:递归版本计算 fibonacci(40) 可能需要几分钟甚至更久!" << endl;
cout << "这是因为递归版本的时间复杂度是指数级的 O(2ⁿ)" << endl;
return 0 ;
}
版本1(递归) :
时间复杂度:O(2ⁿ) (指数级)
问题:会重复计算大量子问题,例如 fib(3) 会被计算多次
计算 fibonacci(50) 可能需要几年时间
不推荐用于实际生产环境
版本2(动态规划) :
时间复杂度:O(n) (线性)
优势:用数组记录已计算的值,避免重复计算
计算 fibonacci(50) 只需要几毫秒
推荐用于实际生产环境
关键差异 :动态规划通过"记忆化"避免了重复计算,大大提高了效率