再看一遍排序算法

  排序算法千千万万, 重新复习一下.

  排序算法使用的情况非常多, 而且大部分情况不只是对数字(int, float, double等)进行排序, 而是通过断言(Predicate)进行元素排序, 比如字符数组按照头文字的英文顺序排序之类的, 就需要使用比较排序才能进行, 所以我们看到的常用算法都是比较排序的. 

  比较排序就是根据两个元素的对比, 获得返回来决定重排的顺序, 大部分语言提供的排序都有断言, 比如C#的:

    var list = new System.Collections.Generic.List<int>();
    list.Sort((_l, _r) =>
    {
        if(_l > _r)
        { return 1; }
        if(_l < _r)
        { return -1; }
        return 0;
    });

  或者Lua的: 

    local compare_func = function(v1, v2) return v1 < v2 end
    local t = {}
    table.sort(t, compare_func);

 

  下面看看几种常用排序算法:

一. 选择排序. 原理就像体育课排队一样, 小朋友乱站成一排, 从中找到最矮的叫他站到排头, 再从排头之外找出最矮的站到第二位置, 以此类推. 代码如下:

    /// <summary>
    /// 选择排序
    /// </summary>
    /// <param name="array"></param>
    public static void SelectSort_BigEnd(int[] array)
    {
        for(int i = 0; i < array.Length; i++)
        {
            int minVal = array[i];
            int minIndex = i;
            for(int j = i + 1; j < array.Length; j++)
            {
                int compareValue = array[j];
                if(minVal > compareValue)
                {
                    minIndex = j;
                    minVal = compareValue;
                }
            }
            if(minIndex != i)
            {
                array[minIndex] = array[i];
                array[i] = minVal;
            }
        }
    }

  复杂度啊什么的, 一般算法都没有突破O(n2), 都差不多, 之所以它们是常用算法, 是因为它不使用额外内存, 也不进行大量内存移动操作, 很简单就能实现排序, 多快好省.

  像这样的算法, 就可以扩展成为通用算法了, 试试把它改成泛型的:

    /// <summary>
    /// 选择排序 -- 泛型版本
    /// </summary>
    /// <typeparam name="T"></typeparam>
    /// <param name="array"></param>
    /// <param name="predicate"></param>
    public static void SelectSort<T>(T[] array, System.Func<T, T, int> predicate)
    {
        if(predicate != null)
        {
            for(int i = 0; i < array.Length; i++)
            {
                var baseVal = array[i];
                int baseIndex = i;
                for(int j = i + 1; j < array.Length; j++)
                {
                    var compareValue = array[j];
                    int compareResult = predicate.Invoke(baseVal, compareValue);
                    if(compareResult > 0)
                    {
                        baseIndex = j;
                        baseVal = compareValue;
                    }
                }
                if(baseIndex != i)
                {
                    array[baseIndex] = array[i];
                    array[i] = baseVal;
                }
            }
        }
    }

  因为语义而调整了一些变量的名称, 并且修改了比较的方法, 通过断言来进行比较, 跟List的比较方法已经差不多了, 这个函数不一定是正确的, 只是说明这种排序可以成为泛型排序使用.

  测试一下:

    // 加个打印Log方便看
    public static void PrintArray<T>(this T[] array)
    {
        string info = "{ ";
        for(int i = 0; i < array.Length; i++)
        {
            info += ((array[i]) + ",");
        }
        info += " }";
        UnityEngine.Debug.Log(info);
    }
    
    // 调用
    var array = new int[] { 5, 2, 3, 4, 1, 6 };
    SelectSort<int>(array, (_l, _r) =>
    {
        if(_l > _r)
        { return 1; }
        if(_l < _r)
        { return -1; }
        return 0;
    });
    array.PrintArray();

  正确排序了, 把顺序反一下看看:

    // 调用
    var array = new int[] { 5, 2, 3, 4, 1, 6 };
    SelectSort<int>(array, (_l, _r) =>
    {
        if(_l > _r)
        { return -1; }    // 反了
        if(_l < _r)
        { return 1; }    // 反了
        return 0;
    });
    array.PrintArray();

   恩, 正确. 接下来几个都是比较排序算法, 跟这个差不多.

 

