目录
排序算法是一种用于对元素进行排序的方法。不同的排序算法具有不同的特点和适用场景。本篇博客将介绍常见的排序算法,包括冒泡排序、选择排序、插入排序、快速排序和归并排序,并讨论它们的使用场景和时间复杂度。
前言
常见排序算法可以分为两大类:
- 非线性时间比较类排序:通过比较来决定元素间的相对次序,由于其时间复杂度不能突破O(nlogn),因此称为非线性时间比较类排序。如:快速排序、归并排序、堆排序、冒泡排序等。在排序的最终结果里,元素之间的次序依赖于它们之间的比较。每个数都必须和其他数进行比较,才能确定自己的位置。
- 线性时间非比较类排序:不通过比较来决定元素间的相对次序,它可以突破基于比较排序的时间下界,以线性时间运行,因此称为线性时间非比较类排序。如:计数排序、基数排序、桶排序。非比较排序是通过确定每个元素之前,应该有多少个元素来排序。针对数组arr,计算arr之前有多少个元素,则唯一确定了arr在排序后数组中的位置。
常见算法的复杂度分析
参考这里:所谓稳定性是指待排序的序列中有两元素相等,排序之后它们的先后顺序不变.假如为A1,A2.它们的索引分别为1,2.则排序之后A1,A2的索引仍然是1和2.(相同的记录在排序前后相对次序不发生改变,那么就是稳定的排序)
对于不稳定的 排序算法 ,只要举出一个实例,即可说明它的不稳定性;而对于稳定的排序算法,必须对算法进行分析从而得到稳定的特性。需要注意的是, 排序算法是否为稳定的是由具体算法决定的 ,不稳定的算法在某种条件下可以变为稳定的算法,而稳定的算法在某种条件下也可以变为不稳定的算法。例如,对于如下冒泡排序算法,原本是稳定的排序算法,如果将记录交换的条件改成a[j]>=a[j+1],则两个相等的记录就会交换位置。
稳定性的意义:
1、如果只是简单的进行数字的排序,那么稳定性将毫无意义。
2、如果排序的内容仅仅是一个复杂对象的某一个数字属性,那么稳定性依旧将毫无意义(所谓的交换操作的开销已经算在算法的开销内了,如果嫌弃这种开销,不如换算法好了?)
3、如果要排序的内容是一个复杂对象的多个数字属性,但是其原本的初始顺序毫无意义,那么稳定性依旧将毫无意义。
4、除非要排序的内容是一个复杂对象的多个数字属性,且其原本的初始顺序存在意义,那么我们需要在二次排序的基础上保持原有排序的意义,才需要使用到稳定性的算法,例如要排序的内容是一组原本按照价格高低排序的对象,如今需要按照销量高低排序,使用稳定性算法,可以使得想同销量的对象依旧保持着价格高低的排序展现,只有销量不同的才会重新排序。(当然,如果需求不需要保持初始的排序意义,那么使用稳定性算法依旧将毫无意义)。
- 稳定排序算法:冒泡排序、插入排序、归并排序、(计数排序、桶排序与基数排序)
- 不稳定排序算法:希尔排序、选择排序、堆排序与快速排序
复杂度分析:
- 数据结构和算法本身解决的是“快”和“省”的问题,衡量算法执行效率的指标就包括复杂度
- 复杂度分析包括时间复杂度和空间复杂度分析,时间复杂度是指算法执行的时间与数据规模的关系,空间复杂度是指算法占用的空间与数据规模的关系
为什么进行复杂度分析?如何分析?
- 为什么分析复杂度:通过测试、统计、监控,可以的得到算法执行的时间和占用的内存大小,但是测试结果非常依赖测试环境,测试结果受数据规模的影响很大;我们需要一个不依赖测试环境和数据规模就可以粗略估算算法执行效率的方法
大O时间复杂度表示法:表示代码执行时间随数据规模增长的变化趋势,又称渐进时间复杂度。
大O空间复杂度表示法:表示代码执行所占的内存空间随数据规模增长的变化趋势,又称渐进空间复杂度 ps:给出随着增长规模的下界,具体流程:
- 大O复杂度表示法
算法的执行效率,粗略地讲就是算法的执行时间。下面的代码是求1,2,3...n累加的和。
cal(int n) { int sum = 0; int i = 1; for (; i <= n; ++i) { sum += i; // 两步操作 } return sum; }
从CPU的角度,这段代码的操作是,读数据 -> 运算 -> 写数据,如果每一个操作都是unit_time,第二行和第三行是一个unit_time,而第四行和第五行的for循环是2n个unit_time,加上return操作。时间复杂度就是2n+3,一般计算的时候会把常量省略,所以这个程序的时间复杂度就是n。所以就可以推断出,所以代码的执行时间T(n)与每行代码的的执行次数成正比。
引出重要概念:所有代码的执行时间 T(n) 与每行代码的执行次数 n 成正比,即T(n) = O(f(n))
- 复杂度分析法则
-
- 单段代码,看循环的次数。
- 多段代码,看代码循环量级。
- 嵌套代码求乘积,比如递归和多重循环。
- 多个规模求加法,方法中并行的两个循环。
- 常用的复杂度级别
-
- 多项式阶:随着数据规模的增长,算法的执行时间和空间占用,按照多项式的比例增长,包括,O(1)(常数阶)、O(logn)(对数阶)、O(n)(线性阶)、O(nlogn)(线性对数阶)、O(n2)(平方阶)、O(n3)(立方阶)。
- 非多项式阶:随着数据规模的增长,算法的执行时间和空间占用暴增,这列算法性能极差。包括,O(2^n)(指数阶)、O(n!)(阶乘阶)
- 复杂度分析的四个概念
-
- 最坏情况时间复杂度:代码在最坏情况下执行的时间复杂度。
- 最好情况时间复杂度:代码在最理想情况下执行的时间复杂度。
- 平均时间复杂度:用代码在所有情况下执行的次数的加权平均值表示。
- 均摊时间复杂度:在代码执行的所有复杂度情况中绝大部分是低级别的复杂度,个别情况是高级别复杂度且发生具有时序关系时,可以将个别高级别复杂度均摊到低级别复杂度上。基本上均摊结果就等于低级别复杂度。
ps:区分平均时间复杂度和均摊时间复杂度
- 平均时间复杂度:代码在不同情况下复杂度出现量级差别,则用代码所有可能情况下执行次数的加权平均值表示。
- 均摊时间复杂度:两个条件满足时使用:1)代码在绝大多数情况下是低级别复杂度,只有极少数情况是高级别复杂度,如hashmap查找元素时间复杂度 O(1);2)低级别和高级别复杂度出现具有时序规律。均摊结果一般都等于低级别复杂度。
排序算法是一种用于对元素进行排序的方法。不同的排序算法具有不同的特点和适用场景。本篇博客将介绍常见的排序算法,包括冒泡排序、选择排序、插入排序、快速排序和归并排序,并讨论它们的使用场景和时间复杂度。
一.冒泡排序(Bubble Sort):
- 使用场景:适用于小型数据集的排序,或者用于教学目的。
- 时间复杂度:最好情况O(n),平均情况O(n^2),最差情况O(n^2)。
public class BubbleSort {
/**
* 冒泡排序算法
*
* @param arr 待排序的数组
*/
public static void bubbleSort(int[] arr) {
int n = arr.length;
for (int i = 0; i < n - 1; i++) { // 进行n-1轮冒泡
for (int j = 0; j < n - i - 1; j++) { // 每轮从头到尾比较相邻元素并交换
if (arr[j] > arr[j + 1]) { // 如果前一个元素大于后一个元素,则交换位置
int temp = arr[j];
arr[j] = arr[j + 1];
arr[j + 1] = temp;
}
}
}
}
}
二.选择排序(Selection Sort):
- 使用场景:适用于小型数据集的排序。
- 时间复杂度:最好情况O(n^2),平均情况O(n^2),最差情况O(n^2)
public class SelectionSort {
/**
* 选择排序算法
*
* @param arr 待排序的数组
*/
public static void selectionSort(int[] arr) {
int n = arr.length;
for (int i = 0; i < n - 1; i++) { // 进行n-1轮选择
int minIdx = i;
for (int j = i + 1; j < n; j++) { // 在剩余的元素中找到最小值的索引
if (arr[j] < arr[minIdx]) {
minIdx = j;
}
}
int temp = arr[minIdx]; // 将最小值与当前位置的元素交换
arr[minIdx] = arr[i];
arr[i] = temp;
}
}
}
三.插入排序(Insertion Sort):
- 使用场景:适用于小型数据集的排序或部分有序的数据集。
- 时间复杂度:最好情况O(n),平均情况O(n^2),最差情况O(n^2)。
public class InsertionSort {
/**
* 插入排序算法
*
* @param 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;
while (j >= 0 && arr[j] > key) { // 将大于key的元素向后移动
arr[j + 1] = arr[j];
j--;
}
arr[j + 1] = key; // 插入key到正确的位置
}
}
}
四.快速排序(Quick Sort):
- 使用场景:适用于大型数据集的排序。
- 时间复杂度:最好情况O(nlogn),平均情况O(nlogn),最差情况O(n^2)。
public class QuickSort {
/**
* 快速排序算法
*
* @param arr 待排序的数组
* @param low 数组的起始下标
* @param high 数组的结束下标
*/
public static void quickSort(int[] arr, int low, int high) {
if (low < high) {
int pi = partition(arr, low, high); // 找到分区点
quickSort(arr, low, pi - 1); // 对分区点左侧进行快速排序
quickSort(arr, pi + 1, high); // 对分区点右侧进行快速排序
}
}
/**
* 划分数组并找到分区点
*
* @param arr 待划分的数组
* @param low 数组的起始下标
* @param high 数组的结束下标
* @return 分区点的索引
*/
private static int partition(int[] arr, int low, int high) {
int pivot = arr[high]; // 选取最后一个元素作为基准值
int i = low - 1; // 初始化i为分区点的前一个元素
for (int j = low; j < high; j++) {
if (arr[j] < pivot) { // 如果当前元素小于基准值,则将其放入左侧分区
i++;
int temp = arr[i];
arr[i] = arr[j];
arr[j] = temp;
}
}
int temp = arr[i + 1]; // 将基准值放入正确的位置
arr[i + 1] = arr[high];
arr[high] = temp;
return i + 1; // 返回分区点的索引
}
}
五.归并排序(Merge Sort):
- 使用场景:适用于大型数据集的排序。
- 时间复杂度:最好情况O(nlogn),平均情况O(nlogn),最差情况O(nlogn)。
public class MergeSort {
/**
* 归并排序算法
*
* @param arr 待排序的数组
* @param left 数组的起始下标
* @param right 数组的结束下标
*/
public static void mergeSort(int[] arr, int left, int right) {
if (left < right) {
int mid = (left + right) / 2; // 计算中间元素的索引
mergeSort(arr, left, mid); // 对左侧子数组进行归并排序
mergeSort(arr, mid + 1, right); // 对右侧子数组进行归并排序
merge(arr, left, mid, right); // 合并两个已排序的子数组
}
}
/**
* 合并两个已排序的子数组
*
* @param arr 待合并的数组
* @param left 左侧子数组的起始下标
* @param mid 中间元素的下标
* @param right 右侧子数组的结束下标
*/
private static void merge(int[] arr, int left, int mid, int right) {
int n1 = mid - left + 1; // 计算左侧子数组的长度
int n2 = right - mid; // 计算右侧子数组的长度
int[] L = new int[n1];
int[] R = new int[n2];
System.arraycopy(arr, left, L, 0, n1); // 将元素复制到临时数组L和R中
System.arraycopy(arr, mid + 1, R, 0, n2);
int i = 0, j = 0;
int k = left;
while (i < n1 && j < n2) { // 逐个比较并合并元素
if (L[i] <= R[j]) {
arr[k] = L[i];
i++;
} else {
arr[k] = R[j];
j++;
}
k++;
}
while (i < n1) { // 将剩余的元素复制到原数组中
arr[k] = L[i];
i++;
k++;
}
while (j < n2) {
arr[k] = R[j];
j++;
k++;
}
}
}