算法之排序
排序算法基本上是我们无论是在项目中还是在面试中都会遇到的问题,加上最近在看《算法》这本书,所以就准备好好的将排序算法整理一下。
所有排序算法都是基于 Java 实现,为了简单,只使用了int类型,从小到大排序
- 基本排序
- 高效的排序
- 各大排序的时间测试
- 如何选择排序
排序之基本排序算法
准备阶段:有一个交换位置的函数exc
/**
* 交换a数组中i和j的位置
* @param a 需要交换的数组
* @param i 位置
* @param j 位置
*/
public static void exc(int a[],int i,int j){
// 当他们相等的时候就没必要进行交换
if(a[i] != a[j]){
a[i] ^= a[j];
a[j] ^= a[i];
a[i] ^= a[j];
}
}
基本排序算法主要是分为插入排序,选择排序,冒泡排序和梳排序。
选择排序
原理: 选择排序的原理很简单,就是从需要排序的数据中选择
最小的(从小到大排序),然后放在第一个,选择第二小的放在第二个……
代码:
/**
* 选择排序
* @param a 进行排序的数组
*/
public static int[] selectionSort(int a[]){
int min;
for(int i=0;i<a.length;i++){
min = i;
// 这个for循环是为了找出最小的值
for (int j = i+1; j < a.length; j++) {
if(a[min]>a[j]){
min = j;
}
}
/** 如果第一个取出的元素不是最小值,就进行交换
* 意思就是:如果取出的元素就是最小值,那么就没有必要进行交换了 */
if(min != i){
// 进行交换
exc(a, i, min);
}
}
return a;
}
选择排序的动画演示
-
假如数组的长度是N,则时间复杂度:
进行比较的次数:(N-1)+(N-2)+……+1 = N(N-1)/2
进行交换的次数:N
-
特点:(稳定)
- 运行时间与输入无关。因为前一次的扫描并不能为后面的提供信息。
- 数据的移动次数是最小的。
插入排序
原理: 如果数组进行循环得到a,若a比a前面的一个数小,则a就与前面数交换位置(相当于a向前面移动一位),若移动后a任然比前面一个数小,则再向前移动一位……
代码:
/**
* 插入排序
* @param a 进行排序的数组
* @return 返回排序好的数组
*/
public static int[] insertSort(int a[]) {
int N = a.length;
for (int i = 0; i < N; i++) {
// 如果a[i]比前面的数字小,则a[i]向前挪
for (int j = i; j >0 && (a[j-1]>a[j]); j--) {
exc(a, j, j-1);
}
}
return a;
}
动画演示:
-
若数组的长度是N(不重复 ),则时间复杂度:
- 平均:N*N/4 次比较,N*N/4次交换
- 最好:N-1次比较,0次交换
- 最坏:N*N/2次比较, N*N/2次交换
-
特点:
若数据倒置的数量很少时,速度快。
冒泡排序
原理: 冒泡排序的原理就是小的数字慢慢的往上浮。从数组最后面开始循环,如果一个数比它前面数小,则交换两者位置。
代码:
/**
* 冒泡排序
* @param a
* @return
*/
public static int[] bubbleSort(int[] a) {
int N = a.length;
for (int i = 0; i < N - 1; i++) {
// 小的数字向上冒泡
for (int j= N-1; j > i; j--) {
// 交换位置
if(a[j-1]>a[j]){
exc(a, j-1, j);
}
}
}
return a;
}
冒泡排序的动画示意图:
这个示意图和代码刚好相反,这个是将大的向后下沉
时间复杂度:
- 平均情况下:冒泡比较的次数约是插入排序的两倍,移动次数一致。
- 平均情况下:冒泡与选择排序的比较此时是一样的,移动比选择排序多出n次
冒泡算法的改进:
改进部分就是,如果在第二层for循环中,如果不发生交换,则代表数据已经排好序了,不需要继续排序。
/**
* 冒泡排序的优化
* @param a
* @return
*/
public static int[] bubbleSort2(int[] a) {
int N = a.length;
boolean flag = true;
for (int i = 0; i < N - 1 && flag; i++) {
int j = N-1;
for (flag = false; j > i; j--) {
if(a[j-1]>a[j]){
flag = true;
exc(a, j-1, j);
}
}
}
return a;
}
bubbleSort2()并不是一个多么令人欣喜的改进,但是基于bubbleSort2()的梳排序,却值得研究一下
——《C++数据结构与算法》
排序之高效排序算法
梳排序
原理: 梳排序分为两部分,第一部分通过步长stepn进行简单的排序,将大的数据集中到后面。第二部分是使用bubbleSort2()进行排序。
通过第一部分step的比较,我们能够有效的消除数组中的乌龟(即在数组尾部的较小的数值)
/**
* 梳排序
* @param a
* @return
*/
public static int[] combSort(int[] a) {
int N = a.length;
int step = N;
int k;
// 第一部分
while((step /= 1.3) > 1) {
for (int i = N-1; i >= step; i--) {
k = i -step;
if(a[k]>a[i]){
// 交换位置
exc(a, k, i);
}
}
}
// 第二部分:进行冒泡排序
a= bubbleSort2(a);
return a;
}
梳排序动画示意图:
在梳排序中,原作者用随机数做实验,得到了最有效的递减效率是1.3。也就是step/=1.3
,同样也可以写成step *= 0.8
,因为编程语言乘法比除法快。
希尔排序
希尔排序是基于插入排序进行改进,又称之为递减增量排序
。在前面中我们知道,插入排序是将小的元素往前挪动位置,并且每次只移动一个位置。那么希尔排序是怎么解决这个问题的呢?
原理:希尔排序的理念和梳排序的理念有点类似。在梳排序中,我们比较距离相差为step
的两个元素来完成交换。在希尔排序中,我们的做法也是类似。我们在数组中每隔h
取出数组中的元素,然后进行插入排序。当h=1时,则就是前面所写的插入排序了。
代码实现:
/**
* shell排序
* @param a
* @return
*/
public static int[] shellSort(int[] a){
int N = a.length;
int h = 1;
// 增量序列
while(h < N/3){
// h = 1,4,13,40,……
h = h*3 + 1;
}
while(h>=1){
for (int i = h; i < N; i++) {
// 进行插入排序,诺a[j]比a[j-h]小,则向前挪动h
for (int j = i; j >= h && a[j-h]>a[j]; j -= h) {
exc(a, j, j-h);
}
}
h /= 3;
}
return a;
}
快速排序
原理: 快速排序使用分治法(Divide and conquer)策略来把一个序列分为较小和较大的2个子序列,然后递归地排序两个子序列。
步骤为:
- 挑选基准值:从数列中挑出一个元素,称为“基准”(pivot),
- 分割:重新排序数列,所有比基准值小的元素摆放在基准前面,所有比基准值大的元素摆在基准后面(与基准值相等的数可以到任何一边)。在这个分割结束之后,对基准值的排序就已经完成,
- 递归排序子序列:递归地将小于基准值元素的子序列和大于基准值元素的子序列排序。
递归到最底部的判断条件是数列的大小是零或一,此时该数列显然已经有序。
选取基准值有数种具体方法,此选取方法对排序的时间性能有决定性影响。
快速排序的实现代码:
在前面我们知道,选取正确的基准值对排序的性能有着决定性的影响,在这里我们选择序列中间的值作为基准值。
代码主要分为两个部分:
-
进行切分的代码
-
进行递归调用的代码
第一部分
/**
* 进行切分,并进行交换
* @param a 数组
* @param lo 切分开始的位置
* @param h 切分结束的位置
* @return 返回分界点的位置
*/
public static int partition(int[] a,int lo,int h){
// 选取中间的值为基准值
int middle = (lo+h+1)/2;
int v = a[middle];
// 将基准值和a[lo]交换位置
exc(a, lo, middle);
int i = lo;
int j = h+1;
while(true){
// 假如左边的小于基准值,则一直进行循环
while(a[++i] < v){
// 防止越界
if(i == h){
break;
}
}
// 假如右边的大于基准值,则一直进行循环
while(a[--j]>v){
if(j == lo){
break;
}
}
// 一旦i>=j则代表i前面的除第一个外都比基准值小,j后面的都比基准值大,这时候就可以跳出循环了
if(i>=j){
break;
}
// 进行交换(因为a[lo]>v,a[h]<v,所以将两者进行交换)
exc(a, i,j);
}
// 将基准放到分界点
exc(a, lo, j);
return j;
}
第二部分:
/**
* 调用quickSort函数
* @param a 数组
*/
public static void quickSort(int[] a){
quickSort(a,0,a.length-1);
}
/**
* 进行递归的快排
* @param a
* @param lo
* @param h
*/
public static void quickSort(int[] a,int lo,int h){
if(h <= lo) {
return ;
}
// j为基准值的位置
int j = partition(a, lo, h);
// 进行递归调用,将j前面的进行快排
quickSort(a,lo,j-1);
// 进行递归调用,将j后面的进行快排
quickSort(a,j+1,h);
}
快速排序动画示意图:
特点:
快速排序在最坏的情况下时间复杂度是O(n**2),平均时间复杂度是O(nlogn)。快速排序基本上被认为是相同数量级的所有排序算法中,平均性能最好的。
堆排序
原理:堆排序是利用堆这个数据结构而设计的一种排序算法。
堆是具有以下性质的完全二叉树:每个结点的值都大于或等于其左右孩子结点的值,称为大顶堆;或者每个结点的值都小于或等于其左右孩子结点的值,称为小顶堆。
接下来我们将使用大顶堆
来进行从小到大的排序。图源这位大佬讲的不错!!
在一个堆中,位置k的结点的父元素的位置是(k+1)/2-1
,而它的两个子节点的位置分别是2k+1
和2k+2
,这样我们就可以通过计算数组的索引在树中上下移动。
那么我们 进行堆排序, 应该怎么做呢?首先,我们得构建一个堆(大顶堆)。构建的思路就是:我们将小的元素下沉(sink())即可。
/**
* 小的结点往下移动
* @param a
* @param k 开始移动的位置
* @param N 下沉结束位置
*/
public static void sink(int[] a,int k,int N) {
// 满足向下移动的条件
while(2*k+1 <= N){
int j = 2*k + 1;
// 从 a[j]和a[j+1]中a比较出较大的元素
if(j < N -1 && a[j+1] > a[j]){
j ++;
}
if(a[j] < a[k]){
break;
}
// 将大的元素移动到上面去
exc(a, k, j);
k = j;
}
}
我们通过调用sink()函数和一些逻辑就可以得到一个大顶堆了。【注意:在大顶堆中,可以很简单的知道堆顶的元素是最大值】那么我们如何进行堆排序呢?这时候我们可以将对顶的元素移动到最后使得末尾的元素最大,然后我们继续调用sink函数,又可以使得堆顶的元素最大(实则为总的第二大),然后继续重复以前的操作即可。
public static void heepSort(int[] a) {
int N = a.length;
// 构造一个堆有序
for (int i = N/2; i >= 0; i--) {
sink(a, i,N - 1);
}
N = N -1;
// 然后进行下沉排序
while(N>0){
exc(a, 0, N--);
sink(a, 0,N);
}
}
动画演示:
堆排序的特点:
- 最好、最坏、平均的时间复杂都为O(nlogn),空间复杂度为O(1)。
- 是一种不稳定的排序。
牺牲空间节约时间的高效排序
归并排序(Merge Sort)
归并排序的核心思想是分治法,是创建在归并操作上面的一种有效的排序算法。
原理:
采用分治法:
-
分割:递归地把当前序列平均分割成两半。
-
集成:在保持元素顺序的同时将上一步得到的子序列集成到一起(归并)。
原理图:
代码实现:
首先我们来实现数组之间的归并操作:
// 临时空间
public static int[] aux;
/**
* 进行归并操作
* @param a 数组
* @param lo 第一部分数组的开始位置
* @param middle 第一部分数组归并的结束位置
* @param hi 第二部分数组归并的结束位置
*/
public static void merge(int[] a,int lo,int middle,int hi) {
int i = lo;
// 第二部分数组归并的开始位置
int j = middle +1;
// 将a[lo..hi]的内容复制到aux[lo..hi]
for (int k = lo; k <= hi; k++) {
aux[k] = a[k];
}
for (int z = lo; z <= hi; z++) {
if(i > middle){
a[z] = aux[j++];
}else if(j > hi){
a[z] = aux[i++];
}else if(aux[i] > aux[j]){
a[z] = aux[j++];
}else{
a[z] = aux[i++];
}
}
}
MergeSort算法调用:
public static void mergeSort(int[] a){
aux = new int[a.length];
mergeSort(a, 0, a.length-1);
}
public static void mergeSort(int[] a, int lo, int hi ){
if(lo >= hi){
return;
}
int middle = (lo + hi)/2;
// 对左半边进行排序
mergeSort(a,lo,middle);
// 对右半边进行排序
mergeSort(a,middle+1,hi);
// 进行归并
merge(a, lo, middle, hi);
}
特点:
归并排序是一种稳定的并且十分高效的排序。在时间复杂度方面,mergeSort的时间复杂度是O(nlogn)【无论是最好还是最坏的情况】,空间复杂度是O(n)。
基数排序(非比较排序)
-
实例分析
基数排序的方式有 LSD (Least sgnificant digital) 和 MSD (Most sgnificant digital)两种方式。LSD 的排序方式由键值的最右边开始,而 MSD 则相反,由键值的最左边开始。 以 LSD 为例
data = [10 123 732 67 5 918 7 ]
首先根据个位数的数值,j将数据分配到不同的桶中
编号 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
---|---|---|---|---|---|---|---|---|---|---|
10 | 732 | 123 | 5 | 67 | 918 | |||||
7 | 99 |
然后,将这些数字按照桶以及桶内部的排序连接起来:
data = [10 732 123 5 67 7 918]
接着按照十位的数值,放入不同的桶中(ps:5的十位是0)
编号 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
---|---|---|---|---|---|---|---|---|---|---|
5 | 10 | 123 | 732 | 67 | 918 | |||||
7 |
重复连接操作,完成排序:
data = [5 7 10 123 732 67 918]
最后根据百位的数值,放入不同的桶中(ps:5的十位是0)
编号 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
---|---|---|---|---|---|---|---|---|---|---|
5 | 123 | 732 | 918 | |||||||
7 | ||||||||||
10 | ||||||||||
67 |
最后重复连接操作,完成排序:
data = [5 7 10 67 123 732 918]
代码实现
public static void radixSort(int[] a){
int max = a[0];
for (int value : a) {
if(max < value){
max = value;
}
}
// 找出最大位数N
int N = 0;
if(max == 0){
N = 1;
}else{
N = (int) (Math.log10(max) + 1);
}
// 进行基数排序
radixSort(a,N);
}
/**
* 基数排序
* @param a
* @param N 最大位数
*/
public static void radixSort(int[] a, int N) {
// 相当于博客中表格的编号
int radix = 10;
int length = a.length;
// 代表1,10,100……
int factor = 1;
//之所以将二位数组的高度设置为length是为了防止极端情况【即所有数据的最高位数相同】
int[][] bucket = new int[radix][length];
// 记录每一个bucket里面有多少个元素
int[] order = new int[radix];
for(int i =0;i<N;i++,factor *= 10){
// 将数据放入桶中
for (int v : a) {
int digit = (v/factor)%10;
bucket[digit][order[digit]] = v;
order[digit] ++;
}
int position = 0;
// 将桶中的数据重新连接放入数组中
for(int j =0;j<radix;j++ ){
// 假如里面有数据
if(order[j] != 0){
// 将数据放入数组中
for (int k = 0; k < order[j]; k++) {
a[position++] = bucket[j][k];
}
// 将计数器置零
order[j] = 0;
}
}
}
}
特点:
- 不依赖于数据比较。
- 时间复杂度为O(k*n);空间复杂度为O(n)
计数排序(非比较排序)
原理:
计数排序使用一个额外的数组C,其中C中第i个元素是待排序数组A中值等于i的元素的个数。然后根据数组C来将A中的元素排到正确的位置。
/**
* 计数排序
* @param a
*/
public static void countSort(int[] a){
int max = a[0];
// 找出最大值
for (int v : a) {
if(v > max){
max = v;
}
}
// 辅助数组
int[] count = new int[max+1];
// 将数据的个数储存到count数组中
for (int v : a) {
count[v] ++;
}
int indexArray = 0;
for (int i = 0; i < count.length; i++) {
while(count[i] > 0){
a[indexArray++]=i;
count[i] --;
}
}
}
当然,如果数据比较集中的话,我们大可不必创建那么大的数组,我们找出最小和最大的元素,以最小的元素作为基底以减小数组的大小。
动画演示:
特点:
- 计数排序是一种稳定的线性时间排序算法。
- 时间复杂度为O(n+k),空间复杂度为O(n+k)