冒泡排序
冒泡排序⽆疑是最为出名的排序算法之⼀,从序列的⼀端开始往另⼀端冒泡(你可以从左
往右冒泡,也可以从右往左冒泡,看⼼情),依次⽐较相邻的两个数的⼤⼩(到底是⽐⼤
还是⽐⼩也看你⼼情)。
图解冒泡
以
[ 8
,
2
,
5
,
9
,
7 ]
这组数字来做示例,上图来战:
从左往右依次冒泡,将⼩的往右移动
⾸先⽐较第⼀个数和第⼆个数的⼤⼩,我们发现
2
⽐
8
要⼩,那么保持原位,不做改动。
位置还是
8
,
2
,
5
,
9
,
7
。
指针往右移动⼀格,接着⽐较:
⽐较第⼆个数和第三个数的⼤⼩,发现
2
⽐
5
要⼩,所以位置交换,交换后数组更新为:
[
8
,
5
,
2
,
9
,
7 ]
。
指针再往右移动⼀格,继续⽐较:
⽐较第三个数和第四个数的⼤⼩,发现
2
⽐
9
要⼩,所以位置交换,交换后数组更新为:
[
8
,
5
,
9
,
2
,
7 ]
同样,指针再往右移动,继续⽐较:
⽐较第
4
个数和第
5
个数的⼤⼩,发现
2
⽐
7
要⼩,所以位置交换,交换后数组更新为:
[
8
,
5
,
9
,
7
,
2 ]
下⼀步,指针再往右移动,发现已经到底了,则本轮冒泡结束,处于最右边的
2
就是已经
排好序的数字。
通过这⼀轮不断的对⽐交换,数组中最⼩的数字移动到了最右边。
接下来继续第⼆轮冒泡:
由于右边的
2
已经是排好序的数字,就不再参与⽐较,所以本轮冒泡结束,本轮冒泡最终
冒到顶部的数字
5
也归于有序序列中,现在数组已经变化成了
[ 8
,
9
,
7
,
5
,
2 ]
。
让我们开始第三轮冒泡吧!
由于
8
⽐
7
⼤,所以位置不变,此时第三轮冒泡也已经结束,第三轮冒泡的最后结果是
[
9
,
8
,
7
,
5
,
2 ]
紧接着第四轮冒泡:
9
和
8
⽐,位置不变,即确定了
8
进⼊有序序列,那么最后只剩下⼀个数字
9
,放在末
尾,⾃此排序结束。
代码实现
冒泡的代码还是相当简单的,两层循环,外层冒泡轮数,⾥层依次⽐较,江湖中⼈⼈尽皆
知。
我们看到嵌套循环,应该⽴⻢就可以得出这个算法的时间复杂度为
O(n2)
。
冒泡优化
冒泡有⼀个最⼤的问题就是这种算法不管不管你有序还是没序,闭着眼睛把你循环⽐较了
再说。
⽐如我举个数组例⼦:
[ 9
,
8
,
7
,
6
,
5 ]
,⼀个有序的数组,根本不需要排序,它仍然是
双层循环⼀个不少的把数据遍历⼲净,这其实就是做了没必要做的事情,属于浪费资源。
针对这个问题,我们可以设定⼀个临时遍历来标记该数组是否已经有序,如果有序了就不
⽤遍历了。
public static
void
sort
(
int
arr
[]){
for
(
int
i
=
0
;
i
<
arr
.
length
-
1
;
i
++
){
for
(
int
j
=
0
;
j
<
arr
.
length
-
1
-
i
;
j
++
){
int
temp
=
0
;
if
(
arr
[
j
]
<
arr
[
j
+
1
]){
temp
=
arr
[
j
];
arr
[
j
]
=
arr
[
j
+
1
];
arr
[
j
+
1
]
=
temp
;
}
}
}
}
public static
void
sort
(
int
arr
[]){
for
(
int
i
=
0
;
i
<
arr
.
length
-
1
;
i
++
){
boolean
isSort
=
true
;
for
(
int
j
=
0
;
j
<
arr
.
length
-
1
-
i
;
j
++
){
int
temp
=
0
;
if
(
arr
[
j
]
<
arr
[
j
+
1
]){
temp
=
arr
[
j
];
arr
[
j
]
=
arr
[
j
+
1
];
arr
[
j
+
1
]
=
temp
;
isSort
=
false
;
}
}
if
(
isSort
){
break
;
}
}
选择排序
选择排序的思路是这样的:⾸先,找到数组中最⼩的元素,拎出来,将它和数组的第⼀个
元素交换位置,第⼆步,在剩下的元素中继续寻找最⼩的元素,拎出来,和数组的第⼆个
元素交换位置,如此循环,直到整个数组排序完成。
⾄于选⼤还是选⼩,这个都⽆所谓,你也可以每次选择最⼤的拎出来排,也可以每次选择
最⼩的拎出来的排,只要你的排序的⼿段是这种⽅式,都叫选择排序。
图解选排
我们还是以
[ 8
,
2
,
5
,
9
,
7 ]
这组数字做例⼦。
第⼀次选择,先找到数组中最⼩的数字
2
,然后和第⼀个数字交换位置。(如果第⼀个数
字就是最⼩值,那么⾃⼰和⾃⼰交换位置,也可以不做处理,就是⼀个
if
的事情)
}
第⼆次选择,由于数组第⼀个位置已经是有序的,所以只需要查找剩余位置,找到其中最
⼩的数字
5
,然后和数组第⼆个位置的元素交换。
第三次选择,找到最⼩值
7
,和第三个位置的元素交换位置。
第四次选择,找到最⼩值
8
,和第四个位置的元素交换位置。
最后⼀个到达了数组末尾,没有可对⽐的元素,结束选择。
如此整个数组就排序完成了。
代码实现
双层循环,时间复杂度和冒泡⼀模⼀样,都是
O(n2)
插⼊排序
插⼊排序的思想和我们打扑克摸牌的时候⼀样,从牌堆⾥⼀张⼀张摸起来的牌都是乱序
的,我们会把摸起来的牌插⼊到左⼿中合适的位置,让左⼿中的牌时刻保持⼀个有序的状
态。
那如果我们不是从牌堆⾥摸牌,⽽是左⼿⾥⾯初始化就是⼀堆乱牌呢? ⼀样的道理,我们
把牌往⼿的右边挪⼀挪,把⼿的左边空出⼀点位置来,然后在乱牌中抽⼀张出来,插⼊到
左边,再抽⼀张出来,插⼊到左边,再抽⼀张,插⼊到左边,每次插⼊都插⼊到左边合适
的位置,时刻保持左边的牌是有序的,直到右边的牌抽完,则排序完毕。
public static
void
sort
(
int
arr
[]){
for
(
int
i
=
0
;
i
<
arr
.
length
;
i
++
){
int
min
=
i
;
//
最⼩元素的下标
for
(
int
j
=
i
+
1
;
j
<
arr
.
length
;
j
++
){
if
(
arr
[
j
]
<
arr
[
min
]){
min
=
j
;
//
找最⼩值
}
}
//
交换位置
int
temp
=
arr
[
i
];
arr
[
i
]
=
arr
[
min
];
arr
[
min
]
=
temp
;
}
}
图解插排
数组初始化:
[ 8
,
2
,
5
,
9
,
7 ]
,我们把数组中的数据分成两个区域,已排序区域和未排
序区域,初始化的时候所有的数据都处在未排序区域中,已排序区域是空。
第⼀轮,从未排序区域中随机拿出⼀个数字,既然是随机,那么我们就获取第⼀个,然后
插⼊到已排序区域中,已排序区域是空,那么就不做⽐较,默认⾃身已经是有序的了。
(当然了,第⼀轮在代码中是可以省略的,从下标为
1
的元素开始即可)
第⼆轮,继续从未排序区域中拿出⼀个数,插⼊到已排序区域中,这个时候要遍历已排序
区域中的数字挨个做⽐较,⽐⼤⽐⼩取决于你是想升序排还是想倒序排,这⾥排升序:
第三轮,排
5
:
第四轮,排
9
:
第五轮,排
7
排序结束。
代码实现
从代码⾥我们可以看出,如果找到了合适的位置,就不会再进⾏⽐较了,就好⽐牌堆⾥抽
出的⼀张牌本身就⽐我⼿⾥的牌都⼩,那么我只需要直接放在末尾就⾏了,不⽤⼀个⼀个
去移动数据腾出位置插⼊到中间。
所以说,最好情况的时间复杂度是
O(n)
,最坏情况的时间复杂度是
O(n2)
,然⽽时间复杂
度这个指标看的是最坏的情况,⽽不是最好的情况,所以插⼊排序的时间复杂度是
O(n2)
。
希尔排序
public static
void
sort
(
int
[]
arr
) {
int
n
=
arr
.
length
;
for
(
int
i
=
1
;
i
<
n
;
++
i
) {
int
value
=
arr
[
i
];
int
j
=
0
;
//
插⼊的位置
for
(
j
=
i
-
1
;
j
>=
0
;
j
--
) {
if
(
arr
[
j
]
>
value
) {
arr
[
j
+
1
]
=
arr
[
j
];
//
移动数据
}
else
{
break
;
}
}
arr
[
j
+
1
]
=
value
;
//
插⼊数据
}
}
希尔排序这个名字,来源于它的发明者希尔,也称作
“
缩⼩增量排序
”
,是插⼊排序的⼀种
更⾼效的改进版本。
我们知道,插⼊排序对于⼤规模的乱序数组的时候效率是⽐较慢的,因为它每次只能将数
据移动⼀位,希尔排序为了加快插⼊的速度,让数据移动的时候可以实现跳跃移动,节省
了⼀部分的时间开⽀。
图解希尔排序
待排序数组
10
个数据:
假设计算出的排序区间为
4
,那么我们第⼀次⽐较应该是⽤第
5
个数据与第
1
个数据相⽐
较。
调换后的数据为
[ 7
,
2
,
5
,
9
,
8
,
10
,
1
,
15
,
12
,
3 ]
,然后指针右移,第
6
个数据与第
2
个数据相⽐较。
指针右移,继续⽐较。
如果交换数据后,发现减去区间得到的位置还存在数据,那么继续⽐较,⽐如下⾯这张
图,
12
和
8
相⽐较,原地不动后,指针从
12
跳到
8
身上,继续减去区间发现前⾯还有⼀
个下标为
0
的数据
7
,那么
8
和
7
相⽐较。
⽐较完之后的效果是
7
,
8
,
12
三个数为有序排列。
当最后⼀个元素⽐较完之后,我们会发现⼤部分值⽐较⼤的数据都似乎调整到数组的中后
部分了。
假设整个数组⽐较⻓的话,⽐如有
100
个数据,那么我们的区间肯定是四五⼗,调整后区
间再缩⼩成⼀⼆⼗还会重新调整⼀轮,直到最后区间缩⼩为
1
,就是真正的排序来了。
指针右移,继续⽐较:
重复步骤,即可完成排序,重复的图就不多画了。
我们可以发现,当区间为
1
的时候,它使⽤的排序⽅式就是插⼊排序。
代码实现
public static
void
sort
(
int
[]
arr
) {
int
length
=
arr
.
length
;
//
区间
int
gap
=
1
;
while
(
gap
<
length
) {
gap
=
gap
*
3
+
1
;
}
while
(
gap
>
0
) {
for
(
int
i
=
gap
;
i
<
length
;
i
++
) {
int
tmp
=
arr
[
i
];
可能你会问为什么区间要以
gap = gap*3 + 1
去计算,其实最优的区间计算⽅法是没有答案
的,这是⼀个⻓期未解决的问题,不过差不多都会取在⼆分之⼀到三分之⼀附近。
归并排序
归并字⾯上的意思是合并,归并算法的核⼼思想是分治法,就是将⼀个数组⼀⼑切两半,
递归切,直到切成单个元素,然后重新组装合并,单个元素合并成⼩数组,两个⼩数组合
并成⼤数组,直到最终合并完成,排序完毕。
图解归并排序
int
j
=
i
-
gap
;
//
跨区间排序
while
(
j
>=
0
&&
arr
[
j
]
>
tmp
) {
arr
[
j
+
gap
]
=
arr
[
j
];
j
-=
gap
;
}
arr
[
j
+
gap
]
=
tmp
;
}
gap
=
gap
/
3
;
}
}
我们以
[ 8
,
2
,
5
,
9
,
7 ]
这组数字来举例
⾸先,⼀⼑切两半:
再切:
再切
粒度切到最⼩的时候,就开始归并
数据量设定的⽐较少,是为了⽅便图解,数据量为单数,是为了让你看到细节,下⾯我画
了⼀张更直观的图可能你会更喜欢:
代码实现
我们上⾯讲过,归并排序的核⼼思想是分治,分⽽治之,将⼀个⼤问题分解成⽆数的⼩问
题进⾏处理,处理之后再合并,这⾥我们采⽤递归来实现:
public static
void
sort
(
int
[]
arr
) {
int
[]
tempArr
=
new
int
[
arr
.
length
];
sort
(
arr
,
tempArr
,
0
,
arr
.
length
-
1
);
}
/**
*
归并排序
* @param arr
排序数组
* @param tempArr
临时存储数组
* @param startIndex
排序起始位置
* @param endIndex
排序终⽌位置
*/
private static
void
sort
(
int
[]
arr
,
int
[]
tempArr
,
int startIndex
,
int
endIndex
){
if
(
endIndex
<=
startIndex
){
return
;
}
//
中部下标
int
middleIndex
=
startIndex
+
(
endIndex
-
startIndex
)
/
2
;
//
分解
sort
(
arr
,
tempArr
,
startIndex
,
middleIndex
);
sort
(
arr
,
tempArr
,
middleIndex
+
1
,
endIndex
);
//
归并
merge
(
arr
,
tempArr
,
startIndex
,
middleIndex
,
endIndex
);
}
/**
*
归并
* @param arr
排序数组
* @param tempArr
临时存储数组
* @param startIndex
归并起始位置
* @param middleIndex
归并中间位置
* @param endIndex
归并终⽌位置
*/
private static
void
merge
(
int
[]
arr
,
int
[]
tempArr
,
int
startIndex
,
int
middleIndex
,
int
endIndex
) {
//
复制要合并的数据
for
(
int
s
=
startIndex
;
s
<=
endIndex
;
s
++
) {
tempArr
[
s
]
=
arr
[
s
];
}
int
left
=
startIndex
;
//
左边⾸位下标
int
right
=
middleIndex
+
1
;
//
右边⾸位下标
for
(
int
k
=
startIndex
;
k
<=
endIndex
;
k
++
) {
if
(
left
>
middleIndex
){
//
如果左边的⾸位下标⼤于中部下标,证明左边的数据已经排完了。
arr
[
k
]
=
tempArr
[
right
++
];
}
else if
(
right
>
endIndex
){
//
如果右边的⾸位下标⼤于了数组⻓度,证明右边的数据已经排完了。
arr
[
k
]
=
tempArr
[
left
++
];
}
else if
(
tempArr
[
right
]
<
tempArr
[
left
]){
我们可以发现
merge
⽅法中只有⼀个
for
循环,直接就可以得出每次合并的时间复杂度为
O(n)
,⽽分解数组每次对半切割,属于对数时间
O(log n)
,合起来等于
O(log2n)
,也就是
说,总的时间复杂度为
O(nlogn)
。
关于空间复杂度,其实⼤部分⼈写的归并都是在
merge
⽅法⾥⾯申请临时数组,⽤临时数
组来辅助排序⼯作,空间复杂度为
O(n)
,⽽我这⾥做的是原地归并,只在最开始申请了⼀
个临时数组,所以空间复杂度为
O(1)
。
快速排序
快速排序的核⼼思想也是分治法,分⽽治之。它的实现⽅式是每次从序列中选出⼀个基准
值,其他数依次和基准值做⽐较,⽐基准值⼤的放右边,⽐基准值⼩的放左边,然后再对
左边和右边的两组数分别选出⼀个基准值,进⾏同样的⽐较移动,重复步骤,直到最后都
变成单个元素,整个数组就成了有序的序列。
arr
[
k
]
=
tempArr
[
right
++
];
//
将右边的⾸位排⼊,然后右边的下标指
针
+1
。
}
else
{
arr
[
k
]
=
tempArr
[
left
++
];
//
将左边的⾸位排⼊,然后左边的下标指针
+1
。
}
}
}
图解快排
我们以
[ 8
,
2
,
5
,
0
,
7
,
4
,
6
,
1 ]
这组数字来进⾏演示
⾸先,我们随机选择⼀个基准值:
与其他元素依次⽐较,⼤的放右边,⼩的放左边:
然后我们以同样的⽅式排左边的数据:
继续排
0
和
1
:
由于只剩下⼀个数,所以就不⽤排了,现在的数组序列是下图这个样⼦:
右边以同样的操作进⾏,即可排序完成。
单边扫描
快速排序的关键之处在于切分,切分的同时要进⾏⽐较和移动,这⾥介绍⼀种叫做单边扫
描的做法。
我们随意抽取⼀个数作为基准值,同时设定⼀个标记
mark
代表左边序列最右侧的下标位
置,当然初始为
0
,接下来遍历数组,如果元素⼤于基准值,⽆操作,继续遍历,如果元
素⼩于基准值,则把
mark + 1
,再将
mark
所在位置的元素和遍历到的元素交换位置,
mark
这个位置存储的是⽐基准值⼩的数据,当遍历结束后,将基准值与
mark
所在元素交
换位置即可。
代码实现:
public static
void
sort
(
int
[]
arr
) {
sort
(
arr
,
0
,
arr
.
length
-
1
);
}
private static
void
sort
(
int
[]
arr
,
int
startIndex
,
int
endIndex
) {
if
(
endIndex
<=
startIndex
) {
return
;
}
//
切分
int
pivotIndex
=
partitionV2
(
arr
,
startIndex
,
endIndex
);
sort
(
arr
,
startIndex
,
pivotIndex
-
1
);
sort
(
arr
,
pivotIndex
+
1
,
endIndex
);
}
private static
int
partition
(
int
[]
arr
,
int
startIndex
,
int
endIndex
) {
int
pivot
=
arr
[
startIndex
];
//
取基准值
int
mark
=
startIndex
;
//Mark
初始化为起始下标
双边扫描
另外还有⼀种双边扫描的做法,看起来⽐较直观:我们随意抽取⼀个数作为基准值,然后
从数组左右两边进⾏扫描,先从左往右找到⼀个⼤于基准值的元素,将下标指针记录下
来,然后转到从右往左扫描,找到⼀个⼩于基准值的元素,交换这两个元素的位置,重复
步骤,直到左右两个指针相遇,再将基准值与左侧最右边的元素交换。
我们来看⼀下实现代码,不同之处只有
partition
⽅法:
for
(
int
i
=
startIndex
+
1
;
i
<=
endIndex
;
i
++
){
if
(
arr
[
i
]
<
pivot
){
//
⼩于基准值 则
mark+1
,并交换位置。
mark
++
;
int
p
=
arr
[
mark
];
arr
[
mark
]
=
arr
[
i
];
arr
[
i
]
=
p
;
}
}
//
基准值与
mark
对应元素调换位置
arr
[
startIndex
]
=
arr
[
mark
];
arr
[
mark
]
=
pivot
;
return
mark
;
}
public static
void
sort
(
int
[]
arr
) {
sort
(
arr
,
0
,
arr
.
length
-
1
);
}
private static
void
sort
(
int
[]
arr
,
int
startIndex
,
int
endIndex
) {
if
(
endIndex
<=
startIndex
) {
return
;
}
//
切分
int
pivotIndex
=
partition
(
arr
,
startIndex
,
endIndex
);
sort
(
arr
,
startIndex
,
pivotIndex
-
1
);
sort
(
arr
,
pivotIndex
+
1
,
endIndex
);
}
private static
int
partition
(
int
[]
arr
,
int
startIndex
,
int
endIndex
) {
int
left
=
startIndex
;
int
right
=
endIndex
;
int
pivot
=
arr
[
startIndex
];
//
取第⼀个元素为基准值
极端情况
快速排序的时间复杂度和归并排序⼀样,
O(n log n)
,但这是建⽴在每次切分都能把数组⼀
⼑切两半差不多⼤的前提下,如果出现极端情况,⽐如排⼀个有序的序列,如
[ 9
,
8
,
7
,
6
,
5
,
4
,
3
,
2
,
1 ]
,选取基准值
9
,那么需要切分
n - 1
次才能完成整个快速排序的过
程,这种情况下,时间复杂度就退化成了
O(n2)
,当然极端情况出现的概率也是⽐较低
的。
while
(
true
) {
//
从左往右扫描
while
(
arr
[
left
]
<=
pivot
) {
left
++
;
if
(
left
==
right
) {
break
;
}
}
//
从右往左扫描
while
(
pivot
<
arr
[
right
]) {
right
--
;
if
(
left
==
right
) {
break
;
}
}
//
左右指针相遇
if
(
left
>=
right
) {
break
;
}
//
交换左右数据
int
temp
=
arr
[
left
];
arr
[
left
]
=
arr
[
right
];
arr
[
right
]
=
temp
;
}
//
将基准值插⼊序列
int
temp
=
arr
[
startIndex
];
arr
[
startIndex
]
=
arr
[
right
];
arr
[
right
]
=
temp
;
return
right
;
}
所以说,快速排序的时间复杂度是
O(nlogn)
,极端情况下会退化成
O(n2)
,为了避免极端
情况的发⽣,选取基准值应该做到随机选取,或者是打乱⼀下数组再选取。
另外,快速排序的空间复杂度为
O(1)
。
堆排序
堆排序顾名思义,是利⽤堆这种数据结构来进⾏排序的算法。
如果你不了解堆这种数据结构,可以查看⼩吴之前的数据结构系列⽂章
---
看动画轻松理解
堆
如果你了解堆这种数据结构,你应该知道堆是⼀种优先队列,两种实现,最⼤堆和最⼩
堆,由于我们这⾥排序按升序排,所以就直接以最⼤堆来说吧。
我们完全可以把堆(以下全都默认为最⼤堆)看成⼀棵完全⼆叉树,但是位于堆顶的元素
总是整棵树的最⼤值,每个⼦节点的值都⽐⽗节点⼩,由于堆要时刻保持这样的规则特
性,所以⼀旦堆⾥⾯的数据发⽣变化,我们必须对堆重新进⾏⼀次构建。
既然堆顶元素永远都是整棵树中的最⼤值,那么我们将数据构建成堆后,只需要从堆顶取
元素不就好了吗? 第⼀次取的元素,是否取的就是最⼤值?取完后把堆重新构建⼀下,然
后再取堆顶的元素,是否取的就是第⼆⼤的值? 反复的取,取出来的数据也就是有序的数
据。
图解堆排
我们以
[ 8
,
2
,
5
,
9
,
7
,
3 ]
这组数据来演示。
⾸先,将数组构建成堆。
既然构建成堆结构了,那么接下来,我们取出堆顶的数据,也就是数组第⼀个数
9
,取法
是将数组的第⼀位和最后⼀位调换,然后将数组的待排序范围
-1
。
现在的待排序数据是
[ 3
,
8
,
5
,
2
,
7 ]
,我们继续将待排序数据构建成堆。
取出堆顶数据,这次就是第⼀位和倒数第⼆位交换了,因为待排序的边界已经减
1
。
继续构建堆
从堆顶取出来的数据最终形成⼀个有序列表,重复的步骤就不再赘述了,我们来看⼀下代
码实现。
代码实现
public static
void
sort
(
int
[]
arr
) {
int
length
=
arr
.
length
;
//
构建堆
buildHeap
(
arr
,
length
);
for
(
int
i
=
length
-
1
;
i
>
0
;
i
--
) {
//
将堆顶元素与末位元素调换
int
temp
=
arr
[
0
];
arr
[
0
]
=
arr
[
i
];
arr
[
i
]
=
temp
;
//
数组⻓度
-1
隐藏堆尾元素
length
--
;
//
将堆顶元素下沉 ⽬的是将最⼤的元素浮到堆顶来
sink
(
arr
,
0
,
length
);
}
}
private static
void
buildHeap
(
int
[]
arr
,
int
length
) {
for
(
int
i
=
length
/
2
;
i
>=
0
;
i
--
) {
sink
(
arr
,
i
,
length
);
}
}
/**
*
下沉调整
堆排序和快速排序的时间复杂度都⼀样是
O(nlogn)
。
计数排序
计数排序是⼀种⾮基于⽐较的排序算法,我们之前介绍的各种排序算法⼏乎都是基于元素
之间的⽐较来进⾏排序的,计数排序的时间复杂度为
O(n + m )
,
m
指的是数据量,说的简
单点,计数排序算法的时间复杂度约等于
O(n)
,快于任何⽐较型的排序算法。
* @param arr
数组
* @param index
调整位置
* @param length
数组范围
*/
private static
void
sink
(
int
[]
arr
,
int
index
,
int
length
) {
int
leftChild
=
2
*
index
+
1
;
//
左⼦节点下标
int
rightChild
=
2
*
index
+
2
;
//
右⼦节点下标
int
present
=
index
;
//
要调整的节点下标
//
下沉左边
if
(
leftChild
<
length
&&
arr
[
leftChild
]
>
arr
[
present
]) {
present
=
leftChild
;
}
//
下沉右边
if
(
rightChild
<
length
&&
arr
[
rightChild
]
>
arr
[
present
]) {
present
=
rightChild
;
}
//
如果下标不相等 证明调换过了
if
(
present
!=
index
) {
//
交换值
int
temp
=
arr
[
index
];
arr
[
index
]
=
arr
[
present
];
arr
[
present
]
=
temp
;
//
继续下沉
sink
(
arr
,
present
,
length
);
}
}
图解计数
以下以
[ 3
,
5
,
8
,
2
,
5
,
4 ]
这组数字来演示。
⾸先,我们找到这组数字中最⼤的数,也就是
8
,创建⼀个最⼤下标为
8
的空数组
arr
。
遍历数据,将数据的出现次数填⼊
arr
中对应的下标位置中。
遍历
arr
,将数据依次取出即可。
代码实现
public static
void
sort
(
int
[]
arr
) {
//
找出数组中的最⼤值
int
max
=
arr
[
0
];
for
(
int
i
=
1
;
i
<
arr
.
length
;
i
++
) {
if
(
arr
[
i
]
>
max
) {
max
=
arr
[
i
];
}
}
//
初始化计数数组
int
[]
countArr
=
new
int
[
max
+
1
];
//
计数
for
(
int
i
=
0
;
i
<
arr
.
length
;
i
++
) {
countArr
[
arr
[
i
]]
++
;
稳定排序
有⼀个需求就是当对成绩进⾏排名次的时候,如何在原来排前⾯的⼈,排序后还是处于相
同成绩的⼈的前⾯。
解题的思路是对
countArr
计数数组进⾏⼀个变形,变来和名次挂钩,我们知道
countArr
存
放的是分数的出现次数,那么其实我们可以算出每个分数的最⼤名次,就是将
countArr
中
的每个元素顺序求和。
如下图:
arr
[
i
]
=
0
;
}
//
排序
int
index
=
0
;
for
(
int
i
=
0
;
i
<
countArr
.
length
;
i
++
) {
if
(
countArr
[
i
]
>
0
) {
arr
[
index
++
]
=
i
;
}
}
}
变形之后是什么意思呢?
我们把原数组
[ 2
,
5
,
8
,
2
,
5
,
4 ]
中的数据依次拿来去
countArr
去找,你会发现
3
这个数
在
countArr[3]
中的值是
2
,代表着排名第⼆名,(因为第⼀名是最⼩的
2
,对吧?),
5
这个数在
countArr[5]
中的值是
5
,为什么是
5
呢?我们来数数,排序后的数组应该是
[ 2
,
3
,
4
,
5
,
5
,
8 ]
,
5
的排名是第五名,那
4
的排名是第⼏名呢?对应
countArr[4]
的值是
3
,第三名,
5
的排名是第五名是因为
5
这个数有两个,⾃然占据了第
4
名和第
5
名。
所以我们取排名的时候应该特别注意,原数组中的数据要从右往左取,从
countArr
取出排
名后要把
countArr
中的排名减
1
,以便于再次取重复数据的时候排名往前⼀位。
对应代码实现:
public static
void
sort
(
int
[]
arr
) {
//
找出数组中的最⼤值
int
max
=
arr
[
0
];
for
(
int
i
=
1
;
i
<
arr
.
length
;
++
i
) {
if
(
arr
[
i
]
>
max
) {
max
=
arr
[
i
];
}
}
//
初始化计数数组
int
[]
countArr
=
new
int
[
max
+
1
];
//
计数
for
(
int
i
=
0
;
i
<
arr
.
length
;
++
i
) {
countArr
[
arr
[
i
]]
++
;
}
//
顺序累加
for
(
int
i
=
1
;
i
<
max
+
1
;
++
i
) {
countArr
[
i
]
=
countArr
[
i
-
1
]
+
countArr
[
i
];
}
//
排序后的数组
int
[]
sortedArr
=
new
int
[
arr
.
length
];
//
排序
for
(
int
i
=
arr
.
length
-
1
;
i
>=
0
;
--
i
) {
sortedArr
[
countArr
[
arr
[
i
]]
-
1
]
=
arr
[
i
];
countArr
[
arr
[
i
]]
--
;
}
计数局限性
计数排序的⽑病很多,我们来找找
bug
。
如果我要排的数据⾥有
0
呢?
int[]
初始化内容全是
0
,排⽑线。
如果我要排的数据范围⽐较⼤呢?⽐如
[ 1
,
9999 ]
,我排两个数你要创建⼀个
int[10000]
的
数组来计数?
对于第⼀个
bug
,我们可以使⽤偏移量来解决,⽐如我要排
[ -1
,
0
,
-3 ]
这组数字,这个简
单,我全给你们加
10
来计数,变成
[ 9
,
10
,
7 ]
计完数后写回原数组时再减
10
。不过有可
能也会踩到坑,万⼀你数组⾥恰好有⼀个
-10
,你加上
10
后⼜变
0
了,排⽑线。
对于第⼆个
bug
,确实解决不了,如果是
[ 9998
,
9999 ]
这种虽然值⼤但是相差范围不⼤的
数据我们也可以使⽤偏移量解决,⽐如这两个数据,我减掉
9997
后只需要申请⼀个
int[3]
的数组就可以进⾏计数。
由此可⻅,计数排序只适⽤于正整数并且取值范围相差不⼤的数组排序使⽤,它的排序的
速度是⾮常可观的。
桶排序
桶排序可以看成是计数排序的升级版,它将要排的数据分到多个有序的桶⾥,每个桶⾥的
数据再单独排序,再把每个桶的数据依次取出,即可完成排序。
//
将排序后的数据拷⻉到原数组
for
(
int
i
=
0
;
i
<
arr
.
length
;
++
i
) {
arr
[
i
]
=
sortedArr
[
i
];
}
}
图解桶排序
我们拿⼀组计数排序啃不掉的数据
[ 500
,
6123
,
1700
,
10
,
9999 ]
来举例。
第⼀步,我们创建
10
个桶,分别来装
0-1000
、
1000-2000
、
2000-3000
、
3000-4000
、
4000-5000
、
5000-6000
、
6000-7000
、
7000-8000
、
8000-9000
区间的数据。
第⼆步,遍历原数组,对号⼊桶。
第三步,对桶中的数据进⾏单独排序,只有第⼀个桶中的数量⼤于
1
,显然只需要排第⼀
个桶。
最后,依次将桶中的数据取出,排序完成。
代码实现
这个桶排序乍⼀看好像挺简单的,但是要敲代码就需要考虑⼏个问题了。
桶这个东⻄怎么表示?
怎么确定桶的数量?
桶内排序⽤什么⽅法排?
代码如下:
public static
void
sort
(
int
[]
arr
){
//
最⼤最⼩值
int
max
=
arr
[
0
];
int
min
=
arr
[
0
];
int
length
=
arr
.
length
;
for
(
int
i
=
1
;
i
<
length
;
i
++
) {
if
(
arr
[
i
]
>
max
) {
max
=
arr
[
i
];
}
else if
(
arr
[
i
]
<
min
) {
min
=
arr
[
i
];
}
}
//
最⼤值和最⼩值的差
int
diff
=
max
-
min
;
//
桶列表
ArrayList
<
ArrayList
<
Integer
>>
bucketList
=
new
ArrayList
<>
();
for
(
int
i
=
0
;
i
<
length
;
i
++
){
bucketList
.
add
(
new
ArrayList
<>
());
}
//
每个桶的存数区间
float
section
=
(
float
)
diff
/
(
float
) (
length
-
1
);
//
数据⼊桶
for
(
int
i
=
0
;
i
<
length
;
i
++
){
//
当前数除以区间得出存放桶的位置 减
1
后得出桶的下标
int
num
=
(
int
) (
arr
[
i
]
/
section
)
-
1
;
if
(
num
<
0
){
num
=
0
;
}
bucketList
.
get
(
num
).
add
(
arr
[
i
]);
}
//
桶内排序
for
(
int
i
=
0
;
i
<
bucketList
.
size
();
i
++
){
//jdk
的排序速度当然信得过
Collections
.
sort
(
bucketList
.
get
(
i
));
}
//
写⼊原数组
int
index
=
0
;
for
(
ArrayList
<
Integer
>
arrayList
:
bucketList
){
for
(
int
value
:
arrayList
){
arr
[
index
]
=
value
;
index
++
;
}
}
}
桶当然是⼀个可以存放数据的集合,我这⾥使⽤
arrayList
,如果你使⽤
LinkedList
那其实
也是没有问题的。
桶的数量我认为设置为原数组的⻓度是合理的,因为理想情况下每个数据装⼀个桶。
数据⼊桶的映射算法其实是⼀个开放性问题,我承认我这⾥写的⽅案并不佳,因为我测试
过不同的数据集合来排序,如果你有什么更好的⽅案或想法,欢迎留⾔讨论。
桶内排序为了⽅便起⻅使⽤了当前语⾔提供的排序⽅法,如果对于稳定排序有所要求,可
以选择使⽤⾃定义的排序算法。
桶排序的思考及其应⽤
在额外空间充⾜的情况下,尽量增⼤桶的数量,极限情况下每个桶只有⼀个数据时,或者
是每只桶只装⼀个值时,完全避开了桶内排序的操作,桶排序的最好时间复杂度就能够达
到
O(n)
。
⽐如⾼考总分
750
分,全国⼏百万⼈,我们只需要创建
751
个桶,循环⼀遍挨个扔进去,
排序速度是毫秒级。
但是如果数据经过桶的划分之后,桶与桶的数据分布极不均匀,有些数据⾮常多,有些数
据⾮常少,⽐如
[ 8
,
2
,
9
,
10
,
1
,
23
,
53
,
22
,
12
,
9000 ]
这⼗个数据,我们分成⼗个桶
装,结果发现第⼀个桶装了
9
个数据,这是⾮常影响效率的情况,会使时间复杂度下降到
O(nlogn)
,解决办法是我们每次桶内排序时判断⼀下数据量,如果桶⾥的数据量过⼤,那
么应该在桶⾥⾯回调⾃身再进⾏⼀次桶排序。
基数排序
基数排序是⼀种⾮⽐较型整数排序算法,其原理是将数据按位数切割成不同的数字,然后
按每个位数分别⽐较。 假设说,我们要对
100
万个⼿机号码进⾏排序,应该选择什么排序
算法呢?排的快的有归并、快排时间复杂度是
O(nlogn)
,计数排序和桶排序虽然更快⼀
些,但是⼿机号码位数是
11
位,那得需要多少桶?内存条表示不服。
这个时候,我们使⽤基数排序是最好的选择。
图解基排
我们以
[ 892
,
846
,
821
,
199
,
810
,
700 ]
这组数字来做例⼦演示。
⾸先,创建⼗个桶,⽤来辅助排序。
先排个位数,根据个位数的值将数据放到对应下标值的桶中。
排完后,我们将桶中的数据依次取出。
那么接下来,我们排⼗位数。
最后,排百位数。
排序完成。
代码实现
基数排序可以看成桶排序的扩展,也是⽤桶来辅助排序,代码如下:
public static
void
sort
(
int
[]
arr
){
int
length
=
arr
.
length
;
//
最⼤值
int
max
=
arr
[
0
];
for
(
int
i
=
0
;
i
<
length
;
i
++
){
if
(
arr
[
i
]
>
max
){
max
=
arr
[
i
];
}
}
//
当前排序位置
int
location
=
1
;
//
桶列表
ArrayList
<
ArrayList
<
Integer
>>
bucketList
=
new
ArrayList
<>
();
//
⻓度为
10
装⼊余数
0-9
的数据
for
(
int
i
=
0
;
i
<
10
;
i
++
){
bucketList
.
add
(
new
ArrayList
());
}
while
(
true
)
{
//
判断是否排完
int
dd
=
(
int
)
Math
.
pow
(
10
,
(
location
-
1
));
if
(
max
<
dd
){
break
;
}
//
数据⼊桶
for
(
int
i
=
0
;
i
<
length
;
i
++
)
{
//
计算余数 放⼊相应的桶
int
number
=
((
arr
[
i
]
/
dd
)
%
10
);
bucketList
.
get
(
number
).
add
(
arr
[
i
]);
}
//
写回数组
int
nn
=
0
;
for
(
int
i
=
0
;
i
<
10
;
i
++
){
int
size
=
bucketList
.
get
(
i
).
size
();
for
(
int
ii
=
0
;
ii
<
size
;
ii
++
){
arr
[
nn
++
]
=
bucketList
.
get
(
i
).
get
(
ii
);
}
bucketList
.
get
(
i
).
clear
();
}
location
++
;
}
}
其实它的思想很简单,不管你的数字有多⼤,按照⼀位⼀位的排,
0 - 9
最多也就⼗个桶:
先按权重⼩的位置排序,然后按权重⼤的位置排序。
当然,如果你有需求,也可以选择从⾼位往低位排。