数据结构:算法的时间复杂度和空间复杂度

目录

 

什么是数据结构?

什么是算法?

算法效率

如何衡量一个算法的好坏?

算法的复杂度

时间复杂度

时间复杂度的概念

大O的渐进表示法

常见时间复杂度计算举例

空间复杂度

常见时间复杂度对比

复杂度的OJ练习


什么是数据结构?

数据结构(Data Structure)是计算机存储、组织数据的方式。指相互之间存在一种或多种特定关系的数据元素的集合

什么是算法?

算法(Algorithm):就是定义良好的计算过程,他取一个或一组的值为输入,并产生出一个或一组值作为输出。简单来说算法就是一系列的计算步骤,用来将输入数据转化成输出结果。

算法效率

如何衡量一个算法的好坏?

如何衡量一个算法的好坏呢?比如对于下面的斐波那契数列:

long long Fib(int N)
{
  if(N<3)
   return 1;
 

return Fib(N-1)+Fib(N-2);
}

斐波那契数列的递归实现方式非常的简洁,但是简洁就一定好嘛?那我们该如何衡量其好与坏呢?这就引出了我们下面要讲的内容。

算法的复杂度

算法在编写可执行程序后,运行时需要耗费时间资源和空间(内存)资源。因此衡量一个算法的好坏,一般是从时间和空间两个维度来衡量的,即时间复杂度与空间复杂度。

时间复杂度主要衡量一个算法的运行快慢,而空间复杂度主要衡量一个算法运行所需要的额外空间。在计算机发展的早期,计算机的存储容量很小。所以对空间复杂度很是在乎。但是经过计算机行业的迅速发展,计算机的存储容量已经达到了很高的程度。所以我们如今已经不需要再特别关注一个算法的空间复杂度了。

时间复杂度

时间复杂度的概念

时间复杂度的定义:在计算机科学中,算法的时间复杂度是一个函数(这个函数指的是数学里面带有未知数的函数表达式),它定量描述了该算法的运行时间。一个算法执行所耗费的时间,从理论上说,是不能算出来的,只有你把你的程序放在机器上跑起来,才能知道。但是我们需要每个算法都上机测试吗?是可以都上机测试,但是这很麻烦,所以才有了时间复杂度这个分析方式。一个算法所花费的时间与其中语句的执行次数成正比例,算法的基本操作的执行次数,为算法的时间复杂度

即:找到某条基本语句与问题规模N之间的数学表达式,就是算出来该算法的时间复杂度。

下面我们来举一个例子看看吧

