前言
我们的计算机能够进行数值运算,但是由于不同数据之间的联系很少,有的数据之间完全没有任何联系,所以我们很难利用这些单一的数据去处理实际生活中的复杂问题。不过如果我们人为地让这些数据存在特定的联系,那我们就有了一种处理问题的工具和材料。
这就和“巧妇难为无米之炊”一个道理,我们需要材料—数据结构,才能进行很多特殊的程序设计。
现实生活中,很多大型程序都应用了数据结构。
这一个专栏会带大家认识并且深入了解数据结构。
初识数据结构
数据结构:是相互之间存在一种或者多种特定关系的数据元素的集合
我们可以通过这种特殊的关系对一组数据进行快速的增加,删除,修改。
是不是感觉这写功能在我们以前学过的某个数据类型很相似?
没错,其实我们在很久以前就接触过一种最简单的数据结构------数组,数组中的每一个元素都是有关联的,我们可以通过下标去向数组中的任意位置写入数据,也可以从数组中读取任意一个下标的数据。
数据结构的分类
数据结构分为这几种类型:
-
线性结构 包括数组,链表,以及由他们衍生出来的栈,队列,哈希表
-
树状结构 比较有代表性的是二叉树
-
图结构 图结构的特点是一个元素可以对应多个元素
-
其他数据结构 由前面三种基本的结构变换而来,用于解决特殊的问题,比如:跳表、哈希链表、位图等。
注意:在C语言中数据结构的对应方式是通过指针来实现的。
数据结构与算法
同样拿烹饪做例子: 我们有了烹饪的材料和工具(数据结构),但是如果我们没有灵巧的技术(算法),那么我们还是很难做出优秀的作品
数据结构和算法的关系: 在现实中面对的大多数问题都需要数据结构和算法共同来解决, 我们利用高效的算法来解决问题,但是高效的算法又依赖于特殊的数据结构才能作用。
算法的效率:
我们利用高效的算法来提高我们代码的效率,而我们又怎样去判断一个算法是否高效呢?
我们利用时间复杂度和空间复杂度去衡量一个代码的性能,复杂度越低的代码拥有越高的效率。
时间复杂度
时间复杂度的概念
时间复杂度主要衡量一个算法的程序快慢。这个快慢不是我们平时理解的一个程序运行了多长时间,这是不准确的方法------这个时间会由于不同的运行环境产生不同的运行时间。
不同的环境包括:硬件不同,输入的数据不同等。
因为一个算法所花费的时间与其中语句执行次数正正比,所以我们可以用算法中语句的基本操作次数来作为算法的时间复杂度,
在计算机科学中:算法的时间复杂度是一个函数(数学里面的函数)。基本操作运行次数 = F(N) 这是一个以N为未知数的函数。
大O的渐进表示
这是一种复杂度的表示方法,我们计算时间复杂度时,我们其实并不一定要计算精确的执行次数,而只需要大概执行次数 。
大O符号(Big O notation):是用于描述函数渐进行为的数学符号。
一般用O() 来表示复杂度例如:O(1) 、O(N) 、O(N^2) 等
为什么可以渐进表示
我们来看下面这个表格
参数N \ 执行次数 | F(1) | F(N) | F(N^2) | F(N^2+N) |
---|
10 | 无影响 | 10 | 100 | 110 | 100 | 无影响 | 100 | 10000 | 10100 | 1000 | 无影响 | 1000 | 1000000 | 1001000 | 10000 | 无影响 | 10000 | 100000000 | 100010000 |
我们可以看出当我们的参数越大时,低阶影响的操作次数在全部次数中占比越来越低。
为了可以方便地表示出时间复杂度,我们就采用渐进表示方法(将操作次数与未知数的关系大致表示出来)。
一般我们采用大O阶渐进表示,下面是推导方法:
大O阶推导方法
- 用常数1取代运行时间中的所有加法常数。
例子:
void Func(int N)
{
int i = 0;
for(i = 0; i< 10;i++)
{
printf("%d",i);
}
}
我们来计算这个程序的时间复杂度并且使用大O表示发将复杂度表示出来:
分析: 这个程序的执行次数和变量N没有任何关系,即无论我们传参过去的值是多少,我们的基础操作执行次数都是一定的,这样的代码时间复杂度是最低的,时间复杂度 = 常数 ,而我们的第一条推导方法就是:用常数1取代运行时间中的所有加法常数。
所以我们这个程序的时间复杂度是**O(1) **
-
在修改后的运行次数函数中,只保留最高阶项。 例子1:
void Func(int N)
{
int i = 0;
for(i = 0; i< 10;i++)
{
printf("%d",i);
}
while(N--)
{
i++;
}
}
分析: 这段代码的执行次数与参数N有关系,时间复杂度 = N+常数 ,根据我们的第二个推导方式:我们保留最高阶项数结果是O(N) 。
所以这一个算法的时间复杂度是O(N)。
如果是这样的代码呢?
例子2:
void Func1(int N)
{
int count = 0;
int num = N*N;
while(num--)
{
++count;
}
for (int k = 0; k < N ; ++ k)
{
++count;
}
int M = 10;
printf("%d",count);
}
分析: 现在我们知道:在计算时间复杂度的时候,只需要去关注和参数有关的操作。 在这段代码中,参数是N,循环1中会循环N*N次,循环2会循环N次。 所以该算法中执行的基础操作次数=N*N+N ,不过由于我们只会保留最高阶
所以该算法的时间复杂度是O(N)
- 如果最高阶项存在且不是1,则去除与这个项目相乘的常数。得到的结果就是大O阶
例子:
void Func1(int N)
{
int count = 0;
for (int i = 0; i < 2*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);
return 0;
}
分析:
现在我们知道只需要去计算最高阶项,这个例子中最高阶项是2*N*N ,在第三条规则中,我们会将最高阶项的系数变为1,剩下的就是该算法的时间复杂度。
所以该例的时间复杂度是O(N^2);
时间复杂度的计算
上面我们在介绍大O渐进表示法的时候已经学会了简单的时间复杂度计算了,为了可以对时间复杂度有更清晰的认识,这里举了几个较为复杂的例子:
例子1:
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两个值的变化而变化,我们排除了对时间复杂度影响不大的次数,所以该算法的时间复杂度是O(M+N)
注意:如果参数中M>>N,那么该算法的时间复杂度可以表示为O(M); 如果参数中N>>M,那么该算法的时间复杂度可以表示为O(N); 如果参数中N约等于M,那么该算法的时间复杂度可以表示为O(M)或者O(N); 原因:我们在计算时间复杂度的时候,不需要计算出十分精确的数,我们可以排除掉对结果影响不大的操作。
时间复杂度的表示:在括号里面的字符不一定是N,也可以是O(M)。只需要满足该字符是未知数即可。
例子2:
const char * strchr ( const char * str, int character );
该函数会在一个字符串中依次寻找character 字符,如果找到就就返回该处的地址,如果整个字符串中都没有找到,就会返回空指针。
注意:
有些算法的时间复杂度存在最好、平均和最坏情况:
- 最坏情况:任意输入规模的最大运行次数(上界) 。
- 最好情况:任意输入规模的最小运行次数(下界) 。
- 其他。
**在实际中一般情况关注的是算法的最坏运行情况,所以数组中搜索数据时间复杂度为**O(N)
所以该例子的时间复杂度是O(N)。
例子3:
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^2)
冒泡排序的空间复杂度计算:我们按照最坏的情况打算:F(N) = (N-1)*N/2 ,渐进表示法是O(N^2)
例子4:对一个已经有序的数组进行二分查找:
int BinarySearch(int* a, int n, int x)
{
assert(a);
int begin = 0;
int end = n;
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(logN)
注意:如果时间复杂度为O(log 2 N) ,那么我们可以简写为O(logN) 。
例子4:一个递归程序的时间复杂度计算
long long Fac(size_t N)
{
if(0 == N)
{
return 1;
}
return Fac(N-1)*N;
}
该算法一共经过了N次函数调用,每一次函数调用都算以此基本操作
递归算法时间复杂度计算:
-
如果每次调用的函数的时间复杂度是O(1),那么整个算法的时间复杂度就看它的递归次数。 -
如果每次调用的函数的时间复杂度不是O(1),那么整个算法的时间复杂度是每次递归中操作次数的累加和。
例如
long long Func(int n,int count)
{
int i = 0;
for( i = 0; i< n;i++)
{
count++;
}
if(0 == n)
{
return 0;
}
return Func(n-1,count);
}
这个算法中每一次调用的函数时间复杂度都是O(N),那么整个算法的时间复杂度就是O(N^2);
例子5:计算斐波那契数列数列算法的时间复杂度
long long Fib(size_t N)
{
if(N < 3)
{
return 1;
}
return Fib(N-1) + Fib(N-2);
}
这个例子也是递归函数,每一次调用时都只是进行了常数次操作,所以我们在计算时间复杂度的时候只需要计算该算法递归的总次数:
这与一个等比数列相似
由于我们不需要计算出很精确的执行次数,所以我们仍然可以将这个等比数列求和,所得的和作为该算法的时间复杂度:
F(N) = 1 + 2 + 4 +…+2^(N-1) 渐进表示法的时间复杂度为:O(2^N)
如果一个算法的时间复杂度是O(2^N),那么这个算法是糟糕的,因为当我们的未知数仅仅为30时,程序就会进行1073741824次操作。当未知数很大时,程序会奔溃。
空间复杂度
我们衡量一个算法的效率的时候还需要靠空间效率
空间复杂度的概念
空间复杂度主要通过函数再运行时显示申请的额外空间来确定的(函数运行时的占空间已经再编译期间就已经确定好了)
空间复杂度也是用大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;
}
}
这个排序算法最多开辟了三个新的空间
-
size_t end -
int exchange -
size_t i
唯一有疑惑的可能是变量exchange:因为这是一个局部变量,再每次处理作用与后,这个空间就销毁了,但是第二次进入循环后有需要开辟一个空间,每次开辟空间都再内存中的同一个位置,所以我们计算额外的空间的时候只需将该变量视为一个额外空间 如下图:每一次循环结束,变量i的空间都会被销毁,每一次进入循环,就会为变量i开辟一个空间,但是每次开辟的其实是同一块空间:
空间数量不会随参数的变化而变化,所以该算法的空间复杂度是O(1)
哪种情况下的空间复杂度是O(N)呢?
下面是几个十分经典的例子:
例子2:消失的数字
这里我们有leetcode上面的一道题来解释:
这道题我们采用一种映射的方法:
首先动态开辟一个数组:
int* arr = (int*)malloc(sizeof(int)*(numsSize+1));
这里我们额外开辟的空间个数由参数传递到过来的数组的元素个数来决定
参数数组元素个数 | 创建的新数组元素个数 |
---|
20 | 21 | 50 | 51 | 10000 | 10001 | N | N+1 |
所以这个算法的空间复杂度是O(N)
如果我们将动态开辟空间的代码改为:
int* arr = (int*)malloc(sizeof(int)*10000)
这里我们创建了一个有10000个int 类型 元素的动态数组,由于这个开辟出来的空间的大小不会因为参数的改变而改变,每一次都是不变的大小,所以此时该算法的空间复杂度为O(1)。
例子3:斐波那契数列的空间复杂度
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)。
例子4:递归函数的空间复杂度
long long Fac(size_t N)
{
if (N == 0)
return 1;
return Fac(N - 1) * N;
}
对于递归函数,空间复杂度不会是O(1)
分析:
递归函数每一次调用都会创建函数栈帧
这里调用了N此函数,并且每次函数都创建了一个变量,所以这里的空间复杂度是O(N)
重点:时间是累积的,无法重复利用的,空间是可以重复利用的
例子5:斐波那契数列的空间复杂度
这是一个斐波那契额数列,计算它的空间复杂度:
long long Fib(size_t N)
{
if (N < 3)
return 1;
return Fib(N - 1) + Fib(N - 2);
}
首先我们了解这样一个概念:
当一个函数在调用的时候,会创建该函数的函数栈帧,当这个函数调用结束的时候,它所对应的函数栈帧会销毁。
从这里可以看出,两次不同的函数调用中创建 了不同的变量a和b,但是a,b却有相同的地址,证明了两次函数调用擦黄健的函数栈帧时同一块。
对于刚才的斐波那契数列算法也能用这个原理解释出来。
分析:
首先我们利用一个准确的数值来代替参数N,例如我们将参数N赋为4,我们 来观察该算法中函数栈帧的创建和销毁:
以此,我们可以判断出该算法会额外创建出N-1个函数栈帧,利用大O渐进表示法:该算法的空间复杂度是:O(N)
时间与空间的取舍
人们之所以花大力气去评估算法的时间复杂度和空间复杂度, 其根 本原因是计算机的运算速度和空间资源是有限的
我们都想要去找到一个时间复杂度和空间复杂度都很低的方法去解决问题,但是大多数时候,这是呈现出鱼和熊掌不可兼得的状态,我们不得不在空间复杂度和时间复杂度之间进行取舍
在计算机发展的早期,由于计算机的容量很小,所以很在意空间复杂度。但是现在的计算机储存容量已经十分高,所以我们不需要再特别关注空间复杂度。
在绝大多数时候,时间复杂度比空间复杂度更为重要一些,我们宁愿多花一点内存空间,也希望提升程序的执行速度。
|