数据结构入门:算法复杂度

很多同学刚啃完 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 为什么不用 "运行多少秒" 来衡量?

有人会说:我跑一下程序看时间不就行了? 其实不行,原因很现实:

  1. 不同电脑配置不同,同一段代码在老机器和新机器上跑,耗时差好几倍
  2. 不同编译器优化程度不同,运行时间也不一样
  3. 总不能等代码写完才知道快不快,我们需要写代码前就能评估效率

所以计算机领域用**「基本操作的执行次数」** 来描述时间效率,记作 T(N),N 代表数据规模。执行次数和运行时间是正相关的,而且和硬件、编译器无关。

2.2 大 O 渐进表示法:只看 "增长趋势"

实际开发中,我们不需要算出精确的执行次数,只需要知道当数据量 N 越来越大时,执行次数的增长量级。这就要用到「大 O 渐进表示法」。

推导大 O 阶有三条核心规则:

  1. 只保留最高阶项 :低阶项对结果影响很小,直接去掉。比如 T(N) = N² + 2N + 10,只保留
  2. 去掉最高阶项的常数系数 :系数不影响增长趋势。比如 T(N) = 2N,去掉系数 2,写成 O(N)
  3. 常数级统一写 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ⁿ):已经是天文数字,跑一天都跑不完
相关推荐
ι:1 小时前
DeepSeek Harness 桌面一键启动与网页背景修改指南
windows·学习·deepseekharness
Ricardo-Yang1 小时前
无人机单目深度估计测试:ZipDepth 与 AerialMetric
人工智能·算法·机器学习·计算机视觉·无人机
W_326001 小时前
Python-OpenCV边缘检测与阈值分割:Sobel、Scharr、Laplacian、Canny、全局与自适应阈值
开发语言·图像处理·python·opencv·机器学习
其实防守也摸鱼1 小时前
红队技能总结导图:从入门到精通的完整知识体系
开发语言·人工智能·学习·安全·web安全
zjnlswd1 小时前
C# 学习笔记
笔记·学习
Nil2081 小时前
golang解决单词转换
算法
聊浮游1 小时前
JAVA2026最新全套学习资料、学习路线
java·开发语言·jvm·mysql·spring·maven·idea
总线通信小百科1 小时前
车载多协议网关选型指南:CAN FD、汽车以太网、EtherCAT如何匹配?
经验分享
远离UE41 小时前
UE5 显存 虚拟内存 深入学习笔记
笔记·学习·ue5