文章目录
算法复杂度
算法复杂度:时间复杂度,空间复杂度
时间复杂度
根据输入数据的特点,时间复杂度具有「最差」、「平均」、「最佳」三种情况,分别使用 O , Θ , Ω 三种符号表示。
大 O 是最常使用的时间复杂度评价渐进符号。
根据从小到大排列,常见的算法时间复杂度主要有:
O(1)<O(logN)<O(N)<O(NlogN)<O(N2)<O(2N)<O(N!)
常数 O(1):
运行次数与 N 大小呈常数关系,即不随输入数据大小 N 的变化而变化。
int algorithm(int N) {
int a = 1;
int b = 2;
int x = a * b + N;
return 1;
}
线性 O(N):
循环运行次数与 N 大小呈线性关系,时间复杂度为 O(N) 。
int algorithm(int N) {
int count = 0;
for (int i = 0; i < N; i++)
count++;
return count;
}
对于以下代码,虽然是两层循环,但第二层与 N 大小无关,因此整体仍与 N 呈线性关系。
int algorithm(int N) {
int count = 0;
int a = 10000;
for (int i = 0; i < N; i++) {
for (int j = 0; j < a; j++) {
count++;
}
}
return count;
}
平方 O(N2) :
两层循环相互独立,都与 N 呈线性关系,因此总体与 N 呈平方关系,时间复杂度为 O(N2)
int algorithm(int N) {
int count = 0;
for (int i = 0; i < N; i++) {
for (int j = 0; j < N; j++) {
count++;
}
}
return count;
}
冒泡排序
int[] bubbleSort(int[] nums) {
int N = nums.length;
for (int i = 0; i < N - 1; i++) {
for (int j = 0; j < N - 1 - i; j++) {
if (nums[j] > nums[j + 1]) {
int tmp = nums[j];
nums[j] = nums[j + 1];
nums[j + 1] = tmp;
}
}
}
return nums;
}
指数 O(2N):
算法中,指数阶常出现于递归,算法原理图与代码如下所示。
int algorithm(int N) {
if (N <= 0) return 1;
int count_1 = algorithm(N - 1);
int count_2 = algorithm(N - 1);
return count_1 + count_2;
}
阶乘 O(N!) :
如下图与代码所示,阶乘常使用递归实现,算法原理:第一层分裂出 N 个,第二层分裂出 N - 1个,…… ,直至到第 N 层时终止并回溯。
int algorithm(int N) {
if (N <= 0) return 1;
int count = 0;
for (int i = 0; i < N; i++) {
count += algorithm(N - 1);
}
return count;
}
对数 O(log N) :
对数阶与指数阶相反,指数阶为 “每轮分裂出两倍的情况” ,而对数阶是 “每轮排除一半的情况” 。对数阶常出现于「二分法」、「分治」等算法中,体现着 “一分为二” 或 “一分为多” 的算法思想。
设循环次数为 m ,则输入数据大小 N 与 2m呈线性关系,两边同时取log2 对数,则得到循环次数 m 与 log2N呈线性关系,即时间复杂度为 O(logN)
int algorithm(int N) {
int count = 0;
float i = N;
while (i > 1) {
i = i / 2;
count++;
}
return count;
}
如下图所示,为二分查找的时间复杂度示意图,每次二分将搜索区间缩小一半。
线性对数O(NlogN) :
两层循环相互独立,第一层和第二层时间复杂度分别为O(logN) 和O(N) ,则总体时间复杂度为O(NlogN) ;
int algorithm(int N) {
int count = 0;
float i = N;
while (i > 1) {
i = i / 2;
for (int j = 0; j < N; j++)
count++;
}
return count;
}
线性对数阶常出现于排序算法,例如「快速排序」、「归并排序」、「堆排序」等,其时间复杂度原理如下图所示。
空间复杂度
- 输入空间: 存储输入数据所需的空间大小;
- 暂存空间: 算法运行过程中,存储所有中间变量和对象等数据所需的空间大小;
- 输出空间: 算法运行返回时,存储输出数据所需的空间大小;
通常情况下,空间复杂度指在输入数据大小为 N 时,算法运行所使用的「暂存空间」+「输出空间」的总体大小。
而根据不同来源,算法使用的内存空间分为三类:
指令空间: 编译后,程序指令所使用的内存空间。
数据空间: 算法中的各项变量使用的空间,包括:声明的常量、变量、动态数组、动态对象等使用的内存空间。
栈帧空间: 程序调用函数是基于栈实现的,函数在调用期间,占用常量大小的栈帧空间,直至返回后释放。如以下代码所示,在循环中调用函数,每轮调用 test() 返回后,栈帧空间已被释放,因此空间复杂度仍为 O(1)
int test() {
return 0;
}
void algorithm(int N) {
for (int i = 0; i < N; i++) {
test();
}
}
算法中,栈帧空间的累计常出现于递归调用。如以下代码所示,通过递归调用,会同时存在 N 个未返回的函数 algorithm()
,此时累计使用 O(N) 大小的栈帧空间。
int algorithm(int N) {
if (N <= 1) return 1;
return algorithm(N - 1) + 1;
}
通常情况下,空间复杂度统计算法在 “最差情况” 下使用的空间大小,以体现算法运行所需预留的空间量,使用符号 O表示。
最差情况有两层含义,分别为「最差输入数据」、算法运行中的「最差运行点」。例如以下代码:
- 最差输入数据: 当 N≤10 时,数组 nums 的长度恒定为 10 ,空间复杂度为 O(10)=O(1);当 N > 10时,数组 nums长度为 N,空间复杂度为 O(N);因此,空间复杂度应为最差输入数据情况下的 O(N) 。
- 最差运行点: 在执行 nums = [0] * 10 时,算法仅使用 O(1) 大小的空间;而当执行 nums = [0] * N 时,算法使用 O(N) 的空间;因此,空间复杂度应为最差运行点的 O(N) 。
根据从小到大排列,常见的算法空间复杂度有:
O(1)<O(logN)<O(N)<O(N2)<O(2N)
常数 O(1):
普通常量、变量、对象、元素数量与输入数据大小 NN 无关的集合,皆使用常数大小的空间。
void algorithm(int N) {
int num = 0;
int[] nums = new int[10000];
Node node = new Node(0);
Map<Integer, String> dic = new HashMap<>() {{ put(0, "0"); }};
}
如以下代码所示,虽然函数 test() 调用了 NN 次,但每轮调用后 test() 已返回,无累计栈帧空间使用,因此空间复杂度仍为 O(1)。
void algorithm(int N) {
for (int i = 0; i < N; i++) {
test();
}
}
线性 O(N):
元素数量与 N 呈线性关系的任意类型集合(常见于一维数组、链表、哈希表等),皆使用线性大小的空间。
void algorithm(int N) {
int[] nums_1 = new int[N];
int[] nums_2 = new int[N / 2];
List<Node> nodes = new ArrayList<>();
for (int i = 0; i < N; i++) {
nodes.add(new Node(i));
}
Map<Integer, String> dic = new HashMap<>();
for (int i = 0; i < N; i++) {
dic.put(i, String.valueOf(i));
}
}
如下图与代码所示,此递归调用期间,会同时存在 N 个未返回的 algorithm()
函数,因此使用 O(N)大小的栈帧空间。
int algorithm(int N) {
if (N <= 1) return 1;
return algorithm(N - 1) + 1;
}
平方 O(N2)
元素数量与 NN 呈平方关系的任意类型集合(常见于矩阵),皆使用平方大小的空间。
void algorithm(int N) {
int num_matrix[][] = new int[N][N];
List<List<Node>> node_matrix = new ArrayList<>();
for (int i = 0; i < N; i++) {
List<Node> nodes = new ArrayList<>();
for (int j = 0; j < N; j++) {
nodes.add(new Node(j));
}
node_matrix.add(nodes);
}
}
如下图与代码所示,递归调用时同时存在 N 个未返回的 algorithm()
函数,使用 O(N)栈帧空间;每层递归函数中声明了数组,平均长度为
N
2
\frac{N}{2}
2N,使用 O(N) 空间;因此总体空间复杂度为 O(N2)
int algorithm(int N) {
if (N <= 0) return 0;
int[] nums = new int[N];
return algorithm(N - 1);
}
指数 O(2N)
指数阶常见于二叉树、多叉树。例如,高度为 NN 的「满二叉树」的节点数量为 2N ,占用 O(2N)大小的空间;同理,高度为 N 的「满 m 叉树」的节点数量为 mN,占用 O(mN) = O( 2N ) 大小的空间。
对数 O(logN) :
对数阶常出现于分治算法的栈帧空间累计、数据类型转换等,例如:
- 快速排序 ,平均空间复杂度为 Θ ( log N ) \Theta(\log N) Θ(logN) ,最差空间复杂度为 O(N)。拓展知识:通过应用 Tail Call Optimization ,可以将快速排序的最差空间复杂度限定至 O(N)。
- 数字转化为字符串 ,设某正整数为 N ,则字符串的空间复杂度为O(logN) 。推导如下:正整数 N 的位数为 l o g 10 N log_{10} N log10N 即转化的字符串长度为 log 10 N \log_{10} N log10N,因此空间复杂度为 O(logN) 。
时空权衡
对于算法的性能,需要从时间和空间的使用情况来综合评价。优良的算法应具备两个特性,即时间和空间复杂度皆较低。而实际上,对于某个算法问题,同时优化时间复杂度和空间复杂度是非常困难的。降低时间复杂度,往往是以提升空间复杂度为代价的,反之亦然。由于当代计算机的内存充足,通常情况下,算法设计中一般会采取「空间换时间」的做法,即牺牲部分计算机存储空间,来提升算法的运行速度。