【数据结构初阶(1):算法的时间复杂度和空间复杂度】

前言:

什么是数据结构?

     数据结构 (Data Structure) 是计算机存储、组织数据的方式,指相互之间存在一种或多种特定关系的数据元素的集合。 简而言之:它的作用是用于内存中管理数据------增删查改。类似的我们还听说过数据库,它与数据结构作用类似,只不过其作用对象是磁盘。

什么是算法?

        算法 (Algorithm): 就是定义良好的计算过程,他取一个或一组的值为输入,并产生出一个或一组值作为输出。简单来说算法就是一系列的计算步骤,用来将输入数据转化成输出结果
        数据结构与算法之间相辅相成,比如数据结构中的B树需要用到算法中的二分查找,而算法中的去重又要用到数据结构中的搜索树,一个优秀的程序员必须能良好的进行数据结构与算法的综合应用。

数据结构和算法的重要性

     校招面试的重点考察项目,同时在你正式步入工作之后对你的编程思维有很大的帮助。

如何学好数据结构和算法

       没有捷径,死磕代码同时注意画图和思考,这必须成为贯穿你整个学习过程的信条。

数据结构和算法书籍及资料推荐

       在学习完数据结构初阶课程之后,先刷《剑指offer》上的练习题,如果你的目标是进入大厂,那么还要进行leetcode OJ上做大量的题(难度较高),总而言之,想要学好基础知识和大量的联系缺一不可。

算法的时间复杂度和空间复杂度

算法效率

        我们如何衡量一个代码的好坏?以几行简单的代码实现一个复杂的功能,写得少能代表这个代码好,效率高吗?当然不能,这个时候,我们就要知道算法复杂度这一概念。

算法的复杂度

        算法在编写成可执行程序后,运行时需要耗费时间资源和空间 ( 内存 ) 资源 。因此 衡量一个算法的好坏,一般 是从时间和空间两个维度来衡量的 ,即时间复杂度和空间复杂度。 时间复杂度主要衡量一个算法的运行快慢,而空间复杂度主要衡量一个算法运行所需要的额外空间
       在计算机发展的早期,计算机的存储容量很小。所以对空间复杂度很是在乎。但是经过计算机行业的迅速发展,计算机的存储容量已经达到了很高的程度。所以我们如今已经不需要再特别关注一个算法的空间复杂度。
        因此,算法效率的高低取决于算法的复杂度,算法的复杂度越低,算法效率就越高,那么为了提高效率,我们就应该深入学习如何计算一个算法的时间和空间复杂度,以及降低复杂度的方法。

时间复杂度

时间复杂度的定义:
       在计算机科学中,算法的时间复杂度是一个函数(这里的函数指的是数学概念中的函数),它定量描述了该算法的运行时间。一个算法执行所耗费的时间,从理论上说,是不能算出来的,只有你把你的程序放在机器上跑起来,才能知道。但是我们需要每个算法都上机测试吗?是可以都上机测试,但是这很麻烦,所以才有了时间复杂度这个分析方式。一个算法所花费的时间与其中语句的执行次数成正比例,算法中的基本操作的执行次数,为算法的时间复杂度。
即:找到某条基本语句与问题规模N之间的数学表达式,就是算出了该算法的时间复杂度。
// 请计算一下Func1中++count语句总共执行了多少次?
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);
}

Func1 执行的基本操作次数 :N^2+2*N+10

  • N = 10 F(N) = 130
  • N = 100 F(N) = 10210
  • N = 1000 F(N) = 1002010

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

O的渐进表示法

O 符号( Big O notation ):是用于描述函数渐进行为的数学符号。
推导大 O 阶方法:
1 、用常数 1 取代运行时间中的所有加法常数。
2 、在修改后的运行次数函数中,只保留最高阶项。
3 、如果最高阶项存在且不是 1 ,则去除与这个项目相乘的常数。得到的结果就是大 O 阶。
使用大 O 的渐进表示法以后, Func1 的时间复杂度为O(N^2)
通过上面我们会发现大 O 的渐进表示法 去掉了那些对结果影响不大的项 ,简洁明了的表示出了执行次数。
另外有些算法的时间复杂度存在最好、平均和最坏情况:
最坏情况:任意输入规模的最大运行次数 ( 上界 )
平均情况:任意输入规模的期望运行次数
最好情况:任意输入规模的最小运行次数 ( 下界 )
例如:在一个长度为 N 数组中搜索一个数据 x
最好情况: 1 次找到
最坏情况: N 次找到
平均情况: N/2 次找到
在实际中一般情况关注的是算法的最坏运行情况,所以数组中搜索数据时间复杂度为 O(N)
下面看几个实例:
例一:几个基础的复杂度计算
1、
// 计算Func2的时间复杂度?
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);
}
//总次数=2*N+10   时间复杂度:O(N)

