淘先锋技术网

首页 1 2 3 4 5 6 7

本博客基于《算法技术手册》,是类似学习日志一样的东西,若有错误请反映给与博主。

我的所有代码可能因为篇幅的问题没有写全,只是选择几个少的或者是书中的代码贴上去上去了。其完整的程序和测试程序都会通过GitHub的形式交付到看这篇blog的你手上。
GitHub传输门

排序的基本要素

马克思主义指导我们具体问题具体分析,排序也是一样针对不同的场景我们也会选择不同的排序手法。请关注以下几个方面

1.数据的表示方式

待排序的集合到底在哪里放着。这个问题是一个值得思考的问题。获取是存储在RAM,也或许是在二级存储甚至三级存储上。如果是是在三级存储上定位数据可能将会有新的开销,同时还可能将数据复制到二级存储上。

数据的存储形式。数据的存储一般有两种形式:存数值存指针

​ 简单来说存数值的话,就是数值本身是有序的。正如:1 2 3 4 5;若是存指针的话,数据不一定是有序的,但是他们的指针是有序的。比如 (用*1 表示指向1 的指针变量)*1 *2 *3 *4 *5.

​ 存指针是更加灵活,反之存数值就没有那么灵活,但是在二级和三级存储上都是使用值存储(毕竟存储指针变量也是需要空间的)

2.可比较

这个问题往往会被正在学习数据结构和算法的诸位所忽视:数据的比较规则,正如 ‘A’ 和 ‘a’ 到底谁大?或许可以使用ASCII码的先后来比较,同时你也可以自己按需求来定义一个排序规则。一般情况把这个排序规则称为compare函数。同时还应该注意排序的意义,升序和降序分别意味这什么。可能是那个数字大或者那个数字小,也可能是那个航班先到或者后到等等。

3.稳定排序

稳定排序在将排序的时候是所有老师都会提到的一个概念。其含义就是保证排序前2个相等的数其在序列的前后位置顺序和排序后它们两个的前后位置顺序相同。这里复述一下书中的原话:

当排序函数cmp认为原始无序集合中的两个元素 ai 和 aj相等时,我们也许需要排序前后的结果集合中维护这两个元素之间的相对顺序。也就是说,如果i < j, 那么ai必须出现在aj做种位置的左侧。

一个不稳定的算法不会关注原始集合中元素之间的关系。

4.选择排序的标准

注意这个小标题的断句,不是"选择排序"的标准,而是选择"排序的标准".选择是动词,不是形容词。

前提条件排序算法
只有少量元素插入排序(如果元素少的话用什么都差不多)
元素几乎已经排好序插入排序
关注最坏情况的性能堆排序
力求平均情况的性能快速排序(实际上,数据越乱快排的效果越好)
元素来自均匀密集分布的数据源桶排序
代码量少插入排序
力求稳定排序归并排序

当然,排序不仅仅只有以上几种,这里只是给一个建议。

这里有一个结论:一个排序算法,无论是在平均还是最坏的情况下,基于比较的排序算法的性能绝不会超过O(nlog2n).

这里其实可以留下一个思考:为什么是O(nlon2n)?给出一个简化的解释:基于比较的排序算法都是相当于一个二叉决策树。决策树的叶子就是一个底层排序。每条路径从根到叶子上的结点就相当于一个比较序列。这棵树的高度就是从根到叶子的最长路径上比较结点的数目。对于任意一个对n个元素进行比较的二叉决策树,一定可以计算他的高度,即,肯定存在某些叶子结点,从根结点到它们只需要经过h个比较结点。考虑一个高度为h的完全二叉树,这个树的所有非叶结点都有左右子节点。那么这颗树一共包括n = 2h-1个结点。高度为h = log2(n+1)。如果这棵树不是完全二叉树,即在某些特殊情况下不平衡。那么一定有h>=log2(n+1) (向上取整)。任何具有n!个叶子结点的二叉决策树至少含有n!个结点。所以,可以按照h=log2(n+1) (向上取整)来计算任何一个二叉决策树的高度。利用对数的性质得:

h = log2(n!) = log2(n*(n-1)*(n-2)* …. * 2 * 1)

h > log2(n!) = log2(n*(n-1)*(n-2)* …. * n/2)

h > log2((n/2)n/2)

h > (n/2)*log2(n/2)

h > (n/2)*(log2(n)-1) 这个最终结果的含义就是:对给定的n个元素进行排序,必定至少存在一条从跟到叶子且路径长度为h的路径,即,这个算法在排序过程中进行至少h次比较。其中h有f(n)得到;f(n) = (1/2)*n*log2(n)-n/2,表示任何基于比较的排序算法都至少需要O(nlog2n)次比较才能完成排序。

5.关于时间复杂度

分析时间复杂度是所有算法学习的第一课。当时我在上学习算法时,我当时判断复杂度就是看多少循环的嵌套。或许有人和我当时犯了同样的错误。

时间复杂度 != 多少层循环的嵌套

如果说你不能改变这个错误的观点,那么算法效率的分析就会寸步难行。

移位排序

直接插入排序

基本思路:每一步将一个待排序的数据插入到前面已经排好序的有序序列中,直到插完所有元素为止。

​ 如果给一个序列 < 1 2 3 7 9 8 6 5 > 我们在使用直接插入排序时会把他分成两个序列 一个有序一个无序。即:

  1. ​ < > < 1 2 3 7 9 8 6 5 >
  2. ​ < 1 > < 2 3 7 9 8 6 5 >
  3. ​ < 1 2 > < 3 7 9 8 6 5 >
  4. ​ < 1 2 3> < 7 9 8 6 5 >
  5. ​ < 1 2 3 7 > < 9 8 6 5>
  6. ​ < 1 2 3 7 9 > < 8 6 5>
  7. ​ < 1 2 3 7 8 9 > < 6 5 >
  8. ​ < 1 2 3 6 7 8 9 > < 5 >
  9. ​ < 1 2 3 5 6 7 8 9 > < >

在整个过程中,我们需要遍历2次。最差就是已经是降序了,比较次数就是(n-1)* n/2 所以其时间复杂度为O(n2), 最好情况就是已经是升序了,就只比较了(n-1)次,所以就是时间复杂度为O(n)

空间上只需要一个辅助空间,即为O(l)

所以其适用场景就是:元素少且基本有序

Code

void straight_sort(int * arr, int len)
{
	int temp, i, j;
	for (int i = 1; i < len; i++)
	{
		temp = arr[i];
		for (j = i - 1; j >= 0 && arr[j] > temp; j--)
			arr[j + 1] = arr[j];
		arr[j + 1] = temp;
	}
}

希尔排序

基本思想:希尔排序是把序列按下标的一定增量分组,对每组使用直接插入排序算法排序;随着增量的逐渐减少,每组包含的关键词越来越多,当增量减至1时,整个序列恰好被分为一组,算法便终止。

​ 这里希尔排序需要提前定义一个增量,可以定义为gap = len / 2,缩小增量的方式为 每次除以2,即 gap /= 2。(这个增量只是随便定义的,不一定就是最好的)

比如: < 1 3 8 6 5 4 2 7>,因为增量gap是2,所以 我们需要分 len/gap = 8/2 = 4组。也就是 < 1 5 >, < 3 4 >, < 8 2 >, < 6 7 >。 我们给这个每个组都用一次直接插入排序。第一轮结束后, < 1 3 2 6 5 4 2 7>。此时我们缩小一次增量 index /= index (index == 5 / 2)也就是被分成 2组。 < 1 2 5 2> < 3 6 4 7 >,再进行第二轮,得 <1 2 2 5 > < 3 4 6 7 >。最后一次缩小就只剩1组。<1 2 2 5 3 4 6 7 >最后使用一次直接插入排序。结束

