【算法】排序 (二):冒泡排序&快速排序&归并排序&基数排序&拓扑排序(C++实现)

来源:互联网 发布:linux openssl安装 编辑:程序博客网 时间:2024/05/21 12:08

一. 交换排序

1. 冒泡排序

  • 冒泡排序是所有排序算法中最原始的,重复的遍历要排序的数串,一次比较两个元素,假如顺序错误就交换这两个元素,直到不再需要交换,则数串已经排序完毕。数串中的最大元素会逐渐冒上来,所以称之为“冒泡排序”。
    冒泡排序流程图如下所示:
    冒泡排序流程图

  • 时间复杂度分析
    无论数串的紊乱状态如何,只要数串的长度确定,比较次数就是确定的。假设数串的长度为n,那么每次遍历(即内层循环)需要的比较操作为 (n-i-1) 步,而i又是从0遍历到 (n-1) 。因此最后得出的比较步数为n(n1)2,比较操作时间复杂度为O(n2)
    另外,交换操作也十分费时,每次交换两个数字至少需要三步,但是交换操作受数串的紊乱程度的影响。最好情况是一开始就有序,不需要交换。最坏情况是全部需要交换,也就是需要3n(n1)2步,时间复杂度也为O(n2)

  • 空间复杂度分析
    只开数组不用递归,因此空间复杂度就维持在O(1)的常数级水平。

  • 优劣及稳定性
    冒泡排序的效率比较一般,虽然都是O(n2),但是冒泡排序前面的系数比较大,而且还有余项,因此实际用时都比较长。
    但是冒泡排序是稳定的。

2. 快速排序

  • 快速排序也是分治的思想,将不断缩小一个大问题的规模,使其变成可处理的小问题。假设有如下场景时,有两堆数,一堆只有一位数,另外一堆只有两位数,那么要如何实现排序?显然是分别对两个堆进行排序,然后再直接拼接起来就可以了。回归到本质,我们是找到一个分隔标准分成两个堆,然后再向上述的做法进行处理。这也就是快速排序算法的由来。此时距离构建算法我们还要解决三个关键问题:一是怎样分数组,二是要将数组分到多小;三是怎么合并这些数组。
    对于第一个问题,我们发现要把数组缩小规模,类似于归并排序取中间元素,快速排序也要找到一个对照的标准元素,然后以这个元素为参照,比这个元素小的放在一起,比这个元素大的放在另一堆,这就完成了拆分的过程。但这又引发了一个问题,如何找标准元素。首先考虑标准元素要找数组里面的还是数组外面的,这一点很好判断,如果是数组外的元素,我们不好确定到底要取多大,这样很容易造成划分的不均匀(极端的例子就是都被放在一个堆里),因此应当要选择数组里面的元素(这样的好处是就算被选择的数字是最大元素或者最小元素,也无需担心出现死循环的问题,所以这个元素的选取其实是任意的),我们可以直接选取数串的第一个元素作为先导元素。但是此时假如数串原本是有序的,那么每次选取的先导元素都是最小/最大值,这导致了算法的效率十分低下,所以我们可以随机选取先导元素
    对于第二个问题,显然我们要将数组划分到最原始最简单的小问题,对于排序来说最原始的数组就是只含有一个元素的数组,因为此时的数组一定是有序的。
    对于第三个问题,快速排序比归并排序要简单。因为递归之后第一堆元素都比对照元素要小,第二堆元素都比对照元素要大。因此我们直接将排完序后的第一堆元素放在对照元素前面,将排完序后的第二堆元素放在对照元素的后面,就完成了合并过程。
    弄清楚算法的大致过程后,我们应当考虑用什么数据结构来存储数据。对比数组的随机读取结构及链表的链式结构:对于划分数据,此时需要频繁地移动数据,对于链表来说需要从头遍历到对应元素,而且需要大量的指针交换,而数组只需要控制下标即可,虽然交换时间差不多,但是数组更好实现,所以数组优于链表;对于合并两个有序串,数组通过控制首末位置的下标就能够很好的实现,链表也不难,直接把第二段的头指针接到第一段的末尾即可,但是找到第一段的末尾需要遍历整个前半段链表,因此链表在此操作上也没有优势,所以一般选择数组实现快速排序。
    照例简单的模拟一下过程,有待排序数串5 2 4 6 1 3,具体如下:
    快速排序示例

  • 时间复杂度分析
    快速排序中递归函数相当于任务分配,其调用过程中用时基本上可以忽略不计,因此主要耗时的环节落在了比较和交换上,我们先分析某一层递归的情况,假设这一层递归传进的数串长度为n,那么每个元素都要与pivot做一次比较,一共 n-1 次,因此比较的时间复杂度为O(n);而交换的次数是不固定的,最好情况就是不用做交换(即原来传入的数串有序),此时时间复杂度仅仅只是比较的复杂度,即为O(n),最坏情况是每次都要做交换(即原来传入的数串倒序),那每一层的交换次数为O(32n)(每次交换需要三步),此时每一层的时间复杂度为O(2n)。
    以上是一层的情况,我们还要关心递归了多少层。最好的情况是每次划分都是二分,此时递归层数为log2n,因此最终的时间复杂度为O(nlog2n)。最坏情况是每次都选中了数串中的最小元素或者最大元素做主元,这样的递归层数为n,因此最终时间复杂度接近于O(2n2),此时比插入排序的最坏情况还要慢。所以我们要分析平均情况,假设一开始传进去的有n个数据,第一层O(n)的比较赋值次数是必须的,我们将所有的递归下去的情况(一共有n种情况,从全部小于先导元素(T(n-1)+T(0)))到全部大于先导元素(T(0)+T(n-1))视作等可能发生,那么我们就可以得到下面这个递推关系式。

    T(n)=1n[nO(n)+2k=0n1T(k)]

    通过迭代可以大致估算出
    T(n)=1.39nlog2n+O(n)

    因此时间复杂度为O(nlog2n)

  • 空间复杂度分析
    空间复杂度与递归层数有关,显然,综合上述分析,最好情况下递归层数约为log2n层,最坏情况下递归层数约为n层,在平均情况下递归层数约为klog2n层,因此空间复杂度为O(log2n)

  • 优劣及稳定性
    快速排序算法已经达到了基于比较的排序算法的时间下界O(nlog2n),而且比较好实现,因此对于数组的随机存储结构和排序首选快速排序算法。
    但是快速排序运算速度上不稳定(最好及最坏情况时间空间复杂度相去甚远,而且非常受一开始先导元素选取的影响);其次快速排序不是稳定的排序算法。

