评估算法优劣的核心指标是什么
- 时间复杂度(Time Complexity) :
- 时间复杂度是评估算法效率的主要指标,它表示算法执行时间随输入规模(如数组长度、图的大小等)增长的趋势。
- 常见的时间复杂度有:O(1)(常数时间)、O(log n)(对数时间)、O(n)(线性时间)、O(n log n)(线性对数时间)、O(n^2)(平方时间)等。
- 在选择算法时,我们倾向于选择时间复杂度较低的算法,因为这意味着算法的执行速度更快,尤其是在处理大规模数据时。
- 额外空间复杂度(Space Complexity) :
- 空间复杂度评估算法执行过程中除输入数据外所需额外存储空间的大小。
- 空间复杂度同样随着输入规模的增长而增长,常见的空间复杂度有:O(1)(常数空间)、O(n)(线性空间)等。
- 在选择算法时,如果输入数据规模很大,空间复杂度也是一个需要考虑的因素,因为过多的额外空间可能导致内存不足。
- 常数项时间(Constant Time Factors) :
- 常数项时间是指不随输入规模变化的时间消耗,例如算法中的固定次数循环、固定次数的比较等。
- 在时间复杂度相同的情况下,常数项时间较小的算法在实际执行中可能具有更快的运行速度。
- 实现细节(如编程语言、数据结构选择等)会影响常数项时间,因此在比较不同算法时,这些细节也需要考虑。
什么是时间复杂度?时间复杂度怎么估算?
- 时间复杂度:算法执行过程中所需的基本操作数量(如比较、交换、移动等)与输入数据规模(如数组长度、图的大小等)之间的关系 (算法流程的总操作数量与样本数量之间的表达式关系)
- 时间复杂度估算:只看表达式最高阶项的部分
什么是常数时间的操作?
如果一个操作的执行时间不以具体样本量为转移,每次执行时间都是固定时间。称这样的操作为常数时间的操作 常见的常数时间的操作: 常见的算术运算(+、-、*、/、% 等) 常见的位运算(>>、>>>、<<、|、&、^等) 赋值、比较、自增、自减操作等 数组寻址操作
如何确定算法流程的总操作数量与样本数量之间的表达式关系?
- 想象该算法流程所处理的数据状况,要按照最差情况来。
- 把整个流程彻底拆分为一个个常数时间的操作。
- 如果数据量为N,看常数时间的操作的数量和数据量N是什么关系。
如何确定算法流程的时间复杂度?
当完成了表达式的建立,只要把最高阶项留下即可。低阶项都去掉,高阶项的系数也去掉。 时间复杂度通常只关注算法中执行次数最多的操作,也就是最高阶项。这是因为当输入规模变得非常大时,高阶项的执行次数将远超过低阶项,从而主导了整个算法的运行时间。 记为:O(忽略掉系数的高阶项)
通过三个具体的例子,来实践一把时间复杂度的估算 选择排序 冒泡排序 插入排序
选择排序
过程描述: 在数组 arr[0~N-1] 的范围内,我们逐步执行以下操作:
- 从第 i 轮开始(i 从 0 到 N-2),在未处理的子数组范围(从 i 到 N-1)内,找到最小值的位置。
- 将找到的最小值与位置 i 上的元素进行交换。
这样,经过 N-1 轮操作后,数组将变成按升序排列。 时间复杂度分析:
- 交换次数:每一轮都会进行一次交换,总共进行 N-1 轮,因此交换次数为 N-1。
- 比较次数:
- 第1轮比较:从 0 到 N-1,共 N-1 次比较。
- 第2轮比较:从 1 到 N-1,共 N-2 次比较。
- ...
- 第 i 轮比较:从 i 到 N-1,共 N-1-i 次比较。
- ...
- 第 N-1 轮比较:从 N-1 到 N-1,共 1 次比较。
同样,这些比较次数形成了一个等差数列,首项 a1 = N-1,末项 an = 1,项数 n = N-1。等差数列求和公式为: 将数列的参数代入公式中:
因此,总共进行了 N-1 次交换和 N(N-1)/2 次比较。 表达式:N(N-1)/2 + N-1
去掉常数项 低阶项 所以其时间复杂度为 O(N2)。
java
import java.util.Arrays;
/**
* @author zhang
* @date 2024/3/1 9:55
* 选择排序
*/
public class Code01_SelectionSort {
/**
* 选择排序
*
* @param arr 待排序数组
* 时间复杂度 O(N^2)
* 1. 假设当前数组最小值是arr[i],记录索引为 minIndex,那么从i+1开始遍历,
* 找到比最小值arr[minIndex]还小的元素的索引,将minIndex更新为j。然后将arr[i]和arr[minIndex]交换位置
* 2. 重复1,直到遍历完整个数组
*/
public static void selectionSort(int[] arr) {
if (arr == null || arr.length < 2) {
return;
}
for (int i = 0; i < arr.length; i++) {
int minIndex = i;
for (int j = i + 1; j < arr.length; j++) {
if (arr[j] < arr[minIndex]) {
minIndex = j;
}
}
int temp = arr[i];
arr[i] = arr[minIndex];
arr[minIndex] = temp;
}
}
/**
* 生成一个随机数组
* @param maxSize 数组最大长度
* @param maxValue 数组最大值
* @return 数组
*/
public static int[] generateRandomArray(int maxSize, int maxValue) {
int[] arr = new int[(int) ((maxSize + 1) * Math.random())];
for (int i = 0; i < arr.length; i++) {
arr[i] = (int) ((maxValue + 1) * Math.random()) - (int) (maxValue * Math.random());
}
return arr;
}
public static void main(String[] args) {
int maxSize = 100;
int maxValue = 100;
int testTime = 50000;
for (int i = 0; i < testTime; i++) {
int[] arr1 = generateRandomArray(maxSize, maxValue);
int[] arr2 = arr1.clone();
selectionSort(arr1);
Arrays.sort(arr2);
if (!Arrays.equals(arr1, arr2)) {
System.out.println("Oops!");
return;
}
}
System.out.println("Nice!");
}
}
冒泡排序
执行过程:
在数组arr的范围[0, N-1]内,执行以下操作: 比较arr[0]和arr[1],将较大的数置于位置1; 随后,比较arr[1]和arr[2],将较大的数置于位置2; 此过程持续进行,直到比较arr[N-2]和arr[N-1],并将较大的数置于位置N-1。
接下来,在范围[0, N-2]内重复上述过程,但在最后一步中,比较arr[N-3]和arr[N-2],并将较大的数置于位置N-2。
类似地,对于范围[0, N-3],重复上述过程,但在最后一步中,比较arr[N-4]和arr[N-3],并将较大的数置于位置N-3。
此过程继续进行,直至在范围[0, 1]内执行最后一步,即比较arr[0]和arr[1],并将较大的数置于位置1。
时间复杂度分析:
- 比较次数
第一轮我们是从 0~N-1 ,N 个数做比较 进行了 N-1 次比较
第二轮我们从0~N-2 ,N-1 个数做比较 进行了 N-2 次比较 ...... 所以这个比较总次数 = (N-1) + (N-2) + ... + 2 + 1 这又是一个等差数列的求和,其和为: 比较总次数 = (N-1) * N / 2
- 交换次数
这个交换次数一定不会大于这个比较次数 最坏情况下,每次比较都交换,交换次数和总比较次数一样也是 (N-1) * N / 2
- 进行了多少轮
对于一个长度为 N 的数组,第一轮会有 N 个元素参与比较和交换,第二轮会有 N - 1 个元素参与,以此类推,直到最后一轮只有两个元素参与比较和交换。所以总共需要进行 N-1 轮冒泡排序 最终简化过程如下: 比较总次数 = (N-1) * N / 2 = O(N2) 交换次数(最坏情况) = (N-1) * N / 2 = O(N2) 进行的轮数 = N-1 = O(N)
由于比较和交换操作的次数都是 N2 阶的,而轮数是 N 阶的,我们取最高阶项来确定整体时间复杂度。因此,冒泡排序的时间复杂度是 O(N2)。
java
import java.util.Arrays;
import static com.zhang.chapter01.util.RandomArrayUtil.generateRandomArray;
/**
* @author zhang
* @date 2023/11/21 13:27
* @description 冒泡排序
*/
public class Code02_BubbleSort {
/**
* 冒泡排序
*
* @param arr 待排序的数组
* 时间复杂度:O(n^2)
*/
public static void bubbleSort(int[] arr) {
if (arr == null || arr.length < 2) {
return;
}
// 外层循环控制需要比较的轮数
for (int i = 0; i < arr.length; i++) {
// 内层循环控制每一轮比较的次数
for (int j = 0; j < arr.length - 1 - i; j++) {
// 比较相邻的元素,如果前面一个比后面一个大,则交换它们
if (arr[j] > arr[j + 1]) {
int temp = arr[j];
arr[j] = arr[j + 1];
arr[j + 1] = temp;
}
}
}
}
public static void main(String[] args) {
int maxSize = 100;
int maxValue = 100;
int testTime = 50000;
for (int i = 0; i < testTime; i++) {
int[] arr1 = generateRandomArray(maxSize, maxValue);
int[] arr2 = arr1.clone();
bubbleSort(arr1);
Arrays.sort(arr2);
if (!Arrays.equals(arr1, arr2)) {
System.out.println("Oops!");
return;
}
}
System.out.println("Nice!");
}
}
插入排序
- 从第一个元素开始,认为该元素已经被排序。
- 取出下一个元素,在已经排序的元素序列中从后向前扫描。
- 如果该元素(已排序)大于新元素,将该元素移到下一位置。
- 重复步骤3,直到找到已排序的元素小于或者等于新元素的位置。
- 将新元素插入到该位置后。
- 重复步骤2~5,直到整个数组排序完毕。
估算时发现这个算法流程的复杂程度,会因为数据状况的不同而不同。
- 最好情况(Best Case):当输入数组已经是排序好的,那么每次插入操作只需要比较一次,因此总的比较次数是N-1次(从第二个元素开始,每个元素与前面一个元素比较一次)。这种情况下,时间复杂度是O(N)。
- 最坏情况(Worst Case):当输入数组是逆序排列的,每次插入操作都需要比较并移动前面所有的元素,因此总的比较次数是1 + 2 + 3 + ... + (N-1) = (N-1) * N / 2次。这种情况下,时间复杂度是O(N^2)。
如果某个算法流程的复杂程度会根据数据状况的不同而不同,那么必须要按照最差情况来估计。很明显,在最差情况下,如果arr长度为N,插入排序的每一步常数操作的数量,还是如等差数列一般 最坏情况 总的比较次数 : 比较次数序列为:0 + 1 + 2 + ... + (N-2) + (N-1) 轮数: 轮数指的是整个排序过程中需要遍历数组的次数。在插入排序中,每轮都会将一个元素插入到已排序的部分中。由于有N个元素需要插入,因此总共有N轮。 所以,最坏情况下轮数是N。 所以插入排序排序的时间复杂度为O(N2)。
java
/**
* 插入排序
*
* @param arr 需要排序的数组
*/
public static void insertSort(int[] arr) {
if (arr == null || arr.length <= 1) {
return;
}
for (int i = 1; i < arr.length; i++) {
// for (int j = i - 1; j >= 0 && arr[j] > arr[j + 1]; j--) {
// swap(arr, j, j + 1);
// }
for (int j = i; j > 0 && arr[j] < arr[j - 1]; j--) {
swap(arr, j, j - 1);
}
}
}
private static void swap(int[] arr, int i, int minIndex) {
// int temp = arr[i];
// arr[i] = arr[minIndex];
// arr[minIndex] = temp;
// i 和 minIndex 是同一个位置时候会出错
if (i != minIndex) {
arr[i] = arr[i] ^ arr[minIndex];
arr[minIndex] = arr[i] ^ arr[minIndex];
arr[i] = arr[i] ^ arr[minIndex];
}
}
注意
- 算法的过程,和具体的语言是无关的。
- 想分析一个算法流程的时间复杂度的前提,是对该流程非常熟悉
- 一定要确保在拆分算法流程时,拆分出来的所有行为都是常数时间的操作。这意味着你写算法时,对自己的用过的每一个系统api,都非常的熟悉。否则会影响你对时间复杂度的估算。
时间复杂度的意义
抹掉了好多东西,只剩下了一个最高阶项... 那这个东西有什么意义呢? 时间复杂度的意义在于:当输入样本数量变得非常大时,高阶项的执行次数将远超过低阶项,从而主导了整个算法的运行时间。 这就是时间复杂度的意义,它是衡量算法流程的复杂程度的一种指标,该指标只与数据量有关,与过程之外的优化无关。