使用Java对排序算法学习和练习
代码标准参考:http://www.cyc2018.xyz/
# 算法-排序
待排序的数组需要实现Java的Comparable接口,该接口有CompareTo()方法,用来判断两个元素的大小关系
使用辅助函数less() 和 swap() 来进行比较和交换的操作,使代码可读性更好。
排序算法的成本模型是 比较和交换的次数
public abstract class Sort<T extends Comparable<T>>{
public abstract void sort(T[] nums);
protected boolean less(T v, T w){
return v.comparaTe(w) < 0;
}
protected void swap(T[] a, int i , int j){
T t = a[i];
a[i] = a[j];
a[j] = t;
}
}
1. 选择排序:从数组中选择最小元素,将它与数组的第一个元素交换位置。再将数组剩下的元素中选择最小的元素依次交换位置。
选择排序需要 N2次比较 和 N次交换,运行与输入无关。
public class Selection<T extends Comparable<T>> extends Sort<T>{
@Override
public void sort(T[] nums){
int N = nums.length;
for(int i = 0; i < N-1; i++){
int min = i;
for(int j = i+1; j < N; j++){
if(less(nums[j], nums[min]){
min = j;
}
}
swap(nums, i , min);
}
}
}
2. 冒泡排序: 从左到右不断交换相邻逆序的元素。在一轮循环之后,可以让未排序的最大元素上浮到右侧。
如果在一轮循环中,如果没有发生交换,说明说组已经有序,可以直接退出
public class Bubble<T extends Comparable<T>> extends Sort<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);
}
}
}
}
}
3. 插入排序: 每次都将当前元素插入到左侧已经排序的数组中,使得插入后左侧数组依然有序。
对于数组存在逆序,插入排序每次只能交换相邻元素,令逆序数量减少1,因此插入排序需要交换的次数为逆序数量
插入排序的时间复杂度取决于数组的初始顺序,部分有序则逆序少,交换次数少,时间复杂度少。
平均需要 N2 /4 比较和 交换 。 最坏需要 N2 / 2比较和交换,逆序排序。 最好是 N- 1次比较和0次交换。
public class Insertion<T extendx Comparable<T>> extends Sort<T>{
@Override
public void sort(T[] nums){
int N = nums.length;
for(int i = 1; i < N; i++){
for(int j = i; j > 0 && less(nums[j], nums[j -1]; j--)){
swap(nums, j, j-1)
}
}
}
}
4. 希尔排序: 对于大规模的数组,插入排序很慢,因为只能交换相邻的元素,每次只能将逆序数量-1. 希尔排序就是为了解决这种局限性,通过交换不相邻的元素, 使逆序数量减少大于1
使用插入排序对间隔h的序列进行排序。通过不断减小h,最后令h=1,可以使数组有序
public class Shell<T extends Comparable<T>> extends Sort<T> {
@Override
public void sort(T[] nums) {
int N = nums.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(nums[j], nums[j - h]); j -= h) {
swap(nums, j, j - h);
}
}
h = h / 3;
}
}
}
5. 归并方法: 归并方法将数组中两个已经排序的部分归并成一个
public abstract class MergeSort<T extends Comparable<T> extends Sort<T>>{
protected T[] aux;
protected void merge(T[] nums, int l, int m, int h){
int i = l, j = m + 1;
for(int k = l; k <= h; k++){
aux[k] = nums[k];
}
for(int k = l; k <= h; k++){
if(i > m){
nums[k] = aux[j++];
} else if( j > h) {
nums[k] = aux[i++];
} else if( aux[i].compareTo(aux[j] <= 0)){
nums[k] = aux[i++];
} else{
nums[k] = aux[j++];
}
}
}
}
2. 自顶向下归并排序: 将一个大数组分成两个小数组求解。每次都将问题对半分为两个子问题,这种对半分的算法复杂度一般为O(NlogN)
public class Up2DownMergeSort<T extends Comparable<T>> extends MergeSort<T>{
@Override
public void sort(T[] nums){
aux = (T[]) new Comparable[nums.length];
sort(nums, 0, nums.length - 1);
}
private void sort(T[] nums, int l, int h){
if(h <= l){
return;
}
int mid = 1 + (h - l) /2;
sort(nums, l, mid);
sort(nums, mid+1, h);
merge(nums, l, mid, h);
}
}
3. 自底向上归并排序: 先归并哪些微型数组,然后成对归并得到的微型的数组
public class Down2UpMergeSort<T extends Comparable<T>> extends MergeSort<T>{
@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(lo + sz + sz -1, N -1));
}
}
}
}
6. 快速排序: 通过一个切分元素将数组分为两个子数组,左子数组小于等于切分元素,右子数组大于等于切分元素,将这两个子数组排序也将整个数组排序
public class QuickSort<T extends Comparable<T> extends Sort<T>>{
@Override
public void sort(T[] nums){
shuffle(nums);
sort(nums, 0, nums.length - 1);
}
private void sort(T[] nums, int l, int h){
if( h <= l)
return;
int j = partition(nums, l, h);
sort(nums, l, j-1);
sort(nums, j+1, h);
}
private void shuffle(T[] nums){
List<Comparable> list = ArrayList.asList(nums);
Collections.shuffle(list);
list.toArray(nums);
}
private int partition(T[] nums, int l, int h){
int i = l , j = h + 1;
T v = nums[l];
while(true){
while(less(nums[++i], v) && i != h);
while(less(v, nums[--j]) && j != l);
if( i >= j)
break;
swap(nums, i, j);
}
}
}
2. 性能分析: 是原地排序,不需要辅助数组,但需要递归调用辅助栈。 最好的情况是每次都将数组对半分,递归调用次数最少。这种情况比较次数最少。O(NlogN)
最坏情况是每次都是从最小元素切分。需要比较N2 /2 。为了防止初始有序,先打乱
3. 算法改进: 因为再小数组中递归调用自己,对于小数组,插入排序比快速排序更好,可以切换到插入排序。
三数取中: 最好的情况是每次渠道中位数,但计算中位数代价高。折中方法是取3个元素,将居中元素作为切分元素
三向切分: 对于有大量重复元素的数组,可以切分为三部分,分别对应小于、等于和大于切分元素。可在线性时间完成重复元素的随机数组排序。
public class ThreeWayQuickSort<T extends Comparable<T>> extends Quick<T>{
@Override
pretected void sort(T[] nums, int l, int h){
if( h <= l)
return;
int lt = l, i = l + 1; gt = h;
T v = nums[l];
while ( i <= gt ){
int cmp = nums[i].CompareTo(v);
if(cmp < 0){
swap(nums, lt++, i++);
} else if(cmp > 0){
swap(nums, i, gt--);
} else{
i++;
}
}
sort(nums, l, lt - 1);
sort(nums, gt+1, h);
}
}
4. 基于切分的快速选择排序: 快速排序的partition()方法,会返回一个整数j,使得a[l...j-1] 小于等于a[j] , a[j+1...h] 大于等于a[j],此时a[j]就是数组第j大元素
可以利用这个性质找到数组第[k]个元素。线性级别,假设能二分,则总比较次数是N + N/2 +N/4
public T select(T[] nums, int k){
int l = 0, h = nums.length - 1;
while(h > l){
int j = partition(nums, l, h);
if(j == k){
return nums[k];
} else if(j > k){
h = j - 1;
} else {
l = j + 1;
}
}
return nums[k];
}
7. 堆排序: 堆中某个节点的值总是大于等于或者小于等于其子节点的值,并且堆是一颗完全二叉树。
堆可以用数组来表示, 因为堆是完全二叉树,完全二叉树可以容易存储再数组中。位置K的节点父节点的位置为K/2, 而它的两个子节点是2k 和 2k+1。
这里不适用数组索引为0的位置,是为了更清晰的描述节点的位置关系。
1. 堆
public class Heap<T extends Comparable<T>> {
private T[] heap;
private int N = 0;
public Head(int maxN){
this.heap = (T[]) new Comparable[maxN + 1];
}
public boolean isEmpty(){
return N == 0;
}
public int size(){
return N;
}
private boolean less(int i, int j){
return heap[i].compareTo(heap[j]) < 0;
}
private void swap(int i, int j){
T t = heap[i];
heap[i] = heap[j];
heap[j] = t;
}
}
2. 上浮和下沉。 一个节点比父节点大,那么交换两个节点。
private void swim(int k){
while(k > 1 && less(k/2, k)){
swap(k / 2, k);
k = k / 2;
}
}
private void sink(int k){
while(2* k <= N){
int j = 2 * k;
if(j < N && less(j, j + 1))
j++;
if(!less(k, j))
break;
swap(k, j);
k = j;
}
}
3. 插入元素。 将新元素放到数组末尾,然后上浮到合适的位置
public void insert(Comparable v){
heap[++N] = v;
swim(N);
}
4. 删除最大元素。 从数组顶端删除最大的元素,并将数组的最后一个元素放到顶端,并让这个元素下沉到合适的位置。
public T delMax(){
T max = heap[1];
swap(1, N--);
heap[N + 1] = null;
sink(1);
return max;
}
5. 堆排序: 把最大元素和当前堆中数组最后一个元素交换位置,并且不删除它,那么可以得到一个从尾到头的递减序列,正向看就是递增序列。这就是堆排序
构建堆: 无序数组建立堆的方法就是从左到右遍历数组进行上浮操作。 高效的方法是从右至左进行下沉操作,如果一个节点的两个节点都已经是堆有序,
那么下沉操作可以使得当前节点为根节点的堆有序。叶子节点不需要下沉操作,可以忽略叶子节点的元素,因此只需要遍历一半的元素
public class HeapSort<T extends Comparable<T>> extends Sort<T>{
@Override
public void sort(T[] nums){
int N = nums.length - 1;
for(int k = N / 2; k >= 1; k--)
sink(nums, k, N);
while( N > 1){
swap(nums, 1, N--);
sink(nums, 1, N);
}
}
private void sink(T[] nums, int k , int N){
while(2 * k <= N){
int j = 2 * k ;
if( j <= N && less(nums, j, j+1))
j++;
if(!less(nums, k, j))
break;
swap(nums, k, j);
k = j;
}
}
private boolean less(T[] nums, int k, int j){
return nums[i].CompareTo(nums[j]) < 0;
}
}
一个堆的高度是 logN, 因此插入和删除元素的复杂度都是logN. 堆排序需要堆N个节点进行操作,复杂度为 N log N;
是一种原地排序,没有额外空间。很少使用因为无法利用局部性原理缓存
8. 总结:
1. 稳定性: 冒泡排序、 插入排序、 归并排序
2. 空间复杂度为1: 选择排序、 冒泡排序、 插入排序、 希尔排序、归并排序
3. 时间复杂度为N-log N: 快速排序, 归并排序、 堆排序
4. 快速排序是最快的通用排序算法,内循环指令少,还能利用缓存,总是顺序访问数据。
5. 三向切分快速排序(大量重复主键),实际可能出现的某些分布可能达到线性级别,而其他排序算法仍需要线性对数时间。
![在这里插入图片描述](https://i-blog.csdnimg.cn/blog_migrate/08782b9a020b8c63ba76925a718620a5.png)