二. 插入排序. 插入排序就像打扑克牌, 摸完牌之后把最右边的一张牌放到最左边作为一个新数组, 一直继续这个过程, 把最右边的牌按大小插入左边那组牌中, 直到插完. 代码如下:

    /// <summary>
    /// 插入排序
    /// </summary>
    /// <param name="array"></param>
    public static void InsterSort_BigEnd(int[] array)
    {
        int tailIndex = array.Length - 1;
        for(int i = 1; i < array.Length; i++)
        {
            var insertValue = array[tailIndex];
            int insertIndex = i;
            for(int j = 0; j < i; j++)
            {
                if(array[j] > insertValue)
                {
                    insertIndex = j;
                    break;
                }
            }
            array[tailIndex] = array[i];  // 末尾元素交换
            for(int index = i; index > insertIndex; index--)
            {
                array[index] = array[index - 1];    // 已经排序的段位移
            }
            array[insertIndex] = insertValue;
        }
    }

  它也是不需要额外内存的, 只进行元素交换就行了. 这个[插入排序]和前面的[选择排序]就是很典型的两个对比:

  1. 从进行元素对比的数量来看, [选择排序]从开始到结束, 每次对比的数量是越来越少的, 因为无序数组中元素越来越少, 只选无序数组最小的加到有序数组的后面即可. 而插入排序需要进行的对比是越来越多的, 因为从无序数组选择一个元素, 插入到有序数组里面, 有序数组元素越来越多, 插入就需要对比越来越多, 可是因为有序数组不一定需要所有元素都进行对比(看代码, 当找到插入位置时就break了, 与原始序列相关), 所以元素对比数量来看[插入排序]占优.

    [选择排序] = [(n-1) + (n-2) + (n-3) +.....1] 次的对比, 约为n2/2次, 固定次数

    [插入排序] Max = [选择排序 : n2/2次] , Min = [n] 次的对比, 在对比数量上可能比[选择排序]更快

  2. 从内存移动的数量来看, [选择排序]只需要最小的内存移动即可完成排序, 因为只需要把元素插入有序队列的末尾即可, 而[插入排序]因为要维持有序队列的顺序, 可能进行大量的内存移动, 从内存移动的角度来看[选择排序]占优.

    [选择排序] Min = [0] , Max = 2n 次的内存移动, 移动次数上比[插入排序]占优

    [插入排序] Min =[选择排序 : 2n次], Max = [2n + [(n-1) + (n-2) + (n-3) +.....1]] => [2n +  n2/2] 次的内存移动

  单从这两方面来看, 它就很有代表性了, 一个是数据对比方面有优势, 一个是在内存移动方面有优势, 这就要根据使用环境来选择了, 如果两个元素的对比很耗费时间的话(比如字符串的对比), 使用[插入排序]比较有优势, 简单的对比的话[选择排序]有优势.

  我们一般的程序对性能不是很敏感的话, 对它们的差别不用很纠结, 系统自带的就行了, 真正用到的地方可能就是各种压榨性能的单片机了, 军方之类的.

 

