堆排序、快速排序和归并排序

简介: 堆排序、快速排序和归并排序



一、堆排序

所谓堆排序,就是在堆的基础上进行排序,那么如何建堆,就是堆排序的重点。

堆排序核心思想:

1.建堆:排升序建大根堆,排降序建小根堆

2.在建好堆后,每次堆顶元素和最后一个位置的元素交换

3.每交换一次,就进行向下调整操作,使其满足堆的性质

4.交换后,最后一个位置向前走一步

1.建堆

堆有两种,大根堆和小根堆。

(1)排升序,建立大根堆

(2)排降序,建立小根堆

再利用大根堆或者小根堆进行排序

(1)建堆

我们建堆使用向下调整的方式,从最后一棵子树开始。每一棵子树都需要进行向下调整。

向下调整创建大根堆

核心:先将一维数组以层序遍历的方式创建成一棵完全二叉树,然后从最后一棵子树(最后一个父亲结点)开始,向下调整(从父亲到最后一个孩子)。每一轮结束,父亲结点减一。

回忆父亲结点和孩子结点的关系:假设知道父亲结点的下标为i,则左孩子的下标为:(2*i)-1;如果知道孩子结点的下标(左右都可)为i,则父亲结点为:(i-1)/2。

通过图示理解大根堆的向下调整创建:

有该数组:{27,15,19,18,28,34,65,49,25,37}

得到一棵逻辑上的完全二叉树:

然后从最后一棵子树开始向下调整:

调整的步骤:

(1)左右孩子比较,找出孩子大的下标(2)大孩子与父亲结点比较,大于父亲结点则交换(3)父亲结点下标走到孩子位置,孩子再等于新的下标

下面是调整的过程:

第一轮:调整最后一棵子树,p的开始位置为4

c=2*9+1=18,上面标错了

第二轮:p--,走到3的位置

c=2*7+1=15,上面标错了  

第三轮:p--,走到2的位置

c=2*6+1=13,上面标错了  

第四轮:p--,走到1的位置

第五轮:p--,走到0的位置

上面就是堆调整的全过程,我们总结一下:

p代表需要调整的子树,每调整完一轮,p--,直到p调整完;而在每一轮的调整中,交换完一小轮,p就要向下走,c也要继续往下走,直到不满足条件。

下面是建立大根堆的代码:

public void create(int[] arr) {
        //从最后一棵子树向下调整
        for (int parent = (arr.length -1-1)/2; parent >= 0 ; parent--) {
            siftDown(arr,parent,arr.length);
        }
    }
    /**
     *  向下调整
     */
public void siftDown(int[] arr,int parent,int len) {
        int child = parent*2 + 1;
        while(child < len) {
            //1.找左右孩子最大的
            if(child+1 < len && arr[child+1] > arr[child]) {
               child = child + 1;
            }
            if(arr[child] > arr[parent]) {
                swap(arr,child,parent);
                parent = child;
                child = parent*2 + 1;
            }else {
                break;
            }
        }
}
public void swap(int[] arr,int i,int j) {
        int tmp = arr[i];
        arr[i] = arr[j];
        arr[j] = tmp;
    }

下面是建立小根堆的代码:

public void createminHeap() {
        for (int parent = (usedSize-1)/2; parent >= 0 ; parent--) {
            siftDown2(parent,usedSize);
        }
    }
    private void siftDown2(int parent,int end) {
        int child = parent*2+1;//找到左孩子下标
        //循环结束,说明一棵子树调整完成
        while (child < end) {
            //1.找左右孩子最小的一个.进入if说明第二个孩子比较大
            if(child+1 < usedSize && elem[child] > elem[child+1]) {
                child++;
            }
            //2.比较父亲结点和大孩子结点
            if(elem[parent] > elem[child]) {
                swap(parent,child);
                parent = child;
                child = parent*2+1;//找到左孩子下标
            }else {
                break;//满足则结束循环
            }
        }
    }
