数据结构与算法---排序


参考:https://www.cnblogs.com/onepixel/p/7674659.html
重点: nlogn排序,快排,归并,堆排序

分类

在这里插入图片描述


题目

  1. 快排
  2. 归并 √
  3. 堆排序
  4. https://leetcode-cn.com/problems/relative-sort-array/ √
  5. https://leetcode-cn.com/problems/valid-anagram/ √
  6. https://leetcode-cn.com/problems/merge-intervals/ √
  7. 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) 时间复杂度出现的主要原因还是因为我们分区点选得不够合理。
最理想的分区点是:被分区点分开的两个分区中,数据的数量差不多。

    1. 三数取中法
    1. 随机法

分析排序函数

  • 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;
    }
	
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值