二分查找要求原数组必须有序。其实,由有序到无序,这是算法领域最常见的一类问题。本课时主要学习4种常见的排序算法,包括冒泡排序、插入排序、归并排序以及快速排序。
衡量一个排序算法的优劣,我们主要从以下3个角度进行分析:
- 时间复杂度,具体包括,最好时间复杂度、最坏时间复杂度以及平均时间复杂度。
- 空间复杂度,如果空间复杂度为1,也叫作原地排序。
- 稳定性,排序的稳定性是指相等的数据对象,在排序之后,顺序是否能保证不变。
常见的排序算法及其思想
1.冒泡排序
原理:从第一个数据开始,依次比较相邻元素的大小。如果前者大于后者,则进行交换,把大的元素往后交换。通过多轮迭代,直到没有交换操作为止。冒泡排序就像在一个水池中处理数据一样,每次会把最大的那个数据传递到最后。
性能:冒泡排序平均时间复杂度为O(n*n),空间复杂度为O(1),排序过程中,相同的元素不做交换,所以冒泡排序是稳定的排序算法。
代码如下:
public static void main(String[] args){
int[] arr = {1,0,3,4,5,-6,7,8,9,10};
//外循环控制趟数,每一趟都会把最大值推到最后,10个数据的话9趟就能排序成功
//又因为内循环比较的时候是和后一位比较的,所以内循环下标需要小于arr.length-i
for(int i=1; i < arr.length ; i++){
for(int j=0 ; j < arr.length-i ; j++){
if(arr[j] > arr[j+1]){
int temp;
temp = arr[j];
arr[j] = arr[j+1];
arr[j+1] = temp;
}
}
}
}
2.插入排序
原理:选择未排序的元素,插入到已排序区间的合适位置,直到未排序区间为空。插入排序顾名思义,就是从左到右维护一个已经排好序的序列,直到所有的待排数据全都完成插入的动作。
性能:插入排序的时间复杂度为O(n*n),空间复杂度为O(1),插入过程中,相同元素顺序不会发生改变,所以插入排序是稳定的排序算法。
代码如下:
public static void main(String[] args){
int[] arr = {2,3,5,1,23,6,78,34};
//外层循环对除了第一个元素以外的所有元素
//内存循环对当前元素前面的有序表进行插入位置查找,并移动
for(int i=1;i<arr.length;i++){
int temp = arr[i];
int j = i-1;
for(;j>=0;j--){
if(arr[j] > temp){
//如果比待排的数字大,就将其后移
arr[j+1] = arr[j];
}else{
//如果发现比待排数字小,则说明可插入位置就是j+1,内循环可结束
break;
}
}
//将待排数字赋值到可插入的位置,循环继续
arr[j+1] = temp;
}
}
小结:插入排序和冒泡排序算法的异同点
相同点:冒泡排序和插入排序的平均时间复杂度都是O(n*n),空间复杂度都是O(1),且都是稳定的排序算法,都属于原地排序。
不同点:冒泡排序每轮的交换操作时动态的,所以需要三个赋值操作才能完成;而插入排序每轮的交换动作会固定待插入的数据,因此只需要一步赋值操作。
以上两种排序算法比较简单,可以帮助我们对排序的思想建立基本了解,接下里再学习两种时间复杂度更低的排序算法,它们的时间复杂度都可以达到O(nlogn)。
3.归并排序
归并排序的原理:实际上就是上一节课我们讲的分治法。它首先将数组不断地二分,直到最后每个部分只包含1个数据。然后再对每个部分分别进行排序,最后将排好序的相邻的两部分合并在一起,这样整个数组就有序了。
public static void main(String[] args) {
int[] nums = new int[] { 9, 8, 7, 6, 5, 4, 3, 2, 10 };
int[] newNums = mergeSort(nums, 0, nums.length - 1);
System.out.println(Arrays.toString(newNums));
}
public static int[] mergeSort(int[] nums, int left, int right){
//递归终止条件
if(left == right){
return new int[]{nums[left]};
}
int middle = left + (right-left)/2;
//利用递归不断的将数组两分,直到达到递归终止条件,也即数组只有1个元素
int[] leftArr = mergeSort(nums, left, middle);
int[] rightArr = mergeSort(nums, middle+1, right);
//然后开始将二分后的两部分进行排序、合并
int[] newNums = new int[leftArr.length+rightArr.length];
//m表示newNums的下标,i和j分别指向二分后数组的开始位置,从头比较两个有序数组
如果左侧的数小,则将数添加到新数组,左侧下标加1,继续左右比较,直到左右两数
组,有一个到数组尾部。最后将剩余一侧的数组全部添加到新数组中即可。
int m=0,i=0,j=0;
while( i < leftArr.length && j < rightArr.length){
newNums[m++] = leftArr[i] < rightArr[j] ? leftArr[i++] :
rightArr[j++];
}
while(i < leftArr.length){
newNums[m++] = leftArr[i++];
}
while(j < right.length){
newNums[m++] = rightArr[j++];
}
}
归并操作的工作原理如下:
第一步:申请空间,使其大小为两个已经排序序列之和,该空间用来存放合并后的序列;
第二步:设定两个指针,最初位置分别为两个已经排序序列的起始位置;
第三步:比较两个指针所指向的元素,选择相对小的元素放入到合并空间,并移动指针到下一位置;
重复步骤3直到某一指针超出序列尾,将另一序列剩下的所有元素直接复制到合并序列尾。
性能:归并排序采用了二分的迭代方式,复杂度是log n。每次的迭代,需要对两个有序数组进行合并,这样的动作在O(n)的时间复杂度下就可以完成。因此归并排序的复杂度就是二者的乘积O(n log n)。并且它的执行频次与输入序列无关,因此,归并排序最好、最坏、平均复杂度都是O(nlogn)。空间复杂度方面,由于每次合并的操作都需要开辟基于数组的临时内存空间,所以空间复杂度为O(n)。归并排序的时候,相同元素的前后顺序不变,所以归并排序是稳定的排序算法。
归并排序的速度仅次于快速排序,一般应用于总体无序,但各子项相对有序的序列。归并排序的比较次数小于快速排序,移动次数一般多于快速排序的移动次数。
4.快速排序
原理:快速排序的原理也是分治法,它是对冒泡排序的一种改进。通过一趟排序将要排序的数列分为两部分,其中一部分的所有数据比另外一部分的所有都要小,然后再分别对这两部分进行快速排序,整个排序过程可以递归进行,以达到整个数列变成有序数列。
性能:在快排的最好时间的复杂度下,如果每次选择分区点时,都能选中中位数,把数组等分成两个,那么此时的时间复杂度和归并一样,都是O(nlogn)。而最坏的时间复杂度下,也就是每次分区都选中了最小值或最大值,得到不均等的两组。那么就要n次的分区操作,每次分区平均扫描n/2个元素,此时时间复杂度就是O(n*n)。因此快排的平均复杂度是O(nlogn)。快排使用了交换法,因此空间复杂度为O(1),分区过程涉及交换操作,所以快排是不稳定的排序算法。
1)设置两个变量i、j,排序开始的时候:i=0,j=N-1;
2)以第一个数组元素作为关键数据,赋值给key,即key=A[0];
3)从j开始向前搜索,即由后开始向前搜索(j--),找到第一个小于key的值A[j],将A[j]和A[i]的值交换;
4)从i开始向后搜索,即由前开始向后搜索(i++),找到第一个大于key的A[i],将A[i]和A[j]的值交换;
5)重复第3、4步,直到i=j; (3,4步中,没找到符合条件的值,即3中A[j]不小于key,4中A[i]不大于key的时候改变j、i的值,使得j=j-1,i=i+1,直至找到为止。找到符合条件的值,进行交换的时候i, j指针位置不变。另外,i==j这一过程一定正好是i+或j-完成的时候,此时令循环结束)。
排序演示
假设一开始序列{xi}是:5,3,7,6,4,1,0,2,9,10,8。
此时,ref=5,i=1,j=11,从后往前找,第一个比5小的数是x8=2,因此序列为:2,3,7,6,4,1,0,5,9,10,8。
此时i=1,j=8,从前往后找,第一个比5大的数是x3=7,因此序列为:2,3,5,6,4,1,0,7,9,10,8。
此时,i=3,j=8,从第8位往前找,第一个比5小的数是x7=0,因此:2,3,0,6,4,1,5,7,9,10,8。
此时,i=3,j=7,从第3位往后找,第一个比5大的数是x4=6,因此:2,3,0,5,4,1,6,7,9,10,8。
此时,i=4,j=7,从第7位往前找,第一个比5小的数是x6=1,因此:2,3,0,1,4,5,6,7,9,10,8。
此时,i=4,j=6,从第4位往后找,直到第6位才有比5大的数,这时,i=j=6,ref成为一条分界线,它之前的数都比它小,之后的数都比它大,对于前后两部分数,可以采用同样的方法来排序。
public static int[] qsort(int arr[],int start,int end) {
int pivot = arr[start];
int i = start;
int j = end;
while (i<j) {
while ((i<j)&&(arr[j]>pivot)) {
j--;
}
while ((i<j)&&(arr[i]<pivot)) {
i++;
}
if ((arr[i]==arr[j])&&(i<j)) {
i++;
} else {
int temp = arr[i];
arr[i] = arr[j];
arr[j] = temp;
}
}
if (i-1>start) arr=qsort(arr,start,i-1);
if (j+1<end) arr=qsort(arr,j+1,end);
return (arr);
}
public static void main(String[] args) {
int arr[] = new int[]{3,3,3,7,9,122344,4656,34,34,4656,5,6,7,8,9,343,57765,23,12321};
int len = arr.length-1;
arr=qsort(arr,0,len);
for (int i:arr) {
System.out.print(i+"\t");
}
}
总结:
如果对数据规模较小的数据进行排序,可以选择时间复杂度为O(n*n)的排序算法,因为当数据规模小的时候O(n*n)和O(nlogn)仅仅相差几十毫秒,对实际影响不大。但对数据规模较大的数据进行排序,就需要选择时间复杂度为O(nlogn)的排序算法了。归并排序空间复杂度为O(n),也就意味着当排序100M的数据,就需要200M的空间,所以对空间资源消耗会很多,快排的平均时间复杂度为O(nlogn),但如果分区点选择不好的话,可能逼近O(n*n),空间复杂度为O(1),而且快排不具备稳定性,这也要看你所面对的问题是否有稳定性的需求。