public void swap(int[] arr,int i,int j) {
        int tmp = arr[i];
        arr[i] = arr[j];
        arr[j] = tmp;
    }

2.堆排序

在学会如何建堆之后,才能进行堆排序操作,堆排序操作是比较简单的

思想:每次堆顶元素和最后一个元素交换,交换完进行一次向下调整,每次过后最后一个元素向前走

大根堆可以保证堆顶元素是最大的,每次和最后一个位置交换;不断交换,就会形成升序。

堆排序部分代码:

public void headSort(int[] arr) {
        //1.排升序,建大根堆
        create(arr);
        int end = arr.length - 1;
        while(end > 0) {
            //2.每次交换堆顶和最后一个元素
            swap(arr,0,end);
            //3.交换完调整
            siftDown(arr,0,end);
            end--;
        }
    }

堆排序完整代码:

public class heapSort {
    public void headSort(int[] arr) {
        //1.排升序,建大根堆
        create(arr);
        int end = arr.length - 1;
        while(end > 0) {
            //2.每次交换堆顶和最后一个元素
            swap(arr,0,end);
            //3.交换完调整
            siftDown(arr,0,end);
            end--;
        }
    }
    /**
     * 交换
     */
    public void swap(int[] arr,int i,int j) {
        int tmp = arr[i];
        arr[i] = arr[j];
        arr[j] = tmp;
    }
    public void create(int[] arr) {
        //从最后一棵子树向下调整
        for (int parent = (arr.length -1-1)/2; parent >= 0 ; parent--) {
            siftDown(arr,parent,arr.length);
        }
    }
    /**
     *  向下调整
     */
    public void siftDown(int[] arr,int parent,int len) {
        int child = parent*2 + 1;
        while(child < len) {
            //1.找左右孩子最大的
            if(child+1 < len && arr[child+1] > arr[child]) {
               child = child + 1;
            }
            if(arr[child] > arr[parent]) {
                swap(arr,child,parent);
                parent = child;
                child = parent*2 + 1;
            }else {
                break;
            }
        }
    }
}

总结:

(1)时间复杂度:O(n*logn)

(2)空间复杂度:O(1)

(3)稳定性:不稳定

二、快速排序

快速排序是一种基于二叉树形式的交换数据排序,快听名字就知道他很快

下面介绍的快速排序,我们会介绍:快排的思想、Hoare版分割法、挖坑法分割法、如何优化快速排序快速排序的非递归写法

1.思想解析

(1)官方概念

在待排序的 N个记录中任意取一个记录,把该 记录放在最终位置后, 数据序列被此记录分成两部分。 (如何分成两个部分:所有关键字比该记录关键 字小的放在前一部分, 所有比它大的放置在后一部分, 并把该记录排在这两部分 的中间,这个过程称作一次快速排序)之后重复上述过程, 直到每一部分内只有 一个记录为止。

(2)简述概念

(1)每次找一个基准,再定义两个指针(分别指向数据序列的两头),遍历该数据序列。(2)遍历时,满足一定的条件,就交换指针所指向的值或者其他操作,直到两个指针相遇。

(3)指针相遇的位置就将该序列分成左右两部分

(4)左右两个部分再重复(1)-(3)的操作,直到不能再分解,排序完成

我们这里暂时称(1)(2)两个步骤为:寻找基准法

上面的都是干巴巴的概念,很难理解,下面结合图解。

(3)图解如何完成排序

下面第一步到找到下一个基准的过程称为:找基准法(官方概念为Hoare版);找基准法还有另一种称为:挖坑法

使用Hoare找基本法的每一轮:

(4)部分代码

根据上述的图解,我们得出,每次结束,就会将数组分成两个部分,然后每个部分再重复步骤,可以想到使用递归的方式完成。

partition2方法就是找基准法,具体实现下面介绍

private static void quick(int[] array,int start,int end) {
        if(start >= end) {
            return;
        }
        int pivot = partition2(array,start,end);//记录每一轮基准的位置
        //递归左边
        quick(array,start,pivot-1);
        //递归右边
        quick(array,pivot+1,end);
    }

