文章目录
参考:https://www.cnblogs.com/onepixel/p/7674659.html
重点: nlogn排序,快排,归并,堆排序
分类
题目
- 快排
- 归并 √
- 堆排序
- https://leetcode-cn.com/problems/relative-sort-array/ √
- https://leetcode-cn.com/problems/valid-anagram/ √
- https://leetcode-cn.com/problems/merge-intervals/ √
- https://leetcode-cn.com/problems/reverse-pairs/ √
如何分析排序算法
排序算法的执行效率
- 最好情况、最坏情况、平均事件复杂度
- 时间复杂度的系数–>常数–>低阶
- 比较次数和交换次数
排序算法的内存消耗
- 原地排序 -->空间复杂度为O(1)
排序算法的稳定性
- 稳定性–>如果排序中存在值相等的元素,经过排序后相等的两个元素先后顺序没有改变
- 稳定性的应用–>如果要实现“订单”排序。订单有两个属性,一个是下单时间,另一个是订单金额。如果我们现在有 10 万条订单数据,我们希望按照金额从小到大对订单数据排序。对于金额相同的订单,我们希望按照下单时间从早到晚有序,进行两次排序实现困难,复杂度高,这时候稳定排序算法就显得很重要了
冒泡排序
冒泡算法可以优化,当某次冒泡操作已经没有数据交换时,说明已经达到完全有序,不用再继续执行后续的冒泡操作
代码实现
// 冒泡排序,a表示数组,n表示数组大小
public void bubbleSort(int[] a, int n) {
if (n <= 1) return;
for (int i = 0; i < n; ++i) {
// 提前退出冒泡循环的标志位
boolean 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; // 没有数据交换,提前退出
}
}
冒泡排序是原地排序吗?=== >是,空间复杂度为O(1)
冒泡排序是稳定的排序算法吗?=== >是,当两个元素大小相同时不会交换位置
冒泡排序的时间复杂度?
平均时间复杂度
需要用概率论分析==> n*(n-1)/4 ==> O(n2)
插入排序
插入排序算法思想==>1.如果插入一个新的数到排序好的数组中,遍历数组比较大小,插入合适的位置
=================>2.将未排序的数组分成两部分已排序区,未排序区 从未排序区取一个数,插入到已排序区,重复1,最终实现排序
插入排序的移动操作次数等于逆序度,是固定的
// 插入排序,a表示数组,n表示数组大小
public void insertionSort(int[] a, int n) {
if (n <= 1) return;
for(int i = 1; i < n; i++)
{
int value = a[i];
int j = i - 1;
// 查找插入的位置
for(; j > 0; j--)
{
if( value < a[j])
{
a[j+1] = a[j]; // 数据移动
}
else
{
break;
{
}
a[j+1] = value; // 插入数据
}
}
插入算法是原地排序算法吗? 是,不需要额外的储存空间,空间复杂度为O(1)
插入算法是稳定的排序算法吗? 是,选择的时候可以将相同元素按先后插入,就不需要交换相同元素的前后问题
插入算法的时间复杂度是?
最好时间复杂度O(n),数组是有序的,不需要移动操作,只需要从头遍历
最差时间复杂度O(n2),数组是倒序的,每次都要在数组开头插入,每次都需要遍历移动所有已排序的数组元素
平均事件复杂度O(n2), 在有序数组中插入一个元素的平均时间复杂度是O(n),所以将n个数组元素插入平均时间复杂度就为O(n2)
选择排序
思想 类似插入排序, 分为已排序区和 未排序区,从未排序区遍历查找最小的元素,交换最小元素和未排序开头元素
是原地排序算法,空间复杂度为O(1)
最好时间复杂度O(n),最差时间复杂度和平均时间复杂度都是O(n2)
选择排序是不是稳定排序算法呢?? 不是稳定排序,比如 5,8,5,2,9
O(n2)排序总结
相对与冒泡排序和插入排序,选择排序就略差一筹,因为选择排序是不稳定排序
插入排序和冒泡排序都是原地排序,也都是稳定排序,时间复杂度也相同,但是插入排序要比冒泡排序更受欢迎
因为,虽然插入排序和冒泡排序不论怎样优化,元素的移动次数都是固定的逆序数,但是从代码来看,冒泡排序每次移动元素,都有三个赋值操作,而插入排序只需要一个赋值操作
归并排序
思想 1.将数组从中间分成两部分,2.然后对前后两部分分别排序,3.排序好的两部分合并在一起
分治
归并排序使用的就是分治思想,
分治==>分而治之,将大问题分成小问题,小问题解决,大问题就解决了
分治思想有点像递归,分治算法一般都是用递归来实现的。
分治是一种解决问题的思想,递归是一种编程技巧
用递归实现归并排序
递归实现步骤==>写出递归公式==>找出终止条件==>翻译公式成递归代码
递推公式:
merge_sort(p…r) = merge(merge_sort(p…q), merge_sort(q+1…r))
终止条件:
p >= r 不用再继续分解
// 归并排序算法
void merge_sort(int[] A, int n)
{
merge_sort_c(A, 0, n-1);
}
// 递归调用函数
void merge_sort_c(int[] A, int p, int r)
{
// 递归终止条件
if(p >= r) return;
// 取中间位置
int q = (p + r) / 2;
// 分治递归
merge_sort_c(A, p, q);
merge_sort_c(A, q+1, r);
// 合并结果
merge(A, p, r);
}
void merge(int[] A, int p, int r)
{
int[] temp = new int[r- p + 1];
int i = p;
// 取中间位置
int q = (p + r) / 2;
int j = q + 1;
int k = 0;
while (i <= q && j <= r) {
temp[k++] = A[i] < A[j] ? A[i++] : A[j++];
}
// 判断哪个子数组中有剩余数据
while (i <= q) temp[k++] = A[i++];
while (j <= r) temp[k++] = A[j++];
// 将tmp数组拷贝到A数组
for (int s=0; s <= k; s++) {
A[p + s] = temp[s];
}
}
归并排序是稳定的排序算法吗? 是的
归并排序的时间复杂度是多少?
递归实现,时间复杂度也可以写成递归公式
T(1) = C; n=1时,只需要常量级的执行时间,所以表示为C。
T(n) = 2*T(n/2) + n; n>1
T(n) = 2*T(n/2) + n
= 2*(2*T(n/4) + n/2) + n = 4*T(n/4) + 2*n
= 4*(2*T(n/8) + n/4) + 2*n = 8*T(n/8) + 3*n
= 8*(2*T(n/16) + n/8) + 3*n = 16*T(n/16) + 4*n
......
= 2^k * T(n/2^k) + k * n
......
T(n) = 2kT(n/2k)+kn。当 T(n/2^k)=T(1) 时,也就是 n/2^k=1,我们得到 k=log2n 。我们将 k 值代入上面的公式,得到 T(n)=Cn+nlog2n 。T(n) 就等于 O(nlogn)
归并排序的空间复杂度是多少??
O(n) 临时空间大小不会超过n个数据
快速排序(Quicksort)
原理
快排也是利用分治思想
==>选择p到r之间的任意一个数据作为pivot(分区点) ====> 遍历p到r之间的所有元素,小于pivot的放在左边,大于的放右边 =====>根据分治、递归思想,递归排序 p到q - 1之间的数据和q + 1 到r之间的数据 ====>直到区间缩小为1
递归公式
递推公式:
quick_sort(p…r) = quick_sort(p…q-1) + quick_sort(q+1… r)
终止条件:
p >= r
代码实现
void quick_sort(int[] A, int n)
{
quick_sort_c(A, 0, n-1);
}
void quick_sort_c(int[] A, int p, int r)
{
if(q >= r) return;
int q = partition(A, p, r);// 获取分区点
quick_sort_c(A, p, q-1);
quick_sort_c(A, q, r);
}
int partition(int[] A, int p, int r)
{
int pivot = A[r];
int i = p, tmp = 0;
for(int j = p; j <= r-1; j++)
{
if(A[j] < pivot)
{
tmp = A[i];
A[i] = A[j];
A[j] = tmp;
i++;
}
}
tmp = A[i];
A[i] = A[r];
A[r] = tmp;
return i;
}
parttition实现可以采用直接遍历,申请两个临时数组,大小分别放,最后合并,这样空间复杂度会是O(n)
这里的partition的处理有点类似选择排序。我们通过游标 i 把 A[p…r-1]分成两部分。A[p…i-1]的元素都是小于 pivot 的,我们暂且叫它“已处理区间”,A[i…r-1]是“未处理区间”。我们每次都从未处理的区间 A[i…r-1]中取一个元素 A[j],与 pivot 对比,如果小于 pivot,则将其加入到已处理区间的尾部,也就是 A[i]的位置。
快排的性能分析
原地排序,空间复杂度O(1)
不稳定排序, 例子5,8,5,2
时间复杂度
快排也是递归实现,和上面归并排序公式一样
T(1) = C; n=1时,只需要常量级的执行时间,所以表示为C。 T(n) = 2*T(n/2) + n; n>1
快排的时间复杂度也是 O(nlogn),但是这前提是每次分区都能让pivot将大区间一分为二,但这是不可能的。
如果一个有序数组,比如1,2,3,4,每次选最后一个为pivot,需要进行n次分区操作,每次分区都要扫描n/2次,快排时间复杂度就退化到O(n2)
T(n) 在大部分情况下的时间复杂度都可以做到 O(nlogn),只有在极端情况下,才会退化到 O(n2),而且还有很多方法避免退化到O(n2)(合理地选择 pivot)
快排和归并的区别
归并排序的处理过程是由下到上的,先处理子问题,然后再合并。而快排正好相反,它的处理过程是由上到下的,先分区,然后再处理子问题
归并排序虽然是稳定的、时间复杂度为 O(nlogn) 的排序算法,但是它是非原地排序算法
如何用快排思想在O(n)内查找第K大元素?leetcode 215. 数组中的第K个最大元素
如果,q+1 =k ,A[q]就是要求解的元素,
====>q+1 <k , 第k大元素在后半部分,
====>q+1 >k, 第k大元素在前半部分,
所以只需要改变代码少许代码就能实现
代码实现
class Solution {
public:
int findKthLargest(vector<int>& nums, int k) {
return find_c(nums, 0, nums.size()-1, k);
}
int find_c(vector<int>& nums, int p, int r, int k)
{
int q = partition(nums, p, r);
if(q+1 == k)
{
return nums[q];
}
else if(q+1 < k)
{
return find_c(nums, q+1, r, k);
}
else
{
return find_c(nums, p, q-1, k);
}
}
int partition(vector<int>& nums, int p, int r)
{
int pivot = nums[r];
int i = p, tmp = 0;
for(int j = p; j <= r-1; j++)
{
if(nums[j] > pivot)
{
// 这里交换比较耗时,可以直接用库函数swap()
tmp = nums[i];
nums[i] = nums[j];
nums[j] = tmp;
i++;
}
}
tmp = nums[i];
nums[i] = nums[r];
nums[r] = tmp;
return i;
}
};
这种方法时间复杂度分析
第一次partition,遍历n个元素,
第二次partition,遍历n/2个元素
。。。。。
所有次数加起来,n+n/2+…+1 = 2n-1 所以,时间复杂度为O(n),这指的是平均时间复杂度,最差O(n*(n-k)),也是O(n2),但是这种概率太小了
桶排序
思想
将要排序的数据分到几个有序的桶里,每个桶的数据再单独进行排序。排序完后,再把每个桶里的数据取出
线性排序时间复杂度为O(n)
桶排序是线性排序, n个数据, 分到m个桶,每个桶有 k=n/m个数据, 每个桶使用快排,时间复杂度为O(klogk), m个桶为O(mklogk) ===>O(nlog(n/m)) ,当m接近n时,log(n/m)就是一个非常小的常量,这个时候桶排序的时间复杂度为O(n)
桶排序是不是可以代替之前的排序算法?
桶排序对数据的要求比较苛刻
- 容易划分成m个桶
- 桶与桶之间有这天然的大小顺序
- 数据在各个桶之间分布比较均匀,极端情况全化到一个桶内,直接退化到O(n*logn) 排序算法
桶排序适用场景
外部排序
就是,排序的数据较大,内存放不下,无法将数据全部加载到内存中
比如说我们有 10GB 的订单数据,我们希望按订单金额(假设金额都是正整数)进行排序,但是我们的内存有限,只有几百 MB,没办法一次性把 10GB 的数据都加载到内存中。这个时候该怎么办呢?
计数排序
- 计数排序其实是桶排序的一种特殊情况
- 计数排序,如最大值是 k,我们就可以把数据划分成 k 个桶。每个桶内的数据值都是相同的,省掉了桶内排序的时间。
- 计数排序只能用在数据范围不大的场景中,如果数据范围 k 比要排序的数据 n 大很多,就不适合用计数排序了。而且,计数排序只能给非负整数排序,如果要排序的数据是其他类型的,要将其在不改变相对大小的情况下,转化为非负整数。
实现过程比较复杂,有点难理解
// 计数排序,a是数组,n是数组大小。假设数组中存储的都是非负整数。
public void countingSort(int[] a, int n) {
if (n <= 1) return;
// 查找数组中数据的范围
int max = a[0];
for (int i = 1; i < n; ++i) {
if (max < a[i]) {
max = a[i];
}
}
int[] c = new int[max + 1]; // 申请一个计数数组c,下标大小[0,max]
for (int i = 0; i <= max; ++i) {
c[i] = 0;
}
// 计算每个元素的个数,放入c中
for (int i = 0; i < n; ++i) {
c[a[i]]++;
}
// 依次累加
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; // 找到放入该元素a[i] 的下标
r[index] = a[i]; // 放入该元素
c[a[i]]--; // 该元素计数减一
}
// 将结果拷贝给a数组
for (int i = 0; i < n; ++i) {
a[i] = r[i];
}
}
小结
如果对小规模数据进行排序,可以选择时间复杂度是 O(n2) 的算法;如果对大规模数据进行排序,时间复杂度是 O(nlogn) 的算法更加高效。所以,为了兼顾任意规模数据的排序,一般都会首选时间复杂度是 O(nlogn) 的排序算法来实现排序函数。
堆排序和快速排序都有比较多的应用,比如 Java 语言采用堆排序实现排序函数,C 语言使用快速排序实现排序函数。
快排在最坏情况下的时间复杂度是 O(n2),而归并排序可以做到平均情况、最坏情况下的时间复杂度都是 O(nlogn),从这点上看起来很诱人,那为什么它还是没能得到“宠信”呢
归并排序并不是原地排序算法,空间复杂度是 O(n)。所以,粗略点、夸张点讲,如果要排序 100MB 的数据,除了数据本身占用的内存之外,排序算法还要额外再占用 100MB 的内存空间,空间耗费就翻倍了。
快速排序在最坏情况下的时间复杂度是 O(n2),如何来解决这个“复杂度恶化”的问题呢
如何优化快速排序?
这种 O(n2) 时间复杂度出现的主要原因还是因为我们分区点选得不够合理。
最理想的分区点是:被分区点分开的两个分区中,数据的数量差不多。
-
- 三数取中法
-
- 随机法
分析排序函数
- Glibc 中的 qsort() 函数
- qsort() 从名字上看,很像是基于快速排序算法实现的,实际上它并不仅仅用了快排这一种算法
- qsort() 会优先使用归并排序来排序输入数据,因为归并排序的空间复杂度是 O(n),所以对于小数据量的排序,比如 1KB、2KB 等,归并排序额外需要 1KB、2KB 的内存空间,这个问题不大
- 要排序的数据量比较大的时候,qsort() 会改为用快速排序算法来排序。
- qsort() 选择分区点的方法就是“三数取中法”
- qsort() 并不仅仅用到了归并排序和快速排序,它还用到了插入排序。在快速排序的过程中,当要排序的区间中,元素的个数小于等于 4 时,qsort() 就退化为插入排序,不再继续用递归来做快速排序,因为我们前面也讲过,在小规模数据面前,O(n2) 时间复杂度的算法并不一定比 O(nlogn) 的算法执行时间长。
- 时间复杂度代表的是一个增长趋势,如果画成增长曲线图,你会发现 O(n2) 比 O(nlogn) 要陡峭,也就是说增长趋势要更猛一些。但是,我们前面讲过,在大 O 复杂度表示法中,我们会省略低阶、系数和常数,也就是说,O(nlogn) 在没有省略低阶、系数、常数之前可能是 O(knlogn + c),而且 k 和 c 有可能还是一个比较大的数。假设 k=1000,c=200,当我们对小规模数据(比如 n=100)排序时,n2的值实际上比 knlogn+c 还要小。
knlogn+c = 1000 * 100 * log100 + 200 远大于10000 n^2 = 100*100 = 10000
- 之前讲到的哨兵来简化代码,提高执行效率吗?在 qsort() 插入排序的算法实现中,也利用了这种编程技巧。虽然哨兵可能只是少做一次判断,但是毕竟排序函数是非常常用、非常基础的函数,性能的优化要做到极致。
堆排序实现
private void maxHeap(int[] array, int end) {
//[1]根据数组的排序范围,计算出最后一个根节点的下标,
//计算公式:lastFather = (start + end) / 2 - 1,并且(start + end) / 2向下取整
int lastFather = Math.floor(end / 2);
//[3]创建一个循环,对数组中所有的根节点都进行如下操作
for(int father = lastFather; father >= 0; father--) {
//[2]使用每一个父节点的两个子节点先比较大小,然后用两个子节点中比较大的一个,
//和根节点比较大小,如果这个子节点比根节点还要大,则互换
/*
* 左右孩子节点下标和根节点下标之间的关系公式:
* leftChild = father * 2 + 1;
* rightChild = father * 2 + 2;
*/
int leftChild = father * 2 + 1;
int rightChild = father * 2 + 2;
//如果右孩子存在并且右孩子比父节点大,那么由右孩子替换父节点
if(rightChild <= end && array[rightChild] > array[father]) {
swap(array, rightChild, father);
}
//如果左孩子比父节点大,那么由左孩子替换父节点,等价于左孩子比右孩子大,
//用右孩子替换原有的父节点
if(array[leftChild] > array[father]) {
swap(array, leftChild, father);
}
}
}
/**
* 堆排序算法
* @param array 待排序数组
*/
public void heapSort(int[] array) {
//[3]创建一个循环,控制数组的待排序部分的最后下标位
for(int end = array.length-1; end > 0; end--) {
//[1]每次都是自顶向下构建大根堆
maxHeap(array, end);
//[2]将大根堆的堆顶元素和数组待排序范围内的最后一个元素进行互换
swap(array, 0, end);
}
}
function swap(arr, i, j) {
var temp = arr[i];
arr[i] = arr[j];
arr[j] = temp;
}