
🏠个人主页:黎雁
🎬作者简介:C/C++/JAVA后端开发学习者
❄️个人专栏:C语言、数据结构(C语言)、EasyX、游戏、规划、程序人生
✨ 从来绝巘须孤往,万里同尘即玉京

文章目录
- 算法复杂度从入门到精通:理论与实战双解析
-
- [1. 啥是算法复杂度?](#1. 啥是算法复杂度?)
-
- [① 时间复杂度:代码的跑步速度 ⏱️](#① 时间复杂度:代码的跑步速度 ⏱️)
- [② 空间复杂度:代码的占地面积 📦](#② 空间复杂度:代码的占地面积 📦)
- [⚖️ 时间vs空间:鱼和熊掌咋选?](#⚖️ 时间vs空间:鱼和熊掌咋选?)
- [2. 常见复杂度类型&分析技巧](#2. 常见复杂度类型&分析技巧)
-
- [① 大O渐近表示法:别纠结细节,抓重点!](#① 大O渐近表示法:别纠结细节,抓重点!)
- [② 最坏、最好、平均情况:为啥咱们只盯最坏的?](#② 最坏、最好、平均情况:为啥咱们只盯最坏的?)
- [③ 常见时间复杂度大盘点:从快到慢排个队](#③ 常见时间复杂度大盘点:从快到慢排个队)
- [④ 常见空间复杂度大盘点:比时间复杂度简单多了](#④ 常见空间复杂度大盘点:比时间复杂度简单多了)
- [3. 实战演练:两道LeetCode经典题](#3. 实战演练:两道LeetCode经典题)
- [4. 写在最后:复杂度优化的小哲学](#4. 写在最后:复杂度优化的小哲学)
算法复杂度从入门到精通:理论与实战双解析
Hello,大家好,新文又和大家见面了,我们这次来聊聊数据结构与算法里的核心知识点------算法复杂度!
咱们写代码,不光要能跑通,还得追求跑得快、占内存少。而衡量代码快不快、省不省的关键,就是时间复杂度和空间复杂度。今天咱们就掰开揉碎了讲,再配上两道经典LeetCode题实战,保证你听完就懂!🚀
1. 啥是算法复杂度?
评价一个算法好不好,咱们一般看两个维度:跑得多快 和占多少内存,这俩对应的就是时间复杂度和空间复杂度。
① 时间复杂度:代码的跑步速度 ⏱️
咱平时说这段代码跑了0.5秒,其实没啥参考价值------不同电脑配置、不同编程语言,跑出来的时间都不一样。
真正靠谱的衡量标准是:随着输入数据规模n变大,算法执行的基本操作次数会怎么增长。这个增长趋势,就是时间复杂度。
简单说:时间复杂度就是看你的代码,数据量翻倍时,工作量会翻几倍?
② 空间复杂度:代码的占地面积 📦
这个更直白:算法在运行过程中,临时占用的存储空间大小,随着输入规模n增长的趋势。
注意哦,是临时空间!比如你定义了一个固定大小的变量,不管n多大,它占的空间都不变;但如果定义了一个长度为n的数组,那空间就跟着n涨了。
简单说:空间复杂度就是看你的代码,数据量翻倍时,需要的临时内存会翻几倍?
⚖️ 时间vs空间:鱼和熊掌咋选?
早年间电脑内存金贵得很,大家都抠抠搜搜省空间;现在不一样了,内存又大又便宜,咱们更看重时间效率。
甚至很多时候,会用以空间换时间的套路------比如多开一个数组存中间结果,避免重复计算,让代码跑得更快。
2. 常见复杂度类型&分析技巧
① 大O渐近表示法:别纠结细节,抓重点!
分析复杂度时,咱们不用算精确的操作次数,只需要用大O表示法描述增长趋势就行。它的推导规则超简单,记好这三步:
- 常数项直接扔
比如代码执行次数是2n+100,100是常数项,不管n多大,它的影响都会越来越小,直接扔掉,变成2n。 - 只留最高次项
比如次数是n²+5n+20,n²是最高次项,n越大,它的影响越主导,直接保留n²,扔掉5n和20。 - 最高次项的系数也扔掉
比如次数是3n²,系数3对增长趋势没影响------n²的增长速度,和3n²是一个量级的,直接写成n²。
举个栗子🌰:
看这段代码的时间复杂度:
c
void calc_sum(int n) {
int sum = 0; // 1次操作
for (int i = 0; i < n; i++) { // 循环n次
sum += i; // 每次循环1次操作,共n次
}
printf("%d\n", sum); // 1次操作
}
总操作次数是1 + n + n + 1 = 2n+2。按规则推导:
- 扔掉常数项2 → 2n
- 最高次项是n,扔掉系数2 → n
所以时间复杂度是 O(n)!
是不是超简单?记住:大O表示法只关心趋势,不关心细节。
② 最坏、最好、平均情况:为啥咱们只盯最坏的?
分析复杂度时,会遇到三种情况,举个在数组里找某个数的例子你就懂了:
- 最好情况:运气爆棚,第一个元素就是要找的数,只需要1次操作 → 时间复杂度O(1)。
- 最坏情况:运气贼差,遍历到最后一个元素才找到,或者压根没找到,需要n次操作 → 时间复杂度O(n)。
- 平均情况:把所有可能的情况算个平均值,大概需要n/2次操作 → 时间复杂度也是O(n)。
划重点:咱们平时说的时间复杂度,默认都是最坏情况!
为啥?因为最坏情况能给代码的性能兜底------你能保证,代码再慢也不会超过这个速度,这才是最有参考价值的。
③ 常见时间复杂度大盘点:从快到慢排个队
不同复杂度的代码,差距可不是一点点!咱们按从快到慢排个队,记住这些常见的:
| 复杂度 | 名字 | 特点 | 典型例子 |
|---|---|---|---|
| O(1) | 常数阶 | 最快!操作次数和n没关系 | 访问数组的某个元素、加减乘除运算 |
| O(log n) | 对数阶 | 超高效!n翻倍,操作次数只加1 | 二分查找 |
| O(n) | 线性阶 | n翻倍,操作次数也翻倍 | 遍历数组、单层for循环 |
| O(n log n) | 线性对数阶 | 比线性阶慢一点,但比平方阶快多了 | 快速排序、归并排序 |
| O(n²) | 平方阶 | n翻倍,操作次数翻4倍 | 冒泡排序、两层嵌套for循环 |
| O(2ⁿ) | 指数阶 | 超级慢!n稍微变大,代码就跑不动了 | 未优化的递归斐波那契数列 |
| O(n!) | 阶乘阶 | 最慢的级别之一!n增大一点就无法承受 | 暴力解旅行商问题 |
复杂度增长趋势图:
^
时间 |
/ 2ⁿ | . (指数阶,嗖嗖涨)
/ | .
/ n² | . . (平方阶,涨得快)
| | .
| n | . . (线性阶,平稳涨)
| | .
| log n | . (对数阶,涨得超慢)
|_______|________________> 输入规模n
O(1) (常数阶,横着走)
再举几个经典例子加深理解:
-
冒泡排序 O(n²)
它的比较次数是(n-1)+(n-2)+...+1 = n(n-1)/2,按大O规则,就是O(n²)。数据量n=1000时,操作次数大概是50万;n=2000时,直接涨到200万! -
二分查找 O(log n)
每次查找都把范围缩小一半,比如找1000个数据里的数,最多只需要10次操作;找2000个数据,也只需要11次------这就是对数阶的魅力! -
递归斐波那契数列 O(2ⁿ)
代码长这样:cint Fib(int n) { return n < 2 ? n : Fib(n-1)+Fib(n-2); }这个递归会重复计算超多值,比如算Fib(5)时,Fib(3)会被算两次。n=20时还能忍,n=30时就慢得离谱了!
④ 常见空间复杂度大盘点:比时间复杂度简单多了
空间复杂度的分析更直接,主要看临时开辟的空间:
- O(1):常数空间。不管n多大,临时变量就那几个,比如定义int a, b;。
- O(n):线性空间。临时开了一个长度为n的数组、列表,比如int temp[n];。
- O(log n):对数空间。常见于递归算法,比如二分查找的递归实现,递归深度是log n,系统栈的空间就是O(log n)。
- O(n²):平方空间。临时开了一个n×n的二维数组,比如int matrix[n][n];。
3. 实战演练:两道LeetCode经典题
光说不练假把式,咱们用两道高频题,看看怎么用复杂度约束解题!
练习1:找缺失的数字 (LeetCode 268)
题目 :给定一个包含[0,n]中n个数的数组nums,找出[0,n]里没出现的那个数。要求时间复杂度O(n)。
示例:输入nums = [3,0,1] → 输出2
解题思路:三种思路,两种达标!
-
思路1:排序后遍历 → 不达标❌
先排序数组,再遍历找空缺。但最快的排序算法(比如归并排序)时间复杂度是O(n log n),超过了题目要求的O(n),直接pass!
-
思路2:数学求和法 → 达标⭐
核心原理:0到n的整数和是个等差数列,用公式总和 = n*(n+1)/2就能秒算。然后算出数组里所有数的和,两者相减,差就是缺失的数!复杂度:遍历一次数组求和,时间O(n);只用到几个变量,空间O(1),完美达标!
C代码实现:
c#include <stdio.h> int missingNumber(int* nums, int numsSize) { // 算0到n的理论总和 int expected_sum = numsSize * (numsSize + 1) / 2; // 算数组的实际总和 int actual_sum = 0; for (int i = 0; i < numsSize; i++) { actual_sum += nums[i]; } // 差值就是缺失的数 return expected_sum - actual_sum; } int main() { int nums[] = {3, 0, 1}; int sz = sizeof(nums)/sizeof(nums[0]); printf("缺失的数字是:%d\n", missingNumber(nums, sz)); // 输出2 return 0; } -
思路3:异或运算法 → 更优⭐⭐
这个方法更巧妙,利用异或运算的三个神仙性质:
- 性质1:a ^ a = 0(相同的数异或,结果为0)
- 性质2:a ^ 0 = a(任何数和0异或,结果还是自己)
- 性质3:交换律、结合律(abc = acb)
核心思路 :把0到n的所有数,和数组里的所有数异或一遍。成对出现的数会相互抵消(变成0),最后剩下的就是只出现一次的缺失数字!
复杂度:时间O(n),空间O(1),而且不会有求和法的溢出风险(比如n超大时,求和可能超出int范围),更稳妥!
C代码实现:
c#include <stdio.h> int missingNumber(int* nums, int numsSize) { int missing = numsSize; // 先把n放进去异或 for (int i = 0; i < numsSize; i++) { missing ^= i ^ nums[i]; // 索引i和数组元素nums[i]异或 } return missing; } int main() { int nums[] = {9,6,4,2,3,5,7,0,1}; int sz = sizeof(nums)/sizeof(nums[0]); printf("缺失的数字是:%d\n", missingNumber(nums, sz)); // 输出8 return 0; }
练习2:旋转数组 (LeetCode 189)
题目 :给定一个数组,把元素向右移动k个位置,k是非负数。要求空间复杂度O(1)(原地修改,不能开新数组)。
示例:输入nums = [1,2,3,4,5,6,7], k=3 → 输出[5,6,7,1,2,3,4]
解题思路:三种思路,只有一种最优!
-
思路1:暴力法(每次移一位)→ 超时❌
循环k次,每次把最后一个元素移到最前面。比如k=3,就移3次。
复杂度:时间O(n*k),空间O(1)。但k很大时(比如k=10^5),直接超时,pass! -
思路2:开新数组 → 空间不达标❌
开一个新数组,把原数组的元素放到新数组的正确位置,再拷贝回去。
复杂度:时间O(n),但空间O(n),违反了O(1)的要求,pass! -
思路3:数组翻转法 → 最优解⭐⭐⭐
这个方法简直是天才思路!核心就是三次翻转,全程不用额外空间:
- 步骤1:整体翻转整个数组
- 步骤2:翻转前k个元素
- 步骤3:翻转后n-k个元素
举个栗子🌰:nums = [1,2,3,4,5,6,7], k=3
- 整体翻转 → [7,6,5,4,3,2,1]
- 翻转前3个 → [5,6,7,4,3,2,1]
- 翻转后4个 → [5,6,7,1,2,3,4](搞定!)
小细节:如果k>n,比如k=10,n=7,其实右移10次和右移3次效果一样(10%7=3),所以先算k = k%n,避免无效操作!
复杂度:时间O(n)(三次翻转都是O(n)),空间O(1),完美符合要求!
C代码实现:
c#include <stdio.h> // 辅助函数:翻转数组[left, right]区间的元素 void reverse(int* nums, int left, int right) { while (left < right) { // 交换两个元素 int temp = nums[left]; nums[left] = nums[right]; nums[right] = temp; left++; right--; } } void rotate(int* nums, int numsSize, int k) { k %= numsSize; // 处理k>numsSize的情况 if (k == 0) return; // 不用旋转 reverse(nums, 0, numsSize-1); // 1.整体翻转 reverse(nums, 0, k-1); // 2.翻转前k个 reverse(nums, k, numsSize-1); // 3.翻转后n-k个 } // 打印数组的辅助函数 void printArray(int* nums, int size) { for (int i = 0; i < size; i++) { printf("%d ", nums[i]); } printf("\n"); } int main() { int nums[] = {1,2,3,4,5,6,7}; int sz = sizeof(nums)/sizeof(nums[0]); int k = 3; printf("原数组:"); printArray(nums, sz); rotate(nums, sz, k); printf("旋转后:"); printArray(nums, sz); // 输出5 6 7 1 2 3 4 return 0; }
4. 写在最后:复杂度优化的小哲学
今天咱们聊了这么多,其实核心就一个:写代码时,要养成分析复杂度的习惯。
- 不要满足于代码能跑,多问自己一句:这个算法的时间和空间复杂度是多少?有没有更优的写法?
- 记住以空间换时间的思路,但也要看场景------比如嵌入式设备内存小,就得反过来以时间换空间。
- 复杂度只是一个参考,实际写代码时,还要考虑代码的可读性、可维护性。毕竟,能让同事看懂的代码,才是好代码!
最后,算法学习没有捷径,多刷题、多总结,你也能成为复杂度优化的高手!
如果这篇文章对你有帮助,别忘了点赞收藏哦!有啥疑问或者想聊的算法题,评论区见~Happy Coding!😊