二. 归并排序

  • 归并排序是插入排序的另一种改良,同样利用了插入排序“在基本有序的前提下,插入排序的耗时总会很短”以及“如果数组很短,就算基本无序的情况下,插入的耗时与基本有序的情况是差不多的”这两点性质。因此归并排序采用分治法,把一个大数组分成若干小块。对于一个数组,我们可以将大数组分成若干个小数组进行排序,再用
    合理的方法将小数组通过一定的规则合并起来。类似于快速排序,我们同样要处理三个关键问题:一是怎么分数组;二是要将数组分到什么程度;三是怎么合并数组。
    首先解决第一个问题,怎样将数组缩小规模。用二分法进行实现,每次对数组做一个中分操作。
    对于第二个问题,要将数组分到什么程度,与快速排序相同,我们最终目的是得到一个元素的数组。
    对于第三个问题,怎么合并。对于两个已经有序的数组,我们只需要不断比较两个两个数组中的最大值(或者最小值)的大小,可以得到合并数组中的最大值、次大值等等,以此类推,就可以将数组合并。如下所示
    合并已排好序的数组
    考虑用什么数据结构存储,同样比较数组的随机读取结构或者是链表的链式结构,对比两者的差距:
    同样对于划分数据,数组的随机结构是最佳的,因为随机读取结构我们只需要控制首末位置的下标即可一步到位轻松实现划分。但对于链表来说就没有那么轻松,因为链表至少要从头到尾遍历一遍才知道一共有多少个元素,然后我们还得算出来到中间有多少个元素,然后还得从头开始再遍历到中间,若原来有n个数据,数组只需要1步,而链表需要32n步。
    对于合并两个有序串,在比较操作上是一样的,但是对于移动元素,两边的操作步数大致相同,但是每一步花费的时间却有很大差别。链表只要把指针传过去就可以实现移动元素的操作,而指针是一个整型值,四个字节,但数组需要把所有元素一个个搬过去。如果数组里存的是大整数或者是一个结构体或者是一个占用空间很大的值,我们也只能全部搬过去。移动的数据量可就不只四个字节,移动数据一大,带来的直接后果就是时间变得很长,对于这种需要大量合并操作的算法来说是极其耗时的。因此对于归并排序来说,虽说在拆分数据上数组有优势但这也是得不偿失的,因此推荐使用链表实现,而且链表还有一个好处,就是空间可以增大,但数组的空间在一开始就是指定的不允许随意修改的。
    为了弥补链表在拆分数据上的劣势,我们从头结点开始规定两个指针,一个每次沿着链表移动一个元素,另一个每次移动两个元素,当那个移动两个元素的指针到末尾时,那个移动一个元素的指针指向的就是中间位置。这样的话,原来就有n个数据,只需要n步。
    对于指针操作必须指定所有空指针为NULL,而且到了末尾要释放掉分配的空间,同时注意指针的传值和传引用。对于不需要修改链表结构我们传值即可,在需要修改链表结构时我们需要传引用,否则系统会在拷贝的新空间上执行我们的操作,使得返回原函数后我们原先操作无效(注意free函数是特殊的,他只要拿到地址的值就可以实现释放操作,所以只需要进行值传递,而不需要引用传递)。日常模拟一波过程:
    归并排序过程

  • 时间复杂度分析
    首先分析第一层递归,对于归并排序来说主要耗时间的有两块,一块是将数据进行合并(因为递归函数相当于将任务分配下去,所以每次任务的耗时是可以忽略不计的)。数据的拆分在之前分析过,如果采用双指针的方法,对于长度为n的数据来说是件复杂度为O(n);数据的合并对于一个合并后为n个数据的链来说,我们每做一次判断就可以找到那个当前“最大”的元素,因此时间复杂度为O(n)。
    对于递归第二层来说,原来的数据被分成两块,每一块都变成原来时间复杂度的一半,因此总体复杂度也是O(n),以此类推第三层、第四层等等,每一层的时间复杂度都为O(n),对于一个n个数据的数串来说,最多能够分log2n次,因此一共只会有log2n层,所以总体的时间复杂度为O(nlog2n)

  • 空间复杂度分析
    这个算法的空间复杂度和递归层数有关,显然递归层数为log2n层,因此空间复杂度为O(log2n)

  • 优劣及稳定性
    归并算法同样是插入排序算法的改进,利用分治法不断缩小题目规模,最终达到简化问题的目的,因此归并排序算法在时间复杂度上有不小的优势。可以证明基于比较的排序算法的时间下界为O(nlog2n),也就是说没有基于比较的排序算法在时间复杂度上可以超过归并排序。
    归并排序是稳定的排序,因为在归并时出现相同元素时,我们优先选取第一条子链中的元素,而第一个子链中的元素正好是原来排在前面的元素。

