🦄个人主页:小米里的大麦-CSDN博客
🎏所属专栏:C语言数据结构_小米里的大麦的博客-CSDN博客
🎁代码托管:黄灿灿/数据结构 (gitee.com)
⚙️操作环境:Visual Studio 2022
目录
一、前言
在C语言编程中,算法的效率是我们常常关注的一个核心问题。我们通常使用时间复杂度和空间复杂度来衡量算法的效率。本文将深入探讨这两个概念,并详细讲解如何通过大O渐进表示法来分析算法的性能。
这一节概念较多,防止枯燥,先上一份凉菜开开胃:(【数据结构2】算法和复杂度_哔哩哔哩_bilibili)
二、算法效率
算法效率指的是算法在执行过程中对资源的消耗情况,通常包括两部分:时间效率和空间效率。
- 时间效率:即算法执行所需的时间。我们通常希望算法在尽可能短的时间内完成任务。
- 空间效率:即算法执行过程中占用的内存空间。对于内存有限的系统,算法的空间效率显得尤为重要。
在实际开发中,我们常常需要在时间和空间之间进行权衡。比如,某些算法可以通过增加内存使用来换取更快的执行速度,而另一些算法则可能为了节省内存而牺牲部分执行时间。
1. 如何衡量一个算法的好坏
如何衡量一个算法的好坏呢?比如对于以下斐波那契数列:
long long Fib(int N)
{
if(N < 3)
return 1;
return Fib(N-1) + Fib(N-2);
}
斐波那契数列的递归实现方式非常简洁,但简洁一定好吗?那该如何衡量其好与坏呢?
2. 算法的复杂度
- 算法在编写成可执行程序后,运行时需要耗费时间资源和空间(内存)资源 。因此衡量一个算法的好坏,一般是从时间和空间两个维度来衡量的,即时间复杂度和空间复杂度。
- 时间复杂度主要衡量一个算法的运行快慢,而空间复杂度主要衡量一个算法运行所需要的额外空间。在计算机发展的早期,计算机的存储容量很小。所以对空间复杂度很是在乎。但是经过计算机行业的迅速发展,计算机的存储容量已经达到了很高的程度。所以我们如今已经不需要再特别关注一个算法的空间复杂度。
一般算法常见的复杂度如下:
常数阶 | 5201314 | o(1) |
线性阶 | 3n+4 | o(n) |
平方阶 | 3n^2+4n+5 | o(n^2) |
对数阶 | 3log (2)n+4 | o(log n) |
n log n阶 | 2n+3n log (2)n+14 | o(n log n) |
立方阶 | n^3+2n^2+4n+6 | o(n^3) |
指数阶 | 2^n | o(2^n) |
三、时间复杂度
时间复杂度是用来描述算法执行时间随输入规模增长而变化的趋势。我们通常用大O记号(O-notation)来表示时间复杂度,这种表示法反映了算法在最坏情况下的运行时间增长趋势。
- 时间复杂度的定义:在计算机科学中,算法的时间复杂度是一个函数,它定量描述了该算法的运行时间。一个算法执行所耗费的时间,从理论上说,是不能算出来的,只有你把你的程序放在机器上跑起来,才能知道。但是我们需要每个算法都上机测试吗?是可以都上机测试,但是这很麻烦,所以才有了时间复杂度这个分析方式。一个算法所花费的时间与其中语句的执行次数成正比例,算法中的基本操作的执行次数,为算法的时间复杂度。
- 即:找到某条基本语句与问题规模N之间的数学表达式,就是算出了该算法的时间复杂度。
1. 时间复杂度的常见类型
-
O(1) - 常数时间复杂度:
示例:访问数组中的某个元素。无论数组有多大,访问时间都是恒定的。 int arr[100]; int x = arr[50]; // O(1)
- O(n) - 线性时间复杂度:
示例:遍历一个长度为n的数组。每个元素都需要访问一次,所以时间复杂度与数组长度成正比。 for (int i = 0; i < n; i++) { printf("%d\n", arr[i]); // O(n) }
- O(n^2) - 平方时间复杂度:
示例:两个嵌套循环,每个循环运行n次。这种复杂度通常出现在简单的排序算法中,如冒泡排序。 for (int i = 0; i < n; i++) { for (int j = 0; j < n; j++) { printf("%d\n", arr[i] * arr[j]); // O(n^2) } }
- O(log n) - 对数时间复杂度:
示例:在一个已排序的数组中进行二分查找。每次查找都会将问题规模减半,因此时间复杂度是对数级的。 int binarySearch(int arr[], int n, int key) { int left = 0, right = n - 1; while (left <= right) { int mid = left + (right - left) / 2; if (arr[mid] == key) return mid; if (arr[mid] < key) left = mid + 1; else right = mid - 1; } return -1; // O(log n) }
- O(n log n) - 线性对数时间复杂度:
示例:归并排序。每次递归将数组对半分开,并进行合并排序。 void mergeSort(int arr[], int l, int r) { if (l < r) { int m = l + (r - l) / 2; mergeSort(arr, l, m); mergeSort(arr, m + 1, r); merge(arr, l, m, r); // O(n log n) } }
2. 时间复杂度的计算
要计算时间复杂度,我们通常遵循以下步骤:
- 分析算法中的基本操作:确定最耗时的操作。
- 计算这些操作的执行次数:根据输入规模确定基本操作的执行次数。
- 忽略低阶项和常数:只关注最高阶项,因为它决定了算法的增长趋势。
例如,在递归算法中,每次递归调用都会创建新的函数调用栈:
int factorial(int n) {
if (n == 0) return 1;
return n * factorial(n - 1); // O(n) 空间复杂度
}
这里,递归深度为n,因此空间复杂度为O(n)。
3. 大O的渐进表示法(重要!!!)
大O渐进表示法是一种用于描述算法复杂度的数学符号,它关注的是算法执行时间或所需空间随输入规模变化的增长趋势。大O表示法忽略常数系数和低阶项,只关注最高阶的项。大O表示法描述了算法的最坏情况,即当输入规模趋向无穷大时,算法的执行时间或空间需求的上界。
3.1 渐进分析的基本概念
渐进分析主要用来讨论算法在输入规模趋向无穷大时的表现。主要的渐进表示法有以下几种:
- O-notation (大O表示法):表示最坏情况下的上界,描述算法的时间或空间复杂度的上界。
- Ω-notation (大Ω表示法):表示最好的情况的下界,描述算法的时间或空间复杂度的下界。
- Θ-notation (大Θ表示法):表示上下界一致的情况,即描述算法的时间或空间复杂度的确界。
在大多数情况下,我们使用大O表示法,因为我们通常更关心最坏情况下的表现。
3.2 大O表示法
大O表示法描述了当输入规模趋向无穷大时,算法的执行时间或空间需求的上界。用数学语言表示,即对于一个函数f(n)
,如果存在一个常数c
和足够大的n0
,使得对于所有的n ≥ n0
,都有f(n) ≤ c * g(n)
,那么我们说f(n)
是O(g(n))
的。
数学定义
概念解释:
如果存在正常数
c
和n0
,使得对所有n ≥ n0
,都有f(n) ≤ c ⋅ g(n)
那么我们可以表示为:
f(n) = O(g(n))
这意味着对于大规模输入,
f(n)
的增长速度不会超过g(n)
的增长速度乘以某个常数因子c
。
示例解释:
如果存在常数
c
和n0
,使得对于所有n ≥ n0
,f(n) ≤ c * g(n)
,那么我们说f(n)
是O(g(n))
的。
- 示例1:如果一个算法的时间复杂度为
f(n) = 3n + 2
,我们可以将其表示为O(n)
,因为当n
很大时,3n
主导了增长趋势,常数项和系数可以忽略。- 示例2:对于一个算法
f(n) = n^2 + 10n + 1000
,大O表示法为O(n^2)
,因为n^2
是最高阶项,其决定了复杂度的增长趋势。
举例
常数时间复杂度 O(1):
- 例子:访问数组中的某个元素,无论数组有多大,访问操作的时间都是恒定的。
- 表达式:
T(n) = O(1)
线性时间复杂度 O(n):
- 例子:遍历一个长度为
n
的数组,每个元素都需要访问一次。- 表达式:
T(n) = O(n)
平方时间复杂度 O(n^2):
- 例子:两个嵌套循环,每个循环运行
n
次。- 表达式:
T(n) = O(n^2)
对数时间复杂度 O(log n):
- 例子:二分查找,通过每次将问题规模减半来查找目标值。
- 表达式:
T(n) = O(log n)
线性对数时间复杂度 O(n log n):
- 例子:归并排序,每次递归将数组对半分开并排序。
- 表达式:
T(n) = O(n log n)
大O表示法的特点
- 忽略常数项:因为大O表示法关心的是增长趋势,常数项在输入规模极大时影响可以忽略不计。例如,
T(n) = 3n + 5
和T(n) = n
都可以表示为O(n)
,因为当n
很大时,3n
和n
的增长趋势相同,常数5
和3
并不影响最终的复杂度。 - 忽略低阶项:低阶项对算法的影响在大规模输入时可以忽略。例如,
T(n) = n^2 + 10n + 1000
可以简化为O(n^2)
,因为n^2
在n
非常大时主导了算法的时间复杂度。
3. 其他渐进表示法
除了大O表示法,还有其他的渐进符号,用于描述算法的不同表现情况:
Ω-notation (大Ω表示法):
- 表示算法的下界,描述最好的情况。
- 数学定义:如果存在正常数
c
和n0
,使得对所有n ≥ n0
,f(n) ≥ c * g(n)
,那么f(n) = Ω(g(n))
。- 例子:一个排序算法的比较次数下界是
Ω(n log n)
。Θ-notation (大Θ表示法):
- 表示算法的确界,描述平均情况下的复杂度。
- 数学定义:如果存在正常数
c1
和c2
以及n0
,使得对所有n ≥ n0
,c1 * g(n) ≤ f(n) ≤ c2 * g(n)
,那么f(n) = Θ(g(n))
。- 例子:在最佳和最坏情况下,插入排序的复杂度都是
Θ(n^2)
。o-notation (小o表示法):
- 表示严格小于某个增长率的情况,表示算法的上界比
g(n)
函数增长慢。- 数学定义:
f(n)
是o(g(n))
,当且仅当f(n)/g(n) → 0
当n → ∞
。ω-notation (小ω表示法):
- 表示严格大于某个增长率的情况,表示算法的下界比
g(n)
函数增长快。- 数学定义:
f(n)
是ω(g(n))
,当且仅当f(n)/g(n) → ∞
当n → ∞
。
其中较为重要的是:
Ω-notation (大Ω表示法):表示算法的下界,即最好的情况。
- 示例:插入排序的最好情况是Ω(n),当数据已经有序时,只需要遍历一次即可。
Θ-notation (大Θ表示法):表示算法的确界,即在平均情况下的复杂度。
- 示例:在平均和最坏情况下,归并排序的时间复杂度都是
Θ(n log n)
。
4. 渐进表示法的应用
在算法分析中,渐进表示法帮助我们理解和比较不同算法的效率。例如,如果一个算法是O(n)
而另一个算法是O(n^2)
,则对于大规模输入,第一个算法会明显更快。尽管在小规模输入时,两者的执行时间可能差别不大,但大O表示法揭示了随着输入规模增大,性能差距将如何扩大。
说了那么多,还是上代码吧,只有代码才能最直观的体现概念:
// 计算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);
}
基本操作执行了2N+10次,通过推导大O阶方法知道,时间复杂度为 O(N)
// 计算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次,有两个未知数M和N,时间复杂度为 O(N+M)
// 计算Func4的时间复杂度?
void Func4(int N)
{
int count = 0;
for (int k = 0; k < 100; ++ k)
{
++count;
}
printf("%d\n", count);
}
3基本操作执行了10次,通过推导大O阶方法,时间复杂度为 O(1)
// 计算strchr的时间复杂度?
const char * strchr ( const char * str, int character );
基本操作执行最好1次,最坏N次,时间复杂度一般看最坏,时间复杂度为 O(N)
// 计算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次,最坏执行了(N*(N+1)/2次,通过推导大O阶方法+时间复杂度一般看最
坏,时间复杂度为 O(N^2)
// 计算BinarySearch的时间复杂度?
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;
}
基本操作执行最好1次,最坏O(logN)次,时间复杂度为 O(logN)
ps:logN在算法分析中表示是底数为2,对数为N。有些地方会写成lgN。
(建议通过折纸查找的方式来体会logN是怎么计算出来的)
// 计算阶乘递归Fac的时间复杂度?
long long Fac(size_t N)
{
if(0 == N)
return 1;
return Fac(N-1)*N;
}
通过计算分析发现基本操作递归了N次,时间复杂度为O(N)。
// 计算斐波那契递归Fib的时间复杂度?
long long Fib(size_t N)
{
if(N < 3)
return 1;
return Fib(N-1) + Fib(N-2);
}
通过计算分析发现基本操作递归了2^N次,时间复杂度为O(2^N)。
四、总结
理解和分析C语言算法的时间复杂度和空间复杂度是编程中一项重要的技能。通过使用大O渐进表示法,我们可以清晰地描述算法的性能,并在不同算法之间进行比较。这不仅帮助我们优化代码,还能为我们选择最合适的算法提供依据。无论是在时间还是空间的效率上,选择一个合适的算法往往是编程中的一个重要步骤。
在实际应用中,掌握这些理论知识,可以让我们在面对复杂问题时更加得心应手,设计出高效的解决方案。希望本文能帮助你深入理解C语言算法的复杂度分析,为你在编程之路上提供帮助!