一:排序
排序(Sort)是计算机程序设计中的一种重要操作,也是日常生活中经常遇到的问题。例如,字典中的单词是以字母的顺序排列,否则,使用起来非常困难。同样,存储在计算机中的数据的次序,对于处理这些数据的算法的速度和简便性而言,也具有非常深远的意义。
1.1:基本概念
排序是把一个记录(在排序中把数据元素称为记录)集合或序列重新排列成按记录的某个数据项值递增(或递减)的序列。
作为排序依据的数据项称为“排序项”,也称为记录的关键码(Keyword)。关键码分为主关键码(Primary Keyword)和次关键码(Secondary Keyword)。一般地,若关键码是主关键码,则对于任意待排序的序列,经排序后得到的结果是唯一的;若关键码是次关键码,排序的结果不一定唯一,这是因为待排序的序列中可能存在具有相同关键码值的记录。此时,这些记录在排序结果中,它们之间的位置关系与排序前不一定保持一致。如果使用某个排序方法对任意的记录序列按关键码进行排序,相同关键码值的记录之间的位置关系与排序前一致,则称此排序方法是稳定的;如果不一致,则称此排序方法是不稳定的。
例如,一个记录的关键码序列为(31,2,15,7,91,7*),可以看出,关键码为 7 的记录有两个(第二个加“*”号以区别,以下同)。若采用一种排序方法得到的结果序列为(2,7,7*,15,31,91),则该排序方法是稳定的;若采用另外一种排序方法得到的结果序列为(1,7*,7,15,31,91),则这种排序方法是不稳定的。
由于待排序的记录的数量不同,使得排序过程中涉及的存储器不同,可将排序方法分为内部排序(Internal Sorting)和外部排序(External Sorting)两大类。
内部排序指的是在排序的整个过程中,记录全部存放在计算机的内存中,并且在内存中调整记录之间的相对位置,在此期间没有进行内、外存的数据交换。
外部排序指的是在排序过程中,记录的主要部分存放在外存中,借助于内存逐步调整记录之间的相对位置。在这个过程中,需要不断地在内、外存之间交换数据。
显然,内部排序适用于记录不多的文件。而对于一些较大的文件,由于内存容量的限制,不能一次全部装入内存进行排序,此时采用外部排序较为合适。但外部排序的速度比内部排序要慢的多。内部排序和外部排序各有许多不同的排序方法。本书只讨论内部排序的各种方法。
任何算法的实现都和算法所处理的数据元素的存储结构有关。线性表的两种典型存储结构是顺序表和链表。由于顺序表具有随机存取的特性,存取任意一个数据元素的时间复杂度为 O(1),而链表不具有随机存取特性,存取任意一个数据元素的时间复杂度为 O(n),所以,排序算法基本上是基于顺序表而设计的。
由于排序是以记录的某个数据项为关键码进行排序的,所以,为了讨论问题的方便,假设顺序表中只存放记录的关键码,并且关键码的数据类型是整型,也就是说,使用的顺序表是整型的顺序表 SeqList<int>,下面讨论各种排序方法简写为 SeqList。
排序有非递增有序和非递减排序排序两种。不失一般性,我们只讨论的所有排序算法都是按关键码非递减有序设计的。
1.2 简单排序方法
1.2.1直接插入排序
插入排序(Insertion Sort)的算法描述是一种简单直观的排序算法。它的工作原理是通过构建有序序列,对于未排序数据,在已排序序列中从后向前扫描,找到相应位置并插入。插入排序在实现上,通常采用in-place排序(即只需用到O(1)的额外空间的排序:意思是所有的操作都是”就地“操作,不允许进行移动,或者称作原位操作,即不允许使用临时变量。如通常交换两个数的值可以通过异或操作实现),因而在从后向前扫描过程中,需要反复把已排序元素逐步向后挪位,为最新元素提供插入空间。
(1):算法描述
一般来说,插入排序都采用in-place在数组上实现。具体算法描述如下:
1.从第一个元素开始,该元素可以认为已经被排序
2.取出下一个元素,在已经排序的元素序列中从后向前扫描
3.如果该元素(已排序)大于新元素,将该元素移到下一位置
4.重复步骤3,直到找到已排序的元素小于或者等于新元素的位置
5.将新元素插入到该位置后
6.重复步骤2~5
(2):排序的过程如下:
1 public SeqList<int> InsertSort(SeqList<int> sqList) 2 { 3 for (int i = 1; i < sqList.GetLength(); i++) 4 { 5 int tmp = sqList[i]; 6 int j = i; 7 while (j > 0 && sqList[j - 1] > tmp) 8 { 9 sqList[j] = sqList[j - 1]; 10 j--; 11 } 12 sqList[j] = tmp; 13 } 14 15 return sqList; 16 }
直接插入排序算法的时间复杂度分为最好、最坏和随机三种情况:
最差时间复杂度 О(n²)
最优时间复杂度 О(n²)
平均时间复杂度 О(n²)
(1) 最好的情况是顺序表中的记录已全部排好序。这时外层循环的次数为n-1,内层循环的次数为 0。这样,外层循环中每次记录的比较次数为 1,所以直接插入排序算法在最好情况下的时间复杂度为 O(n)。
(2) 最坏情况是顺序表中记录是反序的。这时内层循环的循环系数每次均为 j。直接插入排序算法在最坏情况下的时间复杂度为O(n2)。
(3) 如果顺序表中的记录的排列是随机的,则记录的期望比较次数为n2/4。因此,直接插入排序算法在一般情况下的时间复杂度为O(n2)。
可以证明,顺序表中的记录越接近于有序,直接插入排序算法的时间效率越高,其时间效率在O(n)到O(n2)之间。直接插入排序算法的空间复杂度为 O(1)。因此,直接插入排序算法是一种稳定的排序算法。
1.2.2 冒泡排序
冒泡排序(Bubble Sort,台湾译为:泡沫排序或气泡排序)是一种简单的排序算法。它重复地走访过要排序的数列,一次比较两个元素,如果他们的顺序错误就把他们交换过来。走访数列的工作是重复地进行直到没有再需要交换,也就是说该数列已经排序完成。这个算法的名字由来是因为越小的元素会经由交换慢慢“浮”到数列的顶端。
冒泡排序对n个项目需要O(n^2)的比较次数,且可以原地排序。尽管这个算法是最简单了解和实作的排序算法之一,但它对于少数元素之外的数列排序是很没有效率的。
冒泡排序是与插入排序拥有相等的执行时间,但是两种法在需要的交换次数却很大地不同。在最坏的情况,冒泡排序需要O(n^2)次交换,而插入排序只要最多O(n)交换。冒泡排序的实现(类似下面)通常会对已经排序好的数列拙劣地执行(O(n^{2})),而插入排序在这个例子只需要O(n)个运算。因此很多现代的算法教科书避免使用冒泡排序,而用插入排序取代之。冒泡排序如果能在内部循环第一次执行时,使用一个旗标来表示有无需要交换的可能,也有可能把最好的复杂度降低到O(n)。在这个情况,在已经排序好的数列就无交换的需要。若在每次走访数列时,把走访顺序和比较大小反过来,也可以稍微地改进效率。有时候称为往返排序,因为算法会从数列的一端到另一端之间穿梭往返。
(1):算法描述
1.比较相邻的元素。如果第一个比第二个大,就交换他们两个。
2.对每一对相邻元素作同样的工作,从开始第一对到结尾的最后一对。在这一点,最后的元素应该会是最大的数。
3.针对所有的元素重复以上的步骤,除了最后一个。
4.持续每次对越来越少的元素重复上面的步骤,直到没有任何一对数字需要比较。
(2):排序的过程如下:
1 public SeqList<int> BubbleSort(SeqList<int> sqList) 2 { 3 for (int i = 0; i < sqList.GetLength(); i++) 4 { 5 for (int j = sqList.GetLength() - 1; j > i; j--) 6 { 7 if (sqList[j] < sqList[j - 1]) 8 { 9 //sqList[j] = sqList[j] ^ sqList[j - 1]; 10 //sqList[j - 1] = sqList[j] ^ sqList[j - 1]; 11 //sqList[j] = sqList[j] ^ sqList[j - 1]; 12 int temp = sqList[j]; 13 sqList[j] = sqList[j - 1]; 14 sqList[j - 1] = temp; 15 } 16 } 17 } 18 19 return sqList; 20 }
最差时间复杂度
最优时间复杂度
平均时间复杂度
冒泡排序算法的最好情况是记录已全部排好序,这时,循环 n-1 次,每次循环都因没有数据交换而退出。因此,冒泡排序算法在最好情况下的时间复杂度为O(n)。
冒泡排序算法的最坏情况是记录全部逆序存放 ,冒泡排序算法在最坏情况下的时间复杂度为O(n2)。
冒泡排序算法只需要一个辅助空间用于交换记录,所以,冒泡排序算法是一种稳定的排序方法。
1.2.3 简单选择排序
选择排序(Selection sort)是一种简单直观的排序算法。它的工作原理如下。首先在未排序序列中找到最小(大)元素,存放到排序序列的起始位置,然后,再从剩余未排序元素中继续寻找最小(大)元素,然后放到已排序序列的末尾。以此类推,直到所有元素均排序完毕。
选择排序的主要优点与数据移动有关。如果某个元素位于正确的最终位置上,则它不会被移动。选择排序每次交换一对元素,它们当中至少有一个将被移到其最终位置上,因此对n个元素的表进行排序总共进行至多n-1次交换。在所有的完全依靠交换去移动元素的排序方法中,选择排序属于非常好的一种。
(1):排序的过程如下:
最差时间复杂度 О(n²
最优时间复杂度 О(n²)
平均时间复杂度 О(n²)
简单选择排序算法只需要一个辅助空间用于交换记录,所以,简单选择排序算法是一种稳定的排序方法。
1.3 快速排序
快速排序(Quick Sort)的基本思想归纳起来有的三步:
①任意选取序列中的一个元素,用此元素作为“中间元素”,这里说明一下,这个“中间元素”,排序后不一定刚好在序列的中间。
②在序列中取出所有大于“中间元素”的元素,放在“中间元素”的右边。
③在序列中取出所有小于“中间元素”的元素,放在“中间元素”的左边。
经过这三步后便完成了一轮排序,经过一轮排序后,序列一般被“中间元素”分成两部分,一部分比“中间元素”小,另一部分反之。所以我们要继续排序剩下的两部分,所以我们要理解递归的思想,只要我们继续利用以上的三步,排序剩下的两部分,最终便会排序完整个序列。
(1):排序的过程如下:
(2):c#实现
1 /// <summary> 2 /// 快速排序 3 /// </summary> 4 /// <param name="sqList">要排序的数组</param> 5 /// <param name="low">下标开始位置,向右查找</param> 6 /// <param name="high">下标开始位置,向左查找</param> 7 public void QuickSort(SeqList<int> sqList, int low, int high) 8 { 9 if (low >= high) 10 return; 11 //完成一次单元排序 12 int index = SortUnit(sqList, low, high); 13 //递归调用,对左边部分的数组进行单元排序 14 QuickSort(sqList, low, index - 1); 15 //递归调用,对右边部分的数组进行单元排序 16 QuickSort(sqList, index + 1, high); 17 } 18 19 /// <summary> 20 /// 单元排序 21 /// </summary> 22 /// <param name="sqList">要排序的数组</param> 23 /// <param name="low">下标开始位置,向右查找</param> 24 /// <param name="high">下标开始位置,向右查找</param> 25 /// <returns>每次单元排序的停止下标</returns> 26 public int SortUnit(SeqList<int> sqList, int low, int high) 27 { 28 int key = sqList[low];//基准数 29 while (low < high) 30 { 31 //从high往前找小于或等于key的值 32 while (low < high && sqList[high] >= key) 33 { 34 high--; 35 } 36 //比key小开等的放左边 37 sqList[low] = sqList[high]; 38 39 //从low往后找大于key的值 40 while (low < high && sqList[low] <= key) 41 { 42 low++; 43 } 44 //比key大的放右边 45 sqList[high] = sqList[low]; 46 } 47 48 //结束循环时,此时low等于high,左边都小于或等于key,右边都大于key。将key放在游标当前位置。 49 sqList[low] = key; 50 return high; 51 }
快速排序算法的时间复杂度和每次划分的记录关系很大。如果每次选取的记录都能均分成两个相等的子序列,这样的快速排序过程是一棵完全二叉树结构(即每个结点都把当前待排序列分成两个大小相当的子序列结点,n个记录待排序列的根结点的分解次数就构成了一棵完全二叉树),这时分解次数等于完全二叉树的深度log2n。每次快速排序过程无论把待排序列这样划分,全部的比较次数都接近于n-1 次,所以,最好情况下快速排序的时间复杂度为O(nlog2n)。快速排序算法的最坏情况是记录已全部有序,此时n个记录待排序列的根结点的分解次数就构成了一棵单右支二叉树。所以在最坏情况下快速排序算法的时间复杂度为O(n2)。一般情况下,记录的分布是随机的,序列的分解次数构成一棵二叉树,这样二叉树的深度接近于log2n,所以快速排序算法在一般情况下的时间复杂度为O(nlog2n)。
另外,快速排序算法是一种不稳定的排序的方法。
1.4 堆排序
堆排序是利用堆这种数据结构而设计的一种排序算法,堆排序是一种选择排序,它的最坏,最好,平均时间复杂度均为O(nlogn),它也是不稳定排序。首先简单了解下堆结构。
堆:是具有以下性质的完全二叉树:每个结点的值都大于或等于其左右孩子结点的值,称为大顶堆;或者每个结点的值都小于或等于其左右孩子结点的值,称为小顶堆。如下图:
同时,我们对堆中的结点按层进行编号,将这种逻辑结构映射到数组中就是下面这个样子
该数组从逻辑上讲就是一个堆结构,我们用简单的公式来描述一下堆的定义就是:
大顶堆:arr[i] >= arr[2i+1] && arr[i] >= arr[2i+2]
小顶堆:arr[i] <= arr[2i+1] && arr[i] <= arr[2i+2]
(1) 堆排序的基本思想是:
将待排序序列构造成一个大顶堆,此时,整个序列的最大值就是堆顶的根节点。将其与末尾元素进行交换,此时末尾就为最大值。然后将剩余n-1个元素重新构造成一个堆,这样会得到n个元素的次小值。如此反复执行,便能得到一个有序序列了
步骤一 构造初始堆。将给定无序序列构造成一个大顶堆(一般升序采用大顶堆,降序采用小顶堆)。
(2)堆排序过程
1.假设给定无序序列结构如下
2.此时我们从最后一个非叶子结点开始(叶结点自然不用调整,第一个非叶子结点 arr.length/2-1=5/2-1=1,也就是下面的6结点),从左至右,从下至上进行调整。
3.找到第二个非叶节点4,由于[4,9,8]中9元素最大,4和9交换。
这时,交换导致了子根[4,5,6]结构混乱,继续调整,[4,5,6]中6最大,交换4和6。
此时,我们就将一个无需序列构造成了一个大顶堆。
步骤二 将堆顶元素与末尾元素进行交换,使末尾元素最大。然后继续调整堆,再将堆顶元素与末尾元素交换,得到第二大元素。如此反复进行交换、重建、交换。
a.将堆顶元素9和末尾元素4进行交换
b.重新调整结构,使其继续满足堆定义
c.再将堆顶元素8与末尾元素5进行交换,得到第二大元素8.
后续过程,继续进行调整,交换,如此反复进行,最终使得整个序列有序
再简单总结下堆排序的基本思路:
a.将无需序列构建成一个堆,根据升序降序需求选择大顶堆或小顶堆;
b.将堆顶元素与末尾元素交换,将最大元素"沉"到数组末端;
c.重新调整结构,使其满足堆定义,然后继续交换堆顶元素与当前末尾元素,反复执行调整+交换步骤,直到整个序列有序。
(3):c#算法实现
1 /// <summary> 2 /// 堆排序 3 /// </summary> 4 /// <param name="sqList"></param> 5 /// <returns></returns> 6 public SeqList<int> HeapSort(SeqList<int> sqList) 7 { 8 //1.构建大顶堆 9 for (int i = sqList.GetLength() / 2 - 1; i >= 0; i--) 10 { 11 //从第一个非叶子结点从下至上,从右至左调整结构 12 adjustHeap(sqList, i, sqList.GetLength()); 13 } 14 //2.调整堆结构+交换堆顶元素与末尾元素 15 for (int j = sqList.GetLength() - 1; j > 0; j--) 16 { 17 swap(sqList, 0, j);//将堆顶元素与末尾元素进行交换 18 adjustHeap(sqList, 0, j);//重新对堆进行调整 19 } 20 21 return sqList; 22 } 23 24 /// <summary> 25 /// 调整大顶堆(仅是调整过程,建立在大顶堆已构建的基础上) 26 /// </summary> 27 /// <param name="arr"></param> 28 /// <param name="i"></param> 29 /// <param name="length"></param> 30 public static void adjustHeap(SeqList<int> sqList, int i, int length) 31 { 32 int temp = sqList[i];//先取出当前元素i 33 for (int k = i * 2 + 1; k < length; k = k * 2 + 1) 34 {//从i结点的左子结点开始,也就是2i+1处开始 35 if (k + 1 < length && sqList[k] < sqList[k + 1]) 36 {//如果左子结点小于右子结点,k指向右子结点 37 k++; 38 } 39 if (sqList[k] > temp) 40 {//如果子节点大于父节点,将子节点值赋给父节点(不用进行交换) 41 sqList[i] = sqList[k]; 42 i = k; 43 } 44 else 45 { 46 break; 47 } 48 } 49 sqList[i] = temp;//将temp值放到最终的位置 50 } 51 52 /// <summary> 53 /// 交换元素 54 /// </summary> 55 /// <param name="arr"></param> 56 /// <param name="a"></param> 57 /// <param name="b"></param> 58 public static void swap(SeqList<int> sqList, int a, int b) 59 { 60 int temp = sqList[a]; 61 sqList[a] = sqList[b]; 62 sqList[b] = temp; 63 }
(4):总结
堆排序是一种选择排序,整体主要由构建初始堆+交换堆顶元素和末尾元素并重建堆两部分组成。其中构建初始堆经推导复杂度为O(n),在交换并重建堆的过程中,需交换n-1次,而重建堆的过程中,根据完全二叉树的性质,[log2(n-1),log2(n-2)...1]逐步递减,近似为nlogn。所以堆排序时间复杂度一般认为就是O(nlogn)级。
1.5 归并排序
归并排序(MERGE-SORT)是利用归并的思想实现的排序方法,该算法采用经典的分治(divide-and-conquer)策略(分治法将问题分(divide)成一些小的问题然后递归求解,而治(conquer)的阶段则将分的阶段得到的各答案"修补"在一起,即分而治之)。
可以看到这种结构很像一棵完全二叉树,本文的归并排序我们采用递归去实现(也可采用迭代的方式去实现)。分阶段可以理解为就是递归拆分子序列的过程,递归深度为log2n。
再来看看治阶段,我们需要将两个已经有序的子序列合并成一个有序序列,比如上图中的最后一次合并,要将[4,5,7,8]和[1,2,3,6]两个已经有序的子序列,合并为最终序列[1,2,3,4,5,6,7,8],来看下实现步骤。
c# 算法实现
1 /// <summary> 2 /// 归并排序 3 /// </summary> 4 /// <param name="sqList"></param> 5 /// <param name="left"></param> 6 /// <param name="right"></param> 7 /// <param name="temp"></param> 8 /// <returns></returns> 9 public SeqList<int> MergeSort(SeqList<int> sqList, int left, int right, int[] temp) 10 { 11 if (left < right) 12 { 13 int mid = (left + right) / 2; 14 MergeSort(sqList, left, mid, temp);//左边归并排序,使得左子序列有序 15 MergeSort(sqList, mid + 1, right, temp);//右边归并排序,使得右子序列有序 16 merge(sqList, left, mid, right, temp);//将两个有序子数组合并操作 17 } 18 19 return sqList; 20 } 21 22 /// <summary> 23 /// 合并 24 /// </summary> 25 /// <param name="arr"></param> 26 /// <param name="left"></param> 27 /// <param name="mid"></param> 28 /// <param name="right"></param> 29 /// <param name="temp"></param> 30 private static void merge(SeqList<int> sqList, int left, int mid, int right, int[] temp) 31 { 32 int i = left;//左序列指针 33 int j = mid + 1;//右序列指针 34 int t = 0;//临时数组指针 35 while (i <= mid && j <= right) 36 { 37 if (sqList[i] <= sqList[j]) 38 { 39 temp[t++] = sqList[i++]; 40 } 41 else 42 { 43 temp[t++] = sqList[j++]; 44 } 45 } 46 while (i <= mid) 47 {//将左边剩余元素填充进temp中 48 temp[t++] = sqList[i++]; 49 } 50 while (j <= right) 51 {//将右序列剩余元素填充进temp中 52 temp[t++] = sqList[j++]; 53 } 54 t = 0; 55 //将temp中的元素全部拷贝到原数组中 56 while (left <= right) 57 { 58 sqList[left++] = temp[t++]; 59 } 60 }
归并排序是稳定排序,它也是一种十分高效的排序,能利用完全二叉树特性的排序一般性能都不会太差。java中Arrays.sort()采用了一种名为TimSort的排序算法,就是归并排序的优化版本。从上文的图中可看出,每次合并操作的平均时间复杂度为O(n),而完全二叉树的深度为log2n。总的平均时间复杂度为O(nlogn)。而且,归并排序的最好,最坏,平均时间复杂度均为O(nlogn)。
1.6 各种排序方法的比较与讨论
排序在计算机程序设计中非常重要,上面介绍的各种排序方法各有优缺点, 适用的场合也各不相同。在选择排序方法时应考虑的因素有:
(2)记录本身除关键码外的其它信息量的大小;
(3)关键码的情况;
(4)对排序稳定性的要求;
(5 )语言工具的条件,辅助空间的大小等。
综合考虑以上因素,可以得出如下结论:
总结:
本章主要介绍了常用的内部排序方法,包括三种简单排序方法,即直接插入排序、冒泡排序和简单选择排序,这三种排序方法在最好情况下的时间复杂度为O(n),在平均情况下和最坏情况下的时间复杂度都为O(n2),并且都是稳定的排序方法。
快速排序方法的平均性能最好,时间复杂度为O(nlog2n),所以,当待排序序列已经按关键码随机分布时,快速排序是最适合的。但快速排序在最坏情况下的时间复杂度是O(n2)。快速排序方法是不稳定的排序方法。
堆排序方法在最好情况下、平均情况下和最坏情况下的时间复杂度不会发生变化,为O(nlog2n),并且所需的辅助空间少于快速排序方法。堆排序方法也是不稳定的排序方法。
归并排序方法在最好情况下、平均情况下和最坏情况下的时间复杂度不会发生变化,为O(nlog2n),但需要的辅助空间大于堆排序方法,但归并排序方法是稳定的排序方法。
以上排序方法都是通过记录关键码的比较和记录的移动来进行排序排序方法是一种借助 于多关键码排序的思想,是将单关键码按基数分成多关键码 进行排序的方法。
一般情况下,排序都采用顺序存储结构(基数排序方法除外),而当记录非常多时可以采用链式存储结构,但快速排序和堆排序却很难在链表上实现。