清科谷体的博客

  • 文章
  • 关于
  • 联系
  • 隐私政策

  1. 首页
  2. 未分类
  3. 正文

排序算法--算法第四版笔记

2023年8月17日 291点热度 0人点赞 0条评论

开始之前

后面会用到的静态函数

比较compare函数和交换exchange的函数以及随机打乱的shuffle函数:

private static int compare(Comparable v, Comparable w)
{
    return v.compareTo(w);
}

private static void exchange(Comparable[] a, int i, int j)
{
    Comparable t = a[i];
    a[i] = a[j];
    a[j] = t;
}

public static void shuffle(Object[] a) 
{
    Random random = new Random(System.currentTimeMillis());
    int n = a.length;
    for (int i = 0; i < n; i++) {
        int r = i + random.nextInt(n-i);
        Object temp = a[i];
        a[i] = a[r];
        a[r] = temp;
    }
}

将这些操作单独提出来作为单独的函数有利于排序算法代码的【可读性】,便于【理解】。

说明

Comparable[] a指一个实现了Comparable接口的类能够使用比较运算符比较元素的数组,实现了这个接口才能使用compareTo比较两个元素大小:

比如String字符串数组 String[] s 或者Integer数组  Integer[] i

shuffle函数是个随机打乱元素顺序的函数。

排序算法影响执行时间的主要操作是比较和交换;不进行比较的排序算法的主要操作则是访问数组的次数。

选择排序

找到数组中最小元素,将它与第一个元素交换位置;然后在剩下的元素中,找到【最小】元素,将它与第二个元素交换位置;这样直到整个数组都被排序。
选择排序因为不断选择剩余元素中的最小值得名

