排序

基础方法

public class SortUtil
{
    /// <summary>
    /// 小于
    /// </summary>
    /// <param name="v"></param>
    /// <param name="w"></param>
    /// <returns></returns>
    public static bool Less(IComparable v, IComparable w)
    {
        return v.CompareTo(w) < 0;
    }

    /// <summary>
    /// 交换
    /// </summary>
    /// <param name="a"></param>
    /// <param name="i"></param>
    /// <param name="j"></param>
    public static void Exch(IComparable[] a, int i, int j)
    {
        (a[i], a[j]) = (a[j], a[i]);
    }

    /// <summary>
    /// 输出数组内容
    /// </summary>
    /// <param name="a"></param>
    public static void Show(IComparable[] a)
    {
        foreach (var t in a)
            System.Console.WriteLine(t+" ");
    }

    /// <summary>
    /// 测试数组元素是否有序
    /// </summary>
    /// <param name="a"></param>
    /// <returns></returns>
    public static bool IsSorted(IComparable[] a)
    {
        for (var i = 1; i < a.Length; i++)
        {
            if (Less(a[i], a[i - 1]))
                return false;
        }
        return true;
    }
}

选择排序

首先,找到数组中最小的那个元素,其次,将它和数组的第一个元素交换位置(如果第一个元素就是最小元素那么它就和自己交换)。再次,在剩下的元素中找到最小的元素,将它与数组的第二个元素交换位置。如此往复,直到将整个数组排序。

  • 对于长度为N的数组,选择排序需要大约N2/2次比较和N次交换。
public class Selection
{
    public static void Sort(IComparable[] a)
    {
        //将a[]按升序排列
        var n = a.Length;
        for(var i=0;i<n;i++)
        {
            //将a[i]和a[i+1...n]中最小元素交换
            var min = i;
            for (var j = i + 1; j < n; j++)
                if (SortUtil.Less(a[j], a[min])) min = j;
            SortUtil.Exch(a, i, min);
        }
    }
}

插入排序

通常人们整理桥牌的方法是一张一张的来,将每一张牌插入到其他已经有序的牌中的适当位置。在计算机的实现中,为了给要插入的元素腾出空间,我们需要将其余所有元素在插入之前都向右移动一位。

  • 对于随机排列的长度为N且主键不重复的数组,平均情况下插入排序需要~N2/4次比较以及~N2/4次交换。最坏情况下需要~N2/2次比较和~N2/2次交换,最好情况下需要N-1次比较和0次交换。(插入排序对于实际应用中常见的某些类型的非随机数组很有效。)
  • 插入排序需要的交换操作和数组中倒置的数量相同,需要的比较次数大于等于倒置的数量,小于等于倒置的数量加上数组的大小再减一。
public class Insertion
{
    public static void Sort(int[] a)
    {
        //将a[]按升序排序
        var n=a.Length;
        for (var i = 1; i < n; i++)
        {
            //将a[i]插入到a[i-1]、a[i-2]...之中
            //前面i个保证有序
            for(var j = i; j >0 && SortUtil.Less(a[j], a[j-1]);j--)
                SortUtil.Exch(a,j,j-1);
        }
    }
}

希尔排序

对于大规模乱序数组插入排序很慢,因为它只会交换相邻的元素,因此元素只能一点一点地从数组的一端移动到另一端。希尔排序为了加快速度简单地改进了插入排序,交换不相邻的元素以对数组的局部进行排序,并最终用插入排序将局部有序的数组排序。

希尔排序的思想是使数组中任意间隔为h的元素都是有序的。这样的数组被称为h有序数组。换句话说,一个h有序数组就是h个互相独立的有序数组编织在一起组成的一个数组(见图2.1.2)。在进行排序时,如果h很大,我们就能将元素移动到很远的地方,为实现更小的h有序创造方便。用这种方式,对于任意以1结尾的h序列,我们都能够将数组排序。这就是希尔排序。

public class Shell
{
    public static void Sort(int[] a)
    {
        //讲a[]按升序排列
        var n = a.Length;
        var h = 1;
        while (h < n / 3)
            h = 3 * h + 1; //1,4,13,40,121,364,1093,...
        while (h>=1)
        {
            //将数组变为h有序
            for (var i = h; i < n; i++)
            {
                //将a[i]插入到a[i-h],a[i-2*h],a[i-3h]...之中
                for(var j = i; j >=h && SortUtil.Less(a[j], a[j-h]);j-=h)
                    SortUtil.Exch(a,j,j-h);
            }

            h = h / 3;
        }
    }
}

归并排序

将一个数组排序,可以先(递归地)将它分成两半分别排序,然后将结果归并起来。你将会看到,归并排序最吸引人的性质是它能够保证将任意长度为N的数组排序所需时间和NlogN成正比;它的主要缺点则是它所需的额外空间和N成正比。