//请计算一下Func1中++count的语句总共执行了多少次?
void Fun1(int N)
{
  int count = 0;
  for(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;
   }

你认为它总共执行了多少次呢?

Func1执行的次数:

时间复杂度的函数式:F(N) = N^2+2*N+10;

当N=10时,F(N) = 130

当N=100时,F(N) = 10210

当N=1000时,F(N) = 1002010

通过结果我们可以看出,当N越大,后面2*N+10这两项对结果的影响就越小。

实际上我们计算时间复杂度的时候,我们其实并不一定要计算精确的执行次数,而只需要大概的执行次数,那么这里我们使用大O的渐进表示法(估算

上面代码的时间复杂度就是O(N^2)

大O的渐进表示法

大O符号(Big O notation):是用于描述函数渐进行为的数字符号。

推导大O阶方法:

1.用常数1取代运行时间中的所有加法常数。

2.再修改后的运行次数函数中,只保留最高阶项。

3.如果最高阶项存在且不是1,则取出与这个项目相乘的常数。得到的结果就是大O阶。

这三点具体是什么意思呢?我们后面会用代码举例来介绍

使用大O的渐进表示法后,上面Func1的时间复杂度为:O(N^2)

通过上面我们会发现大O的渐进表示法去掉了那些对结果影响不大的项,简洁明了的表示出了执行次数。

另外有些算法的时间复杂度存在最好、平均和最坏的情况:

最坏情况:任意输入规模的最大运行次数(上界)

平均情况:任意输入规模的期望运行次数

最好情况:任意输入规模的最小运行次数(下界)

比如说:在一个长度为N的数组中搜索一个数据x

最好情况:1次找到

平均情况:N/2次找到

最坏情况:N次找到

常见时间复杂度计算举例

实例1:

//计算Fun2的时间复杂度
void Fun2(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) = 2N+10

用大O渐进表示法来表示的话这段代码的时间复杂度就是O(N)

为什么呢?

当N无限大的时候这个10对于结果的影响就很小,并且由推导大O阶方法的第三点可知如果最高阶项存在且不是1,则去除与这个项目的常数,因此时间复杂度就是O(N)。

实例2:

//计算Fun3的时间复杂度
void Fun3(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);
}

你认为这段代码的时间复杂度是多少呢?

这段代码的时间复杂度用大O渐进表示法是O(M+N)

原因:第一个for循环执行了N次,第二个for循环执行了M次,由于这里的N与M都是未知数,不知道谁大谁小,因此是O(M+N)。

一般情况下时间复杂度计算时未知数都是用的N,但是也可以用M,K等等其他的。

如果这道题说了N>>M,那么时间复杂度就是O(N)

如果M>>N,那么时间复杂度就是O(M).

实例3:

//计算Func4的时间复杂度
void Func4(int N)
{
	int count = 0;
	for (int k = 0; k < 100; ++k)
	{
		++count;
	}
	printf("%d\n", count);
}

你认为这段代码的时间复杂度是多少呢?

由于这里的循环执行了100次是常数次,由推导大O阶方法第1点可知用常数1取代运行时间中的所有加法常数。

所以这段代码的时间复杂度是O(1),注意这里的O(1)不是代表算法运行一次,而是常数次

实例4:

//计算strchar的时间复杂度
const char* strchr(const char* str, int character);


strchr的大概写法
//相当于是在一个字符串中找一个字符
while(*str)
{
   if(*str == character)
      return str;
      else
      ++str;
}

你认为这段代码的时间复杂度是多少呢?

对于这个代码我们就需要分情况:

 当一个算法随着输入不同,时间复杂度不同,时间复杂度做悲观预期,看最坏的的情况

因此我们这段代码的时间复杂度是O(N)

实例5:

//计算BubbleSort的时间复杂度
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;
	}
}

你认为上面这段冒泡排序的时间复杂度是多少呢?

精确的是:F(N) = N-1+N-2+N-3+...+N-i+1=0 = N*(N-1)/2

因此这段代码的时间复杂度是O(N^2)

实例6:

//计算BinarySearch的时间复杂度?
int BinarySearch(int *a, int n, int x)
{
	assert(a);
	int begin = 0;
	int end = n;
	while (begin < end)
	{
		int mid = begin + ((end - begin) >> 1);
		if (a[mid] < x)
			begin = mid + 1;
		else if (a[mid]>x)
			begin = mid;
		else
			return mid;
	}
	return -1;
}

你认为这个二分查找代码的时间复杂度是多少呢?

O(N)嘛?我相信很多人就光看这里的循环执行次数肯定会认为是O(N)

因此通过这个例子我想告诉大家:

算时间复杂度不能只去看是几层循环,而是要去看他的思想

那么我们怎么来理解这个算法的思想呢?

我们拿出一张纸,把他折成长方形的样子,假设这张纸就是我们的一个有序数组,我们要在这个数组里面找某个值。我们先从中间的位置开始找,如果我们要找的值比中间的值大那么就从中间的右边开始找,将我们的纸对折一次,这样我们搜索的区域就变成原来搜索区域的一半了。我们继续从中间开始找,假设现在要找的值还是比中间的值要大,我们再将纸对折一次,依此类推直到找到或者找不到为止

因此这个代码的时间复杂度是O(log2^N)