【要点】

  1. 增量是自己定义的,每个分组元素都是按照增量增加的
  2. 分组内的元素,比如是< 1 2 3 4 5 6 7 8 >分4组,就是1在第1组,就是2在第2组,就是3在第3组,就是4在第4组,就是5在第1组,就是6在第2组,就是7在第3组,就是8在第4组。
  3. 每轮组内元素翻增量倍

其特别之处正是在于构造出直接插入排序所擅长的序列来排序

希尔排序的时间复杂度是和增量选取有关的, 这其中包括最好和最坏都是和gap设置有关。

Code

#define INCRGAP 2; //设置增量
void shellSort(int a[],int len)
{
    int insertNum = 0;
    unsigned gap = len/INCRGAP + 1; // 步长初始化,注意如果当len<INCRGAP时,gap为0,所以为了保证进入循环,gap至少为1!!!
    while(gap) // while gap>=1
    {
        for (unsigned i = gap; i < len; ++i) // 分组,在每个子序列中进行插入排序
        {
            insertNum = a[i];//将当前的元素值先存起来方便后面插入
            unsigned j = i;
            while (j >= gap && insertNum < a[j-gap])//寻找插入位置
            {
                a[j] = a[j - gap];
                j -= gap;
            }
            a[j] = insertNum;
        }
        gap = gap/INCRGAP;
    }
}

《算法技术手册》中的表述

书中分为两类进行表述:一类是按值,一类是按指针。其传入一个compare函数

基于指针的插入排序

void sortPoints(void **ar, int n, int (*cmp)(const void *, const void *))
{
	int j;
    for(j = 1; j < n; j++)
    {
		int i = j - 1;
        void * value = ar[j];
        while(i >= 0 && cmp(ar[i], value) > 0)
        {
			ar[i+1] = ar[i];
            i--;
        }
        ar[i+1] = value;
    }
}

基于值的插入排序

void sortValue(void *base, int n, int s, int (*cmp)(const void * const void *))
{
	int j;
    void *saved = malloc(s);
    for(j = 1; j < n; j++)
    {
		int i = j - 1;
        void *value = base + j * s;
        while(i >= 0 && cmp(base+i*s, s*(j-i)))
            i--;
        if(++i == j) continue;
        memmove(saved, value, s);
        memmove(base+(i+1)*s, base+i*s, s*(j-i));
        memmove(base+i*s, saved, s);
    }
    free(saved);
}

在这类移位排序中还有有一个是冒泡排序,这个排序只有大一的时候学一下作为排序的入门。实际上没人会用这个排序。

选择排序

选择算法是一种贪心策略从A[0,n)范围找最大值,将其和最右侧的元素交换,并且每一次将这个范围变小,直到排序完成。

其实我们发现,选择排序的性能也不怎么样。其原因是:寻找最大值。即便是最好的情况也是O(n2)。

Code

void select_sort(int * arr, int len)
{
    int index;
    for(int i = 0; i < len-1; i++)
    {
		index = i; 
        for(int j = i + 1; j < len; j++)
            if(arr[index] < arr[j])
                index = i;
        std::swap(arr[index], arr[i]);
    }
}

堆排序

堆排序,是建立在一种数据结构——的基础上的一种排序。首先要明白的是堆是一种完全二叉树

在这里直接放出堆的Code,对于堆这一数据结构之后会详细说明。等你了解堆之后,堆排序是自然而然的结果。

最好、最坏、平均都是O(nlog2n)的时间复杂度。

其实建堆也需要时间的开销,但是仅仅是O(n)的复杂度;所以,总复杂度T(n)=O(n)+O(nlong2n)=O(nlog2n).从而得出以上结论;

Code 这里我贴的是书中的代码

