【数据结构初阶】--算法复杂度的深度解析

🔥个人主页:@草莓熊Lotso

🎬作者简介:C++研发方向学习者

📖个人专栏:************************************************************************************************************************************************************************************************************************************************************《C语言》《数据结构与算法》****************************************************************************************************************************************************************************************************************************************************************

⭐️人生格言:生活是默默的坚持,毅力是永久的享受。

**前言:在之前的博客中,我们一直都在一起学习C语言的一些相关知识点,为数据结构的学习打下了坚实的基础,到目前为止,C语言专栏的更新就暂时结束了 ,后续会为大家带来新的数据结构与算法专栏。那么这篇就是我数据结构专栏的第一篇博客,主要是给大家分享一下数据结构和算法的概念,数据结构的重要性,算法复杂度,空间复杂度等知识点(**注意:个人主页中的文章顺序可能会存在一些问题,建议想要顺序阅读的直接点进专栏里阅读就好了


文章目录

一.数据结构与算法的介绍

1.1--数据结构

1.2--算法

1.3--数据结构与算法的重要性

二.算法效率

2.1--复杂度的概念及其重要性

三.时间复杂度

3.1--大O渐进表示法

3.2--时间复杂度计算示例

四.空间复杂度

4.1--空间复杂度的计算示例

五.常见复杂度对比

六.复杂度算法题

6.1--旋转数组(三种思路)


一.数据结构与算法的介绍

1.1--数据结构

--数据结构(Data Structure)是计算机存储,**组织(增删查改)**数据的方式,指相互之间存在一种或多种特定关系的数据元素的集合。没有一种单一的数据结构对所有用途都有用,所以我们要学习各式各样的数据结构,如:线性表,树,图,哈希等,当然我们前面学习的数组也是。

1.2--算法

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

我们要知道,数据结构和算法是密不可分的,一道算法题也往往不止一种思路,但每种思路的算法复杂度也会有区别,这个在后面会有详细的讲述。

1.3--数据结构与算法的重要性

--数据结构与算法的重要性在校园招聘的笔试和面试中体现的非常明显,企业的笔试和面试对求职者的代码能力是有一定的要求的,特别是现在越来越多公司更注重编程题的考察,以此来更好的筛选人才。

学好数据结构的两个小秘诀:

  • **秘诀一:**死磕代码,我们在之前的C语言学习中对此应该体会很深,随着你代码写的越来越多,你的代码能力一定会有所提升的
  • **秘诀二:**画图+思考,有时候面对一些复杂的题,我们纯看题然后思考,可能会没有很清晰的思路,但是如果我们通过画图来解析这个题目的话,就往往能够很直观的明确这个题的思路了,可以说想要学好数据结构与算法,画图一定是必不可少的。

二.算法效率

--前面我们提到过一个题可以用不同的算法来解决,其中肯定有较优和较差的解法,那我们该如何区衡量一个算法的变化呢?

我们来通过一个题直观的了解一下--旋转数组**:** 189. 轮转数组 - 力扣(LeetCode)

思路:循环k次将数组所有元素向后移动一位

代码演示:

cpp 复制代码
void rotate(int* nums, int numsSize, int k) {
    while (k--) {
        // 向右轮转一次
        // 保存数组最后一个位置的数据
        int temp = nums[numsSize - 1];
        for (int i = numsSize - 1; i > 0; i--) // 这里其实比原来少一个数据
        {
            nums[i] =nums[i-1]; // 把最后一个元素存放好后,剩余的元素都向后移动一次。
        }
        nums[0] = temp; // 最后再把保存的数据存放在nums[0]中
    }
}

我们来画图理解一下:

这里是从后往前依次向后面前进的,这样不会覆盖掉需要的数据

那我们写的这个代码可以在力扣上自测运行并提交吗?

我们可以发现,自测运行是可以通过的,但是提交后会提示我们超过时间限制,那说明我们设计的算法不够好,效率不够高,我们需要通过算法复杂度来分析其好坏,效率高低。

2.1--复杂度的概念及其重要性

--算法在编写成可执行程序后,运行时需要耗费时间资源和空间(内存)资源 。因此衡量⼀个算法的好坏,⼀般是从时间和空间两个维度来衡量的 ,即时间复杂度和空间复杂度。**时间复杂度主要衡量一个算法的运行快慢,而空间复杂度主要衡量⼀个算法运行所需要的额外空间。**在计算机发展的早期,计算机的存储容量很小。所以对空间复杂度很是在乎。但是经过计算机行业的迅速发展,计算机的存储容量已经达到了很高的程度。所以我们如今已经不需要再特别关注⼀个算法的空间复杂度,另外在有些校园招聘的笔试算法题中对复杂度也有考察和要求。


三.时间复杂度

--定义:在计算机科学中,算法的时间复杂度是一个函数式T(N),它定量描述了该算法的运行时间。时间复杂度是衡量程序的时间效率,那我们为什么不直接计算程序的运行时间呢?

  • 因为程序运行时间和编译环境和运行机器的配置都有关系,比如同一个算法程序,用一个老编译器进行编译和新编译器编译,在同样机器下运行时间不同。
  • 同一个算法程序,用⼀个老低配置机器和新高配置机器,运行时间也不同。
  • 并且时间只能程序写好后测试,不能写程序前通过理论思想计算评估。

程序的执行时间 = 二进制指令运行时间 * 执行次数


假设时间是一定的(即常量)--影响不大,所以最重要的是执行次数
那么算法的时间复杂度是⼀个函数式T(N)到底是什么呢?这个T(N)函数式计算了程序的执行次数。通过对c语言编译链接的学习,我们知道算法程序被编译后生成二进制指令,程序运行,就是cpu执行这些编译好的指令。那么我们通过程序代码或者理论思想计算出程序的执行次数的函数式T(N),假设每句指令执行时间基本⼀样(实际中有差别,但是微乎其微),那么执行次数和运行时间就是等比正相关,这样也脱离了具体的编译运行环境。执行次数就可以代表程序时间效率的优劣。比如解决⼀个问题的算法a程序T(N) = N,算法b程序T(N) = N^2,那么算法a的效率⼀定优于算法b。

举例:

cpp 复制代码
//计算FUNC1的执行次数
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;
	}
}

