排序及选择算法的java实现(一)选择、冒泡、插入、希尔、归并、快排

如果您发现内容有误,请在下面留言告诉我,我会改正的~~


(除非特殊说明,以下排序默认是指从小到大排序)
##最简单的三种排序:选择、冒泡、插入
三种算法基本思想和实现都差不多,有时容易弄混。都是通过两重循环依次比较确定顺序。下面的表格展示了他们的相同点和区别:

算法相同点不同点
插入算法通过两重循环逐个比对确定顺序,时间复杂度是O(n2)每次只在已排序部分的基础上增加一个未排序数并将它放在适当的位置上,所以每一趟未必能把未排序部分的最小值换在最前面(比较直观的例子:第一趟排完后最小值不一定在数组头),但排好的部分一定是从小到大的。另外一点是当发现比待插入的数小的数时就会跳出内部循环。在n比较小时表现较好。
冒泡算法同上每一趟都会把未排序部分的最小值换到未排序部分的最前面(同时剩下的部分也会有一定程度的排序),第一趟一定会把最小值换到数组头(或把最大值换到数组尾)。应该是最糟糕的排序算法了。
选择算法同上每次只交换未排序部分的最小值或最大值,其它部分不变,第一趟一定会把最小值换到数组头。

下面是java实现,其中swap(int arr[],int a,int b)函数表示把arr数组中下标为a和下标为b的两个数互换:

  • 直接插入排序:
public static int[] insertSort(int arr[], int begin, int end) {

	int array[] = arr.clone();
	for (int i = begin + 1; i < end; i++) {
		for (int j = i; j > begin; j--) {
			if (array[j] > array[j - 1])
				break;
			swap(array, j, j - 1);
		}
	}
	return array;
}
  • 冒泡排序:
public static int[] bubbleSort(int arr[], int begin, int end) {

	int array[] = arr.clone();
	for (int i = begin; i < end; i++) {
		for (int j = end - 1; j > begin; j--) {
			if (array[j] < array[j - 1]) {
				swap(array, j, j - 1);
			}
		}
	}
	return array;
}
  • 选择排序:
public static int[] selectionSort(int arr[], int begin, int end) {

		int array[] = arr.clone();
		for (int i = begin; i < end; i++) {
			int minIndex = i;
			for (int j = i + 1; j < end; j++) {
				if (array[minIndex] > array[j]) {
					minIndex = j;
				}
			}
			swap(array, minIndex, i);
		}
		return array;
	}

其中插入排序还有一种叫做折半插入排序的变种,就是在查找插入位置时采用折半查找法而不是从后向前遍历。这种方法能有限的提高算法的效率。

我个人是把上面这三种算法当成一种算法思想的不同实现来看的。其中,插入排序应该是最有潜力的,下面的希尔(Shell)排序就是从插入排序改进来的。

插入排序的改进:希尔(Shell)排序

插入排序有个特点,待排序的数组乱序程度越低,算法的时间复杂度越低。最优情况下可以达到O(n),即数组已经是顺序的情况下,算法只是遍历一遍数组,确定每个数的前一个数一定更小。而冒泡和选择算法最优情况下仍然是O(n2) 。

基于这个特点,D.L.Shell发明了Shell排序(也称缩小增量排序,diminishin increment sort)。

Shell排序的思路是:将待排序数组分成若干份,每一份都是一个子序列,对每一个子序列进行排序,然后将已经稍微有序的数组分成更少更长的子序列,进行排序,直到最后对整个数组进行排序。

举个例子:有二十个数需要排序,先将数组分成十份,判断第一个数和第十一个数的大小,小的放在第一位上,再判断第二个和第十二个,依此类推。第一遍排完后,再将数组分成五份,先判断第一、第六、第十一、第十六位上的数,再判断第二、第七、第十二、第十七位上的数。这样一直下去,最后一次就是对已经大体上排好序的整个数组进行一次排序。以上全部使用插入排序。

Shell排序不关心每一份中的数相隔多远,只要每次排序后数组的整体有序度在增加,到最后一遍排序之前基本有序,就能有效的减少排序时间。

分析Shell排序的时间复杂度是一件比较困难的事,只能说它真的比O(n2)快,但是快多少取决于怎么将数组分成合理的份数。如果按照”每次除以2“(2k,2k-1, … ,4,2,1)份来分的话是没有多大效果的,但如果按照”每次除以三“(… ,121,40,13,4,1)份来分的话,时间复杂度会降到O(n1.5)。

下面是代码实现:

public static int[] shellSort(int arr[], int left, int right, int n) {

	assert right > left;
	int array[] = arr.clone();
	int length = right - left;
	if (length < n) {
		return insertSort(array, left, right);
	}
	for (int begin = left, step = length / n; step > 0; step /= n, begin = left) {
		for (; begin < left + step; begin++) {
			for (int i = begin + step; i < right; i += step) {
				for (int j = i; j > begin; j -= step) {
					if (array[j] >= array[j - step])
						break;
					swap(array, j, j - step);
				}
			}
		}
		if (step < n && step > 1)
			step = n;
	}
	return array;
}

代码中有四层循环,却比上面三种排序更快,看起来是不是有点奇怪?反正它就是快…

其中,第一层循环是用来控制步长,即同一子序列中数的间隔的,同时用来重置偏移量。不同偏移量对应不同的子序列。第二层用来切换不同子序列,对所有子序列进行一次遍历。里面的两层循环都是对当前子序列进行排序操作,与插入排序没有实质区别,只有一点不同:普通插入排序是每次加一,一个一个排,这里因为子序列元素不挨着,所以是跳着排的,每次加一个step。

如果,你不用上面那种划分子序列的方法,而是第一第二、第三第四这样来划分,你会发现,它有点像归并排序。

归并排序

归并排序是一个递归排序算法,基本思想是分治法,即将数组分成只有一个元素的子序列,然后通过两两合并子序列的方式实现排序。合并时的规则如下:比较两个数组的第一个元素,输出较小的那个,然后再比较剩下的部分,直到两个数组全都被输出,即完成一次归并。当n个子数组被合并成n/2个数组后,再进行下一轮归并,直到最后归并成一个数组。

比如下面是一个归并的例子:
划 分 数 组:1|5|3|2|7|9|6|8|3|5|7|4|
第一次归并:1 5|2 3|7 9|6 8|3 5|4 7
第二次归并:1 2 3 5|6 7 8 9|3 4 5 7
第三次归并:1 2 3 5 6 7 8 9|3 4 5 7
第四次归并:1 2 3 3 4 5 5 6 7 7 8 9

上面12个数进行了4次归并,即logn次(log12约等于3.58,因为不能进行0.58次排序,所以认为是4次),每次都对n个数进行操作,所以时间复杂度是O(nlogn)。

归并在对链表操作时是很方便的,但在对数组操作时就有点困难,最困难的地方在于每次归并都需要额外的空间放置归并后的结果,应当避免每次归并都使用一个新的数组,否则时间复杂度和空间复杂度都有可能大幅增加,申请两倍原数组的空间交替轮换排序会是比较好的选择。

R.Sedgewick发明了一个优化归并排序的方法。就是将要归并的第二个数组反过来,这样在向原数组归并时就不必判断是否有某一个子数组被处理完的情况。
看代码比较容易理解一些:

public static int[] mergeSort(int arr[], int begin, int end, int temp[]) {

	int length = end - begin;
	if (length < 10)
		return arr = insertSort(arr, begin, end);
	if (length < 2)
		return arr;
	int mid = (end + begin) >> 1;
	mergeSort(arr, begin, mid, temp);
	mergeSort(arr, mid, end, temp);
	// 数组左右已经各自有序
	for (int i = begin; i < mid; i++)
		//因为返回的是原数组,所以总是从额外的数组向原数组归并,这样方便一些,不用判断最终排好的数组到底在哪
		temp[i] = arr[i];
	for (int i = mid; i < end; i++)
		temp[end + mid - i - 1] = arr[i];
	for (int i = begin, j = end - 1, k = begin; k < end; k++) {
		if (temp[i] < temp[j])
			arr[k] = temp[i++];
		else
			arr[k] = temp[j--];
	}
	return arr;
}

我自己尝试了一种不使用额外空间的实现方法,但实际上这会大幅度的增加时间复杂度,并且退化回上面更慢的几种算法思想之中:

public static int[] mergeSort(int arr[], int begin, int end) {

	int length = end - begin;
	if (length < 10)
		return insertSort(arr, begin, end);
	if (length < 2)
		return arr;
	int mid = (end + begin) >> 1;
	mergeSort(arr, begin, mid);
	mergeSort(arr, mid, end);
	for (int i = begin; i < mid; i++) {
		//对前半段数组排序
		if (arr[i] > arr[mid]) {
			swap(arr, i, mid);
			//对后半段数组排序
			for (int j = mid + 1; j < arr.length; j++) {
				if (arr[j] > arr[j - 1])
					break;
				swap(arr, j, j - 1);
			}
		}
	}
	return arr;
}

