**数据结构(Data Structure)**是计算机存储、组织数据的方式,指相互之间存在一种或多种特定关系的数据元素的集合。
算法(Algorithm):就是定义良好的计算过程,他取一个或一组的值为输入,并产生出一个或一组值作为输出。简单来说算法就是一系列的计算步骤,用来将输入数据转化成输出结果。
数据结构和算法对于程序员来说是必不可少的一部分,也是检验一个程序员代码能力的体现。而在岗位招聘的时候,在笔试阶段中,一般采用Online Judge形式,且内容一般都是20-30道选择题,3-4道编程题,而在大厂中,则是直接给出3-4道编程,而编程题往往就考验一个程序员如何用编程语言利用数据结构和算法来演绎编程题目的要求,在面试中也会考验你数据结构和算法*,甚至让你手写几道编程。
由此可见数据结构和算法对于我们来说十分重要,下面有俩篇文章可以参考一下↓
https://www.zhihu.com/question/36579347/answer/217323640
https://www.zhihu.com/question/289795606/answer/485134867
下面我们就进入今日的正题学习啦
算法由上面可以知道就是定义良好的计算过程,而算法效率就相当于计算过程的计算效率,相当于一道数学题可以有很多解法,但有人就算得快,而算法效率也是一样,越快越好。
在【c语言】函数递归中,
long long Fib(size_t N)
{
if(N < 3)
return 1;
return Fib(N-1) + Fib(N-2);
}
我们可以看到斐波那契数列‘的递归实现方式非常简洁,但简洁一定好吗?那该如何衡量其好与坏呢?
算法在编写成可执行程序后,运行时需要耗费时间资源和空间(内存)资源 。因此衡量一个算法的好坏,一般
是从时间和空间两个维度来衡量的,即时间复杂度和空间复杂度。
时间复杂度的定义:在计算机科学中,算法的时间复杂度是一个函数,它定量描述了该算法的运行时间。所以说,我们所能计算的时间复杂度只是一个近似值,我们只能根据一个算法所花费的时间与其中语句的执行次数成正比例,算法中的基本操作的执行次数,做为算法的时间复杂度。
找到某条基本语句与问题规模N之间的数学表达式,就是算出了该算法的时间复杂度。
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中++count语句总共执行了多少次?
我们可以看到:
Func1 **执行的基本操作次数 :
F(N)=N2+2∗N+10
N = 10 F(N) = 130
N = 100 F(N) = 10210
N = 1000 F(N) = 1002010
你或许觉得这就是我们时间复杂度了,实际中我们计算时间复杂度时,我们其实并不一定要计算精确的执行次数,而只需要大概执行次数,那么这里我们使用**“大O的渐进表示法**”
大O符号(Big O notation**):是用于描述函数渐进行为的数学符号.
推导大O阶方法:
1、用常数1取代运行时间中的所有加法常数。
2、在修改后的运行次数函数中,只保留最高阶项。
3、如果最高阶项存在且不是1,则去除与这个项目相乘的常数。得到的结果就是大O阶。
使用大O的渐进表示法以后,Func1的时间复杂度为
**O(N2) **
N = 10 F(N) = 100
N = 100 F(N) = 10000
N = 1000 F(N) = 1000000
我们在进行最后的计算时,我们去掉了那些对结果影响不大的项,简洁明了的表示出了执行次数
因为在N的数值逐渐变大时,直到很大的时候,后面的面的那些值对我们总运行次数的影响已经非常小了。
在一些算法中算法的时间复杂度存在最好、平均和最坏情况:
最坏情况:任意输入规模的最大运行次数(上界)
平均情况:任意输入规模的期望运行次数
最好情况:任意输入规模的最小运行次数(下界)
例如:在一个长度为N数组中搜索一个数据x
最好情况:1次找到
最坏情况:N次找到
平均情况:N/2次找到
在实际中一般情况关注的是算法的最坏运行情况,所以数组中搜索数据时间复杂度为*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);
}
在这里我们可以看到有俩个循环,第一个循环出现了2N次,第二个循环出现了10次,
所以我们可以得到F(N)=2*N+10.
在上面提到的大O渐进法我们可以知道第三点3、如果最高阶项存在且不是1,则去除与这个项目相乘的常数,所以最后是F(N)=O(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);
}
这里我们可以看到俩层循环,分别是M次和N次。
由于我们对M 和N没有确切的值可以比较,我们不知道谁比较大,谁对谁的影响比较大。
所以俩个我们都要保留下来,结果是O(M+N).
void Func4(int N)
{
int count = 0;
for (int k = 0; k < 100; ++ k)
{
++count;
}
printf("%d\n", count);
在这里我们看到循环次数是100,为常数,通过推导大O阶方法,时间复杂度为 O(1)
strchir函数是一个字符串函数
该函数的作用是定位字符串中第一次出现该字符的位置,返回值是一个指针
const char * strchr ( const char * str, int character );
其实就是在字符串中查找一个字符,并返回它第一次出现的位置的地址。
而在这个函数值,寻找这个字符,可能面临着多种情况,就是上面所提到的大O渐进法的多种情况
可能是第1次就找到了,可能N次才找到,我们取最坏的情况,那就是O(N)
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-1次。
我们可以得出:
N-1
N-2
N-3
N-4
…
2
1
所以F(N)=(N-1)*N/2
这是最坏的情况,而最高的项是N2,除去常数,就是O(N2)
由上题我们可以发现上述代码中出现了两层循环,那是不是可以通过循环层级来判断时间复杂度呢?
并不是这样的。
for(int i=0;i<n;i++)
{
for(int j=0;j<2;j++)
printf("hehe\n");
}
如果是这样,第一个循环是N次,第二个循环是3次,
而结果会打印次hehe,其时间复杂度是O(N)
而不是N^2.
所以我们可以知道,在判断一个算法的时间复杂度的时候,要准确分析算法的思路,不能单纯的用循环的层数来判断时间复杂度
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; }
我们开始理解二分查找的思路通常画图可以很清晰:
我们可以得知二分查找也分为多种情况:
最好:O(1)
最坏:loN
而为什么是logN呢,
long long Fac(size_t N)
{
if(0 == N)
return 1;
return Fac(N-1)*N;
}
对于这个阶乘的递归函数而言,每次函数调用是O(1)
,时间复杂度主要看递归次数
对于N来说,递归需要N次,时间复杂度是O(N)
而如果把代码改一下
long long Fac(size_t N)
{
if(0 == N)
return 1;
for(size_t i=0;i<N,++i)
{
printf("%d",i);
}
printf("\n")
return Fac(N-1)*N;
}
时间复杂度却是O(N^2),
每次递归的次数:
N
N-1
N -2
N-3
最终相加就是N的等差数列之和,结果自然是O(N).
所以我们可以看到在递归算法时间复杂度计算中
如果每次函数调用是O(1),看递归次数
每次函数调用不是O(1),那么就看他递归调用中次数的累加
long long Fib(size_t N) {
if(N < 3)
return 1;
return Fib(N-1) + Fib(N-2);
}
每一次递归,总的来说是以2^N的量级增加的(x代表行数)
1 + 2 + 4 + 8 +………+2^{N-2}
所以一共有N-1项 ,用等比数列求和公式可以得出结果为2^N-1
所以时间复杂度是O(2^N)
PS:然后我们看图可以得出,当递归到底层时,右边其他层数的调用其实已经结束了。
会呈现出这种形态,而缺少的这部分对于总的运行来说影响不大,可以忽略掉。
空间复杂度也是一个数学表达式,是对一个算法在运行过程中临时占用存储空间大小的量度 。
和时间复杂度一样不是计算有多少时间,空间复杂度也不是程序占用了多少bytes的空间,所以空间复杂度算的是变量的个数。
注意:函数运行时所需要的栈空间**(存储参数、局部变量、一些寄存器信息等)****在编译期间已经确定好了,因此空间复杂度主要通过函数在运行时候显式申请的额外空间来确定。
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;
}
}
在计算空间复杂度的时候,我们可以看到算法中定义的变量占据了内存,
算法中变量的int exchange,size_t end,size_t i都i属于常数阶,
自然空间复杂度是O(1)。
要注意与参数是否有关
int arr[N];*//c99变长数组,和传过来的参数有关 ,呈线性增长
int* a=(int*)malloc(sizeof(int)*N);*//和传过来的参数有关,O(N), 呈线性增长
int* a=(int*)malloc(sizeof(int)*3);*//和传过来的参数无关,O(1)
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)
long long Fac(size_t N)
{ if(N == 0)
return 1;
return Fac(N-1)*N;
}
在函数递归中,每一次的递归都会开辟一个函数栈帧空间。
所以,这里就会建立N个栈帧空间,所以空间复杂度是O(N).
同样的我们来看斐波那契数列递归
long long Fib(size_t N)
{
if(N < 3)
return 1;
return Fib(N-1) + Fib(N-2);
}
我们可以知道他的时间复杂度是O(2^N),我们知道他的递归比较麻烦
那他的空间复杂度是多少呢,却是O(N)
这里是为什么呢,
时间一去不复返,是累积的,而空间回收以后可以重复利用
递归开辟的函数栈帧,是可以在后续继续调用fib函数时重复使用的。
但是,当我们的斐波那契数列递归太多次时的时候,栈空间不足,就会发生栈溢出(StackOverFlow)
对于时间复杂度和空间复杂度的理解可以很好的帮我们理解算法,
从而提升我们对算法的判断,从而选择效率更好的算法。