2、

// 计算Func3的时间复杂度?
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   时间复杂度:O(M+N)【当M>>N时为O(M),当N>>M时为O(N)】

3、

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

例二:

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

例二分析:分析代码可知strchr函数功能为在一个字符串中查找给定字符的第一个匹配之处,N个字符构成的字符串最坏情况下要查找N次才能找到给定字符,因此时间复杂度为O(N).

例三:
//计算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;
        }
}

例三分析:解读代码可知这是对一个数列进行冒泡排序的函数,N个数进行冒泡排序,最坏情况下一共需要走N-1趟,每一趟需要进行N-1次,总次数是以N-1为首项,1为尾项,项数是N-1的等差数列求前n项和,根据公式Sn=n(a1+an)/2可得(N^2)/2-N/2,根据复杂度计算的原则可得,O(N^2)

图例:

【数据结构初阶(1):算法的时间复杂度和空间复杂度】_第1张图片

 思考:这个代码中有没有优化部分?答案是有的,当然无论是否优化,它的最坏情况都是没有变的,但当给定的数列本身就是按照从小到大的顺序排列的,即如果在进行第一次冒泡排序的情况下没有任何数发生交换,我们就可以结束排序,这就是例子中加一个exchange 判断的意义,此时代码的最好情况由O(N^2)变成O(N)

例四:
// 计算BinarySearch的时间复杂度?
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; 
}

例四分析:解读代码可知这是对一个有序数列进行二分查找的函数,N个数进行二分查找,最坏情况下是不断地折半最终中间只剩1个数,此时能不能找到就可以确定,进行一次二分查找,元素个数就少一半,即除以2,设查找次数为x,则N/(2^x)=1——>x=log2N,即时间复杂度为O(log2N),由于键盘不方便敲出底数2,在一些地方也会简写成O(logN)、O(lgN),我们自己写的时候不这样写,但出现要知道代表的意思,那么有没有可能是其他底数呢?在编程中我们2分用的最多,一般不会出现其它,即使有也要把底数写出来,此时就不存在简写

图例:

【数据结构初阶(1):算法的时间复杂度和空间复杂度】_第2张图片

 

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

例五分析:解读代码可知这是利用递归求N的阶乘的函数,实际上这里基本操作的次数就是递归的次数,递归次数是N-1,时间复杂度为:O(N)

例六:
// 计算斐波那契递归Fib的时间复杂度?
long long Fib(size_t N) 
{
 if(N < 3)
 return 1;
 return Fib(N-1) + Fib(N-2);
}

例六分析:解读代码可知这是一个用递归求N的斐波那契数的函数,同样这里基本操作的次数也是递归的次数,我们来画图看看N的斐波那契数究竟要递归多少次

图例:

【数据结构初阶(1):算法的时间复杂度和空间复杂度】_第3张图片

由图可知求第N个斐波那契数,要递归的次数之和是一个以2^0为首项,2^(N-2)为尾项,公比为2,项数为N-1的等比数列的前n项和,根据等比数列的前n项和公式(或错位相减法)可求得,总次数为2^(N-1)-1,时间复杂度为O(2^N).【注意,算到最后几行,其实后面的会提前到F(2)因此会缺失一些值,但因为N足够大的时候,这些值对最后结果的影响微乎其微,因此就不考虑把他们减掉】

