各种排序算法总结和比较

       常见的排序算法包括:插入排序、选择排序、冒泡排序、快速排序、堆排序、归并排序、希尔排序、二叉树排序、计数排序、桶排序、基数排序。

  比较排序和非比较排序

      常见的排序算法都是比较排序,非比较排序包括计数排序、桶排序和基数排序,非比较排序对数据有要求,因为数据本身包含了定位特征,所有才能不通过比较来确定元素的位置。

      比较排序的时间复杂度通常为O(n2)或者O(nlogn),比较排序的时间复杂度下界就是O(nlogn),而非比较排序的时间复杂度可以达到O(n),但是都需要额外的空间开销。

     比较排序时间复杂度为O(nlogn)的证明:

      a1,a2,a3……an序列的所有排序有n!种,所以满足要求的排序a1',a2',a3'……an'(其中a1'<=a2'<=a3'……<=an')的概率为1/n!。基于输入元素的比较排序,每一次比较的返回不是0就是1,这恰好可以作为决策树的一个决策将一个事件分成两个分支。比如冒泡排序时通过比较a1和a2两个数的大小可以把序列分成a1,a2……an与a2,a1……an(气泡a2上升一个身位)两种不同的结果,因此比较排序也可以构造决策树。根节点代表原始序列a1,a2,a3……an,所有叶子节点都是这个序列的重排(共有n!个,其中有一个就是我们排序的结果a1',a2',a3'……an')。如果每次比较的结果都是等概率的话(恰好划分为概率空间相等的两个事件),那么二叉树就是高度平衡的,深度至少是log(n!)。

      又因为 1. n! < nn ,两边取对数就得到log(n!)<nlog(n),所以log(n!) = O(nlogn).

                   2. n!=n(n-1)(n-2)(n-3)…1 > (n/2)^(n/2) 两边取对数得到 log(n!) > (n/2)log(n/2) = Ω(nlogn),所以 log(n!) = Ω(nlogn)。

      因此log(n!)的增长速度与 nlogn 相同,即 log(n!)=Θ(nlogn),这就是通用排序算法的最低时间复杂度O(nlogn)的依据。

   

   排序的稳定性和复杂度

      不稳定:

      选择排序(selection sort)— O(n2)

      快速排序(quicksort)— O(nlogn) 平均时间, O(n2) 最坏情况; 对于大的、乱序串列一般认为是最快的已知排序

      堆排序 (heapsort)— O(nlogn)

      希尔排序 (shell sort)— O(nlogn)

      基数排序(radix sort)— O(n·k); 需要 O(n) 额外存储空间 (K为特征个数)

      稳定:

      插入排序(insertion sort)— O(n2)

      冒泡排序(bubble sort) — O(n2)

      归并排序 (merge sort)— O(nlogn); 需要 O(n) 额外存储空间

      二叉树排序(Binary tree sort) — O(nlogn); 需要 O(n) 额外存储空间

      计数排序  (counting sort) — O(n+k); 需要 O(n+k) 额外存储空间,k为序列中Max-Min+1

      桶排序 (bucket sort)— O(n); 需要 O(k) 额外存储空间

 每种排序的原理和实现

      插入排序

      遍历数组,遍历到i时,a0,a1...ai-1是已经排好序的,取出ai,从ai-1开始向前和每个比较大小,如果小于,则将此位置元素向后移动,继续先前比较,如果不小于,则放到正在比较的元素之后。可见相等元素比较是,原来靠后的还是拍在后边,所以插入排序是稳定的。

      当待排序的数据基本有序时,插入排序的效率比较高,只需要进行很少的数据移动。

   public int[] Sort(int[] arr) {
			for (int i = 1; i < arr.length; i++) {
				int j=i-1;
				int temp=arr[i];
				while (j>=0 && arr[j]>temp) {
					arr[j+1]=arr[j];
					j--;
				}
				arr[j+1]=temp;
			}
			return arr;
		}

选择排序

      遍历数组,遍历到i时,a0,a1...ai-1是已经排好序的,然后从i到n选择出最小的,记录下位置,如果不是第i个,则和第i个元素交换。此时第i个元素可能会排到相等元素之后,造成排序的不稳定。

