Hello~,欢迎大家来到我的博客进行学习!
目录
- 1.数据结构
- 2.算法
- 3.算法效率
- 4.复杂度
- 4.1复杂的概念
- 4.2 时间复杂度
- 4.2.1 定义
- 4.2.2 大O的渐进表示法
- 实战:冒泡排序的时间复杂度
1.数据结构
数据结构(Data Structure)是计算机存储、组织数据的方式,指相互之间存在⼀种或多种特定关系的数据元素的集合。没有一种单一的数据结构对所有用途途都有用,所以我们要学各式各样的数据结构,如:线性表、树、图、哈希等。
简而言之:数据结构用来存储数据,还可以用来管理数据。
之前学过的数组为最基本的数据结构,放同类型的数据。既然有管理的功能,我们就可以对里面的数据进行修改,例如:添加、删改。但是数组并不能满足所有的需求,我们需要多种数据结构来承载不同的数据。就好像是生活中我们需要不同的鞋子来进行不同的运动一样。
2.算法
算法(Algorithm):就是定义良好的计算过程,他取一个或一组的值为输入,并产生出一个或一组值作为输出。简单来说算法就是一系列的计算步骤,用来将输入数据转化成输出结果。
简单理解就是假设我们需要对一组整型数据进行排序,我采用冒泡排序来完成这一功能,此时我使用的就是一个排序算法,当然其他人可能会使用其他的方式来完成,此时就会有不同的算法,我们会依靠算法的复杂度来判断哪一个算法比较好。
简而言之:我们对一个问题的实现方法就是算法。
这里我将数据结构和算法放在一起解释是因为数据结构和算法不分家,我们利用算法将数据存储到数据结构上,有算法的地方我们需要对数据进行存储和管理,此时就需要数据结构。
3.算法效率
我们可能会想如何衡量一个算法的好坏?是看代码的简洁度吗?我们可能会想递归看起来很简洁,但是使用递归的代码不一定就好。我们首先会想到看算法的执行时间,当然这是不可以的,算法的执行时间是无法估计出来的。
举例:
#include<stdio.h>
int main()
{int t1 = clock();//clock计算程序执行开始到当前代码的时间for (int i = 0; i < 100000; i++){for (int j = 0; j < 100000; j++){int a = 1;}}int t2 = clock();printf("%d\n", t2 - t1);//总时间return 0;
}
运行结果:
我们可以看到三次的时间并不相同,所以我们并不能直接指出这个算法的执行时间。这个执行时间与编译器和系统的cpu有关。经过实践我们发现直接看程序的执行时间是不行的。
现在上题目进行理解:
轮转数组
给定一个整数数组 nums,将数组中的元素向右轮转 k 个位置,其中 k 是非负数。
现在有一个数组nums,依照图例我们可以找到轮转的方式为(最后一个数字往前放):
我们需要存储最后一个数字7,并把前面的数据整体往后移动。创建int类型的endNum来存储最后一个数据,我们可以利用下标和for循环来实现数据的整体移动,让nums[ i ] =nums[ i-1 ],i 不断的 - - ,一直到i < 0的时候结束循环。此时数字最前面的位置就空了出来,我们可以把之前存储的endNum放到最前面。我们现在对一次轮转已经很清楚了,题目要求进行k次轮转,我们利用while循环,里面的条件写k - - 就行。
代码:
void rotate(int* nums, int numsSize, int k) {while (k--) {int endNum = nums[numsSize - 1];for (int i = numsSize - 1; i > 0; i--) {nums[i] = nums[i - 1];}nums[0] = endNum;}
}
运行结果:
提交结果:
我们的代码在运行时通过了,但是提交的时候却出现了超出时间限制的问题,我们看看题目的提醒事项。
我们可以看到,对于nums数组的大小的范围,里面元素大小的范围,以及轮转的次数的范围。他们都是很大的数字,我们的代码对于一些小范围的值可以正常的运行,对于数据比较庞大的时候我们的代码虽然可以运行,但是执行速度会很慢。这表明了我们的代码虽然可以写出来可以运行,但是不一定好!我们就需要复杂度来衡量算法的好坏。
4.复杂度
4.1复杂的概念
算法在编写成可执行程序后,运行时需要耗费时间资源和空间(内存)资源 。因此衡量一个算法的好
坏,一般是从时间和空间两个维度来衡量的,即时间复杂度和空间复杂度。
时间复杂度主要衡量一个算法的运行快慢,而空间复杂度主要衡量一个算法运行所需要的额外空间。
在计算机发展的早期,计算机的存储容量很小,所以对空间复杂度很是在乎。但是经过计算机行业的
迅速发展,计算机的存储容量已经达到了很高的程度。所以我们如今已经不需要再特别关注一个算法
的空间复杂度。
4.2 时间复杂度
4.2.1 定义
在计算机科学中,算法的时间复杂度是⼀个函数式T(N),它定量描述了该算法的运行时间。时
间复杂度是衡量程序的时间效率。
不采用程序的运行时间的原因前文中我们已经有提及,这里我们进行总结:
- 因为程序运行时间和编译环境和运行机器的配置都有关系,比如同⼀个算法程序,用一个老编译
器进行编译和新编译器编译,在同样机器下运行时间不同 - 同一个算法程序,用一个老低配置机器和新高配置机器,运行时间也不同
- 时间只能程序写好后测试,不能写程序前通过理论思想计算评估
T(N)函数式计算了程序的执行次数。我们知道算法程序被编译后生成⼆进制指令,程序运行就是cpu执行这些编译好的指令。那么我们通过程序代码或者理论思想计算出程序的执行次数的函数式T(N),假设每句指令执行时间基本⼀样(实际中有差别,但是微乎其微,这里直接忽略),那么执行次数和运行时间就是等比正相关,这样也脱离了具体的编译运行环境。执行次数就可以代表程序时间效率的优劣。
假设算法a程序T(N) = N,算法b程序T(N) = N^2,那么算法a的效率⼀定优于算法b。
现在我们直接上实例直接运用。
请计算⼀下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;}}
int count =0 ;执行一次,第一个for循环并进行嵌套的语句执行 n * n 次,第二个for循环执行2n次,但是前面一开始的语句int count =0;并不需要进行计数,只执行一次我们可以忽略不计,最后的while执行10次。所以我们可以得出表达式T(n)= n ^2 +2n+10。但是我们是粗略的估计,我们并不需要给一个固定的值n,计算出一个定值。
实际中我们计算时间复杂度时,计算的也不是程序的精确的执行次数,精确执行次数计算起来还是很麻烦的(不同的一句程序代码,编译出的指令条数都是不⼀样的),计算出精确的执行次数意义也不大,因为我们计算时间复杂度只是想比较算法程序的增长量级,也就是当N不断变大时T(N)的差别,上面我们已经看到了当N不断变大时常数和低阶项对结果的影响很小,所以我们只需要计算程序能代表增长量级的大概执行次数,复杂度的表示通常使用大O的渐进表示法。
4.2.2 大O的渐进表示法
我们可以按照以下规则来推导大O阶:
- 时间复杂度函数式T(N)中,只保留最高阶项,去掉那些低阶项,因为当N不断变大时,低阶项对结果影响越来越小,当N无穷大时,就可以忽略不计了。
- 如果最高阶项存在且不是1,则去除这个项目的常数系数,因为当N不断变大,这个系数对结果影响越来越小,当N无穷大时,就可以忽略不计了。
- T(N)中如果没有N相关的项目,只有常数项,用常数1取代所有加法常数。
根据以上规则,我们可以将T(n)= n ^2 +2n+10中的2n+10去除化为T(n)= n ^2。我们时间复杂度不能用一个函数式来精确的表示,所以我们的时间复杂度为O(n ^2),()里面是影响当前算法的输入条件n。
学会之后我们可以用一些例子来练练手。
示例1
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的时间复杂度为O(n),注意第二条规则。
示例2
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);
}
T(n) = m+n;这里(n)和表达式里面的n是不一样的,(n)是可变的输入条件。在我们的表达式中有两个可变的条件m和n,(n)可以表示我们表达式中的m和n。所以Func3的时间复杂度为O(m+n)。
现在我们对这两个可变的条件进行进一步的讨论,这里我们假设3个情况。
时间复杂度:
- m >> n: O(m)
- n >> m: O(n)
- n == m:O(m)或O(n)
示例3
void Func4(int N)
{
int count = 0;
for (int k = 0; k < 100; ++ k)
{
++count;
}
printf("%d\n", count);
}
Func4的时间复杂度为O(1),参考规则三。这里的1不是表示执行一次,而是表示常数。
示例4
计算strchr的时间复杂度
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;
}
在这里我们需要找字符在字符串中出现的位置。对于一个字符串abcdef,我们的查找的次数的范围为:查找一次到查找字符串的长度次。对于一个长度为n的字符串,查找最前面的字符,我们需要查找常数次;查找后面的字符我们需要查找n次;查找中间的字符,我们需要查找n/2次,利用规则2我们可以视为查找n次。
以上三种情况对我们的时间复杂度的影响还是比较大的,所以我们需要划为三种情况来讨论。
strchr的时间复杂度分为:
- 最好情况: O(1)
- 最坏情况: O(N)
- 平均情况: O(N)
通过以上的示例,我们可以做以下总结:
有些算法的时间复杂度存在最好、平均和最坏情况。
- 最坏情况:任意输入规模的最大运行次数(上界)
- 平均情况:任意输入规模的期望运行次数
- 最好情况:任意输入规模的最小运行次数(下界)
大O的渐进表示法在实际中一般情况关注的是算法的上界,也就是最坏运行情况。
只要最坏的运行情况下时间复杂度都比较好的话,我们就可以认为这个算法比较好。
实战:冒泡排序的时间复杂度
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) 中已经进行了详细的讲解。外层的for循环控制进行几次冒泡排序;内存循环控制的是当前的冒泡,功能是比较大小然后进行交换。这里我们是升序。
我们可以用简单的例子进行理解,假设我们现在有5个乱序的整型数字:8,4,9,3,5
这里我们可以看到外层一共是要冒四趟,内层比较的次数每次都在减少。第一趟需要比较4次,第二趟需要比较3次,第三趟需要比较两次,第四趟需要比较1次,我们就可以把所有的数据排完。合计需要排序的次数是4+3+2+1=10次。
如果有n个数据,需要冒泡n -1次,内层的比较次数从n -1 依次减少,一直到1。
合计 n -1 + n - 2 + n - 3 +…+ 1=
根据规则去除低阶项,去除高阶项的系数,时间复杂度为O(n^2)。——最差情况
当数据有序的时候,根据代码会break跳出循环,只需要比较n - 1次,时间复杂度为O(n)。
示例5
void func5(int n)
{int cnt = 1;while (cnt < n){cnt *= 2;}
}
假设 n = 8
- 1 < 8 cnt = 2
- 2 < 8 cnt = 4
- 4 < 8 cnt = 8
需要循环3次。
n和执行次数(x)之间的关系为:
n = 2 ^ x
所以假设为n,需要循环的次数为:
时间复杂度为:O(log2 n)
注意:
在一些书籍中,我们虽然算出是以2为底,但是书上写的是lg n或log n (把2省略了),这在数学中是不对的。但是在数据结构中是正确的。当n接近无穷大时,底数的大小对结果影响不大。因此,一般情况下不管底数是多少都可以省略不写,即可以表示为 log n。
示例6
计算阶乘递归Fac的时间复杂度
long long Fac(size_t N)
{if (0 == N)return 1;return Fac(N - 1) * N;
}
递归开始时会创建当前的函数栈帧,n不等于0会继续向下递归,调用Fac(n - 1),此时又创建一个函数栈帧同时前面一个函数栈帧并没有销毁,往后的道理是一样的。一直创建到n为0的函数栈帧。创建的次数就是递归调用的执行次数,递归调用多少次就是最终的执行次数。合计为n次,所以时间复杂度为O(n)。
总结·:在阶乘算法中,时间复杂度即递归调用的次数。
好了,我们的数据结构知识就讲到这里。如果文章内容有误,请大佬在评论区斧正!谢谢大家!