简介
相信很多程序员都会听到过关于“空间复杂度”/“时间复杂度”这样的术语。但是其含义却比较模糊,本文将通过Java语言带你深刻理解一下这两者究竟是什么。
首先概括来讲:空间复杂度和时间复杂度是计算机算法效率的两个重要指标。那么何为计算机算法效率?也就是指算法完成特定任务所需的时间和资源
时间复杂度是衡量算法时间性能的指标,它描述了算法执行所需的时间。通俗地说,时间复杂度是指算法在执行过程中基本操作的次数,即在最坏情况下,算法所需执行的次数。
空间复杂度是衡量算法空间性能的指标,它描述了算法所需的存储空间。通俗地说,空间复杂度是指算法所需的额外空间(不包括I/O数据占用的空间)的数量级,即在最坏情况下,算法所需的额外空间。
在算法设计时,两者不可兼得,也就说空间和时间上你只可选择其中一种.就拿数据库的索引来举例,索引就是一种标准的拿空间换取时间的做法。
正文
时间复杂度
在了解时间复杂度之前,先来学习一下与之对应的表达式。我们可以经常听到所谓的大O.它究竟是什么东西?其实不必刻意将它想的很复杂,你可以理解为就是一种数学符号。它表示随着输入规模的增加,算法的时间复杂度的增长率。注意这个"输入规模",它十分重要。
大O表示法的一般形式为:O(f(n)) 其中f(n)是算法的时间复杂度函数,n表示输入规模.在这个表示法中,O表示算法时间复杂度的上界,即算法执行时间的增长率不会超过f(n)的增长率。
O(1) 常数时间复杂度
public class test{
public static void main(String[] args) {
int a = 10;
int b = 20;
int sum = add(a, b);
System.out.println("sum = " + sum);
}
public static int add(int a, int b) {
return a + b;
}
}
以上这段代码很好理解,类中定义了一个add方法,它接受两个整数作为输入并将它们相加后返回结果。由于add方法只执行一次加法操作,因此它的时间复杂度为O(1),即与输入规模无关。无论输入的数据有多大,add方法的执行时间都是恒定的。
O(log n) 对数时间复杂度
我们可以来看一个二分搜索算法的代码。
public class BinarySearch {
public static int binarySearch(int[] arr, int key) {
int left = 0;
int right = arr.length - 1;
while (left <= right) {
int mid = (left + right) / 2;
if (arr[mid] == key) {
return mid;
} else if (arr[mid] < key) {
left = mid + 1;
} else {
right = mid - 1;
}
}
return -1;
}
public static void main(String[] args) {
int[] arr = {1, 2, 3, 4, 5, 6, 7, 8, 9};
int key = 7;
int index = binarySearch(arr, key);
if (index != -1) {
System.out.println("找到了元素,下标为:" + index);
} else {
System.out.println("没有找到元素!");
}
}
}
这个代码就是一个二分搜索算法,用于在一个已经排序好的整数数组中查找指定的元素。算法首先将数组的左右边界初始化为0和数组长度减1,然后在循环中不断缩小搜索范围,每次将搜索范围的中间位置与目标元素进行比较,以确定应该在左侧还是右侧继续搜索。由于每次循环都将搜索范围缩小一半,这样子的时间复杂度表示就是O(log n)。
或许你还是对O(log n)不太理解,请看下图:
我们想在一个含有9个元素的数组中去找到数字7。采用二分搜索算法来看
1.第一次搜索的左边界为0,右边界为(lenth - 1) 也就是8 , 中位数坐标为 (左+右)/2 = 4。
2.也就是说第一次搜索的结果为5.很明显这不是我们想要的答案
3.那么此时左边的下标为: 4(当前搜索到的坐标) +1 = 坐标5. 也就是元素6。
4.再次进行中位数查询(左+右)/2 = (5+8) / 2 = 6(向下取整),取到元素7.
那么如果我们的数组有1~1000个元素呢?要从1000中取到元素7,那每次取中位数后肯定是向左取数的,算法的运行时间随着输入数组长度的增长而增长。但是增长的速度是以对数的方式增长。也就是说,就算我们将n增加10倍,算法的运行时间最多只会增加几个常数级别的时间。这就是O(log n)对数时间复杂度。
O(n) 线性时间复杂度
上文中,我们对已经排序好的数组进行元素查找,使其可以进行二分搜索算法,因此它是一个O(log n)对数时间复杂度,那么如果是一个没有排序的数组要去查找指定元素呢?
public static int linearSearch(int[] arr, int target) {
for (int i = 0; i < arr.length; i++) {
if (arr[i] == target) {
return i;
}
}
return -1; // 如果没有找到目标元素,返回 -1。
}
在这个示例中,我们可以看到,这其实就是一个普通的for循环遍历整个数组,如果找到了目标元素,则返回该元素的索引。如果整个数组都遍历完了,仍然没有找到目标元素,则返回 -1。由于遍历整个数组只需要一次,这就是时间复杂度O(n)。
O(n log n) 线性对数时间复杂度
我们先来看一个快速排序的代码
public static void main(String[] str){
int[] arr = {5, 3, 9, 1, 7};
quickSort(arr, 0, arr.length - 1);
System.out.println(Arrays.toString(arr));
}
public static void quickSort(int[] arr, int low, int high) {
if (low < high) {
// 以数组中间的元素作为基准值进行分区
int pivotIndex = partition(arr, low, high);
// 递归对基准值左边的子数组和右边的子数组进行排序
quickSort(arr, low, pivotIndex - 1);
quickSort(arr, pivotIndex + 1, high);
}
}
// 分区函数,将数组分成小于和大于基准值的两部分,并返回基准值的索引
private static int partition(int[] arr, int low, int high) {
int pivot = arr[low]; // 选择第一个元素作为基准值
int i = low;
int j = high + 1;
while (true) {
do {
i++;
} while (i < high && arr[i] < pivot);
do {
j--;
} while (arr[j] > pivot);
if (i >= j) {
break;
}
swap(arr, i, j);
}
swap(arr, low, j); // 将基准值移动到正确的位置
return j;
}
// 交换数组中的两个元素
private static void swap(int[] arr, int i, int j) {
int temp = arr[i];
arr[i] = arr[j];
arr[j] = temp;
}
//输出结果
[1, 3, 5, 7, 9]
以上代码大致可以概述为:选择数组的第一个元素作为基准值,并使用分区函数将数组分成小于和大于基准值的两部分,然后递归对基准值左边的子数组和右边的子数组进行排序。
在进行分区时,复杂度分区函数的时间复杂度为 O(n),分区后的快速排序的递归深度为 log n。这就是时间复杂度 O(n log n)。
O(n^2) 平方时间复杂度
这个其实很好理解,它表示算法的时间复杂度与输入规模 n 的平方成正比。比如冒泡排序、双重for循环。
int[][] array = new int[n][n];
for (int i = 0; i < n; i++) {
for (int j = 0; j < n; j++) {
array[i][j] = i + j;
}
}
这就是一个大小为 n × n 的二维数组,使用双重for循环对数组中的每个元素进行赋值。由于双重for循环需要执行 n × n 次操作,因此时间复杂度为 O(n^2)。
注意:这种算法的运行时间会随着输入规模的增大而急剧增加,因此对于大规模的数据,使用O(n^2)的算法可能会变得非常慢
O(n^3) 立方时间复杂度
它与O(n^2)大同小异,它指的是算法的运行时间随着问题规模 n 的增加而按照 n 的立方增长。通常出现在需要对三重循环进行嵌套的算法中,比如矩阵乘法。
public static int[][] matrixMultiplication(int[][] a, int[][] b) {
int m = a.length, n = b.length, p = b[0].length;
int[][] c = new int[m][p];
for (int i = 0; i < m; i++) {
for (int j = 0; j < p; j++) {
for (int k = 0; k < n; k++) {
c[i][j] += a[i][k] * b[k][j];
}
}
}
return c;
}
这个矩阵乘法就是用了三重循环来实现,其中第一重循环遍历矩阵 A 的行,第二重循环遍历矩阵 B 的列,第三重循环遍历矩阵 A 的列或者矩阵 B 的行。由于三重循环的嵌套,导致算法的时间复杂度为 O(n^3)。
此类算法通常很少出现在web应用的业务逻辑中,它主要应用于:图像处理、人工智能、金融分析等领域。
O(2^n) 指数时间复杂度
O(2^n) 指数时间复杂度通常出现在需要对问题的所有可能解进行枚举的算法中,如穷举、回溯、分支限界等。这种时间复杂度的算法在处理问题规模较大的情况下,由于计算量的急剧增加,通常很难在合理的时间内完成计算。
public class Subset {
public static void main(String[] args) {
int[] nums = {1, 2, 3};
printAllSubsets(nums);
}
public static void printAllSubsets(int[] nums) {
int n = nums.length;
for (int i = 0; i < (1 << n); i++) {
List<Integer> subset = new ArrayList<>();
for (int j = 0; j < n; j++) {
if ((i & (1 << j)) != 0) {
subset.add(nums[j]);
}
}
System.out.println(subset);
}
}
}
上面代码中,使用了两重循环来枚举数组中的所有子集,第一重循环从 0 到 2^n-1 枚举所有可能的子集,第二重循环从数组中选取元素,并根据当前的子集情况进行筛选。由于使用了递归的方式枚举所有子集,因此时间复杂度为 O(2^n)。
注意:对于规模较大的问题,这种实现方式很容易出现栈溢出等问题。
空间复杂度
空间复杂度的与时间复杂度类似,本文将不再过多描述。它的应用场景通常是利用空间去换取时间的操作。相比于时间复杂度可以更清晰的看到实现逻辑,空间复杂度更多的是体现在内存、磁盘上。它主要表达了我们在对自己代码中定义的变量所需要的空间。比如下面的三个变量赋值操作。
//这个变量只需要占用 4 个字节的空间,与输入规模无关,因此空间复杂度为 O(1)
int a = 1;
//这个变量只需要占用 1 个字节的空间,与输入规模无关,因此空间复杂度为 O(1)。
boolean b = true;
//这个变量只需要占用 8 个字节的空间,与输入规模无关,因此空间复杂度为 O(1)。
double c = 3.14;
上面我们得到了三个不同类型的变量,此时在内存中就已经为这三个变量赋予了自己所需要的空间,我们对该变量再进行赋值时,就是复用了已有的内存空间,减少额外的内存消耗。
注意:变量的复用并不是在所有情况下都适用的。有时候,为了避免出现数据竞争和副作用等问题,必须要为每个变量分配独立的内存空间(也就是时间换空间)。
那么O(n)的空间复杂度是如何体现呢?我们可以回看上文的O(n)的时间复杂度来理解它
int[] nums = new int[n]; // 创建一个长度为 n 的整型数组
for (int i = 0; i < n; i++) {
nums[i] = i; // 将数组中的每个元素赋值为对应的下标
}
在这个示例中,我们创建了一个长度为 n 的整型数组 nums,并将数组中的每个元素赋值为对应的下标。由于数组的空间大小是与输入规模 n 成正比的,因此空间复杂度为 O(n)。其它的复杂度也是如此。
怎么样?是不是很好理解,其实所有的算法都离不开这个复杂度的框架,我们平常写代码的时候也早已不经意间使用了很多所谓的"大O"。
总结
时间复杂度描述的是算法执行所需的时间随着输入规模 n 的增大而增加的趋势,用大 O 记号来表示。时间复杂度越小,算法执行的速度越快。
空间复杂度描述的是算法执行所需的额外空间随着输入规模 n 的增大而增加的趋势,同样使用大 O 记号来表示。空间复杂度越小,算法占用的内存空间越少。
因此在我们使用算法去分析它的时间复杂度时,也同时已经分析出了它的空间复杂度,它们两者是相辅相成的。你需要执行的更快,那你就选择更大的的空间,换取最小的执行时间,反之你需要更小的空间,那就用更多的时间去执行。