在很多场景下都要对数据进行排序,在数据量很大的情况下对于算法性能的要求就会比较高,了解并掌握常用的排序算法及应用场景可以让我们编写出更高效的代码。这里通过一些的例子介绍一些常见的排序算法。
以下所有排序都实现Example接口,该接口代码如下:
/**
* 排序算法模板类
* @author: Charviki
* @create: 2019-09-05 21:44
**/
public interface Example<T extends Comparable<T>> {
/**
* 排序
* @param a
*/
void sort(T[] a);
/**
* 比较数组中两个元素大小
* @param v
* @param w
* @return
*/
default boolean less(T v,T w){
return v.compareTo(w) < 0;
}
/**
* 交换数组中两个元素的位置
* @param a
* @param i
* @param j
*/
default void swap(T[] a,int i,int j){
T t = a[i];
a[i] = a[j];
a[j] = t;
}
/**
* 打印出数组中的所有元素
* @param a
*/
default void show(T[] a){
for (int i = 0;i < a.length;i++){
System.out.print(a[i] + " ");
}
System.out.println();
}
/**
* 判断数组是否有序
* @param a
* @return
*/
default boolean isSorted(T[] a){
for (int i = 1;i<a.length;i++){
if (less(a[i],a[i-1])){
return false;
}
}
return true;
}
/**
* 测试
* @param example
* @param arr
*/
static void test(Example example,Integer[] arr) {
example.show(arr);
example.sort(arr);
assert example.isSorted(arr);
example.show(arr);
}
}
用于产生无序Integer数组的工具类:
/**
* 产生无序Integer数组的工具类
* @author: Charviki
* @create: 2019-10-11 15:41
**/
public class ArrayUtil {
public static Integer[] generateUnorderedArray(){
Integer[] arr = new Integer[110];
return generateUnorderedList(100).toArray(arr);
}
public static Integer[] generateUnorderedArray(int N){
Integer[] arr = new Integer[N + N/10];
return generateUnorderedList(N).toArray(arr);
}
public static List<Integer> generateUnorderedList(int N){
List<Integer> list = new ArrayList<>();
// 添加100个不重复的数
for (int i = 0;i < N;i++){
list.add(i);
}
// 随机添加几个1-100内已存在的数
for (int i = 0;i < N/10;i++){
list.add(i*10 + new Random().nextInt(10));
}
// 将集合中元素顺序打乱
Collections.shuffle(list);
return list;
}
}
选择排序算法的大致思路是:第一次遍历整个数组,找出数组中的最小元素,将最小元素与数组第一个元素交换位置。第二次遍历剩余的数组元素,找出最小元素与数组第二个元素交换。即每次都找出剩余数组元素中最小的元素,将剩余数组元素中的最小元素与剩余数组元素中的第一个元素交换位置,直至整个数组有序。
一次完整的选择排序如下图所示:
实现代码如下:
public class Selection<T extends Comparable<T>> implements Example<T>{
@Override
public void sort(T[] a){
// 将a[]按升序排列
int N = a.length;
for (int i = 0; i < N; i++) {
int min = i;
for (int j = i + 1;j < N;j++){
if (less(a[j],a[min])){
min = j;
}
}
swap(a,i,min);
}
}
public static void main(String[] args) {
Example.test(new Selection(),ArrayUtil.generateUnorderedArray());
}
}
冒泡排序每次只比较相邻元素,从左到右交换逆序相邻元素,每一轮循环都可以使未排序元素中的最大元素上浮到数组右侧。一轮循环的示意图如下:
一轮循环过后,数组中的最大元素4上浮到了数组最右端。继续循环,如果在一轮循环中没有发生元素的交换,则说明数组有序。
实现代码如下:
public class Bubble<T extends Comparable<T>> implements Example<T> {
@Override
public void sort(T[] nums) {
int N = nums.length;
boolean isSorted = false;
for (int i = N - 1;i > 0 && !isSorted;i--){
isSorted = true;
for (int j = 0;j < i;j++){
if (less(nums[j+1],nums[j])){
isSorted = false;
swap(nums,j,j+1);
}
}
}
}
public static void main(String[] args) {
Example.test(new Bubble(),ArrayUtil.generateUnorderedArray());
}
}
插入排序的大致思路是:遍历数组,将遍历到每一个元素i
与在它之前的元素进行比较,如果i
小于其中的某个元素,则将交换两个元素的位置。即将a[i]
插入到a[i-1],a[i-2],a[i-3]···
中。和选择排序一样,插入排序保证了i
之前的所有元素有序。
实现代码如下:
public class Insertion<T extends Comparable<T>> implements Example<T>{
@Override
public void sort(T[] a){
int N = a.length;
for (int i = 0; i < N; i++){
for (int j = i;j > 0 && less(a[j],a[j-1]);j--){
swap(a,j,j-1);
}
}
}
public static void main(String[] args) {
Example.test(new Insertion(),ArrayUtil.generateUnorderedArray());
}
}
希尔排序是一种基于插入排序的快速的排序算法。通过增加一个递增序列,将数组分为多个由间隔为h的元素组成的子数组(逻辑上分组),在遍历数组的过程中同时对间隔为h的子数组进行插入排序,即将a[i]
插入到a[i-h],a[i-2h],a[i-3h]中
,实现间隔为h的子数组有序。当所有间隔为h的所有子数组有序时,缩小间隔h,重复上一步操作,提供数组的有序程度。当h缩小到1是,就是一次直接插入排序,从而实现整个数组有序。
关于h序列的选择对排序的效率是有影响的,对于h序列的性能问题一般都是那些大牛研究的,笔者实力有限,这里就不展开讨论。这里使用的h序列是h=3*h+1
每次循环实现h有序后将缩小间隔h,这里缩小的倍数是3,所以下一次循环的间隔h就是1,相当于对间隔为1的元素进行排序,即一次直接插入排序。
实现代码如下:
public class Shell<T extends Comparable<T>> implements Example<T>{
@Override
public void sort(T[] a){
int N = a.length;
int h = 1;
while (h < N/3){
// 递增序列
h = 3*h + 1;
}
while (h >= 1){
for (int i = h;i < N;i++){
for (int j = i;j >= h && less(a[j],a[j-h]);j = j -h){
swap(a,j,j-h);
}
}
h = h/3;
}
}
public static void main(String[] args) {
Example.test(new Shell(),ArrayUtil.generateUnorderedArray());
}
}
归并排序是将数组拆分为两个数组(逻辑上),并将两个数组分别排序,最后再合并到一起。
public class Merge<T extends Comparable<T>> implements Example<T>{
/**
* 辅助数组
*/
private T[] aux;
@Override
public void sort(T[] nums) {
aux = (T[]) new Comparable[nums.length];
sort(nums,0,nums.length - 1);
}
private void sort(T[] nums,int lo,int hi){
if (lo >= hi){
return;
}
int mid = lo + (hi - lo)/2;
// 左半边排序
sort(nums,lo,mid);
// 右半边排序
sort(nums,mid + 1,hi);
// 归并结果
merge(nums,lo,mid,hi);
}
private void merge(T[] nums,int lo,int mid,int hi){
int i = lo,j = mid + 1;
// 将数组a复制到数组aux中
for (int k = lo;k <= hi;k++){
aux[k] = nums[k];
}
// 将a[lo..mid]和a[mid+1..hi]归并
for (int k = lo;k <= hi;k++){
if (i > mid){
// 左半边用尽,取右半边
nums[k] = aux[j++];
}else if (j > hi){
// 右半边用尽,取左半边
nums[k] = aux[i++];
}else if (less(aux[i],nums[j])){
// 左边元素比右边元素小,取右边
nums[k] = aux[i++];
}else{
// 右边元素比左边元素小,取右边
nums[k] = aux[j++];
}
}
}
public static void main(String[] args) {
Example.test(new Merge(),ArrayUtil.generateUnorderedArray());
}
}
@Override
public void sort(T[] nums){
int N = nums.length;
aux = (T[]) new Comparable[N];
for (int sz = 1;sz < N;sz += sz){
for (int lo = 0;lo < N-sz;lo += sz+sz){
merge(nums,lo,lo+sz-1,Math.min(N-1,lo+sz+sz-1));
}
}
}
快速排序是通过一个切分元素将数组切分成两个数组,左边的数组元素都小于切分元素,右边数组元素都大于切分元素,将子数组排序,子数组都有序时整个数组也就有序了。
public class Quick<T extends Comparable<T>> implements Example<T>{
@Override
public void sort(T[] nums) {
sort(nums,0,nums.length - 1);
}
private void sort(T[] nums,int lo,int hi){
if (lo >= hi){
return;
}
int j = partition(nums,lo,hi);
sort(nums,lo,j-1);
sort(nums,j+1,hi);
}
private int partition(T[] nums,int lo,int hi){
int i = lo,j = hi + 1;
T v = nums[lo];
while (true){
while (less(nums[++i],v)){
if (i == hi){
break;
}
}
while (less(v,nums[--j])){
// 此处if判断可去除
if (j == lo){
break;
}
}
if (i >= j){
break;
}
swap(nums,i,j);
}
swap(nums,lo,j);
return j;
}
public static void main(String[] args) {
Example.test(new Quick(),ArrayUtil.generateUnorderedArray());
}
}
快速排序在对小数组进行拆分的使用仍会使用递归调用sort()。对于小数组,快速排序比插入排序慢,我们可以在对小数组排序时不使用递归,而切换成插入排序以提高效率。
对于切分元素,我们可以采用数组的中位数作为切分元素。但代价是需要计算中位数,对于大数组来说有可能得不偿失。一种更可行的办法是 取 3 个元素,并将大小居中的元素作为切分元素。
对于有大量重复元素的数组,我们可以将数组拆分为三部分,分别对于小于、等于、大于切分元素。
三向切分快速排序对于有大量重复元素的随机数组可以在线性时间内完成排序,比标准的快速排序的效率高得多。
public class Quick3way<T extends Comparable<T>> implements Example<T>{
@Override
public void sort(T[] nums) {
sort(nums,0,nums.length - 1);
}
private void sort(T[] nums,int lo,int hi){
if (lo >= hi){
return;
}
int lt = lo,i = lo+1,gt = hi;
T v = nums[lo];
while (i <= gt){
int cmp = nums[i].compareTo(v);
if (cmp < 0){
swap(nums,i++,lt++);
}else if(cmp > 0){
swap(nums,i,gt--);
}else{
i++;
}
}
sort(nums,lo,lt - 1);
sort(nums,gt + 1,hi);
}
public static void main(String[] args) {
Example.test(new Quick3way(),ArrayUtil.generateUnorderedArray());
}
}
在堆有序的二叉树中,在每一次循环中都把最大元素和当前堆中数组的最后一个元素交换位置,并且剔除最大元素(逻辑上),那么就可以得到一个从尾到头的递减序列,从正向来看就是一个递增序列,这就是堆排序。
当一棵二叉树的每个结点都大于等于它的两个子节点时,它被称为堆有序。
堆可以用数组来表示,这是因为堆是完全二叉树,而完全二叉树很容易就存储在数组中。位置 k 的节点的父节点位置为 k/2,而它的两个子节点的位置分别为 2k 和 2k+1。这里不使用数组索引为 0 的位置,是为了更清晰地描述节点的位置关系。
要想实现堆排序,首先要使堆有序。有两种办法可以实现堆有序,分别是上浮和下沉。
如果堆的状态因为某个节点变得比它的父节点更大而被打破时,那么我们就需要通过交换它和它的父节点来修复堆。
private void swim(T[] nums,int k){
while (k > 1 && less(nums[k/2],nums[k])){
swap(nums,k/2,k);
k = k/2;
}
}
如果堆的状态因为某个节点变得比它的两个子节点或是其中之一更小而被打破时,那么我们就需要通过将它和它的两个子节点中的较大者交换来修复堆。
private void sink(T[] nums, int i, int N) {
while (2*i <= N){
int j = 2*i;
if (j < N && less(nums[j],nums[j+1])){
j++;
}
if (!less(nums[i],nums[j])){
break;
}
swap(nums,i,j);
i = j;
}
}
无序数组建立堆最直接的方法是从左到右遍历数组进行上浮操作。一个更高效的方法是从右至左进行下沉操作,如果一个节点的两个节点都已经是堆有序,那么进行下沉操作可以使得这个节点为根节点的堆有序。叶子节点不需要进行下沉操作,可以忽略叶子节点的元素,因此只需要遍历一半的元素即可
一次下沉排序过程如下:
代码实现如下:
public class Heap<T extends Comparable<T>> implements Example<T> {
@Override
public void sort(T[] nums) {
int N = nums.length - 1;
// i从N/2开始,叶子节点不需要下沉。
for (int i = N/2;i > 0;i--){
sink(nums,i,N);
}
while (N > 0){
swap(nums,1,N--);
sink(nums,1,N);
}
}
private void sink(T[] nums, int i, int N) {
while (2*i <= N){
int j = 2*i;
if (j < N && less(nums[j],nums[j+1])){
j++;
}
if (!less(nums[i],nums[j])){
break;
}
swap(nums,i,j);
i = j;
}
}
public static void main(String[] args) {
List<Integer> list = ArrayUtil.generateUnorderedList(100);
list.add(0,null);
Integer[] arr = new Integer[111];
list.toArray(arr);
Example.test(new Heap(),arr);
}
}
算法 | 稳定性 | 时间复杂度 | 空间复杂度 | 备注 |
---|---|---|---|---|
选择排序 | × | N 2 N^{2} N2 | 1 | |
冒泡排序 | √ | N 2 N^{2} N2 | 1 | |
插入排序 | √ | N N N ~ N 2 N^{2} N2 | 1 | 时间复杂度和初始顺序有关 |
希尔排序 | × | N 的若干倍乘于递增序列的长度 | 1 | 改进版插入排序 |
快速排序 | × | logN | logN | |
三向切分快速排序 | × | N~NlogN | logN | 适用于有大量重复主键 |
归并排序 | √ | NlogN | N | |
堆排序 | × | NlogN | 1 | 无法利用局部性原理 |