常见排序算法的基本原理、代码实现和时间复杂度分析

版权声明:欢迎转载,但请注明出处https://me.csdn.net/qq_28753373 。谢谢! https://blog.csdn.net/qq_28753373/article/details/82917456

  排序算法无论是在实际应用还是在工作面试中,都扮演着十分重要的角色。最近刚好在学习算法导论,所以在这里对常见的一些排序算法的基本原理、代码实现和时间复杂度分析做一些总结 ,也算是对自己知识的巩固。
说明:
1.本文所有的结果均按照非降序排列;
2.本文所有的程序均用c++实现,但由于自己刚开始学习c++,所以代码中存在很多的C风格,而且可能也有对C++语法运用不恰当的地方,欢迎批评指正。

一、插入排序

1、原理:

  插入排序将一个位置数组seq分为两个部分:已排序的部分seq1和未排序的部分seq2。程序依次遍历seq2中的元素seq2[i],在seq1中寻找合适的位置插入。

2、实现:

  为了方便元素插入时的移位操作,首先将seq2中待插入的元素seq2[i]保存为key。然后在seq1中从后往前地依次比较key与seq1[i]的大小,每遍历一个大于等于key的元素,就将其向后移动一位;当找到一个小于key的元素时,该元素不再移动,其后一个位置就是key要插入的位置。

void  InsertionSort(vector<int> &seq) 
{
    for(unsigned int i = 1; i != seq.size(); i++){
        int key = seq[i];
        unsigned int j = i - 1;
        while(j >= 0 && seq[j] > key){
            seq[j + 1] = seq[j];
            j--;
        }
        seq[j + 1] = key;
    }
}

3、时间复杂度分析:

  插入排序的时间复杂度分析比较简单。从原理中可以看出,插入排序最多需要进行两层遍历,即每遍历到一个元素,均需要对它前面的有序元素进行一次遍历,找到相应的插入位置;从代码中也可以看出程序需要执行两层循环,故插入排序的时间复杂度为O(n2)。

二、快速排序

1、原理:

  快速排序采用“分治”的思想,即首先从输入的数组seq中选出一个元素x作为主元,然后将所有小于等于x的元素放在x的左边,组成一个子数组;大于x的元素放在x的右边,组成另一个子数组。接着对这两个子数组重复上述的步骤,直至子数组中只有一个元素。由于在“分”的过程中,各个元素的大小顺序就已经确定,故不需要合并的操作。

2、实现:

  根据原理,快速排序的基本过程用伪代码可表示为:

QuickSort(A, p, r)
	if p < r
		q = Partition(A, p, r)
		QuickSort(A, p, q-1)
		QuickSort(A, q+1, r)

  可见快速排序的实现关键就在于对数组的分割Partition的实现。下面介绍两种方法来实现这一步骤。

(1)法一:

  将数组分为四个区域,从前往后依次为小于等于x的区域一,大于x的区域二,待操作的区域三以及主元x所在的区域四(即数组的最后一个位置)。从前往后依次遍历待操作的区域三的所有元素,若元素大于x,则位置不变,此时区域二就向后延长了一个位置,如下图中(a)所示;若元素小于等于x,则将该元素与区域一的后一个元素(即区域二的第一个元素)的位置互换(注意:区域一的所有元素均小于x,但区域一的元素并不是按从小到大排序的,故进行位置交换时不需要将整个区域向后移动),此时区域一就向后延长了一个位置,区域二整体向后移动了一个位置,如下图中(b)所示。将区域三所有的元素遍历之后,区域三即消失,此时再将主元x与区域二的第一个元素位置互换。至此,实现了以x为主元对数组的分割。
说明:为了防止数组的排列使算法的时间时间度最大的情况出现,可在每次分割前,随机从数组中选择一个数作为主元,再将主元与数组的最后一个元素进行位置互换(由于小于等于x的区域和大于x的区域是动态增长的,因此在进行分割操作前,x的位置一定位于数组的第一个或最后一个位置)。
avatar

代码实现如下:

void QuickSort1(vector<int> &seq, int left, int right)   //left和right是下标
{
    if(left < right){
    	/*随机选择一个元素作为主元,并将其与数组最后一个元素位置互换*/
        srand((unsigned int)time(0));
        int tmp = rand()%(right - left + 1) + left;  
        exchange(seq[tmp], seq[right]);
        
        int x = seq[right];    //数组的最后一个元素作为主元
        int i = left - 1;      //seq[i]为区域一的最后一个元素
        for(int j = left; j < right; j++){  //seq[j]为区域三的第一个元素
        /*若seq[j]小于等于x,则将其与区域二的第一个元素位置互换*/
            if(seq[j] <= x){
                i = i + 1;
                exchange(seq[i], seq[j]); 
            }
        }
        exchange(seq[i + 1], seq[right]); //将主元插在区域二的第一个位置

        QuickSort1(seq, left, i);    //主元的位置为i+1
        QuickSort1(seq, i + 2, right);
    }
}

(2)法二:

  法二来自于网上看到的一篇博客,其基本思想与法一有一些类似。选择待排序数组的第一个元素作为主元,数组从前往后依次为x所在的区域一、小于等于x的区域二、待操作的区域三和大于x的区域四。先从后往前的遍历区域三,直至找到一个小于x的元素,则该元素之后的区域三的所有位置均可划到区域四中,然后将该元素插入到区域三的第一个位置,则区域二向后延长了一个位置;再从前往后的遍历区域三,直至找到一个大于等于x的元素,则该元素之前区域三的所有位置均可划到区域二中,再将该元素插入到区域三的最后一个位置,则区域四向前延长了一个位置。当区域二和区域四相遇时,区域三消失,所有的元素均完成遍历操作,将主元插到区域二的最后一个位置。
  原文链接:https://blog.csdn.net/MoreWindows/article/details/6684558
  代码实现如下:

void QuickSort2(vector<int> &seq, int left, int right)
{
    if(left < right){
    	/*随机产生一个主元*/
        srand((unsigned int)time(0));
        int tmp = rand()%(right - left + 1) + left; 
        exchange(seq[tmp], seq[left]);
        
        int x = seq[left];   //数组的第一个元素作为主元
        int i = left;
        int j = right;

        while(i < j){
            /*从后往前查找小于主元的数*/
            while(j > i && seq[j] >= x)
                j--;
            if(i < j){
                seq[i++] = seq[j];  //seq[i]为区域二的第一个元素
            }
            /*从前往后查找大于主元的数*/
            while(i < j && seq[i] < x)
                i++;
            if(i < j){
                seq[j--] = seq[i];    //seq[j]为区域二的最后一个元素
            }   //每一次循环结束,均有s[i] = s[j+1]
        }   	//至此,小于i的位置均小于主元,大于j的位置均大于等于主元
        seq[i] = x;	//将主元插入到区域一的最后一个位置

        QuickSort2(seq, left, i - 1);	//主元的位置为i
        QuickSort2(seq, i + 1, right);
    }
}

3、时间复杂度分析:

  设规模为n的问题的时间复杂度为T(n),则由前面的分析可知,T(n) = T(q) + T(n-q-1) + θ(n)成立,其中q为小于等于主元的子数组规模,θ(n)为Partition函数(本文将Partition整合到了快速排序的整个程序中了)的时间复杂度。求解快速排序的时间复杂度的关键在于了解Partition中每一个元素被比较的次数。对于该式的求解较为复杂,在此直接给出结论,对详细的求解过程有兴趣的读者可参考《算法导论》第七章《快速排序》的相关内容。
  快速排序排序的平均时间复杂度为O(nlgn)。
  快速排序的性能依赖于输入数据的排列情况,也即在“分”的过程中对数组的划分情况。下面简单的说明最好和最坏的情况划分。
  用递归树来分析前面的递推式,假设原问题的代价为cn,其中c为常数,n为问题规模,将原问题以常数比例(假设为9:1)划分为两个子问题,再将这两个子问题分别按照原比例划分,重复该过程,直至问题的规模降为1。过程如下图所示:

avatar

  由上图可知,递归树的每一层问题的总代价最大均为cn,则原问题的代价就取决于递归树的层数,层数越多,问题的代价就越大。显然,当递归树为满二叉树时,层数最少,为lgn,此时总代价为cnlgn;考虑极端情况,当递归树退化为线性结构,即每次将问题规模划分为0和n-1两个子问题时,层数最多,为n,此时总代价为cn2。故只要问题的规模按照常数比例划分,快速排序的时间复杂度均为O(nlgn),当问题规模按照0和n-1的比例划分时,快速排序的性能最差,时间复杂度为O(n2)。
  为避免最坏的情况出现,我们在选择主元时进行了随机化处理。虽然在理论上仍然有可能出现最坏情况,但可能性已经微乎其微。当然,我们要避免让快速排序处理元素完全相同的输入序列。

三、归并排序

1、原理:

  归并排序同样采用“分治”的思想。算法将待排序的序列平均分为两个子序列,然后将这两个子序列再分别平均分为两个子序列,重复该过程,直至序列中只有一个元素,此时序列是有序的。最后再将两个已排好序的子序列合并,产生已排序的结果。过程如下图所示:
avatar
用伪代码表示该过程为:

MergeSort(A, p, r)
	if p < r
		q = (p + r) / 2
		MergeSort(A, p, q)
		MergeSort(A, q+1, r)
		Merge(A, p, q, r)

  由上述分析可知,归并排序的重点在于如何实现对两个有序子数组的合并。

2、实现:

  Merge函数的输入序列是一个由两个长度相同的有序序列seq1和seq2组成的序列seq。首先比较seq1和seq2的第一个元素的大小,将其中较小的元素(假设为seq1[0])保存到临时序列tmp中,并将指向seq1元素的指针i向后移动一位,再比较seq1[1]与seq2[0]的大小,将其中较小的元素保存到tmp中seq1[0]之后的位置,重复该过程,直至seq1和seq2其中的一个序列的所有元素均被保存到tmp中,假设该序列为seq1,则此时seq2中元素可能没有被完全遍历,这些没有被遍历到的元素一定都大于此时tmp中的所有元素且是有序排列的,因此将seq2中这些没有遍历到的元素顺序不变的保存到tmp的尾部,即实现了对seq1和seq2这两个有序序列的有序合并。最后用tmp中的元素覆盖seq中的元素,就实现了对seq中所有元素的有序排列。代码实现如下:

void Merge(vector<int> &seq, int left, int mid, int right, vector<int> *tmp)
{
    int i = left, j = mid + 1;  //mid是前半个数组的终点
    
	/*依次比较两个字序列每个元素的大小*/
    while(i <= mid && j <= right){
        if(seq[i] <= seq[j]){
            (*tmp).push_back(seq[i++]);
        }
        else{
            (*tmp).push_back(seq[j++]);
        }
    }
    
    /*将未完全遍历的元素接到tmp的尾部*/
    for(; i <= mid; i++)
        (*tmp).push_back(seq[i]);
    for(; j <= right; j++)
        (*tmp).push_back(seq[j]);

	/*用tmp中的有序序列覆盖seq中的为排序序列*/
    for(unsigned int k = 0; k != (*tmp).size(); k++)
        seq[left + k] = (*tmp)[k];
    (*tmp).clear();	//不可漏,否则tmp会保存每次递归过程中tmp保存的所有元素,导致溢出
}

void MSort(vector<int> &seq, int left, int right, vector<int> *tmp)
{
    if(left < right){
        int mid = (right - left) / 2 + left;  
        MSort(seq, left, mid, tmp);
        MSort(seq, mid + 1, right, tmp);
        Merge(seq, left, mid, right, tmp);
    }
}

void MergeSort(vector<int> &seq, int left, int right)
{
    vector<int> *tmp = new vector<int>[seq.size()];

    if(!tmp)
        cout << "ERROR!" << endl;
    else
        MSort(seq, left, right, tmp);

    delete[] tmp;
}

3、时间复杂度分析:

  用递归树来分析归并排序的时间复杂度,如下图所示:
avatar
  由上图可知,递归树高度为1 + lgn,每层的总代价为cn,则原问题的总代价为cnlgn + cn,故归并排序的时间复杂度可表示为θ(nlgn)。

四、冒泡排序

1、原理:

  冒泡排序应该是我们最早接触的,也是最为简单的排序算法。它从前向后地遍历除最末尾的数的数组中的每一个数,当遍历到某一个数seq[i]时,便与它后面的一个数seq[i+1]作比较,若seq[i]较小,则seq[i]的位置不变,再遍历下一个数seq[i+1];若seq[i]较大,则将它的位置与seq[i+1]的位置对调,再遍历下一个数seq[i+1]。

2、实现:

  冒泡排序的实现较为简单。每次遍历之后,都会找出待遍历数中最大的一个数,并将其放在待遍历数的最后,则在下一次遍历时,就不再遍历之前已经被筛选出来的数。所以我们在编写程序时,要注意一下遍历结束时元素的下标。下面给出三种效率不同的实现方法。

(1)法一:

  第一种方法最为简单,暴力遍历每一个元素。

void BubbleSort1(vector<int> &seq)
{
    for(unsigned int i = 0; i != seq.size() - 1; i++){
        for(unsigned int j = 0; j != seq.size() - i; j++){
            if(seq[j] > seq[j + 1]){
                exchange(seq[j], seq[j + 1]);
            }
        }
    }
}

(2)法二:

  第一种方法不依赖于输入的序列,无论输入的序列怎样排列,时间复杂度均为θ(n2),显然对于某些输入序列,这种方法会产生时间的浪费。如果在某一次遍历中没有发生元素位置的交换,则说明所有的元素已经按照从小到大的顺序排列,那么排序工作就已经完成,不需要进行下一次遍历了。具体实现如下。

void BubbleSort2(vector<int> &seq)
{
    int flag = true;    //在一次遍历中,若发生交换,则flag记为true,否则记为false
    unsigned int j = seq.size();

    while(flag){
        flag = false;	//每次遍历开始之前均为发生元素的交换
        for(unsigned int i = 0; i != j; i++){
            if(seq[i] > seq[i + 1]){
                exchange(seq[i], seq[i + 1]);
                flag = true;
            }
        }
        j--;    //数组末尾是已经排好序的,不再遍历
    }
}

(3)法三:

  在前两种方法中,只要某一次遍历开始了,就一定会遍历完所有待遍历的元素。如果待遍历的元素中有一部分已经是按照从小到大的顺序排列了,则遍历这部分元素显然会产生时间上的浪费,故可对第二种方法继续优化。
  在某一次遍历中,我们将最后一次元素交换发生的位置记为position,则position之后的元素一定是排好序的,且均大于position之前的元素。因此,在下一次遍历中,我们就不再遍历这部分元素。

void BubbleSort3(vector<int> &seq)
{
    unsigned int position = seq.size() - 1; //第一次遍历要将所有元素遍历一遍
    while(position > 0){
        unsigned int j = position;   //每次遍历只需遍历到上一次遍历最后一次发生交换的位置
        position = 0;   //清理上一次遍历产生的交换记录
        for(unsigned int i = 0; i != j; i++){
            if(seq[i] > seq[i + 1]){
                exchange(seq[i], seq[i + 1]);
                position = i;  //记录最后一次交换发生的位置
            }
        }
    }
}

3、时间复杂度分析:

  法一的方法完全不依赖输入的序列,无论输入的序列如何排列,时间复杂度均为θ(n2)。法二和法三的时间复杂度依赖于输入序列的排列情况,当输入序列的情况较好,即存在部分已经排好序的序列,则运行时间会降低;排列情况最差,即输入序列中的所有元素按照从大到小排列时,时间复杂度为θ(n2)。故三种冒泡排序方法的时间复杂度可统一为O(n2)。

  待更。。。。。。

五、堆排序

1、原理:

2、实现:

六、计数排序

1、原理:

2、实现:

七、基数排序

1、原理:

2、实现:

八、桶排序

1、原理:

2、实现:

猜你喜欢

转载自blog.csdn.net/qq_28753373/article/details/82917456
今日推荐