二分查找算法其实是一个非常牛逼的算法

N个数中查找              大概查找次数

1000                              10

100w                              20

10亿                               30

但前提是这N个数得是有序的

实例7:

//计算阶乘递归Fac的时间复杂度
long long Fac(size_t N)
{
	if (0 == N)
		return 1;
	
	return Fac(N - 1)*N;
}

你认为这段代码的时间复杂度是多少呢?

这段代码的时间复杂度是O(N)

递归算法:递归次数*每次递归调用的次数

这里递归了n次,每次递归调用的次数是常数次(我们就认为它是O(1)

因此这段代码的时间复杂度是O(N) 

实例8:

//计算斐波那契数列Fib的时间复杂度
long long Fib(size_t N)
{
	if (N < 3)
		return 1;

	return Fib(N - 1) + Fib(N - 2);

}

这段代码的时间复杂度又是多少呢?

它的时间复杂度是O(2^N)

斐波那契数列的递归调用有点像二叉树,也有点像我们生物上面的细胞分裂。

上面的X是递归过程中一些递归分支提前结束的那部分

通过等比数列公式我们可以求得Fib(N) = 2^N -1

因此斐波那契数列的时间复杂度是O(2^N)

总结:当一个算法分多种情况时,时间复杂度取它的最坏情况。通过以上例子我们就可以知道,求一个算法时间复杂度的时候,不能够单单看循环的次数来认定它的时间复杂度,而是应该去看这个算法的思想进行层层分析最后得到它的时间复杂度

空间复杂度

空间复杂度也是一个数学表达式,是对一个算法再运行过程中临时额外占用存储空间大小的量度

空间复杂度不是程序占用了多少bytes的空间,因为这个也没太大意义,所以空间复杂度算的是变量的个数。空间复杂度计算规则基本跟时间复杂度类似,也使用大O渐进表示法

注意:函数运行时所需要的栈空间(存储参数、局部变量、一些寄存器信息等)在编译期间已经确定好了,因此空间复杂度主要通过函数在运行时候显示申请的额外空间来确定

下面我们就通过几个例子来看看吧

实例1:

//计算BubbleSort的空间复杂度
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)吗?

这段代码的空间复杂度是O(1),很多人可能会这么认为:这段代码在第一层循环里面定义了end,然后在第二层循环里面定义了n个i变量,所以是O(N).但其实不是这样的。

在冒泡排序的函数栈帧中,会先定义一个end,再定义一个i,第二层循环结束后i销毁,再循环上来又会创建一个i,但是这里的i与之前的i是共用同一块空间的。这个代码额外使用了三个(再加上change)空间,也就是常数个,所以它的空间复杂度是O(1)

注意:空间是可以重复利用的,不累计的。时间是一去不复返,累计的

实例2:

//计算Fibonacci的空间复杂度
//返回斐波那契数列的前n项
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;
}

你认为这段代码的空间复杂度是多少呢?

这段代码的空间复杂度是O(N)

我们在这里动态开辟了一个n+1个long long空间大小的数组,然后又定义了i变量。

因此它空间复杂度是O(N).

下面我们再来看一个例子吧

实例3:

//计算阶乘递归Fac的空间复杂度
long long Fac(size_t N)
{
	if (N == 0)
		return 1;


	return Fac(N - 1)*N;
}

你认为这段代码的空间复杂度又是多少呢?

它的空间复杂度是O(N)

递归算法的空间复杂度要看栈帧的消耗--递归的深度

这段代码调用了N次,由于每次调用都会创建栈帧,我们可以认为每次建立的栈帧中有常数个变量,由于建立了N次,因此这里的空间复杂度是O(N)

实例4:

//计算斐波那契数列Fib的空间复杂度
long long Fib(size_t N)
{
	if (N < 3)
		return 1;

	return Fib(N - 1) + Fib(N - 2);

}

你认为斐波那契数列的空间复杂度是多少呢?