static void heapifty(void ** ar, int(*cmp)(const void *, const void *), int idx, int max)
{
	int left = 2 * idx + 1;
    int right = 2 * idx + 2;
    int largest;
    /*在A[idx] A[left] 和 A[right]中寻找最大元素*/
    if(left < max && cmp(ar[left], ar[idx]) > 0)
    {
		largest = left;
    }
    else
    {
    	largest = idx;    
    }
    if(right < max && cmp(ar[right], ar[largest]) > 0)
    {
		largest = right;
    }
    /* 如果最大元素不是父节点,那么交换并递归进行 */
    if(largest != idx)
    {
		void * tmp;
        tmp = ar[idx];
        ar[idx] = ar[largest];
        ar[largest] = tmp;
        heapify(ar, cmp, largest, max);
    }
}
static void buildHeap(void ** ar, int(*cmp)(const void *, const void * ), int n)
{
	int i;
    for(i = n/2-1; i>= 0; i--)
    {
		heapify(ar, cmp, i, n);
    }
}
void sortPointers(void ** ar, int n, int(*cmp)(const void * , const void *))
{
	int i ;
    buildHeap(ar, cmp, n);
    for(i = n - 1; i >= 1; i--)
    {
		void * tmp;
        tmp = ar[0];
        ar[0] = ar[i];
        ar[i] = tmp;
        heapify(ar, cmp, 0, i);
    }
}

基于分区的排序算法(快速排序)

所谓的基于分区,其实是一种分治的策略,把一个问题分解成两个大小差不多且独立的子问题

具体的思路:找到A集合的中位数,并将其移到数组A中间的位置。将左边部分都比中位数小,右半部分都比中位数大。然后就可以递归的调用。

实际上,中位数的寻找也需要花费资源。所以对于快速排序而言,其并不是寻找中位数,而是随意的选择的一个数作为中枢(哨兵)来切分。

其性能最好是O(nlog2n),最坏则是 O(n2);

毫无疑问的是,选择一个好的中枢值,有助于快速排序。其实这里有很多的算法用于找到这个比较合适的中枢值。BFPRT算法就是一个线性的算法。

1.选择中枢值:

  1. 选择第一个或者最后一个元素
  2. 随便选一个
  3. 选择k中值:在A中随机选k个元素,然后选择这k个元素的中值

通常会选择3 有学者指出3能带来5%的性能提升。注意在这个过程尽可能不要为了找到一个尽可能合理的中值而增加开销。

2.处理切分:

如果数组中有很多与中枢值相等的元素,那么小于等于的元素被插入子数组的前端这个策略便使得数组大小不平衡。减小失衡问题的可选方式就是将等于中枢值的元素左右轮流插入第一第二数组

3.处理子数组:

快排是建立在递归之上的,所以为了简化栈的可能深度,可以优先处理小的数组。这里小数组是可以使用插入排序,因为元素少的情况下,插入排序的性能比快速排序更好。

4.内观排序:

由于快排是建立在递归之上的,所以我们必须考虑递归深度的问题。如果快排的递归深度大于 log2n 级。我们就使用堆排序。而如何监视快排的递归深度,这个算法被称为内观排序。实际上 C++的STL的SGI就是使用了内观排序。

基于不比较的排序(桶排序)

所谓的桶排序是利用了散列函数,通过额外的空间的消耗来降低时间复杂度。

如果有一个散列函数hash(A[i]),它可以均匀地将n个元素分配到n个桶中,那么在桶排序最坏可以O(n)的时间排序。所以桶排序具有以下两个特性:

1.均匀分布

2.有序散列

实际上桶排序不适合排序随机字符串,因为通常找不到符合特征的hash函数。但是它可以排序[0,1)之间均匀分布的浮点数。一但所有待排元素放入桶中,桶排序会从左至右对每个桶进行插入排序。

从性能的角度来看,将每一个元素插入相应的桶中,这个过程花费的时间是线性的O(n)(这个线性时间是hash函数可以保证的)。但是,若一个桶中有若干个元素,则需要对这些元素进行插入排序。我们需要保证每一个桶所花费的总时间是O(n)。

