什么是快速排序?
摘自漫画算法:
同冒泡排序一样,快速排序也属于交换排序,通过元素之间的比较和交换位置来达到排序的目的。
不同的是,冒泡排序在每一轮中只把1个元素冒泡到数列的一端,而快速排序则在每一轮挑选一个基准元素,并让其他比它大的元素移动到数列的一端,比它小的元素移动到数列的另一端,从而把数列拆解成两个部分。
这种思路就叫做分治法。
每次把数列分成两部分,究竟有什么好处呢?
假如给出一个8个元素的数列,一般情况下,使用冒泡排序需要比较7轮,每一轮把1个元素移动到数列的一端,时间复杂度为O(n²)。
而快速排序的流程是什么样子呢?
如上图所示,在分治法的思想下,原数列的每一轮都被拆分成两部分,每一部分在下一轮又分别被拆分成两部分,知道不可再分为止。
每一轮的比较和交换,需要把数组全部元素都遍历一遍,时间复杂度为O(n)。这样的遍历一共需要多少轮呢?假如元素个数是n,那么平均情况下需要logn轮,因此快速排序算法总体的平均时间复杂度为O(nlogn)。
基准元素的选择
基准元素,英文是pivot,在分治过程中,以基准元素为中心,把其他元素移动到它的左右两边。
那么如何选择基准元素呢?
最简单的方式是选择数列的第一个元素。
这种选择在绝大多数情况下是没有问题的。但是假如有一个原本逆序的数列,期望排序成顺序数列,那么会出现什么情况呢?
如上图所示,整个数列并没有被分成两个部分,每一轮都只确定了基准元素的位置。在这种情况下,数列的第1个元素;要么是最小值,要么是最大值,根本无法发挥分治法的优势。在这种极端的情况下,快速排序需要进行n轮,时间复杂度退化成了O(n²)。
那么,该怎么避免这种情况发生呢?
其实很简单,我们可以随机选择一个元素作为基准元素,并且让基准元素和数列首元素交换位置。
这样一来,即使在数列完全逆序的情况下,也可以有效地将数列分成两部分。
当然,即使是随机选择基准元素,也会有极小的几率选到数列的最大值或最小值,同样会影响分治的效果。
所以,虽然快速排序的平均时间复杂度是O(nlogn),但最坏情况下的时间复杂度是O(n²)。
在后文中,为了简化步骤,省去了随机选择基准元素的过程,直接把首元素作为基准元素。
快速排序的实现
递归实现
选定了基准元素以后,我们要做的就是把其他元素中小于基准元素的都交换到基准元素一边,大于基准元素的都交换到基准元素的另一边。
具体如何实现呢?有两种方法。
-
双边循环法
何谓双边循环法?下面来看一看详细过程。
给出原始数列如下,要求对其从小到大进行排序。
首先,选定基准元素pivot,并且设置两个指针left和right,指向数列的最左和最右两个元素。
接下来进行第1次循环,从right指针开始,让指针所指向的元素和基准元素做比较。如果大于或等于pivot,则指针向左移动,如果小于pivot,则right指针停止移动,切换到left指针。
在当前数列中,1小于4,所以right直接停止移动,换到left指针,进行下一步行动。
轮到left指针行动,让指针所指向的元素和基准元素进行比较。如果小于或等于pivot,则指针向右移动,如果大于pivot,则left指针停止移动。
由于left开始指向的是基准元素,判断肯定相等,所以left右移1位。
由于7大于4,left指针在元素7的位置停下。这时,让left和right指针所指向的元素进行交换。
接下来,进入第2次循环,重新切换到right指针,向左移动。right指针先移动到8,8大于4,继续左移。由于2小于4,停止在2的位置。
按照这个思路,后续步骤如图所示:
整体代码如下(使用递归的方式):
import java.util.Arrays; /** * Create By ZhangBiao * 2020/5/22 */ public class Sort { /** * 快速排序 * * @param arr * @param startIndex * @param endIndex */ public static void quickSort(int[] arr, int startIndex, int endIndex) { // 递归结束条件:startIndex大于或等于endIndex时 if (startIndex >= endIndex) { return; } // 得到基准元素位置 int pivotIndex = partition(arr, startIndex, endIndex); // 根据基准元素,分成两部分进行递归排序 quickSort(arr, startIndex, pivotIndex - 1); quickSort(arr, startIndex + 1, endIndex); } /** * 分治法(双边循环法) * * @param arr 待交换的数组 * @param startIndex 起始下标 * @param endIndex 结束下标 * @return */ private static int partition(int[] arr, int startIndex, int endIndex) { // 取第1个位置(也可以选择随机位置)的元素作为基准元素 int pivot = arr[startIndex]; int left = startIndex; int right = endIndex; while (left != right) { // 控制right指针比较并左移 while (left < right && arr[right] > pivot) { right--; } // 控制left指针比较并右移 while (left < right && arr[left] <= pivot) { left++; } // 交换left和right指针所指向的元素 if (left < right) { int p = arr[left]; arr[left] = arr[right]; arr[right] = p; } } // pivot和指针重合点交换 arr[startIndex] = arr[left]; arr[left] = pivot; return left; } public static void main(String[] args) { int[] arr = new int[]{4, 7, 6, 5, 3, 2, 8, 1}; quickSort(arr, 0, arr.length - 1); System.out.println(Arrays.toString(arr)); } }
在上述代码中,quickSort方法通过递归的方式,实现了分而治之的思想。
partition方法则实现了元素的交换,让数列中的元素依据自身大小,分别交换到基准元素的左右两边。在这里,我们使用的交换方式是双边循环法。
-
单边循环法
双边循环法从数组的两边交替遍历元素,虽然更加直观,但是代码实现相对繁琐。而单边循环法则简单的多,只从数组的一边对元素进行遍历和交换。我们来看一看详细过程。
给出原始数列如下,要求对其从小到大进行排序。
开始和双边循环法相似,首先选定基准元素pivot。同时,设置一个mark指针指向数列起始位置,这个mark指针代表小于基准元素的区域边界。
接下来,从基准元素的下一个位置开始遍历数组。
如果遍历到的元素大于基准元素,就继续往后遍历。
如果遍历到的元素小于基准元素,则需要做两件事:第一,把mark指针右移1位,因为小于pivot的区域边界增大了1;第二,让最新遍历到的元素和mark指针所在位置的元素交换位置,因为最新遍历的元素归属于小于pivot的区域。
首先遍历到元素7,由于7大于4,所以继续遍历。
接下来遍历到的元素是3,由于3小于4,所以mark指针右移1位。
随后,让元素3和mark指针所在位置的元素进行交换,因为元素3归属于小于pivot的区域。
按照这个思路,继续遍历,后续步骤如图所示:
整体代码如下:
import java.util.Arrays; /** * Create By ZhangBiao * 2020/5/22 */ public class Sort { /** * 快速排序 * * @param arr * @param startIndex * @param endIndex */ public static void quickSort(int[] arr, int startIndex, int endIndex) { // 递归结束的条件:startIndex >= endIndex if (startIndex >= endIndex) { return; } // 得到基准元素位置 int pivotIndex = partition(arr, startIndex, endIndex); // 根据基准元素,分成两部分进行递归排序 quickSort(arr, startIndex, pivotIndex - 1); quickSort(arr, pivotIndex + 1, endIndex); } /** * 分治法(单边循环法) * * @param arr 待交换的数组 * @param startIndex 起始下标 * @param endIndex 结束下标 * @return */ private static int partition(int[] arr, int startIndex, int endIndex) { // 取第1个位置(也可以选择随机位置)的元素作为基准元素 int pivot = arr[startIndex]; int mark = startIndex; for (int i = startIndex + 1; i <= endIndex; i++) { if (arr[i] < pivot) { mark++; int p = arr[mark]; arr[mark] = arr[i]; arr[i] = p; } } arr[startIndex] = arr[mark]; arr[mark] = pivot; return mark; } public static void main(String[] args) { int[] arr = new int[]{4, 7, 3, 5, 6, 2, 8, 1}; quickSort(arr, 0, arr.length - 1); System.out.println(Arrays.toString(arr)); } }
可以很明显的看出,partition方法只要一个大循环就搞定了,的确比双边循环法简单多了。
非递归实现
注意:绝大多数的递归逻辑,都可以用栈的方式来代替。
为什么这样说呢?
在方法中一层一层的调用方法,本身就使用了一个方法调用栈。每次进入一个新方法,就相当于入栈;每次有方法返回,将相当于出栈,所以,可以把原本的递归实现转化成一个栈的实现,在栈中存储每一次方法调用的参数。
整体代码如下:
import java.util.Arrays;
import java.util.HashMap;
import java.util.Map;
import java.util.Stack;
/**
* Create By ZhangBiao
* 2020/5/22
*/
public class Sort {
/**
* 快速排序
*
* @param arr
* @param startIndex
* @param endIndex
*/
public static void quickSort(int[] arr, int startIndex, int endIndex) {
// 用一个集合栈来代替递归的函数栈
Stack<Map<String, Integer>> quickSortStack = new Stack<>();
// 整个数列的起止下标,以哈希的形式入栈
HashMap<String, Integer> rootParam = new HashMap<>();
rootParam.put("startIndex", startIndex);
rootParam.put("endIndex", endIndex);
quickSortStack.push(rootParam);
// 循环结束条件:栈为空时
while (!quickSortStack.isEmpty()) {
// 栈顶元素出栈,得到起止下标
Map<String, Integer> param = quickSortStack.pop();
// 得到基准元素位置
int pivotIndex = partition(arr, param.get("startIndex"), param.get("endIndex"));
//根据基准元素分成两部分,把每一部分的起止下标入栈
if (param.get("startIndex") < pivotIndex - 1) {
HashMap<String, Integer> leftParam = new HashMap<>();
leftParam.put("startIndex", param.get("startIndex"));
leftParam.put("endIndex", pivotIndex - 1);
quickSortStack.push(leftParam);
}
if (pivotIndex + 1 < param.get("endIndex")) {
HashMap<String, Integer> rightParam = new HashMap<>();
rightParam.put("startIndex", pivotIndex + 1);
rightParam.put("endIndex", param.get("endIndex"));
quickSortStack.push(rightParam);
}
}
}
/**
* 分治法(单边循环法)
*
* @param arr 待交换的数组
* @param startIndex 起始下标
* @param endIndex 结束下标
* @return
*/
private static int partition(int[] arr, int startIndex, int endIndex) {
// 取第1个位置(也可以选择随机位置)的元素作为基准元素
int pivot = arr[startIndex];
int mark = startIndex;
for (int i = startIndex + 1; i <= endIndex; i++) {
if (arr[i] < pivot) {
mark++;
int p = arr[mark];
arr[mark] = arr[i];
arr[i] = p;
}
}
arr[startIndex] = arr[mark];
arr[mark] = pivot;
return mark;
}
public static void main(String[] args) {
int[] arr = new int[]{4, 7, 3, 5, 6, 2, 8, 1};
quickSort(arr, 0, arr.length - 1);
System.out.println(Arrays.toString(arr));
}
}
和递归实现相比,非递归方式代码的变动只发生在quickSort方法中。该方法引入了一个存储Map类型元素的栈,用于存储每一次交换时的起始下标和结束下标。
每一次循环,都会让栈顶元素出栈,通过partition方法进行分治,并且按照基准元素的位置分成左右两部分,左右两部分再分别入栈。当栈为空时,说明排序已经完毕,退出循环。