经典排序算法

总述

分析一个算法主要从以下几方面来考虑:

1、排序算法的执行效率

又分为最好情况、最坏情况、平均情况时间复杂度。

2、排序算法的内存消耗

内存消耗可以通过空间复杂度来衡量
原地排序(Sorted in place),就是特指空间复杂度是 O(1) 的排序算法。

3、排序稳定性

稳定性,就是如果待排序的序列中存在值相等的元素,经过排序之后,相等元素之间原有的先后顺序不变。
经过某种排序算法排序之后:
如果两个元素的前后顺序没有改变,那我们就把这种排序算法叫作稳定的排序算法;
如果前后顺序发生变化,那对应的排序算法就叫作不稳定的排序算法。
排序算法是否稳定排序是否原地排序最好时间复杂度最坏时间复杂度平均时间复杂度
冒泡o(n)o(n^2)o(n^2)
选择o(n^2)o(n^2)o(n^2)
插入o(n)o(n^2)o(n^2)
快排O(nlogn)o(n^2)O(nlogn)
归并O(nlogn)O(nlogn)O(nlogn)
桶排序O(n)O(n)O(n)
计数排序O(n)O(n)O(n)
基数排序O(n)O(n)O(n)

各排序算法

冒泡排序

1、冒泡排序只会操作相邻的两个数据,看是否满足关系要求,不满足就互换。
2、一次冒泡会让至少一个元素移动到它应该在的位置。
3、重复 n 次,就完成了 n 个数据的排序工作。如果一次冒泡没有变化,则可以提前结束。

void bubbleSort(int * a, int n) {
    if (n <= 1) return;
    for (int i = 0; i < n; ++i) {
        bool flag = false;
	    for (int j = 0; j < n - i - 1; ++j) {
	        if(a[j] > a[j + 1]) {
	            int tmp = a[j];
		        a[j] = a[j + 1];
		        a[j + 1] = tmp;
		        flag = true;
	        }
	    }
	    if (!flag) {
	        break;
	    }
	}
}

插入排序

1、将数组中的数据分为两个区间,已排序区间和未排序区间。初始已排序区间只有一个元素,就是数组的第一个元素。
2、取未排序区间中的元素,在已排序区间中找到合适的插入位置将其插入,保证已排序区间数据一直有序。
3、重复这个过程,直到未排序区间中元素为空,算法结束。

void insertSort(int* a, int n) {
   if (n <= 1) return;
   for(int i = 1; i < n; ++i) {
       int key = a[i];
       int j = i - 1;
       for(; j >= 0; --j) {
           if(key < a[j]) {
	           a[j+1] = a[j];
	       } else {
	           break;
	       }
       }
       if (j != i-1) {
           a[j+1] = key;
       }
   }
}

选择排序

1、类似插入排序,也分已排序区间和未排序区间,初始已排序区间为空。
2、每次会从未排序区间中找到最小的元素,将其放到已排序区间的末尾。
3、重复步骤2,直到末尾。

void selectSort(int* a, int n) {
    if (n <= 1) return;
    for(int i = 0; i < n-1; ++i){
       int minindex = i;
       for(int j = i + 1; j < n; ++j){
           if (a[minindex] > a[j]) {
	           minindex = j;
	       }
       }
       if (minindex != i) {
           int tmp = a[minindex];
	       tmp = a[i];
	       a[i] = a[minindex];
	       a[minindex] = tmp;
       }
    }
}

归并排序

归并排序使用的是分治思想。

1、先把数组从中间分成前后两部分,然后对前后两部分分别排序。
2、再将排好序的两部分合并在一起,这样整个数组就都有序了。

void mergeSort(int* a, int n){
     mergeSortC(a, 0, n-1);
}

void mergeSortC(int* a, int l, int r) {
    if (l < r) {
        int q = (l + r) / 2;
        mergeSortC(a, l, q);
        mergeSortC(a, q+1, r);
        merge(a, l, q, r);
    }
}

