一、直接插入排序
直接插入排序(Insertion Sort)的基本思想是:每次将一个待排序的元素记录,按其关键字大小插入到它前面已经排好序的子序列中的适当位置,直到全部元素插入完成为止。
设需要排序的数组为a[0…n-1]。
1. 初始时,i = 0,a[0]自成1个有序区,无序区为a[1..n-1]。
2. 将a[i]并入当前的有序区a[0…i-1]中形成a[0…i]的有序区间。
3. i++并重复第二步直到 i == n-1,排序完成。
这里,我将a[i]并入当前的有序区时,设置了一个临时变量 tmp 用于交换两个无序元素,这样简化了代码。
1 //直接插入排序 O(N)-O(N^2) 2 void InsertSort(int* a, size_t n) 3 { 4 for (size_t i = 0; i < n - 1; i++) 5 { 6 int end = i; 7 int tmp = a[end + 1]; //临时变量,用于两元素之间的有序化 8 while (end >= 0) { //将a[end+1]并入有序区 9 if (a[end] > a[end + 1]) { 10 a[end + 1] = a[end]; 11 --end; 12 } 13 else 14 break; //若已经有序,终止本次循环 15 a[end + 1] = tmp; 16 } 17 } 18 }
时间复杂度&空间复杂度
如果目标是把n个元素的序列按序排列,那么采用插入排序存在最好情况和最坏情况。最好情况就是,序列已经是升序排列了,在这种情况下,需要进行的比较操作需(n - 1)次即可。最坏情况就是,序列是降序排列,那么此时需要进行的比较共有n(n - 1) / 2次。插入排序的赋值操作是比较操作的次数减去(n - 1)次。平均来说插入排序算法复杂度为O(n²)。因而,插入排序不适合对于数据量比较大的排序应用。但是,如果需要排序的数据量很小,例如,量级小于千,那么插入排序还是一个不错的选择。
这里再介绍另外一个排序算法概念,稳定性:假定在待排序的序列中,存在多个具有相同的关键字的元素,若经过排序,这些元素的相对次序保持不变,即在原序列中,a[i] = a[j],且 a[i] 在 a[j] 之前,而在排序后的序列中,a[i] 仍在 a[j] 之前,则称这种排序算法是稳定的;否则称为不稳定的。在简单插入排序中只有当两个无序的元素不相等时,才会进行交换,所以相等元素的相对位置不会改变。
二、希尔排序
希尔排序(Shell Sort)又称为“缩小增量排序”。该方法的基本思想是:先将整个待排元素序列分割成若干个子序列(由相隔某个“增量”的元素组成的)分别进行直接插入排序,然后依次缩减增量再进行排序,待整个序列中的元素基本有序(增量足够小,例如为1)时,再对全体元素进行一次直接插入排序。因为直接插入排序在元素基本有序的情况下(接近最好情况),效率是很高的,因此希尔排序在时间效率上比前一种方法有较大提高。
基本步骤,在此我们选择增量gap=n/2,缩小增量继续以gap = gap/2的方式,这种增量选择我们可以用一个序列来表示,{n/2,(n/2)/2...1},称为增量序列。希尔排序的增量序列的选择与证明是个数学难题,我选择的这个增量序列是比较常用的,也是希尔建议的增量,称为希尔增量,但其实这个增量序列不是最优的。此处我们做示例使用希尔增量。
希尔排序中,由于相同元素可能被分于不同排序组中,所以它们的相对位置很可能发生变化,如上图中的 25 ,所以希尔排序算法是一种不稳定的算法。
1 //希尔 2 void ShellSort(int *a, size_t n) 3 { 4 int gap = n; 5 while (gap > 1) { 6 gap = gap/3 + 1; //增量逐渐缩小 7 for (size_t i = 0; i < n - gap; i++) { 8 int end = i; 9 int tmp = a[end + gap]; 10 while (end >= 0) { //核心依然是插入排序 11 if (a[end] > a[end + gap]) { 12 a[end + gap] = a[end]; 13 --end; 14 } 15 else 16 break; 17 a[end + gap] = tmp; 18 } 19 } 20 } 21 }
希尔排序在数组中采用跳跃式分组的策略,通过某个增量将数组元素划分为若干组,然后分组进行插入排序,随后逐步缩小增量,继续按组进行插入排序操作,直至增量为1。希尔排序通过这种策略使得整个数组在初始阶段达到从宏观上看基本有序,小的基本在前,大的基本在后。然后缩小增量,到增量为1时,其实多数情况下只需微调即可,不会涉及过多的数据移动。
上面选择的增量序列{n/2,(n/2)/2...1}(希尔增量),其最坏时间复杂度依然为O(n2),一些经过优化的增量序列如代码中的方法经过复杂证明可使得最坏时间复杂度为O(n3/2)。
三、选择排序
选择排序的思想:从所有序列中先找到最小的,然后放到第一个位置。之后再看剩余元素中最小的,放到第二个位置……以此类推,就可以完成整个的排序工作。可以很清楚的发现,选择排序是固定位置,找元素。相比于插入排序的固定元素找位置,是两种思维方式。
1 void select_sort( int *a, int n) 2 { 3 register int i, j, min, t; 4 for( i = 0; i < n - 1; i ++) 5 { 6 min = i; 7 //查找最小值 8 for( j = i + 1; j < n; j ++) 9 if( a[ min] > a[ j]) 10 min = j; 11 //交换 12 if( min != i) 13 { 14 t = a[ min]; 15 a[ min] = a[ i]; 16 a[ i] = t; 17 } 18 } 19 }
基这种思想,我做出了一步优化,同时将无序序列中的最大最小值找出来,放到有序序列的头和尾,简单来说就是从所有序列中先找到最小和最大的的,然后最小的放到第一个位置,最大的放到最后一个位置。之后再看剩余元素中最小的和最大的,放到第二个位置和倒数第二个位置……以此类推,也可以完成整个的排序工作。而且比前一种更快。
1 //选择 2 void SelectSort(int *a, size_t n) 3 { 4 assert(a); 5 size_t left = 0; 6 size_t right = n - 1; 7 while (left < right) { 8 size_t min = left; //最小元素 9 size_t max = right; //最大元素 10 for (size_t i = left; i <= right; i++) { 11 if (a[min] > a[i]) 12 min = i; 13 if (a[max] < a[i]) 14 max = i; 15 } 16 swap(a[min], a[left]); 17 if (max == left)//修正 18 max = min; 19 swap(a[max], a[right]); 20 ++left; 21 --right; 22 } 23 }
从选择排序的思想或者是上面的代码中,我们都不难看出,寻找最小的元素需要一个循环的过程,而排序又是需要一个循环的过程。因此显而易见,这个算法的时间复杂度也是O(n*n)的。这就意味值在n比较小的情况下,算法可以保证一定的速度,当n足够大时,算法的效率会降低。并且随着n的增大,算法的时间增长很快。
传统的简单选择排序,每趟循环只能确定一个元素排序后的定位。我们可以考虑改进为每趟循环确定两个元素(当前趟最大和最小记录)的位置,从而减少排序所需的循环次数。改进后对n个数据进行排序,最多只需进行[n/2]趟循环即可。由以上分析易知选择排序的依然不稳定。
四、堆排序
堆排序的思想(以大顶堆为例):
利用堆顶记录的是最大关键字这一特性,每一轮取堆顶元素放入有序区,就类似选择排序每一轮选择一个最大值放入有序区,可以把堆排序看成是选择排序的改进。
- 将初始待排序关键字序列(R0,R1,R2....Rn)构建成大顶堆,此堆为初始的无序区;
- 将堆顶元素R[0]与最后一个元素R[n]交换,此时得到新的无序区(R0,R1,R2,......Rn-1)和新的有序区(Rn);
- 由于交换后新的堆顶R[0]可能违反堆的性质,因此需要对当前无序区(R0,R1,R2,......Rn-1)调整为新堆。
不断重复此2、3步骤直到有序区的元素个数为n-1,则整个排序过程完成。
1 void AdJustDown(int *a, size_t root, size_t n) 2 { 3 size_t parent = root; 4 size_t child = parent * 2 + 1; 5 while (child < n) { 6 if (child + 1 < n && a[child + 1] > a[child]) { 7 ++child; 8 } 9 if (a[child] > a[parent]) { 10 swap(a[child], a[parent]); 11 parent = child; 12 child = parent * 2 + 1; 13 } 14 else 15 break; 16 } 17 } 18 //堆排 19 void HeapSort(int *a, size_t n) 20 { 21 assert(a); 22 for (int i = (n - 2) / 2; i >= 0; --i) { 23 AdJustDown(a, i, n); 24 } 25 size_t end = n - 1; 26 while (end) { 27 swap(a[0], a[end]); 28 AdJustDown(a, 0, end); 29 --end; 30 } 31 }
由以上分析易知堆排序也不稳定。由二叉树的特点知道它的空间复杂度为O(N*logN)
五、冒泡排序
冒泡排序(Bubble Sort)是一种简单的排序算法。它重复地走访过要排序的数列,一次比较两个元素,如果他们的顺序错误就把他们交换过来。走访数列的工作是重复地进行直到没有再需要交换,也就是说该数列已经排序完成。这个算法的名字由来是因为越小的元素会经由交换慢慢“浮”到数列的顶端。
步骤:
1.比较相邻的元素。如果第一个比第二个大,就交换他们两个。
2.对每一对相邻元素作同样的工作,从开始第一对到结尾的最后一对。在这一点,最后的元素应该会是最大的数。
3.针对所有的元素重复以上的步骤,除了最后一个。
4.持续每次对越来越少的元素重复上面的步骤,直到没有任何一对数字需要比较。
1 //冒泡排序 2 void BubblerSort(int* a, size_t n) 3 { 4 assert(a); 5 size_t j = 0; 6 for (size_t i = 0; i < n - 1; i++){ 7 for (j; j < n - i - 1; j++){ 8 if (a[j] > a[j - 1]) { 9 swap(a[j], a[j - 1]); 10 } 11 } 12 } 13 }
冒泡排序是一种稳定的排序算法,它的最坏情况和平均复杂度是O(n²),甚至连插入排序(O(n²)算法复杂度)效率都比冒泡排序算法更好,唯一的优势在于基于它的改进算法——快速排序算法。
六、快速排序
快速排序是冒泡排序的一种改进,冒泡排序排完一趟是最大值冒出来了,那么可不可以先选定一个值,然后扫描待排序序列,把小于该值的记录和大于该值的记录分成两个单独的序列,然后分别对这两个序列进行上述操作。这就是快速排序,我们把选定的那个值称为枢纽值,如果枢纽值为序列中的最大值,那么一趟快速排序就变成了一趟冒泡排序。
快速排序使用分治法(Divide and conquer)策略来把一个串行(list)分为两个子串行(sub-lists)。
步骤为:(1)从数列中挑出一个元素,称为 "基准"(pivot);
(2)重新排序数列,所有元素比基准值小的摆放在基准前面,所有元素比基准值大的摆在基准的后面(相同的数可以到任一边)。在这个分区退出之后,该基准就处于数列的中间位置。这个称为分区(partition)操作。
(3)递归地(recursive)把小于基准值元素的子数列和大于基准值元素的子数列排序。
当我们每次划分的时候选择的基准数接近于整组数据的最大值或者最小值时,快速排序就会发生最坏的情况,但是每次选择的基准数都接近于最大数或者最小数的概率随着排序元素的增多就会越来越小,我们完全可以忽略这种情况。但是在数组有序的情况下,它也会发生最坏的情况,为了避免这种情况,我们在选择基准数的时候可以采用三数取中法来选择基准数。
三数取中法: 选择这组数据的第一个元素、中间的元素、最后一个元素,这三个元素里面值居中的元素作为基准数。
按照以上思想可以容易实现一下代码:
1 void _QuickSort(int* a, int left, int right) 2 { 3 if (left < right) { 4 if (right - left < 5) //当排序区间较小时采用直接插入排序 5 InsertSort(a + left, right - left + 1); 6 else { 7 //int mid = PartSort1(a, left, right); //一次排序的过程 8 int mid = PartSort2(a, left, right); 9 //int mid = PartSort3(a, left, right); 10 _QuickSort(a, left, mid - 1); //基准值左边进行递归排序 11 _QuickSort(a, mid + 1, right); //基准值右边边进行递归排序 12 } 13 } 14 } 15 //快排 16 void QuickSort(int* a, size_t n) 17 { 18 assert(a); 19 _QuickSort(a, 0, n - 1); 20 }
递归的最底部情形,是数列的大小是零或一,也就是永远都已经被排序好了。虽然一直递归下去,但是这个算法总会退出,因为在每次的迭代(iteration)中,它至少会把一个元素摆到它最后的位置去。小区间优化:当划分的子序列很小的时候(一般认为小于13个元素左右时),我们在使用快速排序对这些小序列排序反而不如直接插入排序高效。因为快速排序对数组进行划分最后就像一颗二叉树一样,当序列小于13个元素时我们再使用快排的话就相当于增加了二叉树的最后几层的结点数目,递归的次数就会骤增。所以我们在当子序列小于13个元素的时候就改用直接插入排序来对这些子序列进行排序。
根据上面的一些思想,我们对快速排序的实现共有如下几种方法:
实现方式一:左右分治法
算法思想:在待排序序列中选择一个数据作为基准值(三数取中法选择基准值),定义两个变量left,right开始分别记录待排序区间的两端的值,左变量向右找比基准值大的数据,找到后停下来,接着右变量向左开始找比基准值小的数据,找到后停下来,交换左右变量所记录的数据,直到两指针相遇,出循环后将左变量值与基准值进行交换,交换成功后比基准值小的数据都在其左边,比基准值大的数据都在基准值的右边,换言之基准值已经处于合适的位置。接着递归对基准值的左区间和右区间进行排序,当区间只有一个数据时,默认已经有序。
1 int MidIndex(int* a, int left, int right)//三数取中法找到区间头和尾及中间位置上中间的值 2 { 3 int mid = left + (right - left) / 2; 4 if (a[mid] < a[right]) { 5 if (a[left] < a[mid]) //a[left]<a[mid]<a[right] 6 return mid; 7 else if (a[left] > a[right]) //a[mid]<a[right]<a[left] 8 return right; 9 else //a[mid]<a[left]<a[right] 10 return left; 11 } 12 else { 13 if(a[left] < a[right]) //a[left]<a[right]<a[mid] 14 return right; 15 else if (a[mid] < a[left]) //a[right]<a[mid]<a[left] 16 return mid; 17 else //a[right]<a[left]<a[mid] 18 return left; 19 } 20 } 21 int PartSort1(int* a, int left, int right) //分治法 22 { 23 int mid = MidIndex(a, left, right); //中间数的下标 24 swap(a[mid], a[right]); //把中间数换到最右边 25 int key = a[right]; //以最右边值为基准 26 int begin = left; //区间最左下标 27 int end = right; //区间最右下标 28 while (begin < end) { //下标不相遇时才进入循环 29 while (begin < end&&a[begin] <= key) 30 ++begin; 31 while (begin < end&&a[end] >= key) 32 --end; 33 if (begin < end) //循环走到这里说明此时a[begin]>key>a[end] 34 swap(a[begin], a[end]); //所以直接将小的换到前面,大的换到后面 35 } 36 //走到这里说明此时begin前面的数都小于或等于key右边的数都大于或等于key 37 swap(a[begin], a[right]); //将基准值放到中间 38 return begin; //返回中间下标 39 }
实现方式二:挖坑法
算法思想:在待排序序列中选择一个数据作为基准值(暂且将区间右端数据作为基准值),首次将坑设在基准值处,定义两个变量left,right开始分别记录待排序区间的两端,左变量向右找比基准值大的数据,找到后将该值填入坑中并且将坑的位置更新在左变量所记录的位置,接着右变量向左开始找比基准值小的数据,找到后将该值填入坑中并且将坑的位置更新在右变量记录的位置,直到两变量相遇,出循环后比基准值小的数据都在其左边,比基准值大的数据都在基准值的右边,换言之基准值已经处于合适的位置。接着递归对基准值的左区间和右区间进行排序,当区间只有一个数据时,默认已经有序。
1 int PartSort2(int* a, int left, int right) //挖坑法 2 { 3 int key = a[right]; //直接确定基准值(有缺陷) 4 while (left < right) { //下标不相遇时才进入循环 5 while (left < right&&a[left] <= key) 6 ++left; 7 //走到这里说明此时a[left] > key 8 a[right] = a[left]; //将比key大的值放到最后面 9 while(left < right&&a[right] >= key) 10 --right; 11 //走到这里说明此时a[right] < key 12 a[left] = a[right]; //将比key小的值放到前面 13 } 14 15 a[left] = key; 16 return left; 17 }
算法实现3:前后指针法
算法思想:在待排序序列中选择一个数据作为基准值(暂且将区间右端数据作为基准值),定义两个临时变量prev和cur,cur首次记录待排序区间的第一个元素,prev指向cur的前一个位置,只要cur没走到区间末尾,就在中间找比基准值小的数据,每找到一次将prev记录的位置下标加一,然后如果二者所指元素不同就将其所指元素交换,直到cur走到区间的最右端退出循环,之后将prev++,将cur和prev所记录的数据进行交换。至此基准值被排序到正确位置,递归其左右区间即可完成整个排序。
1 int PartSort3(int* a, int left, int right) //指针法 2 { 3 int prev = left - 1; // 4 int cur = left; //区间最左下标 5 int key = a[right]; //直接确定区间最右边值基准值(有缺陷) 6 while (cur < right) { //下标小于区间最右下标时进入循环 7 if (a[cur] < key&&++prev != cur) 8 swap(a[prev], a[cur]); //此时必定是a[prev]>key>a[cur],所以让它俩交换 9 ++cur; //a[cur] > key时cur往后移 10 } 11 swap(a[++prev], a[right]); //其实是将本次排序的key值交换给++prev 12 return prev; 13 }
快速排序是一种快速的分而治之的算法,它是已知的最快的排序算法,其平均运行时间为O(N*1ogN) 。它的速度主要归功于一个非长紧凑的并且高度优化的内部循环。但是他也是一种不稳定的排序,当基准数选择的不合理的时候他的效率又会编程O(N*N)。快速排序的最好情况: 快速排序的最好情况是每次都划分后左右子序列的大小都相等,其运行的时间就为O(N*1ogN)。快速排序的最坏情况: 快速排序的最坏的情况就是当分组重复生成一个空序列的时候,这时候其运行时间就变为O(N*N)快速排序的平均情况: 平均情况下是O(N*logN)。
快速排序非递归
思想同递归,只是要借助额外空间来临时存储待排序区间的首尾元素值。
1 //快排非递归 2 void QuickSortNonR(int *a, size_t n) 3 { 4 assert(a); 5 stack<int> mys1; 6 size_t left = 0; 7 size_t right = n - 1; 8 if (left < right) { 9 mys1.push(right); 10 mys1.push(left); 11 } 12 while (!mys1.empty()) { 13 int begin = mys1.top(); 14 mys1.pop(); 15 int end = mys1.top(); 16 mys1.pop(); 17 int div = PartSort1(a, begin, end); 18 if (begin < div - 1) { 19 mys1.push(div - 1); 20 mys1.push(begin); 21 } 22 if (div + 1 < end) { 23 mys1.push(end); 24 mys1.push(div + 1); 25 } 26 } 27 }
七、归并排序
归并排序是建立在归并操作上的一种有效的排序算法。该算法是采用分治法(Divide and Conquer)的一个非常典型的应用。
归并排序基本思想:设两个有序的子序列(相当于输入序列)放在同一序列中相邻的位置上:a[begin1...end1],a[begin2...end2],先将它们合并到一个局部的暂存序列 temp (相当于输出序列)中,待合并完成后将 temp 复制回 a[begin...end]中,从而完成排序。 在具体的合并过程中,设置 begin1,begin2 和 index三个变量,其初值分别记录这三个记录区的起始位置。合并时依次比较 a[begin1] 和 a[begin2] 的关键字,取关键字较小(或较大)的记录复制到 temp[index] 中,然后将被复制记录的变量begin1 或 begin2 加 1,以及指向复制位置的变量 index加 1。重复这一过程直至两个输入的子序列有一个已全部复制完毕(不妨称其为空),此时将另一非空的子序列中剩余记录依次复制到 a中即可。
1 void _MergeSort1(int* a, int* tmp, int begin1, int end1, int begin2, int end2) 2 { 3 int index = begin1; 4 int pos = index; 5 while (begin1 <= end1 && begin2 <= end2) { 6 if (a[begin1] < a[begin2]) 7 tmp[index++] = a[begin1++]; 8 else 9 tmp[index++] = a[begin2++]; 10 } 11 while (begin1 <= end1) 12 tmp[index++] = a[begin1++]; 13 while (begin2 <= end2) 14 tmp[index++] = a[begin2++]; 15 //memcpy(a + pos, tmp + pos, sizeof(int)*(end2 - pos + 1)); 16 } 17 void _MergeSort(int* a, int* tmp, int left, int right) 18 { 19 assert(a); 20 if (left >= right) 21 return; 22 int mid = left + (right - left) / 2; 23 _MergeSort(a, tmp, left, mid); //左边有序 24 _MergeSort(a, tmp, mid + 1, right); //右边有序 25 _MergeSort1(a, tmp, left, mid, mid+1, right); //将二个有序数列合并 26 for (int i = 0; i < (right - left + 1); ++i) 27 { 28 a[left + i] = tmp[i]; //将临时空间的数据拷回原数组 29 } 30 } 31 void MergeSort(int* a, int n) 32 { 33 assert(a); 34 int* tmp = new int[n]; 35 memset(tmp, 0, sizeof(int)*n); 36 _MergeSort(a, tmp, 0, n - 1); 37 delete[] tmp; 38 }
归并排序是一种稳定的排序且效率是比较高的,设数列长为N,将数列分开成小数列一共要logN步,每步都是一个合并有序数列的过程,时间复杂度可以记为O(N),故一共为O(N*logN)。因为归并排序每次都是在相邻的数据中进行操作,所以归并排序在O(N*logN)的几种排序方法(快速排序,归并排序,希尔排序,堆排序)也是效率比较高的。