public class Selection {
    public static void sort(Comparable[] a)
    {   //将a[]按升序排列
        int N = a.length;//数组长度
        for (int i = 0; i < N; i++)
        {   //将a[i]与a[i+1...N]中最小元素交换
            int min = i; //记录最小值位置
            for (int j = i + 1; j < N; j++) //将a[i]和a[i]后面没有排序的元素a[j]比较大小
                if (compare(a[j], a[min] < 0) //compare() < 0 升序排序,compare() > 0 降序排序
                    min = j;
            exchange(a, i, min);
        }
    }
}

一个反列

这看上去是的代码量更小的插入排序。

实际上这个算法实际上就是冒泡排序,只不过是内循环的写法不同。

public class Selection {
    public static void sort(Comparable[] a)
    {   
        int N = a.length;
        for (int i = 0; i < N; i++)
        {   //将a[i]与a[i+1...N]中比a[i]小的元素交换
            for (int j = i + 1; j < N; j++)
                if (compare(a[j], a[i] < 0) //将a[]按升序排列
                    exchange(a, i, j);
        }
    }
}

性能分析和特点

运行时间依赖输入与否:【不依赖】,无论是有序数组还是随机数组,排序的时间都一样长。

操作次数:选择排序大约需要N²/2比较和N次交换。

影响运行时间的操作:选择排序的花费主要花在在内循环中的【比较】上。

特点&使用范围:排序时的过程相当的直观,随着排序的进行,当前处理元素的左边的所有元素都是有序的。大部分的排序算法都没有这么直观。

注意:

最后一轮循环不需要做任何操作,可以不遍历。在外循环的判断条件可以改成for (int i = 0; i < N-1; i++),因为经过前N-1次的选择,除了最后一个元素,其他元素位置都排序好了。

插入排序

插入排序就是找到数组中一个位置,将待排序的元素插入已经排序的元素之间的一个适当【位置】。在插入前需要把其余所有元素向右移动一位,以便能腾出一个能插入位置。

这就像斗地主摸牌理牌那样,把一张牌插入左边刚好大于它,右边刚好小于它的位置,必须要把手牌往旁边移移留个空才能插进去。

易理解的简单算法

《算法 第四版》给出的原算法:

public class Insertion {
    public static void sort(Comparable[] a)
    {   
        int N = a.length;
        for (int i = 1; i < N; i++)  //进行N-1次插入
        {     
            for (int j = i; j > 0 && compare(a[j], a[j-1]) < 0; j--)
                //将a[i]一直与前面的元素比较,调整插入位置
                exchange(a, j, j-1);
        }
    }
}

但是这个算法的实际运行时间实际要比选择排序还要长,因为交换过程的读取数组消耗了太多时间。

优化后可用的算法

去除交换步骤额外开销后的优化算法:

public class Insertion {
    public static void sort(Comparable[] a)
    {
        int N = a.length;
        for (int i = 1; i < N; i++) 
       { 
            Comparable t = a[i]; 
            int j;
            for (j = i; j > 0 && less(t, a[j-1]); j--)
            {   //将比t大的元素右移一格,当元素不再右移后就是要插入的位置
                a[j] = a[j-1]; 
            }
            a[j] = t;  //在腾出的空间放置待插入的元素
        }
    }
}

性能分析和特点

运行时间依赖输入与否:【依赖】于输入元素的初始顺序,有序数组或接近有序的数组进行排序要比随机数组更快,主要是可以避免插入时移动元素次数过多。

操作次数:

插入排序最好情况下需要N-1次比较和0次交换,

最坏情况下大约需要N²/2比较和N²/2次交换,

平均情况下排序大约需要N²/4比较和N²/4次交换。

影响运行时间的操作:插入排序的花费主要花在【移动数组】上,也就是交换上。

特点&使用范围:插入排序对部分有序的数组十分高效,也适用于小规模数组。

希尔排序

希尔排序是基于【插入排序】实现的快速算法,只需要一点小的代码量改变,就可以让运行时间增长速度低于平方级。

希尔排序利用递增序列的数字作为固定的【间隔】,让这些【任意】固定间隔的元素都有序,这样可以大大减少插入移动元素的次数。

希尔排序的序列举例

比方说下面示范代码使用的函数的序列是1, 4, 13, 40, 121, ...,如果测试用的数组长度为15,

它会使用1,4,13三种间隔,

间隔为13时,a[0] a[13]构成一个数组使其有序;

间隔为4时:

a[0] a[4] a[8] a[12] 构成一个数组,使其有序

a[1] a[5] a[9] a[13] 构成一个数组,使其有序

a[2] a[6] a[10] a[14] 构成一个数组,使其有序

a[3] a[7] a[11]构成一个数组,使其有序;

间隔为1时就是插入排序;

最后会完成整个数组的排序,而前面间隔的排序减少了最后插入排序移动元素的次数。

public class Shell {
    public static void sort(Comparable[] a)
    {
        int N = a.length;
        int h = 1;
        while (h < N/3) h = 3*h + 1;
        while (h >= 1)
        {    //将数组变为间隔h有序
            for (int i = h; i < N; i++)
            {   //将a[i]插入到a[i-h], a[i-2*h], a[i-3*h]...之中
                for (int j = i; j >= h && compare(a[j], a[j-h]) < 0; j -= h)
                    exchange(a, j, j-h);
            }
            h = h/3;
        }
    }
}

性能分析和特点

运行时间依赖输入与否:取决于规模,较小时和插入排序相差不大,较大时运行时间增速放缓,大致是n√n(n的根号下n)

操作次数:希尔排序的平均比较次数还是数学上【尚未解决】的难题。希尔排序的比较和交换次数取决于使用的递增序列,所给出的代码最坏比较次数是N的3/2次方。

影响运行时间的操作:已有研究表明,希尔排序中是最主要的操作是【比较】,而不是交换。

特点&使用范围:希尔排序适用于中等大小的数组,虽然运行速度达不到NlogN,但是可以接受。而且代码量很小,不需要额外的内存空间。

很多的论文研究给出了各种不同的递增序列,但还没有已证明的最好的递增序列。彻底理解希尔排序的性能是一项尚未解决的问题。有人通过大量的实验,给出了较好的结果:当规模较大时,比较和移动的次数约在n^1.25 到 (1.6n)^1.25 之间。

归并排序

归并排序的原理是利用分治思想将数组分成两半分别排序,然后将结果归并起来就是排序后的数组。归并排序的重点就如其名一样是指【归并】这一操作。

递归&归并

使用递归分成左右半边并排序很简单,但如何将递归后的数组归并成新【有序数组】却比较难。数组是按照大小对半切分的,递归到最后只有一个元素。

归并前过程中的两个数组虽然【各自】都有序,但直接合并【未必】是有序的。其中一边的数组元素未必都比另一边的【都要】小,反之亦然。如何将两个【已经排序】的数组合并成一个【有序】的数组,这是归并的重点。

原地归并的必要性

实现归并操作最简单的方法是每次归并过程中创建一个新数组,将结果归并回到这个数组中。但是这需要【额外】的空间开销,需要的空间为递归的深度和数组长度的乘积:NlogN。如果使用原地排序的方法可以只需要一个N的空间就可以归并结果。可以【节省】所需的空间。

private static void merge(Comparable[] a, int lo, int mid, int hi)
{   //将a[lo..mid]和a[mid+1..hi]归并
    int i = lo, j = mid + 1;

    for (int k = lo; k <= hi; k++)  //将a[lo..hi]复制到aux[lo..hi]
        aux[k] = a[k];
    
    for (int k = lo; k <= hi; k++)  //归并回到a[lo..hi]
        if (i > mid)                 a[k] = aux[j++];  //如果左半边越界,说明左半边取完了,直接取右半边的元素归并a[]
        else if (j > hi)             a[k] = aux[i++];  //类似,右半边越界,右半边用尽,取左半边的元素
        else if (compare(aux[j], aux[i]) < 0)  a[k] = aux[j++];  //右半边的当前代取元素更小,将其归并回a[]中
        else                            a[k] = aux[i++];  //反之,左半边的当前代取元素更小,将其归并回a[]中
}

自顶向下的归并排序(递归)

public class Merge {
    private static Comparable[] aux; //归并所需的辅助数组