自顶向下的归并排序

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

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

    private static void Sort(int[] a, int lo, int hi)
    {
        //将数组a[lo..hi]排序
        if(hi<=lo) return;
        var mid=lo+(hi-lo)/2;
        Sort(a,lo,mid);
        Sort(a,mid+1,hi);
        MergeArray(a, lo, mid, hi);
    }

    public static void MergeArray(int[] a, int lo, int mid, int hi)
    {
        //将a[lo..mid]和a[mid+1..hi]归并
        int i = lo, j = mid + 1;
        for(var k=lo;k<=hi;k++)
            aux[k] = a[k];  //将a[lo..hi]复制到aux
        for (var k = lo; k <=hi; k++)
        {
            if (i > mid) a[k] = aux[j++];
            else if(j>hi) a[k] = aux[i++];
            else if (SortUtil.Less(aux[j], aux[i]))
                a[k] = aux[j++];
            else a[k] = aux[i++];
        }
    }
}

自底向上的归并排序

public class MergeBU
{
    private static int[] aux;

    public static void Sort(int[] a)
    {
        //进行lgN次两两归并
        var n = a.Length;
        aux = new int[n]; 
        for (var sz = 1; sz < n; sz += sz) //sz子数组大小
        {
            for (var lo = 0; lo < n - sz; lo += sz + sz) //lo:子数组索引
                MergeArray(a, lo, lo + sz - 1, Math.Min(lo + sz + sz - 1, n - 1));
        }
    }
    public static void MergeArray(int[] a, int lo, int mid, int hi)
    {
        //将a[lo..mid]和a[mid+1..hi]归并
        int i = lo, j = mid + 1;
        for (var k = lo; k <= hi; k++)
            aux[k] = a[k];  //将a[lo..hi]复制到aux
        for (var k = lo; k <= hi; k++)
        {
            if (i > mid) a[k] = aux[j++];
            else if (j > hi) a[k] = aux[i++];
            else if (SortUtil.Less(aux[j], aux[i]))
                a[k] = aux[j++];
            else a[k] = aux[i++];
        }
    }
}

自底向上的归并排序会多次遍历整个数组,根据子数组大小进行两两归并。子数组的大小sz的初始值为1,每次加倍。最后一个子数组的大小只有在数组大小是sz的偶数倍的时候才会等于sz(否则它会比sz小)。

快速排序

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

defaultConfig

public class Quick
{
    public static void Sort(int[] a)
    {
        //todo:洗牌算法
        //StdRandom.shuffle(a);
        Sort(a, 0, a.Length - 1);
    }

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

    /// <summary>
    /// 快速排序的切分
    /// </summary>
    /// <param name="a"></param>
    /// <param name="lo"></param>
    /// <param name="hi"></param>
    /// <returns></returns>
    private static int Partition(int[] a, int lo, int hi)
    {
        //将数组切分为a[lo..i-1],a[i],a[i+1..hi]
        int i = lo, j = hi + 1; //左右扫描指针
        var v = a[lo]; //切分元素
        while (true)
        {
            //扫描左右,扫描检查是否结束并交换元素
            while (SortUtil.Less(a[++i],v)) if(i==hi) break;
            while (SortUtil.Less(v, a[--j])) if (j == lo) break;
            if(i>=j) break;
            SortUtil.Exch(a,i,j);
        }
        SortUtil.Exch(a,lo,j);
        return j;
    }
}

切换到插入排序(改进)

和大多数递归排序算法一样,改进快速排序性能的一个简单办法基于以下两点:

  • 对于小数组,快速排序比插入排序慢;
  • 因为递归,快速排序的sort()方法在小数组中也会调用自己。

因此,在排序小数组时应该切换到插入排序。简单地改动算法2.5就可以做到这一点:将sort()中的语句

if (hi <= lo) return;

替换成下面这条语句来对小数组使用插入排序:

if (hi <= lo + M) { Insertion.sort(a, lo, hi); return; }

三取样切分(改进)

改进快速排序性能的第二个办法是使用子数组的一小部分元素的中位数来切分数组。这样做得到的切分更好,但代价是需要计算中位数。人们发现将取样大小设为3并用大小居中的元素切分的效果最好(请见练习2.3.18和练习2.3.19)。我们还可以将取样元素放在数组末尾作为“哨兵”来去掉partition()中的数组边界测试。

一个简单的想法是将数组切分为三部分,分别对应小于、等于和大于切分元素的数组元素。