​ 给出定义:ni的期望是E[ni]。输入中每一个元素插入的一个给定的桶中的概率是1/p,因为每一个元素都是从[0,1)之间均匀提取的。所以,E[ni] = n*p = n*(1/n) =1, 方差 Var[ni] = n*p*(1-p) = (1-1/n).根据统计学的基本知识,我们可以列出: E[ni2] = Var[ni] + E[ni] . 通过这个等式,我们可以得到ni2的期望。这个结果直接决定了插入排序的费用,在最坏的情况下,插入排序的性能为O(n2)。进一步计算,E[ni2] =(1-1/n)+1 = (2-1/n),即E[ni2] 可以被认为是常数。这意味着在n个桶上的插入排序的总费用加起来总的期望性能为O(n)

这一段仅仅是一个证明看不明白就可以直接记住结论:时间花费是O(n)

使用额外存储空间的排序(归并排序)

现在给你一个待排序的集合A,我们将其分成两个更小的部分A1和A2并将两者归并排序。就是把这俩合并成一个有序的集合。但是它花费了过多额外空间。

具体解法:

我记得在严老师的教材上,线性表中的第一个算法就是归并排序…(记不清了)

归并排序在归并左右两个集合时,使用i j遍历做左侧(和右侧)的元素,并总是将A[i]和A[j]中较小的那个元素复制到正确的位置result[idx]。其中会有三种情况:

  • 右侧元素遍历结束(j>=end),在这种情况下,剩余元素可以从左侧提取。
  • 左侧元素遍历结束(i>=mid),在这种情况下,剩余元素可以从右侧提取。
  • 左右侧均有元素,如果A[i]<A[j],则插入A[i],否则插入A[j]

一旦循环完成,result就有序了,并且其中的元素都来自于原始数组A[start,end]。

其时间性能 O(nlog2n);空间性能O(n),因为你复制一遍A。

在排序算法中,归并排序是最容易转换为处理外存数据的算法。这里贴出书上的一段Java代码,实现了使用内存映射数据来高效地对包含二进制编码的整数文件的排序,不过该算法要求元素都具有相同大小。

public static void mergesort(File A) throws IOException{
	File copy = File.createTempFile("Mergesort","bin");
    copyFile(A, copy);
    RandomAccessFile src = new RandomAccessFile(A, "rw");
    RandomAccessFile dest = new RandomAccessFile(copy, "rw");
    FileChannel srcC = src.getChannel();
    FileChannel destC = dest.getChannel();
    MappedByteBuffer srcMap = srcC.map(FileChannel.MapMode.READ_WRITE,0,src.length());
    MappedByteBuffer destMap = destC.map(FileChannel.MapMode.REA_WRITE,0,dest.length());
    //以下两个函数请在Windows平台上才需要
    closeDirectBuffer(srcMap);
    closeDirectBuffer(destMap);
    src.close();
    dest.close();
    copy().deleteOnExit();
}

static void mergesort(MappedByteBuffer A, MappedByteBuffer result,int start, int end)	throws IOException{
    if(end - start < 8)
		return;
    if(end-start == 8){
		result.position(start);
        int left = result.getInt();
        int right = result.getInt();
        if(left > right){
			result.position(start);
            result.position(right);
            result.position(left);
        }
        return;
    }
    int mid = (end+start)/8*4;
    mergesort(result, A, start,mid);
    mergesort(result, A, mid, end);
    result.position(start);
    for(int i = start, j = mid, idx = start; idx < end; idx +=4){
		int Ai = A.getInt(i);
        int Aj = 0;
        if(j < end){Aj = A.getInt(j);}
        if(j >= end || (i < mid && Ai < Aj)){
            result.putInt(Ai);
            i += 4;
        }else{
            result.putInt(Aj);
            j += 4;
        }
    }
}