数据结构(开篇)—— 复杂度详解

前言

本篇文章将带领大家深入了解我们 算法的时间复杂度和空间复杂度!

数据结构与算法 的重要性相信不用多说了吧,那么入门之前,必不可少的就是学习我们的 时间复杂度和空间复杂度!

使用不同算法,解决同一个问题,效率可能相差非常大。

为了对算法的好坏进行评价,我们引入 “算法复杂度” 的概念。

Let’s get it!


文章目录

    • 数据结构前言
      • 什么是数据结构
      • 什么是算法?
      • 数据结构和算法的重要性
      • 如何学好数据结构和算法?
      • 书籍及资料推荐
    • 算法效率
      • 算法的复杂度
    • 时间复杂度
      • ‍ 时间复杂度的定义
      • ‍ 大O的渐进表示法
      • ‍ 时间复杂度的案例分析
    • 空间复杂度
      • ‍ 空间复杂度的定义
      • ‍ 空间复杂度的案例分析
    • 常见时间复杂度
    • 复杂度的OJ练习
      • ‍ 消失的数字
        • ✨ 思路一
        • ✨ 思路二
        • ✨ 思路三
        • ✨ 思路四
      • ‍ 旋转数组
        • ✨ 思路一
        • ✨ 思路二
        • ✨ 思路三
    • ☀ 总结


数据结构前言

平时经常在网上看到 数据结构,那么它到底是什么呢?

什么是数据结构

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

什么是算法?

算法(Algorithm):就是定义良好的计算过程,他取一个或一组的值为输入,并产生出一个或一组值作为输出。

 

简单来说算法就是一系列的计算步骤,用来将输入数据转化成输出结果。

数据结构和算法的重要性

这里给大家看一下知乎上是怎么回答的

学好算法对一个程序员来说是必须的吗?

 

数据结构与算法对一个程序员来说的重要性?

不言而喻,数据结构与算法 真的非常非常非常非常重要!!!

如何学好数据结构和算法?

1、死磕代码:光看怎么写,肯定得多去刷题网站上面刷题。(最好从早刷到晚)

 

推荐两个人人皆知的刷题网站:力扣 (LeetCode) 和 牛客网

 

2、注意画图和思考:很多思路光靠想是不行的,有时候思想抛锚的话,就得需要你画图了。

 

推荐常用的画图工具:ProcessOn 和 draw.io

书籍及资料推荐

数据结构学习得差不多了,推荐大家都去把 《剑指offer》《程序员代码面试指南》 上的题做一遍

数据结构(开篇)—— 复杂度详解_第1张图片

算法效率

思考:如何衡量一个算法的好坏呢?

代码示例:斐波那契数列

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

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

斐波那契数列的递归实现方式非常简洁,但简洁一定好吗?那该如何衡量其好与坏呢?

所以这就引出了我们算法的复杂度

算法的复杂度

算法在编写成可执行程序后,运行时需要耗费时间资源和空间(内存)资源 。

因此衡量一个算法的好坏,一般是从时间空间两个维度来衡量的,即时间复杂度空间复杂度

算法效率分析分为两种:

 

第一种是时间效率

 

第二种是空间效率

 

时间效率被称为时间复杂度,而空间效率被称作空间复杂度

 

时间复杂度主要衡量一个算法的运行快慢,而空间复杂度主要衡量一个算法运行所需要的额外空间。

 

在计算机发展的早期,计算机的存储容量很小。所以对空间复杂度很是在乎。

 

但是经过计算机行业的迅速发展,计算机的存储容量已经达到了很高的程度。所以我们如今已经不需要再特别关注一个算法的空间复杂度。

时间复杂度

‍ 时间复杂度的定义

在计算机科学中,算法的时间复杂度是一个函数,它定量描述了该算法的运行时间
 
一个算法执行所耗费的时间,从理论上说,是不能算出来的,只有你把你的程序放在机器上跑起来,才能知道。

 

