前面介绍了七大排序中的四种,接下来介绍一下七大排序中时间复杂度为O(nlogn)
的三种排序算法,分别为堆排序,归并排序和快速排序。最后简单介绍一下外部排序的三种排序算法。
堆排序
这里的堆排序是指原地堆排序,不创建新的空间。原地堆排序的核心步骤:
1.先将数组调整为最大堆;
2.不断的将当前堆顶元素和无序数组的最后一个元素进行交换;
3.交换之后,无序数组的最大值就放在了最终位置,此时进行siftDown;
4.重复步骤2和3,当无序数组的只剩下一个元素时,整个堆排序完成。
以排为升序数组为例:
步骤1:将数组调整为最大堆
步骤2:将数组堆化并调整为最大堆之后,只能知道当前堆的最大值,此时只需要将堆顶元素(最大值)与最后一个元素交换,此时无序数组的最大值就放在了数组的末尾。
步骤3: 继续调整除了最后一个节点之外的剩余的树,调整为最大堆,继续交换最大堆的堆顶元素和剩余树的最后一个元素,这样又将一个元素放在了正确的位置。
继续调整剩余树为最大堆,重复上述步骤,当无序数组剩下最后一个元素时,堆排序完成。
代码实现
//原地堆排序
public static void heapSort(int[] arr){
//1、先将任意数组堆化,使其变为一个最大堆
//从最后一个非叶子节点不断向前看,进行下沉操作
for (int i = (arr.length-1-1) / 2; i > 0 ; i--) {
siftDown(arr,i,arr.length);
}
//2、不断地将当前无序数组的最大值(堆顶)和最后一个元素交换
for (int i = arr.length - 1; i > 0 ; i--) {
//将堆顶元素和i交换,i是无序数组的最后一个元素
swap(arr,0,i);
//交换完之后进行元素的下沉操作
siftDown(arr,0,i);
}
}
//在数组arr上进行元素下沉操作
private static void siftDown(int[] arr, int k, int size) {
//当前节点的左孩子节点
int j = 2 * k + 1;
while (j < size){
//在左子树存在的条件下
//如果右子树也存在,且右子树的值大于左子树
if(j + 1 < size && arr[j+1] > arr[j]){
//将j更新为最大值所在的索引
j = j + 1;
}
//此时j保存了左右子树最大值索引
if(arr[k] < arr[j]){
//如果此时根的值小于左右子树的最大值
//先交换元素的值
swap(arr,k,j);
//再更新k的值
k = j;
j = 2 * k + 1;
}else{
//此时根的值大于左右子树的最大值,不用交换
break;
}
}
}
//将两个节点的值交换
private static void swap(int[] arr,int i, int j) {
int tmp = arr[i];
arr[i] = arr[j];
arr[j] = tmp;
}
总结
1.在堆排序中,排升序要建大堆,排降序要建小堆。堆排序对数据不敏感。
2.时间复杂度:O(nlogn)
3.空间复杂度:O(1)
4.稳定性:不稳定
归并排序
核心思路: 不断将原数组一分为二,直到拆分后的子数组只剩下一个元素,此时当数组只有一个元素时,天然有序,这个过程称为归。然后不断的将两个连续的子数组合并为一个大的数组,直到整个数组合并完成,这个过程称为并。
在这个过程中,核心步骤是并这个过程的实现,以上图中最后一步的实现过程为例。一开始可能会想取数组1的起始索引为i,数组2的起始索引为j,比较这两个位置的元素大小,用较小的覆盖掉较大的,这样有一个问题,如果直接在原数组上操作,被覆盖掉的元素就找不到了。所以我们需要额外开辟一个和原数组大小相同的临时数组。
比较当前临时数值aux[i]
和aux[j]
的大小关系,将较小值写回原数组arr[k]
,此时虽然原数组中被覆盖掉的元素不见了,但是临时数组还存了一个。
临时数组中当前j
位置的元素已经比较过了,此时j++
,k++
,再次比较aux[i]
和aux[j]
的大小关系,将较小值写回原数组`arr[k],
此时i
位置的元素被写回了原数组,i++
,k++
,再次比较,将较小值写回原数组,
重复上述步骤,
当7和8进行比较之后将较小值7写回了原数组 ,j
要继续访问下一个位置,此时,j
已经大于r
,说明临时数组中子数组2已经处理完了,只需要将子数组1的剩余元素依次填回原数组即可。
代码实现
//归并排序
public static void mergeSort(int[] arr){
mergeSortInternal(arr,0,arr.length - 1);
}
//在数组arr的【l,..r】区间上进行归并排序
private static void mergeSortInternal(int[] arr, int l, int r) {
//base case
//优化2:小数组(64个元素以内)直接使用插入排序
if(r -l < 64){
insertionSort(arr,l,r);
return;
}
if(l >= r){
return;
}
//mid = (l + r) /2
int mid = l + (( r- l) >> 2);
//先将原数组一分为二,在子数组上先进性归并排序
mergeSortInternal(arr,0,mid);
mergeSortInternal(arr,mid + 1,r);
//此时两个子数组已经有序,将这两个子数组合并为原数组
if(arr[mid]>arr[mid + 1]){
//优化1:只有子数组1和子数组2存在元素乱序才需要合并
merge(arr,l,mid,r);
}
}
//在数组【l,...r】上进行插入排序
private static void insertionSort(int[] arr, int l, int r) {
for (int i = l + 1; i <= r; i++) {
for (int j = i; j > l && arr[j] < arr[j - 1] ; j--) {
swap(arr,j,j-1);
}
}
}
//将两个子数组合并为原数组
private static void merge(int[] arr, int l, int mid, int r) {
//创建一个大小为r - l + 1的与原数组长度一样的临时数组aux
int aux[] = new int[r - l + 1];
//拷贝函数
//1.原数组名 2.原数组开始索引 3.目标数组名 4.目标数组开始索引 5.要拷贝长度
System.arraycopy(arr,l,aux,0,r -l + 1);
//两个子数组的开始索引
int i = l,j = mid + 1;
//k表示当前原数组合并到哪个位置了
for (int k = 0; k <= r ; k++) {
if(i > mid){
//子数组1全部拷贝完毕,将子数组2的所有内容写回arr
//arr从l开始,aux从0开始,存在l的偏移量,所以需要将j - l位置的值写回k位置
arr[k] = aux[j - l];
j++;
}else if(j > r){
//子数组2已经全部拷贝完毕,将子数组1的所有内容写回arr
arr[k] = aux[i - l];
i ++;
// 此时两个子数组都没拷贝完
} else if (aux[i - l] <= aux[j - l]) {
//稳定性
arr[k] = aux[i - l];
i ++;
}else{
arr[k] = aux[j - l];
j ++;
}
}
}
总结
1.归并排序的函数展开时间复杂度为logn
,这个展开过程可以看成是一个递归树。 递归排序的核心主要在于 “并” 这个过程的实现,在这个过程中需要开辟一个临时的数组空间。归并排序无论是对完全有序的原始数据还是完全无序的原始数据都要一分为二进行下一步的操作,所以对数据不敏感。
2.时间复杂度:O(nlogn)
3.空间复杂度:O(n)
4.稳定性:稳定
5.应用
①海量数据处理。
②使用归并排序的思想对链表进行排序
③使用归并排序中的merge思想解决数组的逆序对问题
快速排序
核心思想: 每次从无序数组中选取一个元素称为分区点(pivot),将原集合中所有<pivot
的元素放在分区点的左侧,将>=pivot
的元素放在分区点的右侧。 继续在左半区间和右半区间重复该过程,直到整个数组有序。
分区方法1:挖坑法
先定义分区点pivot为arr[l]
,l
和r
分别表示数组的起始和终止位置索引,
让索引j
从后往前扫描,碰到第一个<pivot
的元素终止,此时让arr[i]=arr[j]
, 将元素3填充到i
所在的位置,这步操作可以看成是“填坑”,
填坑完之后,j
位置对应的元素就空出来了,此时让i从前向后扫描,碰到第一个>pivot的元素终止,让arr[i] = arr[j
], 继续填坑,
重复上述步骤,当i
和j
重合即就是i=j
时,说明所有元素扫描完毕,此时再将pivot=4填回,及就是让arr[i] = 4, 现在pivot的左侧都是比它小的,右侧都是比它大的。
最后在左右两个子数组上选取3和7为分区点再不断地重复上述过程,直到整个数组有序。
代码实现
//挖坑法的快速排序
public static void quickSortByHole(int[] arr){
quickSortHoleInternal(arr,0,arr.length - 1);
}
//在区间【l,..r】上进行快速排序
public static void quickSortHoleInternal(int[] arr ,int l, int r){
// //base case
// if(l >= r){
// return;
// }
//优化1:小数组使用插入排序
if(r - l <= 64){
insertionSort(arr,l ,r);
return;
}
int p = partitionByHole(arr,l,r);
//继续在两个子区间上进行快速排序
quickSortHoleInternal(arr,l,p -1);
quickSortHoleInternal(arr,p + 1,r);
}
//分区方法---挖坑法
private static int partitionByHole(int[] arr, int l, int r) {
int pivot = arr[l];
int i = l,j = r;
while(i < j){
//先让j从后往前扫描,碰到第一个小于pivot的元素终止
//此时依然要保证i< j,因为极端情况下选的元素右侧没有比它小的,这样的话j就会跳过i
while(i < j && arr[j] >= pivot){
j--;
}
//此时j落在第一个小于pivot的位置,填坑
arr[i] = arr[j];
//再让i从前向后扫描,碰到第一个大于pivot的元素终止
while (i<j && arr[i] <= pivot){
i ++;
}
//此时i落在了第一个大于pivot的位置,填坑
arr[j] = arr[i];
}
//此时i和j相等,将分区点回填
arr[j] = pivot;
return j;
}
上述方法是通过递归的方式实现的,在递归的过程中,如果把它看成是一颗二叉树的访问,那这就是前序遍历(根左右),属于深度优先遍历,所以,我们可以借助栈来实现。 在递归展开中,不断变化的是数组的开始和结束索引,栈中保存的也应该是数组的开始和结束索引,所以每次栈压入和弹出的都是一对元素,这一对元素就表示当前要分区的左区间和右区间。
非递归实现
//非递归方法实现快速排序
public static void quickSortHoleNonRecursion(int[] arr){
//借助栈
Deque<Integer> stack = new ArrayDeque<>();
//先要访问的是左半区间,所以先要压入右区间
stack.push(arr.length - 1);
stack.push(0);
while (!stack.isEmpty()){
int l = stack.pop();
int r = stack.pop();
if(l >= r){
//当前子数组处理完毕,不再拆分,继续处理下一个子数组
continue;
}
int p = partitionByHole(arr,l ,r);
//此时要将左右两个子数组索引压入栈中
//先处理左边,所以要先压入右边区间
stack.push(r);
stack.push(p + 1);
//继续处理左半区间
stack.push(p - 1);
stack.push(l);
}
}
问题1: 上述挖坑法的分区方法中,在近乎有序的数组上进行排序时,会退化为
O(n^2)
的时间复杂度。原因在于分区点元素每次取的都是最左侧元素,若待排序集合近乎有序甚至完全有序,则二叉树会变为单枝树,此时二叉树的高度就会从原来的logn
变为n
。 所以,为了避免这个现象,需要尽可能随机的选择分区点,保证当前分区点的左侧和右侧都有元素。
优化挖坑法的分区函数
//分区方法---挖坑法优化
private static int partitionByHole(int[] arr, int l, int r) {
//优化,每次分区选择随机数作为分区点,避免快排在近乎有序的数组上的排序性能退化
//每次分区时,都选择一个随机位置与arr[l]交换
int randomIndex = ThreadLocalRandom.current().nextInt(l,r);
swap(arr,l,randomIndex);
int pivot = arr[l];
int i = l,j = r;
while(i < j){
//先让j从后往前扫描,碰到第一个小于pivot的元素终止
//此时依然要保证i< j,因为极端情况下选的元素右侧没有比它小的,这样的话j就会跳过i
while(i < j && arr[j] >= pivot){
j--;
}
//此时j落在第一个小于pivot的位置,填坑
arr[i] = arr[j];
//再让i从前向后扫描,碰到第一个大于pivot的元素终止
while (i<j && arr[i] <= pivot){
i ++;
}
//此时i落在了第一个大于pivot的位置,填坑
arr[j] = arr[i];
}
//此时i和j相等,将分区点回填
arr[j] = pivot;
return j;
}
分区方法2:《算法4》的分区
首先,起始情况下,将最左侧元素作为分区点v
,j
表示小于v
的区间上最后一个位置的索引,i
表示当前正在扫描的元素索引,在[l + 1,j]
上的所有元素都小于v
,[j +1,i)
上的所有元素都大于等于v
。
第一种情况,当arr[i] >= v
时,此时只需要将当前元素纳入>=v
的那部分,即就是i++
即可,
第二种情况,当arr[i] < v
时,交换i
位置和j+1
位置的元素,j++
,i++
,继续访问下一个元素,
当i
扫描完数组所有元素之后,将j
位置的元素和分区点元素交换,这样整个数组左都是小于v
的元素,右侧都是大于等于v
元素。
《算法四》的分区方法实现
//《算法四》的分区方法
private static int partition(int[] arr, int l, int r) {
int randomIndex = ThreadLocalRandom.current().nextInt(l,r);
swap(arr,l,randomIndex);
int v = arr[l];
//arr[l + 1,...j] < v
//初始位置该区间没有元素
int j = l;
//arr[j + 1,...i)>= v
//最开始这个区间依旧没有元素,让i = j + 1
for (int i = l + 1; i <= r ; i++) {
//不管扫描到的元素大于v还是小于等于v,i都要 ++
//只需要处理小于v的情况
if(arr[i] < v){
swap(arr,j + 1,i);
j ++;
}
}
//此时扫描完整个数组
swap(arr,j,l);
//返回分区点索引
return j;
}
问题2: 在上述方法中,当区间包含了大量重复元素时,递归树又会退化为单枝树, 又变为了
O(n^2)
的时间复杂度,为了解决个问题,引入新区间,即就是在扫描时将所有等于v的元素设置为一个新区间, 在递归时只需要递归小于v和大于v的区间。
首先,定义起始位置为分区点元素,lt
为小于v
的元素区间的最后一个位置,i
为待扫描的元素索引,gt
为大于v
的区间起始位置,
第一种情况,当arr[i] < v
时,将lt+1
的位置元素和i
位置的元素交换,lt++
,i++
,
第二种情况,当arr[i]== v
时,此时i++
即可,
第三种情况,当arr[i] > v
时,交换i位置的元素和gt-1
位置的元素,gt--
,此时i
不能i++
,原因在于换过来的元素是还没有扫描的,所以此时i
保持不动,
最终,当扫描完所有元素时,i
即将和gt
重合 将分区点元素和lt
位置所在的元素交换即可,这样在最终递归时,只需要在<v
和>v
的区间上进行快速排序即可。
优化实现(三路快排)
//三路快排
//在一次操作中将所有重复元素一次放在最终位置
//最终只需要递归的在小于和大于v的子区间快排即可
public static void quickSort_3(int[] arr) {
quickSortInternal_3(arr,0,arr.length - 1);
}
private static void quickSortInternal_3(int[] arr, int l, int r) {
if(r - l <= 64){
insertionSort(arr,l,r);
return;
}
int randomIndex = ThreadLocalRandom.current().nextInt(l,r);
swap(arr,l,randomIndex);
int v = arr[l];
// arr[l + 1...lt] < v
int lt = l;
// arr[gt...r] > v
int gt = r + 1;
// arr[lt + 1..i) == v,i指向当前要处理的元素
int i = lt + 1;
//终止条件i和gt重合
while (i < gt){
if(arr[i] < v){
swap(arr,lt + 1,i);
lt++;
i++;
} else if (arr[i] > v) {
// 此时不需要i++,因为gt-1这个未处理的元素换到i位置
swap(arr,gt-1,i);
gt--;
}else{
//此时arr[i] == v
i++;
}
}
//此时扫描完所有元素
swap(arr,l,lt);
//交换后arr[l..lt - 1] < v ; arr[gt..r] >v
quickSortInternal_3(arr,l,lt-1);
quickSortInternal_3(arr,gt,r);
}
总结
1.快速排序的核心在于分区函数的实现,在挖坑法中,只考虑大于或小于pivot的元素,原因在于与分区点相等的元素无论在左侧还是右侧都不影响最终的排序结果。
2.时间复杂度:O(nlogn)
3.空间复杂度:O(logn)
4.稳定性:不稳定
5.应用
快排分区方法来寻找第k大元素,时间复杂度O(n)。
桶排序
将要排序的集合分散在若干个桶(数组)中,子数组的内部排序好,整个数组就有序了。
a.待排序的数据能均分在若干个桶中;
b.桶和桶之间是相对有序的;
比如,以订单系统为例,现在有10GB的订单数据,按照订单金额对订单进行排序,订单金额从0-1000不等。
计数排序
计数排序是桶排序的特殊情况,将数据划分到不同的桶中之后,桶内元素是相等元素,内部不需要再排序,只需要将原数组的所有元素扫描一遍之后划分到不同桶中即可。比如,现在按照年龄把所有中国人排序,此时将年龄相同的人放在同一个桶中。
基数排序
基数排序最明显的特征就是可以按“位”排序,若最高位已经大于另一个元素,其他位数不需要再次比较;若最高位相同,继续比较下一位。位与位之间是独立的。
比如,现在按照身份证号对所有人进行排序,不同省份人的身份证号开头的数字是不同的。
外部排序中的三种排序算法时间复杂度近乎
O(n)
,对数据非常敏感。
继续努力!!!