文章目录
排序的分类
稳定和非稳定
- 稳定排序:如果 a 原本在 b 的前面,且 a == b,排序之后 a 仍然在 b 的前面,则为稳定排序。
- 非稳定排序:如果 a 原本在 b 的前面,且 a == b,排序之后 a 可能不在 b 的前面,则为非稳定排序。
原地排序
- 指不申请多余的空间来进行的排序,就是在原来的排序数据中比较和交换的排序。
- 属于原地排序的是: 希尔排序 、冒泡排序、插入排序、选择排序、堆排序、快速排序
比较和非比较
- 常见的快速排序、归并排序、堆排序、冒泡排序等属于比较排序。在排序的最终结果里,元素之间的次序依赖于它们之间的比较。每个数都必须和其他数进行比较,才能确定自己的位置。
内排序和外排序
- 内排序:指在排序期间数据对象所有存放在内存的排序。 大部分算法实现都是使用内排序
- 外排序:指在排序期间所有对象太多,不能同一时候存放在内存中,必须依据排序过程的要求,不断在内,外存间移动的排序
排序算法总览
选择排序
步骤
- 首先,找到数组中最小的那个元素
- 其次,将它和数组的第一个元素交换位置(如果第一个元素就是最小元素那么当然它就和自己交换)。
- 然后,在剩下的元素中找到最小的元素,将它与数组的第二个元素交换位置。
- 如此往复,直到将整个数组排序
算法分析
- 时间复杂度为O(n^2)
- 空间复杂度O(1)
其他
- 是不稳定排序,举个例子:数组 6、7、6、2、8,在对其进行第一遍循环的时候,会将第一个位置的6与后面的2进行交换。此时,就已经将两个6的相对前后位置改变了。因此选择排序不是稳定性排序算法。
Java实现
public class SelectSort {
public static void main(String[] args) {
int[] a = {1, 4, 2, 8, 3, 7, 8, 4, 6, 9};
System.out.println(Arrays.toString(a));
for (int i = 0; i < a.length - 1; i++) {
int min = i;
for (int j = i; j < a.length; j++) {
if (a[j] <= a[min])
min = j;
}
int temp = a[min];
a[min] = a[i];
a[i] = temp;
}
System.out.println(Arrays.toString(a));
}
}
优化版本
- 选择排序的时间复杂度是O(NN),不管是最好情况还是最坏情况,找最小数的过程都需要遍历一遍,所以,选择排序最好情况也是O(NN)
- 如果在每一次查找最小值的时候,也可以找到一个最大值,然后将两者分别放在它们应该出现的位置,这样遍历的次数就比较少了
public class SelectSort {
public static void main(String[] args) {
int[] a = {1, 4, 2, 8, 3, 7, 8, 4, 6, 9};
System.out.println(Arrays.toString(a));
// selectSort(a);
selectSortOptimized(a);
System.out.println(Arrays.toString(a));
}
/**
* 优化的选择排序
*/
public static void selectSortOptimized(int[] a) {
int left = 0, right = a.length - 1;
int min, max;//存储最大最小值的下标
while (left < right) {
min = left;
max = left;
for (int i = left; i <= right; ++i) {
if (a[i] < a[min]) min = i;
if (a[i] > a[max]) max = i;
}
swap(a, left, min);
if (left == max)
max = min;//这里是考虑了最大值就在left下标的位置这种情况
swap(a, right, max);
++left;
--right;
}
}
public static void swap(int[] arr, int front, int back) {
int temp = arr[front];
arr[front] = arr[back];
arr[back] = temp;
}
}
直接插入排序
步骤
- 从数组第2个元素开始抽取元素。
- 把它与左边相邻的第一个元素比较,如果左边第一个元素比它大,则继续与左边第二个元素比较下去,直到遇到不比它大的元素,然后插到这个元素的右边,其他元素依次后移。
- 继续选取第3,4,….n个元素,重复步骤 2 ,选择适当的位置插入。
算法分析
- 时间复杂度:O(n^2)
- 空间复杂度:O(1)
其他
- 直接插入排序稳定吗? 稳定
- 插入排序是一种比较简单直观的排序算法,适用处理数据量比较少或者部分有序的数据
Java实现
public class InsertSort {
public static void main(String[] args) {
int[] a = {1, 4, 2, 8, 3, 7, 8, 4, 6, 9};
System.out.println(Arrays.toString(a));
for (int i = 1; i < a.length; i++) {
int j = i - 1;
if (a[i] < a[j]) {
int temp = a[i];
do {
a[j + 1] = a[j];
j--;
} while (j >= 0 && temp < a[j]);//注意这里temp不要写成了a[i]
a[j + 1] = temp;//注意是j+1
}
}
System.out.println(Arrays.toString(a));
}
}
鸡尾酒排序
- 鸡尾酒排序是冒泡排序的优化算法
- 鸡尾酒排序的优点是能够在特定条件下,减少排序的回合数;而缺点也很明显,就是代码量几乎增加了1倍
public class CockTailSort {
public static void main(String[] args) {
int[] arr = {9, 2, 3, 6, 4, 8, 1, 0, 5, 7};
System.out.println(Arrays.toString(arr));
cockTailSort(arr);
System.out.println(Arrays.toString(arr));
}
public static void cockTailSort(int[] arr) {
for (int i = 0; i < arr.length / 2; ++i){
boolean isSorted = true;
for (int j = i; j < arr.length - 1 - i; ++j) {
if (arr[j] > arr[j + 1]){
swap(arr, j, j + 1);
isSorted = false;
}
}
if (isSorted) break;
isSorted = true;
for (int j = arr.length - 1 - i; j > i; --j) {
if (arr[j] < arr[j - 1]){
swap(arr, j, j -1);
isSorted = false;
}
}
if (isSorted) break;
}
}
public static void swap(int[] arr, int a, int b) {
int temp = arr[a];
arr[a] = arr[b];
arr[b] = temp;
}
}
二路归并排序(MergeSort)
简介
- 二路归并排序是经典的排序算法,核心思想是分治,属于稳定排序
- 二路归并的递归路径实质上是一个完全二叉树,每次合并操作的平均时间复杂度为O(n),而完全二叉树的深度为log2n。因此总的平均时间复杂度为O(nlogn)
- 而且,归并排序的最好,最坏,平均时间复杂度均为O(nlogn)
- 当然还有n路归并排序
算法实现
Java
public class MergeSort {
public static void main(String[] args) {
int[] a = {1,4,2,8,3,7,8,4,6,9};
System.out.println(Arrays.toString(a));
int[] temp = new int[a.length];//在外部声明辅助数组,避免在递归栈中声明
mergeSort(a, 0, a.length - 1, temp);
System.out.println(Arrays.toString(a));
}
public static void mergeSort(int[] arr, int left, int right, int[] temp){
if (left >= right) return;
int mid = (left + right) >> 1;
mergeSort(arr, left, mid, temp);
mergeSort(arr, mid+1, right, temp);
int l = left, r = mid + 1;
int t = 0;//辅助计数变量
while (l <= mid && r <= right){
if (arr[l] < arr[r]) temp[t++] = arr[l++];
else temp[t++] = arr[r++];
}
//剩下的子序列添加到辅助空间中
while (l <= mid) temp[t++] = arr[l++];
while (r <= right) temp[t++] = arr[r++];
//将辅助数组的值copy到原数组,注意copy到原数组的范围是left到right
t = 0;
while (left <= right) arr[left++] = temp[t++];
}
}
考虑一种情况
- 将代码改为如下所示:
public static void mergeSort(int[] arr, int left, int right, int[] temp) {
if (left >= right) return;
int mid = (left + right) >> 1;
System.out.println("left: " + left);
System.out.println("right: " + right);
mergeSort(arr, left, mid - 1, temp);
mergeSort(arr, mid, right, temp);
int l = left, r = mid;
int t = 0;//辅助计数变量
while (l <= mid - 1 && r <= right) {
if (arr[l] < arr[r]) temp[t++] = arr[l++];
else temp[t++] = arr[r++];
}
//剩下的子序列添加到辅助空间中
while (l <= mid - 1) temp[t++] = arr[l++];
while (r <= right) temp[t++] = arr[r++];
//将辅助数组的值copy到原数组,注意copy到原数组的范围是left到right
t = 0;
while (left <= right) arr[left++] = temp[t++];
}
- 主要是改了这两行代码:
mergeSort(arr, left, mid - 1, temp);
mergeSort(arr, mid, right, temp);
- 这样划分在逻辑上似乎没问题,但会造成栈内存溢出,假设left = 2, right = 3,那么mid = 2,这样就会一直递归调用mergeSort(arr, 2, 3, temp),造成内存溢出。
- 所以不能这样写
参考
- https://www.cnblogs.com/chengxiao/p/6194356.html
快速排序(QuickSort)
简介
- 快排与傅里叶变换等算法并称为二十世纪十大算法
- 使用了分治法
- 冒泡排序在每一轮只把一个元素冒泡到数列的一端,而快速排序在每一轮挑选一个基准元素,并让其他比它大的元素移动到数列一边,比它小的元素移动到数列的另一边,从而把数列拆解成了两个部分。在分治法的思想下,原数列在每一轮被拆分成两部分,每一部分在下一轮又分别被拆分成两部分,直到不可再分为止。
算法分析
这样一共需要多少轮呢?
- 最好情况:每次划分都产生两个长度差不多的子区间,也就是说,所取得基准都是当前无序区的中值元素,这样的递归树的高度为:
l o g 2 n log_2 n log2n
- 而每一层划分的时间为n, 所以:
T n = O ( n l o g n ) , S ( n ) = O ( l o g 2 n ) (递归栈空间) Tn = O(nlogn),S(n) = O(log_2n)(递归栈空间) Tn=O(nlogn),S(n)=O(log2n)(递归栈空间)
- 最坏情况:每次选取得基准都是当前无序区的最大(小)值,这样的话,递归树高度n,需要n-1次划分:
T n = O ( n 2 ) , S ( n ) = O ( n ) Tn = O(n^2) , S(n) = O(n) Tn=O(n2),S(n)=O(n)
- 平均情况:
T n = O ( n l o g n ) Tn = O(nlogn) Tn=O(nlogn)
基准元素的选取
- 基准元素,英文pivot。
- 最简单的方式是选择数列的第一个元素,这种选择在绝大多数情况是没有问题的。但是,假如有一个原本逆序的数列,期望排序成顺序数列,那么会出现什么情况呢?时间复杂度退化为:
n 2 n^2 n2
- 当然,即使是随机选择基准元素,每一次也有极小的几率选到数列的最大值或最小值,同样会影响到分治的效果
算法特性
- 不稳定
- 原地排序
算法实现
单边循环法
Java
- 仅仅需要修改双边循环法的partition函数
public static int partition2(int[] arr, int front, int back){
int pivot = front;
int mark = front;//代表小于基准元素的区域边界
for (int i = front+1; i <= back; i++){
if (arr[i] < arr[pivot]){
mark++;//因为找到一个小于基准元素的元素
swap(arr, i, mark);
}
}
swap(arr, pivot, mark);
return mark;
}
双边循环法
C++
#include<iostream>
using namespace std;
void disppart(int *a, int f, int b){
static int i = 1;
cout << "第" << i << "次划分:" << endl;
for(int j = 0; j < f; j++)
cout << " ";
for(int j = f; j <= b; j++)
cout << a[j] << " ";
cout << endl;
i++;
}
int partition(int *a, int f, int b){
int pivot = f;//pivot = a[f]
int front = f, back = b;
int temp = 0;
while(1){
while(front < back && a[back] >= a[pivot]) back--;//这两个while的顺序看似无关紧要,实际上是很关键的
while(front < back && a[front] <= a[pivot]) front++;
if(front < back){
temp = a[front];
a[front] = a[back];
a[back] = temp;
}
else
break;
}
temp = a[pivot];
a[pivot] = a[front];
a[front] = temp;
disppart(a, f, b);
return back; //return front 也行
}
void QuickSort(int *a, int f, int b){
int mid = 0;
if(f < b){
mid = partition(a, f, b);
QuickSort(a, f, mid - 1);
QuickSort(a, mid + 1, b);
}
}
int main(){
int a[10] = {6,8,7,9,0,1,3,2,4,5};
QuickSort(a, 0, 9);
for(int i = 0; i < 10; i++)
cout << a[i] << " ";
return 0;
}
Java
import java.util.Arrays;
public class QuickSort {
public static void main(String[] args) {
int[] arr = {6,8,7,9,0,1,3,2,4,5};
// System.out.println(Arrays.toString(arr));
sort(arr, 0, 9);
System.out.println(Arrays.toString(arr));
}
public static void sort(int[] arr, int front, int back) {
int mid = 0;
if(front < back) {
mid = partition(arr, front, back);
sort(arr, front, mid-1);
sort(arr, mid+1, back);
}
}
public static int partition(int[] arr, int front, int back) {
int pivot = front;
int f = front, b = back;
while(true) {
while(f < b && arr[b] >= arr[pivot]) b--;
while(f < b && arr[f] <= arr[pivot]) f++;
if(f < b) swap(arr, f, b);
else break;
}
swap(arr, pivot, f);
return f;
}
public static void swap(int[] arr, int front, int back) {
int temp = arr[front];
arr[front] = arr[back];
arr[back] = temp;
}
}
非递归方式
- 和递归实现相比,非递归方式代码的变动只发生在quickSort方法中。该方法引入了一个存储Map类型元素的栈,用于存储每一次交换时的起始下标和结束下标。
- 每一次循环,都会让栈顶元素出栈,通过partition方法进行分治,并且按照基准元素的位置分成左右两部分,左右两部分再分别入栈。当栈为空时,说明排序已经完毕,退出循环
Java
public static void quickSort2(int[] arr, int startIndex, int endIndex) {
// 用一个集合栈来代替递归的函数栈
Stack<Map<String, Integer>> quickSortStack = new Stack<>();
// 整个数列的起止下标,以哈希的形式入栈
Map<String, Integer> rootParam = new HashMap<>();
rootParam.put("startIndex", startIndex);
rootParam.put("endIndex", endIndex);
quickSortStack.push(rootParam);
// 循环结束条件:栈为空时
while (!quickSortStack.isEmpty()) {
// 栈顶元素出栈,得到起止下标
Map<String, Integer> param = quickSortStack.pop();
// 得到基准元素位置
int pivotIndex = partition(arr, param.get("startIndex"), param.get("endIndex"));
// 根据基准元素分成两部分, 把每一部分的起止下标入栈
if (param.get("startIndex") < pivotIndex - 1) {
Map<String, Integer> leftParam = new HashMap<>();
leftParam.put("startIndex", param.get("startIndex"));
leftParam.put("endIndex", pivotIndex - 1);
quickSortStack.push(leftParam);
}
if (pivotIndex + 1 < param.get("endIndex")) {
Map<String, Integer> rightParam = new HashMap<>();
rightParam.put("startIndex", pivotIndex + 1);
rightParam.put("endIndex", param.get("endIndex"));
quickSortStack.push(rightParam);
}
}
}
Q&A
Q: 注意第22、23行,为什么要先让back - - ?
- 因为此题的设定是从小到大(从左到右)排序,在最后的时候(即front==back),必须让他们所指的元素小于pivot元素(因为每次partition的最后还有一次swap),那么就必须先让back先–,因为back寻找的是就是小于pivot的元素,如果先让front++,它在最后时刻找到的就是大于pivot的元素,这就可能error了
Q: 那么如果是从大到小(从左到右)排序呢?(以第一个元素为pivot)
- 这个时候,显然back这时候就该寻找最大的了,所以还是该让back先ki走,到最后时刻才不会找到最小的。这种情况只需修改22、23行里的判断条件就可以了。
Q: 当然,那么还有一个问题,如果以最后一个元素为pivot呢?
- 答案是:这时候就让front先走
总结: 所以只存在两种情况:取决于pivot的位置。
- 如果在最前,就让back先走;在最后,就让front先走(无论从小到大还是从大到小)
- 一般取pivot在前,然后排序顺序不同的话只需要修改22、23行就行了
参考
- 《漫画算法》by 程序员小灰
计数排序(CountingSort)
简介
- 基本思路:假设输入元素序列的最大值和最小值差值为k,则创建一个长度为 k+1 的数组 count[],它的 count[i] 的值对应输入数组中 i 出现的次数。通过遍历一次输入数组并统计每个元素出现次数,最后遍历 count[] 输出。
- 计数排序是一个基于
非比较
的排序算法,该算法于1954年由 Harold H. Seward 提出。它的优势在于在对一定范围内的整数排序时,它的复杂度为Ο(n+k)(其中k是整数的范围)
,快于任何比较排序算法
- 当然这是一种
牺牲空间换取时间
的做法,而且当O(k)>O(n*log(n))*的时候其效率反而不如基于比较的排序(基于比较的排序的时间复杂度在理论上的下限是O(nlog(n)), 如归并排序,堆排序)
算法实现
Java
public class CountingSort {
public static void main(String[] args) {
int[] array = new int[]{95, 94, 91, 98, 99, 90, 99, 93, 91, 92};
int[] sortedArray = countSort(array);
System.out.println(Arrays.toString(sortedArray));
}
@SuppressWarnings("ForLoopReplaceableByForEach")
public static int[] countSort(int[] array) {
//1.得到数列的最大值和最小值,并算出差值d
int max = array[0];
int min = array[0];//最小值作为一个偏移量,用于计算整数在统计数组中的下标
for (int i = 1; i < array.length; i++) {
if (array[i] > max) max = array[i];
if (array[i] < min) min = array[i];
}
int d = max - min;
//2.创建统计数组并统计对应元素的个数
int[] countArray = new int[d + 1];
for (int i = 0; i < array.length; i++)
countArray[array[i] - min]++;
//3.统计数组做变形,后面的元素等于前面的元素之和
for (int i = 1; i < countArray.length; i++)
countArray[i] += countArray[i - 1];//让统计数组存储的元素值,等于相应整数的最终排序位置的序号
//4.倒序(正序也可)遍历原始数列,从统计数组找到正确位置,输出到结果数组
int[] sortedArray = new int[array.length];
for (int i = array.length - 1; i >= 0; i--) {
sortedArray[countArray[array[i] - min] - 1] = array[i];//这里减一是因为数组下标是从0开始的
countArray[array[i] - min]--;
}
return sortedArray;
}
}
算法分析
- 假设原始数列的规模是n,最大和最小整数的差值是m
- 代码第1、2、4步都涉及遍历原始数列,运算量都是n,第3步遍历统计数列,运算量是m,所以总体运算量是3n+m,去掉系数,时间复杂度是O(n+m)。
- 空间复杂度:如果不考虑结果数组,只考虑统计数组大小的话,空间复杂度是O(m)。
特性
- 优化版本的计数排序属于稳定排序
缺点
- 当数列最大和最小值差距过大时,并不适合用计数排序。例如给出20个随机整数,范围在0到1亿之间,这时如果使用计数排序,需要创建长度为1亿的数组。不但严重浪费空间,而且时间复杂度也会随之升高。
- 当数列元素不是整数时,也不适合用计数排序
对于这些局限性,另一种线性时间排序算法做出了弥补,这种排序算法叫作桶排序
桶排序
public class BucketSort {
public static void main(String[] args) {
double[] array = new double[]{4.12,6.421,0.0023,3.0,2.123,8.122,4.12, 10.09};
double[] sortedArray = bucketSort(array);
System.out.println(Arrays.toString(sortedArray));
}
public static double[] bucketSort(double[] array) {
double max = array[0], min = array[0];
for (int i = 1; i < array.length; ++i) {
if (array[i] > max) max = array[i];
if (array[i] < min) min = array[i];
}
double d = max - min;
int bucketNum = array.length;
ArrayList<LinkedList<Double>> bucketList = new ArrayList<>(bucketNum);
for (int i = 0; i < bucketNum; ++i)
bucketList.add(new LinkedList<>());
for (int i = 0; i < array.length; ++i) {
int num = (int)((array[i] - min) * (bucketNum - 1)/d);//???
bucketList.get(num).add(array[i]);
}
for (int i = 0; i < bucketList.size(); ++i){
Collections.sort(bucketList.get(i));
}
double[] sortedArray = new double[array.length];
int index = 0;
for (LinkedList<Double> list : bucketList){
for (double element : list){
sortedArray[index++] = element;
}
}
return sortedArray;
}
}
堆排序
-
堆排序是基于二叉堆的排序算法,二叉堆的节点“下沉”调整(downAdjust 方法)是堆排序算法的基础
-
步骤如下
- 先把无序数组构建成二叉堆。若需要从小到大排序,则构建成最大堆;需要从大到小排序,则构建成最小堆
- 循环将堆顶元素替换到二叉堆的末尾,调整堆,产生新的堆顶
-
堆排序是
不稳定
排序
复杂度分析
- 第1步的时间复杂度是O(n)
- 第2步需要进行n-1次循环。每次循环调用一次downAdjust方法,所以第2步的计算规模是 (n-1)×logn ,时间复杂度为O(nlogn)
- 两个步骤是并列关系,所以整体的时间复杂度是O(nlogn)
- 最坏时间复杂度也稳定在O(nlogn)
- 空间复杂度是
O(1)
Java实现
/**
* 堆排序(降序,利用最小堆)
*/
public class HeapSort {
public static void main(String[] args) {
int[] arr = new int[]{1, 3, 2, 6, 5, 7, 8, 9, 10, 0};
heapSort(arr);
System.out.println(Arrays.toString(arr));
}
/**
* 堆排序(降序,利用最小堆)
* @param array 输入数组
*/
public static void heapSort(int[] array) {
// 1. 把无序数组构建成最小堆
for (int i = (array.length / 2) - 1; i >= 0; i--)
downAdjust(array, i, array.length);
// 2. 循环删除堆顶元素,移到集合尾部,调整堆产生新的堆顶
for (int i = array.length - 1; i > 0; i--) {
// 最后1个元素和第1个元素进行交换
int temp = array[i];
array[i] = array[0];
array[0] = temp;
// “下沉”调整最大堆
downAdjust(array, 0, i);//注意这里长度为i,是本轮排序的有效范围
}
}
}