但是我们需要每个算法都上机测试吗?是可以都上机测试,但是这很麻烦,所以才有了时间复杂度这个分析方式。

 

一个算法所花费的时间与其中语句的执行次数成正比例,算法中的基本操作的执行次数,为算法的时间复杂度。

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

先来看一段代码示例:

// 请计算一下Func1中++count语句总共执行了多少次?
void Func1(int N)
{
	int count = 0;
    
    //代码一
    //两层循环嵌套外循环执行N次,内循环执行N次,整体计算就是N*N的执行次数
	for (int i = 0; i < N; ++i)
	{
		for (int j = 0; j < N; ++j)
		{
			++count;
		}
	}
    
    //代码二
	//2 * N的执行次数
	for (int k = 0; k < 2 * N; ++k)
	{
		++count;
	}
	
    //代码三
    //常数项10
	int M = 10;
	while (M--)
	{
		++count;
	}
	printf("%d\n", count);
}

分析:

Func1 执行的基本操作次数 :

 

在代码一中++count执行了 N ∗ N N*N NN

 

在代码二中++count执行了N次

 

在代码三中++count执行了10次

 

所以总共执行次数为 ( N 2 + N + 10 ) (N^2+N+10) N2+N+10次。

 

我们得到一个函数关系: F ( N ) = N 2 + N + 10 F(N)=N^2+N+10 F(N)=N2+N+10

函数关系分析:

上面已经推出了我们的一个函数公式: F ( N ) = N 2 + N + 10 F(N)=N^2+N+10 F(N)=N2+N+10

当N = 10,F(N) = 130

当N = 100,F(N) = 10210

当N = 1000,F(N) = 1002010

 

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

由上面的示例引出了我们的大O渐进表示法

‍ 大O的渐进表示法

时间复杂度和空间复杂度一般都使用大O的渐进表示法进行表示,大O的渐进表示法规则如下:

1、所有常数都用常数1表示。

 

2、只保留最高阶项。 O ( n 2 + 2 ) O(n^2+2) O(n2+2),保留最高阶项后,成为 O ( n 2 ) O(n^2) O(n2)

 

3、如果最高阶项存在且不是1,则去除与这个项的系数,得到的结果就是大O阶。 O ( 4 n 2 ) O(4n^2) O(4n2),省去最高阶项的系数后,成为 O ( n 2 ) O(n^2) O(n2)

思考:那么我们上面的Func1函数怎么用大O表示呢?

是不是: O ( N 2 ) + N + 10 O(N^2)+N+10 O(N2)+N+10 呢?

那就大错特错了!!!

使用大O的渐进表示法,要去掉那些对结果影响不大的项简洁明了的表示出了执行次数

 

所以正确的应该是: O ( N 2 ) O(N^2) O(N2)

举几个栗子:

1、可以忽略加法常数: O ( 2 n + 3 ) = O ( 2 n ) O(2n + 3) = O(2n) O(2n+3)=O(2n)

 

2、与最高次项相乘的常数可忽略: O ( 2 n 2 ) = O ( n 2 ) O(2n^2) = O(n^2) O(2n2)=O(n2)

 

3、最高次项的指数大的,函数随着 n 的增长,结果也会变得增长得更快: O ( n 3 ) > O ( n 2 ) O(n^3) > O(n^2) O(n3)>O(n2)

 

4、判断一个算法的(时间)效率时,函数中常数和其他次要项常常可以忽略,而更应该关注主项(最高阶项)的阶数

O ( 2 n 2 ) = O ( n 2 + 3 n + 1 ) O(2n^2) = O(n^2+3n+1) O(2n2)=O(n2+3n+1) O ( n 3 ) > O ( n 2 ) O(n^3) > O(n^2) O(n3)>O(n2)

理解了吗?

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

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

 

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

 

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

举个栗子:

例如:在一个长度为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 2*N 2N次,第二个循环执行了10次。 总共执行了 2 ∗ N + 10 2 * N+10 2N+10次。

 