Func1执行的基本操作次数 :

T(N)=N^2+2*N+10

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

通过对N取值分析,对结果影响最大的一项是N^2。

--在实际计算时间复杂度时,计算的也不是程序的精确执行次数,因为精确执行次数计算起来很麻烦而且意义不大,因为我们计算时间复杂度只是想比较算法程序的增长量级,即N不断变大时T(N)的差别,上面我们已经发现了当N不断变大时常数和低阶项对结果的影响很小,所以我们只需要计算程序能代表增长量级的大概执行次数,复杂度的表示通常使用大O的渐进表示法。

3.1--大O渐进表示法

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

⌨️推导大O阶规则:

  1. 时间复杂度函数式T(N)中,只保留最高阶项,去掉那些低阶项,因为当N不断变大时,低阶项对结果影响越来越小,当N无穷大时,就可以忽略不计了。
  2. 如果最高阶项存在且不是1,则去除这个项目的常数系数,因为当N不断变大,这个系数对结果影响越来越小,当N无穷大时,就可以忽略不计了。
  3. T(N)中如果没有N相关的项目,只有常数项,用常数1取代所有加法常数。

--通过以上方法我们可以得到Func1的时间复杂度为:O(N^2)

3.2--时间复杂度计算示例

示例1:

cpp 复制代码
// 计算Func2的时间复杂度?--O(N)
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:

cpp 复制代码
// 计算Func3的时间复杂度?--O(M+N)
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);
}

分析过程:

示例3:

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

分析过程:

示例4:

