排序 之 快速排序
Posted 人生如逆旅,我亦是行人。
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了排序 之 快速排序相关的知识,希望对你有一定的参考价值。
参考文档
快排的递归实现&非递归实现:http://blog.csdn.net/mine_song/article/details/64121553
三种快排及四种优化方式:http://blog.csdn.net/hacker00011000/article/details/52176100
尾递归:https://www.cnblogs.com/babybluevino/p/3714022.html
迭代,循环,遍历,递归的区别:https://www.cnblogs.com/feichengwulai/articles/3642107.html
递归为什么效率差:http://blog.csdn.net/qq_33797186/article/details/50766050
原理
快速排序使用分治的思想,通过一趟排序将待排序列分割成两部分,其中一部分记录的关键字均比另一部分记录的关键字小。之后分别对这两部分记录继续进行排序,以达到整个序列有序的目的。
举个栗子
原数组:{3,7,2,9,1,4,6,8,10,5}
期望结果:{1,2,3,4,5,6,7,8,9,10}
递归实现:
public static void quickSort(int[] numbers, int start, int end) { if (start < end) { int base = numbers[end]; // 选定的基准值(最后一个数值作为基准值) int temp; // 记录临时中间值 int i = start, j = end; do { while ((numbers[i] < base) && (i < end)) i++; while ((numbers[j] > base) && (j > start)) j--; if (i <= j) { temp = numbers[i]; numbers[i] = numbers[j]; numbers[j] = temp; i++; j--; } } while (i <= j); if (start < j) quickSort(numbers, start, j); if (end > i) quickSort(numbers, i, end); } }
非递归实现:
// start和end为前闭后闭 private static void nonRec_quickSort(int[] a, int start, int end) { // 用栈模拟 Stack<Integer> stack = new Stack<>(); if (start < end) { stack.push(end); stack.push(start); while (!stack.isEmpty()) { int l = stack.pop(); int r = stack.pop(); int index = partition(a, l, r); if (l < index - 1) { stack.push(index - 1); stack.push(l); } if (r > index + 1) { stack.push(r); stack.push(index + 1); } } } System.out.println(Arrays.toString(a)); } private static int partition(int[] a, int start, int end) { int pivot = a[start]; while (start < end) { while (start < end && a[end] >= pivot) end--; a[start] = a[end]; while (start < end && a[start] <= pivot) start++; a[end] = a[start]; } a[start] = pivot; return start; }
算法分析:
快速排序的时间主要耗费在划分操作上,对长度为k的区间进行划分,共需k-1次关键字的比较。
1) 最坏时间复杂度
最坏情况是每次划分选取的基准都是当前无序区中关键字最小(或最大)的记录,划分的结果是基准左边的子区间为空(或右边的子区间为空),而划分所得的另一个非空的子区间中记录数目,仅仅比划分前的无序区中记录个数减少一个。因此,快速排序必须做n-1次划分,第i次划分开始时区间长度为n-i+1,所需的比较次数为n-i(1≤i≤n-1),故总的比较次数达到最大值:Cmax= n(n-1)/2=O(n2)
2) 最好时间复杂度
在最好情况下,每次划分所取的基准都是当前无序区的"中值"记录,划分的结果是基准的左、右两个无序子区间的长度大致相等。总的关键字比较次数:0(nlgn)
注意:用递归树来分析最好情况下的比较次数更简单。因为每次划分后左、右子区间长度大致相等,故递归树的高度为O(lgn),而递归树每一层上各结点所对应的划分过程中所需要的关键字比较次数总和不超过n,故整个排序过程所需要的关键字比较总次数C(n)=O(nlgn)。
因为快速排序的记录移动次数不大于比较的次数,所以快速排序的最坏时间复杂度应为0(n2),最好时间复杂度为O(nlgn)。
3) 空间复杂度
快速排序在系统内部需要一个栈来实现递归。若每次划分较为均匀,则其递归树的高度为O(lgn),故递归后需栈空间为O(lgn)。最坏情况下,递归树的高度为O(n),所需的栈空间为O(n)。
4) 稳定性
快速排序是非稳定的。
三种选择基准的方法
对于分治算法,当每次划分时,算法若都能分成两个等长的子序列时,那么分治算法效率会达到最大。也就是说,基准的选择是很重要的。选择基准的方式决定了两个分割后两个子序列的长度,进而对整个算法的效率产生决定性影响。
- 固定位置:取序列的第一个或最后一个元素作为基准
- 随机选取基准
- 三数取中(median-of-three)
最佳的划分是将待排序的序列分成等长的子序列,最佳的状态我们可以使用序列的中间的值,也就是第N/2个数。可是,这很难算出来,并且会明显减慢快速排序的速度。这样的中值的估计可以通过随机选取三个元素并用它们的中值作为枢纽元而得到。事实上,随机性并没有多大的帮助,因此一般的做法是使用左端、右端和中心位置上的三个元素的中值作为枢纽元。显然使用三数中值分割法消除了预排序输入的不好情形,并且减少快排大约14%的比较次数
三种优化方式
- 当待排序序列的长度分割到一定大小后,使用插入排序
对于很小和部分有序的数组,快排不如插排好。当待排序序列的长度分割到一定大小后,继续分割的效率比插入排序要差,此时可以使用插排而不是快排
- 在一次分割结束后,可以把与Key相等的元素聚在一起,继续下次分割时,不用再对与key相等元素分割
- 尾递归优化
尾递归就是函数返回之前的最后一个操作是递归调用。就是:将单次计算的结果缓存起来,传递给下次调用,相当于自动累积。
-
- 尾递归为什么会节省栈的空间呢?
我们知道递归调用是通过栈来实现的,每调用一次函数,系统都将函数当前的变量、返回地址等信息保存为一个栈帧压入到栈中,那么一旦要处理的运算很大或者数据很多,有可能会导致很多函数调用或者很大的栈帧,这样不断的压栈,很容易导致栈的溢出。
我们回过头看一下尾递归,函数在递归调用之前已经把所有的计算任务已经完毕了,他只要把得到的结果全交给子函数就可以了,无需保存什么,子函数其实可以不需要再去创建一个栈帧,直接把就着当前栈帧,把原先的数据覆盖即可。
需要注意的是:在Java、C#等语言中,尾递归使用非常少见,一方面我们可以直接用循环解决,另一方面这几种语言的编译器也不会自动优化尾递归。而在函数式语言中,尾递归却是一种神器,要实现循环就靠它了
尾递归优化,相当于编译器优化成了循环的方式实现!
递归 vs 循环
- 递归算法:
优点:代码简洁、清晰,可读性很好
缺点:它的运行需要较多次数的函数调用,如果调用层数比较深,需要增加额外的堆栈处理(还有可能出现堆栈溢出的情况),比如参数传递需要压栈等操作,导致效率较低。
递归为什么容易内存溢出?:在Java中, JVM中的栈记录了线程的方法调用。每个线程拥有一个栈。在某个线程的运行过程中, 如果有新的方法调用,那么该线程对应的栈就会增加一个存储单元,即栈帧 (frame)。在frame 中,保存有该方法调用的参数、局部变量和返回地址。Java的参数和局部变量只能是 基本类型 的变量(比如 int),或者对象的引用(reference) 。在栈中,只保存有基本类型的变量和对象引用。而引用所指向的对象保存在堆中。栈的初始化大小比较小。如果递归调用深度过深,容易导致栈溢出。
- 循环算法:
优点:效率高。比递归方法快, 因为循环避免了一系列函数调用和返回中所涉及到的参数传递和返回值的额外开销
缺点:不容易理解,编写复杂问题时困难。并不能解决所有的问题。有的问题适合使用递归而不是循环
以上是关于排序 之 快速排序的主要内容,如果未能解决你的问题,请参考以下文章