复杂度讲解
目录
[一、数据结构的概念(Data Structure)](#一、数据结构的概念(Data Structure))
一、数据结构的概念(Data Structure)
计算机存储,组织数据的方式
由多个数据元素组成的集合,这些元素之间存在一种或多种特定关系
二、算法的概念(Algorithm)
一组明确的计算步骤
用于将输入数据转化为预期的输出结果
三、算法效率
3.1.如何衡量一个算法的好坏
程序的运行需要消耗时间资源和内存资源
因此要从时间与空间两个维度来衡量算法的好坏
3.2.算法的复杂度
时间复杂度: 衡量一个算法的运行速度的快慢
**空间复杂度:**衡量一个算法的运行所需要的额外空间
四、时间复杂度
4.1.时间复杂度的概念
一个描述该算法运行时间的函数
计算算法中基本操作的执行次数
4.2.大O的渐进表示法
**大O符号(Big O notation):**描述函数渐进行为的数学符号
推导大O阶方法:
- 用常数1取代运行时间中的所有加法常数
- 在修改后的运行次数函数中,只保留最高阶项
- 如果最高阶项存在且不是1,则去除与这个项相乘的常数,得到的结果就是大O阶
**本质:**计算算法时间复杂度属于哪个量级
时间复杂度中常见的量级:
|-----------------|----------|-------|
| 5201314 | O(1) | 常数阶 |
| 3n+4 | O(n) | 线性阶 |
| 3n^2+4n+5 | O(n^2) | 平方阶 |
| 3log(2)n+4 | O(logn) | 对数阶 |
| 2n+3nlog(2)n+14 | O(nlogn) | nlog阶 |
| n^3+2n^2+4n+6 | O(n^3) | 立方阶 |
| 2^n | O(2^n) | 指数阶 |

4.3.常见的时间复杂度计算
例1:Func1函数中++count语句总共执行了多少次?
cpp
void Func1(int N)
{
int count = 0;
for (int i = 0; i < N ; ++i)
{
for (int j = 0; j < N ; ++j)
{
++count;
}
}
for (int k = 0; k < 2 * N ; ++k)
{
++count;
}
int M = 10;
while (M--)
{
++count;
}
printf("%d\n", count);
}
**解析:**F(N) = N^2 + 2*N +10
- N = 10 F(N) = 130
- N = 100 F(N) = 10210
- N = 1000 F(N) = 1002010
随着N增大,N^2这一项对结果影响越大,而后两项对结果影响越小
如果对结果进行估算,只需要取对结果影响最大的那一项:N^2
所以该函数的时间复杂度为O(N^2)
**注:**估算是为了确定算法属于哪个量级
例2:计算Func2函数的时间复杂度?
cpp
void Func2(int N)
{
int count = 0;
for (int k = 0; k < 2 * N ; ++ k)
{
++count;
}
int M = 10;
while (M--)
{
++count;
}
printf("%d\n", count);
}
**解析:**F(N) = 2*N +10,通过推导大O阶方法,该函数的时间复杂度为O(N)
**问:**为什么要去掉N前面的系数?
当N趋于无穷的时候,其系数对结果的影响不大
例3:计算Func3函数的时间复杂度?
cpp
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;
}
printf("%d\n", count);
}
**解析:**基本操作执行了M+N次,有两个未知数M和N,所以该函数的时间复杂度为O(M+N)
- 如果M远大于N: O(M)
- 如果N远大于M: O(N)
例4:计算Func4函数的时间复杂度?
cpp
void Func4(int N)
{
int count = 0;
for (int k = 0; k < 100; ++ k)
{
++count;
}
printf("%d\n", count);
}
**解析:**F(N) = 100,通过推导大O阶方法,该函数的时间复杂度为O(1)
**注:**O(1)不是代表1次,而是代表常数次
例5:计算strchr函数的时间复杂度?
cpp
const char * strchr ( const char * str, int character );
//数组搜索元素的函数
解析:
- 最坏情况:N次找到→O(N)
- 平均情况:N/2次找到→O(N)
- 最好情况:1次找到→O(1)
**注:**有些算法的时间复杂度存在最好、平均和最坏的情况:
- 最坏情况:任意输入规模的最大运行次数(上界)
- 平均情况:任意输入规模的期望运行次数
- 最好情况:任意输入规模的最小运行次数(下界)
在实际中一般情况关注的是算法的最坏运行情况,所以该函数的时间复杂度为O(N)
例6:计算Func5函数的时间复杂度?
cpp
void Func5(int N)
{
int x = 0;
for (int i = 1; i < N; i *= 2)
{
++x;
}
printf("\n");
}
int main()
{
func(8);//3
func(1024);//10
func(1024*1024)//20
return 0;
}
解析:
F(N) = 1*2*2*2*......*2,假设循环走了x次,就是x个2相乘,即2^x = N → x = log(2)N,所以该函数的时间复杂度为:O(logN)
**注:**在计算时间复杂度时,为了方便,log(2)N可以省略底数2,但其他底数不可以省略
例7:计算BinarySearch函数的时间复杂度?
cpp
/*二分查找函数*/
int BinarySearch(int* a, int n, int x)
{
assert(a);
//区间查找:左闭右闭[begin , end]
//保持左闭右闭的区间:
int begin = 0;//左闭
int end = n - 1;//右闭
while (begin <= end)//end可以取到begin
{
int mid = begin + ((end - begin) >> 1);//注:右移一位相当于除以2
if (a[mid] < x)
{
begin = mid + 1;//左闭
}
else if (a[mid] > x)
{
end = mid - 1;//右闭
}
else
{
return mid;
}
}
return -1;
}
解析:
查找区域的变化:N,N/2,N/4,N/8,...... 1,最坏情况为查找区域只剩一个数,或者找不到的情况,即 N/2/2/....../2 = 1,假设查找了x次,2^x = N → x = log(2)N,所以该函数的时间复杂度为:O(logN)
补充:
1、二分查找 VS 暴力查找
|------|---------|------|
| | 二分查找 | 暴力查找 |
| N | O(logN) | O(N) |
| 1000 | 10 | 1000 |
| 100W | 20 | 100W |
| 10亿 | 30 | 10亿 |
**二分查找的局限性:**1.数据需排序;2.仅适用于数组结构,插入与删除元素时,需频繁移动数据
所以后续还需要学习二叉搜索树,红黑树,AVL树,B树等更高阶的数据结构来处理更复杂的问题
2、二分查找左闭右开:
cpp
/*二分查找函数*/
int BinarySearch(int* a, int n, int x)
{
assert(a);
//区间查找:左闭右开[begin , end)
//保持左闭右开的区间:
int begin = 0;//左闭
int end = n;//右开
while (begin < end)//end无法取到begin
{
int mid = begin + ((end - begin) >> 1);//注:右移一位相当于除以2
if (a[mid] < x)
{
begin = mid + 1;//左闭
}
else if (a[mid] > x)
{
end = mid;//右开
}
else
{
return mid;
}
}
return -1;
}
例8:计算Fac函数的时间复杂度?
cpp
/*阶乘递归函数*/
long long Fac(size_t N)
{
if (0 == N)
{
return 1;
}
return Fac(N - 1) * N;
}
解析:

递归函数的调用次数为N + 1次,F(N) = N + 1,所以时间复杂度为:O(N)
补充:
如果加入一段循环代码,此时的时间复杂度为多少?
cpp
long long Fac(size_t N)
{
if (0 == N)
{
return 1;
}
for (int i = 0; i < N; i++)
{
//...
}
return Fac(N - 1) * N;
}
解析:

每一次调用递归函数前都需要执行一次循环,调用Fac(N)时,循环N次,再调用Fac(N-1),循环N-1次......调用Fac(1)时,循环1次,直到Fac(0)终止,这是一个等差数列,再加上递归函数本身调用的次数,所以F(N) = (N+1)*N/2+(N+1),该函数的时间复杂度为O(N^2)
**注:**递归时间复杂度是所有递归调用次数的累加
例9:计算Fib函数的时间复杂度?
cpp
/*斐波那契递归函数*/
long long Fib(size_t N)
{
if (N < 3)
{
return 1;
}
return Fib(N - 1) + Fib(N - 2);
}
解析:

可以观察到,第一层调用了2^0次,第二层调用了2^1次,第三层调用了2^2次......最后一层调用了2^(N-2)次,这是一个等比数列,F(N) = 2^0 + 2^1 + ......+ 2^(N-2) = 2^(N-1)-1,所以该函数的时间复杂度为O(2^N)
补充:
这种【递归法】的时间复杂度为O(2^N),只有理论意义,但是在实践中运行太慢
我们可以用【迭代法】改进,这种方法的时间复杂度为O(N)
cpp
long long Fib(size_t N)
{
long long f1 = 1;
long long f2 = 1;
long long f3 = 0;
for (size_t i = 3; i <= N; i++)
{
f3 = f1 + f2;
f1 = f2;
f2 = f3;
}
return f3;
}
但迭代法依然有局限性,可以用【大数运算】的方法来改进
五、空间复杂度
5.1.空间复杂度的概念
衡量算法在运行过程中临时占用存储空间的大小,也使用大O的渐进表示法
**本质:**算法执行时创建的临时变量个数
**常见的空间复杂度:**O(1),O(N):一维数组,O(N^2):二维数组
**注:**函数运行时所需的栈空间(局部变量,函数参数,部分寄存器信息...)在编译期间就已确定,因此空间复杂度主要通过函数在运行时候显示申请的额外空间来确定
5.2.常见的空间复杂度计算
例1:计算BubbleSort函数的空间复杂度?
cpp
/*冒泡排序函数*/
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;
}
}
}
解析:
只创建了常数个额外空间,所以该函数的空间复杂度为O(1)
例2:计算Fibonacci函数的空间复杂度?
cpp
long long* Fibonacci(size_t n)
{
if (n == 0)
{
return NULL;
}
long long* fibArray = (long long*)malloc((n + 1) * sizeof(long long));
fibArray[0] = 0;
fibArray[1] = 1;
for (int i = 2; i <= n; ++i)
{
fibArray[i] = fibArray[i - 1] + fibArray[i - 2];
}
return fibArray;
}
**解析:**动态开辟了N个空间,所以该函数的空间复杂度为O(N)
例3:计算Fac函数的空间复杂度?
cpp
long long Fac(size_t N)
{
if (N == 0)
{
return 1;
}
return Fac(N - 1) * N;
}
**解析:**递归调用了N次,开辟了N个栈帧,每个栈帧使用了常数个空间,所以该函数的是空间复杂度为O(N)
六、复杂度的OJ练习
试题1:消失的数字
题目内容:
数组nums包含从0到n的所有整数,但其中缺了一个。请编写代码找出那个缺失的整数。你有办法在O(n)时间内完成吗?
示例1:
输入:[3,0,1] 输出:2
示例2:
输入:[9,6,4,2,3,5,7,0,1] 输出:8
思路1:排序法
解析:
先排序,再依次查找,如果下一个值不等于前一个值加一,下一个值就是消失的数字。但如果用冒泡排序,时间复杂度为O(N^2);如果用快速排序,时间复杂度为O(N*logN),所以这道题目不适合使用排序的方法解决
思路2:求和法
解析:
求和0~N,再依次减去数组中的值,剩下的那个值就是消失的数字,核心操作次数为N+2,时间复杂度为O(N),但如果N太大,会存在溢出的风险
代码部分:
cpp
int missingNumber(int* nums,int numsSize)
{
int N = numsSize;
int ret = (0 + N) * (N + 1) / 2;
for(int i = 0; i < numsSize; i++)
{
ret -= nums[i];
}
return ret;
}
思路3:异或法
解析:
异或符合交换律,相同的值异或后为0,0与其他值异或后为其他值本身。所以只需先让0异或数组内的所有元素,再将所得的值异或0到N的所有整数,这样相同的值就抵消为0。只剩下缺少的那个值与0异或,得到那个值本身,核心操作次数为2N+2,时间复杂度为O(N)
代码部分:
cpp
int missingNumber(int* nums,int numsSize)
{
int N = numsSize;
int x = 0;
for(int i = 0; i < numsSize; i++)
{
x ^= nums[i];
}
for(int j = 0; j <= N; ++j)
{
x ^= j;
}
return x;
}
试题2:轮转数组
题目内容:
给定一个整数数组nums,将数组中的元素向右轮转k个位置,其中k是非负数
示例1:
输入:nums = [1,2,3,4,5,6,7],k = 3
输出:[5,6,7,1,2,3,4]
解释:
向右轮转1步:[7,1,2,3,4,5,6]
向右轮转2步:[6,7,1,2,3,4,5]
向右轮转3步:[5,6,7,1,2,3,4]
思路1:赋值法
解析:
将数组末尾元素暂时存至临时变量,用循环将每个元素的上一位赋值给下一位,最后将临时变量中储存的最后一个元素赋值给第一位。真实的轮转次数为K = k % N,最好情况为k % N == 0;最坏情况为k % N == N - 1。可得出该题的F(N) = 1 + K*(N+1),最坏情况为F(N) = 1 + (N-1)*(N+1),所以这种方法的时间复杂度为O(N^2),该解法超出时间限制
代码部分:
cpp
void rotate(int* nums,int numsSize,int k)
{
k %= numsSize;
while(k--)
{
int tmp = nums[numsSize - 1];
for(int i = numsSize - 2; i >= 0; i--)
{
nums[i+1] = nums[i];
}
nums[0] = tmp;
}
}
思路2:逆置法
解析:
前n-k个元素逆置[4,3,2,1,5,6,7],后k个元素逆置[4,3,2,1,7,6,5],整体逆置[5,6,7,1,2,3,4],三次逆置总操作数为n/2 + k/2 + (n-k)/2 = n,所以这种方法的时间复杂度为O(N)

