简单来说,时间复杂度和空间复杂度是衡量一个算法效率的。
时间复杂度对应时间效率,空间复杂度对应空间效率
。
其中时间效率并不是说程序具体执行了多久,单给一个程序一个算法是不能确定它的执行时间,因为你需要在机器上跑起来才能确定。
而一个算法所花费的时间与其中语句的执行次数成正比,所以就定义算法中基本操作的执行次数为该算法的时间复杂度
。
其中空间效率也不是说一个算法在运行过程中临时占用多少具体大小的存储空间。而一个算法临时占用的空间和它运行过过程中定义变量所开辟的空间有关,它定义的变量越多,要开辟的空间也越多,所以空间复杂度算的是变量的个
数。
总的来说,时间复杂度主要衡量的是一个算法的运行速度,而空间复杂度主要衡量一个算法所需要的额外空间。但是现在随着技术的发展,计算机的存储容量已经达到了很高的程度,空间不再是限制一个算法的条件,所以我们更多关注一个算法的时间复杂度
。这也是经常出现用空间换时间
的做法的原因。
在计算一个算法的时间复杂度和空间复杂度的时候,我们不会计算程序具体的执行次数和具体开辟了多少变量,而是用大O的渐进表示法去描述。
大O符号 (Big O notation) 是用于描述函数渐进行为的数学符号
。
推导大O阶方法:
用常数1取代运行时间中的所有加法常数。
在修改后的运行次数函数中,只保留最高阶项。
如果最高阶项存在且不是1,则去除与这个项目相乘的常数。得到的结果就是大O阶。
我们通过一个具体的实例去理解它:
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 函数共执行了 N2+2×N+10 次。
用 大O的渐进表示法 表示如下:
所以, Func1 函数的时间复杂度就是 O(N2) 。
同理,它的空间复杂度就是 O(1) 。
大O的渐进表示法 的合理之处在于,当 N 特别大的时候,整个算法的执行次数是取决于最高阶项的,且它的常数系数对结果的影响并不大。
还是以 Func1 举例:
总而言之,大O的渐进表示法 去掉了那些对结果影响不大的项,简洁明了的表示出了执行次数。
但是,也有算法的时间复杂度不唯一的情况,比如二分查找,可能上来就找到了,也可能找了一遍都没找到。这时我们关注的是算法的最坏运行情况
,这点是很重要的。
实例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 具体执行次数为 2×N+10 ,
用 大O的渐进表示法 表示就是 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);
}
Func3 基本操作执行了 M+N 次,
有两个未知数 M 和 N ,
时间复杂度为 O(N+M) 。
实例3:
void Func4(int N)
{
int count = 0;
for (int k = 0; k < 100; k++)
count++;
printf("%d\n", count);
}
基本操作执行了 10 次,通过推导大O阶方法,
时间复杂度为 O(1)。
实例4:
int strchr(const char* str, char character)
{
int count = 0;
while (*str != '\0')
{
if (*str == character)
return count;
str++;
count++;
}
return -1;
}
这段代码是实现在字符串 str 中找到字符 character 首次出现的位置的下标并返回。
基本操作执行最好 1 次,
最坏 N(N = strlen(str)) 次,
时间复杂度一般看最坏,所以是 O(N) 。
实例5:
void BubbleSort(int* arr, int sz)
{
for (int i = 0; i < sz - 1; i++)
for (int j = 0; j < sz - 1 - i; j++)
if (arr[j] > arr[j + 1])
{
int tmp = arr[j];
arr[j] = arr[j + 1];
arr[j + 1] = tmp;
}
}
这段代码就是基本的冒泡排序,它的时间复杂度该怎么算呢?
首先基本操作要执行 N+(N-1)+…+1 次,
也就是 N * (N+1) / 2,
所以时间复杂度就是 O(N2) 。
实例6:
BinarySearch(int * nums, int right, int left, int target)
{
while(left <= right)
{
int mid = left + (right - left) / 2;
if(nums[mid] == target)
return mid;
if(nums[mid] > target)
right = mid - 1;
else if(nums[mid] < target)
left = mid + 1;
}
return -1;
}
这是一个简单的二分查找算法。
考虑它最坏的执行次数:
最坏情况是遍历整个有序数组,
设执行次数为 x ,
则有 2x = N,
N 为数组大小,
那么 x = log2N。
所以它的执行次数最坏就是 log2N。
而在实际表示时是忽略它的底数的,
所以它的时间复杂度就是 O(logN) 。
而对于很多题目来说,可能会限制时间复杂度不低于 O(logN) ,这种情况一般就要考虑用到二分查找的算法。
实例7:
long long Factorial(size_t N)
{
return N < 2 ? N : Factorial(N - 1) * N;
}
这是一个求 N 的阶乘的算法,用的递归实现。
那它执行了多少次呢?
它的递归出口是 N = 1 ,
所以在走到出口之前递归了 N 次;
返回又是一次,所以函数总共执行了 2×N 次;
每次调用函数,函数内部只进行一次基本操作,
所以总基本操作次数就是 2×N×1。
综上,这个算法的时间复杂度就是 O(N)。
时间复杂度是区分一个算法好坏的最直观的量,一般算法效率高低有如下之分:
O(1) > O(logN) > O(N) > O(NlogN) > O(NA) > O(AN) > O(N!)
直观一点就是这样:
实例1:
void BubbleSort(int* arr, int sz)
{
for (int i = 0; i < sz - 1; i++)
for (int j = 0; j < sz - 1 - i; j++)
if (arr[j] > arr[j + 1])
{
int tmp = arr[j];
arr[j] = arr[j + 1];
arr[j + 1] = tmp;
}
}
像冒泡排序这样的算法,
它只有 tmp 需要额外开辟空间,
所以它只使用了 1 个额外空间,
那它的空间复杂度就是 O(1)
。
实例2:
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 个斐波那契数。
它给 fibArray 动态开辟了 n 个空间,
也就是额外开辟了 n 个空间。
所以它的时间复杂度就是 O(N)
。
实例3:
long long Factorial(size_t N)
{
return N < 2 ? N : Factorial(N - 1) * N;
}
还是上面的那段计算阶乘的算法。
函数每次调用都要开辟一个函数栈帧,
这里一共调用了 N 次,
瞬时开辟的最大额外空间的个数就是 N ,
所以算法的时间复杂度就是 O(N)
。
对于空间复杂度的计算,需要注意的是我们并不在意它有多少个变量,我们只在意它在某一时刻开辟的最大空间的个数。