因为最高阶项是: 2 ∗ N 2 * N 2N,所以时间复杂度是 O ( N ) O(N) O(N)

案例2

计算Func3的时间复杂度?

void Func3(int N, int M)
{
	int count = 0;
    
    //执行M次
	for (int k = 0; k < M; ++k)
	{
		++count;
	}
    
    //执行N次
	for (int k = 0; k < N; ++k)
	{
		++count;
	}
	printf("%d\n", count);
}

分析:

第一个循环执行了M次,第二个循环执行了N次。 总共执行了$ M+N$次。

 

最高阶项是M和N,所以时间复杂度是 O ( M + N ) O(M+N) O(M+N)

 

假设:

 

M大于N --> O ( M ) O(M) O(M)

 

N大于M --> O ( N ) O(N) O(N)

 

M和N一样大 --> O ( M ) / O ( N ) O(M) / O(N) O(M)/O(N

 

还是取影响最大的那一项,如果并没有说明M和N的大小关系,那么时间复杂度就是 O ( M + N ) O(M + N) O(M+N)

案例3

计算Func4的时间复杂度?

void Func4(int N)
{
	int count = 0;
	for (int k = 0; k < 100; ++k)
	{
		++count;
	}
	printf("%d\n", count);
}

分析:

这个循环执行了100次, 说明总共执行了100次。

 

最高阶项是100,执行次数为常数次,所以时间复杂度是 O ( 1 ) O(1) O(1)

案例4

计算strchr的时间复杂度?

const char* strchr(const char* str, int character)
{
	while (*str)
	{
		if (*str == character)
			return *str;
		else
			str++;
	}
}

提示:strchr是一个在字符串中查找某个字符的算法

分析:

时间复杂度: O ( N ) O(N) O(N)

 

在一个字符串中查找一个字符,肯定要变量这个字符串,所以会利用循环,遍历长度次,由于长度是未知的,所以最高阶项N。

 

最好情况:一次就找到了, O ( 1 ) O(1) O(1)

 

平均情况: O ( N / 2 ) O(N / 2) O(N/2) , 忽略系数项是 O ( N ) O(N) O(N)

 

最坏情况:遍历到最后才找到或者字符串中压根就没有, O ( N ) O(N) 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;
	}
}

分析:

精确的时间复杂度: O ( N ∗ ( N − 1 ) / 2 ) O(N * (N-1) / 2) O(N(N1)/2)

 

冒泡排序的思想是:假设给你N个元素,让你排升序,对N个元素排升序,只需要执行N - 1趟,两两相比较;

 

每趟交换都会将最大的那个元素换到最后面去,这样子一来最大的数就都集中在后面,不就相当于每排序一趟后就少比较一个元素嘛,因为后面已经是最大的了不需要再交换。

 

通过公式展开后采用大O渐进表示法规则后,时间复杂度: O ( N 2 ) O(N ^ 2) O(N2)

案例6

计算BinarySearch的时间复杂度?

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;
}

分析:

先来理解二分查找的思想:

 

关于二分查找可以看之前写的一篇文章(热榜第一):你还不懂二分查找?那是你没看这篇文章!

 

这里还是简单画一下思路图:

数据结构(开篇)—— 复杂度详解_第2张图片

从图上可以看出:

 

二分查找算法最坏的情况是:一直找不到,然后一直分,直到剩下的数不能再分为止。

 

所以可以推导出公式:

 

N / 2 / 2 / 2 / 2... / 2 / 2 = 1 N/2/2/2/2.../2/2=1 N/2/2/2/2.../2/2=1

 

等于1的时候找到了

 

2 x = N 2 ^ x = N 2x=N

 

x = l o g 2 N x = log_2N x=log2N (以2为底,N的对数)

 

最坏情况: O ( l o g N ) O(logN) O(logN)

 

最好情况: O ( 1 ) O(1) O(1)

 

