概述
常见排序算法可以分为两大类:
- 比较类排序:通过比较来决定元素间的相对次序,所以其时间复杂度不能突破O(nlogn)。不过比较类排序适用于各种规模的数据,也不在乎数据的分布。可以说,比较排序适用于一切需要排序的情况。
- 比较类排序的排序算法有:冒泡排序、选择排序、插入排序、希尔排序、归并排序、快速排序、堆排序
- 非比较类排序:不通过比较来决定元素间的相对次序,而是通过确定每个元素之前应该有多少个元素来排序。只要确定每个元素之前的已有的元素个数,所以只需要遍历一次即可。因此它可以突破基于比较排序O(nlogn)的时间下界,以线性时间运行。不过非比较排序对数据规模和数据分布有一定的要求
- 非比较类排序的排序算法有:计数排序、桶排序、基数排序
常见排序算法:
排序方法 | 时间复杂度(平均) | 时间复杂度(最坏) | 时间复杂度(最好) | 空间复杂度 | 稳定性 | 额外内存 |
---|---|---|---|---|---|---|
冒泡排序 | O(n^2) | O(n^2) | O(n) | O(1) | 稳定 | 不占用 |
选择排序 | O(n^2) | O(n^2) | O(n^2) | O(1) | 不稳定 | 不占用 |
插入排序 | O(n^2) | O(n^2) | O(n) | O(1) | 稳定 | 不占用 |
希尔排序 | O(n^1.3) | O(n^2) | O(n) | O(1) | 不稳定 | 不占用 |
归并排序 | O(n log n) | O(n log n) | O(n log n) | O(n) | 稳定 | 占用 |
快速排序 | O(n log n) | O(n^2) | O(n log n) | O(log n) | 不稳定 | 不占用 |
堆排序 | O(n log n) | O(n log n) | O(n log n) | O(1) | 不稳定 | 不占用 |
计数排序 | O(n+k) | O(n+k) | O(n+k) | O(k) | 稳定 | 占用 |
桶排序 | O(n+k) | O(n^2) | O(n+k) | O(n+k) | 稳定 | 占用 |
基数排序 | O(n*k) | O(n*k) | O(n*k) | O(n+k) | 稳定 | 占用 |
每个列的含义:
- 时间复杂度 : 一个算法执行所耗费的时间。
- 空间复杂度 :运行完一个程序所需内存的大小。
- 稳定性:如果一个数组中a原本在b前面,而a=b,排序之后a仍然在b的前面,则为稳定。如果排序后a可能在b的后面,则为不稳定
- 占用额外内存:排序中是否需要占用额外的内存
注意:希尔排序的时间复杂度和增量序列是相关的,而跟增量序列相关的复杂度没有人能够准确的计算,所以不同的地方说的不同。目前没有一个标准答案
判断一个排序方式是否稳定的口诀:快些选一堆不稳定的,其他都是稳定的。其中快些选一堆的意思是:快速排序(快),希尔排序(些),选择排序(选),堆排序(一堆)
各个排序的使用场景
使用的时候从上到下依次匹配
- 计数排序
- 如果数据全是整数,且数据范围不是很大的情况下优先考虑计数排序,例如一群人的年龄,考试的分数等等
- 桶排序
- 如果数据比较均匀的分布在某一范围内的情况下使用
- 堆排序
- 如果只是对整个数组中的某些数据排序例如在数组中找出前几个最大值或最小值。那么就可以采用堆排序
- 归并排序
- 如果要求排序后保证数据的稳定性以及在数据处于基本有序状态下,则可以采用归并排序
- 快速排序
- 如果以上排序都不适用那就使用快速排序,快速排序是适用性最广的,并且性能很好。不过需要注意的是,当数据处于基本有序状态那就不要使用快速排序了,这会让快速排序处于时间复杂度最坏的情况
- 基数排序
- 这个很少用到,因为他的排序效率还没有快速排序好并且还很占用内存。原因是常数项k比较大。他适合用于对时间、字符串等这些整体权值未知的数据进行排序。例如字符串类型的时间和手机号码
- 冒泡排序 、选择排序、插入排序、希尔排序
- 这几个虽然简单但是他们的时间复杂度都没有到nlogn,所以对于大量数据性能是非常差的。如果是数据很小可以考虑使用希尔排序
在一般情况下最常用的是快速排序,不过由于快速排序是不稳定的而且在数据处于基本有序状态下快速排序的时间复杂度接近O(n^2),所以如果要求的稳定的情况下以及数据处于基本有序的情况下通常使用归并排序。当然如果满足某些排序的特殊场景那么优先使用那种排序
排序的区别
- 归并排序和快速排序
- 他们的时间复杂度都是O(n log n)的,由于归并排序需要使用额外的存储空间,所以快速排序会优于归并排序。虽然快速排序的最坏情况下的时间复杂度为O(n^2)不过这种情况并不多见。因为快速排序出现最差的情况是:每次所选的中间数是当前序列中的最大或最小元素,这使得每次划分所得的子数组中一个为空数组,另一子数组的长度为原数组的长度-1。这样,长度为n的数组的快速排序需要经过n趟划分,使得整个排序算法的时间复杂度为O(n2)。当然如果要求排序的稳定性为稳定那么就只能用归并排序
- 快速排序和堆排序
- 就根据上图而言堆排序的会比快速排序要好,因为堆排序的时间复杂度稳定在O(n log n),并且空间复杂度为O(1)。不过数学上的时间复杂度不代表实际运行时的情况。在堆排中,每一个操作都是不利于程序的局部性原理的,每次元素间的比较、数的调整等,都不是相邻或者尽可能附近的元素间的比较(堆调整每次都从堆顶拿元素到堆底然后向下进行调整),那么这就需要不断地在内存和缓存间换入换出数据。反观快排,利用分而治之的方法,元素间的比较都在某个段内,局部性相当好,因此数据就可以直接在缓存中找。所以快速排序会比堆排序由更常用。当然如果只需要找出一个数组中前几个最大或最小的数的时候堆排序是最好的选择。因为这种情况下我们不用把数组全部排序,只需要排序到前几个数就可以了
- 计数排序和桶排序
- 是否使用计数排序和桶排序要看实际情况,首先计数排序只能用于整数排序,并且在排序范围不大的时候可以考虑使用计数排序,而其他情况就不适用了。桶排序则是计数排序的优化将计数排序中的下标替换成了桶,在桶排序中会创建一定数量的桶,每个桶里存储某个范围内的数据,然后再依次对桶里面的数据进行排序。桶排序适用于数据比较均匀的分布在某一范围内的情况下。因为如果数据分布不均匀那么就会出现很多空桶,而有些桶却包含很多数据这样桶排序不仅创建了很多没用的空桶,最终还是用了每个桶里面进行排序的算法。所以桶排序就没有意义了。
冒泡排序法
描述
冒泡排序就是把相邻的元素进行比较,在升序排序中如果第一个比第二个大,则交换他们的位置。如果第二个比第三个大,则交换他们的位置一直下去直到数组倒数第二个和倒数第一个比较。这样一趟比较交换下来之后,数组中最大的数就被交换到了数组最后。然后重复这个过程,每轮交换后都能确定一个元素的位置。
当然交换的轮数只需要交换n-1轮就可以了,因为经过不断的交换后数组中剩下的最后一个元素由于已经没有元素跟他比较了所以他就是最大或最小的元素所以他就处于有序的位置。还有由于每经过一次交换就能确定一个元素的位置,所以对于已经确定位置的元素下一轮及以后的交换就不用再对确定位置的元素进行交换了,因此每交换一轮需要交换的元素就少一个。
动图
算法步骤
- 先确定需要交换的轮数,为n-1轮,然后进行轮数遍历
- 在每轮中依次比较两个相邻的元素。如果第一个比第二个大,则交换他们的位置。直到数组倒数第二个和倒数第一个比较完成则第一轮结束。其中每遍历一轮需要比较的元素就要-1
代码实现
private static void bubbleSort(int[] arr) {
if (arr!=null && arr.length>1){
int temp;
// 比较的轮数为n-1,因为最后一轮只剩一个元素因此就不用在比较了
for (int i = 0; i < arr.length-1; i++) {
// 每比较完一轮交换的元素就少一个所以要-i。
// 而-1是因为我们比较的时候是j跟j+1比较。所以只能遍历到倒数第二个元素,因为如果遍历到最后一个元素还为j那么j+1就会超出元素下标
for (int j = 0; j < arr.length - i - 1; j++) {
// 如果第一个比第二个大,则交换元素
if(arr[j] > arr[j+1]){
temp = arr[j];
arr[j] = arr[j+1];
arr[j+1] = temp;
}
}
}
}
}
优化实现
上面的方式如果数组本来就是有序的那么他依旧会遍历n-1轮,所以我们可以对对算法进行优化。
从上面的算法可以看出如果在第一轮的遍历中没有交换元素那么就说明后一个总是比前一个大,所以就可以说明他本来就是有序的,因此代码可以做如下优化
private static void bubbleSort(int[] arr) {
if (arr!=null && arr.length>1){
int temp;
// 用于判断是否进行了元素交换
boolean flag = true;
// 比较的轮数为n-1,因为最后一轮只剩一个元素因此就不用在比较了
for (int i = 0; i < arr.length-1; i++) {
// 每比较完一轮交换的元素就少一个所以要-i。
// 而-1是因为我们比较的时候是j跟j+1比较。所以只能遍历到倒数第二个元素,因为如果遍历到最后一个元素还为j那么j+1就会超出元素下标
for (int j = 0; j < arr.length - i - 1; j++) {
// 如果第一个比第二个大,则交换元素
if(arr[j] > arr[j+1]){
temp = arr[j];
arr[j] = arr[j+1];
arr[j+1] = temp;
flag = false;
}
}
// 如果元素之间没有进行交换则说明数组本来就是有序的直接跳出循环
if(flag){
break;
}
}
}
}
算法总结
- 时间复杂度:O(n^2)
- 空间复杂度:O(1)
- 稳定性:稳定
- 适用场景:
- 由于时间复杂度是O(n^2)所以基本上不会使用。尤其是数据量很大的时候千万不能用
选择排序
描述
选择排序就是依次遍历数组中的元素,然后让每个元素跟其他元素比较,如果是升序那么就找到最小的元素,然后再跟遍历到的当前元素交换位置,例如将数组中第一个元素跟其他元素比较找到数组中最小的那个元素,然后将它跟数组中第一个元素交换位置。在将数组中第二个元素跟其他元素比较找到数组中最小的那个元素,然后将它跟数组中第二个元素交换位置。依次下去直至到达倒数第二个元素,因为最后只剩下一个元素那个元素处于数组的最后自然就是有序的。
还有就是每经过一轮比较交换后都能够确定一个元素的位置,自然后面在新的一轮比较中就不用在对已经确定位置的元素在比较了
动图
算法步骤
- 先确定要比较的轮数,为n-1轮,然后进行轮数遍历
- 在每轮中从当前轮的起始位置开始跟后面的所有元素进行比较然后找到最小的元素,然后再将起始位置的元素跟最小的元素交换。其中每轮的起始位置就是轮数,从0到n-1
代码实现
private static void selectSort(int[] arr) {
if (arr!=null && arr.length>1){
int temp;
// 临时最小下标
int min;
// 遍历的轮数为n-1,因为最后一个元素不用在比较了
for (int i = 0; i < arr.length - 1; i++) {
// 默认最小下标为每一轮的第一个元素
min = i;
// 每轮中遍历数组的所有元素所以长度为arr.length(由于下标从0开始所以实际下标要-1所以是小于arr.length),
// 起始位置为每轮的起始位置+1,因为我们需要跟起始位置的元素比较
for (int j = i+1; j < arr.length; j++) {
// 遍历数组元素跟已知最小的元素比较,如果比已知最小元素还小则最小元素的下标就是与之比较的元素的下标
if (arr[min]>arr[j]){
min = j;
}
}
// 如果最小元素不是当前轮的第一个元素,则将最小元素跟当前轮的第一个元素交换
if(arr[min]!=arr[i]){
temp = arr[min];
arr[min] = arr[i];
arr[i] = temp;
}
}
}
}
算法总结
- 时间复杂度:O(n^2)
- 空间复杂度:O(1)
- 稳定性:不稳定
- 适用场景:
- 由于时间复杂度是O(n^2)所以基本上不会使用。尤其是数据量很大的时候千万不能用
插入排序
描述
插入排序中将数组分为了两个子数组一个是有序的一个是待排序的,首先会认为数组中第一个元素是有序的(因为只有一个嘛),把第一个元素当做是有序的数组,然后其他的元素当做待排序的数组。依次遍历待排序的数组将数组中的每个元素插入到有序数组中,当然插入后有序数组依然有序。具体插入的方式是,将待排序元素跟有序数组的元素依次进行比较,在升序排序中,将有序数组中大于待排序元素的有序元素都向后移一位。然后在将待排序元素在插入到空出的位置。重复这个过程依次将待排序数组中的所有元素都插入到有序数组中
动图
算法步骤
- 插入排序就是将所有待排序元素插入有序元素中,所以先确认待排序元素有多少,然后遍历这些元素,由于默认第一个元素是有序的,所以待排序的元素的大小为n-1。当然由于第一个元素默认为有序的,所以待排序的初始元素为1,但截止下标依旧为n
- 然后获取待排序元素,跟有序元素比较(从后向前),找到有序数组中比待排序元素小的元素,或者有序数组中参与比较的下标小于0了(这时待排序元素比有序数组中的所有元素都小),然后将这个元素后面的其他有序元素都向后移动一位,当然如果待排序元素比有序数组中的所有元素都小那么有序数组中的所有元素都要向后移动一位。
代码实现
private static void insertSort(int[] arr) {
if (arr!=null && arr.length>1) {
int temp;
// 待排序元素从1开始,直至数组末尾
for (int i = 1; i < arr.length; i++) {
// 有序数组中的最后一个元素
int k = i - 1;
// 待排序元素
temp = arr[i];
// 将待排序元素跟前面元素比较,比较的条件是有序元素的下标大于等于0,等于0是因为0为数组第一个元素,这个元素也是要比较的
// 还有一个条件是待排序元素小于前面的有序元素,这样才需要继续向前比较,直至找到待排序元素大于或等于有序元素。
// 当等于的时候不在往前找可以保证稳定性,因为这样有序元素就不用动了
while (k >= 0 && temp < arr[k]) {
k--;
}
// 将大于待排序元素的有序元素向后移一位,其中目前arr[k]为小于或等于待排序元素的元素
// 当然也可能k=-1,这种情况是待排序元素为最小的元素,跟下标为0的元素比较后还小那么执行k--后k就等于-1
// 所以移动元素的范围为k+1到i,不包括k。不过在赋值的时候是获取arr[j-1]覆盖arr[j]。而arr[j-1]需要向前多拿一位
// 所以k+1不能取,只能取到k+2相当于>=k+2,因此就等价于>k+1
for (int j = i; j > k + 1; j--) {
arr[j] = arr[j - 1];
}
// 交换后k+1就相当于空出来了,将待排序元素插入这个位置
arr[k + 1] = temp;
}
}
}
优化实现
上面的实现在将待排序元素插入有序的数组的时候是先找到插入的位置然后再根据插入的位置将元素进行移动,进行了两次遍历。其实我们可以在找插入的位置的同时对元素进行移动,这样只需要进行一次遍历就可以了。为了提高效率我们可以使用下面的方式
private static void insertSort(int[] arr) {
if (arr != null && arr.length > 1) {
int temp;
// 待排序元素从1开始,直至数组末尾
for (int i = 1; i < arr.length; i++) {
// 有序数组中的最后一个元素
int k = i - 1;
// 待排序元素
temp = arr[i];
// 将待排序元素跟前面元素比较,比较的条件是有序元素的下标大于等于0,等于0是因为0为数组第一个元素,这个元素也是要比较的
// 还有一个条件是待排序元素小于前面的有序元素,这样才需要继续向前比较,直至找到待排序元素大于或等于有序元素。
// 当等于的时候不在往前找可以保证稳定性,因为这样有序元素就不用动了
while (k >= 0 && temp < arr[k]) {
// 如果待排序元素小于前面的有序元素且有序元素的下标大于等于0,则待排序元素向后移动一位
arr[k + 1] = arr[k];
k--;
}
// 由于上面执行了k--后下标为k的元素就不满足上面的条件了,所以k+1是最后一个符合条件的元素
// 由于符合条件的元素都进行了向后移动一位,所以k+1这个位置就空出来了,因此将待插入的元素插入k+1这个位置
arr[k + 1] = temp;
}
}
}
算法总结
- 时间复杂度:O(n^2)
- 空间复杂度:O(1)
- 稳定性:稳定
- 适用场景:
- 由于时间复杂度是O(n^2)所以基本上不会使用。尤其是数据量很大的时候千万不能用
希尔排序
描述
希尔排序也是一种插入排序,它是对插入排序的改进版本,也称为缩小增量排序,同时该算法是冲破O(n^2)的第一批算法之一。
出现希尔排序是因为插入排序存在一个问题那就是,如果数组的最大值刚好是在第一位,要将它挪到正确的位置就需要 n - 1 次移动。也就是说,原数组的一个元素如果距离它正确的位置很远的话,则需要与相邻元素交换很多次才能到达正确的位置,这样是相对比较花时间的。希尔排序为了处理这个问题改进了插入排序,他根据增量(初始为n/2)将数组分为很多组,然后对每组进行插入排序,然后减少增量(增量/2)在分组在对每组进行插入排序,最后直到增量为1,则对整个数组进行插入排序,当增量为1的时候整个数组已经基本有序了小的元素都在前面,大的元素都在后面所以排序会很快。
单纯插入排序其实就是增量为1的插入排序。所以希尔排序就是改变了插入排序中的增量。
图片
算法步骤
- 希尔排序只是在插入排序中增加了增量的控制,所以我们首先需要遍历增量,初始值为n/2,然后每次减少一半gap/2,结束条件为gap<1
- 然后从增量位置开始向后遍历,例如增量为5那就从下标为5的元素开始遍历直至数组末尾,增量为2就从下标为2的元素开始遍历直至数组末尾。注意这里的插入排序是每一组轮流进行的,而不是先排好一组在排下一组
- 遍历过程中对遍历的元素进行插入排序,跟普通插入排序的区别是间距变为了gap增量,也就是之前-1和+1变为了-gap和+gap。逻辑还是不变了
代码实现
private static void shellSort(int[] arr) {
if (arr != null && arr.length > 1) {
int temp;
// 遍历增量从n/2开始,每次减少一半,当gap增量小于1时结束,等于1为整个数组进行插入排序是需要执行的
for (int gap = arr.length / 2; gap >= 1; gap /= 2) {
// 对每组进行插入排序,从增量开始遍历到数组末尾,每组轮流执行插入排序
for (int i = gap; i < arr.length; i++) {
temp = arr[i];
// 普通插入排序默认增量为1,所以为i-1。而这里的增量为gap,所以i-gap
int k = i - gap;
// 将待排序元素跟前面元素比较,比较的条件是有序元素的下标大于等于0,等于0是因为0为数组第一个元素,这个元素也是要比较的
// 还有一个条件是待排序元素小于前面的有序元素,这样才需要继续向前比较,直至找到待排序元素大于或等于有序元素。
while (k >= 0 && temp < arr[k]) {
// 如果待排序元素小于前面的有序元素且有序元素的下标大于等于0,则待排序元素向后移动gap位,同时k向前移gap位
arr[k + gap] = arr[k];
k -= gap;
}
// 由于上面执行了k-=gap后下标为k的元素就不满足上面的条件了,所以k+gap是最后一个符合条件的元素
// 由于符合条件的元素都进行了向后移动gap位,所以k+gap这个位置就空出来了,因此将待插入的元素插入k+gap这个位置
arr[k + gap] = temp;
}
}
}
}
算法总结
- 时间复杂度:O(n^1.3)
- 空间复杂度:O(1)
- 稳定性:不稳定
- 适用场景:
- 由于时间复杂度是O(n^1.3)所以基本上也不会使用。不过如果数据量很小也是可以使用的
归并排序
描述
归并排序使用的是分治思想,他通过递归的方式对数组不断的进行分割,直至数组长度为1,那么这个长度为1的数组就是有序的了。然后在把两个长度为1的数组合并为长度为2的数组,合并后让整个数组依然有序,然后再将两个长度为2的数组合并为长度为4的数组依次下去,直至将所有拆分后的数组合并为一个数组。
动图
为了便于理解可以看第一张图,而实际运行的过程为第二张图
第一张图
第二张图
算法步骤
- 先递归对数组进行拆分,拆分规则是从数组中间进行拆分,直至数组的长度为1,也就是开始位置等于截止位置的时候停止拆分,也就是说当开始位置小于截止位置的时候需要继续进行拆分
- 当递归拆分到数组长度只有1,这个时候无法在继续拆分了所以就返回递归,对长度为1的数组进行合并。合并后数组长度为2,然后再对长度为2的数组进行合并,直至将所有拆分后的数组合并为一个数组
- 合并的逻辑是不断的从两个需要合并的数组中从起始位置开始取出元素进行对比,然后再将小的那个放在一个临时数组中,直至两个数组的元素都取完。
- 最后再将临时数组的元素复制到原始数组对应位置,还是填充在数组之前所在的范围,只不过元素的位置改变了因为排过序了
代码实现
private static void mergeSort(int[] arr, int... index) {
int left,right;
if(index.length==0){
left = 0;
right = arr.length-1;
}else if (index.length==2){
left = index[0];
right = index[1];
}else{
throw new RuntimeException("参数传递错误");
}
// 当起始位置小于截止位置的时候需要继续进行拆分,否则说明数组长度为1不用继续拆分了
if (left < right) {
// 找到数组中间位置
int mid = (left + right) / 2;
// 根据中间位置对左边数组进行拆分
mergeSort(arr, left, mid);
// 根据中间位置对右边数组进行拆分
mergeSort(arr, mid + 1, right);
// 合并两个数组
merge(arr, left, mid, right);
}
}
private static void merge(int[] arr, int left, int mid, int right) {
// 用于存储合并排序后的临时数组,由于left和right为下标,下标是从0开始的,所以长度要+1
int[] mergeArr = new int[right - left + 1];
int mergeIndex = 0;
// 左边数组的起始下标
int l = left;
// 右边数组的起始下标
int r = mid + 1;
// 当左边数组的下标小于或等于左边数组的截止下标时说明还有元素需要合并,右边也同理。
// 使用逻辑或的原因是因为可能左边的数组元素都取完了但是右边的数组元素没有取完,而我们是需要将两个数组的元素都取完才算完成的,所以用逻辑或
while (l <= mid || r <= right) {
// 如果左边数组的元素都取完了,则直接取右边的
if (l > mid) {
mergeArr[mergeIndex++] = arr[r++];
// 如果右边数组的元素都取完了,则直接取左边的
} else if (r > right) {
mergeArr[mergeIndex++] = arr[l++];
// 如果左边数组的元素小于右边数组的元素则取左边数组的元素
} else if (arr[l] < arr[r]) {
mergeArr[mergeIndex++] = arr[l++];
// 不满足上面的条件就取右边的元素,这里的条件其实是右边数组的元素小于或等于左边数组的元素
} else {
mergeArr[mergeIndex++] = arr[r++];
}
}
// 将排序后的临时数组复制到数组原有范围内,范围为left到right
for (int i = 0; i < mergeArr.length; i++) {
// 临时数组从0开始,原始数组的范围为left到right,所以原始数组和临时数组的对应关系为left + i
arr[left + i] = mergeArr[i];
}
}
优化实现
上面的方式是通过递归来实现的,不过如果数据量特别大那么递归太深可能会导致栈溢出。而归并排序是可以不基于递归实现的。
通过非递归实现的思路是:由于递归是将数组不断的分割直至每个数组的长度为1,然后再开始合并。而非递归的方式则是反过来事先将数组中的每个元素都看成是一个子数组。然后再将这些子数组进行归并,一次归并变回原来的2倍,例如数组长度为1时归并后数组长度变为了2,当为长度2时归并后长度变为4,直至变为原数组的长度则停止归并。当然每次归并后新的数组是有序的
算法步骤
- 先确定需要合并的轮数,初始子数组的长度为1,然后每次变为原来的两倍,直至子数组长度变为原数组的长度后停止合并,当然当子数组长度等于原数组长度的时候是不需要在进行合并了的,因为那时只有一个数组了,所以不用在进行合并了,因此合并条件里面不包含等于原数组的长度。还有需要注意的是当子数组长度为1时进行的是将两个长度为1的数组合并为长度为2的数组的操作,当子数组长度为2时进行的是将两个长度为2的数组合并为长度为4的数组的操作依次下去直至子数组长度等于原数组长度
- 在每次合并的过程中,不断的将两组子数组进行合并,其实就是相当于找到两个数组的起始位置和截止位置,也就相当于找到合并后数组的起始位置、中间位置和截止位置。
- 找到这3个位置后合并和过程就跟使用递归方式合并的过程一致了。不断的从两个需要合并的数组中从起始位置开始取出元素进行对比,然后再将小的那个放在一个临时数组中,直至两个数组的元素都取完。最后再将临时数组的元素复制到原始数组对应位置。
具体实现
private static void mergeSort(int[] arr) {
// 初始子数组的长度为1,每次变为原来的2倍,直至子数组长度变为原数组的长度
// 其中等于原数组长度的时候是不需要在进行合并了的,因为那时只有一个数组了。因此是不包括等于的
for (int n = 1; n < arr.length; n *= 2) {
// 第一个子数组初始下标
int left = 0;
/**
* 第一个子数组截止下标,相当于合并后数组的中间下标
* 这里通过初始下标+数组长度就等于子数组的长度。不过这时只是数组长度,不是下标
* 由于下标从0开始所以截止下标要-1
*/
int mid = left + n - 1;
/**
* 第二个子数组的截止下标。相当于合并后数组的截止下标
* 因为一个子数组的长度是n,所以两个子数组的长度是2*n,
* 所以第二个子数组截止位置距离第一个子数组初始位置就等于left+2*n。不过由于下标从0开始所以要-1
*/
int right = left + 2 * n - 1;
/**
* 不断的将数组中的两个子数组进行合并
* 截止条件是第二个子数组的截止下标大于原数组最后的下标也就是arr.length-1。
* 所以继续合并的条件为<=arr.length-1,也就等价于<arr.length
* 不过需要注意的是,这样可能会导致最后两个子数组没有进行合并,例如
* 当最后两个子数组的第二个子数组中由于原数组中剩余的元素已经不足以填满第二个子数组了
* 那么这样计算出来的第二个数组的下标就会超出原数组最后下标。
* 从而导致最后两个子数组没有进行合并,因此后面还需要对这种情况进行处理
* 当然如果最后原数组中剩余的元素只能填充第一个子数组(可能填满也可能填不满第一个子数组),
* 这种情况由于只有一个子数组所以并不需要进行合并因此这种情况是可以不用管的,
* 最后合并时候自然会合并到最终的数组中(当然这种情况最后也相当于是第二个子数组填不满的情况,需要在后面处理)
*/
while (right < arr.length) {
// 将两个数组合并为一个数组
merge(arr, left, mid, right);
// 重新计算初始下标、中间下标和截止下标,逻辑和上面的计算方式一致
// 进行合并的初始下标等于上一次合并的截止下标+1
left = right + 1;
mid = left + n - 1;
right = left + 2 * n - 1;
}
/**
* 处理最后两个子数组的第二个子数组中由于原数组中剩余的元素已经不足以填满第二个子数组导致上面没有进行合并的问题
* 出现这种情况的条件是第二个子数组中有元素,那么就意味着中间下标mid小于原始数组的最后下标arr.length-1
* 不可以等于,因为当中间下标等于元素数组最后下标的时候意味着第一个子数组元素是完整的,而第二个子数组为空。
* 因为第二个子数组是从mid+1开始的
*/
if (mid < arr.length - 1) {
// 如果第二个子数组有值则对最后两个子数组进行合并
merge(arr, left, mid, arr.length - 1);
}
}
}
private static void merge(int[] arr, int left, int mid, int right) {
// 用于存储合并排序后的临时数组
int[] mergeArr = new int[right - left + 1];
int mergeIndex = 0;
// 左边数组的起始下标
int l = left;
// 右边数组的起始下标
int r = mid + 1;
// 当左边数组的下标小于或等于左边数组的截止下标时说明还有元素需要合并,右边也同理。
// 使用逻辑或的原因是因为可能左边的数组元素都取完了但是右边的数组元素没有取完,而我们是需要将两个数组的元素都取完才算完成的,所以用逻辑或
while (l <= mid || r <= right) {
// 如果左边数组的元素都取完了,则直接取右边的
if (l > mid) {
mergeArr[mergeIndex++] = arr[r++];
// 如果右边数组的元素都取完了,则直接取左边的
} else if (r > right) {
mergeArr[mergeIndex++] = arr[l++];
// 如果左边数组的元素小于右边数组的元素则取左边数组的元素
} else if (arr[l] < arr[r]) {
mergeArr[mergeIndex++] = arr[l++];
// 不满足上面的条件就取右边的元素,这里的条件其实是右边数组的元素小于或等于左边数组的元素
} else {
mergeArr[mergeIndex++] = arr[r++];
}
}
// 将排序后的临时数组复制到数组原有范围内,范围为left到right
for (int i = 0; i < mergeArr.length; i++) {
// 临时数组从0开始,原始数组的范围为left到right,所以原始数组和临时数组的对应关系为left + i
arr[left + i] = mergeArr[i];
}
}
算法总结
- 时间复杂度:O(O(n log n))
- 空间复杂度:O(n)
- 稳定性:稳定
- 适用场景:
- 如果在排序中要求保证数据的稳定性那么就可以使用归并排序。不过由于归并排序需要占用额外的内存(临时数组)以及进行数组之间的复制操作所以会比快速排序慢一点,当然由于快速排序不是稳定的,所以要求稳定的情况下优先使用归并排序
快速排序
描述
快速排序和归并排序类似都是使用了分治思想,在快速排序的过程中他首先会选取数组中的某个元素将这个元素作为中轴(有的人叫基准或主元)。然后将大于或等于中轴的元素放在右边,把小于或等于中轴的元素放在左边。这样就确定了中轴元素的位置。然后根据中轴元素将数组拆分成两个子数组(子数组中不包括中轴元素)。之后在通过递归的方式对子数组进行相同的操作,在子数组不断的让中轴元素处于正确位置然后再根据中轴元素对数组进行拆分,直至递归到子数组只有1个或0个元素的时候停止递归。
而让中轴元素处于正确位置也就是将大于或等于中轴的元素放在右边把小于或等于中轴的元素放在左边的操作我们可以通过双向调整的方式来实现,双向调整的意思是我们可以令变量i等于中轴元素的位置+1,令变量j等于数组的最后一个元素,然后i向前扫描j向后扫描。当i扫描到大于中轴元素时停下,j扫描到小于中轴元素时停下,当i和j都停下后我们在互换i和j所指向的元素。调换后继续向前扫描直至j<i。然后再将中轴元素和j所指向的元素互换。这样中轴元素就处于正确位置了。
让中轴元素处于正确位置除了双向调整也可以称为左右指针法还可以有其他的方式例如挖坑法、前后指针法。
图片
快速排序过程:
双向调整:
算法步骤
- 先根据数组获取中轴元素的下标
- 获取中轴元素的下标采用双向调整来实现。双向调整的意思是我们可以令变量i等于中轴元素的位置+1,令变量j等于数组的最后一个元素,然后i向前扫描j向后扫描。当i扫描到大于中轴元素时停下,j扫描到小于中轴元素时停下,当i和j都停下后我们在互换i和j所指向的元素。调换后继续向前扫描直至j<i。然后再将中轴元素和j所指向的元素互换,最后返回j
- 根据中轴元素下标将数组拆分为两个子数组,子数组中不包含中轴元素。将子数组递归重复上面步骤,直至子数组只有1个或0个元素的时候停止递归,也就是当数组初始下标小于截止下标的时候继续递归否则停止递归
代码实现
private static void quickSort(int[] arr, int... index) {
int left,right;
if(index.length==0){
left = 0;
right = arr.length-1;
}else if (index.length==2){
left = index[0];
right = index[1];
}else{
throw new RuntimeException("参数传递错误");
}
// 当子数组只有1个或0个元素的时候停止递归,也就是当数组初始下标小于截止下标的时候继续递归否则停止递归
// 不用等于是因为left==right的时候表示只有一个元素,此时应该停止递归
if (left<right){
// 获取中轴元素下标
int mid = partition(arr, left, right);
// 将左边的子数组进行递归处理,子数组中不包含中轴元素
quickSort(arr,left,mid-1);
// 将右边的子数组进行递归处理,子数组中不包含中轴元素
quickSort(arr,mid+1,right);
}
}
// 获取中轴元素下标
private static int partition(int[] arr, int left, int right) {
// 选取第一个元素为中轴元素
int midVal = arr[left];
int temp;
// 双向调整起始位置
int i = left + 1;
int j = right;
// 当i>j的时候就遍历完了,所以i<=j的时候继续。
// i==j的时候不能停止是因为i==j的时候j可能还需要向前移动也就是说i==j的时候可能出现arr[i]==arr[j]>midVal
// 如果将大于中轴元素的元素跟第一个元素也就是中轴元素交换是会有问题的,因为左边的元素应该小于等于中轴元素
while (i<=j){
// 当i扫描到的元素大于中轴元素且j扫描到的元素小于中轴元素,则交换arr[i]和arr[j]
// 不取等于是因为等于的时候不交换也没关系的,由于等于中轴元素的元素在那边都行
if(arr[i]>midVal&&arr[j]<midVal){
temp = arr[i];
arr[i] = arr[j];
arr[j] = temp;
}
// i和j同时移动,当然如果在一次循环中arr[i]大于中轴元素时i不再移动而此时如果arr[j]依然大于中轴元素则j继续移动
// 当arr[i]小于等于中轴元素i向后移动。由于等于中轴元素的元素在那边都行,所以等于中轴元素时继续向前移动
if(arr[i]<=midVal){
i++;
}
// 当arr[j]大于等于中轴元素j向前移动
if(arr[j]>=midVal){
j--;
}
}
// 数组遍历完之后将中轴元素和下标为j的元素互换,此时中轴元素的下标为j
arr[left] = arr[j];
arr[j] = midVal;
return j;
}
优化实现
上面的实现方式是通过递归的方式实现的,所以如果数据太多会出现栈溢出的问题,因此我们可以使用非递归的方式实现。
因为任何递归形式的代码都可以改用 栈 或者队列这种数据结构进行改造。其实递归的本质就是栈的先进后出的性质。快速排序每次是递归向左右子序列中进行排序,利用栈我们可以把左右子序列的起始位置以及截止位置保存到栈中,然后每次取栈顶区间进行排序,排序的过程是取出栈顶的两个值也就是子数组的起始下标和截止下标,然后在子数组中查找中轴元素下标,在根据中轴元素下标对数组进行拆分,然后将拆分后的两个子数组的起始下标以及截止下标分别存入栈中,当然如果拆分后两个子数组中的元素只有0或1个那么就不用在存入栈了。之后重复这个过程直到栈为空则整个序列为有序。
算法步骤
- 将原始数组的起始下标和截止下标存入栈中,然后判断栈中是否有元素,因为当栈中没有元素时整个数组是排序完成了的
- 从栈中取出两个元素,也就是起始下标和截止下标,这里取的时候需要注意顺序,因为栈是先进后出的。然后根据起始和截止下标获取中轴元素的下标。之后通过中轴元素下标对数据进行拆分。拆分后判断两个数组中元素是否大于1个如果大于则将子数组的起始和截止下标存入栈中。否则进入下一次循环处理。直至栈为空则表示所有的元素都排好序了
- 其实这里 运行过程和递归是差不多的,只不过递归是从数组前面往后面递归。而使用栈则是从数组后面往前面处理,因为第二个子数组先存进去所以第二个子数组就被先取出来
具体实现
private static void quickSort(int[] arr) {
// 创建一个栈
Stack<Integer> stack = new Stack<>();
// 数组起始下标
int left = 0;
// 数组截止下标
int right = arr.length - 1;
// 将起始和截止下标存入栈
stack.push(left);
stack.push(right);
// 当栈中为空则表示栈中所有的子数组都排序完了
while (!stack.empty()) {
// 从栈中取出起始和截止下标,注意顺序,第一个是后存入的数据
right = stack.pop();
left = stack.pop();
// 根据起始和截止下标获取中轴元素下标
int mid = partition(arr, left, right);
// 根据中轴元素下标将数组拆分为两个子数组,然后将子数组的起始和截止下标存入栈中,后面会取出来然后通过这两个下标获取中轴元素下标
// 当中轴下标等于或小于起始下标则表示数组只有1或0个元素了,就不在存入栈了,所以当中轴下标大于起始下标时存入栈
if (mid > left) {
stack.push(left);
stack.push(mid - 1);
}
// 当中轴下标等于或大于截止下标则表示数组只有1或0个元素了,就不在存入栈了,所以当中轴下标小于截止下标时存入栈
if (mid < right) {
stack.push(mid + 1);
stack.push(right);
}
}
}
// 获取中轴元素下标
private static int partition(int[] arr, int left, int right) {
// 选取第一个元素为中轴元素
int midVal = arr[left];
int temp;
// 双向调整起始位置
int i = left + 1;
int j = right;
// 当i>j的时候就遍历完了,所以i<=j的时候继续。
// i==j的时候不能停止是因为i==j的时候j可能还需要向前移动也就是说i==j的时候可能出现arr[i]==arr[j]>midVal
// 如果将大于中轴元素的元素跟第一个元素也就是中轴元素交换是会有问题的,因为左边的元素应该小于等于中轴元素
while (i<=j){
// 当i扫描到的元素大于中轴元素且j扫描到的元素小于中轴元素,则交换arr[i]和arr[j]
// 不取等于是因为等于的时候不交换也没关系的,由于等于中轴元素的元素在那边都行
if(arr[i]>midVal&&arr[j]<midVal){
temp = arr[i];
arr[i] = arr[j];
arr[j] = temp;
}
// i和j同时移动,当然如果在一次循环中arr[i]大于中轴元素时i不再移动而此时如果arr[j]依然大于中轴元素则j继续移动
// 当arr[i]小于等于中轴元素i向后移动。由于等于中轴元素的元素在那边都行,所以等于中轴元素时继续向前移动
if(arr[i]<=midVal){
i++;
}
// 当arr[j]大于等于中轴元素j向前移动
if(arr[j]>=midVal){
j--;
}
}
// 数组遍历完之后将中轴元素和下标为j的元素互换,此时中轴元素的下标为j
arr[left] = arr[j];
arr[j] = midVal;
return j;
}
算法总结
- 时间复杂度:O(n log n)
- 空间复杂度:O(logn)
- 稳定性:不稳定
- 适用场景:
- 最常用的排序算法适应大部分情况。所以通常都采用快速排序。不过需要注意的是当数组已经处于基本有序的状态了那么就不要在用快速排序了,这时他的时间复杂度将接近O(n^2)。这种情况可以使用归并或希尔。
堆排序
描述
堆排序是基于二叉堆来完成的,二叉堆本质上就是一种完全二叉树。他由完全二叉树转化而来。并且二叉堆可以分为最大堆和最小堆。最大堆中所有的父节点都大于他的子节点所以堆顶的元素是数组中的最大值,最小堆所有的父节点都小于他的子节点所以堆顶的元素是数组中的最小值。由于堆顶为最值所以我们可以将堆顶的元素跟堆中最后的元素进行交换,这样交换出来的元素在数组中的位置就确定了。然后在将二叉堆中剩余的元素进行重新构建,当堆中的数据全部交换完成整个数组就有序了。
完全二叉树转换为二叉堆的操作,例如在构建最小堆中,我们只需要从最后一个非叶子节点开始,让他和他的子节点进行比较,如果父节点大于它左右子节点中最小的一个,则让他们交换位置,这也称为下沉。重复这个操作直至堆顶节点跟他的子节点比较或交换完成
动图
代码实现
//堆排序
public static int[] heapSort(int[] arr) {
int length = arr.length;
//构建二叉堆
for (int i = (length - 2) / 2; i >= 0; i--) {
arr = downAdjust(arr, i, length);
}
//进行堆排序
for (int i = length - 1; i >= 1; i--) {
//把堆顶的元素与最后一个元素交换
int temp = arr[i];
arr[i] = arr[0];
arr[0] = temp;
//下沉调整
arr = downAdjust(arr, 0, i);
}
return arr;
}
/**
* 下沉操作,执行删除操作相当于把最后一个元素赋给根元素之后,然后对根元素执行下沉操作
* @param arr
* @param parent 要下沉元素的下标
* @param length 数组长度
*/
public static int[] downAdjust(int[] arr, int parent, int length) {
//临时保证要下沉的元素
int temp = arr[parent];
//定位左孩子节点位置
int child = 2 * parent + 1;
//开始下沉
while (child < length) {
//如果右孩子节点比左孩子小,则定位到右孩子
if (child + 1 < length && arr[child] > arr[child + 1]) {
child++;
}
//如果父节点比孩子节点小或等于,则下沉结束
if (temp <= arr[child])
break;
//单向赋值
arr[parent] = arr[child];
parent = child;
child = 2 * parent + 1;
}
arr[parent] = temp;
return arr;
}
算法总结
- 时间复杂度:O(n log n)
- 空间复杂度:O(1)
- 稳定性:不稳定
- 适用场景:
- 堆排序虽然时间复杂度是O(n log n)的,不过实际的使用效率没有快速排序快。原因是堆排中,每一个操作都是不利于程序的局部性原理就是不利于程序操作缓存。不过堆排序适用于在我们只需要找出数组中前几个最大值或最小值的时候使用,因为在这种情况下我们使用堆排序不用对整个数组进行排序而是只需要排序前几个值就可以了
计数排序
描述
计数排序就是把数组元素作为数组的下标,当然元素的值如果太大我们可以减去一个偏移量,然后用一个临时数组统计该元素出现的次数。最后直接遍历临时数组,输出数组元素的下标值,元素的值是几,就输出几次。不过这种方式只适合数据全是整数,且数据范围不是很大的情况下使用。
动图
代码实现
public static int[] countSort(int[] A) {
// 找出数组A中的最大值、最小值
int max = Integer.MIN_VALUE;
int min = Integer.MAX_VALUE;
for (int num : A) {
max = Math.max(max, num);
min = Math.min(min, num);
}
// 初始化计数数组count
// 长度为最大值减最小值加1
int[] count = new int[max-min+1];
// 对计数数组各元素赋值
for (int num : A) {
// A中的元素要减去最小值,再作为新索引
count[num-min]++;
}
// 创建结果数组
int[] result = new int[A.length];
// 创建结果数组的起始索引
int index = 0;
// 遍历计数数组,将计数数组的索引填充到结果数组中
for (int i=0; i<count.length; i++) {
while (count[i]>0) {
// 再将减去的最小值补上
result[index++] = i+min;
count[i]--;
}
}
// 返回结果数组
return result;
}
算法总结
- 时间复杂度:O(n+k) 其中n为原始数列的规模,k原始数列中最大值和最小值的差值
- 空间复杂度:O(k)
- 稳定性:稳定
- 适用场景:
- 如果数据全是整数,且数据范围不是很大的情况下优先考虑计数排序,例如一群人的年龄,考试的分数等等
桶排序
描述
桶排序其实是对计数排序的改进,因为计数排序无法对小数进行排序,而且如果数据范围很大那么需要创建的临时数组也将很大。而桶排序处理了这个问题,这里桶指的就是一个区间,他根据原数组的最大值和最小值将数组分为很多个区间也就是很多个桶,然后让数组中的元素落在不同的桶中,而桶中则以链表的方式进行存储。然后再对桶中的元素进行排序(可以用快速排序也可以用归并排序)。最后我们在将桶中的数据依次输出,这样数据就是有序的了。
图片
代码实现
public static int[] bucketSort(int[] arr) {
if (arr == null || arr.length == 0) {
return null;
}
//计算最大值与最小值
int max = Integer.MIN_VALUE;// -2147483648
int min = Integer.MAX_VALUE; //2147483647
for(int i = 0;i < arr.length;i++) {
max = Math.max(max, arr[i]);
min = Math.min(min, arr[i]);
}
//计算桶的数量
int bucketNum = (max - min) /arr.length+1;
ArrayList<ArrayList<Integer>> bucketArr = new ArrayList<>(bucketNum);
for (int i = 0; i < bucketNum; i++) {
bucketArr.add(new ArrayList<Integer>());
}
//将每个元素放入桶
for (int i = 0; i < arr.length; i++) {
int num = (arr[i] - min)/(arr.length);
bucketArr.get(num).add(arr[i]);
}
//对每个桶进行排序
for (int i = 0; i < bucketArr.size(); i++) {
Collections.sort(bucketArr.get(i));
}
//将桶中元素赋值到原序列
int index = 0;
for (int i = 0; i < bucketArr.size(); i++) {
for (int j = 0; j < bucketArr.get(i).size(); j++) {
arr[index++] = bucketArr.get(i).get(j);
}
}
return arr;
}
算法总结
- 时间复杂度:O(n+k) 其中n为原始数列的规模,k桶的个数
- 空间复杂度:O(n+k)
- 稳定性:稳定
- 适用场景:
- 如果数据比较均匀的分布在某一范围内的情况下可以使用,但是如果数据分布不均匀,数据只落在几个桶里,那么桶排序就没有什么意义了,就相当于使用了桶中的排序方式并且还白白创建了那么多没用的桶
基数排序
描述
基数排序也是计数排序的改进,由于不管是个位数、十位数百位数的范围都是0到9,所以基数排序就通过从低位到高位来对位数进行计数排序从而实现整体排序的,例如先对数据的个位数进行计数排序,然后对十位数进行计数排序一直排到最高位。当最高位排序完成后整个数组就变得有序了。
动图
代码实现
public static int[] radixSort(int[] a) {
int exp; // 指数。当对数组按各位进行排序时,exp=1;按十位进行排序时,exp=10;...
int max = Arrays.stream(a).max().getAsInt(); // 数组a中的最大值
// 从个位开始,对数组a按"指数"进行排序
for (exp = 1; max/exp > 0; exp *= 10){
int[] output = new int[a.length]; // 存储"被排序数据"的临时数组
int[] buckets = new int[10];
// 将数据出现的次数存储在buckets[]中
for (int i = 0; i < a.length; i++){
buckets[ (a[i]/exp)%10 ]++;
}
// 更改buckets[i]。目的是让更改后的buckets[i]的值,是该数据在output[]中的位置。
for (int i = 1; i < 10; i++){
buckets[i] += buckets[i - 1];
}
// 将数据存储到临时数组output[]中
for (int i = a.length - 1; i >= 0; i--) {
output[buckets[ (a[i]/exp)%10 ] - 1] = a[i];
buckets[ (a[i]/exp)%10 ]--;
}
// 将排序好的数据赋值给a[]
for (int i = 0; i < a.length; i++){
a[i] = output[i];
}
}
return a;
}
算法总结
- 时间复杂度:O(n*k) 其中n为原始数列的规模,k为数组中的数的最大的位数
- 空间复杂度:O(n+k)
- 稳定性:稳定
- 适用场景:
- 这个很少用到,因为他的排序效率还没有快速排序好并且还很占用内存。原因是常数项k比较大。他适合用于对时间、字符串等这些整体权值未知的数据进行排序。例如字符串类型的时间和手机号码