排序算法杂谈(四) —— 快速排序的非递归实现
1. 前提
2. 快速排序与归并排序的递归
快速排序(Quick Sort)与归并排序(Merge Sort)虽然都采用了递归地思想,但是其递归地本质却有所不同。
- 快速排序,手动划分,自然有序。
- 归并排序,自然两分,手动合并。
快速排序,是先通过划分(partition)算法,将数组两分,划分的过程中,比主元(pivot)小的数字全部被划分到了左侧,比主元大的数字全部被划分到了右侧。
然后对两分的数组进行递归。当数组两侧的长度均小于等于1,那么数组就自然有序了。
归并排序,是将原数组二等分,直到被等分的数组长度小于等于1,那么被等分的数组就有序了,然后对这等分的数组进行合并。
所以说,快速排序与归并排序,正好代表了递归的两种典型,如果将递归的过程看做是一颗二叉树,那么:
- 快速排序:下层递归的实现,依赖上层操作的结果。(只有父节点操作完成,才能对子节点进行递归)
- 归并排序:上层递归的操作,依赖下层递归的结果。(只有子节点全部操作完成,才可以操作父节点)
3. 快速排序非递归实现的堆栈模型 stack 与 记录模型 record
快速排序这种,优先操作,然后递归的特点,大大简化了构造目标堆栈模型的难度。
在归并排序中,不难发现,其构造目标堆栈模型的过程,是不断入栈的过程,最后一次性地处理堆栈信息。
相反,在快速排序中,目标堆栈是一个不断 入栈-出栈 的过程,在出栈的过程中,就对数据进行处理,没有必要再最后一次性处理。
而且,由于划分具有不稳定性,所以没有办法给出确切的堆栈模型。
快速排序的递归过程,只需要关心其左边与右边的坐标:
private static class Record { int left; int right; private Record(int left, int right) { this.left = left; this.right = right; } }
4. 快速排序非递归的过程
快速排序非递归的执行过程中,只需要一个堆栈空间,其运行过程如下:
- 对原数组进行一次划分,分别将左边的 Record 和 右边的 Record 入栈 stack。
- 判断 stack 是否为空,若是,直接结束;若不是,将栈顶 Record 取出,进行一次划分。
- 判断左边的 Record 长度(这里指 record.right - record.left + 1)大于 1,将左边的 Record 入栈;同理,右边的 Record。
- 循环步骤 2、3。
于是,有如下代码:
public final class QuickSortLoop extends BasicQuickSort { private Stack<Record> stack = new Stack<>(); @Override public void sort(int[] array) { int left = 0; int right = array.length - 1; if (left < right) { int pivot = partitionSolution.partition(array, left, right); if (pivot - 1 >= left) { stack.push(new Record(left, pivot - 1)); } if (pivot + 1 <= right) { stack.push(new Record(pivot + 1, right)); } while (!stack.isEmpty()) { Record record = stack.pop(); pivot = partitionSolution.partition(array, record.left, record.right); if (pivot - 1 >= record.left) { stack.push(new Record(record.left, pivot - 1)); } if (pivot + 1 <= record.right) { stack.push(new Record(pivot + 1, record.right)); } } } } private static class Record { int left; int right; private Record(int left, int right) { this.left = left; this.right = right; } } }
如果 Record 模型过于简单,可以直接通过入栈-出栈 具体的数据来简化这个过程。
5. 关于递归转循环需要知道的事情
通过归并排序和快速排序非递归实现的讲解,似乎将其转化为循环是一个更佳的做法,其实不然,它只适用于特定的场景。
关于这种方法,需要有如下的认知:
- 递归的代码,在很多时候比循环的代码更加容易理解。
- 递归转循环,在效率上并没有提高。相反,由于增加了构造堆栈模型的过程,其消耗的时间更多。
- 只有当递归的层数过多,而导致 StackOverFlow 的问题出现,才考虑使用递归转循环的方法。
- 可以通过调整 JVM 参数,来达到扩充堆栈空间的目的,但是一般不推荐这么做,因为这个影响是整体的。
- 从代码的角度,如果循环能够解决问题,那么就使用循环;如果递归能解决问题,那么就使用递归,没有必要特意去做两者的转换。