找基准法一共有三种:Hoare版、挖坑法、前后指针法,其中最常用的是:挖坑法;不常见的是:前后指针法

2.Hoare版找基准

根据Hoare版的思想完成的代码,具体思想不再重复

private static int partition1(int[] array,int left,int right) {
        //1.确定基准
        int tmp = array[left];
        int l = left;
        //2.遍历数组,直到相遇
        while (left < right) {
            while (left < right && array[right] >= tmp) {
                right--;
            }
            while (left < right && array[left] <= tmp) {
                left++;
            }
            //交换两个值
            swap(array,left,right);
        }
        //3.交换相遇位置和基本位置的值
        swap(array,l,right);
        //4.返回相遇位置,作为下一次的分割点
        return right;
}
private static void swap(int[] array,int i,int j) {
        int tmp = array[i];
        array[i] = array[j];
        array[j] = tmp;
    }

第一种快速排序完整代码:

public static void quick(int[] array,int start,int end) {
        if(start >= end) {
            return;
        }
        int pivot = partition(array,start,end);//记录每一轮基准的位置
        //递归左边
        quick(array,start,pivot-1);
        //递归右边
        quick(array,pivot+1,end);
}
private static int partition(int[] array,int left,int right) {
        //1.确定基准
        int tmp = array[left];
        int l = left;
        //2.遍历数组,直到相遇
        while (left < right) {
            while (left < right && array[right] >= tmp) {
                right--;
            }
            while (left < right && array[left] <= tmp) {
                left++;
            }
            //交换两个值
            swap(array,left,right);
        }
        //3.交换相遇位置和基本位置的值
        swap(array,l,right);
        //4.返回相遇位置,作为下一次的分割点
        return right;
}
private static void swap(int[] array,int i,int j) {
        int tmp = array[i];
        array[i] = array[j];
        array[j] = tmp;
}

3.挖坑法找基准

挖坑法是最常用的一种,具体实现和Hoare版很相似。

(1)挖坑法思想

每一轮的结果:

(2)挖坑法代码

private static int partition(int[] array,int left,int right) {
        //1.将基准存入临时变量中,形成一个坑
        int tmp = array[left];
        //2.遍历数组,直到相遇
        while (left < right) {
            while (left < right && array[right] >= tmp) {
                right--;
            }
            //3.放入left位置
            array[left] = array[right];
            while (left < right && array[left] <= tmp) {
                left++;
            }
            //4.放入right位置
            array[right] = array[left];
        }
        //5.补坑
        array[right] = tmp;
        return right;
}
private static void swap(int[] array,int i,int j) {
        int tmp = array[i];
        array[i] = array[j];
        array[j] = tmp;
 }

第二种快速排序完整代码:

public static void quick(int[] array,int start,int end) {
        if(start >= end) {
            return;
        }
        int pivot = partition(array,start,end);//记录每一轮基准的位置
        //递归左边
        quick(array,start,pivot-1);
        //递归右边
        quick(array,pivot+1,end);
}
private static int partition(int[] array,int left,int right) {
        //1.将基准存入临时变量中,形成一个坑
        int tmp = array[left];
        //2.遍历数组,直到相遇
        while (left < right) {
            while (left < right && array[right] >= tmp) {
                right--;
            }
            //3.放入left位置
            array[left] = array[right];
            while (left < right && array[left] <= tmp) {
                left++;
            }
            //4.放入right位置
            array[right] = array[left];
        }
        //5.补坑
        array[right] = tmp;
        return right;
}
private static void swap(int[] array,int i,int j) {
        int tmp = array[i];
        array[i] = array[j];
        array[j] = tmp;
 }

4.快速排序的优化

对于不同的数据,死板的写法不一定有其他排序快;但是根据不同的场景进行不同的优化,可以大大提高快速排序的效率。

下面介绍两种常见的优化方式

(1)三数取中法

思想:在一堆序列中,找到最左边的数、中间的数和最右边的数中 中间小的一个数与最左边的数进行交换,再以最左边的数为基准进行划分。可以防止升序或者逆序而造成的单分支情况。

