目录
1. 开场白
假如我想买一台IPhone13ProMax的手机, 于是去搜索百度.
我们在淘宝界面选择按照售价升序、信用、评分等关键字排序
2. 排序的基本概念与分类
2.1 排序稳定性
排序前: ri 领先于 rj
排序后:ri 仍然领先于 rj, 则是稳定排序
2.2 内排序与外排序
根据排序中带排序的记录是否全部放置在内存中,排序悲愤为内排序和外排序
内排序: 在排序整个过程中,待排序的所有记录全部放置在内存中
外排序: 由于排序的记录个数太多,不能同时放在内存,整个排序过程需要在内外存之间多次交换数据才能进行
排序算法性能影响主要有3个方面:
- 时间性能:内排中主要是比较和移动,因此减少比较次数和移动次数即可
- 辅助空间:待排序数据所占用的内存和执行算法所需要的内存
- 算法的复杂性:指的是算法本身的复杂度而非是算法的时间复杂度
2.3 分类
简单算法:冒泡,选择,插排
改进算法:希尔, 堆排,快排,归并
3.排序算法实现
所有排序都是升序
3.1. 冒泡排序
冒泡排序(Bubble Sort) 一种交换排序
基本原理:两两比较相邻记录的关键字,如果反序则交换,直到没有反序的记录为止
/*
时间复杂度:
最坏:O(N^2)【1+2+3+...+(n-1)=n*(n-1)/2】
最好:O(N)【数据是升序, 内层循环进行了 N-1 次比较后直接break退出外层循环, 没有交换】
平均:O(N^2)
空间复杂度: O(1)
稳定性: 稳定
数据对象: 数组
[无须, 有序]-->从无序区, 两两比较找出最值元素放到有序前端
应用场景: 一般不使用
*/
class bubbleSort {
void bubbleSort(int[] arr) {
long before = System.currentTimeMillis();
// 针对所有的元素重复比较交换【两个数需要比较1次,因此需要比较 元素个数-1 次】
for (int i = 0; i < arr.length - 1; ++i) {
// 立一个 flag,当在一趟序列遍历中元素没有发生交换,则证明该序列已经有序。但这种改进对于提升性能来说并没有什么太大作用
boolean flag = true;
// 对每一对相邻元素作同样的工作,从开始第一对到结尾的最后一对。这步做完后,数组中最大的书就会 "冒泡" 到数组的末尾
for (int j = 0; j < arr.length - i - 1; ++j) {
// 比较相邻的元素。如果第一个比第二个大,就交换他们两个
if (arr[j] > arr[j + 1]) {
int tmp = arr[j];
arr[j] = arr[j + 1];
arr[j + 1] = tmp;
flag = false;
}
}
if (flag) {
break;
}
}
long after = System.currentTimeMillis();
System.out.println("BubbleSort time: " + (after - before));
}
}
3.2.选择排序
冒泡排序就像爱炒股票短线的人总在不断的买进卖出赚差价,但操作频繁即使失误不多但由于操作的手续费过高而获利很少
选择排序就像很少出手,观察等待时机,果断买进卖出交易次数少而最终收益丰富
所以选择排序适用于数据规模越小越好,唯一的好处可能就是不占用额外的内存空间
/*
时间复杂度:
最坏:O(N^2)【1+2+3...+(n-1)=n(n-1)/2,内层循环只交换一次,而冒泡内层循环可能会交换很多次所以性能上略优于冒泡】
最好:O(N^2)【数据是升序,因为无论对于有序还是无序,都会进入先外层循环在进入内层循环。】
平均:O(N^2)
空间复杂度:O(1)
稳定性: 不稳定
数据对象: 链表, 数组
[无序, 有序]-->在无序区找一个最值元素放在有序区后面. 对数组: 比较的多, 换得少
应用场景: 一般不使用
*/
class selectSort {
void selectSort(int[] arr) {
long before = System.currentTimeMillis();
// 总共要经历 N-1 次比较
for (int i = 0; i < arr.length - 1; ++i) {
// 每轮需要比较 N-i-1 次并找最小值的下标
int minIndex = i;
for (int j = i + 1; j < arr.length; ++j) {
if (arr[minIndex] > arr[j]) {
minIndex = j;
}
}
// 找到最小值就进行交换: 让小值在前大值在后就是升序
if (minIndex != i) {
int tmp = arr[minIndex];
arr[minIndex] = arr[i];
arr[i] = tmp;
}
}
long after = System.currentTimeMillis();
System.out.println("SelectSort time: " + (after - before));
}
}
3.3. 插入排序
插入排序在对几乎已经排好序的数据操作时,效率高,可达到线性排序的效率但插入排序一般来说是低效的,因为插入排序每次只能将数据移动一位
基本原理:构建有序序列,对于未排序数据,在已排序序列中从后向前扫描,找到相应位置并插入【类似于打扑克】
/*
时间复杂度:
最坏:O(N^2)【比较次数: 1+2+3+...(n-1)=n*(n-1)/2,移动次数:(n-1)+(n-2)+(n-3) ... +1 = n*(n-1)/2】
最好: O(N)【数据升序序, 就是arr[j]>tmp不成立【arr[i-1]和arr[i]进行比较】由于每次arr[i-1]<arr[i], 所以内层循环进不去没有交换, 所以最外层的循环是 O(N)】
平均: O(N^2)【如果排序记录是随机的, 那么根据概率相同的原则, 平均比较和移动次数约为n^2/4次】
空间复杂度: O(1)
稳定性: 稳定
描述: 越排越快, 同样O(N^2)的时间复杂度, 直接插入排序比冒泡和简单选择排序性能要更好
数据对象: 数组, 链表
[有序区, 无序区]-->把无序区的第一个元素插入到有序区的合适的位置. 对数组: 比较的少, 换得多
应用场景: 需要优化部分代码的时候或者数据量较少且最求稳定的时候, 是一种越排越快的算法
*/
class insertSort {
void insertSort(int[] arr) {
long before = System.currentTimeMillis();
// 将第一待排序序列第一个元素看做一个有序序列,把第二个元素到最后一个元素当成是未排序序列
for (int i = 1; i < arr.length; ++i) {
// 从头到尾依次扫描未排序序列,将扫描到的每个元素插入有序序列的适当位置【如果待插入的元素与有序序列中的某个元素相等,则将待插入元素插入到相等元素的后面】
int tmp = arr[i];
int j = i - 1;
for (j = i - 1; j >= 0 && arr[j] > tmp; --j) {
arr[j + 1] = arr[j];
}
arr[j + 1] = tmp;
}
long after = System.currentTimeMillis();
System.out.println("InsertSOrt time:" + (after - before));
}
}
插入排序和冒泡排序一样,也有一种优化算法,叫做拆半插入
折半插入原理:在前边构建的有序序列中进行二分查找找到待插入元素的下标,然后进行运算搬运【利用二分查找优化了比较次数】
void binaryInsertionSort(int[] arr) {
for (int i = 1; i < arr.length; i++) {
int key = arr[i];
int left = 0;
int right = i - 1;
// 在有序序列[0, i-1]中二分查找正确的插入位置
while (left <= right) {
int mid = left + ((right - left) >> 1);
/*
升序:key < arr[mid]则锁紧右区间,让arr[mid]靠近key值下标,
降序:key > arr[mid]则锁紧右区间,让arr[mid]靠近左边最大值的下一个下标
*/
if (key > arr[mid]) {
right = mid - 1;
} else {
left = mid + 1;
}
}
// arr[j+1] = key,将后续查找出的小元素搬运到序列前边
for (int j = i - 1; j >= left; j--) {
arr[j + 1] = arr[j];
}
arr[left] = key;
}
}
在升序排序时是通过将 right 缩小,在降序排序时是通过将 left 增大
3.4. 希尔排序
希尔排序,也称递减增量排序算法,是插入排序的一种更高效的改进版本。但希尔排序是非稳定排序算法
希尔排序是基于插入排序的以下两点性质而提出改进方法的
- 插入排序在对几乎已经排好序的数据操作时,效率高,达到线性排序的效率
- 插入排序一般来说是低效的, 因为插入排序每次只能将数据移动一位
希尔排序的基本思想是: 先将整个待排序的记录序列分割成为若干子序列分别进行直接插入排序, 待整个序列中的记录 “基本有序” 时, 再对全体记录进行依次直接插入排序.
3.4.1 希尔排序原理
上面的简介知识大致说明了希尔排序的特征, 下面详细说明这些原理特点.
之前的插入排序,虽然时间复杂度准确来说是 n2/4,但在某些时候效率很高
- 当数据本身基本有序的时候只需要少量的插入操作就可以排序
- 当记录次数少的时候直接插入的优势也特别明显
于是希尔排序就是将比较和移动逐步减少后进行最后一趟插入排序
减少比较次数:很容易想到的就是把大量的数据进行分组。有点类似于网络中报文的分组转发,一次发送不完分多次发送最后合并在一起组成完整数据
希尔排序就是把数据分割成若干子序列,此时每个记录的排序数据量比较就少了,然后对每个子序列内分别进行插入排序。当整个序列基本有序最后对所有分割的序列整合在一起进行一趟直接插入排序.
基本有序的概念
{9, 1, 5, 8, 3, 7, 4, 6, 2}. 现在分成三组{9,1,5}, {8,3,7}, {4,6,2}哪怕将它们排好序{1,5,9}、{3,7,8}、{2,4,6}再合并为{1,5,9,3,7,8,2,4,6}此时它们也是乱序,根本谈不上基本有序【9在前面, 2在后面】
所谓的基本有序就是:小的关键字基本在前面,大的关键字基本在后面,不大不小的在中间。像{1, 3, 2, 5, 7, 4, 9, 8, 6}这样可以称为基本有序
3.4.2 希尔排序算法实现
/*
时间复杂度:
最坏: O(log^2N)【n logN】
最好: O(log^2N)【n logN(3/2)为logN也有说是logN】
平均: O(logN)【n logN】
空间复杂度: O(1)
稳定性: 不稳定
数据对象: 数组
每一轮按照事先决定的间隔 gap 进行插入排序, 间隔会依次缩小, 最后一次一定要是1
*/
class shellSort {
void shellSort(int[] arr) {
long before = System.currentTimeMillis();
// 选择一个增量序列 gap t1,t2,……,tk,其中 ti > tj, tk = 1
int gap = arr.length;
do {
gap = gap / 3 + 1;
for (int i = gap; i < arr.length; i++) {
int key = arr[i];
int j = i - gap;
for (; j >= 0 && arr[j] > key; j -= gap) {
arr[j + gap] = arr[j];
}
arr[j + gap] = key;
}
} while (gap > 1);
long after = System.currentTimeMillis();
System.out.println("ShellSOrt time:" + (after - before));
}
}
假设 shellSort(arr)
中传入了一个{9,1,5,8,3,7,4,6,2}的数组
增量因子 初始值设为带排序的记录数也就是: arr.length/3+1
后续的循环步骤省略,由此我们发现实现基本有序靠的是 增量因子,它将数组中的元素划分为若干子序列,对每个子序列进行插入排序,将内部所有元素实现基本有序之后也就是 增量因子 为1的时候, 全体数据最后进行一趟 直接插入排序
3.4.3 希尔排序复杂度分析
通过这段代码的剖析, 相信大家都明白, 希尔排序并不是随便分组后各自排序而是将相隔某个 “增量” 的记录组层一个子序列, 实现跳跃式的移动, 使得排序效率提高.
这里的增量选取就很关键, 本文中时 increment/3+1, 可究竟该选取什么样的增量才是最好目前还是一个数学难题, 迄今为止还没有找到一个好的增量序列. 不过大量的研究表明, 当增增量序列为 dlta=2t-k+1-1 [0<=k<=t<=log(n+1)] 时可以获取不错的效率, 其时间复杂度为O(N^(3/2)), 需要注意的是增量序列的最后一个增量值必须是1才行, 另外由于希尔排序是一个跳跃式的交换, 所以不是稳定的排序
3.5. 堆排序
3.5.1 堆排序原理
前面说到的简单选择排序, 它在待排序的 n 个记录中选择一个最小的记录需要比较 n-1 次
可惜的是这样的操作, 并没有把每一趟的比较结果进行保存, 在后一趟的比较中, 有许多比较在之前已经做过了, 但由于前一趟比较时未保存结果, 所以后续的比较又重复这些操作, 因为记录的次数比较多
如果可以做到每次选择到最小记录的同时还能保存比较结果还能对这些结果做出相应的调整, 那样总体效率就很高了, 而堆排就是对选择排序的一种改进
那什么是堆排序呢?
堆排序(Heapsort)是指利用堆这种数据结构所设计的一种排序算法。堆积是一个近似完全二叉树的结构,并同时满足堆积的性质:即子结点的键值或索引总是小于(或者大于)它的父节点。堆排序可以说是一种利用堆的概念来排序的选择排序.
分为两种方法:
- 大顶堆:每个节点的值都大于或等于其子节点的值,在堆排序算法中用于升序排列;
- 小顶堆:每个节点的值都小于或等于其子节点的值,在堆排序算法中用于降序排列
堆调整前后示意图
大小顶堆示意图
3.5.2 堆排序算法实现
/*
时间复杂度:
最坏: O(N log N)
最好: O(N log N)
平均: O(N log N)
空间复杂度: O(1)
稳定性: 不稳定
数据对象: 数组
[大根堆[小根堆], 有序区]-->从堆顶把根卸出来放在有序区间之前, 再恢复堆的结构
*/
class heapSort {
void heapSort(int[] arr) {
long before = System.currentTimeMillis();
// 1.升序就调整为大根堆
createBigHeap(arr);
int end = arr.length - 1;
// 2.堆顶(最大值)和堆尾交换
while (end > 0) {
int tmp = arr[0];
arr[0] = arr[end];
arr[end] = tmp;
// 3.每次都调整堆把新的堆顶元素调整到相应位置
shiftDown(arr, 0, end--);
}
long after = System.currentTimeMillis();
System.out.println("HeapSort time: " + (after - before));
}
private void createBigHeap(int[] arr) {
// 1.arr.length-1选取数组最后一个元素, 再-1是为了求得父节点下标
for (int parent = (arr.length - 2) >> 1; parent >= 0; --parent) {
// 2.对每一个父节点进行调整
shiftDown(arr, parent, arr.length);
}
}
private void shiftDown(int[] arr, int parent, int sz) {
// 1.根据父节点求的其子节点
int child = (parent << 1) + 1;
// 2.
while (child < sz) {
// 3.判定右子节点不越界且保证左子树的值小于右子树
if (child + 1 < sz && arr[child] < arr[child + 1]) {
//if (child + 1 < sz && arr[child] > arr[child + 1]) {//符号改变后就是小顶堆: 降序
++child;
}
// 4.遇到左子树比右子树还大, 那么就把左子树和父节点进行交换
if (arr[child] > arr[parent]) {
//if (arr[child] < arr[parent]) {// 符号改变后就是小顶堆: 降序
int tmp = arr[child];
arr[child] = arr[parent];
arr[parent] = tmp;
// 5.更新父节点, 使其带动子节点下沉
parent = child;
child = (parent << 1) + 1;
} else {
// 6.如果左子树值小于父节点, 无需调整
break;
}
}
}
}
详细步骤分析
图1⃣️是一个大顶堆, 90为最大值, 将 90 与 20(末尾元素)呼唤, 如图2⃣️所示, 此时 90 就成了整个堆序列的最后一个元素, 将 20 经过调整, 使得除 90 以外的节点继续满足大顶堆定义.见图3⃣️
在考虑 30 和 80 互换…
看到这儿, 相信大家已经明白堆排序的基本思想了, 不过要实现它还需要解决两个问题
- 如何由一个无须序列构建成一个堆
- 如何在输出堆顶元素后, 调整剩余元素成为一个新的堆
要解释清楚它们, 我们来详解一下代码
这是一个大的思路框架
// 1.升序就调整为大根堆
createBigHeap(arr);
int end = arr.length - 1;
// 2.堆顶(最大值)和堆尾交换
while (end > 0) {
int tmp = arr[0];
arr[0] = arr[end];
arr[end] = tmp;
// 3.每次都调整堆把新的堆顶元素与堆尾元素进行调整
shiftDown(arr, 0, end--);
}
假设我们要排序的序列式{50,10,90,30,70,40,80,60,20}
. 那么 end = 8
while
循环每次交换堆顶元素和"堆尾"元素, 交换完之后再重新调整堆的大小.
再看 createBigHeap
函数
private void createBigHeap(int[] arr) {
// 1.arr.length-1选取数组最后一个元素, 再-1是为了求得父节点下标
for (int parent = (arr.length - 2) >> 1; parent >= 0; --parent) {
// 2.对每一个父节点进行调整
shiftDown(arr, parent, arr.length);
}
}
第一次当把 9 传入函数中, 是从4开始到1结束【包含1, 这里的1其实就是数组下标0元素的位置, 为了好让大家解读暂且理解为1】
他们都是有孩子的父节点, 注意灰色节点的下标编号
4怎么来的呢?
还记得for (int parent = (arr.length - 2) >> 1; parent >= 0; --parent)
吗?
9-2=7, 7/2=3, 3+1=4
4就是左子树, 4+1=5就是右子树
那么 parent
–>4,3,2,1的调整每个父节点的左右子树使其达成大顶堆.
知道了调整的是哪些节点, 我们在看关键的 shiftDown
函数如何实现的
private void shiftDown(int[] arr, int parent, int sz) {
// 1.根据父节点求的其子节点
int child = (parent << 1) + 1;
// 2.
while (child < sz) {
// 3.判定右子节点不越界且保证左子树的值小于右子树
if (child + 1 < sz && arr[child] < arr[child + 1]) {
//if (child + 1 < sz && arr[child] > arr[child + 1]) {//符号改变后就是小顶堆: 降序
++child;
}
// 4.遇到左子树比右子树还大, 那么就把左子树和父节点进行交换
if (arr[child] > arr[parent]) {
//if (arr[child] < arr[parent]) {// 符号改变后就是小顶堆: 降序
int tmp = arr[child];
arr[child] = arr[parent];
arr[parent] = tmp;
// 5.更新父节点, 使其带动子节点下沉
parent = child;
child = (parent << 1) + 1;
} else {
// 6.如果左子树值小于父节点, 无需调整
break;
}
}
}
- 函数第一次被调用的时候传入的是
arr={50,10,90,30,70,40,80,60,20}
parent=3
sz=9
child=parent2+1得7
while(7<9)成立
child+1 右子树没有越界, 但左子树7⃣️>右子树8⃣️所以child5⃣️不自增
child7⃣️ > child3⃣️, 交换parent3⃣️和child7⃣️的值
新的父节点就是parent7⃣️, 其最小的左子树已经越界while((27+1) < 9), 所以循环结束
调整完毕之后
- 函数第二次被调用的时候传入的是
arr={50,10,90,30,70,40,80,60,20}
parent=2
sz=9
child=2*2+1得5
while(5<9)成立
child+1右子树不越界且左子树child5⃣️<右子树child6⃣️, child5⃣️节点自增
右子树child6⃣️<parent2⃣️ 所以不用交换就直接break出去推出循环
- 函数第三次被调用的时候传入的是
arr={50,10,90,30,70,40,80,60,20}
parent=1
sz=9
child=12+1得3
while(3<9)成立
child+1右子树不越界且左子树child3⃣️>右子树4⃣️, child3⃣️节点不自增
child3⃣️<parent1⃣️, 所以不交换
新parent3⃣️, 新child7⃣️
while(7<9)
7+1不越界, child7⃣️>child8⃣️, 不自增
child7⃣️<parent3⃣️不交换
新parent7⃣️, 新child【27+1】已经越过9下标所以会推出循环
4. 函数第三次被调用的时候传入的是
arr={50,10,90,30,70,40,80,60,20}
parent=0
sz=9
child=20+1
while(1<9)成立
(1+1)<9且左子树child1⃣️>右子树child2⃣️, 所以child1⃣️不自增;
child1⃣️>parent0⃣️, 所以不交换
新parent2⃣️, 新child5⃣️
(5+1)<9且child5⃣️<child6⃣️, 所以++child5⃣️;
child6⃣️<parent2⃣️, 所以交换
新的parent6⃣️, 新child【26+1】已经越界推出循环
- 由于 parent=在第四次循环完毕之后为-1, 所以结束第一次调整
发现, 此时已经是一个大顶堆
- 在看
while (end > 0) {
int tmp = arr[0];
arr[0] = arr[end];
arr[end] = tmp;
// 3.每次都调整堆把新的堆顶元素调整到相应位置
shiftDown(arr, 0, end--);
}
将堆顶元素与堆尾元素交换
再 shiftDown
函数继续调整尺寸-1之后的堆[也就是把90排除在外之后的堆大小]
…然后无限循环下去, 直到堆的尺寸为1之后说明调整完毕, 此时就是升序排列的数组
3.5.2 堆排序复杂度分析
/**
* 每一层共有节点数: 2^0 2^1 2^2...倒数第二层节点数: 2^(n-2)
* 每一层调整的高度: h-1 h-2 h-3...倒数第二层的高度: 1
* <p>
* 每一层节点数 * 高度数 == 时间复杂度
* 2^0 + 2^1 +...+ 2^(n-1)
* h-1 h-2 h-n
* T(N)=2^0*(h-1)+2^1*(h-2)+2^2*(h-3)+2^3*(h-4)...+2^(h-3)*2 + 2^(h-2)*1
* 2*T(N)=2^1*(h-1)+2^2*(h-2)+2^3*(h-3)+2^4*(h-4)...+2^(h-2)*2 + 2^(h-1)*1
* <p>
* T(N)=1-h + 2^1 + 2^2 + 2^3 +..+2^(h-2) + 2^(h-1)
* T(N) = 2^1 + 2^2 + 2^3 +..+2^(h-1) + 1-h
* 等比数列求和: 2^h-1
* h = logN+1
* <p>
* 节点总数: 2^h-1
*/
上面的数学公式说明了堆的排序时间复杂度为何O(logN)
它的运行时间主要损耗在构建堆和重建堆上, 在建堆过程中, 因为我们是完全二叉树丛最下层最右边的非终端节点开始构建, 将它与其他孩子进行比较和若有比较的互换, 对于每个非终端节点来说, 其实最多进行两次比较和互换操作, 因此整个构建堆的时间复杂度为O(n)
在正式排序时, 重建堆时间复杂度为O(nlongN), 因为每个节点狗咬构造
所以堆排序的时间复杂度为O(nlogN), 由于排序对原始记录的排序状态并不敏感, 因此无论是最好, 最坏和平均时间复杂度均为O(nlogN), 这在性能上显然要远远好过冒泡, 简单选择, 直接插入的时间复杂度了
3.6. 归并排序
3.6.1 归并排序原理
归并排序(Merge sort)是建立在归并操作上的一种有效的排序算法。该算法是采用分治法(Divide and Conquer)的一个非常典型的应用。
作为一种典型的分而治之思想的算法应用,归并排序的实现由两种方法:
- 自上而下的递归(所有递归的方法都可以用迭代重写,所以就有了第 2 种方法);
- 自下而上的迭代;
为了更清晰说清楚这里的思想, {16,7,13,10,9,15,3,2,5,8,12,1,11,4,6,14}
通过两两合并排序后再合并, 最终获得了一个有序数组. 注意观察它的形状, 像极了一棵倒置的完全二叉树, 通常涉及到完全二叉树结构的排序算法效率一般都不低.
3.5.1 递归实现归并排序
/*
时间复杂度:
最坏: O(logN)
最好: O(logN)
平均: O(logN)
空间复杂度: O(N)
稳定性: 稳定
数据对象: 数组, 链表
把数据分为两段, 从两段中逐个选最小的元素移入新数据段的末尾. 可以从上到下或从下到上进行
快速排序的最坏运行情况是 O(n²),比如说顺序数列的快排。但它的平摊期望时间是 O(nlogn),且 O(nlogn) 记号中隐含的常数因子很小【1.3~1.5之间】,比复杂度稳定等于 O(nlogn) 的归并排序要小很多。所以,对绝大多数顺序性较弱的随机数列而言,快速排序总是优于归并排序。
*/
class mergeSort {
void mergeSort(int[] arr) {
long start = System.currentTimeMillis();
_mergeSort(arr, 0, arr.length);
long end = System.currentTimeMillis();
System.out.println("mergeSort time:" + (end - start));
}
// 辅助递归
private void _mergeSort(int[] arr, int left, int right) {
if (right - left <= 1) {
/*
判定当前区间是不是只有一个元素或者没有元素
此时不需要进行排序
*/
return;
} else {
int mid = (left + right) >> 1;
/*
先让[left, mid)区间变成有序
再让[mid, right)区间变成有序
合并两个有序区间
二叉树的后序遍历
*/
_mergeSort(arr, left, mid);
_mergeSort(arr, mid, right);
merge(arr, left, mid, right);
}
}
/*
归并排序的核心操作就是: 归并两个有序数组, 使用 merge 方法完成数组归并的过程
此处两个数组就通过参数的 left, mid, right 描述
[left, mid): 左侧数组
[mid, right): 右侧数组
*/
private void merge(int[] arr, int left, int mid, int right) {
/*
1. 先创建一个临时空间: 保存归并的结果
2. 临时空间需要能保存下待归并的两个数组: right-left 这么长
*/
if (left >= right) {// 空区间
return;
} else {
int[] tmp = new int[right - left];
int tmpIndex = 0;// 表示当前元素该放到临时空间哪个位置上
int cur1 = left;
int cur2 = mid;
while (cur1 < mid & cur2 < right) {// 保证区间有效
if (arr[cur1] <= arr[cur2]) {// 为了保证稳定性
tmp[tmpIndex++] = arr[cur1++];// 把 cur1 对应的元素插入到临时空间中
} else {
tmp[tmpIndex++] = arr[cur2++];
}
}
// 循环结束之后, 需要把剩余的元素也都拷贝到最终结果里
while (cur1 < mid) {
tmp[tmpIndex++] = arr[cur1++];
}
while (cur2 < right) {
tmp[tmpIndex++] = arr[cur2++];
}
/*
还需要把 tmp 的结果再放回 arr 数组.(原地排序)
把原始数组的[left, right)区间替换排序后的结果
*/
for (int i = 0; i < tmp.length; i++) {
arr[left + i] = tmp[i];
}
}
}
/*
递归的过程: 就是在逐渐针对数组进行切分
非递归版本: 只需要调整下标【速度更快】
统一针对长度为 1 的数组进行合并
1.[0], [1] 是为待并归的两个数组
2.[2], [3] 是为待并归的两个数组
3.[4], [5] 是为待并归的两个数组
4.[6], [7] 是为待并归的两个数组
5.[8], [9] 是为待并归的两个数组
统一针对长度为 2 的数组进行合并
[0,1], [2,3]
[4,5], [6,7]
[8,9], [10,11]
[0,1,2,3], [4,5,6,7]
[8,9,10,11], [12,13,14,15]
*/
void mergeSortByLoop(int[] arr) {
long start = System.currentTimeMillis();
int gap = 1;// gap 用于限定分组, 每个待归并的数组长度
for (; gap < arr.length; gap *= 2) {
// 当前两个待归并的数组
for (int i = 0; i < arr.length; i += 2 * gap) {
/*
在这个数组中控制两个相邻数组进行归并
[left, mid) 和 [mid, right)就要进行归并
gap:1
i:0 0,1 1,2
i:2 2,3 3,4
i:4 4,5 5,6
...
gap:2
i:0 0,2 2,4
i:4 4,6 6,8
i:8 8,10 10,12
...
gap:4
...
gap:8
i:0 0,8 8,16【测试的数组长度是10】--> 8,10
i:16 越界
*/
int left = i;
int mid = i + gap > arr.length ? arr.length : i + gap;
int right = i + 2 * gap > arr.length ? arr.length : i + 2 * gap;
merge(arr, left, mid, right);
}
}
long end = System.currentTimeMillis();
System.out.println("mergeSortByLoop time:" + (end - start));
}
}
代码详细步骤解析:
void mergeSort(int[] arr) {
long before = System.currentTimeMillis();
mergeSortInternal(arr, 0, arr.length);
long after = System.currentTimeMillis();
System.out.println("MergeSort time: " + (after - before));
}
函数调用入口, 输入一个左闭右开的区间[0, arr.length)
private void mergeSortInternal(int[] arr, int left, int right) {
if (right - left <= 1) {
return;
} else {
int mid = (left + right) >> 1;
mergeSortInternal(arr, left, mid);
mergeSortInternal(arr, mid, right);
merge(arr, left, mid, right);
}
}
进行区间划分左右递归
最后交给merge进行合并[left, mid), [mid, right)两个区间
private void merge(int[] arr, int left, int mid, int right) {
int[] tmp = new int[right - left + 1];
int tmpIndex = 0;
int cur1 = left, cur2 = mid;
while (cur1 < mid && cur2 < right) {
if (arr[cur1] <= arr[cur2]) {// 为了保证稳定性
tmp[tmpIndex++] = arr[cur1++];// 把 cur1 对应的元素插入到临时空间中
} else {
tmp[tmpIndex++] = arr[cur2++];
}
}
// 处理剩余数据
while (cur1 < mid) {
tmp[tmpIndex++] = arr[cur1++];
}
while (cur2 < right) {
tmp[tmpIndex++] = arr[cur2++];
}
// 数据返回原数组位置, 所以加上是arr[left+i]而不是arr[i]
for (int i = 0; i < tmpIndex; i++) {
arr[left + i] = tmp[i];
}
}
区间合并的具体算法实现
假设有 {50,10,90,30,70,40,80,60,20}
数据, 那么递归代码如何执行的呢?
传入的其实左区间值是0, 右区间值是9也就是[0, 9)
然后划分区间为[0,4), 左区间为[4, 9)
[0,2), [2, 4), [4,6), [6, 9)
由于递归的终止条件是right-left<=1
也就是有两个元素的时候就推出递归, 通过merge
函数进行合并
3.5. 非递归实现归并排序
非递归代码
void mergeSortTraversalNo(int[] arr) {
long before = System.currentTimeMillis();
// gap: 限定每个待归并数组的长度
int gap = 1;
for (; gap < arr.length; gap *= 2) {
// 当前两个待归并的数组
for (int i = 0; i < arr.length; i += 2 * gap) {
int left = i;
int mid = i+gap> arr.length? arr.length : i+gap;
int right = i+2*gap> arr.length? arr.length : i+2*gap;
merge(arr, left, mid, right);
}
}
long after = System.currentTimeMillis();
System.out.println("MergeSortTraversalNo time: " + (after - before));
}
变量 gap 和 I 记住即可,
左区间为i,中间间隔是i+gap,右区间为i+2*gap
其中左中右均要保持数组不越界
3.5. 归并排序复杂度分析
我们来分析一下归并 排序的时间复杂度,一趟归并需要将arr[1] ~ arr[n]中相邻的长度为h的有序序列进行两两归并。并将结果放到tmp[1]~TR1[n]中,这需要将待排序序列中的所有记录扫描一遍, 因此耗费0(N)时间,而由完全二叉树的深度可知,整个归并排序需要进行[log2N]次,因此,总的时间复杂度为0(N logN), 而且这是归并排序算法中最好、最坏、平均的时间性能。
由于归并排序在归并过程中需要与原始记录序列同样数量的存储空间存放归并结果以及递归时深度为log2N的栈空间,因此空间复杂度为0(N+logN]。
另外,对代码进行仔细研究,发现merge函数中有if (arr[cur1]<=arr[cur2])语句, 这就说明它需要两两比较,不存在跳跃,因此归并排序是一种稳定的排序算法。
也就是说,归并排序是一种比较占用内存,但却效率高且稳定的算法。
3.7. 快速排序
终于到我们的高手登场了, 如果将来工作后, 你的老板让你一写一个排序算法, 而你会的算法中竟然没有快速排序, 还是不要声张, 偷偷去把快速排序算法找来练习练习抱抱佛脚至少不被嘲笑
希尔排序相当于是直接插入排序的升级, 他们同属于插入排序类
堆排序相当于简单选择排序的升级, 他们同属于选择排序
而快速排序则认为是前面最慢的冒泡排序的升级版, 它们都属于交换排序类
快速排序也是通过不断比较和移动交换来实现排序的, 只不过它的实现增大了记录的比较和移动的距离, 将关键字较大的记录从前面直接移动到后面; 关键字较小的记录从后面直接移动到前面, 从而减少了总的比较次数和移动交换次数
3.7.1 快速排序原理
通过一趟排序将待排记录分割成独立的两部分, 其中一部分记录的关键字均比另一部分记录的关键字小, 则可分别对这两部分记录继续进行排序, 以达到整个序列有序的目的
3.7.2 快速排序递归算法实现及优化步骤
/*
时间复杂度:
最坏: 如果是有序序列则会是O(N^2)
最好: 假设有 N 个数据, 形成一颗满二叉树. 每一层的左右子树遍历之和是 N, 数的高度就是log2(N+1)[向上取整]. 则就是 O(N log N)
平均: O(N log N)
空间复杂度:
最坏: O(logN), 遍历左子树之后再遍历右子树, 左子树的空间会释放掉. 在遍历左子树时候, 每一层一定会有一棵树, 左用完给右边用, 所以空间复杂度就是树的高度
最好: O(N)
稳定性: 不稳定
数据对象: 数组
[小数, 基准元素, 大数]-->在区间中随机挑选一个元素作为基准, 将小于基准值的元素放在基准之后, 在分别对小数区与大数区进行排序
*/
class quickSort {
void quickSort(int[] arr) {
long start = System.currentTimeMillis();
quick(arr, 0, arr.length-1);
long after = System.currentTimeMillis();
System.out.println("quickSort`time:" + (after - start));
}
private void quick(int[] arr, int left, int right) {
if (left >= right) {
return;
} else {
int pivot = partition(arr, left, right);
quick(arr, left, pivot - 1);
quick(arr, pivot + 1, right);
}
}
private int partition(int[] arr, int left, int right) {
int tmp = arr[left];
while (left < right) {
while (left < right && arr[right] >= tmp) {
--right;
}
arr[left] = arr[right];
while (left < right && arr[left] <= tmp) {
++left;
}
arr[right] = arr[left];
}
arr[left] = tmp;
return left;
}
}
代码分析
- 首先执行
quick(arr, 0, arr.length-1)
传入[0, arr.length-1]的闭区间【注意与归并排序的区间传参区分】 - 再执行
private void quick(int[] arr, int left, int right) {
if (left >= right) {
return;
} else {
int pivot = partition(arr, left, right);
quick(arr, left, pivot - 1);
quick(arr, pivot + 1, right);
}
}
这个是递归进行分治
- 再执行
private int partition(int[] arr, int left, int right) {
int tmp = arr[left];
while (left < right) {
while (left < right && arr[right] >= tmp) {
--right;
}
// 右边找到小的就直接移到左边
arr[left] = arr[right];
while (left < right && arr[left] <= tmp) {
++left;
}
// 左边找到大的就直接移到右边
arr[right] = arr[left];
}
//然后给枢纽填充且返回枢纽值
arr[left] = tmp;
return left;
}
一个中枢, 直接移动两端元素然后返回枢纽值
3.8.1 快速排序优化及测试分析性能
3.8.1.1 固定基准
/*
当内存不够就使用外排[计数排序, 基数排序, 桶排序]
10亿 字节 == 1G
4_0000_0000 字节【4亿字节】 == 400MB
100_0000 字节 == 100KB
*/
import java.util.Random;
class createArray {
private int size = 10000;
private int[] arr = new int[size];
// 随机数组
int[] randArr() {
Random random = new Random();
for (int i = 0; i < arr.length; i++) {
arr[i] = random.nextInt(size + 1);
}
return arr;
}
// 重复数组
int[] sameArr() {
Random random = new Random();
for (int i = 0; i < arr.length; i++) {
arr[i] = random.nextInt(101);
}
return arr;
}
// 升序数组
int[] ascArr() {
for (int i = 0; i < size; i++) {
arr[i] = i;
}
return arr;
}
// 降序数组
int[] descArr() {
for (int i = size - 1; i >= 0; i--) {
arr[i] = i;
}
return arr;
}
}
class quickSort {
void quickSort(int[] arr) {
long start = System.currentTimeMillis();
quick(arr, 0, arr.length - 1);
long after = System.currentTimeMillis();
System.out.println("quickSort`time:" + (after - start));
}
private void quick(int[] arr, int left, int right) {
if (left >= right) {
return;
} else {
// medianOfThree(arr, left, right);
int pivot = partition(arr, left, right);
quick(arr, left, pivot - 1);
quick(arr, pivot + 1, right);
}
}
private void medianOfThree(int[] arr, int left, int right) {
int mid = (left + right) >> 1;
if (arr[mid] > arr[left]) {
swap(arr, mid, left);
}
if (arr[mid] > arr[right]) {
swap(arr, mid, right);
}
if (arr[left] > arr[right]) {
swap(arr, left, right);
}
}
private int partition(int[] arr, int left, int right) {
int tmp = arr[left];
while (left < right) {
while (left < right && arr[right] >= tmp) {
--right;
}
arr[left] = arr[right];
while (left < right && arr[left] <= tmp) {
++left;
}
arr[right] = arr[left];
}
arr[left] = tmp;
return left;
}
private void swap(int[] arr, int left, int right) {
int tmp = arr[left];
arr[left] = arr[right];
arr[right] = tmp;
}
}
public class main {
static int[] arr = null;
static createArray product = new createArray();
static quickSort quickSort = new quickSort();
private static void testRandSort() {
arr = product.randArr();
System.out.print("随机数组");
quickSort.quickSort(arr);
}
private static void testSameSort() {
arr = product.sameArr();
System.out.print("重复数组");
quickSort.quickSort(arr);
}
private static void testAscSort() {
arr = new createArray().ascArr();
System.out.print("升序数组");
quickSort.quickSort(arr);
}
private static void testDescSort() {
arr = new createArray().descArr();
System.out.print("降序数组");
quickSort.quickSort(arr);
}
public static void main(String[] args) {
// testRandSort();
testSameSort();
testAscSort();
testDescSort();
}
}
随机数组quickSort`time:4
重复数组quickSort`time:3
升序数组quickSort`time:50
降序数组quickSort`time:44
优化方案 | 随机数组 | 重复数组 | 升序数组 | 降序数组 |
---|---|---|---|---|
固定基准 | 4ms | 3ms | 50ms | 44ms |
刚才的快速排序还是有不少可以改进的地方
3.8.2.2 随机选取基准
如果我们选取的 pivot 处于整个序列的中间位置, 那么我们可以将整个序列分成小数集合和大数集合. 但注意, 这仅仅是如果, 如果运气倒霉, 选了个最小值或者最大值作为枢纽来分治整个数组, 那么这样的划分会导致效率下降
有人说应该随机选取left和right之间的数, 虽然性能上解决了基本有序的序列快速排序是性能瓶颈, 不过随机就很可能撞大运, 随机到了一个极值那不等于白给?
下边是加入了随机选取法
private void quick(int[] arr, int left, int right) {
if (left >= right) {
return;
} else {
int index = new Random().nextInt(right - left) + left+1;
swap(arr, index, left);
int pivot = partition(arr, left, right);
quick(arr, left, pivot - 1);
quick(arr, pivot + 1, right);
}
}
随机数组quickSort`time:4
重复数组quickSort`time:2
升序数组quickSort`time:2
降序数组quickSort`time:1
优化方案 | 随机数组 | 重复数组 | 升序数组 | 降序数组 |
---|---|---|---|---|
固定基准 | 4ms | 3ms | 50ms | 44ms |
随机数选取 | 7ms | 2ms | 2ms | 1ms |
发现是得不偿失的一种操作, 因为对于这么多数字每次进行计算随机数也是一种资源开销而且还带有运气成分, 因此不可取尽管它可以优化逆序情况下的数组排序
3.7.2.2 三数取中
引入的原因:虽然随机选取枢轴时,减少出现不好分割的几率,但是最坏情况下还是O(n^2), 要缓解这种情况,就引入了三数取中选取枢轴.最佳的划分是将数组划分为等长的子序列, 最佳的枢纽值就是我们刚好选取了中间值也就是第 N/2 个数, 这么样的操作即要划分等长又要找到中间值, 显然也是不小的运行开销, 因此中值就通过 left 和 right进行预估 mid.
该如何选取 mid 呢?
我们也从刚才的测试数据中得知, 随机中枢对我们的优化快排作用不大, 因此 三数取中就使用左端, 右端和中心位置上的三个元素的中值作为枢纽元. 显然使用三数中值分割法消除了预排序输入的不好情形
举例:
int[] arr = {-1, -3, -5, -7, -9, -2, -4, -6, -8, -10};
arr[left]是-1, arr[right]是-10 则mid = (left+right)/2, arr[mid]=-9 选取三个数排序后中间枢纽值就是 -9
经过三数取中优化后就是如下效果
int[] arr = {-9, -3, -5, -7, -10, -2, -4, -6, -8, -1};
private void quick(int[] arr, int left, int right) {
if (left >= right) {
return;
} else {
medianOfThree(arr, left, right);
int pivot = partition(arr, left, right);
quick(arr, left, pivot - 1);
quick(arr, pivot + 1, right);
}
}
private void swap(int[] arr, int left, int right) {
int tmp = arr[left];
arr[left] = arr[right];
arr[right] = tmp;
}
private void medianOfThree(int[] arr, int left, int right) {
int mid = (left + right) >> 1;
if (arr[mid] > arr[left]) {
swap(arr, mid, left);
}
if (arr[mid] > arr[right]) {
swap(arr, mid, right);
}
if (arr[left] > arr[right]) {
swap(arr, left, right);
}
}
随机数组quickSort`time:3
重复数组quickSort`time:2
升序数组quickSort`time:1
降序数组quickSort`time:1
优化方案 | 随机数组 | 重复数组 | 升序数组 | 降序数组 |
---|---|---|---|---|
固定基准 | 4ms | 3ms | 50ms | 44ms |
随机数选取 | 7ms | 2ms | 2ms | 1ms |
三数取中选取枢纽 | 3ms | 2ms | 1ms | 1ms |
选取三个关键字进行排序, 将中间数作为枢纽, 一般是取左端, 中间和右端三个数, 也可以随机选取. 这样至少这个中间数一定不会是最小或者最大数, 从概率上说, 去三个数均为最小或最大数的可能性微乎其微. 因此中间数位于较为中间的值的可能性就大大提高了.
发现三数取中在随机数选取基础上更加优化了逆序情况但对于重复数组的优化不是很有提升因此当数据量很小且偏向于有序的时候, 快排的效率反而没有插排的效率高
截止范围:待排序序列长度N = 10,虽然在5~20之间任一截止范围都有可能产生类似的结果,这种做法也避免了一些有害的退化情形。摘自《数据结构与算法分析》Mark Allen Weiness 著
3.7.2.4 优化小数组时的排序方案
当数组非常小的时候直接插入是简单排序中性能最好的, 其原因在于快速排序用到了递归操作, 在大量数据排序时, 这点性能影响相对于它的整体算法优势而言是忽略的, 但如果数组只有几个记录需要排序时, 这就成了一个杀鸡用牛刀的问题.
private void quick(int[] arr, int left, int right) {
if (left >= right) {
return;
} else {
if (right - left <= 20) {
for (int i = 0; i < arr.length; i++) {
int tmp = arr[i];
int j = i - 1;
for (; j >= 0 && arr[j] > tmp; j--) {
arr[j + 1] = arr[j];
}
arr[j + 1] = tmp;
}
} else {
medianOfThree(arr, left, right);
int pivot = partition(arr, left, right);
quick(arr, left, pivot - 1);
quick(arr, pivot + 1, right);
}
}
}
随机数组quickSort`time:26
重复数组quickSort`time:19
升序数组quickSort`time:8
降序数组quickSort`time:41
优化方案 | 随机数组 | 重复数组 | 升序数组 | 降序数组 |
---|---|---|---|---|
固定基准 | 4ms | 3ms | 50ms | 44ms |
随机数选取 | 7ms | 2ms | 2ms | 1ms |
三数取中选取枢纽 | 3ms | 2ms | 1ms | 1ms |
优化小数组排序 | 26ms | 19ms | 8ms | 41ms |
发现插入排序的引入对于随机数组和重复数组是负优化【只是这里数据量只有1w, 当数据量很多的时候会发现提升很明显, 这里使用大量数据会栈溢出因此只能拿少量数据测试排序性能】
对于排好序的数组而言会有一定的影响, 是因为待排序列已经有序, 每次划分只能使待排序列减少一个数据量, 插排在这里发挥不了作用所以对降序数组的影响可以忽略
好奇的同学会注意到为何生序序列会提升如此明显?
原因是快排是生序排序, 当处理这些已经排接近好序的数组时, 插入排序会是越来越快的, 因此会很高的效率的本质其实是接住了快排的风口而降序数组则没有, 因此看的时间上减少的比优化前的低一点点
其实我们还可以优化尾递归操作进行进一步优化
3.7.2.3 优化递归操作
private void quick(int[] arr, int left, int right) {
if (left >= right) {
return;
} else {
medianOfThree(arr, left, right);
while(left < right){
int pivot = partition(arr, left, right);
quick(arr, left, pivot - 1);
left = pivor + 1;
}
}
}
随机数组quickSort`time:1
重复数组quickSort`time:0
升序数组quickSort`time:23
降序数组quickSort`time:21
优化方案 | 随机数组 | 重复数组 | 升序数组 | 降序数组 |
---|---|---|---|---|
固定基准 | 4ms | 3ms | 50ms | 44ms |
随机数选取 | 7ms | 2ms | 2ms | 1ms |
三数取中选取枢纽 | 3ms | 2ms | 1ms | 1ms |
优化小数组排序 | 26ms | 19ms | 8ms | 41ms |
优化递归操作+三数取中 | 1ms | 0ms | 23ms | 21ms |
发现极大的优化了随机数组和重复数组, 但是却损失了对于有序排序的性能. 因此这种情况比较极端
提升原因: 待排序的序列划分很不平衡, 递归的深度将趋近于n, 而栈的大小是很有限的, 每次递归调用都会耗费一定的栈空间, 函数的参数越多, 每次递归耗费的空间也越多. 优化后,可以缩减一半的堆栈深度,由原来的O(n)缩减为O(logn),将会提高性能
3.7.2.6 荷兰国旗【聚集相等元素】问题优化快排
不熟悉荷兰国旗的朋友可以先去看看OJ了解一下这个题目75. 颜色分类
给定一个包含红色、白色和蓝色,一共 n 个元素的数组,原地对它们进行排序,使得相同颜色的元素相邻,并按照红色、白色、蓝色顺序排列。
此题中,我们使用整数 0、 1 和 2 分别表示红色、白色和蓝色。
示例 1:
输入:nums = [2,0,2,1,1,0]
输出:[0,0,1,1,2,2]
示例 2:
输入:nums = [2,0,1]
输出:[0,1,2]
示例 3:
输入:nums = [0]
输出:[0]
示例 4:
输入:nums = [1]
输出:[1]
class Solution {
private void swap(int[] nums, int l, int r){
int tmp = nums[l];
nums[l]=nums[r];
nums[r]=tmp;
}
public void sortColors(int[] nums) {
if(nums == null || nums.length == 0){
return;
}else{
//less: 控制小于区间, 也有可能数组0下标就是大于区间起始, 所以是-1开始; more: 控制大于区间, 有可能 arr.length-1是一个小于区间, 因此 more 应该是 arr.length 开始
int less = -1, more = nums.length;
//cur在[less, more]之间移动比较
int cur = 0;
//遇到more就结束, 代表遇到了大于区间的开始
while(cur<more){
// 遇到小于区间元素0就交换 less的下一个 和 cur下标元素值, 此时 arr[less+1]=0, 是小于区间的元素, 所以可以自增移动, 并且cur也应该移动到下一个元素【如果理解不了第一次循环代入数值后会发现其实 ++less和cur++ 都是0, 经过虽然交换了, 但是发现元素原地不动, 这一点要和大于区间的more不移动需要注意】
if(nums[cur] == 0){
swap(nums, ++less, cur++);
// 此时遇到了等于区间元素1, 则应该保持下雨区间不动, cur指针移动
}else if(nums[cur] == 1){
++cur;
// 遇到了大于区间的元素2, 就交换 arr[cur] 和 more 之前的一个下标元素值, 但由于交换完毕之后不知道 more 之前元素和交换前 cur 下标元素大小关系, 所以不移动 more
}else{
swap(nums, cur, --more);
}
}
}
}
}
有了上述荷兰国旗的思想之后, 发现可以用于优化快速排序
我们把相同元素聚集在中间, 逐渐减少递归区间而优化的
private void quick(int[] arr, int left, int right) {
if (left >= right) {
return;
} else {
int index = left + (int)((right-left)*Math.random());
swap(arr, index, right);
int[] mid = partition(arr, left, right);
quick(arr, left, mid[0] - 1);
quick(arr, mid[0] + 1, right);
}
}
private int[] partition(int[] arr, int left, int right) {
if (left > right) {// 此时应该返回, left已经大于right
return new int[]{-1, -1};
} else if (left == right) {
return new int[]{left, left};// 此时大小区间相遇, 因此不用继续递归
} else {
int less = left - 1;// less 应该处于left之前
int more = right;// more 作为基准值的下标
int cur = left;//cur 保持在less的下一个下标用来遍历数组, 遍历范围是在等于区间中【包含左右边界】
while (cur < more) {
// 应该位于大于区间的元素
if (arr[cur] > arr[right]) {
swap(arr, cur, --more);
// 应该位于小于区间的元素
} else if (arr[cur] < arr[right]) {
swap(arr, cur++, ++less);
} else {
// 遇到了和基准值arr[right]相等元素
++cur;
}
}
/*
将最后的基准值放入等于区间.
小于区间[left, less]
等于区间[less+1, more]
大于区间[more+1, right]
*/
swap(arr, more, right);
return new int[]{less + 1, more};
}
}
随机数组quickSort`time:5
重复数组quickSort`time:2
升序数组quickSort`time:1
降序数组quickSort`time:1
优化方案 | 随机数组 | 重复数组 | 升序数组 | 降序数组 |
---|---|---|---|---|
固定基准 | 4ms | 3ms | 50ms | 44ms |
随机数选取 | 7ms | 2ms | 2ms | 1ms |
三数取中选取枢纽 | 3ms | 2ms | 1ms | 1ms |
优化递归操作+三数取中 | 1ms | 0ms | 23ms | 21ms |
优化递归操作+三数取中+优化小数组排序 | 0ms | 1ms | 18ms | 21ms |
荷兰国旗+随机数选取 | 5ms | 2ms | 1ms | 1ms |
其实这里也可以进行优化递归操作
完整代码如下:
class quickSort {
void quickSort(int[] arr) {
long before = System.currentTimeMillis();
quick(arr, 0, arr.length - 1);
long after = System.currentTimeMillis();
System.out.println("quickSort`s Time:" + (after - before));
}
private void quick(int[] arr, int left, int right) {
if (left >= right) {
return;
} else {
int index=left+(int)((right-left)*Math.random());
swap(arr,index, right);
while (left<right){
int[] mid = partition(arr, left, right);
quick(arr, left, mid[0] - 1);
left=mid[0]+1;
}
}
}
private int[] partition(int[] arr, int left, int right) {
if (left>right){
return new int[]{-1,-1};
}else if (left==right){
return new int[]{left, left};
}else{
int les=left-1;
int more=right;
int cur=left;
while (cur <more){
if (arr[cur]>arr[right]){
swap(arr, cur, --more);
}else if (arr[cur]<arr[right]){
swap(arr, cur++, ++les);
}else{
++cur;
}
}
swap(arr, more,right);
return new int[]{les+1, more};
}
}
private void medianOfThree(int[] arr, int left, int right) {
int mid = (left + right) >> 1;
if (arr[mid] > arr[left]) {
swap(arr, left, mid);
}
if (arr[mid] > arr[right]) {
swap(arr, mid, right);
}
if (arr[left] > arr[right]) {
swap(arr, left, right);
}
}
private void swap(int[] arr, int left, int right) {
int tmp = arr[left];
arr[left] = arr[right];
arr[right] = tmp;
}
}
3.7.4 快速排序非递归实现
3.7.4.1 没有用荷兰国旗优化的partition递归
// 非递归实现快排
void quickSortTraversalNo(int[] arr) {
long before = System.currentTimeMillis();
Stack<Integer> stack = new Stack<>();
stack.push(0);
stack.push(arr.length - 1);
while (!stack.empty)) {
// 注意存取顺序
int right = stack.pop();
int left = stack.pop();
if (left >= right) {
continue;
} else {
int pivot = partition(arr, left, right);// 没有用荷兰国旗优化的partition
// 左右区间谁先开始的顺序不影响
stack.push(left);
stack.push(pivot - 1);
stack.push(pivot + 1);
stack.push(right);
}
}
long after = System.currentTimeMillis();
System.out.println("QuickSortTraversalNo time: " + (after - before));
}
3.7.4.2 使用荷兰国旗的parition递归
void quickSortTraversalNo(int[] arr){
Stack<Integer> stack = new Stack<>();
stack.push(0);
stack.push(arr.length-1);
while (!stack.empty()){
int right = stack.pop();
int left = stack.pop();
int[] mid = partition(arr, left, right);
//等于区间的右边界more还有一个或者多个元素的时候就入栈
if (mid[1]+1<right){
stack.push(mid[1]+1);
stack.push(right);
}
//等于区间的左边界less+1还有一个或者多个元素的时候就入栈
if (mid[0]-1>left){
stack.push(left);
stack.push(mid[0]-1);
}
}
}
3.7.5 快速排序复杂度分析
我们来分析一下快速排序法的性能. 快速排序的时间性能取决于快速排序递归的深度, 可以用递归树来描述递归算法的执行情况.
{50,10,90,30,70,40,80,60,20}在快速排序过程中的递归过程. 由于我们的第一个关键字是50,正好是待排序的序列的中间值,因此递归树是平衡的,此时性能也比较好.
在最优情况下, Partition
每次都划分的很均匀, 如果排序 n 个关键字, 其递归树的深度就为 logN, 需要时间为T(N)的话, 第一个 Partition
应该是需要对整个数组进扫描一次, 做 n 次比较. 然后, 获得的枢纽将数组一分为二, 那么个子还需要 T(n/2) 的时间(注意是最好时间, 所以平分两半). 于是不断的划分下去, 我们就有了下面的不等式
T(N) <= 2T(N/2) + N, T(1)=0
T(N)<=2(2T(N/4)+N/2) + N = 4T(N/4)+2N;
T(N)<=4(2T(N/8)+N/4) + N = 8T(N/8)+3N;
…
T(N) <= NT(1)+(logN)*N = O(NlogN)
也就是说, 在最优的情况下, 排序算法的时间复杂度为O(NlogN)
在最坏情况下, 带排序的数据为正序或者逆序, 每次划分只得到一个比上一次划分少一个记录的子序列, 注意另一个为空.也就是斜树, 此时需要执行 n-1 次递归调用, 且经过 n-1次比较才能找到第 i 个记录, 也就是枢纽的位置. 用多项式累加求和就是:
1+2+3+ … + n-2 + n-2
就是n(n-1)/2
就空间复杂度来说, 主要是递归造成的栈空间的使用, 最好情况递归树的深度为 logN, 其空间复杂度也就为 O(logN), 最坏情况, 需要进行 n-1 次递归调用, 其空间复杂度为 O(N), 平均而言就是 O(logN)
由于是跳跃式的比较和交换, 所以也是一种不稳定的排序
3.8 计数排序
3.8.1 计数排序原理
计数排序的核心在于将输入的数据值转化为键存储在额外开辟的数组空间中。作为一种线性时间复杂度的排序,计数排序要求输入的数据必须是有确定范围的整数.
3.8.2 计数排序算法实现
class countSort {
int[] countSort(int[] arr) {
//1.选择最值
int max = arr[0], min = arr[0];
for (int e : arr) {
max = max < e ? e : max;
min = min > e ? e : min;
}
// 2.统计出现个数: 这里的arr[i]-min是为了解决负数的情况
int count[] = new int[max - min + 1];
for (int e : arr) {
++count[e - min];
}
// 3.填充到新数组
int[] newArr = new int[arr.length];
int index = 0;
for (int i = 0; i < count.length; i++) {
while (count[i]-- > 0) {
newArr[index++] = min + i;// 填充的时候再补上最小值即可还原原数据
}
}
return newArr;
}
}
3.8.2 计数排序复杂度分析
如果给 N 个数字利用计数排序, 则时间复杂度是O(N+k), 空间复杂度就是O(k)了, 计数排序不是比较排序, 相较于比较排序速度会快一点.
由于要开辟额外的内存空间, 也就是最值的差再加1, 所以当数据量过于庞大的时候就需要大量的时间和空间.而且并不适合非数字的排序, 比如按照人名排序.
4. 总结回顾
4.1 复杂度一览表
排序算法 | 平均时间复杂度 | 最好情况 | 最坏情况 | 空间复杂度 | 稳定性 | 排序方式 |
---|---|---|---|---|---|---|
冒泡排序 | O(N2) | O(N) | O(N2) | O(1) | 稳定 | 内排 |
选择排序 | O(N2) | O(N2) | O(N2) | O(1) | 不稳定 | 内排 |
插入排序 | O(N2) | O(N) | O(N2) | O(1) | 稳定 | 内排 |
希尔排序 | O(logN) | O(N logN) | O(N logN) | O(1) | 不稳定 | 内排 |
堆排序 | O(N logN) | O(N logN) | O(N logN) | O(1) | 不稳定 | 内排 |
归并排序 | O(N logN) | O(N logN) | O(N logN) | O(N) | 稳定 | 外排 |
快速排序 | O(N logN) | O(N logN) | O(N logN2) | O(N) | 不稳定 | 内排 |
计数排序 | O(N+k) | O(N+k) | O(N+k) | O(k) | 稳定 | 外排 |
事实上,目前还没有十全十美的排序算法,有优点就会有缺点,即使是快速排序法,也只是在整体性能上优越,它也存在排序不稳定、需要大量辅助助间、对少量数据排序无优势等不足。因此我们就来从多个角度来剖析一下提到的各种排序的长与短。
4.2 应用场景分析
从算法的特性来看可以分为两大类
- 比较排序:
-
- 简单算法: 冒泡, 简单选择, 直接插入
-
- 改进算法: 希尔, 堆排序, 归并排序, 快速排序
- 非比较排序: 计数排序, 桶排序, 基数排序
从平均情况来看,显然最后3种改进算法要胜过希尔排序,并远远胜过前3种简单算法。
从最好情况看,反而冒泡和直接插入排序要更胜一筹, 也就是说,。如果你的待排序序列总是基本有序,反而不应该考虑4种复杂的改进算法。
从最坏情况看,堆排序与归并排序又强过快速排序以及其他简单排序。
从这三组时间复杂度的数据对比中,我们可以得出这样一个认识。 堆排序和归并排序就像两个参加奥数考试的优等生,心理素质强,发挥稳定。而快速排序像是很情绪化的天才,心情好时表现极佳,碰到较糟糕环境会变得差强人意。但是他们如果都来比赛计算个位数的加减法,它们反而算不过成绩极普通的冒泡和直接插入.
从空间复杂度来说,归并排序强调要马跑得快,就得给马吃个饱。快速排序也有相应的空间要求,反而堆排序等却都是少量索取,大量付出,对空间要求是0(1]。 如果执行算法的软件所处的环境非常在乎内存使用量的多少时,选择归并排序和快速排序就不是一个较好的决策了.
从稳定性来看,归并排序独占鳌头,我们前面也说过,对于非常在乎排序稳定性的应用中,归并排序是个好算法.
从待排序记录的个数上来说,待排序的个数n越小,采用简单排序方法越合适。反之, n越大,采用改进排序方法越合适。这也就是我们为什么对快速排序优化时,增加了一个阀值,低于阀值时换作直接插入排序的原因.
选择排序在3种简单排序中性能最差?
其实并非如此, 比如: 如果记录的关键字本身信息量比较大, 此时表明其占用存储空间很大, 这样移动记录所花费的时间也就越多. 此时就会想起我们的简单选择排序, 像是一个老练的股票操盘手一样, 有的放矢很少操作. 因此对于数据量不是很大而关键字信息量比较大的排序要求, 简单选择排序是占优的, 另外记录的关键字信息量大小对于四种改进算法影响不大.
总之, 从综合分析来看, 经过优化的快速排序是性能最好的排序算法, 但是不同的场合也应该考虑使用不同的算法来应对问题.