    public static void sort(Comparable[] a)
    {
        aux = new Comparable[a.length];  //一次性分配空间
        sort(a, 0, a.length - 1);
    }

    private static void sort(Comparable[] a, int lo, int hi)
    {
        if (hi <= lo) return;  //只有一个元素时
        int mid = lo + (hi - lo)/2;
        sort(a, lo, mid);
        sort(a, mid+1, hi);
        merge(a, lo, mid, hi);
    }

    private static void merge(Comparable[] a, int lo, int mid, int hi)
    {
        //见上原地归并的方法
    }
}

自底向上的归并排序(迭代)

和自顶向下归并的【分治】方法不同,自底向上的归并会【多次】【遍历】整个数组。将【子数组】进行【两两归并】。子数组的大小sz初始时是1,每次加倍。

比如,首先是两两归并大小为1的数组,然后是四四归并大小为2的数组,然后是八八归并大小为4的数组,等等。

注意:

最后一次归并的第二个子数组可能比第一个子数组要小。最后一个子数组的大小只有在数组大小是sz的偶数倍时才会等于sz。

举例

比如有15个元素的数组:
第一次遍历,对子数组为1的数组两两归并,进行8次归并,归并后已排序的数组的子数组大小 2 & 2 & 2 & 2 & 2 & 2 & 2 & 1;

第二次遍历,对子数组为2的数组四四归并,进行4次归并,归并后已排序的数组的子数组大小 4 & 4 & 4 & 3;

第三次遍历,对子数组为4的数组八八归并,进行2次归并,归并后已排序的数组的子数组大小分别为 8 & 7;

第四次遍历,对子数组为8的数组归并。进行1次归并,归并后已排序的数组的子数组大小 15,完成排序。

代码

public class MergeBU {

    private static Comparable[] aux;

    public static void sort(Comparable[] a)
    {
        int N = a.length;
        aux = new Comparable[N];
        for (int sz = 1; sz < N; sz = sz+sz)
            for (int lo = 0; lo < N-sz; lo += sz+sz)
                merge(a, lo, lo+sz-1, Math.min(lo+sz+sz-1, N-1));
    }

    private static void merge(Comparable[] a, int lo, int mid, int hi)
    {
        //见上原地归并的方法
    }
}

性能分析和特点

无论是【自顶向下】归并还是【自底向上】归并,其实都是一样的算法。这实际是分治思想中【递归】和【迭代】两种不同的解决方法。

运行时间依赖输入与否:【不依赖】

操作次数:需要1/2NlogN至NlogN次比较,最多需要6NlogN次数组访问(每次归并需要访问数组6N次,2N用来复制,2N用来归并移动回去,最多需要2N次比较)

影响运行时间的操作:【数组访问】

特点&使用范围:【辅助数组】所需【空间】和N的大小成正比。是【渐进最优】的基于比较的排序算法(归并排序在【最坏】情况下的比较次数和是NlogN,这也是所有【基于比较】的排序算法的最少比较次数)。性能分析上有【准确的上界】。

还可以进行的性能优化