这段代码的空间复杂度是O(N)

结合上面时间复杂度的那副图把它递归层层展开的时候开辟的栈帧当作是一个结点,每创建一个结点都是依次函数调用。还是上面那句话:时间是一去不复返的,累积的。空间是可以重复利用的,不累计的。每次递归调用,当Fib(N)递归完了,就会就它申请的内存还给操作系统,然后Fib(N-1)才开始递归依此类推,最多建立了N个栈帧,并且他们用的是同一块空间。

简单来说就是:当Fib(N)开始递归的时候,会建立N个栈帧,当它递归完了之后,就把它申请的内存归还给操作系统,然后Fib(N)递归完了,Fib(N-1)才开始递归,它会建立N-1个栈帧,它所申请的内存空间就是之前Fib(N)还给操作系统的那块内存空间,重复利用

因此它的空间复杂度是O(N)

常见时间复杂度对比

5201314O(1)常数阶段
3n+4O(n)线性阶
3n^2+4n+5O(n^2)平方阶
3log(2)n+4O(logn)对数阶
2n+3nlog(2)n+14O(nlogn)

nlogn阶

n^3+2n^2+4n+6O(n^3)立法阶
2^nO(2^n)指数阶

复杂度的OJ练习

上面举了那么多时间复杂度与空间复杂度的例子。下面我们就来做几道题练练手吧

题目链接:面试题 17.04. 消失的数字

题目描述:

数组nums包含从0n的所有整数,但其中缺了一个。请编写代码找出那个缺失的整数。你有办法在O(n)时间内完成吗?

思路1:利用快速排序然后遍历数组看后一个数是不是前一个数+1,如果是的话就找到了。

时间复杂度是O(N*logN) 不符合题目的要求

思路2:通过一个循环求出1+2+3+....+到n的值,再通过一个循环求出数组中每个值相加的和

再用第一个循环求出的值减去第二个循环求出的值,那么得到的值就是缺失的数字。时间复杂度是O(N)满足要求

思路3:建立一个n+1空间大小的数组,将数组的值写到该数组下标对应的位置上,哪个位置没有写那么这个数就是缺失的数字。时间复杂度O(N) 空间复杂度就变成了O(N) 这种方法就是拿空间换时间。

思路4:给一个值x=0 然后将x与0到n的所有值异或,x再跟数组中的每个值异或,最后x就是却的那个数字。这里就借助了异或的特性:相异为1,相同为0,0跟任何数异或的值就是另外一个数

//思路4:
int missingNumber(int* nums, int numsSize){
int x = 0;
int i = 0;
for(i=0;i<numsSize+1;i++)
{
     x^=i;
}
for(i=0;i<numsSize;i++)
{
    x^=nums[i];
}
return x;
}

题目链接:189. 旋转数组

题目描述:

给定一个数组,将数组中的元素向右移动 k 个位置,其中 k 是非负数。

输入: 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次,时间复杂度O(N*K),空间复杂度O(1)

思路2:开辟额外空间,旋转K次就是将数组中后K个的数拷到新开辟的空间中,再把数组前n-k个数拷到新开辟的空间中去,然后再将新数组中的值拷回到原来的数组中去,以空间换时间。时间复杂度O(N),空间复杂度O(N)

思路3:先将数组中前n-k个数进行逆置,再将数组中后k个数进行逆置,最后再将数组中的数进行整体逆置。时间复杂度O(N),空间复杂度O(1)

//思路3
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){
    if(k>=numsSize)
    {
        k%=numsSize;
    }

//前n-k个数逆置
Reverse(nums,0,numsSize-k-1);
//后k个数逆置
Reverse(nums,numsSize-k,numsSize-1);
//整体逆置
Reverse(nums,0,numsSize-1);
}

以上就是本篇文章的全部内容了,如果对文章内容感兴趣的可以给作者三连(点赞关注收藏)一波哦。

评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值