基本排序算法:
冒泡排序:每次把一个(局部)最大(最小)值归位,然后处理子区间。
public static void bubbleSort(int[] arr){
if(arr == null || arr.length<2)return;
for(int end=arr.length-1;end>0;--end){
for(int i=0;i<end;++i){
if(arr[i]>arr[i+1])swap(arr,i,i+1);
}
}
}
public static void swap(int[] arr,int i,int j){
arr[i]^=arr[j];
arr[j]^=arr[i];
arr[i]^=arr[j];
}
双向冒泡排序:从左到右将大的值下沉,从右到左将小的值上浮。
public static void TwowaySort(int[] arr){
if(arr == null || arr.length<2)return;
int LeftStart=0,RightEnd=arr.length-1,len=arr.length;
for(int i=0;i<len;++i){
if((i&1)==1){
for(int j=LeftStart;j<RightEnd;++j){
if(arr[j]>arr[j+1])swap(arr,j,j+1);
}
--RightEnd;//从左往右 大值下沉后 最后一个元素归位
}else{
for(int j=RightEnd;j>LeftStart;--j){
if(arr[j]<arr[j-1])swap(arr,j,j-1);
}
++LeftStart;//从右往左 小值上浮后 最先一个元素归位
}
}
}
选择排序:每次选择一个最大(最小)归位,然后处理子区间。
//int[] arr=new int[]{3,5,67,435,678,342,56,34,3,2};
public static void selectSort(int[] arr){
if(arr == null || arr.length<2)return;
for(int i=0;i<arr.length;++i){
int minIndex=i;
for(int j=i+1;j<arr.length;++j){
minIndex=arr[j]<arr[minIndex]?j:minIndex;
}
swapNoProblem(arr,minIndex,i);
System.out.println();
}
}
public static void swapNoProblem(int[] arr,int i,int j){
int temp=arr[i]^arr[j];
arr[i]^=temp;
arr[j]^=temp;
}
注意,其实一开始写的swap函数是有一些缺陷的,尤其是在i=j的时候,如果不借助辅助变量,就会因为值覆盖导致这个位置被抹为0,然而除了选择排序意外,其他的排序在swap的时候不可能发生i==j的情况,需注意!测试用例已经在注释中给出。
插入排序:数组分为左段的有序区和右段的无序区,每次将无序区开头的第一个元素放入有序区。
public static void insertSort(int[] arr){
if(arr == null || arr.length<2)return;
for(int i=1;i<arr.length;++i){
for(int j=i-1;j>=0;--j){
if(arr[j]>arr[j+1])swap(arr,j,j+1);
}
}
}
二分插入排序:既然插入排序是将当前考察元素插入到有序区,那么我们很容易想到将实际插入位置的索引targetIndex利用二分查找出来,但是原来从targetIndex到考察元素当前位置的这一段数据需要全体后移,还是需要O(N)的复杂度,总体复杂度为O(logN+N),思想很好但并不具有实用性。
归并排序:将原问题划分为左右两个有序部分,然后将两个有序部分以类似外排的方式组织。
public static void mergeSort(int[] arr){
if(arr==null||arr.length<2)return;
sortProcess(arr,0,arr.length-1);
}
public static void sortProcess(int[] arr,int L,int R){
if(L==R)return;
int Mid=L+((R-L)>>1);//1.可以防止加法溢出 2.保证左区间长度>=右区间长度 即 [L,R]区间长度为奇数时,左区间比右区间长度大1
sortProcess(arr,L,Mid);
sortProcess(arr,Mid+1,R);
merge(arr,L,Mid,R);
}
public static void merge(int[] arr,int L,int Mid,int R){
int p1=L,p2=Mid+1,i=0;
int[] help=new int[R-L+1];//其实也可以直接生成全局够长的help,每次在help的[L,R]上操作
while(p1<=Mid&&p2<=R){
help[i++]=arr[p1]<arr[p2]?arr[p1++]:arr[p2++];
}
while(p1<=Mid)help[i++]=arr[p1++];
while(p2<=R)help[i++]=arr[p2++];
for(i=0;i<help.length;++i){
arr[L+i]=help[i];
}
}
小和问题:在一个数组中,每一个数左边比当前数小的加起来,称为数组的小和,你需要设计一个算法解决小和问题。
朴素做法:直接枚举i和0~i-1,统计符合的数,累加进答案。
优化:使用归并排序每一次小部分排序的信息,直接计算每个数对答案的贡献。归并可以让我们得到左右两个各自有序的部分,设两个指针p1、p2,如果当前p1指向的数比p2指向的数要小,那么由于两边都是有序的,所有右边会存在(R-p2+1)个数比p1指向的数要大,累计上答案可解。mergeSort的正确性分析,首先每次都是在[L,Mid]与[Mid+1,R]的归并过程中计算对答案的贡献量,随着归并长度不断增大直到原数组长度,保证了不会漏算任何一部分小和;同时,每一次小和的产生是发生在两个部分之间,又保证了已经计算过的部分[计算过的部分已经合并成一个部分]不会在以后的归并过程中被再次计算,因此不会发生重复,不重复不遗漏,即归并排序是正确的。
public class SmallSum {
public static int smallSum(int[] arr){
if(arr==null||arr.length<2)return 0;
return mergeSort(arr,0,arr.length-1);
}
public static int mergeSort(int[] arr,int L,int R){
if(L==R)return 0;
int Mid=L+((R-L)>>1);
return mergeSort(arr,L,Mid)+mergeSort(arr,Mid+1,R)+merge(arr,L,Mid,R);
//左部分归并排序产生的小和 右部分归并排序产生的小和
}
public static int merge(int[] arr,int L,int Mid,int R){
int p1=L,p2=Mid+1,i=0;
int[] help=new int[R-L+1];
int sum=0;
while(p1<=Mid&&p2<=R){
sum+=arr[p1]<arr[p2]?(R-p2+1)*arr[p1]:0;
help[i++]=arr[p1]<arr[p2]?arr[p1++]:arr[p2++];
}
while(p1<=Mid)help[i++]=arr[p1++];
while(p2<=R)help[i++]=arr[p2++];
for(i=0;i<help.length;++i)arr[L+i]=help[i];
return sum;
}
public static void main(String[] args) {
int[] arr=new int[]{1,3,4,2,5};
System.out.println(smallSum(arr));
}
}
注意,上述代码虽然简洁,但是并没有缺陷,实际上如果左部分和右部分有相同数的时候,因为我们设置了只有在arr[p1]<arr[p2]严格成立的时候才更新答案,而等于的情况下并不会使得我们的答案更新,只是让p2右移一位罢了,不影响答案。
逆序对问题:当数组中有索引i,j满足Arr[i]>Arr[j]且i<j时,称为逆序对,现在要求出数组中有多少个逆序对。
解法1:依旧使用朴素枚举
解法2:归并排序,对每一个右区间的数,找左边有多少个数比他大
【因为merge过程中需要选取p1和p2指向比较小的那个数归并进结果,所以如果我们设计考虑每一个左边的数,右边有多少个数比他小的话,由于小数需要在当前这一步merge,就会发生问题,比如归并的区间为[1,3,4] [2,5],p1指向3,p2指向2,此时累计答案p2-Mid=1,然后由于p2小,所以p2++,但是这样就会漏掉[4,2]这个逆序对!】
public class ReverseAlignment {
public static void main(String[] args) {
int[] arr=new int[]{1,3,4,2,5};
System.out.println(reverseAlignment(arr));
}
private static int reverseAlignment(int[] arr) {
if(arr==null||arr.length<2)return 0;
return mergeSort(arr,0,arr.length-1);
}
private static int mergeSort(int[] arr, int L, int R) {
if(L==R)return 0;
int Mid=L+((R-L)>>1);
return mergeSort(arr,L,Mid)+mergeSort(arr,Mid+1,R)+merge(arr,L,Mid,R);
}
private static int merge(int[] arr, int L, int Mid, int R) {
int[] help=new int[R-L+1];
int p1=L,p2=Mid+1,i=0,ans=0;
while(p1<=Mid&&p2<=R){
//考虑每一个右边的数 左边有几个数比他大
ans+=arr[p1]>arr[p2]?(Mid-p1+1):0;
help[i++]=arr[p1]>arr[p2]?arr[p2++]:arr[p1++];
}
while(p1<=Mid)help[i++]=arr[p1++];
while(p2<=R)help[i++]=arr[p2++];
for (i = 0; i < help.length; i++) {
arr[L+i]=help[i];
}
return ans;
}
}
解法3:(离散化)+树状数组
注:离散化也是要知道数据的大小关系,所以需要先进行排序。但是如果数据跨度不大也可以选择不使用离散化技巧。
public static int lowbit(int x){
return x&(-x);
}
public static int getSum(int i,int[] Tree){//求>=i的数有多少个
int sum=0;
while(i<Tree.length){
sum+=Tree [i];
i+=lowbit(i);
}
return sum;
}
public static void add(int i,int delta,int[] Tree){
while(i<Tree.length){
Tree[i]+=delta;
i+=lowbit(i);
}
}
public static HashMap<Integer,Integer> spread(int[] arr){//离散化
Arrays.sort(arr);
HashMap<Integer,Integer> hashMap=new HashMap<>();
for(int i=0;i<arr.length;++i){
hashMap.put(arr[i],i+1);
}
return hashMap;
}
public static int[] copyArray(int[] arr){
int[] Arr=new int[arr.length];
for(int i=0;i<arr.length;++i){
Arr[i]=arr[i];
}
return Arr;
}
public static void main(String[] args) {
int[] arr=new int[]{1,3,4,2,5};
int[] Arr=copyArray(arr);
int[] Tree=new int[arr.length+1];
int ans=0;
HashMap<Integer, Integer> hashMap = spread(Arr);
for(int i=0;i<arr.length;++i){
int bias=hashMap.get(arr[i]);//拿到arr[i]根据大小关系离散化后的值
ans+=getSum(bias+1,Tree);
add(bias,1,Tree);
}
System.out.println(reverseAlignment(arr));
}
归并vs冒泡、选择、插入等:
归并比起一般排序快的原因是,实际上一般排序每次通过比较确定一个数的位置,但是他们中间的很多次比较,也可能比较了其他数间的关系,而一般的排序算法都没有利用到这些信息,所以消耗大,而归并排序每次只是组间组织,对于组内部是已经有序的不需要改动,因此利用了更多的有效信息,复杂度低。
使用Master公式剖析递归行为复杂度:
主定理(主公式)
如果递归算法可以描述成T(N)=a*T(N/b)+O(N^d);
那么可以根据主定理得到时间复杂度:
1)log(b,a)>d -> 时间复杂度为O(N^log(b,a))
2)log(b,a)=d -> 时间复杂度为O(N^d*logN)
3)log(b,a)<d -> 时间复杂度为O(N^d)
以归并排序为例:
归并排序的递归算法描述为T(N)=2*T(N/2)+O(N)
分支加上将两部分合并的复杂度。
所以a=2,b=2,d=1,符合主定理2),因此复杂度为O(N^logN)。
对数器的概念与使用:对数器实际上就是用一个暴力正确算法在大样本数据的情况下验核一个新的高性能算法正确性的技术,样本由随机数产生器产生。
public static int[] generateRandomArray(int size,int value){//size:数组最大长度 value:随机数值得上界
//Java的Math.random()产生[0,1)的小数
int[] arr=new int[(int) (Math.random()*(size+1))];
for(int i=0;i<arr.length;++i){
arr[i]=(int)((value+1)*Math.random())-(int)(value*Math.random());//可以兼顾负值样本的产生
}
return arr;
}
public static int[] copyArray(int[] arr){
int[] Arr=new int[arr.length];
for (int i = 0; i < arr.length; i++) Arr[i]=arr[i];
return Arr;
}
public static boolean isEqual(int[] arr1,int[] arr2){
if((arr1==null&&arr2!=null)||(arr1!=null&&arr2==null))return false;
if(arr1==null&&arr2==null)return true;
if(arr1.length!=arr2.length)return false;
for(int i=0;i<arr1.length;++i){
if(arr1[i]!=arr2[i])return false;
}
return true;
}
public static void verify(){
int testTime=500000;
int size=10;
int value=1000;
int i;
boolean success=true;
for(i=0;i<testTime;++i){
int[] arr1=generateRandomArray(size,value);
int[] arr2=copyArray(arr1);
int[] arr3=copyArray(arr1);
bubbleSort(arr2);
insertSort(arr3);
if(!isEqual(arr2,arr3)){
success=false;
for (int i1 : arr1) {
System.out.println(i1);
}
break;
}
}
System.out.println("共测试了:"+i+"次,"+(success?"Nice":"Fuck Sort"));
}
理论上说,对数器需要个绝对正确的方法,但是实际应用中,因为可以把导致两个方法结果不一致的样本打印出来,人工排查两个方法中的逻辑错误,互相修正,同时走向正确。因为实际大样本测试下,两个不同的方法犯一样错误的概率是很低的,而对数器只有在两个结果不一致的时候才会认定发生错误。