【数据结构与算法】高级排序

127 阅读12分钟

开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第2天,点击查看活动详情

🔥 本文由 程序喵正在路上 原创,在稀土掘金首发!
💖 系列专栏:数据结构与算法
🌠 首发时间:2022年11月22日
🦋 欢迎关注🖱点赞👍收藏🌟留言🐾
🌟 一以贯之的努力 不得懈怠的人生

一、前言

基础的排序算法,包括冒泡排序、选择排序和插入排序,通过对它们进行时间复杂度的分析,我们不难得出其最坏时间复杂度都为 O(n2)O(n^2),对于平方阶的算法我们都知道,随着输入规模的增大,时间成本将急剧上升,所以这些基本排序方法不能处理更大规模的问题,下面我们介绍一些比较高级的排序算法,争取降低算法的时间复杂度最高阶次幂

二、希尔排序

希尔排序是插入排序的一种,又称为 “缩小增量排序”,是插入排序算法的一种更高效的改进版本

在使用插入排序的时候,我们会发现一个很不友好的情况,如果已经排序的分组元素为 { 2, 4, 6, 7, 10 },未排序的分组元素为 {1, 8},那么下一个待插入元素为 1,我们需要拿着 1 从后往前依次和 10、7、6、4、2 进行交换位置,才能完成真正的插入,每次交换只能和相邻的元素交换位置,那如果我们要提高效率,直观的想法就是只进行一次交换,就能把 1 放到合适的位置,这样就可以减少交换的次数,那我们该怎么做呢?

下面我们来看看希尔排序吧

我们的需求是:

  • 排序前:{ 9, 1, 2, 5, 7, 4, 8, 6, 3, 5 }
  • 排序后:{ 1, 2, 3, 4, 5, 5, 6, 7, 8, 9 }

排序原理:

  • 选定一个增长量 h,按照增长量 h 作为数据分组的依据,对数据进行分组
  • 对分好组的每一组数据完成插入排序
  • 减小增长量,最小减为 1,重复第二步操作

src=http___img-blog.csdnimg.cn_20201126173331945.gif#pic_center&refer=http___img-blog.csdnimg.gif

至于增长量是怎么确定的,有一固定的规则,我们采用以下规则:

int h = 1;
while (h < 数组的长度/2) {
	h = 2 * h + 1;
}
//循环结束后我们就可以确定 h 的初始值了

h 减小的规则为:
h = h / 2;

< 希尔排序 API 设计 >

类名Shell
构造方法Shell() :创建 Shell 对象
成员方法public static void sort(Comparable[] c) :对数组内的元素进行排序
成员方法private static boolean greater(Comparable c1, Comparable c2) : 判断 c1 是否大于 c2
成员方法private static void exch(Comparable[] c, int i, int j) :交换 c 数组中,索引 i 和索引 j 处的值

< 希尔排序代码实现 >

  • Shell类

    public class Shell {
        //对数组 C 中的元素进行排序
        public static void sort(Comparable[] c) {
            int n = c.length;
    
            //确定增长量 h 的最大值
            int h = 1;
            while (h < n / 2) {
                h = 2 * h + 1;
            }
    
            //当增长量 h 小于 1时,排序结束
            while (h >= 1) {
                //找到待插入的元素
                for (int i = h; i < n; ++i) {
                    //将待插入元素插入到有序数列中
                    for (int j = i; j >= h; j -= h) {
                        //待插入元素是 c[j]
                        if (greater(c[j - h], c[j])) {
                            exch(c, j - h, j);
                        } else {
                            break;
                        }
                    }
                }
    
                //减小 h 的值
                h = h / 2;
            }
        }
    
        //比较 c1 元素是否大于 c2 元素
        private static boolean greater(Comparable c1, Comparable c2) {
            return c1.compareTo(c2) > 0;
        }
    
        //数组中索引为 i 和索引为 j 的元素互换位置
        private static void exch(Comparable[] c, int i, int j) {
            Comparable temp = c[i];
            c[i] = c[j];
            c[j] = temp;
        }
    }
    
  • test类

    import java.util.Arrays;
    
    public class test {
        public static void main(String[] args) {
            Integer[] a = {9, 1, 2, 5, 7, 4, 8, 6, 3, 5};
            Shell.sort(a);
            System.out.println(Arrays.toString(a));
        }
    }
    
  • 运行结果