void merge(int* a, int l, int q, int r) {
    int b[10] = {0};
    int first = l;
    int second = q + 1;
    int num = 0;
    while(first <= q &&  second <= r) {
        if(a[first] <= a[second]) {
            b[num++] = a[first++];
        } else {
            b[num++] = a[second++];
        }
    }
    while (first <= q) {
        b[num++] = a[first++];
    }
    while (second <= r) {
        b[num++] = a[second++];
    }
    first = l;
    for (int i = 0; i < num; ++i) {
        a[first] = b[i];
        first++;
    }
}

快速排序

快排与归并排序的区别:
1、归并排序的处理过程是由下到上的,先处理子问题,然后再合并。
2、快排正好相反,它的处理过程是由上到下的,先分区,然后再处理子问题。

快排有个partition() 分区函数。partition() 分区函数功能是选择一个元素作为 pivot(一般情况下,可以选择 p 到 r 区间的最后一个元素),然后对 A[p…r]分区,函数返回 pivot 的下标。

选取pivot的方式:
1、选择第一个
2、选择最后一个
3、选取第一个、最后一个以及中间的元素的中位数

快排递归代码

思路一:
类似选择排序。我们通过游标 i 把 A[p…r-1]分成两部分:
A[p…i-1]的元素都是小于 pivot 的,我们暂且叫它“已处理区间”,A[i…r-1]是“未处理区间”。
每次都从未处理的区间 A[i…r-1]中取一个元素 A[j],与 pivot 对比,如果小于 pivot,则将其加入到已处理区间的尾部,也就是 A[i]的位置。
在这里插入图片描述

思路二:
维护左右指针,将分区元素与左右指针相比。

void quickSort(int* a, int n){
     quickSortn(a, 0, n-1);
}

void quickSortn(int* a, int l, int r) {
    if (l >= r) return;
    int pivot = partition2(a, l, r);
    quickSortn(a, l, pivot-1);
    quickSortn(a, pivot+1, r);
}

int partition(int* a, int l, int r) {
    int pivot = a[r];
    int i = l;
    int j = l;
    for (; j < r; j++){
        if (a[j] < pivot) {
            int tmp = a[j];
            a[j] = a[i];
            a[i] = tmp;
            i++;
        }
    }
    int tmp = a[i];
    a[i] = a[r];
    a[r] = tmp;
    return i;
}

int partition2(int* a, int low, int high) {
    int pivot = a[low];
    while(low < high) {
         while (low < high && a[high] >= pivot) {
             high--;
         }
         a[low] = a[high];
         while (low < high && a[low] <= pivot) {
             low++;
         }
         a[high] = a[low];
    }
    a[high] = pivot;
    return low;
}


快排非递归代码

借助栈来实现。

void quickSortNoRecusive(int* a, int l, int r) {
    stack<int> s;
    if (l < r) {
        int pivot = partition(a, l, r);
        if (pivot - 1 > l) {
            s.push(pivot - 1);
            s.push(l);
        }
        if (pivot + 1 < r) {
            s.push(r);
            s.push(pivot + 1);
        }
        while (!s.empty()) {
            int start = s.top();
            s.pop();
            int end = s.top();
            s.pop();
            int pivot = partition(a, start, end);
            if (pivot - 1 > start) {
                s.push(pivot - 1);
                s.push(start);
            }
            if (pivot + 1 < end) {
                s.push(end);
                s.push(pivot + 1) ;
            }
        }
    }
}

快速排序的优化

优化1、使用三数取中法获取枢轴元素

三数取中(随机数算法效果相同)在处理升序数组时效率较高。

优化2、当待排序序列的长度分割到一定大小后,使用插入排序。

原因:对于很小的数组,快排不如插排效率搞。

优化3、在一次分割结束后,可以把与Key相等的元素聚在一起,继续下次分割时,不用再对与key相等元素分割。

例如:待排序序列 1 4 6 7 6 6 7 6 8 6

通过三数取中选取枢轴:下标为4的数6

转换后,待分割序列:6 4 6 7 1 6 7 6 8 6