三. 基数排序

  • 基数排序的发明可以追溯到1887年赫尔曼·何乐礼在打孔卡片制表机(Tabulation Machine)上的贡献。它是这样实现的:将所有待比较数值(正整数)统一为同样的数位长度,数位较短的数前面补零。然后,从最低位开始,依次进行一次排序。这样从最低位排序一直到最高位排序完成以后, 数列就变成一个有序序列。
    基数排序(radix sort)属于分配式排序。该算法的实现基于两个步骤:分配以及收集。首先根据数据的进制,从最低位(或者最高位)开始,将数据分配到不同桶中,然后按顺序收集,在根据次低位(次高位)再次分配,再收集……以此类推,知道最高位(最低位),收集到的就是有序数据。基数排序有两种实现方法 —— 最高位优先法和最低位优先法。MSD由键值的最左边开始,LSD由键值的最右边开始。
    最高位优先(Most Significant Digit first)法,简称MSD法:先按k1排序分组,同一组中记录,关键码k1相等,再对各组按k2排序分成子组,之后,对后面的关键码继续这样的排序分组,直到按最次位关键码kd对各子组排序后。再将各组连接起来,便得到一个有序序列。
    最低位优先(Least Significant Digit first)法,简称LSD法:先从kd开始排序,再对kd-1进行排序,依次重复,直到对k1排序后便得到一个有序序列。
    模拟一波过程,以LSD为例如下所示
    LSD

  • 时间复杂度分析
    设待排序数串长度为n,数据的最大位数为d,每一位的取值范围为r,则进行链式基数排序的时间复杂度为O(d(r+n)),其中,分配一次的时间复杂度为O(n),收集一次的时间复杂度为O(n),共进行d次分配和收集。由于d远远小于n,所以更加接近线性级别算法。

  • 空间复杂度分析
    需要radix个桶,以及用于静态链表的n个指针。一般情况下,n远大于radix。

  • 优劣及稳定性
    对比桶排序,基数排序的性能略差。基数排序的优点在于所需要的桶不多,而且基数排序几乎不需要任何的“比较”操作,而桶排序在桶相对比较少的情况下,桶内多个数据必须基于比较操作的排序。在实际应用中,基数排序的应用范围更加广泛。
    LSD和MSD都可以稳定实现,但是对于MSD,需要从大桶里分小桶,实现比较麻烦,将每个桶里的数值按照下一数位的值分配到子桶中,在进行完最低位数的分配后再合并回单一数组(递归实现)。LSD的实现比较易于理解,在文章末尾有代码实现。
    LSD的基数排序适用于位数小的数列,如果位数多的话,使用MSD的效率会比较好。
    尽管基数排序执行的循环轮数少于快速排序,但是每一轮基数排序所需的时间要长得多,利用计数排序作为中间稳定排序的基数排序不是原址排序,而很多基于比较的排序是原址排序。因此当主存的容量比较宝贵(如嵌入式)时,会更倾向于如快速排序之类的原址排序。

