在我们程序的开发过程中,数据不仅仅只是存储,还经常需要对数据做一定的处理。这就是算法,如果是几百条,几千条甚至几万条的数据的话,可能我们对算法的效率和时间复杂度没那么敏感。但是如果我们面对的是几百万,几千万甚至几亿,几十亿,几百亿的数据的话,那么我们对算法的要求就非常高了。下面就是我们开发中经常用到的算法。
1.数组
与数组相关的算法很多,但最基本最常见的就是排序的算法
在排序过程中我们经常会用到时间复杂度来描述一个算法的好坏,上面是我们常见的一些复杂度的函数图。
(1)冒泡排序
冒泡排序的思想是这样的:比较数组中第0个对象与第1个对象的关键字大小,如果第0个大的话则与第1个调换位置,然后再第1个与第2个比较,如果第1个大的话则调换位置,接下来第2个...,以此类推直到倒数最后1个与倒数第2个相比,这样就算第一轮。接下来从头开始,还是第0个与第1个,只是到最后只需要与倒数第2个相比就可以了,因为倒数第1个经过第一轮比较已经是最大的了。以此类推,直到最后只剩第1个和第2个相比就算完成。
冒泡排序因为是将最大的数一个一个往上推,想象成鱼在吐泡一样,所以叫冒泡排序。
public void bubbleSort(int[] a){
int size = a.length;
int out,in;
//第一个for循环,控制比较的终点
for(out=size-1;out>1;out--){
//控制相邻两个数之间的比较
for(in=0;in<out;in++){
if(a[in]>a[in+1]){
swap(a[in],a[in+1]);
}
}
}
}
private void swap(int a,int b){
int temp = a;
a = b;
b = temp;
}
上面的代码为冒泡排序算法的代码。如代码所示,冒泡排序的算法是比较容易实现的,但是他的时间复杂度确实非常大的。假设一个数组有10项数据的话,那么第一轮要9次比较,第二轮8次,以此类推,到最后一轮是1次。所以如果一个数组有N项的话,那么他的比较次数为 (N-1)+(N-1)+...+1=N*(N-1)/2;平均需要N^2/2次比较,而交换的概率大概为比较的一半,就是N^2/4。比较和交换都和N^2成正比。
所以,冒泡排序的时间复杂度为O(N^2)。
(2)选择排序
选择排序的思想是:定义一个最小值的中间变量,然后先把数组的第一个元素赋值给他,接下来遍历整个数组,一个一个和这个中间变量比较,如果比这个中间变量小的话就把值赋给这个中间变量,最后遍历完整个数组,这个中间变量就是最小值了。接下来是从第二个元素开始赋值给这个最小变量,然后再遍历一遍数组。以此类推直到从倒数第二个元素赋值开始遍历。
public void selectionSort(int[] a){
int size = a.length;
int in,out,min;
for(out = 0;out<size-1;out++){
//假设开始遍历的第一数是余下数中最小的
min=out
for(in = out+1;in<size -1;in++){
if(a[in]<a[min]){
//如果有比min更小的,则把这个值赋给min
min = in;
}
}
//当一轮遍历结束后,就可以进行值的交换了
swap(a[out],a[min]);
}
}
private void swap(int a,int b){
int temp = a;
a = b;
b = temp;
}
虽然,比较的次数还是和冒泡排序一样N*(N-1)/2;平均需要N^2/2次比较,但是交换的次数只剩下N次了,所以比冒泡排序有效多了。
选择排序的时间复杂度还是O(N^2)。
(3)插入排序
插入排序的思想是:把一个大的数分成两个小的数组,左边的数组是有序的,而右边的数组是无序的。我们只需要遍历右边的数组,从第一个遍历到最后一个,然后一个一个和左边的数组的元素进行对比,然后在合适的位置插入他,这样,左边的元素就越来越多并且是有序的,右边的元素就越来越少直至没有为止。插入排序经常被用到高级排序中,比如快速排序。
public void insertionSort(int[] a){
int size = a.length;
int in,out;
for(out=1;out<size;out++){
int temp = a[out];
in = out;
while(in>0&&a[in-1]>=temp){
a[in] = a[in-1];
--in;
}
a[in] = temp;
}
}
在上面的算法中,第一个for循环遍历的是右边无序的数组,定义了一个中间量temp来存储右边数组的值,而里面的while循环遍历的是左边有序的数组,取右边无序的数组与左边有序数组相比然后插入到合适的为止。
插入排序比较的次数是多少呢?第一轮需要比较1次,第二轮需要比较2次,以此类推,最后一轮最多需要比较N-1次,所以比较次数也为N*(N-1)/2,和选择排序和冒泡排序一样。但是交换的次数小于N-1次,所以他比选择排序和冒泡排序更快。
插入排序的时间复杂度还是O(N^2)。
(4)归并排序
归并排序是一种比冒泡、选择和插入排序时间效率更高的排序。归并排序的思想是把一个数组分成两个数组,然后对这两个数组分别进行排序,再用下面的merge方法将两个数组归并成一个有序的数组。
public static void main(String[] args) {
int[] arrayA = {23,47,81,95};
int[] arrayB = {7,14,39,55,62,74};
int[] arrayC = new int[10];
merge(arrayA,arrayA.length,arrayB,arrayB.length,arrayC);
}
public static void merge(int[] arrayA,int sizeA,int[] arrayB,int sizeB,int[] arrayC){
int aDex = 0,bDex = 0,cDex = 0;
while(aDex<sizeA && bDex<sizeB){
if(arrayA[aDex]<arrayB[bDex]){
arrayC[cDex++] = arrayA[aDex++];
}else{
arrayC[cDex++] = arrayB[bDex++];
}
}
while (aDex<sizeA){
arrayC[cDex++] = arrayA[aDex++];
}
while (bDex<sizeB){
arrayC[cDex++] = arrayB[bDex++];
}
}
如何将一个数组分为两个数组并排序呢?我们可以再将这两个数组再次分为更小的四个数组,以此类推,直到分到的最小数组已经只有一个元素,我们就可以认定这个数组是有序的,然后再将两个小数组排好序之后就变成大数组,最后得到的最大的数组就是有序的了,所以我们可以采用递归调用的方法。
public void mergeSort(){
long[] workSpace = new long[nElems];
recMergeSort(workSpace,0,nElems-1);
}
private void recMergeSort(long[] workSpace,int lowerBound,int upperBound){
if(lowerBound == upperBound){
return;
}else{
int mid = (lowerBound+upperBound)/2;
recMergeSort(workSpace,lowerBound,mid);
recMergeSort(workSpace,mid+1,upperBound);
merge(workSpace,lowerBound,mid+1,upperBound);
}
}
private void merge(long[] workSpace,int lowPtr,int highPtr,int upperBound){
int j = 0 ;
int lowerBound = lowPtr;
int mid = highPtr -1;
int n = upperBound - lowerBound +1;
while(lowPtr<=mid && highPtr<=upperBound){
if(theArray[lowPtr]<theArray[highPtr]){
workSpace[j++]=theArray[lowPtr++];
}else{
workSpace[j++]=theArray[highPtr++];
}
}
while (lowPtr<=mid){
workSpace[j++] = theArray[lowPtr++];
}
while(highPtr<=upperBound){
workSpace[j++] = theArray[highPtr++];
}
for(j=0;j<n;j++){
theArray[lowerBound+j] = workSpace[j];
}
}
归并排序效率是如何呢?假设有N个元素,那么需要递归的次数为log2N,每一次需要复制的次数为N,所以总共需要N*log2N次数的复制,比较的话比复制少,所以归并排序的时间复杂度为O(N*logN)。比冒泡、选择和插入排序都快一点。但是归并排序需要的是两倍的存储空间来进行排序,所以如果对空间有要求的一般都不使用归并排序。
(5)希尔排序
希尔排序是对插入排序的一种优化。在插入排序中每次只能将数据移动一位,如果是较小的数存在数组的右边的话,这样左边的数组的元素移动的次数就会非常多。如果插入排序的数组是基本有序的话,那么插入排序的时间复杂度大概只需要O(N),所以希尔排序就从这点出发,对插入排序进行优化。
希尔排序通过加大插入排序中元素之间的间隔,并在这些间隔的元素中进行插入排序,从而使数据项能大跨度地移动。当这些数据项拍过一趟序之后,再减小插入排序中元素之间的间隔进行排序,最后直到排序中元素之间的间隔为1,排完序之后整个数组就是有序的了。
间隔取多大合适呢?一般根据公式h=3*h+1来取间隔。
public void shellSort(){
int inner,outer;
long temp;
int h = 1;
while(h<=nElems/3){
h = h*3+1;
}
while (h>0){
for(outer=h;outer<nElems;outer++){
temp = theArray[outer];
inner = outer;
while(inner>h-1&&theArray[inner-h]>=temp){
theArray[inner] = theArray[inner-h];
inner -= h;
}
theArray[inner] = temp;
}
h = (h-1)/3;
}
}
希尔排序的时间复杂度是多少呢?根据估算,希尔排序的时间复杂度为O(N*(logN)^2)。
(6) 快速排序
快速排序之所以叫快速排序是因为他的算法非常快。快速排序的思想是:将一个大数组划分为两部分,根据什么划分呢?取一个数,数组左边的数据全都小于这个数,数组右边的数据全都大于这个数,然后再将左边的小数组划分为两个小部分,左边的部分也是小于某个数,右边的部分也是大于这个数,以此类推,直到划分的小数组的数据量为2个时,那么它们就是就有序,这样大数组也就是有序的。可以发现,快速排序也是采用递归的算法。下面这张图展示了快速排序的过程。
这个划分的数如何取呢?一般来说都是取数组的第一个,最后一个和中间位置的数据的中间值作为划分的值。 这样就可以避免数组已经有序或者逆序的情况下,选择最大或者最小的数据项作为划分值造成的算法很低效的问题。
public void quickSort(){
recQuickSort(0,nElems-1);
}
public void recQuickSort(int left,int right){
int size = right - left + 1;
if(size<=3){
manualSort(left,right);
}else{
long median = medianOf3(left,right);
int partition = partitionIt(left,right,median);
recQuickSort(left,partition-1);
recQuickSort(partition+1,right);
}
}
public int partitionIt(int left,int right,long pivot){
int leftPtr = left;
int rightPtr = right - 1;
while(true){
while(theArray[++leftPtr]<pivot);
while(theArray[--rightPtr]>pivot);
if(leftPtr>=rightPtr){
break;
}else{
swap(leftPtr,rightPtr);
}
}
swap(leftPtr,right-1);
return leftPtr;
}
public long medianOf3(int left,int right){
int center = (left+right)/2;
if(theArray[left]>theArray[center]){
swap(left,center);
}
if(theArray[left]>theArray[center]){
swap(left,center);
}
if(theArray[left]>theArray[right]){
swap(left,right);
}
if(theArray[center]>theArray[right]){
swap(center,right);
}
swap(center,right-1);
return theArray[right-1];
}
public void swap(int dex1,int dex2){
long temp = theArray[dex1];
theArray[dex1] = theArray[dex2];
theArray[dex2] = temp;
}
public void manualSort(int left,int right){
int size = right - left + 1;
if(size <= 1){
return;
}
if(size == 2){
if(theArray[left] > theArray[right]){
swap(left,right);
}
return;
}else{
if(theArray[left]>theArray[right-1]){
swap(left,right-1);
}
if(theArray[left]>theArray[right]){
swap(left,right);
}
if(theArray[right-1]>theArray[right]){
swap(right-1,right);
}
}
}
代码如上,recQuickSort是进行快速排序的迭代方法,medianOf3是求数组第一个、最后一个和中间位置的值的中间值的方法,partitionIt方法是根据中间值来进行数组的划分的方法。
快速排序的时间复杂度为多少呢?因为递归排序需要递归调用logN次,每一次划分需要花费O(N),所以快速排序的时间复杂度O(N*logN)。
总结:在上面几种数组排序中,快速排序和归并排序的时间复杂度是最小的O(N*logN),但是由于归并排序需要一个额外的数组来进行排序,所以使用快速排序的空间效率可以更高些。不过基于递归的排序有个缺点,每一次递归都要将结果和调用参数存储在栈里面,很容易抛出内存不足的异常。而希尔排序的时间复杂度为O(N*(logN)^2)。其他的插入排序,选择排序和冒泡排序的时间复杂度都为O(N^2)。
所以时间复杂度方面:O(快速排序)=O(归并排序)<O(希尔排序)<O(插入排序)=O(选择排序)=O(冒泡排序)