系列文章导引
开源项目
本系列所有文章都将会收录到GitHub
中统一收藏与管理,欢迎ISSUE
和Star
。
GitHub传送门:Kiner算法算题记
快速排序的基础知识
排序
我们对于数组排序的目的是:让一个无序的数组趋于有序,那么我们一个长度为n的数组,那么这个数组有n!
种排列方式
排序算法的重要性
问题系统熵决定了一个问题被解决的难易程度
PS: 在计算机领域,可以把
熵
理解为复杂度
排序算法之所以很重要,是因为它能够降低我们问题系统熵的大小。当一个乱序的数组经过排序算法处理后,我们的数组就趋于有序了,这样,我们就由一个熵很大的无序数组变成了一个熵较小且稳定的有序数组了。我们的问题熵越小且越稳定,我们就能够更好的解决系统出现的各种问题。如果是一个乱序的数组,我们只能一个个去遍历,时间复杂度为O(n)
,但基于一个有序的数组,我们可以使用二分查找来提升查找效率,时间复杂度为O(logn)
。这样的话,我们的时间复杂度就由O(n)
提升到了O(logn)
。
快速排序
快速排序之所以叫快速排序,那是因为我们扫描一遍数组,就能将原本排列方式有n!
种变为n!/2
,你也可以理解为使用二分思想,每次将我们的数组都拆分成两块进行处理。快速排序在大部分情况下的的效率是最高的。所以大部分情况下,快速排序是我们的首选,但是在某些情况下,快速排序的性能会比较差,如递归层级过深时。
**概念:**先从一个乱序的数组中,选择一个基准值,然后把小于基准值的数都放在基准值的前面,大于基准值的数都放在基准值后面,即按照基准值进行分区,一个是小于基准值的区,一个是大于等于基准值的区,这个分区的过程成为Partition
,即分区操作。
通过上述操作,将数组扫完一遍之后,我们就把我们的数组以基准值为界限拆分成左侧都比基准值小,右侧都比基准值大的数组了,接下来,我们只需要递归上述过程,将左侧数组和右侧数组分别进行快排,最终就可以完成对于数组的排序了。
说到这里,大家有没有觉得我们这样的操作有点像是在操作二叉树呢?基准值就是根节点,而小于基准值的数组和大于基准值的数组分别是他的左右子树,我们对他们进行操作时也是使用树形结构最常使用的递归操作。说到二叉树,大家是否还记得之前我们聊过的堆
(大顶堆
和小顶堆
)呢,堆本身就是一个特殊的完全二叉树。好像扯得有点远了,不过在这里之所以扯那么多,只是想让大家发散思维,不要局限于数组这样的一维结构,有些时候我们把一维结构转化成二维图形或许能够帮助我们更好的理解一个算法的原理与精髓。
我们可以把上述的操作用下图表示:
我们遍历一次数组进行分区的操作,时间复杂度最快能达到O(logn)
,也就是我们的树高「一个有n个节点的二叉树的树高为logn的证明」,而我们最多需要重复n
次这样的操作,那么,我们快速排序的时间复杂度就是O(nlogn)
。
快排基准值的选择
我们最理想的情况下,快排的时间复杂度应该是O(nlogn),但是如果我们的基准值选的不好,很有可能让我们的logn就等于我们的n,比如说我们的一个数组,本身除了2个元素的顺序不对,其他所有元素的都是有序的,这个时候我们的基准值如果选得不好,就会有很多无异议的操作,最终变成了一个冒泡排序
,而冒泡排序
的事件复杂度就是O(n^2)
的。
大家可以想象一下,就像上面说的,我们的快排可以想象成一个二叉树,那么,在极端情况下,即我们的树形结构变成了一个链表,那么我们的树高就变成了n,排序的事件复杂度也就变成了O(n^2)
快排的实现
v1:非原地快排
// 以下为按照我们上面分析的思路直接实现的一个最简单的快排,但这个并不是最优的实现方法,原因是:
// 1. 该算法借助了额外的数组空间,造成不必要的空间浪费,空间复杂度为:O(n)
// 2. 基准值固定为数组第一个元素,容易出现上面说的树形结构退化成链表的极端情况,时间复杂度最高能达到O(n^2)
// 之所以实现这个版本,是因为这个版本的快排最容易理解,也跟我们上面所说的基本一致,之后的优化版本接住了一些诸如如双指针之类的额外编程技巧解决空间浪费的问题,但本质上快排实现的原理都是一样的,都是找到一个基准值,然后小于基准值的放左边,大于基准值的放右边
function quickSortV1(arr) {
if (arr.length <= 1) return arr;
const left = [];
const right = [];
let base = arr[0];
for (let i = 1; i < arr.length; i++) {
if (arr[i] < base) left.push(arr[i])
else right.push(arr[i]);
}
return [...quickSortV1(left), base, ...quickSortV1(right)];
}
const arr = [2, 4, 6, 1, 3, 8, 5, 6, 7, 2, 3, 5, 22, 34, 11, 8, 71];
console.time('非原地快排耗时:');
console.log('非原地快排:', quickSortV1(arr));
console.timeEnd('非原地快排耗时:');// 非原地快排耗时:: 0.364990234375 ms
V2: 原地快排
// 原地快排即能够通过双指针方式解决额外的空间浪费的问题,又因为使用了双指针,实际上其时间复杂度也降低了不少,因为我们同时有两个指针在工作,当然,后续我们优化了基准值的选取之后,又能够得到一定的提升
function quickSortV2(arr, left=0, right=arr.length-1) {
// 如果左右指针重叠,说明已经遍历完一轮,直接退出
if (left >= right) return;
// 记录左右游标和基准值,方便后续移动游标
// 基准值我们依然先使用第一个元素,后续我们再优化基准值的选择,避免树形结构退化成链表导致时间复杂度激增的情况
let x = left,
y = right,
base = arr[left];
// 只要左右游标不相遇就继续遍历
while (x < y) {
// 因为我们的基准值是从左边找的,因此我们先让右游标往左遍历
// 当左右指针没有重叠并且右指针指向的值大于或等于基准值的话就继续往左走,直到左右指针相遇或者右游标找到有一个比基准值小的数才跳出循环
while (x < y && arr[y] >= base) y--;
// 当跳出循环之后,如果x和y未相遇的话,交换左右指针所指向的值,并让左指针向右移动一位
if (x < y) arr[x++] = arr[y];
// 当左右指针没有重叠并且左指针指向的值小于基准值的话就继续往右走,直到左右指针相遇或者找到有一个比基准值大的数才跳出循环
while (x < y && arr[x] < base) x++;
// 当跳出循环之后,如果x和y未相遇的话,交换左右指针所指向的值,并让右指针向左移动一位
if (x < y) arr[y--] = arr[x];
}
// 循环结束后,数据结构类似:[x,x,x,x,x, ,y,y,y,y,y]
// 上面的x代表比基准值小的树,y代表比基准值大的数,而中间空出来的地方,实际上应该要放我们的基准值的
// 因此,循环结束之后,我们要把基准值放回他该去的地方。那么,这个基准值到底要放在哪里呢?因为当跳出循环时
// 我们的x、y重叠,这个位置就是我们用来放基准值的位置
arr[x] = base;
// 交换完基准值之后,我们第一轮的快排就完成了,接下来就是递归处理基准值左侧和右侧的数组了
// 左侧数组的有效范围是从我们的left到我们基准值的前一位,基准值的索引是x
quickSortV2(arr, left, x - 1);
// 右侧数组的有效范围是从基准值的后一位到right
quickSortV2(arr, x + 1, right);
}
const arr2 = [2, 4, 6, 1, 3, 8, 5, 6, 7, 2, 3, 5, 22, 34, 11, 8, 71];
console.time('原地快排耗时:');
quickSortV2(arr2)
console.log('原地快排:', arr2);
console.timeEnd('原地快排耗时:');// 原地快排耗时:: 0.310791015625 ms
排序算法的比较
堆排序
我们之前有讲过堆排序,其实就是利用了堆(优先队列)的特性进行的排序
复杂度
时间复杂度
O(nlogn)
空间复杂度
O(1)
优点
- 稳定好:堆排序由于无论是向上调整还是向下调整,都需要执行恒定的次数,因此堆排序的稳定性较高
缺点
- 空间复杂度高:我们上面不是说堆排序的空间复杂度是
O(1)
,即常量级的吗?为什么说空间复杂度高呢?上面的空间复杂度O(1)
是基于对一个已经存在的堆进行排序的的出来的结论,这个时候,由于是原地排序,空间复杂度确实很低。但因为我们现在是对数组进行排序,不是一个堆,我们要进行堆排序就要建立一个堆,并将数组元素一个个插入进去,然后再依次弹出。因此,此处说的空间复杂度是指对数组进行堆排序的空间复杂度高,而不是说堆排序本身的空间复杂度高,这点一定要注意不要混淆了。
快排
复杂度
时间复杂度
平均为O(nlogn),最坏的情况是O(n^2),即树形结构退化成链表的情况。
空间复杂度
使用原地快排的话,空间复杂度为:O(1)
优点
- 平均性能好,平均能够达到O(nlogn)
缺点
- 稳定性差:上面分析时间复杂度时我们也可以看出,快排的时间复杂度跟数组的乱序程度和选择的基准值有直接关系,导致其时间复杂度多变: O(nlogn) -> O(n^2)
插入排序
复杂度
时间复杂度
当数组是逆序时,插入排序的性能最差,时间复杂度达到O(n^2),当数组越趋于有序时,插入排序性能越好,最好的时候,就是只需要当前数跟上一个数比较即可,时间复杂度为O(n)
空间复杂度
因为是进行原地排序,未占用额外空间,因此空间复杂度是O(1)
优势互补,强强联合——内省排序(introsort)
上面分析了堆排序、快排、插入排序的优缺点,可以看出他们各有各的优缺点,各自有个字的适用场景,那么,我们能不能将几个排序方式的优点结合起来,摒弃缺点,相互配合,实现一个适用于大部分场景的最佳排序呢?
答案就是我们的内省排序
内省排序既能在常规数据集上实现快速排序的高性能,又能在最坏情况下仍保持O(nlogn)
那么,我们内省排序
什么情况下会使用快排
,什么时候使用堆排序
,什么情况使用插入排序
呢?
当递归层数不足2*logn
时使用快速排序
为什么我们的界限是2*logn
而不是logn
呢?我们知道,我们的快排最快能达到O(nlogn)
的,而logn
是最理想的情况,但是我们大多数情况是达不到的,所以取一个倍数,通常来说2倍就够了,如果在2倍logn
的情况都还没有完成排序,那么我们就直接切换使用堆排序
当递归层数达到2*logn
时使用堆排序
由于堆排序的事件复杂度恒定是O(nlogn)
,因此,在快速排序无法在2倍logn
的时间内完成排序时,就切换到堆排序
,避免掉入快排陷阱
中
当数组趋于有序或数据量较小时使用插入排序
插入排序适合在数据量较小或者数据趋于有序时的排序场景,因此,我们可以在排序的首尾使用插入排序。
业界优秀的内省排序算法的设计
目前一个比较优秀的内省排序算法是这样设计的(此处提供的是C++的标准模板库STL中实现的对于数组排序的方案,大家应该知道,Chrome V8
引擎底层其实也是使用C++实现的,他实际实现sort
时,其实也是调用了std::sort
的。如果想了解v8跟多关于排序的细节,可以看一下这篇文章[译] V8引擎中的排序),如果想要了解C++中标准模板库STL中sort实现的细节,可以看:C++标准模版库排序相关实现:
- 先使用
快速排序
和堆排序
将一个大的数组划分多个分区,每个分区大小最大为16
(在Chrome V8
中,这个分区大小是10
)。至于什么时候用快排,什么时候用推排序,上面已经说过,就不再赘述了。这一步的目的是为了将一个庞大的数组划分为多个分区,从而减小快排
和堆排序
的复杂度 - 经过第一步的处理,我们每一个分区已经足够小,我们再使用
插入排序
对每个分区进行排序
// 以下为C++标准模版库STL中实现内省排序的部分主要逻辑代码,如果想看完整代码,请访问上面给出的链接查看
// 进行快排/堆排单个分区的大小,在c++标准模版库中是16,而在Chrome V8中应该是10
const int __stl_threshold = 16;
// ...
template <class _RandomAccessIter, class _Tp, class _Size>
void __introsort_loop(_RandomAccessIter __first,
_RandomAccessIter __last, _Tp*,
_Size __depth_limit)
{
while (__last - __first > __stl_threshold) {
// 当递归层数超过2*logn时,使用堆排序(优先队列排序)
if (__depth_limit == 0) {
partial_sort(__first, __last, __last);
return;
}
--__depth_limit;
_RandomAccessIter __cut =
__unguarded_partition(__first, __last,
_Tp(__median(*__first,
*(__first + (__last - __first)/2),
*(__last - 1))));
__introsort_loop(__cut, __last, (_Tp*) 0, __depth_limit);
__last = __cut;
}
}
template <class _RandomAccessIter, class _Tp, class _Size, class _Compare>
void __introsort_loop(_RandomAccessIter __first,
_RandomAccessIter __last, _Tp*,
_Size __depth_limit, _Compare __comp)
{
while (__last - __first > __stl_threshold) {
if (__depth_limit == 0) {
partial_sort(__first, __last, __last, __comp);
return;
}
// 递减递归层数,直到减到0还未结束则改用堆排序
--__depth_limit;
// 使用快速排序,这里使用的是一个无监督的partition操作对本次的调整进行处理
// 所谓无监督的partition(__unguarded_partition),就是在特定条件下去掉边界条件的校验也能正常运行的操作函数
// 我们使用递归时,都会有一些边界条件的检验用以判断是否跳出递归过程,拿我们上面实现的快排代码来说,这里所说的边界条件其实就是:if (left >= right) return; 本来这个边界条件存在的目的是为了让我们避免无限递归导致的死循环。但是下面这段代码大家可以看到,并没有这个边界条件的判断。但是这个__unguarded_partition使用了一个很巧妙的方式能够避免无限递归导致的死循环,所以不需要边界条件判断了。至于__unguarded_partition如何实现的,用了什么巧妙的方法,我们下面单独讨论
_RandomAccessIter __cut =
__unguarded_partition(__first, __last,
// 使用三点取中间的方式来选择我们的基准值,避免因为基准值选得不合适而造成性能浪费
// 其实就是比较区间中第一个元素、中间的元素、最后一个元素,看一下哪个大小是排在中间的,
// 我们就直接拿中间的值作为基准值
_Tp(__median(*__first,
*(__first + (__last - __first)/2),
*(__last - 1), __comp)),
__comp);
// 递归操作,但是在这里可能很多人会奇怪了,我们上面实现快排的时候,不是需要分别递归左分区和右分区吗?为什么这里只是递归调用了一次。在这里就要说一下在使用递归时的一种技巧:左/单边递归法,左/单边递归法的实现,我在后面会单独实现一个js版本的帮助理解。
// 我们为什么要怎么做呢?因为我们递归也是有开销的,每一次的递归都需要再系统空间开辟一个调用栈,所以,能够减少递归次数
// 也可以减少一定的内存和时间的开销
__introsort_loop(__cut, __last, (_Tp*) 0, __depth_limit, __comp);
// 修正右指针的位置
__last = __cut;
}
}
template <class _RandomAccessIter>
inline void sort(_RandomAccessIter __first, _RandomAccessIter __last) {
__STL_REQUIRES(_RandomAccessIter, _Mutable_RandomAccessIterator);
__STL_REQUIRES(typename iterator_traits<_RandomAccessIter>::value_type,
_LessThanComparable);
if (__first != __last) {
__introsort_loop(__first, __last,
__VALUE_TYPE(__first),
__lg(__last - __first) * 2);
__final_insertion_sort(__first, __last);
}
}
template <class _RandomAccessIter, class _Compare>
inline void sort(_RandomAccessIter __first, _RandomAccessIter __last,
_Compare __comp) {
__STL_REQUIRES(_RandomAccessIter, _Mutable_RandomAccessIterator);
__STL_BINARY_FUNCTION_CHECK(_Compare, bool,
typename iterator_traits<_RandomAccessIter>::value_type,
typename iterator_traits<_RandomAccessIter>::value_type);
// 当左右游标没有相遇时才进行排序
if (__first != __last) {
// 先进行快排+堆排,对大数组进行分区。
__introsort_loop(__first, __last,// 传入左右游标
__VALUE_TYPE(__first),
// 这个就是使用快排和堆排的界限,我们上面有说过,如果大于2*logn时就用堆排,否则就用快排
__lg(__last - __first) * 2,
// 比较函数
__comp);
// 使用插入排序对已经分好区的数组进行最终排序得出结果
__final_insertion_sort(__first, __last, __comp);
}
}
// 无监督partition函数的具体实现
template <class _RandomAccessIter, class _Tp>
_RandomAccessIter __unguarded_partition(_RandomAccessIter __first,
_RandomAccessIter __last,
_Tp __pivot)
{
while (true) {
while (*__first < __pivot)
++__first;
--__last;
while (__pivot < *__last)
--__last;
if (!(__first < __last))
return __first;
iter_swap(__first, __last);
++__first;
}
}
template <class _RandomAccessIter, class _Tp, class _Compare>
_RandomAccessIter __unguarded_partition(_RandomAccessIter __first,
_RandomAccessIter __last,
_Tp __pivot, _Compare __comp)
{
while (true) {
while (__comp(*__first, __pivot))
++__first;
--__last;
while (__comp(__pivot, *__last))
--__last;
if (!(__first < __last))
return __first;
iter_swap(__first, __last);
++__first;
}
}
左/单边递归法优化快排
function quickSortV3(arr, left=0, right=arr.length-1) {
while(left < right) {
// 记录左右游标和基准值,方便后续移动游标
// 基准值我们依然先使用第一个元素,后续我们再优化基准值的选择,避免树形结构退化成链表导致时间复杂度激增的情况
let x = left,
y = right,
base = arr[left];
// 只要左右游标不相遇就继续遍历
while (x < y) {
// 因为我们的基准值是从左边找的,因此我们先从右游标往左遍历
// 当左右指针没有重叠并且右指针指向的值大于或等于基准值的话就继续往左走,直到左右指针相遇或者找到有一个比基准值小的数才跳出循环
while (x < y && arr[y] >= base) y--;
// 当跳出循环之后,如果x和y未相遇的话,交换左右指针所指向的值,并让左指针向右移动一位
if (x < y) arr[x++] = arr[y];
// 当左右指针没有重叠并且左指针指向的值小于基准值的话就继续往右走,直到左右指针相遇或者找到有一个比基准值大的数才跳出循环
while (x < y && arr[x] < base) x++;
// 当跳出循环之后,如果x和y未相遇的话,交换左右指针所指向的值,并让右指针向左移动一位
if (x < y) arr[y--] = arr[x];
}
// 循环结束后,数据结构类似:[x,x,x,x,x, ,y,y,y,y,y]
// 上面的x代表比基准值小的树,y代表比基准值大的数,而中间空出来的地方,实际上应该要放我们的基准值的
// 因此,循环结束之后,我们要把基准值放回他该去的地方。那么,这个基准值到底要放在哪里呢?因为当跳出循环时
// 我们的x、y重叠,这个位置就是我们用来放基准值的位置
arr[x] = base;
// 交换完基准值之后,我们第一轮的快排就完成了,接下来就是递归处理基准值左侧和右侧的数组了
// 与第二个版本的递归左右区间不同,我们这里只递归一次右区间,递归完之后,右区间处理完之后,我们可以直接让我们的右游标
// right挪动到左区间的有边界,剩余的左区间的操作由最外层的while循环来代替,图示:
// [x,x,x,x,x,x,base,y,y,y,y,y,y,y]
// 首先对右区间进行递归操作,操作完之后,右区间已经有序了,我们不需要再处理
// [x,x,x,x,x,x,base]
// 然后,我们只要把右游标指向base的前一位,也就是x-1的位置,然后进入下一次循环处理
// 我们为什么要怎么做呢?因为我们递归也是有开销的,每一次的递归都需要再系统空间开辟一个调用栈,所以,能够减少递归次数
// 通过减少了一半的递归操作可以减少一定的内存和时间的开销
quickSortV3(arr, x + 1, right);
right = x - 1;
}
}
const arr3 = [2, 4, 6, 1, 3, 8, 5, 6, 7, 2, 3, 5, 22, 34, 11, 8, 71];
console.time('原地快排-左递归耗时:');
quickSortV2(arr3)
console.log('原地快排-左递归:', arr3);
console.timeEnd('原地快排-左递归耗时:');// 原地快排-左递归耗时:: 0.236083984375 ms
快排+插入
// 我们其实可以发现,使用这种算法,反而没有上面的左递归原地快排的速度快,实际上是因为javascript本身的运行环境有关,跟算法本身的性能是没有关系的,我们之前说了,chrome V8是使用C++实现的,而这里只是将C++中实现的算法用js实现一遍,方便理解,实际开发过程中,其实我们只需要调用原生提供的sort排序即可。再次强调一下,我们是在强调算法思维,但具体某一个算法在不同的语言,不同的运行环境,可能会因为各种因素导致性能差异,在此暂不讨论
// 定义每个分区的大小
const max = 10;
function getMid(a, b, c) {
if (a < c)[a, c] = [c, a];
if (a < b)[a, b] = [b, a];
if (b < c)[b, c] = [c, b];
return b;
}
function __quickSortV4(arr, left = 0, right = arr.length - 1) {
// 如果单个分区的大小小于一个分区最大大小时继续循环
while (right - left > max) {
let x = left,
y = right,
// 使用三点取中值的方式,尽量避免我们的基准值命中最小值或最大值
base = getMid(arr[left], arr[Math.floor((left + right) / 2)], arr[right]);
do {
// 左右游标在还没有命中基准值时,各自向基准值靠拢
while (arr[x] < base) x++;
while (arr[y] > base) y--;
// 当左右游标所对应的值已经大于或等于基准值时,此时如果我们的左游标依然在右游标的左侧或两个游标重合
if (x <= y) {
// 交换左右游标所指向的值
[arr[x], arr[y]] = [arr[y], arr[x]];
// 并让左右游标向中间靠拢一步
x++;
y--;
}
} while (x <= y);// 知道左游标跑到右游标的右侧时停止循环
// 采用单边递归法继续快排右侧区间
__quickSortV4(arr, x, right);
// 修正右游标的边界为当前左区间的边界
right = x;
}
}
// 实现插入排序
function insertSort(arr, left = 0, right = arr.length - 1) {
let idx = left;
// 先找到数组中最小值的索引
for(let i = idx + 1; i <= right; i++) {
if(arr[i] < arr[idx]) idx = i;
}
// 将最小值与数组头部交换
[arr[0], arr[idx]] = [arr[idx], arr[0]];
// 由于我们已经知道最小值已经在数组头部了,无需处理,直接从第三个元素开始(因为需要让第三个元素与第二个元素比较)
for(let i=left+2; i<=right; i++) {
let j = i;
while (arr[j] < arr[j-1]) {
[arr[j], arr[j-1]] = [arr[j-1], arr[j]];
j--;
}
}
}
// 用快排与插入排序相结合,快排先将一个大数组分割成一个个长度为max的趋于有序的小分区,然后再通过插入排序将这些小分区一个个排序
function quickSortV4(arr, left = 0, right = arr.length - 1) {
__quickSortV4(arr, left, right);
insertSort(arr, left, right);
}
const arr4 = [...sourceArr];
console.time('原地快排-快排与插入排序相结合判断耗时:');
quickSortV4(arr4)
console.log('原地快排-快排与插入排序相结合判断:', arr4);
console.timeEnd('原地快排-快排与插入排序相结合判断耗时:'); // 数据量为10000时,原地快排-快排与插入排序相结合判断耗时:: 21.94091796875 ms