四. 拓扑排序

  • 拓扑排序的应用场景比较特殊,并不是通常意义上的“排序”。在许多实际应用中,我们都需要使用有向无环图来指明事件的优先次序,拓扑排序是对有向无环图中所有节点的一种线性排序,即为事件的一种合理次序(注意次序有时是不唯一的)。拓扑排序是通过深度优先搜索实现的,可以证明有向无环图进行深度优先搜索后不产生后向边。
    拓扑排序的实现有两种方式:
    1、Kahn算法,其思路是:(1) 从有向图中选取一个没有前驱的节点V并输出;(2) 从有向图中删去没有前驱的节点V,以及以节点V为起点的边;(3) 重复上述步骤,直到有向图中不存在入度为0的点。
    2、DFS算法,其思路为递归:(1) 对于所有未访问的出度为0的节点V,假如其入度不为0,先访问节点V’(存在边V’->V);(2) 重复步骤(1)直到节点V是入度为0的点,输出节点V,返回上一层。
    假如能够确定给出的图为DAG,那么可以使用DFS算法实现更加简洁;假如图不能确定是否为DAG,那么应当使用Kahn实现。
    考虑算法采用的数据结构,对于稀疏图,一般采用list容器等存储;对于稠密图,可以使用数组来存储邻接矩阵。

  • 时间复杂度分析
    假设存在有向无环图G,图中有e条边,n个节点。
    对于Kahn算法实现,首先建立数组记录每个节点的入度,则需要遍历所有边,时间复杂度为O(e)。找到入度为0的节点,删除节点及对应的边,注意此时对应的点入度需要减1,一条边对应一次入度减1操作,所以时间复杂度为O(e)。找n次入度为0的节点,时间复杂度为O(n)。综上,复杂度为O(n+2e),即O(n+e)。
    对于DFS算法实现,其时间复杂度与DFS算法是一致的,只是在DFS的最后加上存储节点值。对DFS算法,在遍历图时,每个节点最多调用一次DFS函数,一旦某个顶点被标记成已访问,就不会再从该节点开始搜索。遍历图的过程实质上是对每个顶点查找邻接点的过程,其耗费时间取决于使用的存储结构。当使用二维数组表示图时,查找每个顶点的邻接点所需要的时间O(n2),而以邻接表表示图时,查找邻接点所需要时间为O(e)。因此以邻接表表示图的时候,其时间复杂度为O(n+e)。

  • 空间复杂度分析
    对于Kahn算法实现,需要一个数组存储每个节点的入度,即空间复杂度为O(n)。
    对于DFS算法实现,空间复杂度为O(1)。