public int[] Sort(int[] arr) {
		for (int i = 0; i < arr.length - 1; i++) {
			int temp = i;
			for (int j = i; j < arr.length; j++) {
				if (arr[temp] > arr[j])
					temp = j;
			}
			swap(arr, i, temp);
		}
		return arr;

	}

	private void swap(int[] arr, int i, int j) {
		int temp = arr[i];
		arr[i] = arr[j];
		arr[j] = temp;
	}

   冒泡排序

      冒泡排序的名字很形象,实际实现是相邻两节点进行比较,大的向后移一个,经过第一轮两两比较和移动,最大的元素移动到了最后,第二轮次大的位于倒数第二个,依次进行。这是最基本的冒泡排序,还可以进行一些优化。

      优化一:如果某一轮两两比较中没有任何元素交换,这说明已经都排好序了,算法结束,可以使用一个Flag做标记,默认为false,如果发生交互则置为true,每轮结束时检测Flag,如果为true则继续,如果为false则返回。

 

	//冒泡排序
	public int[] Sort(int[] arr) {
		for (int i = 0; i < arr.length; i++) {
			for (int j = 0; j < arr.length-1; j++) {
				if(arr[j]>arr[j+1]) swap(arr, j, j+1);
			}
		}
		return arr;
	}
	
	private void swap(int[] arr, int i, int j) {
		int temp = arr[i];
		arr[i] = arr[j];
		arr[j] = temp;
	}

   快速排序

    一.  快速排序的基本思想

快速排序使用分治的思想,通过一趟排序将待排序列分割成两部分,其中一部分记录的关键字均比另一部分记录的关键字小。之后分别对这两部分记录继续进行排序,以达到整个序列有序的目的。

二.  快速排序的三个步骤

1) 选择基准:在待排序列中,按照某种方式挑出一个元素,作为 “基准”(pivot);

2) 分割操作:以该基准在序列中的实际位置,把序列分成两个子序列。此时,在基准左边的元素都比该基准小,在基准右边的元素都比基准大;

3) 递归地对两个序列进行快速排序,直到序列为空或者只有一个元素;

三.  选择基准元的方式

对于分治算法,当每次划分时,算法若都能分成两个等长的子序列时,那么分治算法效率会达到最大。也就是说,基准的选择是很重要的。选择基准的方式决定了两个分割后两个子序列的长度,进而对整个算法的效率产生决定性影响。

最理想的方法是,选择的基准恰好能把待排序序列分成两个等长的子序列。

方法一:固定基准元(基本的快速排序)

思想:取序列的第一个或最后一个元素作为基准元。

	//快速排序
	public void  Sort(int[] arr, int l , int r) {
		if(l>=r) return;
		int first=l;
		int last=r;
		int key=arr[l];
		while(first<last) {
			while (last>first && arr[last]>=key)  last--;
			arr[first]=arr[last];
			while (first<last && arr[first]<=key) first++;
			arr[last]=arr[first];
		}
		arr[first]=key;
		Sort(arr,l,first-1);
		Sort(arr, first+1, r);
		
	}

注意:基本的快速排序选取第一个或最后一个元素作为基准。但是,这是一直很不好的处理方法。

测试数据:

测试数据分析:如果输入序列是随机的,处理时间可以接受的。如果数组已经有序时,此时的分割就是一个非常不好的分割。因为每次划分只能使待排序序列减一,此时为最坏情况,快速排序沦为冒泡排序,时间复杂度为Θ(n^2)。而且,输入的数据是有序或部分有序的情况是相当常见的。因此,使用第一个元素作为基准元是非常糟糕的,为了避免这个情况,就引入了下面两个获取基准的方法。

方法二:随机基准元

思想:取待排序列中任意一个元素作为基准元。

引入的原因:在待排序列是部分有序时,固定选取基准元使快排效率底下,要缓解这种情况,就引入了随机选取基准元。

public void  Sort(int[] arr, int l , int r) {
		if(l>=r) return;
		QSortRandom(arr, l, r);
		int first=l;
		int last=r;
		int key=arr[l];
		while(first<last) {
			while (last>first && arr[last]>=key)  last--;
			arr[first]=arr[last];
			while (first<last && arr[first]<=key) first++;
			arr[last]=arr[first];
		}
		arr[first]=key;
		Sort(arr,l,first-1);
		Sort(arr, first+1, r);
		
	}
	//给定一个随机值,然后把随机值和数组的第一个元素交换,转变为以第一个元素为基准的快排
	private void QSortRandom(int[] arr,int l,int r) {
		if(l>=r) return ;
		Random rd=new Random();
		int RandomIndex=Math.abs(rd.nextInt()%(r-l)) + l;
		System.out.println(RandomIndex +" " + r +" " + l);
		swap(arr, RandomIndex, l);
	}
	private void swap(int[] arr, int i, int j) {
		int temp = arr[i];
		arr[i] = arr[j];
		arr[j] = temp;
	}

