当我们进行算法分析时,通常会忽略掉常数倍数的因子和低阶项,只考虑最高阶的项。这是因为在大规模问题下,较小的项和常数倍数的因子相对于最高阶的项来说变得可以忽略不计。
以下是一些常见的示例,说明了常数倍数的因子和高阶项对算法的影响:
O(2n) 和 O(n):在 O(2n) 中,常数倍数因子为 2,而在 O(n) 中为 1。但是,当 n 变得非常大时,2n 和 n 之间的差距就变得微不足道,因此我们可以说 O(2n) 等价于 O(n)
O(3n^2) 和 O(n^2):在 O(3n^2) 中,常数倍数因子为 3,而在 O(n^2) 中为 1。但是,当 n 变得非常大时,3n^2 和 n^2 之间的差距就变得微不足道,因此我们可以说 O(3n^2) 等价于 O(n^2)
O(n^2 + n) 和 O(n^2):在 O(n^2 + n) 中,我们有两个项,分别是 n^2 和 n。然而,在大规模问题下,n 这样的低阶项可以被 n^2 这样的高阶项主导,因此我们可以忽略掉 n,即 O(n^2 + n) 等价于 O(n^2)
O(n^3 + n^2) 和 O(n^3):在 O(n^3 + n^2) 中,我们有两个项,分别是 n^3 和 n^2。同样,在大规模问题下,n^2 这样的低阶项可以被 n^3 这样的高阶项主导,因此我们可以忽略掉 n^2,即 O(n^3 + n^2) 等价于 O(n^3)
通过忽略常数倍数的因子和低阶项,我们可以简化算法的复杂度表示,并更好地理解算法的增长趋势和相对性能。这种简化使得我们能够更容易地比较和分析不同算法之间的效率。
基本的冒泡排序算法
public static void bubbleSort(int[] arr) {
int n = arr.length;
for (int i = 0; i < n - 1; i++) {
for (int j = 0; j < n - i - 1; j++) {
if (arr[j] > arr[j + 1]) {
// 交换arr[j]和arr[j+1]
int temp = arr[j];
arr[j] = arr[j + 1];
arr[j + 1] = temp;
}
}
}
}
即当输入的序列是降序排列时,每次比较都需要进行交换操作。在最坏情况下,共进行了(n-1)+(n-2)+…+2+1 = n*(n-1)/2次比较和交换,时间复杂度为O(n^2)
1、在最好的情况下,即当输入的序列已经是升序排列时,冒泡排序只需要进行一遍比较即可完成排序。
2、但是根据上面代码,无论输入序列是否有序,冒泡排序都将进行n*(n-1)/2次比较,时间复杂度为O(n^2)。
3、其实,最好情况下的时间复杂度O(n)通常是指在某些优化的冒泡排序算法中,如果发现某一轮比较中没有交换操作,就可以提前结束排序。
/**
* 改进版冒泡排序
* @param arr
*/
public static void improvedBubbleSort(int[] arr) {
int n = arr.length;
boolean swapped;
for (int i = 0; i < n - 1; i++) {
swapped = false;
for (int j = 0; j < n - i - 1; j++) {
if (arr[j] > arr[j + 1]) {
// 交换arr[j]和arr[j+1]
int temp = arr[j];
arr[j] = arr[j + 1];
arr[j + 1] = temp;
swapped = true;
}
}
// 如果某轮比较没有发生交换,说明已经有序,提前结束排序
if (!swapped) {
break;
}
}
}
public class QuickSort {
public static void main(String[] args) {
int[] arr = {7, 2, 1, 6, 8, 5, 3, 4};
quickSort(arr, 0, arr.length - 1);
for (int num : arr) {
System.out.print(num + " ");
}
}
public static void quickSort(int[] arr, int low, int high) {
if (low < high) {
// 分区操作,将数组分为两部分,返回基准元素的索引
int pivot = partition(arr, low, high);
// 对左子数组进行快速排序
quickSort(arr, low, pivot - 1);
// 对右子数组进行快速排序
quickSort(arr, pivot + 1, high);
}
}
public static int partition(int[] arr, int low, int high) {
// 选择最后一个元素作为基准
int pivot = arr[high];
// i 指向小于基准的元素的位置
int i = low - 1;
// 遍历数组,将小于基准的元素移动到基准的左边
for (int j = low; j < high; j++) {
if (arr[j] < pivot) {
i++;
swap(arr, i, j);
}
}
// 将基准元素放到正确的位置上
swap(arr, i + 1, high);
// 返回基准元素的索引
return i + 1;
}
public static void swap(int[] arr, int i, int j) {
// 交换数组中两个元素的位置
int temp = arr[i];
arr[i] = arr[j];
arr[j] = temp;
}
}
快速排序的最好情况下,每次划分都能将待排序序列分成长度为 n/2 的两个子序列。假设递归树的深度为 d,初始时,序列的长度为 n。每次划分后,序列的长度变为原来的一半,即 n/2。那么经过 d 次划分后,序列的长度变为 n/(2^d)。当划分完毕后,序列的长度为 1。
所以我们有以下等式:n/(2^d) = 1
通过移项,可以得到:
n = 2^d
取以 2 为底的对数,我们得到:
d = log2(n)
此时递归树的深度为 log2n,每层的时间复杂度为O(n),因此最好情况下的时间复杂度为O(nlogn)
在算法分析中,我们通常只关注时间复杂度的增长趋势,而不是具体的常数因子或底数。因此,在常见的情况下,会省略对数的底数,并将时间复杂度简化为O(n log n)。
对数的底数对于增长趋势的影响较小。对于底数为2的对数(log2n)和底数为10的对数(log10n)来说,它们之间的差异只是一个常数因子,而不会改变时间复杂度的增长趋势。
深度算法:
快速排序的最坏情况下,每次划分都将待排序序列分为长度为 1 和 n-1 的两个子序列,此时递归树的深度为 n,每层的时间复杂度为O(n),因此最坏情况下的时间复杂度为O(n^2)
public class MergeSort {
public static void mergeSort(int[] arr) {
if (arr == null || arr.length <= 1) {
return;
}
int[] temp = new int[arr.length];
mergeSort(arr, 0, arr.length - 1, temp);
}
private static void mergeSort(int[] arr, int left, int right, int[] temp) {
if (left < right) {
int mid = (left + right) / 2;
mergeSort(arr, left, mid, temp); // 左边归并排序,使得左子序列有序
mergeSort(arr, mid + 1, right, temp); // 右边归并排序,使得右子序列有序
merge(arr, left, mid, right, temp); // 将两个有序子数组合并操作
}
}
private static void merge(int[] arr, int left, int mid, int right, int[] temp) {
int i = left; // 左序列指针
int j = mid + 1; // 右序列指针
int t = 0; // 临时数组指针
while (i <= mid && j <= right) {
if (arr[i] <= arr[j]) {
temp[t++] = arr[i++];
} else {
temp[t++] = arr[j++];
}
}
while (i <= mid) { // 将左边剩余元素填充进temp中
temp[t++] = arr[i++];
}
while (j <= right) { // 将右序列剩余元素填充进temp中
temp[t++] = arr[j++];
}
t = 0;
// 将temp中的元素全部拷贝到原数组中
while (left <= right) {
arr[left++] = temp[t++];
}
}
public static void main(String[] args) {
int[] arr = {5, 3, 8, 6, 2, 7, 1, 4};
mergeSort(arr);
System.out.println(Arrays.toString(arr)); // 输出排序结果:[1, 2, 3, 4, 5, 6, 7, 8]
}
}
最坏、最好情况下,归并排序的时间复杂度是O(nlogn)。这种情况发生在每次划分子问题时,都需要将待排序数组的元素分成两半,并且这个过程需要递归执行直到只剩下一个元素为止。
(与快速排序的最好情况类似)然后再将这些子问题的解归并成一个有序数组。因此,在最坏、最好情况下,需要进行logn次划分,每次划分需要O(n)的时间复杂度来合并子问题的解。并且这个时间复杂度是稳定的。
具体的排序过程如下:
假设要排序的数组为 [5, 2, 4, 6, 1, 3]
第一步,将第一个元素 5 视为已排序的部分:[5]
第二步,将第二个元素 2 插入到已排序的部分中,比较得到 [2, 5]
第三步,将第三个元素 4 插入到已排序的部分中,比较得到 [2, 4, 5]
第四步,将第四个元素 6 插入到已排序的部分中,比较得到 [2, 4, 5, 6]
第五步,将第五个元素 1 插入到已排序的部分中,比较得到 [1, 2, 4, 5, 6]
第六步,将第六个元素 3 插入到已排序的部分中,比较得到 [1, 2, 3, 4, 5, 6]
最终得到的排序结果为 [1, 2, 3, 4, 5, 6]
public class InsertionSort {
public static void main(String[] args) {
int[] arr = {5, 2, 9, 1, 5, 6};
insertionSort(arr);
}
// 插入排序函数
public static void insertionSort(int[] arr) {
int n = arr.length;
for (int i = 1; i < n; i++) {
int key = arr[i]; // 当前要插入的元素
int j = i - 1;
// 将大于key的元素向右移动,为key腾出位置
while (j >= 0 && arr[j] > key) {
arr[j + 1] = arr[j];
j--;
}
arr[j + 1] = key; // 插入key到正确的位置
}
}
}
最好情况发生在输入数据已经是有序的情况下。在这种情况下,插入排序只需要将每个元素与其前面的元素进行比较,不需要进行元素的移动,因此时间复杂度是线性的,即O(n)
最坏情况发生在输入数据是逆序的情况下。在这种情况下,每个元素都必须与前面的所有元素进行比较,并且需要进行大量的元素移动,1+2+3+…+n = (n-1)*(n-1+1)/2 导致时间复杂度为O(n^2)
public class HeapSort {
public static void heapSort(int arr[]) {
int n = arr.length;
// 构建最大堆
buildMaxHeap(arr, n);
// 逐个提取元素并排序
for (int i = n - 1; i > 0; i--) {
// 交换堆顶元素(最大值)和当前未排序部分的最后一个元素
swap(arr, 0, i);
// 调整堆,使得未排序部分重新成为最大堆
heapify(arr, i, 0);
}
}
// 构建最大堆
private static void buildMaxHeap(int arr[], int n) {
// 从最后一个非叶子节点开始,逐个向上调整节点位置
for (int i = n / 2 - 1; i >= 0; i--) {
heapify(arr, n, i);
}
}
// 调整堆,使其满足最大堆性质
private static void heapify(int arr[], int n, int i) {
int largest = i; // 初始化最大值索引
int left = 2 * i + 1; // 左子节点索引
int right = 2 * i + 2; // 右子节点索引
// 找到左子节点的索引,并比较其值与父节点的值
if (left < n && arr[left] > arr[largest]) {
largest = left;
}
// 找到右子节点的索引,并比较其值与父节点的值
if (right < n && arr[right] > arr[largest]) {
largest = right;
}
// 如果最大值索引不是当前节点索引,则交换节点值,并递归调整下层堆结构
if (largest != i) {
swap(arr, i, largest);
heapify(arr, n, largest);
}
}
// 交换数组中的两个元素
private static void swap(int arr[], int i, int j) {
int temp = arr[i];
arr[i] = arr[j];
arr[j] = temp;
}
public static void main(String args[]) {
int arr[] = { 12, 11, 13, 5, 6, 7 };
int n = arr.length;
heapSort(arr);
}
}
O(n log n) - 堆排序的最佳时间复杂度也是O(n log n),因为它在构建最大堆和重复调整堆的过程中都需要对整个数组进行遍历,所以无论输入数据的分布如何,时间复杂度都保持不变。
O(n log n) - 平均情况下,堆排序的时间复杂度也是O(n log n)。虽然堆排序的性能不太依赖于输入数据的分布,但由于构建最大堆的操作会导致较多的比较和交换,因此其平均时间复杂度仍然是O(n log n)。
O(n log n) - 堆排序的最坏时间复杂度是O(n log n),这是因为在最坏情况下,每次调整堆时都需要将元素下移到树的底部,这导致了较多的比较和交换操作。