2.3.24

2.3.24 #

解答 #

取样排序的想法很简单:

常规快排的枢轴只有一个。

如果用一个数组来充当枢轴,根据排序位置的不同自动选择对应的枢轴,

显然能够更好的估计中位数,以求更好的切分效果。

于是引入了「取样」的概念,假如我们从源数组中随机取了 3 个元素并对其排序,

那么这 3 个元素的中位数可以作为第一次切分的枢轴,剩余两个元素则可以充当切分后两个子数组的枢轴。

那么当取样元素到达一个合适的数量时,就能达到提升切分效率的目标。

大致思路如下:

首先先从输入数组里随机取一些元素,作为「取样数组」。

用任意排序算法(比如快排)对取样数组进行排序。

(由于取样数组通常都比较小,这一步的时间消耗通常不会影响性能)

取出取样数组里面的中位数,当作枢轴对剩下的数组进行切分。

之后的切分中,根据排序区间在剩余数组中的相对位置,

用取样数组中对应位置的数作为枢轴,直到整个排序完成。

论文里提到了两种实现方式。

第一种方法

取样数组和剩余数组是分开保存的。

每次切分完成后,并不把枢轴放入剩余数组中,

而是等到剩余数组全部排序完毕之后再用一次归并(merge)操作将取样数组和剩余数组归并。

第二种方法

取样数组和剩余数组保存在同一片空间里,这也是这份题解所实现的方法。

在打乱输入数组之后,取前 2^k-1 个元素作为取样数组,用快排对其排序。

然后把取样数组的后半部分放到整个数组的末尾。

这样操作的结果是输入数组分为了四个部分:

有序的取样数组、取样数组的中位数、无序的剩余数组、有序的取样数组。

中位数则位于第一部分的末尾,我们将其作为枢轴对剩余数组进行切分,数组变为:

有序的取样数组、小于中位数的部分、枢轴、大于中位数的部分、有序的取样数组

接下来我们再对第一个部分取半,放到中位数之前;对最后一部分取半,放到中位数之后:

0 ~ 1/4 取样数组、小于中位数、1/4 ~ 1/2 取样数组、枢轴、1/2~3/4 取样数组、大于中位数、3/4~1 取样数组

你会发现枢轴前后又分别变回了初始条件,递归执行上述操作,便能对整个数组排序。

注意当取样数组用完的时候,直接变回普通的快排。

现代的取样排序

这里的「现代」并不意味着更好,只是让取样排序能更好的适应多线程排序。

首先仍然是取样,取样的数量往往取决于线程的数量,比如说取了 p-1 个,就将数组分为 p 份。

对取样数组进行排序,获得 p 个区间(桶)。

遍历输入的数组,把元素扔到相应的桶里面。

把每个桶和对应的枢轴送到对应的线程进行排序。

汇总各个桶中的结果,排序完毕。

测试结果:

大概能提升 5%~10% 的性能。

代码 #

/// <summary>
/// 取样排序类。
/// </summary>
public class SampleSort : QuickSort
{
    /// <summary>
    /// 取样数组长度 2^k - 1 的阶数。
    /// </summary>
    /// <value>取样数组长度的阶数。</value>
    public int K { get; set; }

    /// <summary>
    /// 默认构造函数。
    /// </summary>
    public SampleSort()
    {
        K = 8;
    }

    /// <summary>
    /// 用快速排序对数组 a 进行升序排序。
    /// </summary>
    /// <typeparam name="T">需要排序的类型。</typeparam>
    /// <param name="a">需要排序的数组。</param>
    public override void Sort<T>(T[] a)
    {
        if (a.Length < Math.Pow(2, K + 1))
        {
            // 小于 2^(k+1) 的数组直接进行快排
            base.Sort(a);
            return;
        }

        Shuffle(a);
        var samplehi = (int)Math.Pow(2, K) - 2;
        // 利用快速排序对取样数组进行排序
        base.Sort(a, 0, samplehi);
        // 找到取样数组的中位数
        var sampleMedian = samplehi / 2;
        // 将取样数组后半部分放到数组末尾
        int i = samplehi, j = a.Length - 1;
        while (i != sampleMedian)
            Exch(a, i--, j--);
        // 根据取样数组进行排序
        Sort(a, 0, sampleMedian, j, a.Length - 1);
        Debug.Assert(IsSorted(a));
    }

