排序算法是一类重要的算法,也是最基础的一类算法,在学习排序算法的时候,涉及到大量的各种核心算法概念,例如大O表示法,分治法,堆和二叉树之类的数据结构,随机算法,最佳、最差和平均情况分析,时空权衡以及上限和下限,其重要性不言而喻,下面就介绍一些常见的排序算法
时间复杂度
时间复杂度是衡量一个算法的重要指标,通常用大O表示法来表示,其反应了算法对数据的操作量级
空间复杂度
空间复杂度表示一个算法小号空间的大小,如果一个算法不需要额外的空间开销,我们称之为原地算法,此时其空间复杂度为O(1)
关于排序算法的稳定性
如果在数组中有两个元素是相等的,在经过某个排序算法之后,原来在前面的的那个元素仍然在另一个元素的前面,那么我们就说这个排序算法是稳定的。如果在排序之后,原来的两个相等元素中在前面的一个元素被移到了后面,那么这个算法就是不稳定的。
比较排序、非比较排序
如果一个算法需要在排序的过程中使用比较操作来判断两个元素的大小关系,那么这个排序算法就是比较排序,大部分排序算法都是比较排序,比如冒泡排序、插入排序、堆排序等等,这种排序算法的平均时间复杂度最快也只能是O(nlogn)O(nlogn)。
非比较排序比较典型的有计数排序、桶排序和基数排序,这类排序能够脱离比较排序时间复杂度的束缚,达到O(n)O(n)级别的效率。
冒泡排序
冒泡排序是最为基础的排序算法,代码也比较简洁明了,且是一种稳定的算法(相等的元素顺序不会发生变化)
public void BubbleSort(int[] nums){
for(int i=nums.length;i>=0;i--){//第一层循环相当于冒泡的过程,没经过一次循环“待排序”的数组长度减一
for(int j=0;j<i;j++){
if(nums[j]>nums[j+1]){
swap(nums[j],nums[j+1]);//交换得到正确顺序
}
}
}
}
时间复杂度为O(n^2),空间复杂度为O(1)
选择排序
选择排序的思路和冒泡排序差不多,每一轮都选出一个最大的值和最后一个元素交换,其实整个过程和冒泡排序差不多,都是要找到最大的元素放到最后,不同点是冒泡排序是不停的交换元素,而选择排序只需要在每一轮交换一次。
public void selectionSort(int[] nums){
for(int i=nums.length;i>0;i--){//做n轮选择
int MaxIndex=0;
for(int j=0;j<=i;j++){
if(nums[MaxIndex]<nums[j]){maxIndex=j;}
}
swap(nums, maxIndex, i); // 把这个最大的元素移到最后
}
时间复杂度和空间复杂度与冒泡排序相同
插入排序
插入排序的核心思想是遍历整个数组,保持当前元素左侧始终是排序后的数组,然后将当前元素插入到前面排序完成的数组的对应的位置,使其保持排序状态。有点动态规划的感觉,类似于先把前i-1个元素排序完成,再插入第i个元素,构成i个元素的有序数组
public void insertionSort(int[] nums){
for(int i=1;i<nums.length;i++){//从第二项开始,其左边只有一个元素,所以可认为是排好序的
int j=i;
//while循环相当于对左边序列的一个排序
while(j>=0&&nums[j-1]>nums[j]){
swap(nums, j, j-1);
j--
}
}
}
时间复杂度上,插入排序在最好的情况,也就是数组已经排好序的时候,复杂度是O(n)O(n),在其他情况下都是O(n^2)
空间复杂度是O(1)O(1),不需要额外的空间,是原地算法。
插入排序是稳定排序,每次交换都是相邻元素的交换,不会有选择排序的那种跳跃式交换元素。
归并排序
归并排序很好地体现了分治算法的思想,从而大大提高了算法的效率
private void mergeSort(int[] nums, int left, int right) { // 需要左右边界确定排序范围
if (left >= right) return;//递归出口
int mid = (left+right) / 2;
mergeSort(nums, left, mid); // 先“分”,即先对左右子数组进行排序
mergeSort(nums, mid+1, right);
int[] temp = new int[right-left+1]; // 临时存放合并结果的数组
int i=left,j=mid+1;
int cur = 0;
//再“合”,合并排序后的两个数组
while (i<=mid&&j<=right) {
if (nums[i]<=nums[j]) temp[cur] = nums[i++];
else temp[cur] = nums[j++];
cur++;
}
while (i<=mid) temp[cur++] = nums[i++];
while (j<=right) temp[cur++] = nums[j++];
for (int k = 0; k < temp.length; k++) { // 合并数组完成,拷贝到原来的数组中
nums[left+k] = temp[k];
}
}
归并排序代码看似复杂,实则掌握好其过程,考虑递归的出口(left>=right)先“分”(先对左右子序列排序),再“合”(将排序好的子序列合并,实则也是再次排序),然后考虑特殊的情况(即有一部分子序列为被合并完全,需要再次用循环加入),最后拷贝入原数组.
时间复杂度上归并排序能够稳定在O(nlogn)的水平,在每一级的合并排序数组过程中总的操作次数是n,总的层级数是logn,相乘得到最后的结果就是O(nlogn)。
空间复杂度是O(n)O(n),因为在合并的过程中需要使用临时数组来存放临时排序结果。
归并排序是稳定排序,保证原来相同的元素能够保持相对的位置。
快速排序
快速排序(有时称为分区交换排序)是一种高效的排序算法。其核心的思路是取第一个元素(或者最后一个元素)作为分界点,把整个数组分成左右两侧,左边的元素小于或者等于分界点元素,而右边的元素大于分界点元素,然后把分界点移到中间位置,对左右子数组分别进行递归,最后就能得到一个排序完成的数组。当子数组只有一个或者没有元素的时候就结束这个递归过程。
int quicksort(int a[], int m, int p)
{
int i = m, j = p,v=a[m];//v就是基准值
while (i < j) {
while (i<j&&a[j]>v) {
j--;
}
while (i < j&&a[i] <= v) {
i++;
}
if(i<j){
swap(a[i],a[j]);
}
a[m]=a[i];
a[i]=v;//将基准值归位
}
quicksort(m,i-1);//递归处理左边的
quicksort(i+1,p);//递归处理右边的
}
时间复杂度在最佳情况是O(nlogn),但是如果分界点元素选择不当可能会恶化到O(n^2),快速排序是不稳定的,因为包含跳跃式交换元素位置。
非比较排序:计数排序
该算法的核心思想是建立一个映射表,通过映射原数组中的每个数字在映射表中出现的次数来依次“写入”到原始表中,所需的映射表的大小最小是(max-min+1)保证原数组中每个数据都能被构造相应的映射
由于数组的下标都是非负的,所以我们将映射数组的下标index=nums[i]-min(nums)。
下面根据一个示例来讲解,比如现在有个待排序的整数序列A={-1, 2, 0, 4, 3, 6, 5, 8, -2, 1, 3, 0, 3,6, 5, 2}。首先我们花O(n)的时间扫描一下整个序列,可以得到max=8,min=-2。然后我们建立一个新的数组C,长度为(max-min+1)=11
此时我们再扫描一下数组A,比如对于-1,我们的操作是:-1-min=-1-(-2)=1;C[1]++。对于2,我们的操作是:2-(-2)=4;C[4]++。这样我们又花了O(n)的时间。操作结果是:
然后我们可以根据count数组中出现值的个数来向写入相应个数的数字。
class Solution {
public int[] sortArray(int[] nums) {
int max = -65535, min = 65535;
for (int num: nums) {//先找出最大最小值
max = Math.max(num, max);
min = Math.min(num, min);
}
int[] counter = new int[max - min + 1];//新建一个count表,count的大小至少是max-min+1
for (int num: nums) {
counter[num - min]++;//遍历原数组,找出每个元素出现的次数
}
int idx = 0;
for (int num = min; num <= max; num++) {//由于这里要从min到max,保证被写入的num是原数组中的数字
int cnt = counter[num - min];//num-min是对应count数组中的下标
while (cnt-- > 0) {
nums[idx++] = num;//如果cnt>0,则依次写入原数组,否则代表未出现过
}
}
return nums;
}
}
最后贴上不同排序算法之间的比较