三. 冒泡排序. 出镜率最高的排序算法, 哪哪都能看到, 原理就是通过相邻元素对比, 把大的放到右边, 最大的数就会像水里的气泡一样慢慢移动到末尾, 然后再来N次, 就能把所有元素按照顺序排列了. 代码如下:

    /// <summary>
    /// 冒泡排序
    /// </summary>
    public static void BuddleSort_BigEnd(int[] array)
    {
        for(int i = 0, imax = array.Length - 1; i < imax; i++)
        {
            int jmax = array.Length - i;
            for(int j = 1; j < jmax; j++)
            {
                var lVal = array[j - 1];
                var rVal = array[j];
                if(lVal > rVal)
                {
                    array[j - 1] = rVal;
                    array[j] = lVal;
                }
            }
        }
    }

  代码之简单, 所以才成了天天拿出来说的算法了吧, 整理一下逻辑, 看看第二重循环里面, 就是从左往右, 把大的数放在右边, 这样循环完一次之后, 数组中最大的数就被放在数组的末尾了, 其实跟[选择排序]是有点像的, 可是它又进行了多次内存移动, 这也有[插入排序]的影子, 其实它简单的代码包含了两种算法的优缺点, 在每次内循环之后也等于对原有数组进行了更接近正确排序的操作, 这样经过N次循环之后就能得到正确数组了.

  PS: 其实并不一定需要这么多次外循环才能得到正确数组, 因为每次循环都进行了排序, 在不同情况下需要的循环次数不同, 这就保证了内存移动的开销, 可是没有办法减少无用的数据比较过程, 如果有一个检测机制检测什么时候数组已经全排好了, 是可以提升性能的, 比如这样:

    public static void BuddleSort_BigEnd_CheckFunc(int[] array, int minSortCount, System.Func<int[], bool> succCheckFunc)
    {
        for(int i = 0, imax = array.Length - 1; i < imax; i++)
        {
            int jmax = array.Length - i;
            for(int j = 1; j < jmax; j++)
            {
                var lVal = array[j - 1];
                var rVal = array[j];
                if(lVal > rVal)
                {
                    array[j - 1] = rVal;
                    array[j] = lVal;
                }
            }
            if((i + 1) >= minSortCount)
            {
                if(succCheckFunc.Invoke(array))
                {
                    UnityEngine.Debug.Log("应该循环 " + imax + "");
                    UnityEngine.Debug.Log("退出循环时已经循环 " + (i + 1) + "");
                    return;
                }
            }
        }
    }

  在上面添加了一个超过minSortCount循环次数之后进行顺序检测的逻辑, 如果数组已经是正确顺序了, 就退出循环. 测试代码如下:

    int[] array = new int[] { 5, 2, 3, 4, 1, 6, 2 };
    BuddleSort_BigEnd_CheckFunc(array, 3, (_array) =>
    {
        for(int i = _array.Length - 1; i >= 0; i--)
        {
            var maxVal = _array[i];
            for(int j = 0; j < i; j++)
            {
                if(array[j] > maxVal)
                {
                    return false;
                }
            }
        }
        return true;
    });
    array.PrintArray();

  输出信息表示跳出了循环, 并且顺序是对的.

  只不过检测数组是否正确排序的方法效率很低, 不如继续运行冒泡排序剩下的循环过程, 因为它的比较过程也跟选择算法一样, 越到后面循环里的比较数量越少, 所以也是"理论上可以优化"的算法.

 