image.png

< 希尔排序时间复杂度分析 >

在希尔排序中,增长量 h 并没有固定的规则,有很多论文研究了各种不同的递增序列,但都无法证明某个序列是最好的,对于希尔排序的时间复杂度分析,已经超出了我们的能力范畴

但是我们可以使用事后分析法来比较希尔排序和插入排序的性能

首先我们准备一个保存有 10000 个数据的文件,如下图所示,有了这些数据我们就可以完成测试

image.png

测试的思想:在执行排序前记录一个时间,在排序完成后记录一个时间,两个时间的时间差就是排序的耗时

  • SortCompare类
    import java.io.*;
    import java.util.*;
    
    public class SortCompare {
        public static void main(String[] args) throws Exception{
            List<Integer> list = new ArrayList<>();
    
            //读取 data.txt 文件
            BufferedReader reader = new BufferedReader(new InputStreamReader(new FileInputStream("D:\\IdeaProjects\\jdbc_demo\\src\\blog\\data.txt")));
            String line = null;
    
            while ((line = reader.readLine()) != null) {
                //将每一个数组存入到集合中
                list.add(Integer.valueOf(line));
            }
    
            reader.close();
    
            //把集合转换成数组
            Integer[] arr = new Integer[list.size()];
            list.toArray(arr);
    
            testInsertion(arr);
            testShell(arr);
        }
    
        public static void testInsertion(Integer[] arr) {
            //使用插入排序完成测试
            long start = System.currentTimeMillis();
            Insertion.sort(arr);
            long end = System.currentTimeMillis();
            System.out.println("使用插入排序耗时:" + (end - start) + "毫秒");
        }
    
        public static void testShell(Integer[] arr) {
            //使用希尔排序完成测试
            long start = System.currentTimeMillis();
            Shell.sort(arr);
            long end = System.currentTimeMillis();
            System.out.println("使用希尔排序耗时:" + (end - start) + "毫秒");
        }
    }
    
  • 运行结果

image.png

通过测试发现,在处理大批量数据时,希尔排序的性能确实高于插入排序

三、归并排序

归并排序是建立在归并操作上的一种有效的排序算法,该算法是采用分治法的一个非常典型的应用。将已有序的子序列合并,得到完全有序的序列;即先使每个子序列有序,再使子序列段间有序。若将两个有序表合并成一个有序表,称之为二路合并

我们的需求是:

  • 排序前:{ 8, 4, 5, 7, 1, 3, 6, 2 }
  • 排序后:{ 1, 2, 3, 4, 5, 6, 7, 8 }

排序原理:

  • 尽可能地将一组数据拆分成两个元素个数相等的子组,并对每一个子组继续拆分,直到拆分后的每个子组的元素个数是 1
  • 将相邻的两个子组进行合并成为一个有序的大组
  • 不断地重复步骤 2,直到最终只有一个组为止

src=http___www.icode9.com_i_l__n=20&i=blog_2234027_202105_2234027-20210520205909900-1234357802.gif&refer=http___www.icode9.gif

< 归并排序 API 设计 >

