Abstract
- 一、时间复杂度
- 1.1 时间复杂度的定义
- 1.2 大O渐进法
- 1.3 最坏情况时间复杂度
- 二、空间复杂度
- 2.1 空间复杂度的定义
- 三、常见复杂度类型及其实例
- 3.1 经典实例
- 3.2 排序算法实例
- 快速排序
- 归并排序
- 插入排序
- 直接插入排序
- 使用二分法优化的入排序
- 希尔排序
- 堆排序
- 四、经典例题(待补充)
- 五、重新审视学习数据结构和算法的旅程
在算法的代码运行前,衡量一个算法的好坏,一般从时间和空间两个维度衡量,即时间复杂度
和空间复杂度
。
简单来说:
时间复杂度主要衡量一个算法的运行快慢。
空间复杂度主要衡量一个算法运行所需要的额外空间。
因为内存空间是可以复用的,所以这里说的“额外空间”不是总共申请的空间,而是占用空间最大时刻的空间。
下面我们具体阐述两者的内容:
算法的时间复杂度是一个函数(数学意义上的函数),它定性描述了该算法的运行时间。一个算法所花费的时间与其中语句的执行次数成正比例,因此时间复杂度的定性描述的是,算法中的基本操作的执行次数随着 输入规模 n
增长的增长速度。
时间复杂度是对算法本身效率的度量,它与运行环境、硬件和软件平台无关。
大O记法是用来表示时间复杂度和空间复杂度的一个工具。但时间复杂度和空间复杂度本身是对算法性能的描述,而大O记法只是这种描述的一种形式(不排除存在其他方法,这里不展开)。
[!Info] 推导大O渐进法的方法:——《大话数据结构》
- 用常数1取代运行时间中的所有加法常数。
- 在修改后的运行次数函数中,只保留最高阶项。
- 如果最高阶项存在且不是1,则去除与这个项目相乘的常数。得到的结果就是大O阶。
常见时间复杂度举例:
输入规模(基本操作数) | 时间复杂度 | 分类 |
---|---|---|
5201314 | O(1) | 常数阶 |
3n+4 | O(n) | 线性阶 |
3n^2 + 4n + 5 | O(n^2) | 平方阶 |
3log(2)n + 4 | O(log n) | 对数阶 |
2n+3nlog(2)n+14 | O(nlog n) | nlogn阶(线性对数阶) |
n^3 +2n^2 +4n+6 | O(n^3) | 立方阶 |
2^n | O(2^n) | 指数阶 |
大O的渐进表示法去掉了那些对结果影响不大的项,简洁明了的表示出了大致执行次数。
另外有些算法(比如排序算法)的时间复杂度存在最好、平均和最坏情况:
最坏情况:任意输入规模的最大运行次数(上界)
平均情况:任意输入规模的期望运行次数(一般)
最好情况:任意输入规模的最小运行次数(下界)
[!Quote]
找东西有运气好的时候,也有怎么也找不到的情况。但在现实中,通常我们碰到的绝大多数既不是最好的也不是最坏的,所以算下来是平均情况居多。算法的分析也是类似,我们查找一个有 n 个随机数字数组中的某个数字,最好的情况是第一个数字就是,那么算法的时间复杂度为 O(1), 但也有可能这个数字就在最后一个位置上待着,那么算法的时间复杂度就是O(n) ,这是最坏的一种情况了。
最坏情况运行时间是一种保证,那就是运行时间将不会再坏了。在应用中,这是一种最重要的需求,通常,除非特别指定,我们提到的运行时间都是最坏情况的运行时间。
而平均运行时间也就是从概率的角度看,这个数字在每一个位置的可能性是相同的,所以平均的查找时间为 n/2 次后发现这个目标元素。
平均运行时间是所有情况中最有意义的,因为它是期望的运行时间。也就是说,我们运行一段程序代码时,是希望看到平均运行时间的。可现实中,平均运行时间很难通过分析得到,一般都是通过运行一定数量的实验数据后估算出来的。
对算法的分析,一种方法是计算所有情况的平均值,这种时间复杂度的计算方法称为平均时间复杂度。另一种方法是计算最坏情况下的时间复杂度,这种方法称为最坏时间复杂度。一般在没有特殊说明的情况下,时间复杂度都默认指最坏时间复杂度。
——《大话数据结构》
空间复杂度也是一个数学函数,是对一个算法在运行过程中临时占用存储空间大小的量度。
[!Attention] 注意:
- 时间一去不复返,但空间可以重复利用,所以已经开辟了的空间可以被重复利用。
- 空间复杂度主要通过算法显式申请的额外空间来确定。
下面这段代码输出的两个指针值相同,这就证明了内存空间的可重复利用的特性。
#include
void Func1()
{
int a = 0;
printf("%p\n", &a);
}
void Func2()
{
int a = 0;
printf("%p\n", &a);
}
int main()
{
Func1();
Func2();
return 0;
}
Func1()函数栈帧销毁之后,Func2()又在原处创建了大小内容完全一样的栈帧。
// Func函数中,基本操作执行了常数次,其时间复杂度为 O(1)
void Func(int N)
{
int count = 0;
for (int k = 0; k < 100; ++ k)
{
++count;
}
printf("%d\n", count);
}
// 二分查找算法的时间复杂度是典型的O(log N)
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;
}
// 递归了N次,时间复杂度为O(N)
long long Fac(size_t N)
{
if(0 == N)
return 1;
return Fac(N-1)*N;
}
线性对数阶 O(n log n):归并排序、快速排序和堆排序
在下一个小标题详细介绍
平方阶 O(n^2):冒泡排序、插入排序和选择排序
在下一个小标题详细介绍
指数阶 O(2^n):斐波那契数列的递归实现
// 递归了2^N次,时间复杂度为O(2^N)
long long Fib(size_t N)
{
if(N < 3)
return 1;
return Fib(N-1) + Fib(N-2);
}
对于排序算法,我们先给出一张关于排序算法的思维导图帮助回忆。
由图可见,时间空间复杂度的概念贯穿排序算法的学习,下面我们讨论几种经典的排序算法的时间空间复杂度的计算推导过程。
对于时间复杂度,我们讨论:最好情况、平均情况、最坏情况
对于空间复杂度,我们讨论:最好情况、最坏情况
[!Attention]
我们通常更乐意牺牲空间复杂度,来换取算法时间效率上的提升,用空间换时间,因为时光一去不复返。
最好情况: 如果每次划分都能够均匀地分割序列(即每次划分后的两个子序列长度都约为上一条序列的1/2)。因为此时排序的递归树的深度为log n,每一层的处理的时间复杂度为O(n),所以其时间复杂度为O(n log n)。
平均情况: 在平均情况下,快速排序也是O(n log n)。虽然平均情况的推导较为复杂,涉及到概率和数学期望的计算,但统计上结论是这样的。
最坏情况: 如果每次划分都是极不平衡的,例如,当输入数组已经完全有序或完全逆序时,每次都将数组分成一个元素与其余元素两部分,此时的时间复杂度为O(n^2)。(计算方法是等差数列求和)
//Hoare法单趟排序
int PartSort(int* arr, int left, int right)
{
int keyi = left;
while (left < right)
{
// 右边找小
while (left < right && arr[right] >= arr[keyi])
{
--right;
}
// 左边找大
while (left < right && arr[left] <= arr[keyi])
{
++left;
}
Swap(&arr[left], &arr[right]);
}
Swap(&arr[keyi], &arr[left]);
return left;
}
void QuickSort(int* arr, int begin, int end)
{
if (begin >= end)
{
return;
}
int keyi = PartSort3(arr, begin, end);
QuickSort(arr, begin, keyi - 1);
QuickSort(arr, keyi + 1, end);
}
时间复杂度:
这个时间复杂度在归并排序的所有情况下(最好、平均、最坏)都是相同的,因为无论输入的数组如何,都需要进行相同的分割和合并步骤,而不像快速排序,有极限情况出现。
空间复杂度:
归并排序需要一个与原数组同样大小的临时数组来存放合并过程中的结果。因此,空间复杂度为:
在这两种情况下,空间复杂度都是 O(n),因为无论如何,都需要额外的空间来存放合并的结果。
//归并排序_递归法
void _MergeSort(int* arr, int begin,int end,int* tmp)
{
if (begin == end)
{
return;
}
//小区间优化
if (end - begin + 1 < 10)
{
InsertSort(arr + begin, end - begin + 1);
return;
}
int mid = (begin + end)/2;
_MergeSort(arr, begin, mid, tmp);
_MergeSort(arr, mid+1, end, tmp);
int begin1 = begin, end1 = mid;
int begin2 = mid + 1, end2 = end;
int i = begin;
while (begin1 <= end1 && begin2 <= end2)
{
if (arr[begin1] < arr[begin2])
{
tmp[i++] = arr[begin1++];
}
else
{
tmp[i++] = arr[begin2++];
}
}
while (begin1<=end1)
{
tmp[i++] = arr[begin1++];
}
while (begin2 <= end2)
{
tmp[i++] = arr[begin2++];
}
memcpy((arr + begin), tmp + begin, sizeof(int) * (end - begin + 1));
}
void MergeSort(int* arr, int size)
{
int* tmp = malloc(sizeof(int) * size);
_MergeSort(arr, 0, size - 1,tmp);
free(tmp);
}
[!Hint]
在快速排序和归并排序中,我们可能采取“小区间优化”的策略优化了对小数组的排序,即当数组大小小于10时,使用插入排序,从而减少深层递归的性能(内存)开销,这在实际应用中可以提高排序的效率,但不会改变其基本的时间复杂度。
时间复杂度:
空间复杂度:
void InsertSort(int* arr, int size)
{
//从i=1开始遍历是因为默认了首元素组成的单元素序列是已有序的
for (int i = 1; i < size; ++i)
{
int end = i; //end找最终待排数据落位的数组下标
int temp = arr[end]; //记录待排数值
while (end > 0)
{
if (arr[end - 1] > temp) //若前一个数大于待排数值,则后移一位
{
arr[end] = arr[end - 1];
end--;
}
else
{
break;
}
}
arr[end] = temp; //将数据放入应该插入的位置
}
}
这个版本是对插入排序的一个优化,它通过使用二分查找来找到每个元素的正确位置,提高了效率。
时间复杂度:
Tips:二分查找的效率之前提到过,是O(log₂n)
空间复杂度:
void InsertSort2(int* arr, int size)
{
int i = 0;
for (i = 1; i < size; i++)
{
int left = 0;
int right = i - 1;
//查找插入位置
while (left <= right)
{
int mid = (left + right) / 2;
if (arr[i] > arr[mid])
{
left = mid + 1;
}
else
{
right = mid - 1;
}
}
//后移数据并插入
int temp = arr[i];
for (right = i; right > left; right--)
{
arr[right] = arr[right - 1];
}
arr[left] = temp;
}
}
希尔排序是插入排序的一个变体,通过使用“希尔增量”来将元素排序,从而减少元素的交换。
时间复杂度:
希尔排序的时间复杂度取决于使用的增量序列,我们给出的代码中的增量为gap/3 + 1
:
空间复杂度:
void ShellSort(int* arr, int size)
{
// 1、gap > 1 预排序
// 2、gap == 1 直接插入排序
int gap = size;
while (gap > 1)
{
gap = gap / 3 + 1; //调整希尔增量
// gap = gap / 2;
for (int i = 0; i < size - gap; ++i)
{
int end = i;
int tmp = arr[end + gap];
while (end >= 0)
{
if (arr[end] > tmp)
{
arr[end + gap] = arr[end];
end -= gap;
}
else
{
break;
}
}
arr[end + gap] = tmp;
}
}
}
[!attention]
希尔排序的效率高低很大程度上取决于增量序列的选择,有很多研究和不同的增量序列被提出来,如 Hibbard、Sedgewick 等。我们选择的是一个常见的增量序列,但最佳的增量序列选择仍然是一个开放的研究问题。
时间复杂度:
n
次AdjustDown
,每次AdjustDown
的时间复杂度为O(log₂n),AdjustDown
时间复杂度的计算可以参考讲解快速排序时画的二叉树,child
的遍历是跨层的。空间复杂度:
void AdjustDown(int* arr, int size, int parent)
{
int child = parent * 2 + 1;
while (child < size)
{
//find bigger child
if (child + 1 < size && arr[child + 1] > arr[child])
{
child++;
}
if (arr[child] > arr[parent])
{
Swap(&arr[child], &arr[parent]);
parent = child;
child = parent * 2 + 1;
}
else
{
break;
}
}
}
//排升序 建大堆
void HeapSort(int* arr, int size)
{
//建堆
//向下调整建堆
for (int i = (size - 1 - 1) / 2;i >= 0;i--)
{
AdjustDown(arr, size, i);
}
int end = size - 1;
while (end > 0)
{
Swap(&arr[0], &arr[end]);
AdjustDown(arr, end, 0);
end--;
}
}
[!Example] 6.给定一个整数sum,从有N个有序元素的数组中寻找元素a,b,使得a+b的结果最接近sum,最快的平均时间复杂度是( )
A. O(n)
B. O(nA2)
C. O(nIogn)
D. O(Iogn)
答案:A
解析:
此题目中,数组元素有序,所以a,b两个数可以分别从开始和结尾处开始搜,
根据首尾元素的和是否大于sum,决定搜索的移动,整个数组被搜索一遍,就可以得到结果,
所以最好时间复杂度为n
[!Example] 4.设某算法的递推公式是T(n)=T(n-1)+n,T(0)=1,则求该算法中第n项的时间复杂度为()
A.O(n)
B.O(n^2)
C.O(nlogn)
D.O(Iogn)
答案:A
解析:
T(n)
=T(n-1)+n
=T(n-2)+(n-1)+n
=T(n-3)+(n-2)+(n-1)+n
...
=T(0)+1+2+...+(n-2)+(n-1)+n
=1+1+2+...+(n-2)+(n-1)+n
从递推公式中可以看到,第n项的值需要从n-1开始递归,一直递归到0次结束,共递归了n-1次
所以时间复杂度为n
学习数据结构和算法是一项长期、深入的任务,这个旅程可能有时充满了挑战和困难,但当你克服这些挑战时,你会发现它也是非常有成就感的。这里有一些关于学习数据结构和算法旅程的思考和建议:
重视基础:数据结构和算法的基础知识是非常重要的,它为更复杂的主题打下了坚实的基础。例如,没有对数组和链表的深入理解,你可能会在学习更复杂的数据结构(如哈希表和平衡二叉树)时遇到困难。
实践和应用:学习数据结构和算法不只是为了考试或完成课程。它们在实际的软件开发中也有广泛的应用。尝试在真实的项目中使用您学到的知识,或者参与编程挑战和竞赛,如LeetCode或牛客网。
持续学习:技术和算法领域是不断发展的。即使你已经学完了课程或书籍,你仍然需要时常回顾、更新知识和了解新的算法和技术。
连接到现实世界:理解数据结构和算法如何解决现实世界的问题可以加深您的理解。例如,了解搜索引擎如何使用图算法,或者如何使用二叉树在数据库中快速检索数据。
教是最好的学习:尝试解释你所学的内容给他人听,无论是通过博客、讲座还是仅仅是给一个朋友解释。教授他人可以帮助你更深入地理解材料。
与他人合作:学习数据结构和算法是一个复杂的过程,与他人合作可以帮助你看到不同的观点和解决方案。组队参与编程竞赛或开源项目是一个很好的开始。
学习数据结构和算法上的进步将为今后的职业和学术生涯打下坚实的基础。继续探索、挑战自己,并享受学习的过程!