cpp 复制代码
// 计算strchr的时间复杂度?--O(N)
const char* strchr(const char
	* str, int character)
{
	const char* p_begin = s;
	while (*p_begin != character)
	{
		if (*p_begin == '\0')
			return NULL;
		p_begin++;
	}
	return p_begin;
}

分析过程:
💡总结:
通过上面我们会发现,有些算法的时间复杂度存在最好、平均和最坏情况。

  • 最坏情况:任意输⼊规模的最大运行次数(上界)
  • 平均情况:任意输⼊规模的期望运行次数
  • 最好情况:任意输⼊规模的最小运行次数(下界)

大O的渐进表示法在实际中⼀般情况关注的是算法的上界,也就是最坏运行情况。

示例5:

cpp 复制代码
// 计算BubbleSort的时间复杂度?--O(N^2)
void BubbleSort(int* a, int n)
{
	assert(a);
	for (int end = n; end > 0; --end)
	{
		int exchange = 0;
		for (int i = 1; i < end; ++i)
		{
			if (a[i - 1] > a[i])
			{
				Swap(&a[i - 1], &a[i]);
				exchange = 1;
			}
		}
		if (exchange == 0)
			break;
	}
}

先补充一下等差数列的求和公式: 分析过程:

示例6:

cpp 复制代码
//计算func5的时间复杂度--O(logn)
void func5(int n)
{
	int cnt = 1;
	while (cnt < n)
	{
		cnt *= 2;
	}
}

分析过程:

补充:logn,lgn,log2n(2在底下)

  • 当n接近无穷大时,底数的大小对结果影响不大。因此,⼀般情况下不管底数是多少都可以省略不 写,即可以表示为logn ,不同书籍的表示方式不同,以上写法差别不大,这里建议使用logn

示例7:

cpp 复制代码
// 计算阶乘递归Fac的时间复杂度?--O(N)
long long Fac(size_t N)
{
	if (0 == N)
		return 1;

	 return Fac(N - 1) * N;
}

分析过程:

--上面这些示例就是一些常见的时间复杂度推理情况了,目前来说把这几种掌握好就可以了,重要的是其中分析的思路和过程。


四.空间复杂度

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

4.1--空间复杂度的计算示例

示例1:

cpp 复制代码
// 计算BubbleSort的空间复杂度?--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;
	}
}

分析过程:

补充说明:申请的是什么类型的空间,几个字节影响不大。

示例2:

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

分析过程:

补充一个例子,看下用malloc额外申请空间的情况:

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


五.常见复杂度对比

通过上面这几个表格和图片,我们可以直观的看到不同复杂度的变化情况。

时间复杂度:(由低到高)

O(1) < O(logn) < O(n) < O(n^2) < O(2^N) <O (n!)

再在这里补充一个排序算法时间复杂度的表格,大家感兴趣的可以了解一下:


六.复杂度算法题

6.1--旋转数组(三种思路)

题目链接: https://leetcode.cn/problems/rotate-array/description/
思路一:
时间复杂度O(n^2),空间复杂度O(1)
大致想法:循环k次,每次将数组所有元素向后移动一位(时间复杂度太高,超时,代码不通过)
具体代码实现:

cpp 复制代码
void rotate(int* nums, int numsSize, int k) {
    while (k--) {
        // 向右轮转一次
        // 保存数组最后一个位置的数据
        int temp = nums[numsSize - 1];
        for (int i = numsSize - 1; i > 0; i--) // 这里其实比原来少一个数据
        {
            nums[i] =nums[i -1]; // 把最后一个元素存放好后,剩余的元素都向后移动一次。
        }
        nums[0] = temp; // 最后再把保存的数据存放在nums[0]中
    }
}

这种方法的思考过程,在前文中有详细介绍,这里就不再赘述了。这种算法因为时间复杂度太高,导致超时,那我们是否可以采用下别的方法呢,我们接着来看下一种思路。

思路二:

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

大致想法:申请一个新的数组空间,先将后k个数据放在新数组中,再将剩下的数组挪到新数组中。

具体代码实现:

cpp 复制代码
void rotate(int* nums, int numsSize, int k) {
    //创建新数组
    int newsize[numsSize];
    //遍历原数组,将数据轮转后放到newsize数组中
    for (int i = 0;i < numsSize;i++)
    {
        newsize[(i + k) % numsSize] = nums[i];
    }
    //将newsize数组中的数据导入到nums数组
    for (int i = 0;i < numsSize;i++)
    {
        nums[i] = newsize[i];
    }
}

解题过程:看图片+注释

我们再来看看思路2,会发现虽然时间复杂度降下来了,但是空间复杂度上去了,这种思想就是"空间换时间"在后续的数据结构与算法学习中,经常会使用。那我们再做进一步的思考,我们能不能在不牺牲空间的情况下,把时间复杂度降下来呢,接着来看看思路三吧。

思路三:

时间复杂度:O(N),空间复杂度:O(1)

大致思路:分3次逆置

  • 前n-k个逆置:4 3 2 1 5 6 7
  • 后k个逆置: 4 3 2 1 7 6 5
  • 整体逆置: 5 6 7 1 2 3 4

具体代码实现:

cpp 复制代码
//逆置
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) {
    k = k % numsSize;
    //前n-k个数据逆置
    reverse(nums, 0, numsSize - 1 - k);
    //后k个数据逆置
    reverse(nums, numsSize - k, numsSize - 1);
    //整体逆置
    reverse(nums, 0, numsSize - 1);
}

解题过程:

我们可以发现思路三,不仅将时间复杂度降了下来,也没有额外的牺牲空间,那么在上面的几种思路讲解中,我们都采用了画图加思考的方式来解决问题,非常直观以及方便。


往期回顾:(指针的六篇都可以看看,这里就不都挂上去了)

【C语言指针超详解(一)】--指针变量和地址,指针变量类型的意义,指针运算

【自定义类型-结构体】--结构体类型,结构体变量的创建和初始化,结构体内存对齐,结构体传参,结构体实现位段【C语言动态内存管理】--动态内存分配的意义,malloc和free,calloc和realloc,常见的动态内存的错误,动态内存经典笔试题分析,柔性数组,总结C/C++中程序内存区域划分

【通关函数的递归】--递归思想的形成与应用

【进阶】--函数栈帧的创建和销毁详解

**结语:**本篇文章就到此结束了,本篇博客是数据结构与算法专栏的第一篇,在下篇博客中会接着为大家分享数据结构与算法中的其它知识点。大家感兴趣的话可以先看一下往期回顾中的几篇文章,都是对于数据结构来说比较重要的一些C语言知识储备,如果文章对你有帮助的话,欢迎评论,点赞,收藏加关注,感谢大家的支持。

相关推荐
我命由我1234510 分钟前
Spring Boot 项目集成 Redis 问题:RedisTemplate 多余空格问题
java·开发语言·spring boot·redis·后端·java-ee·intellij-idea
fail_to_code15 分钟前
递归法的递归函数何时需要返回值
算法
Rocky40120 分钟前
JAVAEE->多线程:锁策略
java·开发语言·jvm
C137的本贾尼26 分钟前
(每日一道算法题)二叉树剪枝
算法·机器学习·剪枝
JSUITDLWXL1 小时前
ideal2022.3.1版本编译项目报java: OutOfMemoryError: insufficient memory
java·开发语言
magic 2451 小时前
Java建造者模式(Builder Pattern)详解与实践
java·开发语言·建造者模式
前端小崔1 小时前
前端面试题之ES6保姆级教程
开发语言·前端·javascript·面试·职场和发展·ecmascript·es6
吴声子夜歌1 小时前
OpenCV——Mat类及常用数据结构
数据结构·opencv·webpack
Love__Tay1 小时前
【学习笔记】Python金融基础
开发语言·笔记·python·学习·金融
笑口常开xpr1 小时前
数 据 结 构 进 阶:哨 兵 位 的 头 结 点 如 何 简 化 链 表 操 作
数据结构·链表·哨兵位的头节点