五. 其他

  1. 计数排序、基数排序以及桶排序的比较?

    计数排序(counting sort):输入数据范围均落在0~k之间,构造一个数组大小为k+1,计算每个元素出现的次数。

    COUNTING-SORT(A,B,k)let C[0..k] be a new arrayfor i = 0 to k    C[i] = 0for j = 1 to A.length    C[A[j]] = C[A[j]]+1// C[i] now contains the number of elements equal to i.for i = 1 to k    C[i] = C[i]+C[i-1]// C[i] contains the number of elements less than or equal to ifor j = A.length to  1    B[C[A[j]]] = A[j]    C[A[j]] = C[A[j]] - 1

    桶排序(bucket sort):假设数据服从均匀分布,平均情况下时间代价为O(n)。计数排序假设输入数据都属于一个小区间内的整数,而桶排序则假设输入是由一个随机过程产生,该过程将元素均匀地分布在[0, 1)区间上。桶排序将[0, 1)区间划分为n个相同大小的子空间,或称为桶。然后将n个输入数分别放到各个桶中。然后对每个桶中的数进行排序,再遍历每个桶,按照次序把各个桶中的元素列出来。

    BUCKET-SORT(A)n = A.lengthlet B[0 .. n-1] be a new arrayfor i = 0 to n-1    make B[i] an empty listfor i = 1 to n    insert A[i] into list B[floor(nA[i])]for i = 0 to n-1    sort list B[i] with insertion sortconcatenate the lists B[0], B[1], ..., B[n-1] together in order

    基数排序(radix sort):基数排序使用的桶的个数与数据使用的进制相关,而且算法的复杂度与数据的大小相关。

  2. 二维数组与指针?

    • 二维数组名是数组的首地址,虽然值等于第一个元素的地址,但是并不代表元素的地址。
      数组名都仅仅是地址常量,但是不是指针,都是可以赋值给指针,但是一维数组名和二维数组名赋予给指针时是不一样的。一维数组的数组名可以直接赋给指针(形如int a[3]; int *p=a;),二维数组的数组名不可以直接赋给指针(形如int a[3][4]; int *p=a;是错误的,正确的赋值方法为int *p=a[0]; 另外可以直接定义二维数组的指针,int (*p)[4]; p=a;也是正确的)。
      此时好像有些矛盾,为什么数组名a是数组的首地址,但是不能直接使用p=a,而要使用p=a[0]?
      需要注意的是此时数据类型是不同的,因为p是指向int的指针,而a可以看成指向int [3]的指针。而int和int [3]不是相同的类型,前者是简单数据类型,后者是由简单数据类型构成的数组类型。显然这两种数据类型不同,所以指向他们的指针类型也不同。指针运算是按照指针的类型进行的,所以p++只使p移动了一个整数所占的字节长度,a++移动了三个整数所占的字节长度。由指针运算可以看出这两个指针不是同一类型。但是可以进行强制转换,即p=(int*)a; 因为a和a[0]虽然类型不同,但是值是相同的。
      除了sizeof、&和字符串常量之外的表达式中,array type会被自动转换为pointer type。
    • 注意区分指针数组和二维数组指针:
      指针数组可以声明如 int *(p1[4]); 其中括号可以去除,因为[]操作符的优先级高于*操作符。指针数组是一个数组,只是每个元素保存的都是指针,在32位环境下他占用4x5=20个字节的内存。
      二维数组指针可以声明为 int (*p2) [4]; 。它是一个指针,指向一个二维数组,占用4个字节的内存。

五. 参考及代码

[1] 排序算法C++实现

阅读全文
0 0