平均情况:不考虑

案例7

计算阶乘递归Fac的时间复杂度?

long long Fac(size_t N)
{
	if (0 == N)
		return 1;

	return Fac(N - 1) * N;
}

分析:

计算F(N),利用递归调用F(N) = N*F(N-1)

 

这个算法,递归的开始条件是当N >= 2,它的结束条件是N < 2,当条件成立的时候会一直递归下去,直达条件为假递归终止才会将结果返回,递归的深度决定的是算法的时间复杂度。

 

F(N-1) = (N-1) * F(N-2)

 

F(N-2) = (N-2) * F(N-3)

 

 

F(2) = 2 * F(1)

 

F(1) = 1

 

这样一共递归调用了N次,所以时间复杂度是 O ( N ) O(N) 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 ) O(2 ^ N) O(2N)

 

经典的递归算法,我们可以将它看成一个完全二叉树,在它递归层层展开的时候开辟的栈帧看作是一个结点,每创建一个结点都是一次函数调用,那么画图的场景是这样的:

数据结构(开篇)—— 复杂度详解_第3张图片

可以看到它的调用次数是 F i b ( N ) = 2 0 + 2 1 + 2 2 + … + 2 ( n − 1 ) − x Fib(N) = 2 ^0 + 2 ^1 + 2 ^ 2 + … + 2 ^(n -1) - x Fib(N)=20+21+22++2(n1)x

 

时间复杂度: O ( 2 N ) O(2 ^ N) O(2N)

空间复杂度

‍ 空间复杂度的定义

空间复杂度是对一个算法在运行过程中临时占用存储空间大小的量度

 

空间复杂度不是程序占用了多少 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;
	}
}

分析:

这串代码中开辟了exchangeendi三个变量的空间,也就是常数个空间;

 

所以空间复杂度是 O ( 1 ) O(1) 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;
}

分析:

这里动态内存申请了一块 n+1 个大小的空间,其它都是常数次;

 

所以空间复杂度为 O ( N ) O(N) O(N)

案例3

计算阶乘递归Fac的空间复杂度?

long long Fac(size_t N)
{
	if (N == 0)
		return 1;

	return Fac(N - 1) * N;
}

分析:

递归算法的空间复杂度通常是递归的深度(即递归多少层)。

 

计算F(N),利用递归调用 F ( N ) = N ∗ F ( N − 1 ) F(N) = N*F(N-1) F(N)=NF(N1)

数据结构(开篇)—— 复杂度详解_第4张图片
所以空间复杂度是 O ( N ) O(N) O(N)

常见时间复杂度

一般算法常见的复杂度如下:

数据结构(开篇)—— 复杂度详解_第5张图片

大O分析:

数据结构(开篇)—— 复杂度详解_第6张图片

常用的排序算法的时间复杂度和空间复杂度:

数据结构(开篇)—— 复杂度详解_第7张图片

复杂度的OJ练习

‍ 消失的数字

题目链接:面试题 17.04. 消失的数字
数据结构(开篇)—— 复杂度详解_第8张图片
分析:

由题可知,该题对时间复杂度有要求,并且需要在 O ( n ) O(n) O(n)时间内完成;

 

其实这道题的做法是非常多的,我这里也给出了几种思路,往下看

✨ 思路一

1、先排序

 

2、排完序以后,再进行从小到大遍历,看后一个数是否为前一个数加1

 

排序可以采用:

 

qsort快排 —> 时间复杂度为: O ( n ∗ l o g 2 N ) O(n*log_2{N}) O(nlog2N)

 

但是用快排不满足题中要求的时间复杂度

✨ 思路二

写一个循环,从 0 + 1 + 2 + 3 + . . . + n 0+1+2+3+...+n 0+1+2+3+...+n ,然后再减去数组中的n个数的值

 