    /// <summary>
    /// 用快速排序对数组 a 的 lo ~ hi 范围排序。
    /// </summary>
    /// <typeparam name="T">需要排序的数组类型。</typeparam>
    /// <param name="a">需要排序的数组。</param>
    /// <param name="sampleLo">取样数组的起始下标。</param>
    /// <param name="lo">排序范围的起始下标。</param>
    /// <param name="hi">排序范围的结束下标。</param>
    /// <param name="samplehi">取样数组的终止下标。</param>
    private void Sort<T>(T[] a, int sampleLo, int lo, int hi, int samplehi) where T : IComparable<T>
    {
        if (hi <= lo)                   // 别越界
            return;

        var j = Partition(a, lo, hi);
        // 将前部的有序取样数组取半,后半部分放在枢轴前面。
        if (lo - sampleLo > 1)
        {
            // p 应该始终指向有序部分的最后一项
            // v 应该始终指向有序部分的前面一项
            int p = lo - 1, v = j - 1;
            for (var i = 0; i < (lo - sampleLo) / 2; i++)
            {
                Exch(a, p--, v--);
            }
            Sort(a, sampleLo, p, v, j - 1);
        }
        else
        {
            // 取样数组已经用完,退化为普通 Quicksort
            base.Sort(a, sampleLo, j - 1);
        }

        // 将尾部有序取样数组取半,前半部分放在枢轴后面。
        if (samplehi - hi > 1)
        {
            // p 应该始终指向有序部分的前面一项
            // v 应该始终指向有序部分的最后一项
            int p = hi, v = j;
            for (var i = 0; i < (samplehi - hi) / 2; i++)
            {
                Exch(a, ++p, ++v);
            }
            Sort(a, j + 1, v, p, samplehi);
        }
        else
        {
            // 取样数组已用完,退化为普通 Quicksort
            base.Sort(a, j + 1, samplehi);
        }
    }

    /// <summary>
    /// 对数组进行切分,返回枢轴位置。
    /// </summary>
    /// <typeparam name="T">需要切分的数组类型。</typeparam>
    /// <param name="a">需要切分的数组。</param>
    /// <param name="lo">切分的起始点。</param>
    /// <param name="hi">切分的末尾点。</param>
    /// <returns>枢轴下标。</returns>
    private int Partition<T>(T[] a, int lo, int hi) where T : IComparable<T>
    {
        int i = lo, j = hi + 1;
        var v = a[lo];
        while (true)
        {
            while (Less(a[++i], v))
                if (i == hi)
                    break;
            while (Less(v, a[--j]))
                if (j == lo)
                    break;
            if (i >= j)
                break;
            Exch(a, i, j);
        }
        Exch(a, lo, j);
        return j;
    }

    /// <summary>
    /// 打乱数组。
    /// </summary>
    /// <typeparam name="T">需要打乱的数组类型。</typeparam>
    /// <param name="a">需要打乱的数组。</param>
    private void Shuffle<T>(T[] a)
    {
        var random = new Random();
        for (var i = 0; i < a.Length; i++)
        {
            var r = i + random.Next(a.Length - i);
            var temp = a[i];
            a[i] = a[r];
            a[r] = temp;
        }
    }
}

测试用例:

var quickNormal = new QuickSort();
var sampleSort = new SampleSort();
var arraySize = 1600000; // 初始数组大小。
const int kSteps = 10; // 取样 k 值的递增次数。
const int trialTimes = 1; // 每次实验的重复次数。
const int trialLevel = 2; // 双倍递增的次数。

Console.WriteLine("k\tn\t\tsample\tnormal\tratio");
for (var i = 0; i < kSteps; i++)
{
    var array = arraySize;
    for (var j = 0; j < trialLevel; j++)
    {
        double timeSample = 0;
        double timeNormal = 0;
        for (var k = 0; k < trialTimes; k++)
        {
            var a = SortCompare.GetRandomArrayInt(array);
            var b = new int[a.Length];
            a.CopyTo(b, 0);
            timeNormal += SortCompare.Time(quickNormal, b);
            timeSample += SortCompare.Time(sampleSort, a);

        }

        timeSample /= trialTimes;
        timeNormal /= trialTimes;
        if (arraySize < 10000000)
            Console.WriteLine(
                sampleSort.K + "\t" + array + "\t\t" + timeSample + "\t" + timeNormal + "\t" + timeSample / timeNormal);
        else
            Console.WriteLine(
                sampleSort.K + "\t" + array + "\t" + timeSample + "\t" + timeNormal + "\t" + timeSample / timeNormal);
        array *= 2;
    }

    sampleSort.K++;
}

另请参阅 #

关于取样排序的论文(1970 年): Frazer W D, McKellar A C. Samplesort: A sampling approach to minimal storage tree sorting[J]. Journal of the ACM (JACM), 1970, 17(3): 496-507. 维基百科中的取样排序: Samplesort-Wikipedia 本题用到的类库链接: Quick 库