类名Merge
构造方法Merge() :创建 Merge 对象
成员方法public static void sort(Comparable[] c) :对数组内的元素进行排序
成员方法private static void sort(Comparable[] c, int lo, int hi) :对数组 c 中从索引 lo 到索引 hi 之间的元素进行排序
成员方法private static void merge(Comparable[] c, int lo, int mid, int hi):从索引 lo 到索引 mid 为一个子组,从索引 mid + 1 到索引 hi 为另一个子组,把数组 c 中的这两个子组的数据合并成一个有序的大组(从索引 lo 到索引 hi
成员方法private static boolean less(Comparable c1, Comparable c2) : 判断 c1 是否小于 c2
成员方法private static void exch(Comparable[] c, int i, int j) :交换 c 数组中,索引 i 和索引 j 处的值

< 归并排序代码实现 >

  • Merge类

    public class Merge {
        private static Comparable[] assist; //归并需要的辅助数组
    
        //对数组 c 中元素进行排序
        public static void sort(Comparable[] c) {
            assist = new Comparable[c.length];
            int lo = 0;
            int hi = c.length - 1;
            sort(c, lo, hi);
        }
    
        //对数组 c 中从 lo 到 hi 的元素进行排序
        private static void sort(Comparable[] c, int lo, int hi) {
            if (hi <= lo) {
                return;
            }
    
            int mid = lo + (hi - lo) / 2;
    
            //对 lo 到 mid 之间的元素进行排序
            sort(c, lo, mid);
            //对 mid + 1 到 hi 之间的元素进行排序
            sort(c, mid + 1, hi);
            //对 lo 到 mid 这组数据和 mid 到 hi 这组数据进行归并
            merge(c, lo, mid, hi);
        }
    
        //对数组中,从 lo 到 mid 为一组,从 mid + 1 到 hi 为一组,进行归并
        private static void merge(Comparable[] c, int lo, int mid, int hi) {
            int i = lo;         //定义一个指针,指向 assist 数组中开始填充数据的索引
            int p1 = lo;        //定义一个指针,指向第一组数据的第一个元素
            int p2 = mid + 1;   //定义一个指针,指向第二组数组的第一个元素
    
            //比较左边小组和右边小组中的元素大小,哪个小,就把哪个数据先填充到 assist 数组中
            while (p1 <= mid && p2 <= hi) {
                if (less(c[p1], c[p2])) {
                    assist[i++] = c[p1++];
                } else {
                    assist[i++] = c[p2++];
                }
            }
    
            /*
            上面的循环结束后,如果退出循环的条件是 p1<=mid,则证明左边小组中的数组已经
            归并完毕,如果退出循环的条件是 p2<=hi,则证明右边小组中的数组已经填充完毕
            所以需要把未填充完毕的数据继续填充到 assist 中,下面两个循环只会其中一个
             */
            while (p1 <= mid) {
                assist[i++] = c[p1++];
            }
    
            while (p2 <= hi) {
                assist[i++] = c[p2++];
            }
    
            /*
            到这里,assist 数组中,从 lo 到 hi 的元素是有序的,再把数据拷贝到 c 数组
            中对应的索引处即可
             */
            for (int index = lo; index <= hi; ++index) {
                c[index] = assist[index];
            }
        }
    
        //比较 c1 元素是否小于 c2 元素
        private static boolean less(Comparable c1, Comparable c2) {
            return c1.compareTo(c2) < 0;
        }
    
        //数组元素 i 和 j 交换位置
        private static void exch(Comparable[] c, int i, int j) {
            Comparable temp = c[i];
            c[i] = c[j];
            c[j] = temp;
        }
    }
    
  • test类

    import java.util.Arrays;
    
    public class test {
        public static void main(String[] args) throws Exception {
            Integer[] arr = {8, 4, 5, 7, 1, 3, 6, 2};
            Merge.sort(arr);
            System.out.println(Arrays.toString(arr));
        }
    }
    
  • 运行结果

image.png

< 归并排序时间复杂度分析 >

归并排序是分治思想的最典型的例子,上面的算法中,对 c[lo...hi] 进行排序,先将它分为 c[lo..mid]c[mid+1...hi] 两部分,分别通过递归调用它们单独排序,最后将有序的子数组归并为最终的排序结果。该递归的出口在于如果一个数组不能再被分为两个子数组,那么就会执行 merge 方法进行归并,在归并的时候判断元素的大小进行排序

image.png

用树状图来描述归并,如果一个数组有 8 个元素,那么它将每次除以 2 找最小的子数组,一共要拆 log28\log_{2}{8} 次,值为 3,所以树共有 3 层,那么自顶向下第 k 层有 2k2^k 个子数组,每个子数组的长度为 23k2^{3-k},归并最多需要 23k2^{3-k} 次比较。因此每层的比较次数为 2k23k=232^k * 2^{3-k} = 2^3,那么 3 层的比较次数总共为 3233*2^3

假设元素的个数为 n,那么使用归并排序拆分的次数为 log2n\log_{2}{n},所以一共 log2n\log_{2}{n} 层,那么使用 log2n\log_{2}{n} 替换上面 3233*2^3 中的 3 这个层数,最终得出的归并排序的时间复杂度为 log2n2log2n=log2nn\log_{2}{n} * 2^{\log_{2}{n}} = \log_{2}{n} * n,根据大 O 推导法则,忽略底数,最终归并排序的时间复杂度为 O(nlogn)O(nlogn)

< 归并排序的缺点 >

需要申请额外的数组空间,导致空间复杂度提升,是典型的以时间换空间的操作

< 归并排序和希尔排序的性能比较 >

我们还是借助那个保存有 10000 个数据的文件来进行测试

测试代码如下

import java.io.*;
import java.util.*;

public class SortCompare {
    public static void main(String[] args) throws Exception{
        List<Integer> list = new ArrayList<>();

        //读取 data.txt 文件
        BufferedReader reader = new BufferedReader(new InputStreamReader(new FileInputStream("D:\\IdeaProjects\\jdbc_demo\\src\\blog\\data.txt")));
        String line = null;

        while ((line = reader.readLine()) != null) {
            //将每一个数组存入到集合中
            list.add(Integer.valueOf(line));
        }

        reader.close();

        //把集合转换成数组
        Integer[] arr = new Integer[list.size()];
        list.toArray(arr);

        testMerge(arr);
        testShell(arr);
    }

    public static void testMerge(Integer[] arr) {
        //使用归并排序完成测试
        long start = System.currentTimeMillis();
        Merge.sort(arr);
        long end = System.currentTimeMillis();
        System.out.println("使用归并排序耗时:" + (end - start) + "毫秒");
    }

    public static void testShell(Integer[] arr) {
        //使用希尔排序完成测试
        long start = System.currentTimeMillis();
        Shell.sort(arr);
        long end = System.currentTimeMillis();
        System.out.println("使用希尔排序耗时:" + (end - start) + "毫秒");
    }
}

测试结果如下

image.png

通过测试,我们发现希尔排序和归并排序在处理大批量数据时差别不是很大

四、快速排序

快速排序是对冒泡排序的一种改进。它的基本思想是:通过一趟排序将要排序的数据分割成独立的两部分,其中一 部分的所有数据都比另外一部分的所有数据都要小,然后再按此方法对这两部分数据分别进行快速排序,整个排序 过程可以递归进行,以此达到整个数据变成有序序列

我们的需求是:

  • 排序前:{ 6, 1, 2, 7, 9, 3, 4, 5, 8 }
  • 排序后:{ 1, 2, 3, 4, 5, 6, 7, 8, 9 }

排序原理:

  • 首先设定一个分界值,通过该分界值将数组分成左右两部分
  • 将大于或等于分界值的数据放到到数组右边,小于分界值的数据放到数组的左边。此时左边部分中各元素都小于或等于分界值,而右边部分中各元素都大于或等于分界值
  • 然后,左边和右边的数据可以独立排序。对于左侧的数组数据,又可以取一个分界值,将该部分数据分成左右两部分,同样在左边放置较小值,右边放置较大值。右侧的数组数据也可以做类似处理
  • 重复上述过程,可以看出,这是一个递归定义。通过递归将左侧部分排好序后,再递归排好右侧部分的顺序。当左侧和右侧两个部分的数据排完序后,整个数组的排序也就完成了

src=http___www.zzvips.com_uploads_allimg_211024_1446341626-0.jpg&refer=http___www.zzvips.gif

< 快速排序 API 设计 >

类名Quick
构造方法Quick() :创建 Quick 对象
成员方法public static void sort(Comparable[] c) :对数组内的元素进行排序
成员方法private static void sort(Comparable[] c, int lo, int hi) :对数组 c 中从索引 lo 到索引 hi 之间的元素进行排序
成员方法private static void partition(Comparable[] c, int lo, int hi):对数组 c 中,从索引 lo 到索引 hi 之间的元素进行分组,并返回分组界限对应的索引
成员方法private static boolean less(Comparable c1, Comparable c2) : 判断 c1 是否小于 c2
成员方法private static void exch(Comparable[] c, int i, int j) :交换 c 数组中,索引 i 和索引 j 处的值

< 切分原理 >

把一个数组切分成两个子数组的基本思想为:

  1. 找一个基准值,用两个指针分别指向数组的头部和尾部
  2. 先从尾部向头部开始搜索一个比基准值小的元素,搜索到即停止,并记录指针的位置
  3. 再从头部向尾部开始搜索一个比基准值大的元素,搜索到即停止,并记录指针的位置
  4. 交换当前左边指针位置和右边指针位置的元素
  5. 重复 2,3,4 步骤,直到左边指针的值大于右边指针的值为止

< 快速排序代码实现 >

  • Quick类

    public class Quick {
        //对数组 c 中元素进行排序
        public static void sort(Comparable[] c) {
            int lo = 0;
            int hi = c.length - 1;
            sort(c, lo, hi);
        }
    
        //对数组 c 中从 lo 到 hi 的元素进行排序
        private static void sort(Comparable[] c, int lo, int hi) {
            if (hi <= lo) {
                return;
            }
    
            //对 c 数组中,从 lo 到 hi 的元素进行切分
            int partition = partition(c, lo, hi);
            //对左边分组中的元素进行排序
            sort(c, lo, partition - 1);
            //对右边分组中的元素进行排序
            sort(c, partition + 1, hi);
        }
    
        private static int partition(Comparable[] c, int lo, int hi) {
            Comparable key = c[lo];     //把最左边的元素当作基准值
            int left = lo;              //定义一个左指针,初始指向最左边的元素
            int right = hi + 1;         //定义一个右指针,初始指向最右侧元素的下一个
    
            //进行切分
            while (true) {
                //先从右往左扫描,找到一个比基准值小的元素
                while (less(key, c[--right])) {     //循环停止,证明找到了一个比基准值小的元素
                    if (right == lo) {
                        break;     //已经扫描完了
                    }
                }
    
                //再从左往右扫描,找到一个比基准值大的元素
                while (less(c[++left], key)) {     //循环停止,证明找到了一个比基准值小的元素
                    if (left == hi) {
                        break;     //已经扫描完了
                    }
                }
    
                if (left >= right) {
                    //扫描完了所有元素,结束循环
                    break;
                } else {
                    //交换 left 和 right 索引处的元素
                    exch(c, left, right);
                }
            }
    
            //交换最后 right 索引处和基准值所在的索引处的值
            exch(c, lo, right);
            return right;   //right 就是切分的界限
        }
    
        //比较 c1 元素是否小于 c2 元素
        private static boolean less(Comparable c1, Comparable c2) {
            return c1.compareTo(c2) < 0;
        }
    
        //数组元素 i 和 j 交换位置
        private static void exch(Comparable[] c, int i, int j) {
            Comparable temp = c[i];
            c[i] = c[j];
            c[j] = temp;
        }
    }
    
  • test类

    import java.util.Arrays;
    
    public class test {
        public static void main(String[] args) throws Exception {
            Integer[] arr = {6, 1, 2, 7, 9, 3, 4, 5, 8};
            Quick.sort(arr);
            System.out.println(Arrays.toString(arr));
        }
    }
    
  • 运行结果

image.png

< 快速排序和归并排序的区别 >

快速排序是另外一种分治的排序算法,它将一个数组分成两个子数组,将两部分独立的排序。快速排序和归并排序 是互补的:归并排序将数组分成两个子数组分别排序,并将有序的子数组归并从而将整个数组排序,而快速排序的 方式则是当两个数组都有序时,整个数组自然就有序了。在归并排序中,一个数组被等分为两半,归并调用发生在 处理整个数组之前,在快速排序中,切分数组的位置取决于数组的内容,递归调用发生在处理整个数组之后

< 快速排序时间复杂度分析 >

快速排序的一次切分从两头开始交替搜索,直到 leftright 重合,因此,一次切分算法的时间复杂度为 O(n)O(n),但整个快速排序的时间复杂度和切分的次数相关

最优情况:每一次切分选择的基准数字刚好将当前序列等分

image.png

如果我们把数组的切分看做是一个树,那么上图就是它的最优情况的图示,共切分了 lognlogn 次,所以,最优情况下快速排序的时间复杂度为 O(nlogn)O(nlogn)

最坏情况:每一次切分选择的基准数字是当前序列中最大数或者最小数,这使得每次切分都会有一个子组,那么总 共就得切分 n 次,所以,最坏情况下,快速排序的时间复杂度为 O(n2)O(n^2)

image.png

平均情况:每一次切分选择的基准数字不是最大值和最小值,也不是中值,这种情况我们也可以用数学归纳法证 明,快速排序的时间复杂度为 O(nlogn)O(nlogn)

五、排序的稳定性

< 稳定性的定义 >

数组 arr 中有若干元素,其中 A 元素和 B 元素相等,并且 A 元素在 B 元素前面,如果使用某种排序算法排序后,能够保证 A 元素依然在 B 元素的前面,那么可以说该算法是稳定的

image.png

< 稳定性的意义 >

如果一组数据只需要一次排序,则稳定性一般是没有意义的,如果一组数据需要多次排序,稳定性是有意义的。例 如要排序的内容是一组商品对象,第一次排序按照价格由低到高排序,第二次排序按照销量由高到低排序,如果第 二次排序使用稳定性算法,就可以使得相同销量的对象依旧保持着价格高低的顺序展现,只有销量不同的对象才需 要重新排序。这样既可以保持第一次排序的原有意义,而且可以减少系统开销

< 常见排序算法的稳定性 >

冒泡排序 只有当 arr[i] > arr[i+1] 的时候,才会交换元素的位置,而相等的时候并不交换位置,所以冒泡排序是一种稳定排序 算法

选择排序 选择排序是给每个位置选择当前元素最小的,例如有数据 {5(1), 8 , 5(2), 2, 9 },第一遍选择到的最小元素为 2,所以 5(1) 会和 2 进行交换位置,此时 5(1) 到了 5(2) 后面,破坏了稳定性,所以选择排序是一种不稳定的排序算法

插入排序 比较是从有序序列的末尾开始,也就是想要插入的元素和已经有序的最大者开始比起,如果比它大则直接插入在其 后面,否则一直往前找直到找到它该插入的位置。如果碰见一个和插入元素相等的,那么把要插入的元素放在相等 元素的后面。所以,相等元素的前后顺序没有改变,从原无序序列出去的顺序就是排好序后的顺序,所以插入排序 是稳定的

希尔排序 希尔排序是按照不同步长对元素进行插入排序,虽然一次插入排序是稳定的,不会改变相同元素的相对顺序,但在 不同的插入排序过程中,相同的元素可能在各自的插入排序中移动,最后其稳定性就会被打乱,所以希尔排序是不 稳定的。

归并排序 归并排序在归并的过程中,只有 arr[i] < arr[i+1] 的时候才会交换位置,如果两个元素相等则不会交换位置,所以它 并不会破坏稳定性,归并排序是稳定的

快速排序 快速排序需要一个基准值,在基准值的右侧找一个比基准值小的元素,在基准值的左侧找一个比基准值大的元素, 然后交换这两个元素,此时会破坏稳定性,所以快速排序是一种不稳定的算法