这段代码模拟使用额外数组进行归并的操作,为此需要做一些额外的操作:当前半段的数交换到后半段的时候,需要将交换来的数插入到合适的位置,以保证后半段仍然是有序的,当前半段排好后,由于较小数都交换到前面去了,后半段自然比前半段大,整个数组就排好了。

其实这与shell排序有些相似,当shell排序不再以大于一的间隔进行子序列的划分而是连续划分 —— 比如第一、第二为一组,第三第四为一组这样 —— 你会很明显地发现它合并子序列的过程实际上就是归并。

这种基于分治法的思想实际上是很有效的,动态规划、贪心、回溯法等等都与分治法有密切联系。而下面这个则是另一个基于分治法思想的很有效的排序算法。

快速排序

快排是以上所有排序算法加上堆排序之中平均情况下最快的排序算法(O(logn))。但它的最差时间复杂度却可以达到O(n2)。

这个算法的重点在于每次递归时都需要选择一个轴值,这个轴值理论上可以是任意一个存在于数组中的值。按照所有比轴值小的数都放在轴值左边,所有比轴值大的数都放在轴值右边的方法遍历一遍数组。这时候数组就会被分成两份,可能一样长,可能不一样长,取决于轴值的大小。再对两个子数组分别进行一次上述操作,直到每一个轴值的左右分别最多只有一个数时,整个数组就排好序了。值得注意的是,快排不在乎左右子数组中的数是否是乱序的,只要子数组里所有的数都比轴值小,右子数组所有的数都比轴值大(即左边一定比右边小)就可以了。当左右都只有零个或一个数时,是不可能出现”乱序“这种情况的。

快排的思路和归并刚好是反着的,归并是先在最基本最深入的层次排序,在不断向上归并,而快排则是先在最顶层按大小分成两边,再在两边递归,直到最底层为止。

这个算法的最差时间复杂度O(n2)的来历是:当你运气非常差,每次选的轴值都是最大值或最小值时,数组会变成一边是空的,另一边只少了轴值这一个数而已,相当于每次遍历都只排好了一个数。反之,当每次选中的轴值都是中位数时,算法的效率最好。

为了避免最差情况这种悲催的事情出现,选择一个合适的轴值就变得非常必要了。一种比较简单,效果又比较好的方法是”三者取中法“,即随机取三个数,将三个数的中位数当成轴值,一般是取当前子数组中第一个、中间的和最后一个数值。

另外一点是,当n很小时,快排是很慢的,所以可以考虑使用插入排序或者选择排序进行优化。优化有两种方式:第一种是当左右子数组小于一定值的时候使用插入或选择直接进行排序,第二种是当左右子数组小于一定至的时候直接返回,因为此时整个数组已经大致有序,非常适合使用插入排序(参考Shell排序)。第二种方式往往更有效。

下面是基于第二种优化方式的代码实现,缺点是调用的函数比较多:

public static int[] quikSort(int arr[], int begin, int end) {

	sort(arr, begin, end);
	insertSort(arr, begin, end);
	return arr;
}

private static int[] sort(int[] arr, int begin, int end) {

	int length = end - begin;
	if (length < 10)
		return arr;
	int mid = length >> 1;
	swap(arr, findpivot(arr, begin, end - 1, mid), begin);
	partition(arr, begin, end);
	sort(arr, begin, mid);
	sort(arr, mid, end);
	return arr;
}

private static int findpivot(int arr[], int a, int b, int c) {

	if (arr[a] < arr[b]) {
		if (arr[c] < arr[a])
			return arr[a];
		else if (arr[b] < arr[c])
			return arr[b];
		else
			return arr[c];
	} else {
		if (arr[b] > arr[c])
			return arr[b];
		else if (arr[a] < arr[c])
			return arr[a];
		else
			return arr[c];
	}
}

private static void partition(int[] arr, int begin, int end) {

	int left = begin, right = end;
	do {
		while (arr[++left] <= arr[begin])
			;
		while (arr[--right] <= arr[begin])
			;
		swap(arr, left, right);
	} while (left < right);
	swap(arr, left, right);
	swap(arr, begin, right);
}

上面这六种排序算法其实都可以认为是一棵二叉树,每一次比较都对应一个树的内部节点,叶节点是数组元素。具有n个叶节点的二叉树最小深度是logn,所以最差时间复杂度是O(nlogn)。实际上所有基于比较的算法都可以归结为一棵二叉树,这就决定了它们的最差时间复杂度都不可能低于O(nlogn)。


参考:

数据结构与算法分析(C++版)(第二版)

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值