本次划分后,未对与key元素相等处理的结果:1 4 6 6 7 6 7 6 8 6

下次的两个子序列为:1 4 6 和 7 6 7 6 8 6

本次划分后,如果对与key元素相等处理的结果:1 4 ==6 6 6 6 6 == 7 8 7

下次的两个子序列为:1 4 和 7 8 7

可以看出,在一次划分后,把与key相等的元素聚在一起,能减少迭代次数,效率会提高不少

template <class T>
void QSort(T arr[], int low, int high)
{
	int first = low;
	int last = high;
 
	int left = low;
	int right = high;
 
	int leftLen = 0;
	int rightLen = 0;
 
	if (high - low + 1 < 10) {
		insertSort(arr,low,high);
		return;
	}
 
	//一次分割
	int key =  NumberOfThree(arr,low,high);//使用三数取中选择枢轴
 
	while(low < high) {
		while(high > low && arr[high] >= key) {
			if (arr[high] == key) {//处理相等元素
				Swap(arr[right],arr[high]);
				right--;
				rightLen++;
			}
			high--;
		}
		arr[low] = arr[high];
		while(high > low && arr[low] <= key) {
			if (arr[low] == key) {
				Swap(arr[left],arr[low]);
				left++;
				leftLen++;
			}
			low++;
		}
		arr[high] = arr[low];
	}
	arr[low] = key;
 
	//一轮快排结束后,把与key相等的元素移到key周围
	int i = low - 1;
	int j = first;
	while(j < left && arr[i] != key)
	{
		Swap(arr[i],arr[j]);
		i--;
		j++;
	}
	i = low + 1;
	j = last;
	while(j > right && arr[i] != key)
	{
		Swap(arr[i],arr[j]);
		i++;
		j--;
	}
    QSort(arr, first, low - 1 - leftLen);
    QSort(arr, low + 1 + rightLen, last);
}

int NumberOfThree(int* arr,int low,int high) {
	int mid = low + ((high - low) >> 1);
	if (arr[mid] > arr[high]){
		Swap(arr[mid],arr[high]);
	}
	if (arr[low] > arr[high]){
		Swap(arr[low],arr[high]);
	}
	if (arr[mid] > arr[low]){
		Swap(arr[mid],arr[low]);
	}
	return arr[low];
}

桶排序

算法思想:
1、将要排序的数据分到几个有序的桶里,每个桶里的数据再单独进行排序。
2、桶内排完序之后,再把每个桶里的数据按照顺序依次取出,组成的序列就是有序的了。

适用场景:
1、桶排序比较适合用在外部排序中。
所谓的外部排序就是数据存储在外部磁盘中,数据量比较大,内存有限,无法将数据全部加载到内存中。

2、要排序的数据需要很容易就能划分成 m 个桶,并且,桶与桶之间有着天然的大小顺序。
这样每个桶内的数据都排序完之后,桶与桶之间的数据不需要再进行排序。

3、数据在各个桶之间的分布是比较均匀的。
如果数据经过桶的划分之后,有些桶里的数据非常多,有些非常少,很不平均,那桶内数据排序的时间复杂度就不是常量级了。在极端情况下,如果数据都被划分到一个桶里,那就退化为 O(nlogn) 的排序算法了。

计数排序

算法思想:

计数排序是桶排序的一种特殊情况。
当要排序的 n 个数据,所处的范围并不大的时候,比如最大值是 k,我们就可以把数据划分成 k 个桶。每个桶内的数据值都是相同的,省掉了桶内排序的时间。

适用场景:

1、计数排序适用在数据范围不大的场景中。如果数据范围 k 比要排序的数据 n 大很多,就不适合用计数排序了。
2、计数排序只能给非负整数排序,如果要排序的数据是其他类型的,要将其在不改变相对大小的情况下,转化为非负整数。

以统计分数排名为例:

假设只有 8 个考生,分数在 0 到 5 分之间。这 8 个考生的成绩我们放在一个数组 A[8]中,它们分别是:2,5,3,0,2,3,0,3。对其进行排序。方法如下:

