在我们数据结构中,有两个很重要的概念,便是时间复杂度和空间复杂度,这两个复杂度通常是来衡量一个算法的时间耗费和空间耗费大小的,下面由我们一起来了解一下这两个很重要的概念吧!
时间复杂度
时间复杂度的定义:在计算机科学中,算法的时间复杂度是一个函数,它定量描述了该算法的运行时间。一个算法执行所耗费的时间,从理论上说,是不能算出来的,只有你把你的程序放在机器上跑起来,才能知道。但是我们需要每个算法都上机测试吗?是可以都上机测试,但是这很麻烦,所以才有了时间复杂度这个分析方式。一个算法所花费的时间与其中语句的执行次数成正比例, 算法中的基本操作的执行次数,为算法的时间复杂度。
由我们的概念可以得知,时间复杂度的概念其实是算法中基本操作执行的次数,而这个次数事实上并不容易准确的计算出来,所以我们采用估算的方式来对算法的时间复杂度进行概括,下面我们将举几个实例来向大家说明时间复杂度是如何计算的
void Func1(int N) {
int count = 0;
for (int i = 0; i < N; ++i) {//循环n次
for (int j = 0; j < N; ++j)//嵌套循环n次
{
++count;//执行n^2次
}
}
for (int k = 0; k < 2 * N; ++k) {//循环2n次
++count;//执行2n次
}
int M = 10;
while (M--) {//循环9次
++count;//执行9次
}
printf("%d\n", count);//执行1次
}
通过我们上述的统计,这个Func1函数一共执行了N^2+2N+10次,很显然,直接进行计算一个算法的执行次数并不方便,需要统计各个语句执行的次数,这时候我们就取最影响最终执行次数的一项,也就是最高次项,来进行估算,这种估算方法被称之为大O的渐进表示法
大 O 符号( Big O notation ):是用于描述函数渐进行为的数学符号。推导大 O 阶方法:1 、用常数 1 取代运行时间中的所有加法常数2 、在修改后的运行次数函数中,只保留最高阶项3 、如果最高阶项存在且不是 1 ,则去除与这个项目相乘的常数。得到的结果就是大 O 阶。使用大 O 的渐进表示法以后, Func1 的时间复杂度为:O(N^2)N = 10 F(N) = 100N = 100 F(N) = 10000N = 1000 F(N) = 1000000通过上面我们会发现大 O 的渐进表示法 去掉了那些对结果影响不大的项 ,简洁明了的表示出了执行次数。另外有些算法的时间复杂度存在最好、平均和最坏情况:最坏情况:任意输入规模的最大运行次数 ( 上界 )平均情况:任意输入规模的期望运行次数最好情况:任意输入规模的最小运行次数 ( 下界 )例如:在一个长度为 N 数组中搜索一个数据 x最好情况: 1 次找到最坏情况: N 次找到平均情况: N/2 次找到
接下来我们来举几个例子来深刻认识一下时间复杂度的计算
1.计算Func3的时间复杂度
void Func3(int N, int M) {
int count = 0;
for (int k = 0; k < M; ++ k) {//循环M次
++count; }
for (int k = 0; k < N ; ++ k) {//循环N次
++count; }
printf("%d\n", count);
}
我们通过观察代码可知,第一个循环执行M次,第二个循环执行N次,所以一共加起来执行了M+N次,又因为M与N都为未知数,是同一阶,都是最高次项为一次项的,所以都需要保留,则Func3的时间复杂度为O(M+N)
2.计算Func4的时间复杂度
void Func4(int N) {
int count = 0;
for (int k = 0; k < 100; ++ k) {//循环100次
++count; }
printf("%d\n", count);
}
在Func4中我们可以看到,这个函数的执行次数是固定的,不随输入形参的改变而改变,是一个常数100,此时其最高次项为常数项,所以根据估算规则,其时间复杂度为O(1),坐落在常数阶上
3.计算strchr函数的时间复杂度
const char * strchr ( const char * str, int character );
while (*str != '\0'){
if (*str == character)
return str;
++str;
}
我们可以得知,这段代码的查找次数和传入字符串的长度有关,而我们字符串的计算时,我们规定将字符串长度默认为N,所以在我们这个算法中,时间复杂度为O(N),线性阶
4.计算冒泡排序的时间复杂度
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-1次,第二轮N-2,第三轮N-3,直到1为止,其执行次数是将这些数相加,为N(-2)*(N-1)/2,我们可以发现,其在时间复杂度的计算上仍在O(N^2)阶上,只是系数为1/2较低而已,但是我们时间复杂度的计算时忽略系数的,所以其时间复杂度为O(N^2)
5.二分查找的时间复杂度
int BinarySearch(int* a, int n, int x) {
assert(a);
int begin = 0;
int end = n - 1;
while (begin < end) {
int mid = begin + ((end - begin) >> 1);
if (a[mid] < x)
begin = mid + 1;
else if (a[mid] > x)
end = mid;
else
return mid;
}
return -1;
}
我们之前学习过二分查找,其大致思路就是将一个有序数组折半,不断缩小查找的区间,直到使mid等于要查找到的值或者没查到,那么其时间复杂度是如何计算的呢?可以想到的是,当我们使用二分查找查找时,最好的情况是一次查找便查找到了目标值,但是最坏情况呢?我们时间复杂度都是取最坏情况的。其实我们的二分查找每次查找都将搜索域减半,折半一次查找一次,其查找次数可以写为N/2/2/2/2..../2=1,而我们/2的次数便是查找的次数,所以最后计算我们可以得到次数为log2N以二为底的N的对数,N是数组长度,所以其时间复杂度为O(logN),处在对数阶上
6.计算阶乘的时间复杂度
long long Factorial(size_t N) {
return N < 2 ? N : Factorial(N - 1)*N;
}
观察代码,我们可知,结成是一个递归代码,当出现递归代码时,我们发现时间复杂度并不是那么容易求解了,那么我们将其剖析,看看实际拆解时,其时间复杂度如何计算
我们观察到,将递归函数分拆来看,其每一层递归都会缩小值,直到为1,此时我们便可以将最后一层视为O(1)的时间复杂度,而将其向上推,就可以发现每一层都可以视为常数阶O(1),而又因为一共具有N层递归,递归函数向上向下来回两趟一共2N次执行,所以整个递归函数可以看为是O(N)的时间复杂度
7.斐波那契数列的时间复杂度
long long Fibonacci(size_t N) {
return N < 2 ? N : Fibonacci(N - 1) + Fibonacci(N - 2);
}
同样的,这个也是一个经典的递归函数,我们的递归问题时间复杂度求解可以采用一个公式
递归算法的时间复杂度=递归次数*每次递归函数中次数
事实上,斐波那契数列在层级逻辑上更像是一个二叉树,每一层都有2^层数次执行,所以到最后将其相加得到的结果为2^N-1-缺的常数项,所以其最后的时间复杂度为O(2^N),这个阶数是非常庞大的,所以我们也可以发现,递归算法在效率上是特别差的
空间复杂度
空间复杂度是对一个算法在运行过程中 临时占用存储空间大小的量度 。空间复杂度不是程序占用了多少bytes 的空间,因为这个也没太大意义,所以空间复杂度算的是变量的个数。空间复杂度计算规则基本跟实践复杂度类似,也使用 大 O 渐进表示法 。
事实上,对于空间复杂度,我们随着硬件的不断发展,我们对于空间内存的发展也越来越大,相对而言不再那么缺少空间,所以对其看待的重要性相对于时间复杂度较弱,但也是衡量一个算法好坏的标志之一
接下来我们举几个例子加深一下对于空间复杂度的理解
1.计算冒泡排序的空间复杂度
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;
}
}
其实,对于空间复杂度的计算,我们只需要去观察一个算法所创建的临时变量的个数就可以了,在冒泡排序中,我们发现在不考虑输入数组时,具有4个临时的变量,4是一个常数,所以其空间复杂度在常数阶上,为O(1),事实上,时间是累积的,空间是不累计可以复用的,当算法中一个空间使用完毕,另一个空间需要开辟,开辟的空间可能用的都是一个空间,这是为了提高效率而进行的空间服用
2.计算斐波那契的空间复杂度
long long* Fibonacci(size_t n) {
if (n == 0)
return NULL;
long long * fibArray =
(long long *)malloc((n + 1) * sizeof(long long));//开辟n的空间
fibArray[0] = 0;
fibArray[1] = 1; for (int i = 2; i <= n; ++i)
{
fibArray[i] = fibArray[i - 1] + fibArray[i - 2];
}
return fibArray;
}
对于这个算法而言,我们在函数中进行了空间开辟,开辟了N+1的空间,所以我们的空间复杂度为O(N)
3.计算递归算法中阶乘的空间复杂度
long long Factorial(size_t N) {
return N < 2 ? N : Factorial(N-1)*N; }
对于我们的递归算法而言,其进行运算时会向下不断地开辟栈帧,最后返回时再一个一个销毁,所以我们的递归算法的空间复杂度通常是递归的深度,在本题为O(N)
下面我们将几种复杂度的增长趋势表进行展示
空间复杂度同理
力扣OJ
在实际的算法题中,题目有时会对我们的时间或空间按复杂度进行要求,此时我们便需要按照规定设计尽量低时间复杂度的算法,这样才会使程序更加高效,这边是时间复杂度的重要含义,下面我们来看在实际力扣中,对于时间复杂度的应用吧
1.
我们可以看到,这道题对于我们时间复杂度是有O(N)的要求的,所以我们在最后设计程序的时候需要遵循O(N)的时间复杂度,那么我们先从分析这道题的角度出发,忽略时间复杂度问题,列出几种解法
这种思路可以通过,但是时间复杂度是不合题意的,我们不采用这种解法
int missingNumber(int* nums, int numsSize){
int n = numsSize + 1;//一共n个数
int res1 = (0 + n)*(n + 1) / 2;//0-n相加和
int res2 = 0;
for (int i = 0; i < numsSize; i++){//数组中数字累加
res2 += nums[i];
}
return res1 - res2;//返回两者之差
}
这便是第二种思路的代码
int missingNumber(int* nums, int numsSize){
int x = 0;
for (int i = 0; i < numsSize + 1; i++){//将0-n异或给x
x ^= i;
}
for (int j = 0; j < numsSize; j++){//将数组中的数抑或给x
x ^= nums[j];
}
return x;
}
2.
注意,我们这道题具有一定的复杂性,我们在这里只使用最简单的解法来对此题进行解答
int* singleNumber(int* nums, int numsSize, int* returnSize){//接口函数,通过原数组传参
//步骤1:所有数异或
int ret = 0;
for (int i = 0; i < numsSize; i++){
ret ^= nums[i];
}
//步骤2:找出异或后结果为1的位,第j位
int j = 0;
for (; j < 32; j++){
if ((ret >>j)&1)
break;
}
//步骤3:分别将第j位为1或第j位为0的数,放入不同的两组,再异或
int x = 0; int y = 0;
for (int k = 0; k < numsSize; k++){
if ((nums[k]>>j)&1){
x ^= nums[k];
}
else{
y ^= nums[k];
}
}
int* arr = (int*)malloc(sizeof(int) * 2);//开辟空间大小为2的数组
arr[0] = x;//赋值
arr[1] = y;
*returnSize = 2;//返回个数
return arr;//返回数组
}
这道题的核心思想是先将所有数异或得到那两个不同的数的异或结果,再根据结果中为1的位去确定那两个数哪一位不相同,再将数组按照那一位为0或为1分类,最后再次异或得到结果,本题主要考察对于位运算异或的熟练掌握,不是很容易想到