排序算法
References:
值得收藏的十大经典排序算法
漫画:什么是桶排序?
漫画:什么是计数排序?
leetbook:排序算法全解析
visualgo:数据结构与算法可视化
概述
排序算法是一类非常经典的算法,说来简单,说难也难。刚学编程时大家都爱用冒泡排序,随后接触到选择排序、插入排序等,历史上还有昙花一现的希尔排序,公司面试时也经常会问到快速排序等等。小小的排序算法,融入了无数程序大牛的心血。
排序算法在生活中的应用非常广泛,比如:
在学校时,每位学生的考试成绩会按照降序排出名次
在电商领域,需要按照出单量排序,快速找出销量领先的商品
在游戏清算时,根据用户的表现分评选出 MVP
在不同领域,排序算法的实现各有千秋。总体来看,排序算法大致可分为十类:
- 时间复杂度为 O(n^2)的排序算法
选泡插:选择排序、冒泡排序、插入排序 - 时间复杂度为 O(nlog n) 的排序算法
快归希堆:快速排序、归并排序、希尔排序、堆排序
希尔排序比较特殊,它的性能略优于 O(n^2),但又比不上O(nlog n) - 时间复杂度为线性的排序算法
桶计基:桶排序、计数排序、基数排序
非线性时间比较类排序:通过比较来决定元素间的相对次序,由于其时间复杂度不能突破O(nlogn),因此称为非线性时间比较类排序。
线性时间非比较类排序:不通过比较来决定元素间的相对次序,它可以突破基于比较排序的时间下界,以线性时间运行,因此称为线性时间非比较类排序。
排序算法划分 值得收藏的十大经典排序算法说明:
稳定:如果a原本在b前面,而a=b,排序之后a仍然在b的前面;
不稳定:如果a原本在b的前面,而a=b,排序之后a可能会出现在b的后面;
内排序:所有排序操作都在内存中完成;
外排序:由于数据太大,因此把数据放在磁盘中,而排序通过磁盘和内存的数据传输才能进行;
当然,以上列举的只是最主流的排序算法,在算法界还存在着更多五花八门的排序,它们有些基于传统排序变形而来;有些则是脑洞大开,如鸡尾酒排序、猴子排序、睡眠排序等。
鸡尾酒排序
猴子排序
睡眠排序
此外,排序算法还可以根据其稳定性,划分为稳定排序和不稳定排序。
虽然工作中很少需要我们手打排序算法,只需要调用基础库中的 Arrays.sort() 便可解决排序问题。但你可曾静下心来,阅读 Arrays.sort() 背后的原理,它是采用了哪种排序算法呢?
事实上,Arrays.sort() 函数并没有采用单一的排序算法。Java 中的 Arrays.sort() 函数是由 Java 语言的几位创始人编写的,这个小小的函数逻辑严密,并且每个步骤都被精心设计,为了最大化性能做了一层又一层的优化,根据数据的概况采用双轴快排、归并或二分插入算法完成排序,堪称工业级排序算法的典范,理清之后其乐无穷。
并且,排序算法深受面试官的喜爱,在人才招聘时,总是将排序算法作为程序员的基本功来考察。对排序算法的理解深度在一定程度上反映了程序员逻辑思维的严谨度。攻克排序算法的难关是每位程序大牛的必经之路。
如牛顿所言,正是站在巨人的肩膀上,我们才能望得更远。本系列文章我们就来一起梳理一下排序算法的前世今生。
O(n^2):选择排序、插入排序、冒泡排序
选择排序 Select Sort
原理:首先在未排序序列中找到最小(大)元素,存放到排序序列的起始位置,然后,再从剩余未排序元素中继续寻找最小(大)元素,然后放到已排序序列的末尾。以此类推,直到所有元素均排序完毕。
public static int[] selectionSort(int[] array) {
if (array.length == 0) return array;
for (int i = 0; i < array.length; i++) {
int minIndex = i;
for (int j = i; j < array.length; j++) {
if (array[j] < array[minIndex]) //找到最小的数
minIndex = j; //将最小数的索引保存
}
int temp = array[minIndex];
array[minIndex] = array[i];
array[i] = temp;
}
return array;
}
插入排序 Insertion Sort
原理:在要排序的一组数中,假定前n-1个数已经排好序,现在将第n个数插到前面的有序数列中,使得这n个数也是排好顺序的。如此反复循环,直到全部排好顺序。
public static void insertSort(int[] arr) {
// 从第二个数开始,往前插入数字
for (int i = 1; i < arr.length; i++) {
int currentNumber = arr[i];
int j = i - 1;
// 寻找插入位置的过程中,不断地将比 currentNumber 大的数字向后挪
while (j >= 0 && currentNumber < arr[j]) {
arr[j + 1] = arr[j];
j--;
}
// 两种情况会跳出循环:1. 遇到一个小于或等于 currentNumber 的数字,跳出循环,currentNumber 就坐到它后面。
// 2. 已经走到数列头部,仍然没有遇到小于或等于 currentNumber 的数字,也会跳出循环,此时 j 等于 -1,currentNumber 就坐到数列头部。
arr[j + 1] = currentNumber;
}
}
冒泡排序 Bubble Sort
原理:比较两个相邻的元素,将值大的元素交换到右边。如果遇到相等的值不进行交换,那这种排序方式是稳定的排序方式。
通常来说,冒泡排序有三种写法:
- 一边比较一边向后两两交换,将最大值 / 最小值冒泡到最后一位;
- 经过优化的写法:使用一个变量记录当前轮次的比较是否发生过交换,如果没有发生交换表示已经有序,不再继续排序;
- 进一步优化的写法:除了使用变量记录当前轮次是否发生交换外,再使用一个变量记录上次发生交换的位置,下一轮排序时到达上次交换的位置就停止比较。
写法一
public static void bubbleSort(int[] arr) {
for (int i = 0; i < arr.length - 1; i++) {
for (int j = 0; j < arr.length - 1 - i; j++) {
if (arr[j] > arr[j + 1]) {
// 如果左边的数大于右边的数,则交换,保证右边的数字最大
arr[j + 1] = arr[j + 1] + arr[j];
arr[j] = arr[j + 1] - arr[j];
arr[j + 1] = arr[j + 1] - arr[j];
}
}
}
}
i
代表起泡的次数,j
代表起泡的位置。最外层的 for 循环每经过一轮,剩余数字中的最大值就会被移动到当前轮次的最后一位,中途也会有一些相邻的数字经过交换变得有序。总共比较次数是(n-1)+(n-2)+(n-3)+…+1
。整个过程看起来就像一个个气泡不断上浮,这也是“冒泡排序法”名字的由来。
其中,我们在交换两个数字时使用了一个小魔术:没有引入第三个中间变量就完成了两个数字的交换。这个交换问题曾经出现在大厂面试题中,感兴趣的读者可以细品一下。除了这种先加后减的写法,还有一种先减后加的写法:
arr[j + 1] = arr[j] - arr[j + 1];
arr[j] = arr[j] - arr[j + 1];
arr[j + 1] = arr[j + 1] + arr[j];
写法二
第二种写法是在第一种写法的基础上改良而来的:
public static void bubbleSort(int[] arr) {
// 初始时 swapped 为 true,否则排序过程无法启动
boolean swapped = true;
for (int i = 0; i < arr.length - 1; i++) {
// 如果没有发生过交换,说明剩余部分已经有序,排序完成
if (!swapped) break;
// 设置 swapped 为 false,如果发生交换,则将其置为 true
swapped = false;
for (int j = 0; j < arr.length - 1 - i; j++) {
if (arr[j] > arr[j + 1]) {
// 如果左边的数大于右边的数,则交换,保证右边的数字最大
int temp = arr[j];
arr[j] = arr[j + 1];
arr[j + 1] = temp;
// 表示发生了交换
swapped = true;
}
}
}
}
最外层的 for 循环每经过一轮,剩余数字中的最大值仍然是被移动到当前轮次的最后一位。这种写法相对于第一种写法的优点是:如果一轮比较中没有发生过交换,则立即停止排序,因为此时剩余数字一定已经有序了。
写法三
第三种写法比较少见,它是在第二种写法的基础上进一步优化:
public static void bubbleSort(int[] arr) {
boolean swapped = true;
// 最后一个没有经过排序的元素的下标
int indexOfLastUnsortedElement = arr.length - 1;
// 上次发生交换的位置
int swappedIndex = -1;
while (swapped) {
swapped = false;
for (int i = 0; i < indexOfLastUnsortedElement; i++) {
if (arr[i] > arr[i + 1]) {
// 如果左边的数大于右边的数,则交换,保证右边的数字最大
int temp = arr[i];
arr[i] = arr[i + 1];
arr[i + 1] = temp;
// 表示发生了交换
swapped = true;
// 更新交换的位置
swappedIndex = i;
}
}
// 最后一个没有经过排序的元素的下标就是最后一次发生交换的位置
indexOfLastUnsortedElement = swappedIndex;
}
}
经过再一次的优化,代码看起来就稍微有点复杂了。最外层的 while 循环每经过一轮,剩余数字中的最大值仍然是被移动到当前轮次的最后一位。
在下一轮比较时,只需比较到上一轮比较中,最后一次发生交换的位置即可。因为后面的所有元素都没有发生过交换,必然已经有序了。
当一轮比较中从头到尾都没有发生过交换,则表示整个列表已经有序,排序完成。
测试:
public void test() {
int[] arr = new int[]{6, 2, 1, 3, 5, 4};
bubbleSort(arr);
// 输出: [1, 2, 3, 4, 5, 6]
System.out.println(Arrays.toString(arr));
}
复杂度
复杂度:时间复杂度O(n^2),空间复杂度O(1)。
最好情况:在数组已经有序的情况下,只需遍历一次,由于没有发生交换,排序结束。
最差情况:数组顺序为逆序,每次比较都会发生交换。
但优化后的冒泡排序平均时间复杂度仍然是 O(n^2),所以这些优化对算法的性能并没有质的提升。正如 Donald E. Knuth(1974 年图灵奖获得者)所言:“冒泡排序法除了它迷人的名字和导致了某些有趣的理论问题这一事实外,似乎没有什么值得推荐的。”
不管怎么说,冒泡排序法是所有排序算法的老祖宗,如同程序界经典的 “Hello, world” 一般经久不衰,总是出现在各类算法书刊的首个章节。但面试时如果你说你只会冒泡排序可就太掉价了,下一节我们就来认识一下他的继承者们。
题目
O(nlogn):快速排序、归并排序、希尔排序、堆排序
快速排序
快速排序的框架
给你一个整数数组 nums,请你将该数组升序排列。
输入:nums = [5,2,3,1]
输出:[1,2,3,5]
partition函数
- 在
arr[left, right]
选择中枢点arr[mid]
,双指针相向移动,进行交换排序,直到left > right
。 - 左指针移动到
>=
中枢点的位置,而不是>
中枢点。 - 不能在
left == right
时就停止,因为该处的值还没处理。 - 当
left > right
时停止,此时有两种可能:-
arr = [1, 2, 3, 4, 5], pivot = 3
,left
和right
都指向3,那么移动后left
指向4,right
指向2。两个指针隔一个数,且中间那个数就是pivot
。 -
arr = [1, 3, 3', 5], pivot = 3
,left
和right
分别指向3和3',那么swap后arr = [1, 3', 3, 5]
,left
指向3,right
指向3'。两个指针相邻。
-
- 所以,令
index1 = right
,index2 = left
,返回。当然,也可以只返回一个索引,然后再判断。
quickSort函数
-
arr[left, index1]
都是小于等于pivot
,arr[index2, right]
都是大于等于pivot
。在这两个区间分别quickSort。
注意
- 如果只返回一个index也可以,如果left == right-1,pivot可能是两者之一,如果left == right-2,pivot是left+1。
- 两个函数可以合并写
- 也可以选择
arr[left]
或arr[right]
为pivot
,就有另一种写法,似乎是同向双指针,没仔细研究。
时间复杂度
画出递归树
n 1 * n
/ \
n/2 n/2 2 * n/2
/ \ / \
n/4 n/4 n/4 n/4 4 * n/4
1 * n + 2 * n/2 + ... + 2^i * n/2^i = n * i = nlog(n)
代码
class Solution:
def sortArray(self, nums: List[int]) -> List[int]:
# 面试中可能不允许修改原数组,所以把nums拷贝成arr
arr = [0] * len(nums)
for i in range(len(nums)):
arr[i] = nums[i]
# 进行快速排序
self.quickSort(arr, 0, len(nums) - 1)
return arr
# 归并排序函数:将nums[left...right]排序
def quickSort(self, arr: List[int], left: int, right: int):
if left >= right:
return
# 找到两个中枢点
index1, index2 = self.partition(arr, left, right)
# 左边排序
self.quickSort(arr, left, index1)
# 右边排序
self.quickSort(arr, index2, right)
def partition(self, arr: List[int], left: int, right: int):
pivot = arr[left + (right- left) // 2]
# 当left == right时也要做处理
while left <= right:
# 找到左边第一个大于等于pivot的数
while left <= right and arr[left] < pivot:
left += 1
# 找到右边第一个小于等于pivot的数
while left <= right and arr[right] > pivot:
right -= 1
# 交换
if left <= right:
arr[left], arr[right] = arr[right], arr[left]
left += 1
right -= 1
# [..., right]都是小于等于pivot的
# [left, ...]都是大于等于pivot的
# 有两种可能:right + 1 == left or right + 2 == left
return right, left
另一种写法
如果选第一个数作为参考数,那么先排好后面的直到i==j,然后交换0和i-
image.png
# 定义 pivot 函数,他会以数组第一个数作为参考数,并会按上述规则调整数组,并返回参考数的下标
def pivot(a):
# 首先我们分析一下边界情况,首先如果只有一个数,这种情况下数组已经是有序的了,我们返回 -1 代表不需要再继续后面的过程。那如果是两个数的话,我们可以直接比较大小然后给出正确排序,也不需要 pivot 过程了。我们仍然返回 -1。
if len(a) == 1:return -1
if len(a) == 2:
if a[0] > a[1]:
a[0],a[1] = a[1],a[0]
return -1
# 那么接下来我们就得进行我们的算法了,首先按我们刚才说的,我们假设参考数是第一个值。同时我们定义两个指针,i 指向 1,j 指向末尾。
pivot = a[0]
i = 1; j = len(a)-1
# 如果 i 和 j 还没重叠的话
while i < j:
# 我们比较 a[i] 和 pivot 的大小关系,直到碰到第一个 a[i] 大于 pivot,或者 i 等于 j 就退出
while a[i] < pivot and i < j:
i += 1
# 对 a[j] 进行类似操作
while a[j] > pivot and i < j:
j -= 1
# 如果 i, j 重合,就可以退出了
if i == j:break
# 交换 a[i], a[j] 继续算法
a[i],a[j] = a[j],a[i]
# 最后交换 pivot
if a[i] > a[0]:
a[0],a[i-1] = a[i-1],a[0]; i -=1
else:
a[0],a[i] = a[i],a[0]
return i
public static void quickSort(int[] arr) {
quickSort(arr, 0, arr.length - 1);
}
public static void quickSort(int[] arr, int start, int end) {
// 如果区域内的数字少于 2 个,退出递归
if (start >= end) return;
// 将数组分区,并获得中间值的下标
int middle = partition(arr, start, end);
// 对左边区域快速排序
quickSort(arr, start, middle - 1);
// 对右边区域快速排序
quickSort(arr, middle + 1, end);
}
// 将 arr 从 start 到 end 分区,左边区域比基数小,右边区域比基数大,然后返回中间值的下标
public static int partition(int[] arr, int start, int end) {
// 取第一个数为基数
int pivot = arr[start];
// 从第二个数开始分区
int left = start + 1;
// 右边界
int right = end;
while (left < right) {
// 找到第一个大于基数的位置
while (left < right && arr[left] <= pivot) left++;
// 找到第一个小于基数的位置
while (left < right && arr[right] >= pivot) right--;
// 交换这两个数,使得左边分区都小于或等于基数,右边分区大于或等于基数
if (left < right) {
exchange(arr, left, right);
left++;
right--;
}
}
// 如果 left 和 right 相等,单独比较 arr[right] 和 pivot
if (left == right && arr[right] > pivot) right--;
// 将基数和轴交换
exchange(arr, start, right);
return right;
}
private static void exchange(int[] arr, int i, int j) {
int temp = arr[i];
arr[i] = arr[j];
arr[j] = temp;
}
快速排序的优化思路
第一种就是我们在前文中提到的,每轮选择基数时,从剩余的数组中随机选择一个数字作为基数。这样每轮都选到最大或最小值的概率就会变得很低了。所以我们才说用这种方式选择基数,其平均时间复杂度是最优的
第二种解决方案是在排序之前,先用洗牌算法将数组的原有顺序打乱,以防止原数组正序或逆序。Java 已经将洗牌算法封装到了集合类中,即 Collections.shuffle() 函数。洗牌算法由 Ronald A.Fisher 和 Frank Yates 于 1938 年发明,思路是每次从未处理的数据中随机取出一个数字,然后把该数字放在数组中所有未处理数据的尾部。
还有一种解决方案,既然数组重复排序的情况如此常见,那么我们可以在快速排序之前先对数组做个判断,如果已经有序则直接返回,如果是逆序则直接倒序即可。在 Java 内部封装的 Arrays.sort() 的源码中就采用了此解决方案。
快速选择的框架
在未排序的数组中找到第 k 个最大的元素。请注意,你需要找的是数组排序后的第 k 个最大的元素,而不是第 k 个不同的元素。
输入: [3,2,1,5,6,4] 和 k = 2
输出: 5
partition函数
和快排一样。
quickSelect函数
- 快排是:1. 找中枢点 2. 快排左和右
- 快选是:1. 找中枢点 2. 快选左或右
时间复杂度
- 平均: n + n/2 + n/4 +... = O(n)。期望为 O(n),具体证明可以参考《算法导论》第 9 章第 2 小节。
- 最差: n + n-1 + n-2 + ... + 1 = O(n^2)。情况最差时,每次的划分点都是最大值或最小值,一共需要划分 n - 1次,而一次划分需要线性的时间复杂度。
空间复杂度
- 平均:O(logn)。递归调用的期望深度为O(logn),每层需要的空间为 O(1),只有常数个变量。
- 最差:O(n)。最坏情况下需要划分 n 次,即 quickSelect 函数递归调用最深 n - 1层,而每层由于需要 O(1) 的空间,所以一共需要 O(n)的空间复杂度。
代码
class Solution:
def findKthLargest(self, nums: List[int], k: int) -> int:
# 面试中可能不允许修改原数组,所以把nums拷贝成arr
arr = [0] * len(nums)
for i in range(len(nums)):
arr[i] = nums[i]
# 进行快速排序
self.quickSelect(arr, 0, len(nums) - 1, len(nums) - k)
return arr[len(nums) - k]
# 归并排序函数:将nums[left...right]排序
def quickSelect(self, arr: List[int], left: int, right: int, k: int):
# 也可以写 if left == right
if left >= right:
return
# 找到两个中枢点
index1, index2 = self.partition(arr, left, right)
# 左边排序
if k <= index1:
self.quickSelect(arr, left, index1, k)
# 右边排序
elif k >= index2:
self.quickSelect(arr, index2, right, k)
else:
return
def partition(self, arr: List[int], left: int, right: int):
pivot = arr[left + (right- left) // 2]
# 当left == right时也要做处理
while left <= right:
# 找到左边第一个大于等于pivot的数
while left <= right and arr[left] < pivot:
left += 1
# 找到右边第一个小于等于pivot的数
while left <= right and arr[right] > pivot:
right -= 1
# 交换
if left <= right:
arr[left], arr[right] = arr[right], arr[left]
left += 1
right -= 1
# [..., right]都是小于等于pivot的
# [left, ...]都是大于等于pivot的
# 有两种可能:right + 1 == left or right + 2 == left
return right, left
O(n):桶排序、计数排序、基数排序
计数排序 Counting Sort
计数排序是一个非基于比较的排序算法。计数排序需要根据原始数列的取值范围,创建一个统计数组,用来统计原始数列中每一个可能的整数值所出现的次数。
原始数列中的整数值,和统计数组的下标是一一对应的,以数列的最小值作为偏移量。比如原始数列的最小值是90, 那么整数95对应的统计数组下标就是 95-90 = 5。
计数排序
基于比较的排序的时间复杂度在理论上的下限是O(n*log(n)), 如归并排序,堆排序。
计数排序的优势在于在对一定范围内的整数排序时,它的复杂度为Ο(n+r)(其中r是整数的范围),快于任何比较排序算法。当然这是一种牺牲空间换取时间的做法,而且当O(r)>O(nlog(n))的时候其效率反而不如基于比较的排序。
def counting_sort(array):
largest = max(array); smallest = min(array) # 获取最大,最小值
counter = [0 for i in range(largest-smallest+1)] # 用于统计个数的空数组
idx = 0 # 桶内索引值
#step1:put
for i in range(len(array)):
counter[array[i]-smallest] += 1 # 统计每个元素出现的次数
#step2:retrieve
for j in range(len(counter)):
while counter[j] > 0:
array[idx] = j + smallest # 取出元素
idx += 1
counter[j] -= 1
return array
优化版的计数排序
下面的讲解会有一些烧脑,请大家扶稳坐好。我们仍然以刚才的学生成绩表为例,把之前的统计数组变形成下面的样子:
image.png
这是如何变形的呢?统计数组从第二个元素开始,每一个元素都加上前面所有元素之和。这样相加的目的,是让统计数组存储的元素值,等于相应整数的最终排序位置。比如下标是9的元素值为5,代表原始数列的整数9,最终的排序是在第5位。
接下来,我们创建输出数组sortedArray,长度和输入数列一致。然后从后向前遍历输入数列:
第一步,我们遍历成绩表最后一行的小绿:
小绿是95分,我们找到countArray下标是5的元素,值是4,代表小绿的成绩排名位置在第4位。
同时,我们给countArray下标是5的元素值减1,从4变成3,,代表着下次再遇到95分的成绩时,最终排名是第3。
image.png
第二步,我们遍历成绩表倒数第二行的小白:
小白是94分,我们找到countArray下标是4的元素,值是2,代表小白的成绩排名位置在第2位。
同时,我们给countArray下标是4的元素值减1,从2变成1,,代表着下次再遇到94分的成绩时(实际上已经遇不到了),最终排名是第1。
image.png
第三步,我们遍历成绩表倒数第三行的小红:
小红是95分,我们找到countArray下标是5的元素,值是3(最初是4,减1变成了3),代表小红的成绩排名位置在第3位。
同时,我们给countArray下标是5的元素值减1,从3变成2,,代表着下次再遇到95分的成绩时(实际上已经遇不到了),最终排名是第2。
image.png
这样一来,同样是95分的小红和小绿就能够清楚地排出顺序了,也正因此,优化版本的计数排序属于稳定排序。
public static int[] countSort(int[] array) {
//1.得到数列的最大值和最小值,并算出差值d
int max = array[0];
int min = array[0];
for(int i=1; i<array.length; i++) {
if(array[i] > max) {
max = array[i];
}
if(array[i] < min) {
min = array[i];
}
}
int d = max - min;
//2.创建统计数组并统计对应元素个数
int[] countArray = new int[d+1];
for(int i=0; i<array.length; i++) {
countArray[array[i]-min]++;
}
//3.统计数组做变形,后面的元素等于前面的元素之和
int sum = 0;
for(int i=0;i<countArray.length;i++) {
sum += countArray[i];
countArray[i] = sum;
}
//4.倒序遍历原始数列,从统计数组找到正确位置,输出到结果数组
int[] sortedArray = new int[array.length];
for(int i=array.length-1;i>=0;i--) {
sortedArray[countArray[array[i]-min]-1]=array[i];
countArray[array[i]-min]--;
}
return sortedArray;
}
public static void main(String[] args) {
int[] array = new int[] {95,94,91,98,99,90,99,93,91,92};
int[] sortedArray = countSort(array);
System.out.println(Arrays.toString(sortedArray));
}
复杂度
- 时间复杂度:O(n+r)
- 空间复杂度:O(r)
局限性
1.当数列最大最小值差距过大时,并不适用计数排序。
比如给定20个随机整数,范围在0到1亿之间,这时候如果使用计数排序,需要创建长度1亿的数组。不但严重浪费空间,而且时间复杂度也随之升高。
2.当数列元素不是整数,并不适用计数排序。
如果数列中的元素都是小数,比如25.213,或是0.00000001这样子,则无法创建对应的统计数组。这样显然无法进行计数排序。
桶排序 Bucket Sort
思想
当数据范围过大,或者不是整数时,计数排序就行不通了。这时候我们可以用桶排序。
它利用了函数的映射关系,高效与否的关键就在于这个映射函数的确定。桶排序 (Bucket sort)的工作的原理:假设输入数据服从均匀分布,将数据分到有限数量的桶里,每个桶再分别排序(有可能再使用别的排序算法或是以递归方式继续使用桶排序进行排)。
每一个桶(bucket)代表一个区间范围,里面可以承载一个或多个元素。桶排序的第一步,就是创建这些桶,确定每一个桶的区间范围:
桶排序step1
具体建立多少个桶,如何确定桶的区间范围,有很多不同的方式。我们这里创建的桶数量等于原始数列的元素数量,除了最后一个桶只包含数列最大值,前面各个桶的区间按照比例确定。
区间跨度 = (最大值-最小值)/ (桶的数量 - 1)
第二步,遍历原始数列,把元素对号入座放入各个桶中:
桶排序step2
第三步,每个桶内部的元素分别排序(显然,只有第一个桶需要排序):
桶排序step3
第四步,遍历所有的桶,输出所有元素:0.5,0.84,2.18,3.25,4.5
代码
public static double[] bucketSort(double[] array){
//1.得到数列的最大值和最小值,并算出差值d
double max = array[0];
double min = array[0];
for(int i=1; i<array.length; i++) {
if(array[i] > max) {
max = array[i];
}
if(array[i] < min) {
min = array[i];
}
}
double d = max - min;
//2.初始化桶
int bucketNum = array.length;
ArrayList<LinkedList<Double>> bucketList = new ArrayList<LinkedList<Double>>(bucketNum);
for(int i = 0; i < bucketNum; i++){
bucketList.add(new LinkedList<Double>());
}
//3.遍历原始数组,将每个元素放入桶中
for(int i = 0; i < array.length; i++){
int num = (int)((array[i] - min) * (bucketNum-1) / d);
bucketList.get(num).add(array[i]);
}
//4.对每个桶内部进行排序
for(int i = 0; i < bucketList.size(); i++){
//JDK底层采用了归并排序或归并的优化版本
Collections.sort(bucketList.get(i));
}
//5.输出全部元素
double[] sortedArray = new double[array.length];
int index = 0;
for(LinkedList<Double> list : bucketList){
for(double element : list){
sortedArray[index] = element;
index++;
}
}
return sortedArray;
}
public static void main(String[] args) {
double[] array = new double[] {4.12,6.421,0.0023,3.0,2.123,8.122,4.12, 10.09};
double[] sortedArray = bucketSort(array);
System.out.println(Arrays.toString(sortedArray));
}
代码中,所有的桶保存在ArrayList集合当中,每一个桶被定义成一个链表(LinkedList<Double>),这样便于在尾部插入元素。
复杂度
下面我们来逐步分析算法复杂度:
第一步求数列最大最小值,运算量为n。
第二步创建空桶,运算量为m。
第三步遍历原始数列,运算量为n。
第四步在每个桶内部做排序,由于使用了O(nlogn)的排序算法,所以运算量为 n/m * log(n/m ) * m。
第五步输出排序数列,运算量为n。
加起来,总的运算量为 3n+m+ n/m * log(n/m ) * m = 3n+m+n(logn-logm) 去掉系数,时间复杂度为:O(n+m+n(logn-logm))
最好情况,桶中的元素分布均匀,当m=n时,时间复杂度O(n)
最差情况,O(nlogn),白白创建了许多空桶。
至于空间复杂度就很明显了:空桶占用的空间 + 数列在桶中占用的空间 = O(m+n)。
基数排序 Radix Sort
桶排序是基数排序的一个特例。
基数排序是按照低位先排序,然后收集;再按照高位排序,然后再收集;依次类推,直到最高位。有时候有些属性是有优先级顺序的,先按低优先级排序,再按高优先级排序。最后的次序就是高优先级高的在前,高优先级相同的低优先级高的在前。
乍一看会感觉很神奇,为什么明明只考虑了单个位置的大小,最后的数据却整体有序了。我们这样来分析每一步:
- 首先按个位桶排序,那么现在所有的数据都保持个位上有序。
- 然后按十位排序,数据在十位上是有序的。单独看每个桶内,由于 桶内数据保持之前顺序,因此桶内数据在个位上也是有序的。总结一下,桶内数据在后两位有序。
- 同样,按百位排序时,桶内数据 在后三位是有序的。
- 最后,所有位数排完时,桶内数据是有序排列,加上桶本身按最高位顺序排序,因此所有数据都有序了。
由于我们排序每一位都需要遍历一遍数据,所以整体时间复杂度为 O(n*m),其中 m 为数字的最高位数
复杂度:快排vs桶排
快速排序的时间复杂度是O(nlogn),而基数排序的时间复杂度是 O(n),是不是说基数排序一定优于快速排序呢?实际上并不是。
从运行速度角度看,基数排序的理论时间复杂度是更低的,但是其依赖于排序的数据的位数,而数据 x 的位数等价于 log(x),因此基数排序的常数 log(x)和快速排序多出来的复杂度部分log(n) 实际上是一个量级,并不能得到一个绝对的速度优势。
从空间消耗看,快速排序递归调用系统栈,空间复杂度为 O(logn),而基数排序需要一个额外的O(n)桶空间。
排序题目
题目 | 方法 | 时间 |
---|---|---|
剑指 Offer 40. 最小的k个数 | 3x堆排序+冒泡+计数+快排 | 2021/3/9 |
347. 前 K 个高频元素 | 堆排序 桶排序 | 2021/3/9 |
147. 对链表进行插入排序 | 插入排序,跟数组的有区别,注意避坑 | 2021/3/9 |