四. 希尔排序. 是从插入排序衍生出来的, 插入排序是它的一个特殊形式, 带有点玄学或者统计学的味道, 原理就跟插入排序一样:

  选取一个数N作为间隔, 在原数组中每隔N个数取出来, 组成一个数组(0, N, 2N......)|(1, N+1, 2N+1....), 这样可以分出N个数组来使用插入排序, 再进行划分(比如N/2间隔), 让数组有交叉数据, 这样多次划分之后最后N=1还是会走到最初的插入排序那一步. 所以有点玄学的成分, 因为N=1就是插入排序...

  代码如下:

    /// <summary>
    /// 希尔排序 -- 缩小增量排序, 通过间隔取数组排序, 间隔越来越小
    /// </summary>
    /// <param name="array"></param>
    public static void ShellSort_BigEnd(int[] array)
    {
        int gap = (array.Length >> 1);
        while(gap > 0)
        {
            int begin = 0;
            while(begin < gap)
            {
                var endIndex = (array.Length - 1) - ((array.Length - 1 - begin) % gap);
                for(int i = begin + gap; i <= endIndex; i += gap)
                {
                    var insertValue = array[endIndex];       // 插入排序, 只取最后一个即可
                    int insertIndex = i;
                    for(int j = begin; j < i; j += gap)
                    {
                        if(array[j] > insertValue)
                        {
                            insertIndex = j;
                            break;
                        }
                    }
                    array[endIndex] = array[i];
                    for(int index = i; index > insertIndex; index -= gap)
                    {
                        array[index] = array[index - gap];
                    }
                    array[insertIndex] = insertValue;
                }
                begin++;
            }
            gap = (gap >> 1);
        }
    }

  单从计算量来看, 如果一个数组长度为10, 那么根据划分, 第一个会划分出gap=5的5个数组, 第二次gap=2的两个数组, 和最后gap=1的原始数组, 并且代码中的[插入排序]不仅有比较, 还有每次内循环至少2次的内存移动, 这是算法决定的, 代码怎样优化都没有办法避免. 从时间复杂度上来看理论上为 n3/2 , 可是那是理论, 也就是说数学统计上来说它从开始到到达正确数组的时间会比上面的排序短一些, 不过就像上面[冒泡排序]中说的, 代码上没有可以快速判断数组是否已经顺序正确的检测方法, 你必须运行完所有过程才能返回正确结果, 所以从执行效率上来说[希尔排序]还是有玄学的成分的(几乎可以说效率堪忧). 这就是为什么它没有像[冒泡排序]这样哪哪都是.

 

五. 快速排序. 它的原理是二分法, 先从数组中选择一个元素, 然后其他元素跟它做对比, 小的放左边, 大的放右边, 然后继续在分出来的数组中进行快速排序, 以此类推就能实现数组排序了. 快速排序是一个很有优化潜力的排序方法, 可以通过良好的代码免去使用额外的内存, 减少数据移动量, 算法逻辑上数据比较的量也较少. 代码如下:

    /// <summary>
    /// 快速排序
    /// </summary>
    /// <param name="array"></param>
    public static void QuickSort_BigEnd(int[] array)
    {
        QuickSort_BigEnd_Wrap(array, 0, array.Length - 1);
    }
    
    /// <summary>
    /// 快速排序封装
    /// </summary>
    /// <param name="array"></param>
    /// <param name="left"></param>
    /// <param name="right"></param>
    private static void QuickSort_BigEnd_Wrap(int[] array, int left, int right)
    {
        if(left < right)
        {
            var partitionIndex = QuickSort_BigEnd_Partition(array, left, right);
            QuickSort_BigEnd_Wrap(array, left, partitionIndex - 1);
            QuickSort_BigEnd_Wrap(array, partitionIndex + 1, right);
        }
    }
    /// <summary>
    /// 快速排序的分区操作分区操作 -- array[pivot]作为中位数, 左边为小于它的, 右边为大于它的, 二分法排序
    /// </summary>
    /// <param name="array"></param>
    /// <param name="left"></param>
    /// <param name="right"></param>
    private static int QuickSort_BigEnd_Partition(int[] array, int left, int right)
    {
        var pivot = left;
        var index = pivot + 1;
        for(int i = index; i <= right; i++)
        {
            var iVal = array[i];
            if(iVal < array[pivot])
            {
                array[i] = array[index];
                array[index] = iVal;
                index++;
            }
        }
        var tempVal = array[pivot];
        array[pivot] = array[index - 1];
        array[index - 1] = tempVal;
        return (index - 1);
    }

  代码仍然可以优化, 这样的写法已经能表达出快速排序的效果了, 它的比较数量级虽然没有太大变化, 不过确实比其他算法少 [(n-1) + (n-1-2) + (n1-2-4) + (n-1-2-4-8)......] 减少量是2次幂上升的, 要比[(n-1) + (n-2) + (n-3)......]要好很多, 特别是在大量数据的时候.

  