1、考生的成绩从 0 到 5 分,我们使用大小为 6 的数组 C[6]表示桶,其中下标对应分数。不过,C[6]内存储的并不是考生,而是对应的考生个数。
2、对 C[6]数组顺序求和,C[6]存储的数据就变成:C[k]里存储小于等于分数 k 的考生个数。
3、维护一个数据R[8]存放排序后的结果。
4、从后到前依次扫描数组 A。比如,当扫描到值3 时,从数组 C 中取出下标为 3 的值 7,也就是说,到目前为止,包括自己在内,分数小于等于 3 的考生有 7 个,也就是说 3 是数组 R 中的第 7 个元素(也就是数组 R 中下标为 6 的位置)。当 3 放入到数组 R 中后,小于等于 3 的元素就只剩下了 6 个了,所以相应的 C[3]要减 1,变成 6。
5、重复上述第5步骤,当扫描完整个数组 A 后,数组 R 内的数据就是按照分数从小到大有序排列的了。
在这里插入图片描述

void countingSort(int* a, int n) {
    if (n <= 1) {
        return;
    }
    //计算最大的数值
    int max = 0;
    for (int i = 0; i < n; ++i) {
        if (a[i] > max) {
            max = a[i];
        }
    }
    //初始化c数组
    int * c = new int[max + 1];
    for (int i = 0; i <= max; ++i) {
        c[i] = 0;
    }
    for (int i = 0; i < n; ++i) {
        c[a[i]]++;
    }
    //依次累加c数组
    for (int i = 1; i <= max; ++i) {
        c[i] = c[i-1] + c[i];
    }
    //依次累加c数组
    for (int i = 1; i <= max; ++i) {
        c[i] = c[i-1] + c[i];
    }
    //临时数组r,存放排序之后的结果
    int * r = new int[n];
    for (int i = n -1; i >= 0; --i) {
       int index = c[a[i]] - 1;
       r[index] = a[i];
       c[a[i]]--;
    }
    // 将结果拷贝给a数组
    for (int i = 0; i < n; ++i) {
        a[i] = r[i];
    }
}

基数排序

算法思想:
按位排序,要求数据可以划分成高低位,位之间有递进关系。比较两个数,我们只需要比较高位,高位相同的再比较低位。

以手机号码排序为例,先按照最后一位来排序手机号码,然后,再按照倒数第二位重新排序,以此类推,最后按照第一位重新排序。经过 11 次排序之后,手机号码就都有序了。

适用场景:
1、要排序的数据需要可以分割出独立的“位”来比较,而且位之间有递进的关系。
如果 a 数据的高位比 b 数据大,那剩下的低位就不用比较了。
2、每一位的数据范围不能太大。
如果数据范围太大就不能用线性排序算法来排序,时间复杂度就无法做到 O(n) 了。

如何实现一个通用高效排序算法

对比各排序算法:使用快排

1、线性排序时间复杂度很低但使用场景特殊,如果要写一个通用排序函数,不能选择线性排序。
2、为了兼顾任意规模数据的排序,一般会首选时间复杂度为O(nlogn)的排序算法来实现排序函数。
3、同为O(nlogn)的快排和归并排序相比,归并排序不是原地排序算法,所以最优的选择是快排。

通用排序函数实现技巧

1、当元素个数小于某个常数小时,可以考虑使用O(n^2)级别的插入排序
2、当元素个数较多时,使用快排,注意优化快排分区点的选择。
3、防止堆栈溢出,可以选择在堆上手动模拟调用栈解决
4、用哨兵简化代码,每次排序都减少一次判断,尽可能把性能优化到极致

Java 中的排序算法 Arrays.sort ,综合了插入排序、堆排序、归并排序、快排。
1、若数组元素个数总数小于47,使用插入排序
2、若数据元素个数总数在47~286之间,使用快速排序。应该是使用的优化版本的三值取中的优化版本。
3、若大于286的个数,使用归并排序。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值