public class Quick3way
{
    private static void Sort(int[] a, int lo, int hi)
    {
        //调用此方法的共有方法Sort(),请见上面快速排序。
        if(hi<=lo) return;
        int lt = lo, i = lo + 1, gt = hi;
        var v = a[lo];
        while (i<=gt)
        {
            var cmp = a[i].CompareTo(v);
            if(cmp<0) SortUtil.Exch(a,lt++,i++);
            else if (cmp > 0) SortUtil.Exch(a, i, gt--);
            else i++;
        } //现在a[lo..lt-1]<v=a[lt..gt]<a[gt+1..hi]成立
        Sort(a,lo,lt-1);
        Sort(a, gt + 1, hi);
    }
}

优先队列

很多情况下我们会收集一些元素,处理当前键值最大的元素,然后再收集更多的元素,再处理当前键值最大的元素。一个合适的数据结构应该支持两种操作:删除最大元素和插入元素。这种数据类型叫做优先队列。

通过插入一列元素然后一个个地删掉其中最小的元素,我们可以用优先队列实现排序算法。一种名为堆排序的重要排序算法也来自于基于堆的优先队列的实现。

堆的定义

数据结构二叉堆能够很好地实现优先队列的基本操作。在二叉堆的数组中,每个元素都要保证大于等于另两个特定位置的元素。相应地,这些位置的元素又至少要大于等于数组中的另两个元素,以此类推。

定义。当一棵二叉树的每个结点都大于等于它的两个子结点时,它被称为堆有序。

相应地,在堆有序的二叉树中,每个结点都小于等于它的父结点(如果有的话)。从任意结点向上,我们都能得到一列非递减的元素;从任意结点向下,我们都能得到一列非递增的元素。

根结点是堆有序的二叉树中的最大结点。

二叉堆表示法(数组)

完全二叉树只用数组而不需要指针就可以表示。具体方法就是将二叉树的结点按照层级顺序放入数组中,根结点在位置1,它的子结点在位置2和3,而子结点的子结点则分别在位置4、5、6和7,以此类推。

在一个堆中,位置k的结点的父结点的位置为[k/2],而它的两个子结点的位置则分别为2k和2k+1。

堆的算法

堆的操作示意图:

堆的操作示意图

基于堆的优先队列:

public class MaxPQ
{
    private int[] pq;  //基于堆的完全二叉树
    private int n = 0;  //存储于pq[1..n]中,pq[0]没有使用

    public MaxPQ(int maxN)
    {
        pq = new int[maxN+1];
    }
    public bool IsEmpty()
    {
        return n == 0;
    }
    public int Size()
    {
        return n;
    }

    public void Insert(int v)
    {
        pq[++n] = v;
        Swim(n);
    }

    public int DelMax()
    {
        var max = pq[1];  //从根节点得到最大的元素
        Exch(1,n--);          //将其和最后一个节点交换
        pq[n + 1] = 0;     //防止对象游离
        Sink(1);              //恢复堆的有序性
        return max;
    }

    /// <summary>
    /// 比较
    /// </summary>
    /// <param name="i"></param>
    /// <param name="j"></param>
    /// <returns></returns>
    public bool Less(int i, int j)
    {
        return pq[i].CompareTo(pq[j]) < 0;
    }
    /// <summary>
    /// 交换
    /// </summary>
    /// <param name="i"></param>
    /// <param name="j"></param>
    public void Exch(int i, int j)
    {
        (pq[i], pq[j]) = (pq[j], pq[i]);
    }
    /// <summary>
    /// 由上至下堆的有序化(上浮)的实现
    /// </summary>
    /// <param name="k"></param>
    public void Swim(int k)
    {
        while (k>1 && Less(k/2,k))
        {
            Exch(k/2,k);
            k = k / 2;
        }
    }
    /// <summary>
    /// 由上至下的堆有序化(下沉)的实现
    /// </summary>
    /// <param name="k"></param>
    public void Sink(int k)
    {
        while (2 * k <= n)
        {
            var j = 2 * k;
            if (j < n && Less(j, j + 1)) j++;
            if(!Less(k,j)) break;
            Exch(k,j);
            k = j;
        }
    }
}

优先队列由一个基于堆的完全二叉树表示,存储于数组pq[1..N]中,pq[0]没有使用。在insert()中,我们将N加一并把新元素添加在数组最后,然后用swim()恢复堆的秩序。在delMax()中,我们从pq[1]中得到需要返回的元素,然后将pq[N]移动到pq[1],将N减一并用sink()恢复堆的秩序。

总结

各种排序算法的性能特点:
各种排序算法的性能特点

  • 快速排序是最快的通用排序算法。
  • 如果稳定性很重要而空间又不是问题,归并排序可能是最好的。
posted @ 2023-06-07 23:44  huihui_teresa  阅读(20)  评论(0)    收藏  举报