六. 堆排序. 利用的是堆的概念, 看起来就像是一个二叉树那样的, 并且只需要保证堆的子节点小于主节点即可, 原理就是建立堆模型, 然后每次循环都能把最大的数放到堆顶, 就和[冒泡排序]原理差不多. 代码如下:

    /// <summary>
    /// 堆排序
    /// </summary>
    /// <param name="array"></param>
    public static void HeapSort_BigEnd(int[] array)
    {
        var heapLen = array.Length;
        // 建立分堆 -- 前半数组中每个都大于后半数组, 最大的数在堆顶
        for(int i = (array.Length >> 1); i >= 0; i--)
        {
            Heapify(array, i, heapLen);
        }
        // 取出堆顶的数, 放到最后, 继续调整堆使最大数在堆顶
        for(int i = array.Length - 1; i > 0; i--)
        {
            var temp = array[0];
            array[0] = array[i];
            array[i] = temp;
            heapLen--;
            Heapify(array, 0, heapLen);
        }
    }
    
    /// <summary>
    /// 堆调整
    /// </summary>
    /// <param name="array"></param>
    /// <param name="i"> 最大Index </param>
    private static void Heapify(int[] array, int i, int heapLen)
    {
        int left = 2 * i + 1;
        int right = 2 * i + 2;
        int largest = i;
        if(left < heapLen)
        {
            if(array[left] > array[largest])
            {
                largest = left;
            }
        }
        if(right < heapLen)
        {
            if(array[right] > array[largest])
            {
                largest = right;
            }
        }
        if(largest != i)
        {
            var temp = array[i];
            array[i] = array[largest];
            array[largest] = temp;
            Heapify(array, largest, heapLen);
        }
    }

   先看Heapify逻辑, 它就是一个堆逻辑, 首先传入数组以及堆顶的index=>i, 然后他的子节点左右被计算出来, 然后对比哪个最大就放到 i 的位置, 这样 i 就是父节点, left, right就是它的子节点, 然后如果被置换的子节点发生了变动(largest != i){ ... }, 那么largest这个节点下面的子节点也要重新计算, 这就是Heapify递归调用的原因. 这样就能保证所有变动都能触发子堆的排列正确性.

  而这些堆在逻辑上的关联性就是Heapify中定义左右子节点的逻辑, int left = 2*i +1; int right = 2*i+2; 这样关联起来的, 因为理解比较麻烦这里打几个Log来看看一个数组的堆的关联性:

    int[] a = new int[11];
    for(int i = 0; i < (a.Length >> 1); i++)
    {
        Debug.Log("Index:" + i + "  left:" + (i * 2 + 1) + " right:" + (i * 2 + 2));
    }

  这是长度11的数组, 那么根据关联性得到的堆如下:

  可以看到如果按照反序进行堆排序的话, 把数组看成两段[0-4] [5-10] 先从Index:4 left:9 right:10 开始第一个堆, 那么最大的数被放到Index=4中, 然后是Index=3, 依次类推到Index2执行完之后 Index2, Index3, Index4存储的其中 一个就是后半段数组中最大的值了, 然后通过Index:1 就得到[1,3,4]中最大的值了, 然后Index:0 得到[0,1,2] 中的最大值, 所以就像冒泡一样把最大值推到了Index=0的位置, 就是这段代码的功能:

        for(int i = (array.Length >> 1); i >= 0; i--)
        {
            Heapify(array, i, heapLen);
        }

  然后取出Index:0的数, 放在数组末尾, 再进行类似的堆排序, 就能一个个得到最大的数了. 这样通过很巧妙的堆关联实现了堆排序.

 

  

  

posted @ 2020-04-07 10:00  tiancaiKG  阅读(253)  评论(0编辑  收藏  举报