常用排序算法总结与分析

版权声明:本文为博主原创文章,转载请注明出处,谢谢。 https://blog.csdn.net/FreeeLinux/article/details/53458084

本文地址:(LYanger的博客:http://blog.csdn.net/freeelinux/article/details/53458084)


一:冒泡排序

冒泡排序不用多说,教科书必备。主要思想就是把间隔两个数相比较,哪个大哪个往后放。每一次能冒出来未排序部分最大的一个数,所以冒N-1次就可以了。因为最后一个元素不需要再比较了。

代码如下:
template <typename T>
void bubble_sort(T* arr, const int size)
{
    assert(arr != NULL && size > 0);
    for(int i=0; i<size-1; ++i){
        for(int j=0; j<size-1-i; ++j){
            if(arr[j] > arr[j+1]){
                std::swap(arr[j], arr[j+1]);
            }
        }
    }
}
时间复杂度分析:
冒泡排序一共冒N-1次,第一次比较N-1次,第二次N-2次,然后是N-3,N-4,... 1。
所以满足:
时间复杂度即为O(N*2)
我们可以对冒泡排序进行优化,如果冒泡排序某一次没有发生交换,就说明数组已经有序了,所以直接跳出即可。

代码如下:
template <typename T>
void bubble_sort(T* arr, const int size)
{
    assert(arr != NULL && size > 0);
    bool flag = true;
    for(int i=0; i<size-1; ++i){
        for(int j=0; j<size-1-i; ++j){
            if(arr[j] > arr[j+1]){
                std::swap(arr[j], arr[j+1]);
                flag = false;
            }
        }
        if(flag)
            break;
    }
}
所以改进后冒泡排序可以达到最低时间复杂度为O(N),即数组本身有序,仅需要遍历一遍数组,冒泡排序就结束了。
稳定性:稳定。由于冒泡排序只有满足后面元素小于前面元素才交换,相等不交换,所以是稳定排序。

二:选择排序

选择排序是每次从未排序数组中找出最小的那一个,放在前面,这样逐步下去,就调整成为一个有序的数组了。同样选择排序选N-1次就可以了。

代码如下:
template <typename T>
void select_sort(T* arr, const int size)
{
    assert(arr != NULL && size > 0);
    for(int i=0; i<size-1; ++i){
        int min_index = i;
        for(int j=i+1; j<size; ++j){
            if(arr[j] < arr[min_index])
                min_index = j;
        }
        if(min_index != i)
            std::swap(arr[i], arr[min_index]);
    }
}
选择排序编程时要注意,我们用临时变量保存最小值的下标,而不是最小值。这么做是因为每次循环结束后,我们要交换当前arr[i]和最小值,是需要凭借数组下标的。而不是
swap(arr[i], min),这么做完全是错误的。

时间复杂度:选择排序选N-1次,每次遍历个元素个数也服从等差数列,N-1+N-2+N-3+...+1,所以时间复杂度是O(N*2)。
选择排序最好情况也是O(N*2),它总是每次循环要通过遍历去选最小值。
稳定性:选择排序是非稳定性排序。举例5,8,5,2,7,那么第一次选择最小值是第一个5会和2交换,两个5换了位置,所以不稳定。

三:插入排序

插入排序类似于玩扑克牌,我们总是把锅底的扑克牌拿起来按顺序插入到手中已有牌的合适位置,锅底牌完了,数据也就排好序了。

代码如下:
template <typename T>
void insert_sort(T* arr, const int size)
{
    assert(arr != NULL && size > 0);
    int j;
    for(int i=1; i<size; ++i){
        int temp = arr[i];
        for(j=i-1; j>=0 && arr[j]>temp ; --j)  //j>=0防越界,记住用while循环是不要忘了break
            arr[j+1] = arr[j];
        arr[j+1] = temp;
    }
}
我们通常用i表示带插入的牌,它从1开始,因为我们假设手中已经有一张牌了。然后寻找待插入牌的合适位置插入它,直到牌插入完毕。
编程时需要注意j=i-1开始,并且要防止j越了左边的界。

时间复杂度:插入排序同样遵循等差数列,时间复杂度是O(N*2)。不过如果数组已经有序的情况下,插入排序最好情况可以达到O(N)
稳定性:稳定。如果是相等元素元素,我们可不能白费力气把新来的元素插入之前的元素前面。

四:希尔排序

希尔排序是对插入排序的改进,相当于增量版的插入排序。我们对数据中不同增量的层次进行插入排序,并不断缩小增量,直至有序,这就是希尔排序。

代码实现如下:
template <typename T>
void shell_sort(T* arr, const int size, int increment)
{
    assert(arr != NULL && size > 0 && increment > 0);
    int j;
    while(increment > 0){
        for(int i=increment; i<size; i+=increment){
            T temp = arr[i];
            for(j=i-increment; j>=0 && arr[j]>temp; j-=increment){  //记住用while循环时不要忘了break
                arr[j+increment] = arr[j];
            }
            arr[j+increment] = temp;
        }
        increment >>= 1;
    }
}


增量一般推荐从size/2开始,这里给了一个外部参数表示增量,是考虑到可扩展性。
时间复杂度:希尔排序的时间复杂度是:O(N*2),最好情况数组已经有序,这是可以达到O(N),这都和插入排序一样。不过希尔排序的平均时间复杂度是:
稳定性:非稳定,虽然插入排序是稳定排序,但是希尔排序非稳定。 由于希尔排序是在不同增量间执行插入排序,所以相等元素完全可能改变相对位置。


五:归并排序

归并排序是利用分治思想,分而治之,将所有数据一分为二,再分为二,大而化小,然后再自底向上进行排序以及合并。
归并排序有非递归的优化版本,我在另一篇博客有介绍: 归并排序递归及非递归实现(自然合并排序)

代码如下:
template <typename T>
void merge(T* arr, int low, int middle, int high)
{
    T* tmp = new int[high-low+1];

    int left = low;
    int right = middle + 1;
    int k = 0;
    
    while(left <= middle && right <= high){
        if(arr[left] < arr[right])
            tmp[k++] = arr[left++];
        else
            tmp[k++] = arr[right++];
    }

    while(left <= middle)
        tmp[k++] = arr[left++];
    while(right <= high)
        tmp[k++] = arr[right++];
    
    for(int i=0; i<k; ++i)  //注意从arr从low开始
        arr[low+i] = tmp[i];

    delete []tmp;
}

template <typename T>
void merge_sort(T* arr, int low, int high)
{
    if(low < high){
   int middle = (int)((low + high) >> 1);
        merge_sort(arr, low, middle);
        merge_sort(arr, middle+1, high);
        merge(arr, low, middle, high);
    }
}

时间复杂度:归并排序的事件复杂度可以通过递归式来计算,T(N) = 2T(N/2) + O(N),可推得归并排序的时间复杂度是O(NlogN)。不过归并排序使用了额外的数组来辅助排序,再加上递归调用栈产生的O(lgN),所以它的空间复杂度是O(N)+O(lgN) ≈ O(N)
稳定性:稳定

推荐:
归并排序优化,由于我们每个递归调用都在merge中申请一个临时数组,那么在任意时刻就可能有logN个临时数组处在活动期,这对于小内存的机器是致命的。另一方面,如果merge动态分配并释放最小量临时内存,那么由malloc占用的时间会很多。我们采用提前分配一个数组传参进入,就可以避免这些缺点。

代码如下:
template <typename T>
void merge(T* arr, T* tmp, int low, int middle, int high)
{
    int left = low;
    int right = middle + 1;
    int k = 0;
        
    while(left <= middle && right <= high){
        if(arr[left] < arr[right])
            tmp[k++] = arr[left++];
        else
            tmp[k++] = arr[right++];
    }   

    while(left <= middle)
        tmp[k++] = arr[left++];
    while(right <= high)
        tmp[k++] = arr[right++];
        
    for(int i=0; i<k; ++i)
        arr[low+i] = tmp[i];
}

template <typename T>
void merge_sort(T* arr, T* tmp, int low, int high)
{
    if(low < high){
        int middle = (int)((low + high) >> 1); 
        merge_sort(arr, tmp, low, middle);
        merge_sort(arr, tmp, middle+1, high);
        merge(arr, tmp, low, middle, high);
  }
}

template <typename T>
void merge_sort(T* arr, const int size)
{
    assert(arr != NULL && size > 0);
    T* tmp = NULL;
    try{
        tmp = new T[size];
    }
    catch(std::bad_alloc& e){
        std::cerr<<e.what()<<std::endl;
        exit(1);
    }
    merge_sort(arr, tmp, 0, size-1);
    delete []tmp;
}



六:快速排序

快速排序同样利用分治思想,不过它是利用一个关键值,每次将小于该值的元素放在它左边,大于该值的元素放在它右边。逐步下去,使数组有序。

双边扫描版本:

代码如下:
template <typename T>
int partition(T* arr, int low, int high)
{
    int i = low;
    int j = high + 1;
    while(i < j){
        while(arr[++i] < arr[low] && i < high);  //防止越界
        while(arr[--j] > arr[low]);
        if(i < j)
            std::swap(arr[i], arr[j]);
    }
    std::swap(arr[low], arr[j]);
    return j;
}

template <typename T>
void quick_sort(T* arr, int low, int high)
{
    if(low < high){
        int middle = partition(arr, low, high);
        quick_sort(arr, low, middle-1);
        quick_sort(arr, middle+1, high);
    }
}


快速排序编程时需要防止i越界,不需要防止j越界的原因是j最终会到达关键值arr[low],这会终止j所在的循环。
时间复杂度:O(NlgN),最差情况下快速排序可能是O(N*2),这在选择了极差的关键值情况下发生。如果每次选择的关键值进行partition都会导致所有其他元素都出现在它的一侧,另一侧是空的。那么这一轮操作相当于只从未排序元素中排出了关键值这一个元素。接下来继续最差情况,从N-1个再取出一,持续下去,这不就是形成了类似插入排序的等差数列吗?所以时间复杂会达到O(N*2)。不过快速排序经时间检验,平均情况是O(NlgN)。
空间复杂度:O(lgN),虽然快速排序没有用额外数组,但是存在递归产生递归调用栈。
稳定性:非稳定。举例:1,3,3,3,5,如果随机选择了中间的3作为关键值,那么左右两边的3会被划分到同一侧,所以不稳定。

推荐:
上面的快速排序版本是使用了从前后双向来做的,我更推荐使用下面这种,从左到右。如果你使用上面这种,不是很熟悉的情况下每次快排都要考虑越界情况,我曾经就是这样的,很麻烦。并且上述版本没有加入入口参数检测以及没有用随机选元法。

单边扫描如下:
inline int random_range(int begin, int end)
{
    return random()%(end-begin+1) + begin;    //注意+begin,因为begin不总是0
}

template <typename T>
int partition(T* arr, int low, int high)
{
    int index = random_range(low, high);
    std::swap(arr[index], arr[high]);

    int small = low -1; 
    for(index=low; index<high; ++index){   //注意<high,因为最后一个元素是关键值
        if(arr[index] < arr[high]){
            ++small;
            if(small != index)   //不等于才交换
                std::swap(arr[small], arr[index]);
        }   
    }   
    ++small;
    std::swap(arr[small], arr[high]);
    return small;
}

template <typename T>
void quick_sort_detail(T* arr, int low, int high)
{
    if(low < high){
       int middle = partition(arr, low, high);
        quick_sort_detail(arr, low, middle-1);
        quick_sort_detail(arr, middle+1, high);
    }
}

template <typename T>
void quick_sort(T* arr, int low, int high)
{
    assert(arr != NULL && low >= 0 && high > 0 && low < high);   //注意low>=0而不是>0
    quick_sort_detail(arr, low, high);
}
这个快速排序的思想是,方向从前往后,使用一前一后两个指针。前面负责探路,由于arr[high]是我们的关键值,所以不会越界。前面指针index每次收集一个小于关键值的元素,如果small指针一直紧随index指针,那么small指针仅需通过++small获得该元素。如果没有,说明中间间隔有大于关键值的元素,那么就把index当前对应的小于关键值的元素和++small之后对应的元素arr[small]交换。因为small始终指向小于关键值元素的最后一个元素,而++之后那个值不是,所以把它换过去换回来index对应的新的小于关键值元素就可以了,直到index=high-1,一次partition完毕。

我们还是用了random_range()函数来防止选择最差键值的情况一直出现。

七:堆排序

堆排序是利用最大堆的特性。最大堆堆顶总是最大元素,我们每次删除该元素把它放到数组的后面,逐步进行指导堆中仅剩一个元素,此时堆已经由小到大有序了。
值得注意的是:堆排序的下标计算,父节点->子节点=2*i+1,下标从0开始,可以带入测试一下。
堆排序其实有两步,建堆buildheap和调整siftdown,我在一个函数内实现的,也可以分开实现。对于N个元素的数组,我们每次从N/2-1开始调整去建立堆,其实从N/2开始也可以,不过多浪费一次比较过程。

堆排序千万不要忘了break!  堆排序千万不要忘了break! 堆排序千万不要忘了break! 

代码如下:
inline int left_child(int i) { return (i << 1) + 1; }

template <typename T>
void sift_down(T* arr, int i, const int size)
{
    T tmp = arr[i];
    int child = 0;
    for(; left_child(i)<size; i=child){
        child = left_child(i);
        if(child != size-1 && arr[child] < arr[child+1])
            ++child;
        if(tmp < arr[child])
            arr[i] = arr[child];
        else
            break;
    }   
    arr[i] = tmp;   //注意这里是i,不是child,不要写错。  //注意这句话,不能写进break处,因为有叶子结点情况,不会进循环
}

template <typename T>
void heap_sort(T* arr, const int size)
{
    assert(arr != NULL && size > 0); 
    for(int i=(size>>1)-1; i>=0; --i)
        sift_down(arr, i, size);  //bulidheap
    for(int i=size-1; i>0; --i){
        std::swap(arr[0], arr[i]);
        sift_down(arr, 0, i);   //heapify
    }   
}
我们使用内敛函数left_child()来增强可读性,使得我们程序无需再关注下标的问题,专注于思路。

时间复杂度 :堆排序时间复杂度是O(NlgN),并且堆排序是原地排序。它的最坏情况和平均情况也是O(NlgN)。
稳定性:非稳定。举例:L={1,2,2},构造初始堆时,L={2, 1, 2}(堆排序左右相等,和左孩子交换,所以中间2上去堆顶了),最终排序序列为L={1,2,2},堆顶先输出,就会排在后面,相等元素相对位置发生了改变,所以非稳定。
展开阅读全文

没有更多推荐了,返回首页