前言
之前的几节我们学习了C语言中的多种数据结构算法,那么我们该如何衡量一个算法呢?它的标准是什么呢?为了解决这些问题,我们就正式开始今天的学习吧!
算法的复杂度
代码在编写完运行的时候,需要耗费时间和空间资源。所以衡量一个算法是好是坏的标准一般是从时间和空间两个角度来考虑,即时间复杂度和空间复杂度
时间复杂度主要是衡量一个算法在运行的时候的快慢,而空间复杂度主要是衡量一个算法运行时所需要占用的额外空间。两者相比,时间复杂度的关注点更高,会适当的忽略的空间复杂度
时间复杂度
时间复杂度的概念
时间复杂度的定义是:算法的时间复杂度是一个函数。从理论上来说,一个算法的时间复杂度是不能够被精确的计算出来的,一个算法所花费的时间与其中语句的执行次数成正比。简洁的说:时间复杂度这个函数表示的是算法中的基本操作的执行次数
所以找到某条基本语句和问题规模N之间的数学表达式,就求出了算法的时间复杂度
为了更好地理解时间复杂度,我们来看以下几个实际案例:
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;
}
}
请你来计算一下++count语句一共执行了多少次?
我们通过计算可以知道,++count语句一共执行了 N^2 + 2*N + 10 次,我们把它写作一个函数表达式,即:F(N) = N^2 + 2*N + 10
假设我们还能够写出另外两种不同的代码,同时又满足以上代码的功能时
假设其中一个函数的表达式为:G(N) = N + 100
另外一个函数表达式为:H(N) = N^3 + N
这些函数表达式都有很多项,那么我们又该如何比较三个算法之中谁的效率最高呢?
在F(N)表达式中,我们知道:对整个表达式影响最大的是 N^2
在G(N)表达式中,我们知道:对整个表达式影响最大的是 N
在H(N)表达式中,我们知道:对整个表达式影响最大的是 N^3
因为通常来说N的取值很大,当N趋近无穷的时候,其他位相较于最高位的影响就非常小,甚至可以忽略,所以我们只考虑最高位的那一项。据此我们来比较算法就可以知道:在三个算法中 G(N) 优于 F(N) 优于 H(N)
时间复杂度就是运用了这一种思想,是对算法效率的一种估算,算出来的不是准确值而是一个近似值,所以时间复杂度只考虑对函数大小影响最大的那一项
所以时间复杂度采取大O的渐近表示法,如上面的例子的时间复杂度就是 O(N^2),因为N^2对时间复杂度的大小取了决定性的因素
此时有的人可能会有疑问:
如果一个函数基本算法的执行次数的表达式为 F(N) = N + 100
另外一个可以满足同样功能的函数基本算法的执行次数的表达式为 G(N) = N^3
当N的取值非常小的时候,例如N取1或者2的时候 G(N)的值远小于F(N) ,这种情况下算法二要优于算法一;
而当我们以时间复杂度的角度看待这个题目时:
F(N)的时间复杂度为O(N);
G(N)的时间复杂度为O(N^3);
这样看来算法一的效率要远远高于算法二
所以我们可能产生疑问:这不是与之前产生了矛盾了吗?
其实并不是的,在N取值较小的时候的确是算法二在本质上优于算法一。但是当N很小的时候的情况我们都不会去考虑,这样的比较没有意义,因为CPU的运算速度非常的快,每秒的运算速度都是上亿次,在处理很小的数据的时候产生的时间差距几乎没有,可以直接忽略;只有在处理很大的数据的时候才会存在很大的时间差。所以我们在考虑时间复杂度的时候只会在意对数据影响最大的项
直接说明可能会有点晦涩难懂,我们用代码来直观的体现这一特点吧:
在开始举例说明之前先介绍一个函数:clock函数
我们来看看clock函数的定义:
clock()函数可以捕捉从程序开始运行时到clock()函数被调用时所耗费的时间(毫秒)
int main(void)
{
int begin = clock();
int n = 100000000;
int x = 10;
for (int i = 0; i < n; ++i)
{
++x;
}
int end = clock();
printf("%d\n", x);
printf("%d ms\n", end - begin);
return 0;
}
此时我们是在32位debug的环境下计算100000000+10执行代码所耗费的毫秒数
可以看到,这种上亿级别的计算都非常快,仅仅用时34毫秒
我们修改一下代码,让代码计算量在百万的量级,来比较一下执行代码所耗费的时间的差距:
int main(void)
{
int begin = clock();
int n = 100000000;
int x = 10;
for (int i = 0; i < n; ++i)
{
++x;
}
int end = clock();
printf("%d\n", x);
printf("%d ms\n", end - begin);
int begin1 = clock();
int n1 = 1000000;
int x1 = 10;
for (int i = 0; i < n1; ++i)
{
++x1;
}
int end1 = clock();
printf("%d\n", x1);
printf("%d ms\n", end1 - begin1);
return 0;
}
我们发现百万量级的代码在执行时所耗费的时间居然不到1毫秒(不是0毫秒)
何况我们现在还是在debug版本之下!!!!
当我们改成release版本时:
可以看到,百万量级和亿量级的运算代码在运行时所消耗的时间都小于1毫秒,可以忽略不计;
既然百万、千万量级的代码在运行时所消耗的时间都可以忽略不计,就更不用说之前我们所考虑的N取非常小的值的情况了,这就很好的解释了为什么时间复杂度在计算的时候只取对数据影响最大的项
大O的渐进表示法
大O符号是用于描述函数渐进行为的数学符号,大O符号的使用方法如下:
1.用常数1取代运行时间中的所有加法常数
2.在修改后的运行次数函数中,只保留最高阶项
3.如果最高阶项存在且不是1,则去除与这个项目相乘的常数得到的结果就是大O阶
大O符号的本质就是计算算法的时间复杂度(次数)属于哪个量级
例子 | 大O表达式 | 量级 |
520 | O(1) | 常数阶 |
5n+20 | O(n) | 线性阶 |
5n^2+1 | O(n^2) | 平方阶 |
5log(2)8+8 | O(logn) | 对数阶 |
5n+5nlog(2)8+8 | O(nlogn) | nlogn阶 |
n^3+1000n^2+n | O(n^3) | 立方阶 |
2^n+666 | O(2^n) | 指数阶 |
通常情况下就只有这几个量级,4次方阶等更高阶的情况基本上不存在,因为计算量会过大,导致电脑计算不出,讨论的意义就不大了
例题1
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(N)
我们可能会有疑惑:为什么时间复杂度是N不是2N呢?
我们继续用之前的思想,我们知道当N的取值非常大的时候,前面的系数2就起不到决定性的作用,此时就要把2省略掉,只留下N
不仅仅是2要去掉,只要N前面有常数都需要去掉,哪怕是2000或者20000,因为它们的取值都无法对程序的运行速度起到决定性的作用
例题2
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);
}
我们在没有熟练之前还是先写出函数式:F(N) = M + N
此时时间复杂度应该就是:O(M+N),或者写作O(MAX(M,N))
如果:M远大于N,就可以写作O(M)
如果:N远大于M,就可以写作O(N)
例题3
void Func4(int N)
{
int count = 0;
for (int k = 0; k < 100; ++k)
{
++count;
}
printf("%d\n", count);
}
这个代码中没有未知数,虽然它往里传入了一个参数但是并没有使用
此时函数内部执行了100次循环
此时的时间复杂度就应该记作O(1)
注意:O(1)不是代表一次,而是代表常数次
例题4
const char * strchr ( const char * str, int character );
试着计算一下strchr的时间复杂度
此函数的功能是在一个字符串当中寻找指定的字符;
我们来模拟实现一下这个代码来弄明白代码的逻辑:
while (*str)
{
if (*str == character)
return str;
else
++str;
}
此时我们若是要计算代码的时间复杂度就需要分三种情况来考虑:
1.最好的情况:第一次就找到了指定字符——此时的时间复杂度为O(1)
2.最坏的情况:最后一次才找到指定字符——此时的时间复杂度为O(N)
3.平均:平均 (1+2+3+...+N)/N=(N+1/2) 次找到指定字符——此时的时间复杂度为O(N)
当一个算法中的时间复杂度有很多种情况的时候就考虑最差的情况
例题5
void func(int n)
{
int x = 0;
for (int i = 1; i < n; i *= 2)
{
++x;
}
printf("%d\n", x);
}
int main()
{
func(8);
func(1024);
func(1024 * 1024);
return 0;
}
假设这个循环走了x次,此时就是x个2相乘
故2^x = N 则 x=log2N
此时代码的时间复杂度为 O(logN)
因为写对数表达式的时候需要专业公式,否则就不好书写底数,所以在含对数的时间复杂度公式里面 log2N 为了方便通常不写底数,但是底数不为2的时候仍然需要写底数,而其他底数出现的频率也不高
例题6
int BinarySearch(int* a, int n, int x)
{
assert(a);
int begin = 0;
int end = n-1;
// [begin, end]:begin和end是左闭右闭区间,因此有=号
while (begin <= end)
{
int mid = begin + ((end-begin)>>1);
if (a[mid] < x)
begin = mid+1;
else if (a[mid] > x)
end = mid-1;
else
return mid;
}
return -1;
}
该代码是一个二分查找的函数,我们来计算一下二分查找的时间复杂度
我们先对代码进行分析:
1.我们先考虑最好的情况:我们第一次查找是查找数组最中间的元素,如果我们运气好,那么第一次查找就能找到
2.我们知道,如果第一次查找没有找到指定的数据,如果所查找的值小于中间的数据,那么就在数组的左边进行查找;如果所查找的值大于中间的数据,那么就在数组的右边进行查找
3.我们接着考虑最差的情况:最后一次查找才找到指定的数据,或者找不到指定数据,因为每次查找完以后,要是我们没有找到指定的数据,查找的范围就会减小二分之一,所以最后一次查找的区间只有一个数据,或者找不到指定数据
所以我们在最坏的情况下,查找了多少次,就除掉了多少个2;
所以此时的时间复杂度就是O(logN);
此时来讲一个“题外话”
二分查找其实在生活或者工作中使用的频率非常的低,有以下几点理由:
1.需要排序,若是基数较大就有可能会拖垮代码运行速度
2.数组结构不方便插入和删除
有一个数据结构叫做二叉搜索树,是专门用于处理这类问题的
例题7
long long Fac(size_t N)
{
if (0 == N)
return 1;
return Fac(N - 1) * N;
}
该代码是一个阶乘函数,我们试着来计算一下这个函数的时间复杂度:
这是一个递归函数,会被重复调用很多次,我们来画图分析:
通过图像我们可以知道,会调用N+1次Fac函数
所以此时的时间复杂度就是O(N)
我们修改一下题目中的代码:
long long Fac(size_t N)
{
if (0 == N)
return 1;
for (int i = 0; i < N; ++i)
{
//相关操作
}
return Fac(N - 1) * N;
}
那么此时函数的时间复杂度是多少呢?
不难看出:该代码修改以后的时间复杂度是O(N^2)
所以我们可以得出结论:递归函数的时间复杂度是所有递归调用次数的累加值
例题8
long long Fib(size_t N)
{
if(N < 3)
return 1;
return Fib(N-1) + Fib(N-2);
}
这个函数斐波那契数,我们来计算一下斐波那契递归的时间复杂度:
该代码比较复杂,可以先分析一下:
1.如果我们要算Fib(N)的取值的话,我们首先需要知道Fib(N-1)和Fib(N-2)的取值;如果我们要知道Fib(N-1)的取值的话,我们就需要知道Fib(N-2)和Fib(N-3)的取值
2.我们第1次递归调用的时候,需要知道的数据从一个变成了2个;我们第2次递归调用的时候,需要知道的数据从2个变成了4个,所以当我们第n此调用递归的时候,需要知道的数据就会变成2^(N-2)个,因为我们知道了Fib(2)和Fib(1),所以最后一层不是2^(N-1)
3.因为递归的时间复杂度是所有递归调用次数累加,所以递归调用的累计次数为:
Sn = 2^0 + 2^1 + 2^2 + 2^3 + …… + 2^(N-2)
故 2Sn = 2^1 + 2^2 + 2^3 + …… + 2^(N-2) + 2^(N-1)
两式相减可以得出:Sn = 2^(N-1) - 1
所以我们可以知道该代码的时间复杂度就是O(2^N),这是一个估算值,实际值会比该值要小,因为有些数算出来并不需要调用那么多次。虽然有误差但是不会影响量级
这个斐波那契数列的时间复杂度为O(2^N),但是我们这种写法几乎没有任何意义,因为调用函数的次数太多导致代码运行的效率被严重拖垮,我们试着想想可不可以换一种写法,降低时间复杂度的量级呢?
我们来优化一下斐波那契数列,不使用递归:
long long Fib(size_t N)
{
long long f1 = 1;
long long f2 = 1;
long long f3 = 0;
for (size_t i = 0; i < N; i++)
{
f3 = f1 + f2;
f1 = f2;
f2 = f3;
}
return f3;
}
这样修改以后,时间复杂度就优化为O(N)
其实这样写还是有一点局限性,因为当N的取值很大的时候,可能算出的结果就会超出上限,会存在溢出的问题
为了解决这个问题我们就需要使用大数运算,大数运算就是把数据转换为字符串,并写出相关的运算方法,这个问题现在暂时很难解决,需要我们深入学习C++才能够实现
面试题:消失的数字
要解决这个题目我们通常会有如下几种思路:
思路一:
先对数组nums里面的元素排序,再依次查找数组里面的元素,如果所查的数据的值不等于它前面的一个数字+1,则所查的数据就是消失的数据
这个思路很容易想到,而且实现起来也不存在什么难度
但是原题目中规定了时间复杂度要为O(N)
如果我们使用冒泡排序对数组进行排序:
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(N^2)了,显然不满足题目的要求
若是我们使用qsort函数对数组进行快速排序:
我们先要知道qsort函数的时间复杂度为O(nlogn)(暂时只需要记住,运用当前的知识不好解释说明)
仍然不符合题目的要求,所以思路一我们暂时排除
思路二:
求出0到N之间的所有数的总和,再依次的减去数组中的每个元素,最后剩下的数就是消失的数字
我们对这个思路进行分析,可以知道此时的时间复杂度就是O(N)
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;
}
但是在求和的时候,如果N的取值稍微大一点的话可能会存在溢出的风险
思路三:
我们可以使用异或的方法,我们知道:两个相同的值使用异或以后为0,0和一个数异或后的数据就是自己本身
我们可以把这个思路应用到这个题目中去
我们可以依次取用输入的数据去异或0-N,把异或后的结果它存入数组,再用0-N去异或数组中的数据,因为两个相同的值使用异或以后为0,0和一个数异或后的数据就是自己本身,就可以知道消失的数字
int missingNumber(int* nums, int numsSize)
{
int N = numsSize;
int x = 0;
for (int i = 0; i < numsSize; ++i)//9个值
{
x ^= nums[i];
}
for (int j = 0; j <= N; ++j)//10个值
{
x ^= j;
}
return x;
}
例题二:轮转数组
我们先来计算一下排序算法的时间复杂度:
因为排序数据个数为N的数组里面的元素时,需要执行N-1次交换。所以通过计算我们知道, 排序算法函数式=K*(N-1),所以时间复杂度为O(N)
我们再接着分析一下题目:
假设K=7的时候,我们旋转完成以后的数组就是原数组,就相当于没有旋转,所以说旋转7X次的时候就与没有旋转没区别
如果K=10此时的效果与K=3的效果一模一样;
所以K旋转的真实次数就是K %= N;
在最坏的情况下:K % N = N - 1 此时数组要轮转N-1次
在最好的情况下:K % N == 0 此时就相当于数组不轮转
在计算时间复杂度的时候通常考虑最坏的情况;在最坏的情况下:时间复杂度为O(N^2)
所以我们用最简单的方法写出代码:
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;
}
}
我们发现在leetcode的官网里面提交代码并没有通过,因为有一组例子超出了时间的限制,说明我们还得减小时间复杂度;
此时我们就需要用一个较难想到的方法来压缩时间复杂度——三段逆置
我们就以题目中所给的示例为例:
1.先把数组里面前N-K个数据逆置,此时的情况为:4 3 2 1 5 6 7
2.再把数组里面后K个数据逆置,此时数组里面的情况为:4 3 2 1 7 6 5
3.最后将数组里面的数据整体逆置,此时数组里面的情况为:5 6 7 1 2 3 4
在这种算法的情况下,时间复杂度是O(N)
我们首先需要写一个逆置函数以实现数据的逆置功能:
要实现一个逆置函数,我们需要三个变量:
1.数组nums
2.左边的变量left
3.右边的变量right
当left小于right的时候,说明左右两个变量还没有相遇,此时我们就需要执行交换操作;
知道了代码的逻辑,我们就能很轻松的写出代码了:
void reverse(int* nums, int left, int right)
{
while (left < right)
{
int tmp = nums[left];
nums[left] = nums[right];
nums[right] = tmp;
}
}
所以此时的代码就可以完整的写出来:
void reverse(int* nums, int left, int right)
{
while (left < right)
{
int tmp = nums[left];
nums[left] = nums[right];
nums[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);
}
结尾
本节我们我们知道了衡量一个算法好坏的标准,而且讲解了时间复杂度的概念和相关示例,下一节我们接着来讲空间复杂度,希望能给您带来帮助,谢谢您的浏览!!!