java中常见的排序算法设计介绍
排序算法
- 复杂度
- 原地排序
- 冒泡排序
- 算法逻辑
- 时间复杂度:最好O(n),最坏和平均O(n^2)
- 冒泡排序:稳定性算法
- 选择排序
- 算法逻辑
- 时间复杂度:最好,最坏和平均都是O(n^2)
- 选择排序:不稳定性算法
- 插入排序
- 算法逻辑
- 时间复杂度:最好O(n),最坏和平均O(n^2)
- 插入排序:稳定性算法
- 希尔排序
- 逻辑步骤
- 时间复杂度
- 希尔排序:不稳定算法
- 快速排序
- 步骤逻辑
- 时间复杂度:最好:O(nlog2n),平均:O(nlog2n),最坏:O(n^2)
- 快速排序:不稳定算法
- 归并排序
- 逻辑步骤
- 时间复杂度:最好 平均 最坏都是O(nlog2n)
- 归并排序:稳定性算法
- 堆排序
- 最大堆MaxHeap
- 堆排序
- 堆调整
- 使用堆(Heap)来实现优先级队列管理
- 时间复杂度
- 堆排序:不稳定算法
复杂度
在介绍具体的排序分类之前,先引入时间空间复杂度的概念,这是各个排序算法的应用选择条件
算法的复杂度是一个函数,一个语句的频度是指该语句在算法中重复执行的次数时间复杂度是循环执行次数值
,次数越多那执行越复杂需要运行时间或占用空间也就越多,所以也用它定量描述算法运行时间或空间的大小,理论上我们运行时间越短空间越小越好,也就是循序执行次数越少也好
常见的时间复杂度并且按照从小到大排序
如果一个数据集的规模是n
O(1) < O(logn) < O(n) < O(nlogn) < O(n^2) < O(n^3) < O(2^n)O(1):表示与元素数量无关,多了少了都一样为1,这种时间复杂度是最低的
O(logn) :其实有个省略的底数,可以是2可以是10具体情况看待,如每次2分,那就是2,已2为底数的对数向上取整,比如n为10,2^4 > 10 里O(logn)这代表值就是4
O(n):表示与元素数量大小正相关,多少元素执行多少次
O(nlogn) :算法的运行时间会以n乘以logn的速度增长nlogn的意思是 n * logn,O(nlogn) = O(n) * O(logn) ,归并排序就是这个复杂度
O(n^2):复杂度值与输入数据大小n的二次方成正比,比如双重for循环:(n-1)+(n-2)+…+2+1 = n*(n-1)/2
O(n^3):复杂度值与输入数据大小n的三次方成正比,三层for循环:0+(1-1)*1/2+...+(n-1)n/2=n(n+1)(n-1)/6
O(2^n):复杂度随着输入数据规模 n 的增加呈指数增长
原地排序
原地排序就是指在排序过程中不申请多余的存储空间,只利用原来存储待排数据的存储空间进行比较和交换的数据排序
int[] array = {5,4,3,2,1};
//临时存储元素4
int temp = array[1];
//将索引位置2的元素 更新替换掉索引位置1的元素
array[1] = array[2];//在将原索引位置1的元素更新替换掉索引位置2的元素
array[2] = temp;
这种方式 不申请多余的存储空间,利用原数组的存储空间进行交互数据,也是一种排序,至于先介绍它是因为后面介绍的排序都用到它
冒泡排序
通过重复地遍历待排序的列表,比较相邻的元素并交换它们的位置来实现排序。该算法的名称来源于较小的元素会像"气泡"一样逐渐"浮"到列表的顶端
算法逻辑
它通过不断交换相邻的元素将最大(或最小)的元素逐渐“冒泡”到数组的末尾
// 将数组元素大小按照从小到大排序public static void main(String[] args) {int[] array = {5,4,3,2,1};int count1 = 0;int count2 = 0;int n = array.length;for (int i = 0; i < n - 1; i++) {count1 = count1+1;System.out.println("外循环第"+(i+1)+"次");for (int j = 0; j < n - i - 1; j++) {if (array[j] > array[j + 1]) {count2 = count2+1;// Swap array[j+1] and array[j]int temp = array[j];array[j] = array[j + 1];array[j + 1] = temp;System.out.println("内循环第"+(j+1)+"次"+Arrays.toString(array));}}}System.out.println("外循环总次数:"+count1+"内循环总次数:"+count2+",最终排序数组"+Arrays.toString(array));}
打印输出
它通过不断交换相邻的元素将最大(或最小)的元素逐渐“冒泡”到数组的末尾
外层循环一次就能固定一个元素外循环第1次
内循环第1次[4, 5, 3, 2, 1]
内循环第2次[4, 3, 5, 2, 1]
内循环第3次[4, 3, 2, 5, 1]
内循环第4次[4, 3, 2, 1, 5] (n-1)
外循环第2次
内循环第1次[3, 4, 2, 1, 5]
内循环第2次[3, 2, 4, 1, 5]
内循环第3次[3, 2, 1, 4, 5] (n-2)
外循环第3次
内循环第1次[2, 3, 1, 4, 5]
内循环第2次[2, 1, 3, 4, 5] (n-3)
外循环第4次
内循环第1次[1, 2, 3, 4, 5] (n-4)
外循环总次数:4内循环总次数:10,最终排序数组[1, 2, 3, 4, 5]最坏情况下就是原数组元素大小顺序是和我们需求相反的
需要进行(n-1)+(n-2)+…+2+1 = n*(n-1)/2次比较和交换操作,用时间复杂度表示 是O(n^2)最好的情况就是 int[] array = {1,2,3,4,5}; 原数组顺序正好是我们需要的,这种
外循环总次数:4内循环总次数:0,最终排序数组[1, 2, 3, 4, 5] 时间复杂度是(0/n)
每外层循序一次通过相邻元素两两比较并交换,最终确定一个最值的元素 放在末尾
第二次外循环 确定第二个最值元素放在倒数第二位
以此类推 到确定倒数第二个元素,那剩下的一个元素就自然是首位了
索引 | 0 | 1 | 2 | 3 | 4 |
---|---|---|---|---|---|
外循环第1次 | * | * | * | * | 5 |
外循环第2次 | * | * | * | 4 | 5 |
外循环第3次 | * | * | 3 | 4 | 5 |
外循环第4次 | 1 | 2 | 3 | 4 | 5 |
冒泡排序的时间复杂度在(0/n) 到 O(n^2)
之间,但(0/n)是极端才有,默认的时间复杂度为O(n^2)
平均时间复杂度:O(n^2)
最坏时间复杂度:O(n^2)
最好时间复杂度:O(n)
时间复杂度:最好O(n),最坏和平均O(n^2)
什么情况下时间复杂度为(0/n)呢,数组元素顺序正好是我们排序需要的顺序,比如我们需要正序,数组正好是[1, 2, 3, 4, 5] 这样正序的,而且还需要优化下排序算法,上面算法复杂度是O(n^2)
public static void bubbleSort(int arr[]) {// 添加一个是否进行了交互元素的标识boolean didSwap;for(int i = 0, len = arr.length; i < len - 1; i++) {//初始还没有交互元素 默认flasedidSwap = false;for(int j = 0; j < len - i - 1; j++) {//第一次内循环 n-1次,不断相邻位置比较if(arr[j + 1] < arr[j]) {//走到这里证明有需要相互交互的元素 执行了交换swap(arr, j, j + 1);//交换过元素 变更标识 证明了数组调整了 原数组不是正好排序的didSwap = true;}}// 如果标识为false,代表不需要交换元素,原数组正好排序的,那就直接退出if(didSwap == false)return; }}// 原地排序private static void swap(int[] arr, int j, int i) {int temp = arr[i];arr[i] = arr[j];arr[j] = temp;}
如果int[] arr = {1,2,3,4,5};这样正序的数组,那么再i=0,然后进行4次内循环之后,就会因didSwap == false触发return,循环结束,时间复杂度:O(n),这就是最好也是最极端的情况
冒泡排序:稳定性算法
稳定性是指在排序过程中保持相等元素的相对顺序不变。简单来说,如果一个排序算法能够保证相等元素的顺序相对位置不发生改变,那么它就是稳定的
比如:[5,4,3,3,2] 排序的过程中无论怎么调整
相等的两个元素3相对位置顺序不变;即第一个元素3始终在第二个元素3前面
冒泡排序:冒泡排序是稳定的,因为在比较相邻元素并交换时,只有当前元素比相邻元素大才会交换, 相等元素的顺序不会发生交换的,这种稳定性在处理具有多个相同键值的记录时十分有用
选择排序
算法逻辑
先找到最小元素所在位置的索引,然后将该元素与第一位上的元素进行交换
int[] array = {5,4,3,2,1};int count1 = 0;int count2 = 0;for (int i = 0; i < array.length-1; i++) {int swapIndex = i;//获取剩余未排序元素最小值的索引值for (int j = i + 1; j < array.length; j++) {if (array[j] < array[swapIndex]) {// 如果在这一步 是两个比较元素符合条件直接替换 就是冒泡排序了swapIndex = j;}}//获取到最小元素的索引值不是 不是当前元素的 直接替换if(swapIndex != i){int swapValue = array[i];array[i] = array[swapIndex];array[swapIndex] = swapValue;}System.out.println("外循环第"+(i+1)+"次变更后"+Arrays.toString(array));}
打印输出
外循环第1次变更后[1, 4, 3, 2, 5]第1次外循环:内循环4次得最小元素,并根据索引替换第一个元素
外循环第2次变更后[1, 2, 3, 4, 5]第2次外循环:内循环3次得剩余最小的元素,并根据索引替换第二个元素
外循环第3次变更后[1, 2, 3, 4, 5]第3次外循环:内循环2次得剩余最小的元素,并根据索引替换第三个元素
外循环第4次变更后[1, 2, 3, 4, 5]第4次外循环:内循环1次得剩余最小的元素,并根据索引替换第四个元素
外循环总次数:4 内循环总次数:10,最终排序数组[1, 2, 3, 4, 5]
处理逻辑是
索引 | 0 | 1 | 2 | 3 | 4 |
---|---|---|---|---|---|
外循环第1次 | 确定 | ||||
外循环第2次 | 确定 | 确定 | |||
外循环第3次 | 确定 | 确定 | 确定 | ||
外循环第4次 | 确定 | 确定 | 确定 | 确定 |
选择排序:按照空间顺序,每次从未排序的序列中 通过遍历比较确定,选择
一个元素 替换到当前待确认的空间
和冒泡排序相似地方在于,每次外循环一次都能确定一个最值
区别就在于:
冒泡排序每次内循环符合条件的都有交换一次(可能需要多次交换元素),最多替换值次数是O(n^2)
选择排序内循环上只获取最值的索引值,在外层循环里交换值(每次外循环交换一次),最多替换值次数是n-1
这样看其实选择排序是冒泡排序的优化后的排序,虽然两者时间复杂度看似一样,但实际应用中选择使用选择排序的比较多,因为选择排序的交换次数较少,通常比冒泡排序更快
时间复杂度:最好,最坏和平均都是O(n^2)
时间复杂度上
平均时间复杂度:O(n^2)
最坏时间复杂度:O(n^2)
最好时间复杂度:O(n^2)
正如这个逻辑图所示,n为5
索引 | 0 | 1 | 2 | 3 | 4 |
---|---|---|---|---|---|
元素 | 1 | 2 | 3 | 4 | 5 |
外循环第1次 | 确定1 | ||||
外循环第2次 | 确定1 | 确定2 | |||
外循环第3次 | 确定1 | 确定2 | 确定3 | ||
外循环第4次 | 确定1 | 确定2 | 确定3 | 确定4 | 余下的5 |
第1次外循环,内循环n-1
第2次外循环,内循环n-2
第3次外循环,内循环n-3
第4次外循环,内循环n-4
需要进行(n-1)+(n-2)+…+2+1 = n*(n-1)/2次,用时间复杂度表示 是O(n^2)
最好情况下 数组顺序正好是排序的,就是上面展示 的数组元素样式,但依然需要 n*(n-1)/2次
选择排序:不稳定性算法
选择排序的工作原理是:
第一次从待排序的数据元素中选出最小(或最大)的一个元素,存放在起始位置,
然后再从剩余的未排序元素中寻找到最小(大)元素,然后存放到第二位。
以此类推,直到全部待排序的数据元素的个数为零
选择排序在交换过程中可能会改变相同元素间的原始顺序
在一趟选择,如果一个元素比当前元素小,而该小的元素又出现在一个和当前元素相等的元素后面,那么交换后稳定性就被破坏了
比如序列arr = [5 8 5 2 9],第一遍选择中第1个元素5会和2交换,
数组变成arr = [2 8 5 5 9],两个相对元素5相对先后顺序变化了,所以选择排序是非稳定的
插入排序
算法逻辑
通过构建有序序列,对于未排序的数据,在已排序序列中从后向前扫描,找到相应位置并插入
,工作原理类似于整理扑克牌
int[] array = {5,4,3,2,1};for (int i = 1; i < array.length; i++) {for (int j = i; j > 0; j--) {//不断和有序列表元素比较大的放后面if (array[j - 1] > array[j]) {int tmp = array[j - 1];array[j - 1] = array[j];array[j] = tmp;}}}}
打印输出
外循环第1次 第2个元素和第1个元素比较,符合条件替换
内循环第1次[4, 5, 3, 2, 1]
外循环第2次 第3个元素和前2个元素比较,符合条件替换
内循环第1次[4, 3, 5, 2, 1]
内循环第2次[3, 4, 5, 2, 1]
外循环第3次 第4个元素和前3个元素比较,符合条件替换
内循环第1次[3, 4, 2, 5, 1]
内循环第2次[3, 2, 4, 5, 1]
内循环第3次[2, 3, 4, 5, 1]
外循环第4次 第5个元素和前4个元素比较,符合条件替换
内循环第1次[2, 3, 4, 1, 5]
内循环第2次[2, 3, 1, 4, 5]
内循环第3次[2, 1, 3, 4, 5]
内循环第4次[1, 2, 3, 4, 5]
外循环总次数:4内循环总次数:10,最终排序数组[1, 2, 3, 4, 5]外层循环标识并决定待比较的数值
内层循环为待比较数值确定其最终位置
和上面的排序的区别在于:
冒泡排序每次外循序将一个数归位,将最值元素放入合适的对应位置
选择排序每次外循环能确定一个索引位置 选择了哪个元素
而直接插入排序每次外循环确定的是一个有序列表
,这个有序列表每次外循序一次 增加一个元素到这个有序表
第一次外循环 比较索引1的值和首的值大小并决定是否交换,后面的元素内容不管的,着就相当于把原数组切割
,相当于得到一个有序数组{4,5} 和 剩余待管理的数组{x,x,x}
索引 | 0 | 1 | 2 | 3 | 4 |
---|---|---|---|---|---|
外循环第1次 | 4 | 5 | * | * | * |
第二次循环,从剩余待管理数组{x,x,x}按照顺序取一个元素和有序数组{4,5}里面的元素比较并直接插入合适的位置,相当于得到一个有序数组{3,4,5} 和 剩余待管理的数组{x,x,x}
,然后依次类推
索引 | 0 | 1 | 2 | 3 | 4 |
---|---|---|---|---|---|
外循环第1次 | 3 | 4 | 5 | * | * |
该算法设计亮点之一在于利用了数组元素在存储空间的联系性,原数组切割后有序列表和未管理列表总空间大小还是原数组空间,未管理列表拿出以为元素相当于空间减少1 对于的有序列表空间加1
时间复杂度:最好O(n),最坏和平均O(n^2)
上面的逻辑步骤里面 随着有序列表越长,内循环次数越多,最坏的情况就是数组顺序完全是相反的
原始数组 [5, 4, 3, 2, 1]外循环第1次:确定有序列表[4, 5, *, *, *] 内循环1一次:n-4
外循环第2次:确定有序列表[3, 4, 5, *, *] 内循环2一次:n-3
外循环第3次:确定有序列表[2, 3, 4, 5, *] 内循环3一次:n-2
外循环第4次:确定有序列表[1, 2, 3, 4, 5] 内循环4一次:n-1
需要进行(n-1)+(n-2)+…+2+1 = n*(n-1)/2次,用时间复杂度表示 是O(n^2)
上面的展示代码就是最坏情况,时间复杂度就是O(n^2)
那最好的情况下数组顺序正好是正序的,原始数组 [1, 2, 3, 4, 5],但是需要调整一步算法逻辑:
调整的插入排序算法加强点在于:利用已知序列表的排序性,逻辑解读:
原始数组 [1,3,5,4,6,8]按正序
第一次外循环
第一个元素和第二个元素比较并确定长度为2的有序列表
得到一个有序数组{1,3} 和 剩余待管理的数组{x,x,x,x}第二次外循环
第三个元素5和有序列表元素{1,3}比较
这里就利用已知序列表的排序性,有序列表已经排序了,最后元素也是最大的是3
拿元素5和元素3比较,如果比元素3大,那有序列表前面的元素一定都比该元素小的,就不用比了
得到一个有序数组{1,3,5} 和 剩余待管理的数组{x,x,x}第三次外循环
第四个元素4和有序列表元素{1,3,5}比较
拿元素4和元素5比,结果比元素5小,交换两者位置
然后继续往前比
拿元素4和元素3比,结果比元素3大,好,前面的都不用再比了循序上面步骤直到外层循序结束,那这里看下时间复杂度
外层循序总共次数n-1次,这个省不掉的外层循序对应的内层循环里,每次最理想的情况就是,待比较的元素比有序列表的最后元素大,
这样外层循序里的比较次数就是1,那这种情况下的总的时间复杂度就是n-1就是O(n)
这种思路对应的代码:
public static void main(String[] args) {int[] array = {1,2,3,4,5};int i, j;int temp;//外层循序从未知排序列表开始for (i = 1; i < array.length; i++) {//未知排序列表第一个元素,准备往有序列表插入的元素temp = array[i];j = i - 1;/**待插入的元素 和有序列表从后往前顺序开始比较大小*不满足j>=0 && array[j] > array[i] 的两种情况* 1.有序列表从后往前遍历到头了结束这次循环* 2.array[j] < array[i]找到合适插入的位置了*/for (;j>=0 && array[j] > array[i];j-=1){//插入的位置不符合要求,有序列表的元素往后移array[j + 1] = array[j];j--;}//待插入的元素插入到合适位置array[j + 1] = temp;}
所以直接插入排序复杂度在(0/n)
到 O(n^2)
之间,但(0/n)也是极端下才有,这段展示代码时间复杂度就是O(n) ,默认的平均时间复杂度为O(n^2)
平均时间复杂度:O(n^2)
最坏时间复杂度:O(n^2)
最好时间复杂度:O(n):正好数组的顺序就是我们需要的排序,而且优化算法
插入排序:稳定性算法
插入排序:插入排序是稳定的,因为插入时只有当前元素比前面的元素小才会插入,并且插入位置是有序区的最后一个位置
比如数组序列arr = [1,3,2,2]
第一次排序后变化 [1,2,3,2]
第二次排序后变化 [1,2,2,3]
相对元素2的相对顺序没有变化,原来在前面的元素2还是在前面
希尔排序
插入排序每次移动比较的是1位,希尔排序是插入排序的高效改进版本
,通过引入“增量gap”分组来预处理数据
,使得元素可以一次移动多位,从而减少总的移动次数和后续插入排序的工作量
逻辑步骤
1.选择增量序列gap:
增量序列的选择对希尔排序的性能至关重要。常见的增量序列
有:
- 希尔原始序列:
gap = n/2, n/4, ..., 1
- Hibbard序列:
1, 3, 7, ..., 2^k-1
- Sedgewick序列:
1, 5, 19, 41, ...
(由9×4^k - 9×2^k +1或4^k - 3×2^k +1组成)
2.按增量分组对每个子序列 进行插入排序操作:
区别于直接插入排序,每次都是从未排序的数组排序选择出最值插入到有序区间,这样相当于每次增量都是1。希尔排序通过增量序列值,每次都是“跳着”选择排序的空间,比如gap = n/2
初始数组: [8, 3, 5, 1, 4, 2, 7, 6]0 1 2 3 4 5 6 7
gap=4:增量分组
Group1: [8, 4] -> 排序后虚拟数组: [4, 8]
Group2: [3, 2] -> 排序后虚拟数组: [2, 3]
Group3: [5, 7] -> 排序后虚拟数组: [5, 7]
Group4: [1, 6] -> 排序后虚拟数组: [1, 6]
这样的好处在于
每个虚拟数组的首位都是最值,最终排序数组的首位的最值也从这些总序列数组产生
同样第二个最值也从各自排序后子序列中的第二位产生,这样竖着合并起来
第一次合并[4, 2, 5, 1, 8, 3, 7, 6]缩小增量继续排序(gap = 2)分组:
当前数组[4, 2, 5, 1, 8, 3, 7, 6]
对应索引:0 1 2 3 4 5 6 7
Group1: [4, 5, 8, 7] -> 排序后: [4, 5, 7, 8]
Group2: [2, 1, 3, 6] -> 排序后: [1, 2, 3, 6]
第二次合并[4, 1, 5, 2, 7, 3, 8, 6]缩小增量继续排序(gap = 1):最终插入排序
执行标准插入排序:
[4, 1, 5, 2, 7, 3, 8, 6]
第一次→ [1, 4, 5, 2, 7, 3, 8, 6]
第二次→ [1, 4, 5, 2, 7, 3, 8, 6]
第三次→ [1, 2, 4, 5,7, 3, 8, 6]
直到完成最终排序
→ [1, 2, 3, 4, 5, 6, 7, 8] (完成)
下面已希尔原始序列gap(每次减半
)为例 代码展示
public static void shellSort(int[] arr) {int n = arr.length;//初始增量序列设为数组长度的一半,逐步缩小增量直到gap为1for (int gap = n/2; gap > 0; gap /= 2) {// 对每个子序列进行插入排序(从gap开始)subsequenceShort(array,n,gap);}}
进入每个子序列进行插入排序subsequenceShort方法
private static void subsequenceShort(int[] array,int n, int gap) {//开始位置从每次增量分组索引位置开始for (int i = gap; i < n; i++) {// 当前待插入变量元素array[gap]int temp = array[i]; int j;// 对以gap为间隔的子序列进行插入排序,将比temp大的元素向前移动 交换for (j = i; j >= gap && array[j - gap] > temp; j -= gap) {array[j] = array[j - gap];}// 插入到正确位置array[j] = temp;}}
这段代码眼熟吗,和上面插入排序优化时间复杂度算法里的一样,只是插入排序每次移动单位是1,这里替换成了增量序列gap
时间复杂度
如果按照上面每次已一半为增量序列
gap = gap/2 初始gap= n/2
那么最外层的循环次数,就是底数为2的时间复杂度O(logn)
对于每个增量的内层循环,就是一个插入排序
平均时间复杂度:O(n^2)
最坏时间复杂度:O(n^2)
最好时间复杂度:O(n):
所以总体时间复杂度
最好的情况就是O(n) * O(logn) = O(nlogn),
最坏的情况gao增量设置为1的定量,这时候的希尔排序就完全退化成了退化排序,那最坏的情况下的时间复杂度就完全取决于插入排序的最坏时间复杂度O(n^2)
所以希尔排序的时间复杂度的范围空间在O(nlogn)~O(n^2)
主要取决于
1.增量系列的选择,比如每次是一半 还是三分之一 还是4分之一
2.子序列的有序性:根据增量序列的子序列排序性状况越好,对应的时间复杂度越低
不同增量序列的时间复杂度
{1, 2, 4, 8, …}:这种序列的时间复杂度在最坏情况下是O(n^2)。
{1, 3, 7, …, 2^k-1}: 这种序列的时间复杂度在最坏情况下是O(n^1.5)。
{1, 5, 19, 41, 109, …}:这种序列的时间复杂度在最坏情况下可以达到O(n^1.3)
虽然看着希尔排序的时间复杂度好像还不如插入排序,但是对应中等规模的数据处理上是比较快速的
希尔排序:不稳定算法
希尔排序不是稳定性的算法,虽然是插入排序的改进,原有就在于增量序列的跳这比对
初始数组: [1, 3, 3, 2],初始增量gap = n/2
对应元素:a b c d 表示
gap=2:增量分组
Group1: [1, 3] -> 排序后虚拟数组: [1, 3] 这里的元素是[a, c]
Group2: [3, 2] -> 排序后虚拟数组: [2, 3]这里的元素是[d, b]
第一次合并[1, 2, 3, 3]对应元素是[a,d,c, b] 这里 bc两个相等元素的相对顺序变化了
所以希尔排序不是稳定算法
快速排序
快速排序:采用分区治理策略。基本思想是选一个元素作为基准(pivot),将数组分成两部分,使得左边部分的元素都小于等于基准,右边部分的元素都大于等于基准,然后递归地对左右两部分进行排序
步骤逻辑
快速排序步骤
1.选择基准(pivot)
:数组中随机选一个元素,一般选首尾或者最中间位置的
(这样好比较,移动不乱)
2.分区(Partition)
:将数组分成两部分 左边元素都小于基准元素值5 右边元素都大于基准元素5
public static void main(String[] args) {//定义一个乱序的数组int[] arr = {4,6,7,9,8,1,3,5};int head = 0;int tail = arr.length-1;// 进入自定义快速排序方法进行排序quickSort(arr,head,tail);System.out.println(Arrays.toString(arr));
}
// 进入到自定义的快速排序的方法
private static void quickSort(int[] arr, int head, int tail) {// 先判断待排序的数组是否有效存在,比如就head = tail 就一个元素的不用排序了if (arr == null || arr.length == 0) return;if( head>= tail) return;//随选基准值:这里选择最后一个元素作为基准int pivot = arr[tail]; //定义一个小于基准的左子数组末尾索引值 int i = head - 1; for (int j = head; j < tail; j++) {// 当前元素 <= 基准时,将其交换到左侧if (arr[j] <= pivot) {i++;//当前索引位置j的元素和 左子数组末尾元素后一位置元素交互//即左子数组扩容新增一位,后面预留的右子数组少一位,总体空间不变if(i==j)continue;//如果相互交互的两个元素是同一个元素 跳过swap(arr, i, j);}}//上面都交互完了,那么这时候将基准放到正确位置swap(arr, i + 1, tail);// 记录下现在基准索引的值pivot = i + 1; }//指定索引位置的元素相互交互private static void swap(int[] arr, int i, int j) {if(i == j) return;int temp = arr[i];arr[i] = arr[j];arr[j] = temp;}
代码到这里就是分区的步骤逻辑,分区过程如下
原数组 [4, 6, 7, 9, 8, 1, 3, 5],基准选择末尾元素5,这个时候的分区数组其实相当于
左子数组 右子数组
[还没有元素 4, 6, 7, 9, 8, 1, 3, 5]
理论上现在左子数组的末尾索引在元素4的前一位,
即左子数组 末尾索引 = 元素4索引 - 1 代码表示就是int i = head-1开始移动分区元素
第一次循环:基准和第一个元素4比较 符合在左子数组条件
将元素4和左子数组末尾索引后一位的元素进行交换 只是这里正好是同一个索引位置元素
左子数组 右子数组
[4, 6, 7, 9, 8, 1, 3, 5]
现在左子数组里面有一个元素了,末尾索引值变为0了第二次循环:基准和第2个元素6比较 不符合在左子数组条件 跳过交互 开始下次循环
第三次循环:基准和第3个元素7比较 不符合在左子数组条件 跳过交互 开始下次循环
第四次循环:基准和第4个元素9比较 不符合在左子数组条件 跳过交互 开始下次循环
第五次循环:基准和第5个元素8比较 不符合在左子数组条件 跳过交互 开始下次循环第六次循环:基准和第6个元素1比较 符合在左子数组条件,现在左子数组末尾索引值为0
将元素1和 左子数组的末尾元素的后一位元素交换,即和索引位0+1=1位置的元素交互
左子数组 右子数组
[4, 1, 7, 9, 8, 6, 3, 5]
现在左子数组里面有两个元素了,末尾索引值变为1了第七次循环:基准和第7个元素3比较 符合在左子数组条件,现在左子数组末尾索引值为1
将元素3和 左子数组的末尾元素的后一位元素交换,即和索引位1+1=2位置的元素交互
左子数组 右子数组
[4, 1, 3, 9, 8, 6, 7, 5]
现在左子数组里面有三个元素了,末尾索引值变为2了到这里循环就结束了,然后左右子数组都分好了,但基准还在右子数组里面,
把基准元素5 挪到左右子数组中间,现在左子数组末尾索引值为2
方法就是将基准元素5和 左子数组的末尾元素的后一位元素交换,即和索引位2+1=3位置的元素交互
左子数组 基准 右子数组
[4, 1, 3, 5, 8, 6, 7,9]
到这里第一次分区结束,保证已选择的基准值为标准,左变都比基准值小,右边都比基准值大
2.递归排序
:对左右子数组递归执行相同操作
上面分区后得到是左子数组都比基准值小,右子数组都比基准大,但左右子数组还是乱序呢
左子数组 基准 右子数组
[4, 1, 3, 5, 8, 6, 7,9]
处理的方式就是把左右子数组各看成一个数组,只是右子数组的起始索引不是0而已,还和上面的分区操作一样继续操作左右子数组
//上面我们代码理我们保留的基准现在的索引位置pivot = i + 1; // 递归排序左子数组 从左子数组的首位为到末尾元素(基准值元素前一位) 进行分区递归quickSort(arr, head, pivot - 1);// 递归排序右子数组 从基准值元素后一位的右边子数组 进行分区递归quickSort(arr, pivot + 1, tail);
处理逻辑步骤
Ⅰ.处理左子数组
左子数组 基准 右子数组
[4, 1, 3, 5, 8, 6, 7,9]左子数组[4, 1, 3] 索引值0,1 ,2 按照上面的分区步骤 得到一个分区数组
左子数组 二次基准值 右子数组
[1, 3, 4]
然后就分不动了 左子数组递归也就结束了,分区的左侧区域也就治理好了
==================================================================左子数组 基准值 右子数组
已排序的区域:[1, 3, 4, 5,
未排序的区域: 8, 6, 7,9]
Ⅱ.处理右子数组
右子数组[ 8, 6, 7,9] 索引值4,5 ,6,7 按照上面的分区步骤 基准值用末尾元素9
第一次
左子数组 右子数组
[ 8, 6, 7,9]第二次
左子数组 右子数组
[ 8, 6, 7,9]第三次
左子数组 右子数组
[ 8, 6, 7, 9]然后调整基准值位置
左子数组 基准值 右子数组
[ 8, 6, 7, 9 ]
右子树组第一次递归也就结束了,到这里
已排序的区域:[1, 3, 4, 5, ,9]
未排序的区域: 8, 6, 7
================================================================
然后继续递归按照上面的步骤执行未排序区域 8, 6, 7 ]最终于排序完成
总结快速排序的步骤逻辑
这就是整个的分区治理的策略的概念,一分二,二分四,四分八 等,直到分不下去的时候,也就排好序列了
一分二 一个基准元素(相当于插眼一个点) 两块区域
二分四 又多了两个基准元素(又查验两个点) 四块区域
.....直到分不下去了
这些基准值的点占据的位置就正好是整个数组的全部空间(点连成线),此时排序完成
时间复杂度:最好:O(nlog2n),平均:O(nlog2n),最坏:O(n^2)
快速排序的时间复杂度
每次分区域成2份 就相当于除以2,虽然得到的不一定是大小相同的区域(不是平均切割的哦
),那这里的时间复杂度理论上应该就是能除以2多少次,即 O(logn)
:已2为底数的对数
切割后的区域里面有循环进行比较然后决定是否交互,这里的时间复杂度应该是O(n)
所以理论上的时间复杂度是:O(logn) * O(n) = O(nlogn),当然极端情况下时间复杂度会多点,比如最坏情况下,每次选择的基准元素都是当前子数组中的最小或最大元素
,这样每次分区都相于没分,其中一个子数组和原数组一样,递归调用就变成了类似for循序的作用,在加上分区比较的for循序,就是双层for循环了,这样的快速排序实际上退化成了冒泡排序或选择排序,时间复杂度变为O(n^2)
快速排序的性能很大程度上取决于选择的基准元素(pivot),基于基准元素的每次分区能越均衡,整体时间复杂度越小,这里不进行介绍更优化的分区方法,只是了解快速排序的逻辑步骤
平均时间复杂度:O(nlog2n)
最坏时间复杂度:O(n^2)
最好时间复杂度:O(nlog2n)
当然Java数组快速排序最简单是是可以使用Java内置的Arrays.sort()方法
int[] array = {5,4,3,2,1};//调用排序方法Arrays.sort(arr1);
Arrays.sort()方法默认使用的是快速排序算法,但在某些情况下可能会退化为归并排序
快速排序:不稳定算法
快速排序:分区时可能改变相等元素顺序
先说一下快速排序的几种分区方式
1.单方向扫描分区:Lomuto洛穆托 分区方法就是,常用方式以最后一个元素为基准的选择
2.双向扫描分区:Hoare霍尔 分区方法也是原始分区方式,左右两边同时开始
3.三指针扫描分区:和基准的元素相同的元素划出成一个区间,左区间小于基准,右边区间大于基准
先看Lomuto分区方式的稳定性,选第一个元素为基准,从左向右遍历
情况Ⅰ:和基准值相同的元素的顺序
示例数组[3a, 2, 4, 3b, 1]其中有两个相等的元素3(用3a和3b区分,3a表示第一个3,3b表示第二个3)
基准值(pivot) = 3a (第一个元素),交换移动条件是arr[j] <= pivot 就将其移动到左边
[3a, 2, 4, 3b, 1]0 1 2 3 4
从索引位置j = 1开始 从左到右遍历到最后一个元素
j=1 元素2(<3a)-> 真,交换arr[1]和arr[1] 后数组:[3a, 2, 4, 3b, 1]
j=2:元素4(<=3a)-> 否,不交换,数组不变。继续。
j=3:元素3b(<=3a)-> 真 交换arr[2]和arr[3] 后数组:[3a, 2, 3b, 4, 1]
j=3:元素1(<=3a)-> 真 交换arr[3]和arr[4] 后数组:[3a, 2, 3b, 1, 4]
此时遍历结束,分区 左右序列2, 3b, 1 和 4注意最后一步,将基准值(索引0)与左侧序列末尾元素1(索引3)对调
得到:[1, 2, 3b, 3a, 4] 原数组是3a在前3b在后,现在相对顺序改变了,
所以得出非稳定性,这个举例出现非稳定性的情况是因为:
选择第一个元素为基准值,如果后面元素有和基准值相同的元素在移动到左序列时候,在最后一步替换基准值位置到左序列末尾,这样一定会出现相等元素相对顺序改变的情况解决方案:
1.如果选择的是第一个元素作基准值,在Lomuto分区中,一般将相等元素放在右侧,不放在左侧
j=3:元素3b(<=3a)-> 真 这步条件里 <=改成 < 条件2.更简单,按照Lomuto分区选择末尾元素,还使用<=这个条件,这样如果有和基准相等的元素放在左序列,最后基准值调换到左序列末尾,一定在还在原来相等元素的后面
情况Ⅰ:在排序后的左序列末尾元素有相等元素
示例数组[3, 2a, 1, 2b]这里基准值选第一个元素3,从索引位置j = 1开始 从左到右遍历
j=1 元素2a <3 -> 真,交换arr[1]和arr[1] 后数组不变:[3, 2a, 1, 2b]
j=2:元素1 <3 -> 真,交换arr[2]和arr[2] 后数组不变:[3, 2a, 1, 2b]
j=3:元素2a <3-> 真,交换arr[3]和arr[3] 后数组不变:[3, 2a, 1, 2b]
遍历结束,将基准值(索引0)与左侧序列末尾元素2b(索引3)对调,呜呼
[2b, 2a, 1, 3] 元素2a 和 2b的先后顺序改变了,不稳定性体现了这两个例子都能看出,在最后一步替换基准值和左序列末尾时候,相同元素相对顺序变化的不可控
这就是不稳定的表现
再看Hoare霍尔分区方式的稳定性
Hoare分区的逻辑及特性
1.已双向指针left和right 分别从两端开始指针扫描
2.区别单向扫描的分区最后已基准值作枢轴,双向扫描最后已左右指针做区分左右序列,递归调用时候选择右指针做分区索引
数组[3, 7, 8, 5, 2, 1, 9, 4]为例,选择第一个元素 3 作为基准值 (pivot = 3)
索引 0 1 2 3 4 5 6 7左边指针left 已首位 索引位置0开始,从左到右顺序遍历,不符合指针移动条件时 指针移动"停"
右边指针right 已末尾 索引位置7开始,从右到做顺序遍历,不符合指针移动条件时 指针移动"停"
注意:内层循环 指针移动条件 使用严格比较 (左循环使用< 和 右循环>),不能使用<= 和>=
主要是为了遍历时遇到和基准元素相同的元素时停止移动。这对于正确分区和避免死锁至关重要
不使用严格比较可能会出现越界异常,无法正确异常 比如[4,3,2,1] left直接越界了
死锁(无限循环)主要会由于递归引起 这两点下面讲第一次扫描交换
此时left = 0 (值 3), right = 7 (值 4)
内左循环:arr[0] (3) < pivot (3)->否 不符合移动条件 -> 停。此时left=0(值 3)
内右循环:arr[7] (4) > pivot (3)->真 符合移动条件 ->往前移动指针 right-1 =6(值 9) arr[6] (9) > pivot (3)->真 符合移动条件 ->往前移动指针 right-1 =5(值 1) arr[5] (1) > pivot (3)->否 不符合移动条件 -> 停。此时right=5(值 1)
左指针停下的位置表明该元素不符合在左序标准,应该呆在右序
右指针停下的位置表明该元素不符合在右序标准,应该呆在左序先进行左右指针交叉或相遇判断 if left >= right-->不成立
好的 两者进行元素交换 -> 交换 arr[0] (3) 和 arr[5] (1) -->交换元素后左右指针各移1位
-> 数组变为 [1, 7, 8, 5, 2, 3, 9, 4]
此时左右指针 L R
移动左右指针 L R
左半部分也就是左指针左侧: 所有元素 小于或等于 基准
右半部分也就是右指针右侧: 所有元素 大于或等于 基准
==============================================================================
第二次扫描,继续缩小范围
此时left = 1 (值 7), right = 4 (值 2)
内左循环:arr[1] (7) < 3 ->否 不符合移动条件 -> 停。此时left=1(值 7)
内右循环:arr[4] (2) > 3 ->否 不符合移动条件 -> 停。此时right=4(值 2)
左右指针交叉或相遇判断 if left >= right-->不成立
两者进行元素交换 -> 交换 arr[1] (7) 和 arr[4] (2)
-> 数组变为 [1, 2, 8, 5, 7, 3, 9, 4]
此时左右指针 L R
此时左右指针 L R
==============================================================================
第三次扫描
此时left = 2 (值 8), right = 3 (值 5)
内左循环:arr[2] (8) < 3 -> 否 不符合移动条件 -> 停。此时left=2(值 8)
内右循环:arr[3] (5) > 3 -> 真 符合移动条件 ->往前移动指针 right-1 =2(值 8) arr[2] (8) > 3 -> 真 符合移动条件 ->往前移动指针 right-1 =1(值 2) arr[1] (2) > 3 -> 否 不符合移动条件 -> 停。此时right=1(值 2)
然后左右指针 left >= right-->成立--> 返回right =1(值 2) 值 跳出循环,此时左指针left = 2分区结果:[1, 2, 8, 5, 7, 3, 9, 4] left =2 right = 1
左分区 (索引 0 到 2): [1,2,8] -> 1 2都小于基准(枢轴)3 但8好像不符合
右分区 (索引 1 到 7): [2,8,5,7,3,9,4] -> 基准(枢轴)3 为什么放在右边序列里面了这里揭示了 Hoare 分区的一个重要特性:
它不保证分区点左边的元素都严格 <= 枢轴,或者右边的都严格 >= 枢轴。它的保证是:在分区完成时
left 指针左边的所有元素(不包括 left 本身)都 <= 枢轴()。
right 指针右边的所有元素(不包括 right 本身)都 >= 枢轴。分区点 right 是划分两个区域的边界索引之一(另一个是 left)
看完逻辑步骤三个问题
1.为什么内层循环指针移动条件使用严格比较 (左循环使用< 和 右循环>)?
2.为什么在分区结果左序列中会存在元素比基准值大的?
3.为什么基准值会放在序列中,而不是替换位置放在左右分区中间?
延申的有点多了,但是不把这些疑问解释清除是不好理解霍尔分区的特性,上代码
public static void main(String[] args) {int[] arr = {3, 7, 8, 5, 2, 1, 9, 4};//进入快速分区方法quickSort(arr, 0, arr.length - 1);}public static void quickSort(int[] arr, int low, int high) {//判断待分区的序列是否为有效数组空间if (low < high) {//返回上次分区的右指针作为分区索引int pi = partition(arr, low, high);//递归左序分区空间(low 到 pi)quickSort(arr, low, pi);//递归右序分区空间(pi+1 到 high)quickSort(arr, pi + 1, high);}}//霍尔分区方法private static int partition(int[] arr, int low, int high) {int pivot = arr[low]; // 选择第一个元素作为枢轴int left = low; // 初始化左指针 (实际会立即右移)int right = high; // 初始化右指针while (true){// 无限循环,内部检查退出条件// 移动左指针,找到 >= pivot 的元素while (arr[left] < pivot){// 注意:严格小于才移动left = left + 1;}// 移动右指针,找到 <= pivot 的元素while (arr[right] > pivot){// 注意:严格大于才移动right = right - 1;}// 检查指针是否相遇或交叉if (left >= right) return right;// 返回分区点// 交换左右指针指向的元素swap(arr[left], arr[right]);// 移动指针继续扫描 (避免死锁,尤其是元素等于pivot时)left = left + 1;right = right - 1;}}
这段代码是一个霍尔分区的递归调用,解释一下上面的问题
1.为什么在分区结果左序列中会存在元素比基准值大的?
这是因为左右指针是作为 分区边界线
用的 比如
分区结果:[1, 2, 8, 5, 7, 3, 9, 4] right = 1
左分区 (索引 0 到 2): [1,2,8] -> 1 2都小于基准(枢轴)3 但8好像不符合
右分区 (索引 1 到 7): [2,8,5,7,3,9,4] -> 元素2不符合,而且基准(枢轴)3 为什么放在右边序列里面了这里是内层循环 指针移动条件 使用严格比较 (左循环使用< 和 右循环>)的好处之一
任何一个不等于基准元素的值 是不可能同时满足 < 和 >严格条件
它必然会让左右指针双方一方移动,一方停下
这样符合left >right条件的情况就是left = right+1,左右序列交互的元素个数最多是2个
而且这两个元素必然按照顺序排序好的如果是left = right 满足,那代表左右指针指向同一个元素,而且这个元素必然是和基准元素相同的元素
此时左右序列交互的元素数量是一个以上这两种情况表示存在交互数据,那么在递归调用之后,交互范围的元素不可能同时给两边处理
只能归属一方,要么归左边 要么归右边,不然数据就重复了这里的处理方式就是讲交互范围的元素
已右指针作为分区索引切割划分
[1, 2, 8, 5, 7, 3, 9, 4] R
位置小于等于R的 属于真正意义上的左半部分
位置大于R 属于真正意义上的右半部分:那么此时R指针数据是不算真正的右半部分
==============================================================
如果已左指针作为分区索引切割划分
[1, 2, 8, 5, 7, 3, 9, 4] L
位置小于L的 属于真正意义上的左半部分
位置大于等于L 属于真正意义上的右半部分常见方式是已右指针作为分区索引方式
==============================================================
这里也就是Hoare 分区的一个重要特性在重申一下:
它不保证分区点左边的元素都严格 <= 枢轴,或者右边的都严格 >= 枢轴。它的保证是:在分区完成时
left 指针左边的所有元素(不包括 left 本身)都 <= 枢轴()。
right 指针右边的所有元素(不包括 right 本身)都 >= 枢轴。分区点 right 是划分两个区域的边界索引之一(另一个是 left)
2.为什么在分区结果左序列中会存在元素比基准值大的?
个人认为的几点原因
a.稳定性,在上面单向扫描最后替换基准值的不可控制相对顺序,从而破坏稳定性
b.简化算法,单向扫描最后替换基准值是选择基准值是一个分区标准,最后分区后使用指针作为分区索引,就不用特意在替换基准值并作为分区索引了,使用基准值分区索引意义没必要了
c.算法的平衡,基准值放在左边序列那一定是当前序列最大值,基准值放在右边序列一定是右边序列的最小值
在快速排序的逻辑步骤讲解里,每次分区左右序列越均衡越好,这和随机选则的基准值有很大关系,应使用"三数取中"
法取基准值的(随机三个元素 排序取中间值那个元素作基准值),如果分区后左右一侧元素为空极度失衡不好,这里基准值放在子序列中有点配重意思,基准值停留在子序列中为了均衡下次分区
作用体现:递归子序列分区,如上面例子[8,5,7,3,9,4],无论基准值选哪个,左边序列一定有元素3,这样能保证左边序列不为空,保证该分区算法不会失衡
3.为什么内层循环指针移动条件使用严格比较 (左使用< 和 右循环>)
a.保证正确分区:[18,5,7,3,9,4],选择第一个18作为基准,恰巧是最大值,如果使用<=
while (arr[left] <= pivot){//在比对元素4之后 再后移动,arr[6]越界报错了left = left + 1;}//发生越界异常,程序都报错终止了 还谈什么能正常分区
b.放在死锁(无线循环):发生再递归调用中
//为了放在报错 我们加上边界限制while (left<=arr.length-1 && arr[left] <= pivot){left = left + 1;}while (right>=0 &&arr[right] >= pivot){right = right - 1;}if (left >= right) return right;
会再递归调用出现无限循环
比如数组[3,3,3]
执行分区
quickSort(arr, 0, 2);
这样左边指针会一直指完整个数组,最后left = arr.length
右指针这样也会一直指完整个数组,最后right = -1[3,3,3]
左序列 L
右序列 R 0 1 2
也就是整个数组既然成左序列 又成右序列然后递归,左版本分L>arr.length 不执行分区方法了,
但右半部分符合,开始分区执行,输入参数
quickSort(arr, pi + 1, high); == quickSort(arr, 0, 2); 就这样递归循环下去吧,直到堆栈溢出报error
以上就是霍尔分区的逻辑步骤以及注意特性,现在对其稳定性解析
1.基准值被放在子序列中,防止了像单向扫描最后替换基准值时相对顺序不可控的情况,提高稳定性
2.不一定是相邻两个元素顺序交换,所以相同元素的先后顺序不可控制
比如[5,1a,3,1b]基准值选择3,第一次左右指针停留,左指针停留在5,右指针停留在1,然后交换变更数组[1b,1a,3,5], 两个1的先后顺序变更
3.对于和基准值相同的元素稳定性完全破坏稳定性,比如[3a,1,3b],第一次交换就是[3b,1,3a],相同元素3的先后顺序变更
总结:相同元素+霍尔分区,不稳定算法分区
归并排序
归并排序(Merge Sort)是一种分治算法,其核心思想是将数组分成两半,分别排序,然后合并两个有序数组。归并排序是稳定的排序算法,时间复杂度为 O(n log n),但需要额外的空间。
逻辑步骤
算法步骤
1.分解(Divide):将数组递归地分成两个子数组,直到每个子数组只有一个元素
2.解决(Conquer):对每个子数组进行排序(单个元素自然有序)
3.合并(Combine):将已排序的子数组合并成一个完整的有序数组
public static void main(String[] args) {int[] arr = {38, 27, 43, 3, 9, 82, 10};// 复制一个临时存储数组,并作为参数传入,这样只new一次 能复用节省空间int[] temp = new int[arr.length];//进入归并排序方法mergeSort(array, 0, array.length - 1, temp);
}
进入归并排序的mergeSort方法
步骤1:分解
private static void mergeSort(int[] arr, int left, int right, int[] temp) {if (left < right) {//步骤1:分解 这里每次都是对半分,防止整数溢出不能平分的就元素给左边int mid = left + (right - left) / 2; // 排序左半部分leftmergeSort(arr, left, mid,temp);// 排序右半部分right mergeSort(arr, mid + 1, right,temp);//步骤2:解决 将拆分后的子数组进行排序 并 合并有序数组merge(arr, left, mid, right, temp,temp);}
}
先图解看步骤1分解 到步骤2解决之间的逻辑
以数组 arr数组 [38, 27, 43, 3, 9, 82, 10] 为例
1. 分解:[38, 27, 43, 3] 和 [9, 82, 10]继续分解:[38, 27] [43, 3] [9, 82] [10]继续分解:[38] [27] [43] [3] [9] [82] [10]注意:这里的拆分并不是将原数组分解成n个数组,
只是将对应的索引值标记出来传递进入治理合并的方法里去
merge(arr, left, mid, right, temp);
步骤2:治理解决排序问题
// 解决并合并两个有序数组,参数mid是左右区域分开的中间索引值(偏左指向)private static void merge(int[] arr, int left, int mid, int right, int[] temp) {int i = left; // 左序列子数组初始位置索引指针int j = mid + 1; // 右序列子数组初始位置索引指针int t = 0; // 临时存储数组的指针
原数组arr [38, 27, 43, 3, 9, 82, 10]已分解为 [38] [27] [43] [3] [9] [82] [10]
临时数组temp [0, 0, 0, 0, 0, 0,0]
第一次进入该方法比较执行的这样序列是[38] [27] 左序列索引为0 右序列索引为1
(0和1之间没有,两个中间索引值总不能0.5吧,所以参数mid偏左指向)就下面这样式的
left|mid righti j[38] [27]第一次进入该方法的参数 letf:0 mid:0 right:1 i:0 j:1
步骤1:比较左右子数组元素,按序放入临时数组
//自旋条件是不能超出"两块数组"的空间范围while (i <= mid && j <= right) {if (arr[i] <= arr[j]) {temp[t++] = arr[i++];} else {temp[t++] = arr[j++];}}
(i <= mid && j <= right) 索引指针范围 符合条件
[38] [27] 比较得出最小元素是 27 放入临时存储数组temp
arr [38, 27, 43, 3, 9, 82, 10]
temp [27, 0, 0, 0, 0, 0,0]注意一下符合条件的元素放入temp后 其索引指针进行了后置+1,
如果仅仅是填充temp元素temp[t++] = arr[i];或者temp[t++] = arr[j];就可以了
为什么还要后置+1呢,因为还要把它作为判断标识:biao如i<= mid成立表明左序指针还在左数组范围内,左序还存在未填充进temp的剩余元素,刚填充的元素是右侧的
left|mid righti j [38] [27]//这时候就将左边剩余元素按空间顺序陆续填充进tempwhile (i <= mid) {temp[t++] = arr[i++];}如j<=right成立表明右序指针还在右数组范围内,右序还存在未填充进temp的剩余元素,刚填充的元素是左序的
left|mid righti|j[38] [27]//这时候就将右边剩余元素按空间顺序陆续填充进tempwhile (j <= right) {temp[t++] = arr[j++];}
填充后的数组
arr [38, 27, 43, 3, 9, 82, 10]
temp [27, 38, 0, 0, 0, 0,0]
步骤2:将temp中的元素全部拷贝回原数组
//重置临时数组索引指针从首位元素开始t = 0;// 将temp中的元素全部拷贝回原数组while (left <= right) { //表明拷贝的范围只在这次排序的左右序列范围内arr[left++] = temp[t++];}
拷贝后
arr [27, 38, 43, 3, 9, 82, 10]
temp [27, 38, 0, 0, 0, 0,0]
原来分解的数组[38] [27] 就归并成了 [27, 28]
步骤4:递归调用
再来看第二次参数进入执行
第二次要解决的是[43] [3]
参数
arr [27, 38, 43, 3, 9, 82, 10]
temp [27, 38, 0, 0, 0, 0,0]
left|mid righti j[43] [3]
letf:2 mid:2 right:3 i:2 j:3然后执行到比较大小 小的元素放入临时数组temp,这里元素3小于元素43,放入元素3
temp [3, 38, 0, 0, 0, 0,0]
left|mid righti j[43] [3]然后执行填充剩余元素,i没超过左序指针范围,那就是左侧还有未填充元素进入temp,填充进去
temp [3, 43, 0, 0, 0, 0,0]此时数组arr和临时数组元素
arr [27, 38, 43, 3, 9, 82, 10]
temp [3, 43, 0, 0, 0, 0,0]开始按arr[left++] = temp[t++];合并变成
arr [27, 38, 3, 43, 9, 82, 10]
原来分解的数组[43] [3]就归并成了 [3, 34]
再来看第三次参数进入指向情况
第三次传入的参数letf:0 mid:1 right:3
arr [27, 38, 3, 43, 9, 82, 10]
temp [3, 43, 0, 0, 0, 0,0]
这次处理的是上面归并的两个数组[27, 38] [3, 43] (自上而下分解,然后自下而上归并)left|mid righti j
[27, 38] [3, 34]
letf:0 mid:1 right:3 i:0 j:2然后执行比较arr[i] <= arr[j]while (i <= mid && j <= right) {if (arr[i] <= arr[j]) {temp[t++] = arr[i++];} else {temp[t++] = arr[j++];}}
第一次:arr[0]<= arr[2] 否 temp填充arr[2]=3 变成[3, 43, 0, 0, 0, 0,0] j++
第二次:arr[0]<= arr[3] 是 temp填充arr[0]=27 变成[3, 27, 0, 0, 0, 0,0] i++
第二次:arr[1]<= arr[3] 是 temp填充arr[1]=27 变成[3, 27, 28, 0, 0, 0,0] i++
循环结束,变成这样式的了
left|mid righti j
[27, 38] [3, 34]
这里i超出左侧数组指针范围,表明元素填充完了,右侧没有超还有待填充进temp的元素
temp [3, 27, 28, 34, 0, 0,0]然后开始拷贝
temp [3, 27, 28, 43, 0, 0,0]
arr [3, 27, 28, 43, 9, 82, 10]
到这里原数组第一次分解的左序 可就归并好了,右边同样如此,总结一下步骤
原数组[38, 27, 43, 3, 9, 82, 10] 分解[38, 27, 43, 3] 和 [9, 82, 10]继续分解:[38, 27] [43, 3] [9, 82] [10]继续分解:[38] [27] [43] [3] [9] [82] [10]第一次归并 [38] [27] 成 [27, 38]
第二次归并 [43] [3] 成[3, 43]
第二次归并 [27, 38] [3, 43] 成 [3, 27, 28, 43]
-----------
最后归并[3, 27, 28, 43][9, 10, 82] 得到最终排序的数组
时间复杂度:最好 平均 最坏都是O(nlog2n)
至于时间复杂度,每次对半分,这样时间复杂度是O(log2n)
循环比较并交互元素复杂度是O(n)
两者相乘O(nlog2n) = O(n) * O(log2n)
由于每次都是对半分,不像快速排序每次分可能两边极度不平衡导致退化成冒泡排序或选择排序,所以归并排序是稳定的排序方式,而快速排序就属于非稳定排序了
归并排序
平均时间复杂度:O(nlog2n)
最坏时间复杂度:O(nlog2n)
最好时间复杂度:O(nlog2n)
归并排序:稳定性算法
归并排序在合并过程中,如果遇到相等的元素,会先保留原来顺序,因此它是稳定的排序算法
它只是将数组从上往下不停拆分,直到拆不动了,然后开始比较排序,在这个排序的过程中,不断的是有序的左右子数组 比较元素然后合并,这个过程是中相等元素的顺序是不变的
堆排序
区别于以上的基于数组结构的排序算法,堆排序
(Heap Sort)虽然实际操作的还是数组,但是 ,是一种基于二叉堆(Binary Heap)
这种数据结构所设计的一种排序算法,堆是一个近似完全二叉树。并同时满足堆的性质:即父节点的值总是大于或等于(最大堆
)或小于或等于(最小堆
)子节点的值
基于数组 隐式指针方式构建二叉堆
static class TaskTreeNode {int priority;TaskTreeNode (int x) {priority = x;}}TaskTreeNode buildCompleteTaskTree() {int[] arr = {38, 27, 43, 3, 9, 82, 10};// 构建初始数组TreeNode[],这里是一维静态数组TaskTreeNode[] nodes = new TaskTreeNode [arr.length];// 按照先后顺序给所有节点创建节点TreeNode,并且加到数组nodes for (int i = 0; i < arr.length; i++) {nodes[i] = new TaskTreeNode (arr[i]);}//返回一个根节点就是一个符合的完全二叉树return nodes[0];}
父子节点之间的索引值的数学规律 得到的隐式指针,可以看这里关于完全二叉树的详细介绍
父节点索引i
其存在的左边子节点索引值为:2*i + 1
其存在的右边子节点索引值为:2*i + 2
基于数组的隐式指针构建二叉堆的方式又免除了 替换操作节点时候变更显示指针的麻烦,所以二叉堆的实现大都是基于数组实现的
隐式指针的完全二叉树实际上就是数组,这种设计的好处:
1.可以利用二叉树的数据结构优势和数学规律
2.可以利用数组优势:数组连续的存储空间可以根据索引值快速定位
3.操作数据时候不用再处理二叉树中的节点指针指向关系
4.完全二叉树的数据结构特征就决定了:一维数组的数据集是一定符合完全二叉树的,即父子节点索引值的规律是一定适用的
堆排序的算法步骤过程叫 堆化过程,而堆类型分最大堆MaxHeap(大顶堆)和最小堆MinHeap(小顶堆)两种
最大堆MaxHeap
最大堆MaxHeap(大顶堆)
①首先它必须满足完全二叉树的定义
②其次堆树中任一节点的值总是不小于其子节点的值
③最大的值是根节点,堆树中每个节点的子树都是堆树,注意没有要求左右节点的大小关系
如下图所示
堆排序
以数组 [4, 10, 3, 5, 1,12] 为例,展示堆排序过程:
初始数组: [4, 10, 3, 5,1,12],对应的二叉堆结构4/ \10 3/ \ / 5 1 12
根节点为4 索引值为0
左子节点10 索引值为1 隐藏指针链接关系为 leftIndex = 2*rootIndex +1
右子节点3 索引值为2, 隐藏指针链接关系为rightIndex = 2*rootIndex +2
节点3是该二叉堆最后一个非叶子节点 索引值为(N-1)/2
.......这些规律介绍可以点击上面关于完全二叉树的详情介绍链接...........
二叉堆排序调整数据序列:将待排序的序列构造成一个大顶堆(最大堆)
public static void main(String[] args) {int[] array = {4, 10, 3, 5, 1,12};int n = array.length;// 步骤1: 构建最大堆(从最后一个非叶子节点开始 也就是倒数第二层最后一个节点开始)for (int i = n / 2 - 1; i >= 0; i--) {heapSort(array, n, i);}}//堆排序(下沉对比)private static void heapSort(int[] array, int heapSize, int rootIndex) {int maxValueIndex = rootIndex; // 先初始定义最大值的元素是根节点int left = 2 * rootIndex + 1; // 左子节点索引int right = 2 * rootIndex + 2; // 右子节点索引// 如果左子节点比根节点大if (left < heapSize && array[left] > array[rootIndex]) {maxValueIndex = left;}// 如果右子节点比当前最大值大if (right < heapSize && array[right] > array[rootIndex]) {maxValueIndex = right;}// 如果最大值不是根节点,需要交换值if (maxValueIndex != rootIndex) {swap(array, rootIndex, maxValueIndex);}}//原地排序 交互对应索引的值private static void swap(int[] array, int i, int j) {int temp = array[i];array[i] = array[j];array[j] = temp;}
打印输出
第1次循环后:[4, 10, 12, 5, 1, 3]
第2次循环后:[4, 10, 12, 5, 1, 3]
第3次循环后:[12, 10, 4, 5, 1, 3]
来看具体步骤
二叉堆数据结构4/ \10 3/ \ / \5 1 12 NIL(空节点)
==================================================================
第一次排序
选择最后一个非叶子节点,也就是倒数第二层的最后一个节点(最后一个子树)
比较的范围是3 和左子节点比较 rootValue<leftValue/ \ ----------------> 更新最大值索引值给MaxIndex
12 NIL(空节点)
swap交互值 数组变更为[4, 10, 12, 5, 1, 3]
此时二叉堆数据结构 未排序 的范围4/ \10 12/ \ 5 1
==================================================================
第二次排序 递减推选择节点 10
比较的范围是10 和左子节点比较 rootValue>leftValue 和右子节点比较/ \ ----------------> 不更新maxIndex----------------> 不更新maxIndex
5 1
这次数组元素顺序不需要变更
此时二叉堆数据结构 未排序 的范围4/ \10 12
==================================================================
第三次排序 和上面一样步骤
比较的范围是4 和右子节点比较/ \
10 12
和左子节点比较 MaxValue<leftValue---> 更新maxIndex 此时maxIndex 对应value = 10
和右子节点比较 MaxValue<leftValue---> 更新maxIndex 此时maxIndex 对应value = 12
swap交互值 数组变更为[12, 10, 4, 5, 1, 3] 这样排序完成
最终二叉树数据结构调整未12/ \10 4/ \ / \5 1 3 NIL(空节点)
总结一下
1.归并排序,递归排序,原地排序
归并排序:每次一个子树,从最后的开始往上递推排序,最终将最值 给“堆”到顶上去
递归排序:for循环控制的每次子树范围的比较,根据子树个数循环次数
原地排序:每次有需要交互元素的时候根据索引值进行交互,不需要申请额外的空间
2.看下堆排序的复杂度
外层循环或者说递归次数是0到n/2 - 1 接近n/2,每次循环里面子树root和left right比较,最多进行两次比较和互换操作 所以复杂度就是(n/2 ) 乘以2 = n,所以堆排序的时间复杂度是O(n)
,网上很多都说堆排序的时间复杂度都是O(nlogn),这个说法是不太准确的,正确的说法应该是构建最大堆未或者最小堆的时间复杂度O(nlogn):包括堆排序的O(n)和堆调整O(logn)
构建堆:
1.堆排序:
对一个非排序的完全二叉树进行排序,时间复杂度O(n)
2.堆调整:进行堆调整的前提是 当前堆已经是最大堆或者最小堆了
a.当向一个堆中新增节点,上浮调整排序每次只需要和其对应的父节点比较,时间复杂度O(log n)
b.当从堆中取出一个节点后,剩余的堆中节点下沉调整已维持最大堆或最小堆特性时间复杂度O(logn)
堆调整
区别于我们上面从一个已经存在元素的堆再进行排序,堆调整示例从数据元素最初添加进去就按照最大堆的数据结构添加
第一步:创建一个堆的内部表示
public class MaxHeap {private final int[] heap;//用来存放元素的数组private int size; //当前堆的大小也就是节点N的数量private final int capacity; //堆的最大容量public MaxHeap(String mame,int capacity) {this.capacity = capacity;this.size = 0; this.heap = new int[capacity];//初始化堆数组}
二叉堆的构造函数给一个初始容量值相当于:
MaxHeap maxHeap = new MaxHeap(10);
先初始一个容量为10的数组Heap,初始元素默认值都为0
0 0 0 0 0 0 0 0 0 0
第二步:实现添加元素的操作insert
// 插入元素public void insert(int value) {if (size == capacity) throw new IllegalStateException("Heap is full");heap[size] = value;//将新的元素放在堆的末尾heapifyUp(size);//上浮新的元素已来维持最大堆或者最小堆的性质size++; //增加堆的大小}// 核心方法:上浮调整private void heapifyUp(int index) {int parent = (index - 1) / 2; //计算新插入节点对应的父节点的索引while(index >0 ){if(heap[index] > heap[parent]){//和父节点比较swap(index, parent);//原地排序:交互位置index = parent;//更新当前索引为父节点的位置 然后继续往上比}else{break;}}}// 辅助方法:交换元素private void swap(int i, int j) {int temp = heap[i];heap[i] = heap[j];heap[j] = temp;}
测试代码
public static void main(String[] args) {//表明容量大小为10,如果是任务优先级队列表明最大同时9个任务,也就是二叉堆节点最多9个MaxHeap maxHeap = new MaxHeap(10);maxHeap.insert(10);maxHeap.insert(15);maxHeap.insert(20);maxHeap.insert(19);maxHeap.insert(22);maxHeap.insert(13);maxHeap.insert(6);//...如添加元素的数量超过了数组容量,报错抛出 }
打印输出
第1次插入节点后:[10, 0, 0, 0, 0, 0, 0, 0, 0, 0]
第2次插入节点后:[15, 10, 0, 0, 0, 0, 0, 0, 0, 0]
第3次插入节点后:[20, 10, 15, 0, 0, 0, 0, 0, 0, 0]
第4次插入节点后:[20, 19, 15, 10, 0, 0, 0, 0, 0, 0]
第5次插入节点后:[22, 20, 15, 10, 19, 0, 0, 0, 0, 0]
第6次插入节点后:[22, 20, 15, 10, 19, 13, 0, 0, 0, 0]
第7次插入节点后:[22, 20, 15, 10, 19, 13, 6, 0, 0, 0]二叉堆数据结构随着添加进的数据进行`上浮调整`节点位置,插入数据前提是该二叉堆已经是最大堆所以每次比较都只需要和父节点比较就行(不用和旁边节点比较了,因为旁边节点肯定没有父节点大)只是如果比父节点大交互位置后,还需要再往上一层比较,直到能确定值不在比父节点的值大
新增节点-->父节点-->爷爷节点-->太爷爷节点-->....-->根节点那最好的情况下:新增节点就是最小的,到父节点比较来位置都不需要交互 时间复杂度O(1)
那最坏的情况下:新增节点 要比较到 根节点才能知道最大值是哪个并确定最值位置
那这种情况下 上面这条线上有多少个节点就需要多少次,就是对应的时间复杂度还是画图为例吧,已4层二叉堆为例A/ \B C/ \ / \D E F G/ \ / \ / \ / \
H I J K M L O NEW最坏情况下需要比较次数:NEW-->G-->C-->A
往上推导父节点索引 parent = (N- 1) / 2,如果满足上面比较次数那就是需要比较到A
那此时parent = (N- 1) / 2 = 0,也就是共需要多少次(N- 1) / 2为0,就个次数是对应的时间复杂度
对应的表示就是O(log2 n)
第三步:查看最大元素的操作peek
public int peek() {if (size == 0) throw new IllegalStateException("Heap is empty");return heap[0];//返回根节点元素也就是最大元素
第四步:取出最大元素的操作poll
// 删除最大值,每次移除都是根元素 就不用索引值了public int poll() {//检查堆是不是空了if (size == 0) throw new IllegalStateException("Heap is empty");int max = heap[0];//保持一下最大元素值heap[0] = heap[size - 1];//将最后一个元素赋值到根节点先保持下二叉堆的完整size--;//减少堆的大小heapifyDown(0);//向下调整return max;//返回最大元素}// 核心方法:下沉调整private void heapifyDown(int index) {int maxIndex = index;//先假设最大值是当前节点int left = 2 * index + 1;//左子节点索引int right = 2 * index + 2;//右子节点索引if (left < size && heap[left] > heap[maxIndex]) //和左子节点比较元素大小maxIndex = left;//更新最大元的索引if (right < size && heap[right] > heap[maxIndex])maxIndex = right;//更新最大元的索引if (index != maxIndex) {swap(index, maxIndex);//原地排序:交互当前位置与最大的位置heapifyDown(maxIndex);//往下递归调整}}
这个方法实现的根基在于:移除元素操作的二叉堆是最大堆或者最小堆,在移除节点操作时候通过下沉调整堆中节点元素位置已维持最大堆或最小堆的特征
[20, 15, 11, 10, 12, 9, 6, 0, 0, 0]20/ \15 11/ \ / \ 10 12 9 6
此时最大堆的最大值为根节点,第二大的值就在左右子树的顶点中产生,即15 或者 11第一步:先赋值heap[0] = heap[size - 1];//将最后一个元素赋值到根节点先保持下二叉堆的完整
此时的数组数据为[6, 15, 11, 10, 12, 9, 6, 0, 0, 0]
对应对二叉树结构为6/ \15 11/ \ / 10 12 9 会发现和数组比少了一个老6
因为size,堆大小表示值size在取出最大元素进行了size--,虽然当前数组元素是7个但有效节点就6个了第二步骤 开始递归判断6/ \ --根节点和左右节点两次比较得出最大值并替换6和15的位置15 11
此时树结构为15/ \ 6 11 / \ / 10 12 9 此时新的根节点已经确定了,再往下进行的是调整节点已维持最大堆的性质第二次递归判断比较6/ \10 12 --根节点和左右节点两次比较得出最大值并替换6和12的位置
二叉堆的数据结构变成15/ \12 11/ \ / 10 6 9 最新的需要比较的节点是6在索引4,是叶子节点了循序也就结束了
最新数组为 [15, 12, 11, 10, 6, 9, 6, 0, 0, 0]其中在insert方法
if (size == capacity) throw new IllegalStateException("Heap is full");
每添加一次size++; 如果capacity为10 那么添加到第10次就会触发报错
所以这个容量为10的数组,最多能同时容纳9个有效元素
再来看时间赋值度
第一次根节点和左右子节点比较 替换并决定下次比较是左树还是右树
第二次节点和其左右子节点比较 替换并决定下次比较是其左树还是右树
-------------------这样相当于每次都除以2 时间复杂度O(log2n)---------------
测试一下
public static void main(String[] args) {MaxHeap maxHeap = new MaxHeap(10);maxHeap.insert(10);maxHeap.insert(15);maxHeap.insert(20);maxHeap.insert(9);System.out.println("此时堆中有效节点数:"+maxHeap.size);System.out.println("最大值:"+maxHeap.peek());System.out.println("移除的最大值:"+maxHeap.poll());System.out.println("最新最大值:"+maxHeap.peek());}
最小堆和这个逻辑思路类似,总体来说时间复杂度:
1.构建堆:O(n)
2.每次堆调整:O(log n)
总时间复杂度:O(n log n)(最优、最差和平均情况相同)
使用堆(Heap)来实现优先级队列管理
堆排序讲的比较多,顺便这里在提一下为什么选择使用二叉堆来进行优先级任务队列的管理
首先明确优先级队列的操作需求:插入任务(带优先级)和取出最高优先级的任务(即出队),这就要求 最快找出最小(或最大)元素,为了删除最小(或最大)
满足该需求的数据结构符合条件
1.最快找出最小(或最大)元素
,这点上大顶堆取根节点值arr[0]就可以,复杂度为O(1),但是首位为最大值的平常数组 或者 首位为最大值的链表这点也符合为什么不用他们来管理呢
2.数据结构调整上复杂度
:为了上述条件1,新增或删除后的数据结构首位保持是最值元素,那么新增或者删除元素后的数据结构调整是必要的,选择数据结构调整上复杂度最低的
①.先看没有采用二叉堆数据结构设计思路的完全排序的数组
int[] arr = new int[10];
先初始一个容量为10的数组,初始元素默认值都为0
0 0 0 0 0 0 0 0 0 0 设计思路上1:将最大值放在首位,后续位置不管排序上:
第一次来添加优先级值9 放入索引0
9 0 0 0 0 0 0 0 0 0
第二次添加优先级元素值7 和首位比较 然后决定索引位置放入数组
9 7 0 0 0 0 0 0 0 0
第二次添加优先级元素值10 和首位比较 然后决定索引位置放入数组
10 7 9 0 0 0 0 0 0 0
时间复杂度只需要取[0] 比较插入值 是O(1)
调整上:
取出首位最大值元素[0],剩下的元素哪个是最大的呢,冒泡排序或者选择排序给它重新排序
时间复杂度瞬间就上来了设计思路上2:完全按照从大到小顺序排放,这就已经是冒泡排序或者选择排序了
排序上:
第一次来添加优先级值9 放入索引0
9 0 0 0 0 0 0 0 0 0
第二次添加优先级元素值7 和前面的比较 然后决定索引位置放入数组
9 7 0 0 0 0 0 0 0 0
第二次添加优先级元素值10 和前面的比较 然后决定索引位置放入数组
10 9 7 0 0 0 0 0 0 0
再看调整上
取出首位最大值元素[0],剩下的元素最大的[1]
但是注意是取出,也就是[0]对应的元素不在数组了,原来[1]变成[0]
9 7 0 0 0 0 0 0 0 0
每个元素都往前平移一位,时间复杂度和空间复杂度又上一层
②再来看链表再进行优先级节点调整上的时间复杂度
使用单链表的方式进行管理优先级队列的方式:按照大小顺序排放,最大的放在队首
// 继承一个比较接口,这样可以方便比较节点里面的优先级值大小
public class PriorityQueueTest <T extends Comparable<T>> {//定义一个队首private Node<T> head;//记录任务节点数private int size;private static class Node<T> {T data;Node<T> next;Node(T data) {this.data = data;this.next = null;}}//新增元素public void insert(T data) {//根据插入优先级值 生成新的节点Node<T> newNode = new Node<>(data);//和最大值头子比较,如果比队列首位大就把新节点放在首位if (head == null || data.compareTo(head.data) < 0) {newNode.next = head;head = newNode;} else { //否则就往下遍历查找合适插入的位置Node<T> current = head;// 已值大小作为判断条件不断往下遍历查找位置while (current.next != null && data.compareTo(current.next.data) >= 0) {current = current.next;}//直到找到合适插入的位置,最坏情况可能需要到队尾 时间复杂度 O(n)newNode.next = current.next;current.next = newNode;}size++;}//移除最大元素简单 拿掉队首的和第二位的指针指向关系就行 时间复杂度 O(1)public T poll() {if (head == null) {throw new NoSuchElementException("Priority queue is empty");}T data = head.data;head = head.next;size--;return data;}// 查看队头元素 查看最大值元素时间复杂度也是O(1)public T peek() {if (head == null) {throw new NoSuchElementException("Priority queue is empty");}return head.data;}// 检查队列是否为空public boolean isEmpty() {return head == null;}//获取队列大小(size)public int size() {return size;}
}
时间复杂度
插入操作(insert):在最坏情况下插入操作需要遍历整个链表以找到合适的位置。因此 时间复杂度为O(n),其中 n 是链表的长度。
删除最小元素操作(deleteMin):删除操作总是从链表的头部进行,因此时间复杂度为 O(1)。
获取最小元素操作(peek):获取最小元素操作也是从链表的头部进行,因此时间复杂度为 O(1)。
判断队列是否为空(isEmpty):这个只要检查链表的头部是否为 null,因此时间复杂度为 O(1)。
获取队列大小(size):这个操作只需要返回一个整数值,因此时间复杂度为 O(1)。
总结
1.二叉堆的堆调整的时间复杂度是O(log2n),随着n越大 二叉堆管理优先级队列的性能越好
2.使用链表实现的优先级队列在插入操作上时间复杂度较高为 O(n)
,但在删除最小元素和获取最小元素等操作上时间复杂度较低为 O(1)
。这种实现方式适用于需要频繁删除最小元素但插入操作较少
的场景
堆排序:不稳定算法
堆排序:堆排序不是稳定的,因为堆化过程中会交换不相邻的元素
在堆数据调整过程不是相邻的元素调整的,调整的元素一般是隔着2i+1或者2i+2个空间调整的,所以这种相对元素先后顺序的稳定性是完全不可控滴,堆排序不稳定举例
[8, 2, 5a, 5b,3],其中5a和5b都是元素5,一个未排序的二叉堆进行堆排序8/ \2 5a/ \5b 3
排序最大堆结果(排序逻辑在上面堆排序里面)8/ \5b 5a/ \2 3
5b 跑到5a前面去了,相对顺序变了,所以堆排序非稳定的算法