思考:这样求斐波那契数是 最优方式吗?答案是否定的,通过观察上图,我们能看出,利用递归的方式求斐波那契数会进行大量的重复计算,且当N特别大时,计算速度会越来越慢,甚至会导致程序崩溃,原因是指数函数的增长速度极快,因此根据斐波那契数的特点我们可以用迭代的方式来优化,具体见以下代码:

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

	long long f1 = 1, f2 = 1, f3;
	for (size_t i = 3; i <= N; ++i)
	{
		f3 = f2 + f1;
		f1 = f2;
		f2 = f3;
	}

	return f3;
}

优化后的时间复杂度变为O(N)

注意:算时间复杂度数循环不靠谱!!!要注重底层逻辑

空间复杂度

空间复杂度也是一个数学表达式,是对一个算法在运行过程中临时(额外)占用存储空间大小的量度 。 空间复杂度不是程序占用了多少bytes的空间,因为这个也没太大意义,所以空间复杂度算的是变量的个数。空间复杂度计算规则基本跟实践复杂度类似,也使用大O渐进表示法。 注意:函数运行时所需要的栈空间(存储参数、局部变量、一些寄存器信息等)在编译期间已经确定好了,因此空间复杂度主要通过函数在运行时候显式申请的额外空间来确定。

例一:

//计算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;
        }
}
例一分析:在这个冒泡排序的函数中,只创建了end、i、exchange三个变量,属于常数阶,因此空间复杂度为:O(1)。这里要注意,不是循环多少次就创建多少次变量,以end为例,只创建一次,但赋值会随着每次循环发生改变,再如exchange,虽然每次循环都会重新创建一次变量,但由于上一次结束之后,原来的空间就被还给操作系统,因此再一次进入循环后会向操作系统同一位置再次申请这块空间的使用权,这里明确【时间是不断积累的,但空间是可以反复利用的,申请空间相当于租房,释放空间相当于退房】
例二:
// 计算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; 
}

例二分析:动态开辟了N个空间,空间复杂度为 O(N)。

例三:

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

例三分析:递归调用了N次,开辟了N+1个栈帧,每个栈帧使用了常数个空间。空间复杂度为O(N)

例四:
// 计算斐波那契递归Fib的空间复杂度?
long long Fib(size_t N) 
{
 if(N < 3)
 return 1;
 return Fib(N-1) + Fib(N-2);
}

例四分析:我们前面再分析其时间复杂度的时候画过图,又有提过空间是可以重复利用的,那么计算额外开辟的栈帧其实只要计算调用次数最深的那一层即可,这一层调用完后返回这块空间就会释放,后续的调用用的还是这些空间,由图分析可知,调用最深的的那一层调用了N+1次,因此空间复杂度为O(N)。

例五:

#include 
#include 

void f1(int n)
{
	int a = 0;
	int* p = (int*)malloc(4 * n);
	if(p == NULL)
	{
		perror("malloc fail");
		return;
	}

	free(p);
}
 
int main()
{
	f1(10);
	f1(10);

	return 0;
}

例五分析:两次调用其实用的是同一块栈帧空间,第一次调用,创建2个变量和堆上的n个变量,调用完成后都会释放相应的空间,第二次调用,用的还是这块空间,,即N+2,因此空间复杂度为:O(N)。为证明上述过程,我们把两次调用创建的变量的地址打印出来如下图:

【数据结构初阶(1):算法的时间复杂度和空间复杂度】_第4张图片

 由此可见,两次调用利用的是同一块栈帧空间。

常见复杂度对比

一般算法常见的复杂度如下:
【数据结构初阶(1):算法的时间复杂度和空间复杂度】_第5张图片

 

【数据结构初阶(1):算法的时间复杂度和空间复杂度】_第6张图片

 *一般情况下当时间复杂度达到平方阶算法效率就比较低了,空间复杂度一般是常数阶和线性阶,很少达到。

复杂度难题练习-----轮转数组:请给出尽可能多的解决方案,并且分析时间复杂度和空间复杂度。

【数据结构初阶(1):算法的时间复杂度和空间复杂度】_第7张图片

 具体解决方案可以参考此链接:旋转数组 - 轮转数组 - 力扣(LeetCode)icon-default.png?t=M666https://leetcode.cn/problems/rotate-array/solution/xuan-zhuan-shu-zu-by-leetcode-solution-nipk/

你可能感兴趣的:(数据结构,数据结构)