目录
一、算法的效率
1.引入
算法 对一个程序员来说无疑是最重要的“法宝”。那么作为程序员的我们便需要将我们的法宝用得好、用得快,提高算法的效率。那么,什么是衡量算法好坏的因素呢?是代码的简洁度吗?我们来看看下面这个代码💨
long long Fib(int N)
{
if (N < 3)
return 1;
return Fib(N - 1) + Fib(N - 2);
}
这是查找斐波那契数列第n项的一个算法,虽然代码看起来十分整洁,但是它的效率并不高,甚至在实际应用中毫无价值,因为其中会大量重复调用相同的函数,非常浪费时间。
💭那么究竟什么是衡量算法好坏的因素呢?
下面将引进一个概念——算法的复杂度
2.算法的复杂度
算法在编写成可执行程序后,运行时需要耗费时间资源和空间(内存)资源 。因此 衡量一个算法的好坏,一般是从时间和空间两个维度来衡量的,即时间复杂度和空间复杂度。 时间复杂度主要衡量一个算法的运行快慢,而空间复杂度主要衡量一个算法运行所需要的额外空间。
在计算机发展的早期,计算机的存储容量很小。所以对空间复杂度很是在乎。但是经过计算机行业的迅速发展,计算机的存储容量已经达到了很高的程度。所以我们如今已经不需要再特别关注一个算法的空间复杂度,更加关注的是算法的时间复杂度了。
二、时间复杂度🕞
1.什么是时间复杂度?
时间复杂度的定义:在计算机科学中,算法的时间复杂度是一个函数,它定量描述了该算法的运行时间。
(1). 时间复杂度不是算法的绝对执行时间,绝对执行时间需要代码跑起来才能测量到,而时间复杂度是我们设计算法时评估算法效率的指标
(2). 时间复杂度可以说是一个量级的概念。我们在推算出一个算法的基本执行次数后,将其简化为数量级,这个数量级可以是N、N2、logN等等。
⭕示例:
// 请计算一下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;
}
}
💡显而易见,Func1中++count语句总共执行 N2+2*N+10 次,虽然这是Func1精确的执行次数,但这并不是Func1最终的时间复杂度。我们设F(N)=N2+2*N+10,我们称F(N)为运行次数函数。
- 当N=10时, F(N)=13;
- 当N=100时, F(N)=10210;
- 当N=1000时,F(N)=1002010。
实际中我们计算时间复杂度时,并不一定要计算精确的执行次数,而只需要大概执行次数,那么这里我们使用 大O的渐进表示法来表示时间复杂度。
2.大O的渐进表示法 —— 表示时间复杂度
大O渐进表示法是一种数量级的估算,是用另一个(通常更简单的)函数来描述一个函数数量级的渐近上界。
大O符号(Big O notation):是用于描述函数渐进行为的数学符号。
推导大O阶方法:
- 用常数1取代运行时间中的所有加法常数。
- 在修改后的运行次数函数中,只保留最高阶项(对结果起决定性作用)。
- 如果最高阶项存在且不是1,则去除与这个项目相乘的常数。得到的结果就是大O阶。
⭕注意: 推导过程不是简单地数循环次数,而要根据算法的逻辑,推算出时间复杂度。
用大O渐进表示法进行计算,我们可以得到上一目中Func1的时间复杂度为:O(N2)
- 当N=10时, F(N)=10;
- 当N=100时, F(N)=10000;
- 当N=1000时, F(N)=1000000。
我们可以看出,大O渐进表示法舍去了对结果影响不大的项,简洁明了地表示出了算法的执行次数。
⭕ 另外大部分算法的时间复杂度存在最好、平均和最坏情况:
- 最坏情况:任意输入规模的最大运行次数(上界)
- 平均情况:任意输入规模的期望运行次数
- 最好情况:任意输入规模的最小运行次数(下界)
🌰 举个例子:在一个长度为N数组中搜索一个数据target
- 最好情况:1次找到
- 最坏情况:N次找到
- 平均情况:N/2次找到
在实际中一般情况关注的是算法的最坏运行情况,所以数组中搜索数据时间复杂度为O(N)
3.常见的时间复杂度计算示例
示例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);
}
📝分析:准确运行次数是2*N+10,根据大O渐进表示法可得时间复杂度为O(N)
示例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);
}
📝分析:这里不知道M与N的大小关系,所以推导出时间复杂度为O(N+M)
假设:
当M>>N时,时间复杂度为O(M);
当M<<N时,时间复杂度为O(N);
当M==N时,时间复杂度为O(M)或O(N);
示例3
// 计算Func4的时间复杂度?
void Func4(int N)
{
int count = 0;
for (int k = 0; k < 100; ++k)
{
++count;
}
printf("%d\n", count);
}
📝分析:k有明确的上限100,根据大O渐进表示法第一条,推出时间复杂度为
O(1)
。
不管常数有多大,时间复杂度都是O(1),在CPU的眼里,它们都属于一个量级。
示例4
// 计算函数strchr的时间复杂度?
const char * strchr ( const char * str, int character );
⭕该函数的功能是从字符串中找到指定的字符并返回指向它的指针
📝分析:和上面介绍时间复杂度三种情况举的例子类似。这里最好情况是O(1),最坏的情况是O(N),我们取最坏情况O(N)。
示例5
// 计算BubbleSort(冒泡排序)的时间复杂度?
void BubbleSort(int* a, int n)
{
assert(a);
for (size_t end = n; end > 0; --end)//n次
{
int exchange = 0;
for (size_t i = 1; i < end; ++i)//end-1次
{
if (a[i - 1] > a[i])
{
Swap(&a[i - 1], &a[i]);
exchange = 1;
}
}
if (exchange == 0)
break;
}
}
📝分析:冒泡排序内部执行次数第一次为N-1,第二次为N-2,依次递减下去。执行次数为
(N-1)+(N-2)+...+2+1 == (N-1)*N/2
,因此时间复杂度为O(N2)。
示例6
// 计算BinarySearch(二分查找)的时间复杂度?
int BinarySearch(int* a, int n, int x)
{
assert(a);
int begin = 0;
int end = n - 1;
// [begin, end]:begin和end是左闭右闭区间,因此有=号
while (begin <= end)
{
int mid = begin + ((end - begin) >> 1);
if (a[mid] < x)
begin = mid + 1;
else if (a[mid] > x)
end = mid - 1;
else
return mid;
}
return -1;
}
📝分析:二分查找,不断缩小begin和end之间的范围,最坏情况是当范围小到1(既begin=ebd)的时候结束,此时,N/2/2/2/…/2=1,我们设N/2x=1,则可以得出x=logN,因此时间复杂度为O(logN)。
⭕注意:logN在算法分析中表示是底数为2,对数为N。
示例7
// 计算阶乘递归Fac的时间复杂度?
long long Fac(size_t N)
{
if (0 == N)
return 1;
return Fac(N - 1) * N;
}
📝分析:Fac函数递归了N次,每次递归执行一次,因此时间复杂度为O(N)。
示例8
// 计算斐波那契递归Fib的时间复杂度?
long long Fib(size_t N)
{
if (N < 3)
return 1;
return Fib(N - 1) + Fib(N - 2);
}
📝分析:运行次数为所有递归次数相加:
2^0^+2^1^+2^2^+...+2^(n-2)^+2^(n-1)^
,由等比数列求和公式可得结果为:2n-1,所以时间复杂度为O(2N)。
三、空间复杂度🏠
1.什么是空间复杂度?
- 空间复杂度是衡量算法好坏的另一因素,与时间复杂度相同,它也是一个数学表达式,但是 是对一个算法在运行过程中临时占用存储空间大小的量度
- 空间复杂度不是程序占用了多少字节的空间,因为这个也没太大意义,内存中一个G有10243个字节,这个数量是极其庞大的,这也是我们不太关注空间复杂度的原因。因此,空间复杂度算的是变量的个数。
- 空间复杂度计算规则基本跟时间复杂度类似也使用大O渐进表示法。
⭕注意: 函数运行时所需要的栈空间 (存储参数、局部变量、一些寄存器信息等) 在编译期间已经确定好了,因此空间复杂度主要通过函数在运行时候显式申请的额外空间来确定。
(函数传参时,形参不算额外空间,额外空间可以理解为为了实现该算法而专门开辟的空间)
2.空间复杂度计算示例
示例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;
}
}
📝分析:这里看似在循环过程中重复定义了n次exchange和n次i变量,那么空间复杂度就是O(N)了吗?其实不然。我们知道,时间是一去不复返的,所以时间复杂度就需要依据执行次数来计算。
而空间是可以重复利用的,虽然这里exchange变量定义了n次,但是只要它每次都开辟在同一块空间,从空间复杂度的角度,我们认为它只开辟了一次,也就是只定义了一个exchange变量。那么究竟是不是如我们所说呢?我们来作一验证:
🔎有如下代码:
#include <stdio.h>
#include <assert.h>
void Swap(int* p1, int* p2)
{
int tmp = *p1;
*p1 = *p2;
*p2 = tmp;
}
void BubbleSort(int* a, int n)
{
assert(a);
for (size_t end = n; end > 0; --end)
{
int exchange = 0;//我们要观察不同两次循环中,exchange变量的地址是否相同
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 main()
{
int arr[10] = { 9,8,7,6,5,4,3,2,1,0 };
BubbleSort(arr, 10);
return 0;
}
1️⃣end为10时,第一次循环
2️⃣end为9时,第二次循环
💡很明显,两次循环定义的exchange变量有相同的地址,说明它们开辟了同一块空间,验证了空间的可重复利用的性质。
同理,i 变量也只是每次都在同一块空间上创建。因此,我们在计算空间复杂度时,这里实际上是定义了三个变量,根据大O渐进表示法,本例的空间复杂度是O(1)。
示例2
// 计算Fibonacci的空间复杂度?
// 返回斐波那契数列的前n项
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;
}
📝分析:malloc了(n+1)*sizeof(long long)个字节的空间,用long long型指针接受,空间复杂度看变量个数不看字节数,所以这里应该是开辟了(n+1)个变量空间。再加上for循环中的i变量,根据大O渐进表示法可得空间复杂度为O(N)。
示例3
// 计算阶乘递归Fac的空间复杂度?
long long Fac(size_t N)
{
if (N == 0)
return 1;
return Fac(N - 1) * N;
}
📝分析:这里的Fac函数递归了N次,开辟了N个栈帧,每个栈帧使用了常数个空间,空间复杂度为O(N)。