  1. 因为小规模问题,递归调用过于频繁,影响性能。在处理小规模问题时使用其他方法,比使用递归算法要更优。比如对于长度小于15的子数组,使用插入排序可以缩短运行时间。
  2. 测试数组是否有序,在确定有序时跳过merge归并方法,处理有序数组的运行时间会变为线性。
  3. 可以在递归中,不将元素复制到辅助数组,虽然不能减少空间花费,但可以减少访问数组时间的花费。将数据从输入数组排序到辅助数组,并将数组从辅助数组中排序到输入数组。这需要在递归调用的每个层次交换数组和辅助数组的角色。

快速排序

快速排序利用分治思想将一个数组【分成】两个字数组两部分独立地排序。快速排序的关键是【切分】(partition)。

快速排序和归并排序是互补的。快速排序在排序前要切分数组,再继续递归排序;归并排序则是在递归调用后,再进行数组归并。

快速排序的切分

切分的方法是随机地取a[lo]作为【切分元素】,以它为基准,将小于它的放在其左边,大于它的放在其右边。

使用两个指针从数组两头分别向对面出发,在左边找到比【切分元素】大、右边找到比【切分元素】小的两个元素,【交换】两个的位置。在两个指针【相遇】时切分结束。

private static int partition(Comparable[] a, int lo, int hi)
{   // 将数组切分为a[lo..i-1], a[i], a[i+1..hi]
    int i = lo, j = hi + 1;    // 左右扫描指针
    Comparable v = a[lo];      // 切分元素
    while (true)
    {   // 扫描左右,检查扫描是否结束并交换元素
        while (compare(a[++i], v) < 0) if (i == hi) break;
        while (compare(v, a[--j]) < 0) if (j == lo) break;
        if (i >= j) break;
        exchange(a, i, j);
    }
    exchange(a, lo, j);  // 将v = a[lo]放入正确的位置
    return j;            // a[lo..j-1] <= a[j] <= a[j+1..hi] 达成
}

第一个扫描的是左边的【第二个】元素,【切分元素】一直在数组【第一位】。

为了将【切分元素】放在中间的正确位置,最后一次的交换将作为基准的【切分元素】 和指针越界指向的【小于切分元素】的元素互换位置。

随机打乱输入的快速排序

在切分不平衡时,如果第一次从最小元素切分,第二次从第二小的元素切分,如此这般每次只会移除一个元素。在这种情况下,快速排序的运行时间将会降低到【平方级】。在快速排序前随机打乱数组,可以避免这种糟糕的情况发生。

public class Quick {

    private static Comparable[] aux;

    public static void sort(Comparable[] a)
    {
        shuffle(a);     // 消除对输入的依赖
        sort(a, 0, a.length - 1);
    }

    private static void sort(Comparable[] a, int lo, int hi)
    {
        if (hi <= lo) return;
        int j = partition(a, lo, hi);   // 切分
        sort(a, lo, j-1);               // 将左半部分a[lo .. j-1]排序
        sort(a, j+1, hi);               // 将右半部分a[j+1 .. hi]排序
    }
}

三向切分的快排

三向切分处理大量重复元素的数组要更快。将数组切分成三部分,分别对应【小于】、【等于】和【大于】切分元素的数组元素。

巧妙的使用三个指针切分整个数组:

指针lt使得a[lo .. lt-1] 的元素小于v

指针i使得a[lt .. i-1] 的元素等于v

指针gt使得a[gt+1 .. hi] 的元素大于v

a[i] 小于v, 将 a[lt]  和 a[i] 交换,将 lt 和 i 加一;

a[i] 大于v, 将 a[gt] 和 a[i] 交换,将 gt 加一;

a[i] 等于v, 将 i加一;

public class Quick3way {

    private static Comparable[] aux;

    public static void sort(Comparable[] a)
    {
        shuffle(a);
        sort(a, 0, a.length - 1);
    }