测试数据:

测试数据分析::这是一种相对安全的策略。由于基准元的位置是随机的,那么产生的分割也不会总是会出现劣质的分割。在整个数组数字全相等时,仍然是最坏情况,时间复杂度是O(n^2)。实际上,随机化快速排序得到理论最坏情况的可能性仅为1/(2^n)。所以随机化快速排序可以对于绝大多数输入数据达到O(nlogn)的期望时间复杂度。一位前辈做出了一个精辟的总结:“随机化快速排序可以满足一个人一辈子的人品需求。”

方法三:三数取中

引入的原因:虽然随机选取基准时,减少出现不好分割的几率,但是还是最坏情况下还是O(n^2),要缓解这种情况,就引入了三数取中选取基准。

分析:最佳的划分是将待排序的序列分成等长的子序列,最佳的状态我们可以使用序列的中间的值,也就是第N/2个数。可是,这很难算出来,并且会明显减慢快速排序的速度。这样的中值的估计可以通过随机选取三个元素并用它们的中值作为基准元而得到。事实上,随机性并没有多大的帮助,因此一般的做法是使用左端、右端和中心位置上的三个元素的中值作为基准元。显然使用三数中值分割法消除了预排序输入的不好情形,并且减少快排大约14%的

比较次数。

举例:待排序序列为:8 1 4 9 6 3 5 2 7 0

左边为:8,右边为0,中间为6

我们这里取三个数排序后,中间那个数作为枢轴,则枢轴为6

注意:在选取中轴值时,可以从由左中右三个中选取扩大到五个元素中或者更多元素中选取,一般的,会有(2t+1)平均分区法(median-of-(2t+1),三平均分区法英文为median-of-three。

具体思想:对待排序序列中low、mid、high三个位置上数据进行排序,取他们中间的那个数据作为基准,并用0下标元素存储基准。

即:采用三数取中,并用0下标元素存储基准。

//将下标为mid,l,r的三个数相比,中间大小的数和数组第一个元素交换位置
转化为取第一个数为基准值的快排	
public void  Sort(int[] arr, int l , int r) {
		if(l>=r) return;
		QSortMedianOfThree(arr, l, r);
		int first=l;
		int last=r;
		int key=arr[l];
		while(first<last) {
			while (last>first && arr[last]>=key)  last--;
			arr[first]=arr[last];
			while (first<last && arr[first]<=key) first++;
			arr[last]=arr[first];
		}
		arr[first]=key;
		Sort(arr,l,first-1);
		Sort(arr, first+1, r);
		
	}
	//三数取中 
	private void QSortMedianOfThree(int[] arr,int l,int r) {
		int mid=l+(r-l)/2;
		if(arr[mid]>arr[r]) swap(arr, r, mid);
		if(arr[l]>arr[r]) swap(arr, l, r);
		if(arr[mid]>arr[l]) swap(arr, l, mid);
	}

测试数据:

测试数据分析:针对随机数组,使用三数取中选择基准+插排,效率还是可以提高一点,真是针对已排序的数组,是没有任何用处的。因为待排序序列是已经有序的,那么每次划分只能使待排序序列减一。此时,插排是发挥不了作用的。所以这里看不到时间的减少。另外,三数取中选择基准+插排还是不能处理重复数组。

堆排序

       堆排序是把数组看作堆,第i个结点的孩子结点为第2*i+1和2*i+2个结点(不超出数组长度前提下),堆排序的第一步是建堆,然后是取堆顶元素然后调整堆。建堆的过程是自底向上不断调整达成的,这样当调整某个结点时,其左节点和右结点已经是满足条件的,此时如果两个子结点不需要动,则整个子树不需要动,如果调整,则父结点交换到子结点位置,再以此结点继续调整。

      下述代码使用的大顶堆,建立好堆后堆顶元素为最大值,此时取堆顶元素即使堆顶元素和最后一个元素交换,最大的元素处于数组最后,此时调整小了一个长度的堆,然后再取堆顶和倒数第二个元素交换,依次类推,完成数据的非递减排序。

      堆排序的主要时间花在初始建堆期间,建好堆后,堆这种数据结构以及它奇妙的特征,使得找到数列中最大的数字这样的操作只需要O(1)的时间复杂度,维护需要logn的时间复杂度。堆排序不适宜于记录数较少的文件

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值