主要从以下几个方面讲解:
1 动图了解
2 原理讲解
3 递归的实现
4 非递归的实现
动画(双边循环法)来源与五分钟学算法公众号:
详解
同冒泡排序一样,快速排序也属于交换排序,通过元素之间的比较和交换位置来达到排序的目的。
不同的是,冒泡排序在每一轮只把一个元素冒泡到数列的一端,而快速排序在每一轮挑选一个基准元素,并让其他比它大的元素移动到数列一边,比它小的元素移动到数列的另一边,从而把数列拆解成了两个部分。
这种思路就叫做分治法。
每次把数列分成两部分,究竟有什么好处呢?
假如给定8个元素的数列,一般情况下冒泡排序需要比较8轮,每轮把一个元素移动到数列一端,时间复杂度是O(n^2)。
而快速排序的流程是什么样子呢?
如图所示,在分治法的思想下,原数列在每一轮被拆分成两部分,每一部分在下一轮又分别被拆分成两部分,直到不可再分为止。
这样一共需要多少轮呢?平均情况下需要logn轮,因此快速排序算法的平均时间复杂度是 O(nlogn)。
基准元素的选择
基准元素,英文pivot,用于在分治过程中以此为中心,把其他元素移动到基准元素的左右两边。
那么基准元素如何选择呢?
最简单的方式是选择数列的第一个元素:
这种选择在绝大多数情况是没有问题的。但是,假如有一个原本逆序的数列,期望排序成顺序数列,那么会出现什么情况呢?
时,整个数列没有被分成两列,每一轮都只确定了基准元素的位置。
在这种情况下,快速排序需要进行n轮,时间复杂度退化为O(n^2)。
所以,虽然快速排序的平均时间复杂度是0(logn),但最坏情况下的时间复杂度我O(n^2),即上面的这种情况。
双边循环法(递归方式)
思想:
给定原始数列如下,要求从小到大排序:
我们首先选定基准元素Pivot,并且设置两个指针left和right,指向数列的最左和最右两个元素:
接下来是第一次循环,从right指针开始,把指针所指向的元素和基准元素做比较。如果大于等于pivot,则指针向左移动;如果小于pivot,则right指针停止移动,切换到left指针。
在当前数列中,1<4,所以right直接停止移动,换到left指针,进行下一步行动。
轮到left指针行动,把指针所指向的元素和基准元素做比较。如果小于等于pivot,则指针向右移动;如果大于pivot,则left指针停止移动。
由于left一开始指向的是基准元素,判断肯定相等,所以left右移一位。
由于7 > 4,left指针在元素7的位置停下。这时候,我们让left和right指向的元素进行交换。
接下来,我们进入第二次循环,重新切换到right向左移动。right先移动到8,8>4,继续左移。由于2<4,停止在2的位置。
切换到left,6>4,停止在6的位置。
元素6和2交换。
进入第三次循环,right移动到元素3停止,left移动到元素5停止。
元素5和3交换。
进入第四次循环,right移动到元素3停止,这时候请注意,left和right指针已经重合在了一起。
当left和right指针重合之时,我们让pivot元素和left与right重合点的元素进行交换。此时数列左边的元素都小于4,数列右边的元素都大于4,这一轮交换终告结束。
代码:
/**
* 获取基准元素
* 注意: 一定要先从右边开始
* @param array
* @param startPoint
* @param endPoint
* @return
*/
private static int partition(int[] array, int startPoint, int endPoint) {
// 1 获取第一个元素作为基准元素
int baseValue = array[startPoint];
// 2 设置左右指针
int leftPoint = startPoint;
int rightPoint = endPoint;
while (leftPoint != rightPoint) {
// 3 右指针向左移动的情况
while (leftPoint < rightPoint && array[rightPoint] > baseValue) {
rightPoint--;
}
// 4 左指针向右移动的情况
while (leftPoint < rightPoint && array[leftPoint] <= baseValue) {
leftPoint++;
}
// 5 交换left和right指针所指向的元素
if (leftPoint < rightPoint) {
int temp = array[leftPoint];
array[leftPoint] = array[rightPoint];
array[rightPoint] = temp;
}
}
// 6 最终左右指针重合出,则是我们的基准元素的位置,而我们一开始,我们的基准元素就取第一个位置
// 这里的array[leftPoint],也可以换为array[rightPoint],因为此时左右指针是重合状态
array[startPoint] = array[leftPoint];
array[leftPoint] = baseValue;
return leftPoint;
}
/**
* 递归方式实现
* @param array
* @param startPoint
* @param endPoint
*/
public static void quickSore(int[] array, int startPoint, int endPoint) {
// 1 如果开始的位置大于结束的位置,跳出循环
if (startPoint >= endPoint) {
return ;
}
// 2 获取基准元素
int basePoint = partition(array, startPoint, endPoint);
// 3 将基准元素左边再进行快排
quickSore(array, startPoint, basePoint - 1);
// 4 将基准元素右边进行快排
quickSore(array, basePoint + 1, endPoint);
}
public static void main(String[] args) {
int[] array = new int[]{4, 7, 6, 5, 3, 2, 8, 1};
quickSore(array, 0, array.length - 1);
System.out.print(Arrays.toString(array));
}
单边循环法(递归方式)
单边循环法,则主要是多了一个指针mark,用来表示小于基准元素的区域边界。
开始和双边循环相似,首先选取基准元素pivot,同时,设置一个mark指针指向数列其实位置,这个mark指针代表小于基准元素的区域边界。
从基准元素的下一个元素开始遍历:
如果遍历到的元素大于基准元素,就继续往后遍历。
如果遍历到的元素小于基准元素,则需要做两件事:
第一: 把mark指针右移1位,因为小于pivot(基准元素)的区域变大了1;
第二: 让最新遍历到的元素和mark指针所在的位置的元素交换位置,因为最新遍历的元素归属与小于pivot(基准元素)的区域。
首先遍历到元素7,7>4,所以继续遍历。
接下来遍历到的元素是3,3<4,所以mark指针右移1位。
随后,让元素3和mark指针所在位置的元素交换,因为元素3归属于小于pivot的区域。
按照这个思路,继续遍历,后续步骤如图所示。
代码:
/**
* 单边循环法获取基准元素
*
* @param array
* @param startPoint
* @param endPoint
* @return
*/
private static int partition1(int[] array, int startPoint, int endPoint) {
// 基准元素值
int baseValue = array[startPoint];
// 表示小于基准元素的区域边界
int mark = startPoint;
for (int i = startPoint; i <= endPoint; i++) {
// 小于基准元素,则对应代表小于基准元素的边界扩大,并交换彼此的位置
if (array[i] < baseValue) {
mark++;
int temp = array[mark];
array[mark] = array[i];
array[i] = temp;
}
}
array[startPoint] = array[mark];
array[mark] = baseValue;
return mark;
}
/**
* 单边循环方式的递归方式实现
* @param array
* @param startPoint
* @param endPoint
*/
public static void quickSore1(int[] array, int startPoint, int endPoint) {
// 1 如果开始的位置大于结束的位置,跳出循环
if (startPoint >= endPoint) {
return ;
}
// 2 获取基准元素
int basePoint = partition1(array, startPoint, endPoint);
// 3 将基准元素左边再进行快排
quickSore1(array, startPoint, basePoint - 1);
// 4 将基准元素右边进行快排
quickSore1(array, basePoint + 1, endPoint);
}
非递归方式
非递归方式,则是使用栈来代替。非递归方式代码的变动只发生在quickSort方法中。该方法引入一个存储Map类型的栈,用于存储每一次交换时的起始下标和结束下标。
每一次循环,都会让栈顶元素出栈,通过partition方法进行分治,并且按照基准元素的位置分为左右两个部分,左右两个部分再分别入栈。当栈为空时,说明排序已经完毕,退出循环。
/**
* 非递归方式实现
* @param arr
* @param startIndex
* @param endIndex
*/
public static void quickSort(int[] arr, int startIndex, int endIndex) {
Stack<Map<String, Integer>> quickSortStack = new Stack<Map<String, Integer>>();
// 先将首尾指针压入栈中
Map<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 basePoint = partition(arr, param.get("startIndex"), param.get("endIndex"));
// 根据基准元素进行入栈,分治思想
if (param.get("startIndex") < basePoint - 1) {
// 入栈
Map<String, Integer> leftParam = new HashMap<>();
leftParam.put("startIndex", param.get("startIndex"));
leftParam.put("endIndex", basePoint - 1);
quickSortStack.push(leftParam);
}
if (param.get("endIndex") > basePoint + 1) {
// 入栈
Map<String, Integer> rightParam = new HashMap<>();
rightParam.put("startIndex", basePoint+1);
rightParam.put("endIndex", param.get("endIndex"));
quickSortStack.push(rightParam);
}
}
}
/**
* 分治(单边循环法)
* @param arr 待交换的数组
* @param startIndex 起始下标
* @param endIndex 结束下标
*/
private static int partition(int[] arr, int startIndex, int endIndex) {
// 取第一个位置的元素作为基准元素(也可以选择随机位置)
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,6,5,3,2,8,1};
quickSort(arr, 0, arr.length-1);
System.out.println(Arrays.toString(arr));
}
说明: 该文章中的知识点是出自于《漫画算法》本书,本人主要是将书中知识点提炼到文章中,方便后期的回顾。