应对情况:常见与已有序的序列(顺序或者逆序)

核心:找到三个数中排在中间的数

举例:下面为顺序的序列,当默认选择第一个数为基准时,右边都是比基准大的而不会交换;在递归过程中,就会形成单分支的二叉树,复杂度进而变大

三数取中,取的数为:

如何找中间值:

代码如下:

private static void quick(int[] array,int start,int end) {
        if(start >= end) {
            return;
        }
        //三数取中法(优化)
        int index = findMid(array,start,end);
        swap(array,index,start);
        int pivot = partition(array,start,end);
        //递归左边
        quick(array,start,pivot-1);
        //递归右边
        quick(array,pivot+1,end);
 }
/*
        找中间大的元素下标
     */
    private static int findMid(int[] array,int left,int right) {
        int mid = left+( (right - left) >> 1 );
        if(array[left] > array[right]) {
           if(array[left] < array[mid]) {
               return left;
           }else if(array[right] > array[mid]) {
               return right;
           }else  {
               return mid;
           }
        }else {
            if(array[mid] > array[right]) {
                return right;
            }else if(array[left] > array[mid]) {
                return left;
            }else {
                return mid;
            }
        }
    }

(2)递归到小区间时,使用插入排序

思想:结合插入排序

应对情况:一棵二叉树的结点,一般2/3都集中在最后面的几层;如果一直递归下去,计算量是非常大的。

核心:会使用插入排序

private static void quick(int[] array,int start,int end) {
        if(start >= end) {
            return;
        }
        //递归到一定的区间,使用插入排序(优化)
        if((end-start+1) < 5) {
            insertSort(array,start,end);
            return;
        }
        int pivot = partition2(array,start,end);
        //递归左边
        quick(array,start,pivot-1);
        //递归右边
        quick(array,pivot+1,end);
}
 /**
     * 区间插入排序
     */
    private static void insertSort(int[] array,int start,int end) {
        for (int i = start+1; i <= end; i++) {
            int tmp = array[i];
            int j = i-1;
            for (; j >= start ; j--) {
                if(array[j] > tmp) {
                    array[j+1] = array[j];
                }else {
                    break;
                }
            }
            array[j+1] = tmp;
        }
    }

总结:针对递归法

(1)时间复杂度:O(N*logN)

(2)空间复杂度:O(logN)

(3)稳定性:不稳定

(4)使用场景:数据较乱,不适合趋于有序或者已有序的

5.快速排序非递归

思想:

(1)先找一次基准(借助挖坑法)

(2)(判断:如果基准的左边:pivot-1>left不成立,则不放入栈;右边:pivot+1>right不成立也不放入)放入基准左边头跟尾的两个元素,再放入基准右边的头跟尾两个元素。

(3)栈不为空,取fenbuie除两个元素,分别赋值给右跟左

(4)以左跟右的区间继续找基准

(5)重复(2)-(4)

图解:

判断是否入栈:

代码:

public static void quickSortNor(int[] array) {
        Stack<Integer> stack = new Stack<>();
        int left = 0;
        int right = array.length - 1;
        //1.找第一次基准
        int pivot = partition(array,left,right);
        //2.将基准左右两边存入栈中
        if(pivot-1>left) {
            stack.push(left);
            stack.push(pivot-1);
        }
        if(pivot+1<right) {
            stack.push(pivot+1);
            stack.push(right);
        }
        //3.栈不为空出栈
        while (!stack.empty()) {
            right = stack.pop();
            left = stack.pop();
            pivot = partition2(array,left,right);
            //2.将基准左右两边存入栈中
            if(pivot-1>left) {
                stack.push(left);
                stack.push(pivot-1);
            }
            if(pivot+1<right) {
                stack.push(pivot+1);
                stack.push(right);
            }
        }
}
private static int partition(int[] array,int left,int right) {
        //1.将基准存入临时变量中,形成一个坑
        int tmp = array[left];
        //2.遍历数组,直到相遇
        while (left < right) {
            while (left < right && array[right] >= tmp) {
                right--;
            }
            //3.放入left位置
            array[left] = array[right];
            while (left < right && array[left] <= tmp) {
                left++;
            }
            //4.放入right位置
            array[right] = array[left];
        }
        //5.补坑
        array[right] = tmp;
        return right;
}
private static void swap(int[] array,int i,int j) {
        int tmp = array[i];
        array[i] = array[j];
        array[j] = tmp;
 }

