很多同学刚啃完 C 语言的语法,搞定了循环、函数、数组和递归,满怀信心踏入数据结构的大门,第一关就被 "时间复杂度""空间复杂度" 绊住了脚。 "代码能跑不就行了,算这些有什么用?" "O (N)、O (N²)、O (logn),这些符号到底在说什么?"
其实复杂度就是算法的 "性能标尺"。同样解决一道题,有人写的代码十万条数据秒出结果,有人写的代码跑半分钟还超时 ------ 这就是复杂度的差距。这篇文章就站在 C 语言初学者的视角,把算法复杂度讲得明明白白。
一、为什么要学复杂度?
在正式讲概念之前,先看一个很真实的场景: LeetCode 上有一道经典题「旋转数组」
很多刚学完数组的同学会写出这样的代码:每次把数组所有元素往后挪一位,循环 k 次完成旋转。
cs
void rotate(int* nums, int numsSize, int k) {
while(k--)
{
int end = nums[numsSize-1];
for(int i = numsSize - 1;i > 0 ;i--)
{
nums[i] = nums[i-1];
}
nums[0] = end;
}
}
这段代码在本地跑个长度为 10 的数组,完全正确、一秒出结果。可一提交到 OJ 平台,直接提示「超出时间限制」
问题出在哪?出在效率。当数组长度变成几万、几十万的时候,这种写法的执行次数会爆炸式增长,程序自然就跑不动了。
衡量一个算法的好坏,核心就是两个维度:
- 时间复杂度:衡量算法运行的快慢,也就是执行效率
- 空间复杂度:衡量算法运行需要额外占用的内存空间
在计算机发展早期,内存很小,大家非常在意空间开销;而如今硬件性能大幅提升,我们更关注时间复杂度
二、时间复杂度
2.1 为什么不用 "运行多少秒" 来衡量?
有人会说:我跑一下程序看时间不就行了? 其实不行,原因很现实:
- 不同电脑配置不同,同一段代码在老机器和新机器上跑,耗时差好几倍
- 不同编译器优化程度不同,运行时间也不一样
- 总不能等代码写完才知道快不快,我们需要写代码前就能评估效率
所以计算机领域用**「基本操作的执行次数」** 来描述时间效率,记作 T(N),N 代表数据规模。执行次数和运行时间是正相关的,而且和硬件、编译器无关。
2.2 大 O 渐进表示法:只看 "增长趋势"
实际开发中,我们不需要算出精确的执行次数,只需要知道当数据量 N 越来越大时,执行次数的增长量级。这就要用到「大 O 渐进表示法」。
推导大 O 阶有三条核心规则:
- 只保留最高阶项 :低阶项对结果影响很小,直接去掉。比如
T(N) = N² + 2N + 10,只保留N² - 去掉最高阶项的常数系数 :系数不影响增长趋势。比如
T(N) = 2N,去掉系数 2,写成O(N) - 常数级统一写 O (1) :如果执行次数和 N 无关,是固定常数,统一用
O(1)表示
2.3 7 个经典例子
示例 1:两层嵌套循环
cs
void Func1(int N)
{
int count = 0;
// 两层循环,执行 N*N 次
for (int i = 0; i < N ; ++ i)
{
for (int j = 0; j < N ; ++ j)
{
++count;
}
}
// 单层循环,执行 2*N 次
for (int k = 0; k < 2 * N ; ++ k)
{
++count;
}
// 常数循环,执行10次
int M = 10;
while (M--)
{
++count;
}
}
执行次数:T(N) = N² + 2N + 10 按大 O 规则:保留最高阶 N²,去掉系数和常数项 → 时间复杂度 O (N²)
示例 2:单层循环 + 常数循环
cs
void Func2(int N)
{
int count = 0;
for (int k = 0; k < 2 * N ; ++ k)
++count;
int M = 10;
while (M--)
++count;
}
执行次数:T(N) = 2N + 10 按规则去掉系数 2 和常数 10 → 时间复杂度 O (N),也叫线性阶。
示例 3:两个独立变量的循环
cs
void Func3(int N, int M)
{
int count = 0;
for (int k = 0; k < M; ++ k)
++count;
for (int k = 0; k < N ; ++ k)
++count;
}
执行次数:T(N) = M + N 两个都是未知数,所以时间复杂度是 O(M + N)。如果题目说明 M 远大于 N,也可以写成 O (M)。
示例 4:固定次数的循环
cs
void Func4(int N)
{
int count = 0;
for (int k = 0; k < 100; ++ k)
++count;
}
执行次数固定是 100 次,和 N 无关 → 时间复杂度 O (1),也叫常数阶。
示例 5:字符串查找(最好 / 最坏 / 平均情况)
cs
// 在字符串str中查找字符character
const char * strchr ( const char * str, int character)
{
const char* p_begin = str;
while (*p_begin != character)
{
if (*p_begin == '\0')
return NULL;
p_begin++;
}
return p_begin;
}
这段代码的执行次数不固定:
- 最好情况:第一个字符就是目标,执行 1 次 →
O(1) - 最坏情况:最后一个字符才是目标,执行 N 次 →
O(N) - 平均情况:中间位置找到,执行 N/2 次
实际开发和面试中,我们默认关注最坏时间复杂度 ,因为它代表了算法的性能上限,能保证再差也不会超过这个时间。所以 strchr 的时间复杂度是 O(N)。
示例 6:冒泡排序
cs
void BubbleSort(int* a, int n)
{
assert(a);
for (size_t end = n; end > 0; --end)
{
int exchange = 0;
for (size_t i = 1; i < end; ++i)
{
if (a[i-1] > a[i])
{
Swap(&a[i-1], &a[i]);
exchange = 1;
}
}
if (exchange == 0)
break;
}
}
- 最好情况:数组本来就有序,只走一遍就跳出,执行 N 次 →
O(N) - 最坏情况:数组完全逆序,执行
N*(N+1)/2次 →O(N²)
所以冒泡排序的时间复杂度为 O(N²)。
示例 7:对数阶循环
cs
void func5(int n)
{
int cnt = 1;
while (cnt < n)
{
cnt *= 2;
}
}
我们代入数值看:
- n=2 时,循环执行 1 次
- n=4 时,循环执行 2 次
- n=16 时,循环执行 4 次
假设执行 x 次后循环结束,那么 2^x = n,也就是 x = log₂n。 按大 O 规则,对数的底数可以省略,所以时间复杂度是 O(logn),也叫对数阶。
示例 8:阶乘递归
cs
long long Fac(size_t N)
{
if(0 == N)
return 1;
return Fac(N-1)*N;
}
递归的时间复杂度,看递归调用的总次数 。 Fac (N) 会依次调用 Fac (N-1)、Fac (N-2) ... Fac (0),总共调用 N 次,每次函数内部都是常数操作。 所以时间复杂度为 O(N)。
三、空间复杂度
3.1 空间复杂度到底算什么?
空间复杂度衡量的是算法运行过程中,额外临时开辟的空间大小,注意两个关键点:
- 输入数据本身占用的空间不算,算的是你为了实现算法额外申请的空间
- 和时间复杂度一样,也用大 O 渐进表示法,算变量 / 数组的个数,不用算具体多少字节
函数的参数、局部变量这些栈帧空间,编译期就确定了;我们主要关注运行时显式申请的额外空间,比如 malloc 出来的数组、递归调用的栈帧等。
3.2 两个经典例子
示例 1:冒泡排序的空间复杂度
还是上面的冒泡排序代码
cs
void BubbleSort(int* a, int n)
{
assert(a);
for (size_t end = n; end > 0; --end)
{
int exchange = 0;
for (size_t i = 1; i < end; ++i)
{
if (a[i-1] > a[i])
{
Swap(&a[i-1], &a[i]);
exchange = 1;
}
}
if (exchange == 0)
break;
}
}
整个函数只额外定义了 end、exchange、i 这几个变量,数量是固定常数,和数据规模 n 无关。 所以空间复杂度为 O(1),这种不额外开空间的排序也叫「原地排序」。
示例 2:阶乘递归的空间复杂度
还是上面的阶乘递归代码。 递归调用会在栈上开辟函数栈帧,Fac (N) 会递归调用 N 次,也就是同时存在 N 个栈帧,每个栈帧占用常数空间。 所以空间复杂度为 O(N)。
四、复杂度优化
我们回到开头的「旋转数组」题目,看看三种不同思路的复杂度差异
思路 1:暴力移位法
就是开头的写法,循环 k 次,每次把数组整体右移一位。
- 时间复杂度:O (k*N),当 k 等于 N 时就是 O (N²),数据量大了必然超时
- 空间复杂度:O (1),只开了几个临时变量
结论:能跑对,但效率太低,OJ 提交不通过。
思路 2:额外数组法
开辟一个新数组,利用模运算直接把元素放到正确的位置,再拷贝回去。
理论分析:
假设要让数组 nums={1,2,3,4,5,6,7}向右轮转3位,我们可以换位思考,我们让数组的后三位5,6,7移到最前面,前4位1,2,3,4移到最后面不就行了吗,这时我们创建一个新的数组来存放移位后的结果,这时问题又来了,是先将前4位数移到后面,还是先将后3位数移到前面呢,如果先从后三位数开始遍历数组的话,那么遍历完这三个数字之后,前面4位数并没有被遍历到,还得再循环遍历一遍,所以我们这里选择先将前4位数移到新数组的后面,我们要让原来数组的前4位依次赋值给新数组的后4位,我们用i+k可以先找到新数组的后4位,但是问题又来了,当i+k=7时,已经越界了,所以我们这里用取模的方法,(i+k)%numsSize,当i+k=7时,(i+k)%numsSize=0,指向了新数组的第一位,然后依次赋值,最后将新数组再依次赋值给原数组
cs
void rotate(int* nums, int numsSize, int k)
{
int newArr[numsSize];
for (int i = 0; i < numsSize; ++i)
{
newArr[(i + k) % numsSize] = nums[i];
}
for (int i = 0; i < numsSize; ++i)
{
nums[i] = newArr[i];
}
}
- 时间复杂度:O (N),只遍历了两次数组
- 空间复杂度:O (N),额外开了一个大小为 N 的数组
结论:时间够快,但占用了额外内存,属于 "用空间换时间"。
思路 3:三次逆置法(最优解)
通过三次数组逆置实现原地旋转,不需要额外开数组:
理论分析:
还是让数组nums={1,2,3,4,5,6,7} 向右轮转3位,我们让前4位逆转,变成4,3,2,1,然后然后让后3位逆转变成7,6,5,整个数组就变成了4321765,然后整个数组逆转,就变成了5671234,这里需要逆转3次,所以我们直接写一个逆转函数reverse,但是注意,这里的numsSize-k,如果数组个数<k,下标变成负数,数组越界,程序崩溃、乱码 。,所以我们这里让k%numsSize就可以了
cs
void reverse(int* nums,int begin,int end)
{
while(begin<end){
int tmp = nums[begin];
nums[begin] = nums[end];
nums[end] = tmp;
begin++;
end--;
}
}
void rotate(int* nums, int numsSize, int k)
{
k = k%numsSize;
reverse(nums,0,numsSize-k-1);
reverse(nums,numsSize-k,numsSize-1);
reverse(nums,0,numsSize-1);
}
- 时间复杂度:O (N),三次逆置总操作量还是线性的
- 空间复杂度:O (1),只用了几个临时变量
结论:时间和空间都达到了最优,也是面试中的标准写法。
你看,同一个问题,三种写法,效率天差地别。这就是学习复杂度的意义:不只要写出 "能跑" 的代码,还要写出 "高效" 的代码。
五、常见复杂度对比
5.1 复杂度效率排行
常见的时间复杂度,从优到劣排序如下: O(1) < O(logn) < O(n) < O(nlogn) < O(n²) < O(2ⁿ) < O(n!)
给大家一个直观感受:当 n=1000 时
- O (1):1 次操作,瞬间完成
- O (logn):约 10 次操作
- O (n):1000 次操作
- O (n²):1,000,000 次操作
- O (2ⁿ):已经是天文数字,跑一天都跑不完