5. 归并排序
核心思想
将无序数组从中间分为左右两个部分,分别给左右两个部分排序,排序以后把两个有序区间进行合并
- 把数组从中间分为左右两部分
- 分别对前后两部分进行排序
- 将排好序的两部分合并
包含两个部分:拆解阶段和合并阶段
归并排序的递归性质
归并排序递归代码逻辑
public class MergeSorter {
public void sort(int[] data){
if(data == null || data.length<2) return;
sort(data,0,data.length-1); //大问题
}
//给子数组进行排序,子区间
private void sort(int[] data,int left,int right){
//1.终止递归条件
if(left == right) return;
//2.分别对两个子问题求解
int mid = (left + right) / 2;
sort(data,left,mid);
sort(data,mid+1,right);
//3.合并两个有序的数组,即合并[left...mid] 和 [mid+1...right]
//[left...mid] 和 [mid+1,right]这两个数组在上面已经排好序了
merge(data,left,mid,right);
}
private void merge(int[] data, int left, int mid, int right) {
}
}
归并排序合并的逻辑
以最后一次合并为例:
- 定义两个指针i和j,i指针用来遍历前半部分的数据,j指针用来遍历后半部分的数据
- 申请一个额外数组tmp用来存放排好序的数据,(注意这里是每次合并都需要去申请一个额外的临时数组)
- 对比i指针和j指针指向元素哪个大,谁小就把谁存入额外数组中
- 排序完成后将额外数组tmp中的元素再挨个复制到原来的数组中去
//合并
private void merge(int[] data, int left, int mid, int right) {
int num = right - left + 1;
//临时数组tmp
int[] tmp = new int[num];
//临时数组指针
int tmpPos = 0;
//i指针遍历前半部分
int i = left;
//j指针遍历后半部分
int j = mid + 1;
//将左边和右边的元素按照顺序拷贝到临时数组中
while (i<=mid && j<=right){
if(data[i] <= data[j]){
tmp[tmpPos++] = data[i++];
}else{ // data[i] > data[j]
tmp[tmpPos++] = data[j++];
}
}
//如果左边还有元素,则直接将左边的元素拷贝到临时数组
while (i<=mid){
tmp[tmpPos++] = data[i++];
}
//如果左边还有元素,则直接将右边的元素拷贝到临时数组
while (j<=right){
tmp[tmpPos++] = data[j++];
}
}
归并排序拷贝实现
public class MergeSorter {
public void sort(int[] data){
if(data == null || data.length<2) return;
sort(data,0,data.length-1); //大问题
}
//给子数组进行排序,子问题
private void sort(int[] data,int left,int right){
//终止递归条件
if(left == right) return;
//分别对两个子问题求解
int mid = (left + right) / 2;
sort(data,left,mid);
sort(data,mid+1,right);
//合并两个有序的数组,即合并[left...mid] 和 [mid+1...right]
//[left...mid] 和 [mid+1,right]这两个数组在上面已经排好序了
merge(data,left,mid,right);
}
//合并
private void merge(int[] data, int left, int mid, int right) {
int num = right - left + 1;
//临时数组tmp
int[] tmp = new int[num];
//临时数组指针
int tmpPos = 0;
//i指针遍历前半部分
int i = left;
//j指针遍历后半部分
int j = mid + 1;
//将左边和右边的元素按照顺序拷贝到临时数组中
while (i<=mid && j<=right){
if(data[i] <= data[j]){
tmp[tmpPos++] = data[i++];
}else{ // data[i] > data[j]
tmp[tmpPos++] = data[j++];
}
}
//如果左边还有元素,则直接将左边的元素拷贝到临时数组
while (i<=mid){
tmp[tmpPos++] = data[i++];
}
//如果左边还有元素,则直接将右边的元素拷贝到临时数组
while (j<=right){
tmp[tmpPos++] = data[j++];
}
//拷贝
for (tmpPos = 0; tmpPos < tmp.length; tmpPos++) {
data[left++] = tmp[tmpPos];
}
}
}
归并排序临时数组优化
一次性申请一个和原始输入数组长度一样的临时数组,后面合并就不再申请了
例如合并后四个元素,只需要用到临时数组的4到7,tmpPos从left=4开始
例如合并全部元素,就是从0到7
package com.xiaoyi.line.algo.sort;
import java.util.Arrays;
public class MergeSorter {
public void sort(int[] data){
if(data == null || data.length<2) return;
//一次性申请一个和原始数组同样大小的临时数组
int[] tmp = new int[data.length];
sort(data,0,data.length-1,tmp); //大问题
}
//给子数组进行排序,子问题
private void sort(int[] data,int left,int right,int[] tmp){
//终止递归条件
if(left == right) return;
//分别对两个子问题求解
int mid = (left + right) / 2;
sort(data,left,mid,tmp);
sort(data,mid+1,right,tmp);
//合并两个有序的数组,即合并[left...mid] 和 [mid+1...right]
//[left...mid] 和 [mid+1,right]这两个数组在上面已经排好序了
merge(data,left,mid,right,tmp);
}
//合并
private void merge(int[] data, int left, int mid, int right,int[] tmp) {
int tmpPos = left;
//i指针遍历前半部分
int i = left;
//j指针遍历后半部分
int j = mid + 1;
//将左边和右边的元素按照顺序拷贝到临时数组中
while (i<=mid && j<=right){
if(data[i] <= data[j]){
tmp[tmpPos++] = data[i++];
}else{ // data[i] > data[j]
tmp[tmpPos++] = data[j++];
}
}
//如果左边还有元素,则直接将左边的元素拷贝到临时数组
while (i<=mid){
tmp[tmpPos++] = data[i++];
}
//如果左边还有元素,则直接将右边的元素拷贝到临时数组
while (j<=right){
tmp[tmpPos++] = data[j++];
}
//拷贝
for (tmpPos = left; tmpPos < right; tmpPos++) {
data[left++] = tmp[tmpPos];
}
}
public static void main(String[] args) {
int[] data = new int[]{12,23,36,9,24,42};
new MergeSorter().sort(data);
System.out.println(Arrays.toString(data));
}
}
归并两个有序的数组区间(写法二)
之前的做法是将两个有序的数组归并到一个临时数组中,然后把临时数组拷贝到原始数组区间里面去
还有一种做法是将原始数组先拷贝到临时数组去,然后再针对临时数据把它们归并到原始数组里面去,和之前的做法相反了,但是效果一样
public class MergeSorter {
public void sort(int[] data){
if(data == null || data.length<2) return;
//一次性申请一个和原始数组同样大小的临时数组
int[] tmp = new int[data.length];
sort(data,0,data.length-1,tmp); //大问题
}
//给子数组进行排序,子问题
private void sort(int[] data,int left,int right,int[] tmp){
//终止递归条件
if(left == right) return;
//分别对两个子问题求解
int mid = (left + right) / 2;
sort(data,left,mid,tmp);
sort(data,mid+1,right,tmp);
//合并两个有序的数组,即合并[left...mid] 和 [mid+1...right]
//[left...mid] 和 [mid+1,right]这两个数组在上面已经排好序了
merge(data,left,mid,right,tmp);
}
//合并
private void merge2(int[] data, int left, int mid, int right,int[] tmp) {
for (int i = left; i <= right; i++) {
tmp[i] = data[i];
}
int i = left;
int j = mid + 1;
for (int k = left; k <= right;k++){
if(i == mid+1){ //左边没有元素,右边有元素
data[k] = tmp[j++];
}else if(j == right+1){ //左边有元素,右边没有元素
data[k] = tmp[i++];
}else if(tmp[i] <= tmp[j]){ //两边都有元素,左边元素小于右边元素
data[k] = tmp[i++];
}else{
data[k] = data[j++]; //两边都有元素,左边元素大于右边元素
}
}
}
}
归并排序时间复杂度分析
O(nlogn)
归并排序的特点
- 空间复杂度是O(n),不是原地排序,会申请一个临时数组
- 归并排序是稳定的排序算法
data[i]<=data[j]会将data[i]左边的元素放入tmp中
自底朝上的归并排序逻辑
之前使用的是自定向下的归并排序
还有一种是自底朝上的归并排序
自底朝上归并排序实现
public void sortBU(int[] data){
if(data == null || data.length<=1)return;
int len = data.length;
int[] tmp = new int[len];
for (int size = 1;size<len;size += size){ // size 表示子数组的长度 1,2,4,8...
for (int left = 0; left < len - size; left += 2 * size) {
int mid = left + size -1;
int right = Math.min(left+2*size-1,len-1);
merge(data,left,mid,right,tmp);
}
}
}
6. 快速排序
快速排序VS归并排序
快速排序:将数组一分为二,分为两个子数组,然后分别对两个子数组进行独立排序,排完之后整个数组就有序了。
归并排序:将数组从中间一分为二,分别对两个子数组进行排序,就得到了两个有序的子数组,然后对两个有序的子数组进行合并
归并排序有一个合并的过程,快排没有合并的过程
归并排序是简单的从中间一分为二,快排并非如此
快速排序切分数组的逻辑
首先找到一个分区点,这个分区点可以是数组中任意一个元素,可以是第一个也可以是最后一个
假设最后一个元素为分区点(pivot)
然后将数组元素根据分区点进行分区,将大于分区点的元素放到分区点后面,将小于分区点的元素放到分区点前面,这样就可以把数组分为两部分,前面是小于分区点的,后面是大于分区点的
然后再对分区点的左边子数组进行单独排序,对分区点的右边子数组进行排序,这样整个数组就有序了,就没必要再去合并数组了
快速排序符合递归的三个条件
根据分区点一分为二以后,应该如何给左右两边的子数组进行排序呢?不断递归
不断递归解决子问题的过程
快排的递归代码实现
public class QuickSorter {
public void sort(int[] data){
if(data == null || data.length<=1) return;
sort(data,0,data.length-1);
}
//子问题
private void sort(int[] data,int lo,int hi){
if(lo>=hi) return;
//分区
int j = partition(data,lo,hi);
//对左边数组排序
sort(data,lo,j-1);
//对右边数组排序
sort(data,j+1,hi);
}
private int partition(int[] data, int lo, int hi) {
//TODO
return 0;
}
}
深入理解快排过程
每次都假设最后一个元素作为分区点,进行分区
分区的逻辑过程
两个指针lo 和 hi ,选择最后一个元素作为分区点(pivot)20
定义两个指针less 和 great ,都是从lo开始,less指向小于分区点的元素的后一个位置,great指向大于20的最后一个元素
首先判断great指针如果指向的数值比pivot小的话,将less和great进行交换,比pivot大就向前移动
分区逻辑代码实现
public class QuickSorter extends Sorter {
public void sort(int[] data){
if(data == null || data.length<=1) return;
sort(data,0,data.length-1);
}
//子问题
private void sort(int[] data,int lo,int hi){
if(lo>=hi) return;
//分区
int j = partition(data,lo,hi);
//对左边数组排序
sort(data,lo,j-1);
//对右边数组排序
sort(data,j+1,hi);
}
//分区过程
private int partition(int[] data, int lo, int hi) {
int pivot = data[hi]; //将最后一个元素作为分区点
int less = lo;
int great = lo;
for (; great <= hi-1 ; great++) { //遍历整个数组
if(data[great] < pivot){ //如果小于分区点,将元素交换
swap(data,less,great);
less++;
}
}
//great 遍历到 hi -1,将less和 hi 也就是分区点进行交换
swap(data,less,hi);
return less;
}
public static void main(String[] args) {
int[] data = new int[]{12,23,36,9,24,42};
new QuickSorter().sort(data);
System.out.println(Arrays.toString(data));
}
}
快速排序的特点
- 时间复杂度:O(nlogn)
- 空间复杂度O(logn),原地排序算法
- 不稳定的排序算法
结论:并不是原地排序算法的空间复杂度就是O(1)
空间复杂度是O(1)的排序算法,肯定是原地排序算法
快速排序的优化
在极端情况下,数组已经有序,并且选择了最后一个元素作为分区点,这个时候时间复杂度会退化成O(n2)
在给已经有序的数组进行排序的情况下,假设最后一个元素为分区点,这个时候第一遍分区,要比较n-1个元素,分区完后,小于20的都在左边,接下来遍历前一个,又需要进行n-2次比较;
每次分区得到的区间是不均等的,每次分区后所有的元素都在分区点的左边
导致退化成O(n2)的主要原因还是分区点选择的不合理
要保证分区点左右两侧数据量差不多
性能优化:
- 三数取中法
取第一个元素,中间元素,最后一个元素的中间值作为分区点 - 随机法,随机选择一个元素,不能保证每次选择的分区点很好
三路快排的逻辑
有很多重复元素的数组
左侧还会去分区,已经有序了,再去分区就是浪费资源了
引入三路快排
i到great之间就是正在处理的元素空间处理后会分为三个部分,重复的元素都会放到pivot;只需要对小于pivot和大于pivot的元素进行分区就可以了