    private static void sort(Comparable[] a, int lo, int hi)
    {
        if (hi <= lo) return;
        int lt = lo, i = lo + 1, gt = hi;
        Comparable v = a[lo];
        while (i <= gt)
        {
            int cmp = a[i].compareTo(v);
            if (cmp < 0) exchange(a, lt++, i++); else if (cmp > 0) exchange(a, i, gt--);
            else              i++;
        }
        sort(a, lo, lt-1);
        sort(a, gt + 1, hi);
    }

性能分析和特点

快速排序是【最快】的【通用排序】算法。无数计算机系统中的实现也证明了这点,精心优化的快速排序也是很多编程语言的内置排序算法的实现。

运行时间依赖输入与否:依赖,糟糕的切分会让快速排序极为低效。极端情况下,最多需要N²/2次比较。

操作次数:平均需要2NlogN次比较,已经1/6的交换。极端情况下,最多需要N²/2次比较。

影响运行时间的操作:【比较】操作

特点&使用范围:快速排序的内循环指令很少,因为总是顺序访问数据,还能利用缓存。所以它的运行时间的增长数量级为cNlogN,c比其他的对数级别的排序算法的常数都要小。

但是快速排序也很脆弱,要设计时注意扫描指针越别界、保持输入随机性、切分内终止循环的条件、终止递归的条件才能保障快速排序的高效性。

堆排序

堆排序是基于二叉堆这一数据结构实现地一种排序算法。

二叉堆是一颗完全二叉树,父节点总是大于(小于)或等于子节点。因为堆的父节点的特性,将堆按递减顺序取出就可以得到排序结果。

堆操作

二叉堆重要的两个操作是【插入元素】或者【删除根节点】(根节点就是最大或者最小的元素,这取决于父节点和子节点保持何种关系)。

二叉堆可以在进行这两个操作改变了堆结构时,可以【恢复】堆的【顺序】。插入元素到数组末尾,使用上浮(swim)进行由下至上的堆有序化。从数组顶端删除根节点,并将最后元素放在顶端,使用下沉(sink)进行由上至下的堆有序化。

堆操作实现

堆通常使用数组实现,规定堆从1开始计数,符合二叉树结构,便于【取节点】操作。

代码使用面向【最大】元素的堆,这样排序后的数组是【从小到大】的,和前面的排序算法结果保持一致。

private static void sink(Comparable[] a, int k, int n) {
    while (2*k <= n) // a[k]没有子节点时
    {   //
        int j = 2*k; // a[j]是a[k]第一个子节点
        if (j < n && less(a, j, j+1)) j++;  // a[j]与第二个子节点a[j+1]比较,取较大的那个
        if (!less(a, k, j)) break;  // a[k]大于或者等于它的最大子节点,则不需要继续下沉
        exch(a, k, j);  //a[k]小于它的最大子节点,交换位置
        k = j;
    }
}

private static boolean less(Comparable[] a, int i, int j) {
    return a[i-1].compareTo(a[j-1]) < 0;  // 堆的索引和数组相差1
}

private static void exch(Object[] a, int i, int j) {
    Object swap = a[i-1]; a[i-1] = a[j-1]; a[j-1] = swap; // 堆的索引和数组相差1
}

堆排序

for循环用于构造堆;while循环将数组【顶端】的最大元素和数组【最后】的元素交换位置,直到堆为空。

堆排序的主要工作是在第二阶段完成的。将堆中的最大元素删除,然后放进堆【缩小】后数组空出的位置。一直如此删除最大元素,就是交换最大元素和堆末尾元素再修复堆,就会将较大元素一直向数组后面移动。

public class Heap {

    public static void sort(Comparable[] a) 
    {
        int n = a.length;
        // 每次确定一个父节点的左右子节点,确定所有父节点和子节点的关系时,堆就建好了
        for (int k = n/2; k >= 1; k--) // 父节点的数量只占总结点数的一半不到,所以是k = n/2
            sink(a, k, n);

        int k = n;
        while (k > 1)  // 堆从1开始计数,循环k取到1为止
        {
            exch(a, 1, k--);  // 交换根节点的最大元素和堆中最后的元素
            sink(a, 1, k);    // 下沉使新堆(k自减一,堆变小了)重新有序
        }
    }
}

这个过程类似【选择排序】,但是需要的【比较】要少得多,堆提供了一种从未排序部分找到最大元素的有效方法。

性能分析和特点

运行时间依赖输入与否:【不依赖】

操作次数:少于2NlogN+2N次比较,少于NlogN+N次数的交换。(2N项来自于堆的构造,2NlogN项来自于每次下沉操作最大可能2NlogN次比较)

影响运行时间的操作:【比较】操作

特点&使用范围:堆排序是所知的唯一能够【同时】【最优】地利用【空间】和【时间】的方法,最坏情况下也能保证使用~2NlogN次比较和恒定的额外空间。也只需要几行代码就能实现较好的性能,但【不能】利用缓存,现代系统的应用中很少使用它。

本作品采用 知识共享署名 4.0 国际许可协议 进行许可
标签: 排序算法 笔记 算法
最后更新:2024年7月14日

ingker

自娱自乐

点赞

文章评论

取消回复

COPYRIGHT © 2025 清科谷体's blog. ALL RIGHTS RESERVED.
THEME KRATOS MADE BY VTROIS | MODIFIED BY INGKER

正在加载今日诗词....

本站已运行