已经太久没用更新了,由于各种原因,导致很久没用更新了,但是停更期间我也是一直在很努力的学习与复习之前学过的知识,读了两本C语言的数据,初学者也是可以看的,推荐给大家,如果需要pdf,可以私信我
第一本:《C陷阱与缺陷》
第二本:《C语言深度剖析》
回归正题,今天开始正式学习数据结构,我将带领大家由浅入深的学习,不用害怕,因为我会把我认为比较难懂的知识学习好几遍,然后再给大家写出来。今天所讲的算法复杂度难度还好,不是很难,大家放心,不用担心学不会。
正式开始学习:
目录
[5.1 空间复杂度计算⽰例](#5.1 空间复杂度计算⽰例)
[5.1.1 ⽰例1](#5.1.1 ⽰例1)
[5.1.2 ⽰例2](#5.1.2 ⽰例2)
[7.1 旋转数组](#7.1 旋转数组)
1、算法复杂度
数据结构与算法介绍:
数据结构:数据结构的内存中存储数据的格式,指的是相互之间存在一种或多种特定关系的数据元素集合与数据元素之间的集合,一种数据结构不会适用于全部的用途,所以我们要学习数据结构,数据结构包括:数组、链表、线性表、哈希值等等。
算法:算法是指数据输入与输出时,在底层中是如何实现,有的算法对于程序运行快,有的慢,但实现的功能都是一样的。
总结:数据结构管理在内存中是如何放置的,算法是如何在内存中计算的
数据结构与算法的概念是不是特别的简单,所以大家不要听到数据结构这门课的名字就害怕自己学不会,我们把这些拗口的语言给打回原形,让它们看起来也没用这么高大尚。
2、如何学好数据结构
有小伙伴问了,我们应该如何学习数据结构呢,我这里给出两点的建议:
秘诀1:死磕代码!!!
秘诀2:画图画图画图+思考!!!
3、算法效率
3.1、什么是算法效率?
算法效率就是我们所写的代码运行的速度快慢,比如:有两个人一起学习,学习的时间地点都是一样的,第一个人学的很快,第二个人学的很慢,很明显,第一个人学习效率高,第二个人学习效率低,这就是因为学习方法不一样导致的,虽然这两个人都可以学会相同的知识。
我们所写的代码也是如此,两个人写同一道算法题目时候,一个人写的代码运行效率快,一个人写的代码运行效率慢,但是最终实现的结构都是相同的。
如何衡量⼀个算法的好坏呢?
案例:旋转数组https://leetcode.cn/problems/rotate-array/description/
思路:循环K次将数组所有元素向后移动⼀位
void rotate(int* nums, int numsSize, int k) {
while (k--) {
int end = nums[numsSize - 1];
for (int i = numsSize - 1; i > 0; i--) {
nums[i] = nums[i - 1];
}
nums[0] = end;
}
}
在我们刚学完C语言的同学们,肯定第一时间想到的是使用两个for循环进行嵌套到达交换,我也是第一时间想到的是两个for循环进行嵌套,那么这个写法到底对不对呢?有没有通过测试?
我们看见了,这个题目一共会验证38个输入数据,我们通过了0-36个输入数据,在进行第37个输入数据时候,显示超出计算时间,显然,我们算法实现的功能是没用问题的,主要的问题在于在进行很大数计算的时候,我们所写的代码运行时间超出了题目给定的时间限制,这时候我们就需要优化我们的算法了。
3.2、复杂度的计算
一般我们衡量一个算法的好坏是从时间与空间两个维度进行衡量的,分别成为时间复杂度与空间复杂度
时间复杂度是一个衡量算法运行快慢的
空间复杂度是衡量是指一个算法在运行时所需要的额外空间。
4、时间复杂度
定义:在计算机科学中,算法的时间复杂度是一个函数T(N),他定量描述了算法的运行时间,时间复杂度是该程序运行的时间快慢,那么我们为什么不直接计算程序的运行时间呢?
- 在相同的机器上,相同的代码分别放入新编译器与老编译器中,程序所执行的运行时间快慢是不一样的。
- 不同配置的电脑,在同一款编译器运行相同的代码,由于电脑的配置不一样,会导致计算出来的程序运行的速度快慢不一样
- 测试代码运行的时候,需要在代码完成以后才能进行测试,会耽误我们写代码。
总结:所以我们通过一个T(N)的算法模型,就可以大致判断代码的运行效率,可以大大提高我们代码的算法与书写效率。
案例:
前情提要:在我们计算T(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;
}
}
我们如何计算T(N)呢?
我们需要找到程序中导致程序运行速度的主要地方,但是我们这一次分析程序全部的运行次数,让大家方便理解。
- 在一个for循环中又嵌套一个for循环,而且这两个循环的判断语句都是i,j<N,所以我们最坏的运行情况是外循环执行N次,内循环执行N次。N*N
- 在第二个循环中,判断语句是2N,这是很好看出来的。2N
- 第三个是M=10,占用十次运算。10
得出来我们的T(N):N^2+2N+10.
如果N=10;
10^2+20+10=130
如果N=100;
100^2+200+10=10210
如果N=100000。
100000^2+200000+10= 10000200010
通过上述三个给N的数值,我们可以看出来,N^2对该代码的运行效率所产生的运行时间快慢影响是最大的,从而我们可以写出来该程序的时间复杂度是T(N^2)。
我们无需精确的计算程序每一条语句的计算时间,这是无意义的,因为对于计算机而言,执行的速度是很快的,我们人类认为很大的数字,计算机一下子就可以计算完毕。
上面我们看见当N不断变大时常数和低阶项对结果影响很小,所以我们只需要计算程序能代表最大增长量级的大概执行次数,复杂度的表示通常使用大O的渐进法来表示。
4.1、大O的渐进表示法
函数的渐近行为是描述算法的时间复杂度与空间复杂度随着输入规模增加时的变化趋势,他帮助我们理解在处理非常大的数据时,算法的性能表现。
大O符号:用于描述函数渐进行为的数学符号:
- 在时间复杂度T(N)中,只保留最高项,忽略低阶项,因为随着输入规模越来越大时,低阶项的影响越来越小
- 如果最高项存在且不是1,如:2N,那么就忽略常数项,直接写N。因为当N不断变大的时候,常数项的影响会越来越小。还用2N这个例子:当N位无穷大的时候,如果2无穷大也等于无穷大,所以我可以直接忽略
- 如果T(N)中没用N,是一个常数的话,那么我们就直接写成O(1),因为对于计算机来说,计算机运行的速度是非常快的,我们人类觉得大的数字,计算机很快就能运行完。
介绍完大O以后,大家还是不理解,那么我就先说出我学习这部分的疑惑,给大家解释一下:
1、N是什么?N是我们输入的数据,可以很大可以很小,比如我们第一个代码例子中,输入数据小的时候,可以通过测试,输入数据过大的时候,就会超时运行,所以N看作是一个变量
2、如果是O(10000000000000)那还要写成O(1)吗?是的,是需要写成O(1),还是那句话,对于我们人来说"10000000000000"这个数很大,但是对于机器来说,运行速度很快就可以完成运行。如果这里大家实在不理解,后续我会给出一张运行效率的图标,大家可以先试着背一下,后续再慢慢理解也是可以的,如果实在不理解,可以私信问我。
4.2时间复杂度计算示例
4.2.1、示例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);
}
Func2函数中的时间复杂度是2*N+10,在上述所说的大O符号表示法中,在2N中常数项2对代码运行效率影响是比较低的,同时常数10对代码影响也是较低,所以取对代码运行效率最大的,也就是我们刚刚所说的大O。
所以Func2函数中时间复杂度可以使用O(N)来表示。
4.2.2、示例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);
}
在Func3函数中,我们的输入变量一共有两个,在Func3中有三种大O的表示方法:
1、N>> M那么写成O(N)
2、M>>N那么写成O(N)
3、N≈M那么写成O(N+M)
注意:这不是循环嵌套,如果是循环嵌套那么写成O(N*M)。
4.2.3、示例3:
// 计算Func4的时间复杂度?
void Func4(int N)
{
int count = 0;
for (int k = 0; k < 100; ++k)
{
++count;
}
printf("%d\n", count);
}
在Func4函数中,很明显的一点就是,判断for循环的条件是K<100,Func4函数传进来的形参N并不会在运行这个函数中用到,在这个函数中时间复杂度是O(100),在上面所讲的大O的表示形式中第三条可以得到,Func3函数的时间复杂度为O(1)。
4.2.4、示例4:
// 计算strchr的时间复杂度?
const char* strchr(const char* str, int character)
{
const char* p_begin = str;
while (*p_begin != character)
{
if(*p_begin == '\0')
return NULL;
p_begin++;
}
return p_begin;
}
strchr函数是为了实现输入一个字符(character),在字符串(str)中查找我们所输入的字符是否在str中,如果存在那么就return p_begin;,否则就return NULL;
在这个函数中查找字符时候有三种时间复杂度T(N).
1、如果我们所需要查找的字符在字符串开头就找到了,那么时间复杂度表示T(N)=1;
2、如果我们所需要查找的字符在字符串中间找到了,那么时间复杂度表示T(N)=2/N;
3、如果我们所需要查找的字符在字符串末尾才找到了,那么时间复杂度表示T(N)=N;
我们可以得到大O分别为:O(1),O(N),O(N)
我们找出了这三种字符在字符串中查找出现的位置,我们上述所说,我们函数渐进式影响运行效率最大的数据,那么我们就可以很明显的得到为O(N).
4.2.5、示例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;
}
}
上述代码是冒泡排序,当外层循环一次,内存循环实现数据交换,这个函数并不是像两个一样由一个判断语句控制,所以分析这个代码的时候,要小心点。
每经历一次循环,end就会变小,交换的次数就会变少,所以这是一个等差数列,通过等差数列的公式我们可以得到,时间复杂度为O(N^2)。
4.2.6、示例6:
// 计算Func4的时间复杂度?
void func5(int n)
{
int cnt = 1;
while (cnt < n)
{
cnt *= 2;
}
}
Func5函数中,循环的判断语句是cnt<n。
我们假设n=10,我们循环的时候,第三次循环cnt=8还是cnt<n,当第四次循环cnt直接等于16了,此时cnt>n停止循环。
此时我们如何写时间复杂度呢?
可以使用log来表示 logn^16=4,此时系统就可以知道,我们执行了四次循环,时间复杂度我们就可以直接描述为O(logn),底数写不写都一样,计算机执行速度很快,对于速度影响不大。
4.2.7、示例7:
// 计算阶乘递归Fac的时间复杂度?
long long Fac(size_t N)
{
if (0 == N)
return 1;
return Fac(N - 1) * N;
}
递归的时间复杂度是由他递归调用的次数和每次调用计算开销来决定的,我们一般分析最坏情况下的递归深度和工作量
在这个函数中调用fac的时间复杂度是O(1),但是fac函数存在n次调用的情况,所以是O(N)。
5、空间复杂度
空间复杂度是在该函数中,除去计算机先前已经开辟好的空间,在该函数中额外需要再次开辟的空间。
5.1 空间复杂度计算⽰例
5.1.1 ⽰例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;
}
}
最为简单的理解计算,看系统在这段函数中开辟了几个栈帧,这个函数中开辟的栈帧为3个
因此最大空间复杂度为O(1)
5.1.2 ⽰例2
// 计算阶乘递归Fac的空间复杂度?
long long Fac(size_t N)
{
if (N == 0)
return 1;
return Fac(N - 1) * N;
}
在每次进行递归的时候,都会创建新的栈帧(额外的空间)
空间复杂度为:O(N)
6、常⻅复杂度对⽐
曲线越平缓性能越好,复杂度越小。
7、复杂度算法题
7.1 旋转数组
回到我们刚开始讲解的旋转数组题目,我们大致明白要怎么样减小时间复杂度了,如果能让我们之前所写的第一个代码的时间缩小一级,那会不会通过呢?
思路1
时间复杂度 O(n2)
循环K次将数组所有元素向后移动⼀位(代码不通过)
void rotate(int* nums, int numsSize, int k) {
while (k--)
{
int end = nums[numsSize - 1];
for (int i = numsSize - 1; i > 0; i--)
{
nums[i] = nums[i - 1];
} n
ums[0] = end;
}
}
思路2
空间复杂度 O(n)
申请新数组空间,先将后k个数据放到新数组中,再将剩下的数据挪到新数组中。
void rotate(int* nums, int numsSize, int k)
{
int newArr[numsSize];
for (int i = 0; i < numsSize; ++i)
{
newArr[(i + k) % numsSize] = nums[i];
} f
or (int i = 0; i < numsSize; ++i)
{
nums[i] = newArr[i];
}
}
思路3
空间复杂度 O(1)
• 前n - k个逆置:4 3 2 1 5 6 7
• 后k个逆置 :4 3 2 1 7 6 5
• 整体逆置 :5 6 7 1 2 3 4
void reverse(int* nums, int begin, int end)
{
while (begin < end) {
int tmp = nums[begin];
nums[begin] = nums[end];
nums[end] = tmp;
begin++;
end--;
}
} v
oid rotate(int* nums, int numsSize, int k)
{
k = k % numsSize;
reverse(nums, 0, numsSize - k - 1);
reverse(nums, numsSize - k, numsSize - 1);
reverse(nums, 0, numsSize - 1);
}