一、选择排序
从数组中选择最小元素,将它与数组的第一个元素交换位置。再从数组剩下的元素中选择出最小的元素,将它与数组的第二个元素交换位置。不断进行 这样的操作,直到将整个数组排序。
选择排序需要
N
2
/
2
N^{2}/2
N2/2次比较和
N
{N}
N次交换,它的运行时间与输入无关,这个特点使得它对一个已经排序的数组也需要这么多的比较和交换操作。
public void sort(int []arr){
if(arr==null || arr.length==0)
return;
int N=arr.length;
for(int i=0;i<N-1;i++){
int min=i;
for(int j=i+1;j<N;j++){
if(arr[j]<arr[min]){
min=j;
}
}
swap(arr,i,min);
}
}
二、冒泡排序
从左到右不断交换相邻逆序的元素,在一轮的循环之后,可以让未排序的最大元素上浮到右侧。
在一轮循环中,如果没有发生交换,那么说明数组已经是有序的,此时可以直接退出。
public void sort(int []arr){
if(arr==null || arr.length==0)
return;
boolean isSorted=false;
int N=arr.length;
for(int i=N-1;i>0 && !isSorted;i--){
isSorted=true;
for(int j=0;j<i;j++){
if(arr[j+1]<arr[j]){
isSorted=false;
swap(arr, j+1, j);
}
}
}
}
三、插入排序
每次都将当前元素插入到左侧已经排序的数组中,使得插入之后左侧数组依然有序。
对于数组 {3, 5, 2, 4, 1},它具有以下逆序:(3, 2), (3, 1), (5, 2), (5, 4), (5, 1), (2, 1), (4, 1),插入排序每次只能交换相邻元素,令逆序数量减少 1,因此插入排序需要交换的次数为逆序数量。
插入排序的时间复杂度取决于数组的初始顺序,如果数组已经部分有序了,那么逆序较少,需要的交换次数也就较少,时间复杂度较低。
平均情况下插入排序需要
N
2
/
4
N^{2}/4
N2/4比较以及
N
2
/
4
N^{2}/4
N2/4 次交换;
最坏的情况下需要
N
2
/
2
N^{2}/2
N2/2 比较以及
N
2
/
2
N^{2}/2
N2/2次交换,最坏的情况是数组是倒序的;
最好的情况下需要
N
−
1
N-1
N−1 次比较和
0
0
0次交换,最好的情况就是数组已经有序了。
public static void sort(int []arr){
if(arr==null || arr.length==0)
return;
int N=arr.length;
for(int i=1;i<N;i++){
for(int j=i;j>0&&arr[j]<arr[j-1];j--){
swap(arr,j,j-1);
}
}
}
四、希尔排序
对于大规模的数组,插入排序很慢,因为它只能交换相邻的元素,每次只能将逆序数量减少 1。希尔排序的出现就是为了解决插入排序的这种局限性,它通过交换不相邻的元素,每次可以将逆序数量减少大于 1。
希尔排序使用插入排序对间隔 h 的序列进行排序。通过不断减小 h,最后令 h=1,就可以使得整个数组是有序的。
public static void sort(int []arr){
if(arr==null || arr.length==0)
return;
int N=arr.length;
int step=N/2;
for(; step>0 ;step/=2){
for(int i=0;i<step;i++){
for(int j=i+step;j<N;j+=step){
for(int k=j;k>i && arr[k]<arr[k-step];k-=step){
swap(arr,k,k-step);
}
}
}
}
}
希尔排序的运行时间达不到平方级别,使用递增序列 1, 4, 13, 40, … 的希尔排序所需要的比较次数不会超过 N 的若干倍乘于递增序列的长度。后面介绍的高级排序算法只会比希尔排序快两倍左右。
五、归并排序
归并排序的思想是将数组分成两部分,分别进行排序,然后归并起来。
自顶向下
将一个大数组分成两个小数组去求解。
因为每次都将问题对半分成两个子问题,这种对半分的算法复杂度一般为 O(NlogN)。
int temp[];
public void sort(int []arr){
if(arr==null || arr.length==0)
return;
int N=arr.length;
temp=new int[N];
mergeSort(arr,0,N-1);
}
private void mergeSort(int[] arr, int l, int h) {
// TODO Auto-generated method stub
if(l>=h)
return ;
int m=l+(h-l)/2;
mergeSort(arr, l, m);
mergeSort(arr, m+1, h);
merge(arr,l,m,h);
}
private void merge(int[] arr, int l, int m, int h) {
// TODO Auto-generated method stub
int i=l;
int j=m+1;
for(int k=l;k<=h;k++) {
temp[k]=arr[k];
}
int k=l;
while(i<=m && j<=h) {
if(temp[i]<=temp[j]) {
arr[k++]=temp[i++];
}else {
arr[k++]=temp[j++];
}
}
while(i<=m) {
arr[k++]=temp[i++];
}
while(j<=h) {
arr[k++]=temp[j++];
}
}
六、快速排序
归并排序将数组分为两个子数组分别排序,并将有序的子数组归并使得整个数组排序;
快速排序通过一个切分元素将数组分为两个子数组,左子数组小于等于切分元素,右子数组大于等于切分元素,将这两个子数组排序也就将整个数组排序了。
public void sort(int []arr){
if(arr==null || arr.length==0)
return;
int N=arr.length;
sort(arr,0,N-1);
}
private void sort(int[] arr, int l, int h) {
// TODO Auto-generated method stub
if(l>=h)
return;
int j=partion(arr,l,h);
sort(arr,l,j-1);
sort(arr,j+1,h);
}
private int partion(int[] arr, int l, int h) {
int m=arr[l];
while(l<h) {
while(l<h && arr[h]>=m)
h--;
arr[l]=arr[h];
while(l<h && arr[l]<=m)
l++;
arr[h]=arr[l];
}
arr[l]=m;
return l;
}
private void swap(int[] arr, int i, int j) {
// TODO Auto-generated method stub
int temp=arr[i];
arr[i]=arr[j];
arr[j]=temp;
}
性能分析
快速排序是原地排序,不需要辅助数组,但是递归调用需要辅助栈。
快速排序最好的情况下是每次都正好将数组对半分,这样递归调用次数才是最少的。这种情况下比较次数为 C N = 2 C N / 2 + N C_{N}=2C_{N/2}+N CN=2CN/2+N,复杂度为 O( N l o g N NlogN NlogN)。
最坏的情况下,第一次从最小的元素切分,第二次从第二小的元素切分,如此这般。因此最坏的情况下需要比较 N 2 / 2 N^{2}/2 N2/2。为了防止数组最开始就是有序的,在进行快速排序时需要随机打乱数组。
算法改进
1 切换到插入排序
因为快速排序在小数组中也会递归调用自己,对于小数组,插入排序比快速排序的性能更好,因此在小数组中可以切换到插入排序。
2 三数取中
最好的情况下是每次都能取数组的中位数作为切分元素,但是计算中位数的代价很高。一种折中方法是取 3 个元素,并将大小居中的元素作为切分元素。
3 三向切分
对于有大量重复元素的数组,可以将数组切分为三部分,分别对应小于、等于和大于切分元素。
三向切分快速排序对于有大量重复元素的随机数组可以在线性时间内完成排序。
public static void sort(int []arr){
if(arr==null || arr.length==0)
return;
int N=arr.length;
sortCore(arr,0,N-1);
}
private static void sortCore(int[] arr, int l, int h) {
// TODO Auto-generated method stub
if(l>=h)
return;
int i=l+1;
int v=arr[l];
int lt=l;
int gt=h;
while(i<=gt){
if(arr[i]-v<0){
swap(arr, i++, lt++);
}else if(arr[i]-v>0){
swap(arr, i, gt--);
}else{
i++;
}
}
sortCore(arr, l, lt-1);
sortCore(arr, gt+1, h);
}
public void sortColors(int[] nums) {
if(nums==null || nums.length==0)
return ;
int l=0,h=nums.length-1;
int i=0;
while(i<=h){
if(nums[i]==1){
i++;
}else if(nums[i]==0){
swap(nums,i++,l++);
}else{
swap(nums,i,h--);
}
}
}
public void swap(int nums[],int i,int j){
int temp=nums[i];
nums[i]=nums[j];
nums[j]=temp;
}
4 基于切分的快速选择算法( 215 数组中的第K个最大元素)
快速排序的 partition() 方法,会返回一个整数 j 使得 a[l…j-1] 小于等于 a[j],且 a[j+1…h] 大于等于 a[j],此时 a[j] 就是数组的第 j 大元素。
可以利用这个特性找出数组的第 k 个元素。
该算法是线性级别的,假设每次能将数组二分,那么比较的总次数为 (N+N/2+N/4+…),直到找到第 k 个元素,这个和显然小于 2N。
public int findKthLargest(int[] nums, int k) {
if(nums==null || nums.length==0 || nums.length<k){
return -1;
}
int N=nums.length-1;
int l=0,h=N;
k=N-k+1;
while(l<h){
int j=partion(nums,l,h);
if(j==k){
return nums[j];
}else if(j<k){
l=j+1;
}else{
h=j-1;
}
}
return nums[k];
}
public int partion(int nums[],int l,int h){
int m=nums[l];
while(l<h){
while(l<h && nums[h]>=m) h--;
nums[l]=nums[h];
while(l<h && nums[l]<=m) l++;
nums[h]=nums[l];
}
nums[h]=m;
return h;
}
七、堆排序
堆中某个节点的值总是大于等于其子节点的值,并且堆是一颗完全二叉树。
堆可以用数组来表示,这是因为堆是完全二叉树,而完全二叉树很容易就存储在数组中。
public void sort(int []arr){
if(arr==null || arr.length==0)
return;
int N=arr.length-1;
for(int i=(N-1)/2;i>=0;i--) {
sink(arr,i,N);
}
while(N>0) {
swap(arr, 0, N);
N--;
sink(arr, 0, N);
}
}
private void sink(int[] arr, int i, int n) {
// TODO Auto-generated method stub
while((2*i+1)<=n) {
int j=2*i+1;
if(j<n && arr[j]<arr[j+1]) {
j++;
}
if(arr[i]>=arr[j]) {
break;
}
swap(arr,j,i);
i=j;
}
}
一个堆的高度为 logN,因此在堆中插入元素和删除最大元素的复杂度都为 logN。
对于堆排序,由于要对 N 个节点进行下沉操作,因此复杂度为 NlogN。
堆排序是一种原地排序,没有利用额外的空间。
现代操作系统很少使用堆排序,因为它无法利用局部性原理进行缓存,也就是数组元素很少和相邻的元素进行比较和交换。
215 数组中第K个最大的元素
public int findKthLargest(int[] nums, int k) {
PriorityQueue<Integer> pq=new PriorityQueue<>();
for(int val: nums){
pq.add(val);
if(pq.size()>k)
pq.poll();
}
return pq.peek();
}
八、桶排序
347 前K个高频元素
设置若干个桶,每个桶存储出现频率相同的数,并且桶的下标代表桶中数出现的频率,即第 i 个桶中存储的数出现的频率为 i。
把数都放到桶之后,从后向前遍历桶,最先得到的 k 个数就是出现频率最多的的 k 个数。
public List<Integer> topKFrequent(int[] nums, int k) {
List<Integer> result=new ArrayList<>();
if(nums==null || nums.length==0 || k>nums.length)
return result;
HashMap<Integer,Integer> freMap=new HashMap<>();
for(int num:nums){
freMap.put(num,freMap.getOrDefault(num,0)+1);
}
List<Integer> buckets[]=new ArrayList[nums.length+1];
for(int key:freMap.keySet()){
int fre=freMap.get(key);
if(buckets[fre]==null){
buckets[fre]=new ArrayList<>();
}
buckets[fre].add(key);
}
for(int i=buckets.length-1; result.size()<k && i>=0; i--){
if(buckets[i]!=null){
if(buckets[i].size()<=k-result.size()){
result.addAll(buckets[i]);
}else{
result.addAll(buckets[i].subList(0,k-result.size()));
}
}
}
return result;
}
451 根据字符出现频率排序
public String frequencySort(String s) {
Map<Character,Integer> frequencyMap=new HashMap<>();
for(char c : s.toCharArray())
frequencyMap.put(c,frequencyMap.getOrDefault(c,0)+1);
List<Character> buckets[]=new ArrayList[s.length()+1];
for(char key:frequencyMap.keySet()){
int f=frequencyMap.get(key);
if(buckets[f]==null){
buckets[f]=new ArrayList<>();
}
buckets[f].add(key);
}
StringBuilder sb=new StringBuilder();
for(int i=buckets.length-1;i>=0;i--){
if(buckets[i]==null){
continue;
}
for(char c:buckets[i]){
for(int j=0;j<i;j++){
sb.append(c);
}
}
}
return sb.toString();
}
小结
快速排序是最快的通用排序算法,它的内循环的指令很少,而且它还能利用缓存,因为它总是顺序地访问数据。它的运行时间近似为 ~cNlogN,这里的 c 比其它线性对数级别的排序算法都要小。
使用三向切分快速排序,实际应用中可能出现的某些分布的输入能够达到线性级别,而其它排序算法仍然需要线性对数时间。
Java 主要排序方法为 java.util.Arrays.sort(),对于原始数据类型使用三向切分的快速排序,对于引用类型使用归并排序。