引入
首先看一个菲波那契数列的算法
int Fib(int N)
{
if (N < 3)
return 1;
else
return Fib(N - 1) + Fib(N-2);
}
写完这个算法后,首先思考一下这个算法是不是最好的?不是,是否还有什么可以改进的地方,其实,衡量一个算法的好坏,是用算法的复杂度来衡量的。
正文
1.算法效率
算法效率分为两种:第一种是时间效率,第二种是空间效率。时间效率被称为时间复杂度,二空间效率被称为空间复杂度。时间复杂度主要衡量的是一个算法的运算速度,而空间复杂度主要衡量的是一个算法所需要的额外空间,在计算机发展的早期,计算机的存储容量很小。所以对空间复杂度很重视。但是经过计算机行业的发展,计算机的存储容量已经达到了很高的程度。所以如今我们已经不需要特别关注一个算法的空间复杂度了。
2.时间复杂度
2.1时间复杂度概念
定义:在计算机科学中,算法的时间复杂度是一个函数,它定量的描述了该算法的运行时间。一个算法执行所耗费的时间,从理论上说,是不能算出来的,只有你吧你的程序放在机器上跑起来,才能知道。但是我们需要每个算法都在机器上测试吗?是可以都测试,但是很麻烦。所以才有了时间复杂度这个分析方式。一个算法所花费的时间与其语句的执行次数成正比,算法中的基本操作的执行次数,为算法的时间复杂度。
2.2大O的渐进表示法
我们先来看一个简单程序考虑一下该程序计算的次数:
// 请计算一下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;
}
printf("%d\n", count);
}
FNC1执行的基本次数:
N = 10 F(N) = 130
N = 100 F(N) = 10210
N = 1000 F(N) = 1002010
基本语句: ++count ———>该语句的总的执行次数
在这里,我们可以用这个数学表达式表示该程序的时间复杂度。它是关于问题规模N的数学表达式,计算算法中某条语句的总的执行次数。这里我们用大O的渐进表示法来表征一个算法的计算次数。
大O符号(Big O notation): 用于描述函数渐进行为的数学符号。
推导大O阶方法:
1. 用常数1取代运行时间中的所有加法常数。
说明:如果计算的F(N) = 100 则大O阶方法表示为:
O(F(N)) = O(100)——>O(1)
也就是说,O(常数)就可以替换为O(1)。
2. 在修改后的运行次数函数中,只保留最高阶项。
说明:O(F(N))——>O(N^2)
3. 如果最高阶项存在且不是1,则去除与这个项目相乘的常数。得到的结果就是大O阶。
使用大O的渐进表示法以后,Func1的时间复杂度为:
N = 10 F(N) = 100
N = 100 F(N) = 10000
N = 1000 F(N) = 1000000
通过上面我们会发现大O的渐进表示法去掉了那些对结果影响不大的项,简洁明了的表示出了执行次数。
另外有些算法的时间复杂度存在最好、平均和最坏情况:
最坏情况:任意输入规模的最大运行次数(上界)
平均情况:任意输入规模的期望运行次数
最好情况:任意输入规模的最小运行次数(下界)
例如:在一个长度为N数组中搜索一个数据x
最好情况:1次找到
最坏情况:N次找到
平均情况:N/2次找到
说明:时间复杂度是以最差的情况为准——>最差情况是一个人对事物承受的最低限度
问题:时间复杂度为什么要以总次数作为参考,而不是时间参考?
因为时间没有办法去衡量一个算法的好坏,对于不同的系统,不同的编译器,电脑的硬件配置也是不尽相同的,同一个代码放到不同的环境中运算的时间是不同的,所以用时间作为参考是不合理的。
2.3常见时间复杂度计算举例
举例1:
未优化斐波那契数列的时间复杂度?
int Fib(int N)
{
if (N < 3)
return 1;
else
return Fib(N - 1) + Fib(N-2);
}
二叉树分析:
时间复杂度为O(2^N)
改进,让时间复杂度为O(N),因为只有一个循环,所以时间复杂度为O(N),代码如下:
unsigned long long Fib(int N)
{
unsigned long long first = 1, second = 1;
unsigned long long ret = 1;
for (int i = 2; i < N ; i++)
{
ret = first + second;
first = second;
second = ret;
}
return ret;
}
int main()
{
printf("%lld ", Fib(10));
return 0;
}
举例2:
// 计算Func1的时间复杂度?
void Func1(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);
}
Func1: F(M,N) = M + N 大O表示: O(F(M,N)) = O(M+N)
举例3:
计算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;
}
}
冒泡排序可分为两步:
1. 外循环:冒泡的躺数
2. 内循环:具体的冒泡方式——>相邻元素比较,不满足则进行交换
因此总的时间复杂度 = 单趟冒泡的时间 * 冒泡总躺数
F(N) = O(N) * O(N-1) = O(N^2)
冒泡排序的具体分析跳转这一篇博客:https://blog.csdn.net/aaaaauaan/article/details/104946701
举例4:
计算二分查找的复杂度?
在这里说明一下二分查找:
首先来说明一下二分查找的具体流程,这里主要说明的是while()循环括号中两种情况,因为while循环中不同条件影响后面的代码:
这里面的size是数组元素的总大小
情况一 int left = 0; int right = size - 1;
这种情况左右都是闭区间,left <= right ,原因如下:
假设我们要找的数字是9,第一次二分查找后,9比5大left = ++mid,right不变,结果是:
第二次二分查找后,9比(7+8)/2的结果大,left = ++mid(此时mid取8的下标),结果是:
注意,我们要找的数字是9,如果left < right,此时循环会跳出,找不到数字9,所以这种情况下是while(left <= right)
关于中间值arr[mid]和要查找的值大小相关的细节问题。
if第一次查找的值比中间值5大,那下一次的查找区间到底要不要包含5呢?答案是不需要。因为第一次查找的时候包含5了,5不是我们想要的查找的数,为了避免重复查找, left = mid + 1
else第一次查找的值比中间值5小,这次变的是右边,right = mid - 1
情况一 代码如下:
int binarysearch(int arr[], int data, int size)
{
int left = 0;
int right = size - 1;
while (left <= right)
{
int mid = left + ((right - left) >> 1);
if (data == arr[mid])
return mid;
else if (data < arr[mid])
right = mid - 1;
else
left = mid + 1;
}
return -1;
}
情况二 int left = 0; int right = size;
First,开始还是讨论while(),中括号里面的左右比较,这次范围是left < right,因为这次右边的范围是开区间,所以不需要再加上等号,否则在查找的过程中会产生越界的情况。
Secong,中间值的情况和情况一样,都是mid = left + ((right - left) >> 1)
Third,关于中间值arr[mid]和要查找的值大小相关的细节问题。
if查找的值比中间值小,因为右边是开区间,所以这次right = mid
else查找的值比中间值大,因为左侧是闭区间,所以left = mid + 1
Finally,情况二代码如下:
int binarysearch(int arr[], int data, int size)
{
int left = 0;
int right = size;
while (left < right)
{
int mid = left + ((right - left) >> 1);
if (data == arr[mid])
return mid;
else if (data < arr[mid])
right = mid;
else
left = mid + 1;
}
return -1;
}
二分查找的复杂度:
总体思路就是每次减半,如图:
计算过程:
以上相关内容均摘录我的另一篇博客:
https://blog.csdn.net/aaaaauaan/article/details/106181310
3.空间复杂度
空间复杂度是对一个算法在运行过程中临时占用存储空间大小的量度。空间复杂度不是程序占用了多少byte的空间,因为这个也没太大的意义,所以空间复杂度算的是变量的个数。空间复杂度计算规则基本跟时间复杂度类似,也使用大O渐进表示法。
简而言之,空间复杂度,是数学表达式,是函数创建变量(对象)个数的函数。
举例1:
将2个有序的数组合并,并且排序
int* MergeData(int array1[], int size1, int array2[], int size2)
{
int index1 = 0, index2 = 0, index = 0;
int* array = (int*)malloc((size1 + size2) * sizeof(array1[0]));
if (array == NULL)
return NULL;
while (index1 < size1 && index2 < size2)
{
if (array1[index1] <= array2[index2])
array[index++] = array1[index1++];
else
array[index++] = array2[index2++];
}
while (index1 < size1)
array[index++] = array1[index1++];
while (index2 < size2)
array[index++] = array2[index2++];
return array;
}
int main()
{
int array1[] = { 2,5,6,8 };
int array2[] = {1,3,5,6,8,9};
int* array = MergeData(array1, sizeof(array1)/sizeof(array1[0]), array2, sizeof(array2)/sizeof(array2[0]));
for (int i = 0; i < 10; ++i)
{
printf("%d ", array[i]);
}
printf("\n");
free(array);
array = NULL;
return 0;
}
时间复杂度(运行次数):进行两次值的传递, O(M+N)
空间复杂度(变量个数):创建变量的个数,包括malloc在堆上开辟的变量,O(M+N+8) = O(M+N)
本质:看算法中是否使用了辅助空间
200个辅助空间--->常量--->O(1)
N个辅助空间--->变化--->O(N)
M+N个辅助空间--->变化--->O(M+N)
举例2:
递归算法的时间复杂度 = 递归的深度 * 每次递归所需要的空间的个数
int Fac(int N)
{
if (0 == N)
return 1;
return Fac(N - 1) * N;
}
说明1:每个函数在运行时,系统必须要在栈上划分一个栈帧给该函数,保存相应的参数、局部变量、寄存器信息。
对于该函数,编译器给该函数每次调用时划分的栈空间大小都是相同的----常量。
故函数的空间复杂度为:O(N+1)*O(常量) = O(N+1) = O(N)
说明2:递归不能太深?
因为每次递归都是一次函数调用,每次函数调用都需要在栈上划分一个栈帧,而栈是有大小限制的,如果过深,可能无法分配栈帧而导致程序崩溃。
举例3:
未优化斐波那契数列的空间复杂度?
由图可知F(5)的深度是4,所以空间复杂度为:O(N-1) = O(N)