實現快速又穩定的排序法 (2. 以Array.Sort為基礎,實踐部分OrderBy功能)

  • 6484
  • 0
  • C#
  • 2012-09-15

實現快速又穩定的排序法 (2. 以Array.Sort為基礎,實踐部分OrderBy功能)

上次分析了比較了Linq.OrderBy( )與Array.Sort( ),發現Array.Sort( )的速度快很多。

但是我想要的是Linq.OrderBy()的特性--穩定 + 不改變原始資料順序。

那麼,能不能將兩者特性結合,變得(快+省記憶體+穩定+不改變原始資料)呢?

讓我們動手試試吧!

 

首先,為了將問題簡化,將演算核心做成傳入Key、Value的陣列與Key的比較實作,但要符合穩定排序+不改變原始資料的Order原則;傳回值則是IEnumerable<TValue>。

而其他多載則先將資料轉換成陣列形式,再傳遞給核心函式。

 

關於核心,我的想法很簡單:

1. 如果來源資料需要轉換的話,先將其複製為陣列結構。

2. 建立一個相同大小的索引表。

3. 利用Array.Sort<TKey, TValue> 方法 (TKey[], TValue[], IComparer<TKey>)將索引表依照比較鍵值排序。

4. 掃描比較鍵值,將鍵值相等的區塊對應的索引區塊,利用Array.Sort<T> 方法 (T[], Int32, Int32)將索引表的每部分作子排序。

5. 依索引表,yield return每個TValue。

 


internal class Sorter1<T, TKey>
{
    public IEnumerable<T> OrderBy(T[] items, TKey[] keys, Comparer<TKey> comparer)
    {
        // 注意這裡的傳入keys會被排序
        return OrderByCore(items, keys, comparer);
    }

    public IEnumerable<T> OrderBy(T[] items, Func<T, TKey> keySelector, Comparer<TKey> comparer)
    {
        return this.OrderBy(items, computeKeys(items, keySelector, items.Length), comparer);
    }

    public IEnumerable<T> OrderBy(IEnumerable<T> items, Func<T, TKey> keySelector, Comparer<TKey> comparer)
    {
        T[] _items;
        TKey[] _keys;
        computeValuesAndKeys(items, keySelector, out _items, out _keys);
        return OrderBy(_items, _keys, comparer);
    }

    private IEnumerable<T> OrderByCore(T[] items, TKey[] keys, Comparer<TKey> comparer)
    {
        if (keys.Length > items.Length)
            throw new ArgumentException("keys 的長度大於 items 的長度。");
        int count = keys.Length;
        // 建立索引表
        int[] indexTable = new int[count];
        for (int i = 0; i < count; i++)
            indexTable[i] = i;
        // 將索引表依照比較鍵值排序。
        Array.Sort(keys, indexTable, comparer);
        // 為了穩定排序而做的子排序
        TKey keyLeft = keys[0];
        int indexLeft = 0;
        for (int i = 1; i < count; i++)
        {
            if (comparer.Compare(keys[i], keyLeft) == 0)
                continue;
            // 掃到不相等的部分時, 將之前相等的部分以索引自身重新排序
            if (i - 1 > indexLeft)
                Array.Sort(indexTable, indexLeft, i - indexLeft);
            indexLeft = i;
            keyLeft = keys[i];
        }
        // 注意邊界
        if (count - 1 > indexLeft)
            Array.Sort(indexTable, indexLeft, count - indexLeft);
        // 依索引順序將元素返回
        foreach (int i in indexTable)
            yield return items[i];
    }

    private TKey[] computeKeys(IEnumerable<T> items, Func<T, TKey> keySelector, int count)
    {
        TKey[] keys = new TKey[count];
        int i = 0;
        foreach (var item in items)
            keys[i++] = keySelector(item);
        return keys;
    }

    private void computeValuesAndKeys(IEnumerable<T> items, Func<T, TKey> keySelector, out T[] values, out TKey[] keys)
    {
        int count = items.Count();
        values = new T[count];
        keys = new TKey[count];
        int i = 0;
        foreach (var item in items)
        {
            values[i] = item;
            keys[i] = keySelector(item);
            i++;
        }
    }
}

或許大家對需要做兩步驟的排序有疑問,為什麼不利用自訂比較方法(Comparison)或是實作排序法,一次排序就能解決問題呢?