代码部分:
cpp
/*逆置函数*/
void reverse(int* a,int left,int right)
{
while(left < right)
{
int tmp = a[left];
a[left] = a[right];
a[right] = tmp;
++left;
--right;
}
}
void rotate(int* nums,int numsSize,int k)
{
k %= numsSize;
reverse(nums,0,numsSize-k-1);
reverse(nums,numsSize-k,numsSize-1);
reverse(nums,0,numsSize-1);
}
思路3:拷贝法
解析:
开辟一个额外的数组,使用memcpy函数,将k后的数据拷贝到前面,k前的数组拷贝到后面,这是一个以空间换时间的方法
代码部分:
cpp
void _rotate(int* nums, int numsSize, int k, int* tmp)
{
k %= numsSize;
int n = numsSize;
memcpy(tmp, nums + n - k, sizeof(int) * k);
memcpy(tmp + k, nums, sizeof(int) * (n - k));
memcpy(nums, tmp, sizeof(int) * n);
}
void _rotate(int* nums, int numsSize, int k)
{
int tmp[numsSize];
_rotate(nums, numsSize, k, tmp);
}
补充:
回顾memcpy函数:
cpp
void* memcpy (void* destination, const void* source, size_t num);
| 参数 | 含义 |
|---|---|
| destination | 目标内存块起始地址 |
| source | 源内存块起始地址 |
| num | 要拷贝的字节数 |
| 返回值 | 目标内存块起始地址 |
模拟实现:
cpp
void* my_memcpy(void* dest,const void* src, size_t num)
{
assert(dest && src);
void* ret = dest;
while (num--)
{
*(char*)dest = *(char*)src;
((char*)src)++;
((char*)dest)++;
}
return ret;
}
**注:**重叠内存拷贝不能使用memcpy函数,而是使用 memmove函数
七、总结
本篇博客是对于数据结构中复杂度知识点的整理归纳,后续还会更新链表等内容,如果对你有帮助,欢迎点赞+收藏+关注,让我们一起共同进步🌟~