时间复杂度:nlogn

空间复杂度:longn

稳定性:不稳定

三、归并排序

下面的归并排序,我们注重介绍:归并排序的思路(包括文字思路,图片思路)、归并排序的代码代码执行过程的讲解

归并排序和快速排序类型,使用了分治的思想,其中,也需要用到递归的。而归并排序的核心就是一分为二,再合并。

1.归并思路

(1)大致思想

(1)将数据不断平分为两段,直到不能再分解,形成一个单独的有序个体。

(2)然后在往回走的过程中,不断合并做到有序。

(2)具体图解:

分解思路:

并起思路:在合并的过程中就完成了排序

(3)如何分解:加入一点数学知识

下面是所有片段的left,mid和right位置

根据这里得出一个规律:当left>=right时,分解停止。

2.代码展示及步骤

代码中的注释很全面的介绍了每个步骤

(1)代码展示

public class mergeSort {
    public static void mergeS(int[] array){
        mergeF(array,0,array.length-1);
    }
    //一.分解的方法(归)
    private static void mergeF(int[] array,int left,int right){
        //1.停止分解的条件
        if(left>=right) {
            return;
        }
        //2.记录拆分的位置
        int mid = left + ((right - left)>>1);
        //3.拆成的左半部分(使用递归)
        mergeF(array,left,mid);
        //4.拆成的右半部分(使用递归)
        mergeF(array,mid+1,right);
        //5.开始合并
        merge(array,left,mid,right);
    }
    //二.合并的方法(并)
    private static void merge(int[] array,int left,int mid,int right) {
        int[] arrTmp = new int[right-left+1];
        int s1 = left;//遍历第一个子序列
        int e1 = mid; //记录第一个子序列末端位置
        int s2 = mid+1;//遍历第二个子序列
        int e2 = right;//记录第二个子序列末端位置
        int k = 0;//遍历临时数组
        //1.将其中一个序列的数据全部拷入临时数组中
        while (s1<=e1 && s2<=e2) {
            if(array[s1] > array[s2]) {
                arrTmp[k++] = array[s2++];
            }else {
                arrTmp[k++] = array[s1++];
            }
        }
        //2.将未拷完的子序列继续拷入
        while (s1<=e1) {
            arrTmp[k++] = array[s1++];
        }
        while (s2<=e2) {
            arrTmp[k++] = array[s2++];
        }
        //3.将排序好的临时数组中的数据拷回原数组
        for (int i = 0; i < arrTmp.length; i++) {
            array[i+left] = arrTmp[i];
        }
    }
}

(2)数据拷贝回原数组部分

//3.将排序好的临时数组中的数据拷回原数组
        for (int i = 0; i < arrTmp.length; i++) {
            array[i+left] = arrTmp[i];
        }

3.代码分析

上面对代码的简述也只是一种简单的概述,下面详细介绍一下代码的执行过程,包括是如何递归,及如何并。

部分过程:

类似二叉树的递归,当递归到一定条件时,才会开始合并;并不是和前面的图片一样,要全部分解完才开始逐一递归。

部分合并过程:

总结:

(1)时间复杂度:O(N*logN)

(2)空间复杂度:O(logn)

(3)稳定性:稳定

(4)使用场景:在磁盘中的外排序问题

4.归并排序非递归

引入:非递归的归并排序,重点也在后面的合并方法上;递归法是先递归到最小单元才开始合并,而非递归是直接开始合并。

步骤分析:

(1)定义一个变量gap,用来标识最小的有序集;每次合并两个有序集,当gap>=数组长度一半时,代表排序完成。

(2)定义一个变量i,用来遍历数组中所有的有序集,每次跳过两个有序集

(3)我们只需要根据合并的方法拿到对应的下标即可

思路解析:非递归,我们使用直接从最小单元开始合并,也就是以一个数据作为最小单位。

需要拿到的下标:这是根据上面递归法写的合并方法,这里通用。

根据上面得到以下代码:

public static void mergeSortNor(int[] array) {
        int gap = 1;//一组的数据个数
        //循环一次,完成一轮合并
        while (gap < array.length) {
             //每循环一次,代表合并两个组
            for (int i = 0; i < array.length; i = i+2*gap) {
                int left = i;
                int mid = left+gap-1;
                //两个if,防止数组越界
                if(mid >= array.length) {
                    mid = array.length-1;
                }
                int right = mid+gap;
                if(right >= array.length) {
                    right = array.length-1;
                }
                merge(array,left,mid,right);
            }
            gap = gap*2;
        }
    }

完整非递归代码:

public static void mergeSortNor(int[] array) {
        int gap = 1;//一组的数据个数
        //循环一次,完成一轮合并
        while (gap < array.length) {
             //每循环一次,代表合并两个组
            for (int i = 0; i < array.length; i = i+2*gap) {
                int left = i;
                int mid = left+gap-1;
                //两个if,防止数组越界
                if(mid >= array.length) {
                    mid = array.length-1;
                }
                int right = mid+gap;
                if(right >= array.length) {
                    right = array.length-1;
                }
                merge(array,left,mid,right);
            }
            gap = gap*2;
        }
    }
private static void merge(int[] array,int left,int mid,int right) {
        int[] arrTmp = new int[right-left+1];
        int s1 = left;
        int e1 = mid;
        int s2 = mid+1;
        int e2 = right;
        int k = 0;
        while (s1<=e1 && s2<=e2) {
            if(array[s1] > array[s2]) {
                arrTmp[k++] = array[s2++];
            }else {
                arrTmp[k++] = array[s1++];
            }
        }
        while (s1<=e1) {
            arrTmp[k++] = array[s1++];
        }
        while (s2<=e2) {
            arrTmp[k++] = array[s2++];
        }
        for (int i = 0; i < arrTmp.length; i++) {
            array[i+left] = arrTmp[i];
        }
    }

5.使用归并排序解决海量数据

当所排序的对象巨大时,需要使用外部排序,而归并排序就是外部排序的一种典型代表。

外部排序:排序过程需要在磁盘等外部存储进行的排序(不直接借助内存)

使用场景:内存只有1G,而排序的数据却有100G

所以我们需要使用归并排序去完成:

(1)先把文件切分成 200 份,每个 512 M
(2)分别对 512 M 排序,因为内存已经可以放的下,所以任意排序方式都可以
(3)进行 2路归并,同时对 200 份有序文件做归并过程,最终结果就有序了(这个时候不借助内存)

相关文章
|
索引
写一个希尔排序,归并排序,快速排序
写一个希尔排序,归并排序,快速排序
快速排序(超超详细,因为自己也不是很会)
快速排序(超超详细,因为自己也不是很会)
|
7月前
|
索引
快速排序与归并排序
快速排序与归并排序
|
7月前
快速排序
快速排序
26 0
|
7月前
|
搜索推荐 C++
C++快速排序的实现
C++快速排序的实现
|
C++
C++快速排序
C++快速排序
60 1
|
7月前
|
搜索推荐 算法 Java
java排序算法:快速排序、归并排序、堆排序等
排序算法:快速排序、归并排序、堆排序等
101 0
|
存储 搜索推荐 算法
常用排序算法:快速排序、归并排序与堆排序
常用排序算法:快速排序、归并排序与堆排序
167 0
|
人工智能 算法
归并排序和快速排序
归并排序和快速排序
88 0
|
机器学习/深度学习
785. 快速排序
785. 快速排序
70 0