也就是 ( 0 + 1 + 2 + 3 + . . . + n ) − ( a [ 0 ] + a [ 1 ] + a [ 2 ] + a [ n − 1 ] ) (0+1+2+3+...+n) - (a[0]+a[1]+a[2]+a[n-1]) (0+1+2+3+...+n)(a[0]+a[1]+a[2]+a[n1])

 

那为什么要这样做呢?

 

很简单,如图:

数据结构(开篇)—— 复杂度详解_第9张图片

时间复杂度为 O ( n ) O(n) O(n),这样的话,就符合题目要求啦!

代码实现:

int missingNumber(int* nums, int numsSize){
    int sum = 0;//用作数组相加
    int Sn = 0;//用作0~n之间的数相加
    int ret = 0;//sum - Sn
    int i = 0;
    for(i = 0; i < numsSize; ++i)
    {
        sum += nums[i];//数组中的和
    }
    Sn = numsSize * (numsSize+1) / 2;//数列之和
    ret = Sn - sum;//数列和 - 数组和
    return ret;
}
✨ 思路三

数据结构(开篇)—— 复杂度详解_第10张图片

那么这个方法的空间复杂度为: O ( n ) O(n) O(n)

✨ 思路四

给一个值x=0

 

x先跟0~n所有值异或

 

x再跟数组中的每个值异或

 

最后x就是缺的那个数字

 

如图:

数据结构(开篇)—— 复杂度详解_第11张图片

这里再看我们的问题:

数据结构(开篇)—— 复杂度详解_第12张图片

该方法的时间复杂度为: O ( n ) O(n) O(n),空间复杂度为: O ( n ) O(n) O(n)

代码示例

int missingNumber(int* nums, int numsSize){
    int x = 0;
    
    //跟[0,n]异或
    for(int i = 0; i <= numsSize; ++i) //[0,n]之间的数,比数组的数多一个,所以用<=
    {
        x ^= i;
    }

    //再跟数组中的值异或
    for(int i = 0; i < numsSize; ++i)
    {
        x ^= nums[i];
    }

    return x;
}

‍ 旋转数组

题目链接:189. 轮转数组

数据结构(开篇)—— 复杂度详解_第13张图片

数据结构(开篇)—— 复杂度详解_第14张图片

分析:

是不是感觉没读懂题目?哈哈哈,刚开始我也是懵的,但其实很简单,往下看

数据结构(开篇)—— 复杂度详解_第15张图片

✨ 思路一

暴力求解,旋转 k 次

 

时间复杂度: O ( N ∗ K ) O(N*K) O(NK)

 

空间复杂度: O ( 1 ) O(1) O(1)

✨ 思路二

开辟额外空间,以空间换时间

数据结构(开篇)—— 复杂度详解_第16张图片

所以时间复杂度为: O ( N ) O(N) O(N)

 

空间复杂度为: O ( N ) O(N) O(N)

✨ 思路三

这种思路是一个非常牛的方法

数据结构(开篇)—— 复杂度详解_第17张图片

所以时间复杂度为: O ( N ) O(N) O(N)

 

空间复杂度为: O ( 1 ) O(1) O(1)

 

这种思路就满足我们的题目要求啦

代码示例

//写一个逆置算法
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等于数组的大小,相当于不旋转
    //如果k大于数组的大小,比如k=10, 10%7=3, 所以旋转10次和旋转3次是一样的
    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);
}

代码分析:

数据结构(开篇)—— 复杂度详解_第18张图片

数据结构(开篇)—— 复杂度详解_第19张图片

数据结构(开篇)—— 复杂度详解_第20张图片

☀ 总结

一道题有多种方法,那么我们不用实现,只需要分析出每种方法的复杂度,然后选择复杂度最优的方法即可;

这就是复杂度在实际中的意义!

以上就是关于算法的 时间复杂度空间复杂度 的详解!

你知道的越多,你不知道越多,我们下期见!

你可能感兴趣的:(数据结构艺术,算法,数据结构,时间复杂度,空间复杂度,c语言)