關於Comparison,其實我測試過了,把32行、第一個Array.Sort()改掉變成如下:


private IEnumerable<T> OrderByCore(T[] items, TKey[] keys, Comparer<TKey> comparer)
{
    if (keys.Length > items.Length)
        throw new ArgumentException("keys 的長度大於 items 的長度。");
    int count = keys.Length;
    // 建立索引表
    int[] indexTable = new int[count];
    for (int i = 0; i < count; i++)
        indexTable[i] = i;
    // 使用自訂Comparison排序
    Array.Sort(indexTable, new Comparison<int>((i, j) => {
        int result = comparer.Compare(keys[i], keys[j]);
        if (result == 0) return i - j;
        return result;
    }));
    // 子排序可以全數省略
    foreach (int i in indexTable)
        yield return items[i];
}

的確省了很多程式碼,可惜效能卻也大幅降低。

 

至於自己寫演算法,這我當然也考慮過。但一來一開始先以最少的code去實驗可行性;二來若寫出不怎樣的快速排序會被笑速度會差framkwork內建許多(大家可以自己試試看);最後則是我寫blog喜歡歹戲拖棚好酒沉甕底。

當然因為是實驗性作品所以bug不少,這也是拖戲的竅門之一?

最後當然要驗收一下成果,加上新的測試方法,由於使用相同的亂數種子,得到的陣列會與上次一樣:


static void Main(string[] args)
{
    MyData[] items = MyData.getRandomMyData(10, 10000000, 0, 4000000);

    GC.Collect();
    GC.WaitForPendingFinalizers();
    GC.Collect();
    Sorter1Order(items);

    Console.ReadKey();
}

static void Sorter1Order(MyData[] arr)
{
    var comparer = Comparer<int>.Default;
    Func<MyData, int> keySelector = item => item.Value;
    Sorter2.Sorter1<MyData, int> sorter = new Sorter2.Sorter1<MyData, int>();
    var memBefore = System.Environment.WorkingSet;
    var watch = System.Diagnostics.Stopwatch.StartNew();
    IEnumerable<MyData> result = sorter.OrderBy(arr, keySelector, comparer);
    PrintResult(result, 10);
    //foreach (var item in result) break;
    var memAfter = System.Environment.WorkingSet;
    GC.KeepAlive(result);
    Console.WriteLine("Sorter1.Order use: {0}ms", watch.ElapsedMilliseconds);
    Console.WriteLine("mem usage: {0:###,###,###}", memAfter - memBefore);
    //PrintResult(result, 10);
}

新的結果:

Sorter1Order

上一篇的測試:

ArraySortLinqOrder

看看結果,雖然使用記憶體比Array.Sort多,但卻比Linq.Order快上許多。

速度的話甚至比兩者都快!

會比Array.Sort快是因為Sorter1內部比較Value時使用的是預設比較,framkwork有最佳化;而Array.Sort那時是使用MyData實作的CompareTo做比較,沒有優化所以比較慢。

(9/15補充):外一個是交換MyData結構,一個是交換int 索引,這方面速度也有差。

Index則與Linq.Order完全相同,也就是穩定排序。

 

另外,仔細一看,會發現印出結果的順序不同了,其實這是因為bug而逼不得已如此做的。

看看Sorter1的程式碼,我利用 OrderByCore() 裡面的yield reture去實現IEnumerable列舉。

在編譯器處理後,這段程式碼會被抽出到編譯器自動產生實作IEnumerable的class內,並將傳入參數保存在class field內,傳回的IEnumerable就是該class。

執行foreach或Enumerator.MoveNext 而需要結果時,才真正去跑OrderByCore()。

另外,OrderBy()在呼叫OrderByCore()時,傳入的keys值先建立好了,OrderByCore()內沒有重設keys

由於上述情形,會產生以下bug:

1. 由於keys先建立好了,若在列舉運算前改變items內容的值,對應的keys不會跟著改變,會導致排序錯誤。

2. keys不會重建,故在使用同樣IEnumerable的第二次時,會因keys已排序,而排序錯誤。

大多情況下,在建立完IEnumerable時就會馬上使用,也只會用一次,故不會有問題。

提醒大家也提醒自己(受害者)用yield return常寫出的bug。順便為下集舖梗