前言
相信学过数据结构的大家都不陌生排序算法,这种算法初学觉得抽象,难以理解,今天笔者将从算法思想,代码实现,时间复杂度的分析等角度带您深度了解排序算法的奥妙。可能您能说出很多种算法,冒泡排序,快排等。但无法用自己的语言熟练写出一个完整的算法,读完此文,相信能够对您的数据结构知识体系构建有所帮助。
一、排序算法是什么?
所谓排序,就是使一串记录,按照其中的某个或某些关键字的大小,递增或递减的排列起来的操作。排序算法,就是如何使得记录按照要求排列的方法。。
二、排序算法的思想(本文所有排序默认升序)
1.冒泡排序
这可能使我们代码生涯中遇见的第一种排序算法,冒泡排序是一种简单的排序算法。它重复地走访过要排序的数列,一次比较两个元素,如果它们的顺序错误就把它们交换过来。走访数列的工作是重复地进行直到没有再需要交换,也就是说该数列已经排序完成。这个算法的名字由来是因为越小的元素会经由交换慢慢“浮”到数列的顶端。
1.1算法步骤
- 比较相邻的元素。如果第一个比第二个大,就交换它们两个;
- 对每一对相邻元素作同样的工作,从开始第一对到结尾的最后一对,这样在最后的元素应该会是最大的数;
- 针对所有的元素重复以上的步骤,除了最后一个;
- 重复步骤1~3,直到排序完成。
代码实现如下
void BubbleSort(int* a, int n)//最容易理解的,但是理解了就能融会贯通 { for (int i = 0; i < n; i++) { for (int j = 0; j < n - i-1; j++) { if (a[j] > a[j + 1]) { int tmp = a[j]; a[j] = a[j + 1]; a[j + 1] = tmp; } } } } void Swap(int* a, int* b)//交换两个变量一定要使用传址调用,不然改变不了实参 { int tmp = *a; *a = *b; *b = tmp; } void BubbleSort(int* a, int n)//n为数字个数 { for (int j = 0; j < n; j++) //第一种方式 { for (int i = 1; i < n - j; i++) { if (a[i - 1] > a[i])//防止越界 { Swap(&a[i], &a[i - 1]); } } } int end = n; //第二种方式 while (end > 0)//用end来控制每趟排多少次 { for (int i = 1; i < n; i++) { if (a[i - 1] > a[i]) { Swap(&a[i], &a[i - 1]); } } } }
2.选择排序
概念:
选择排序是一种简单直观的排序算法,无论什么数据进去都是 O(n²) 的时间复杂度。所以用到它的时候,数据规模越小越好。唯一的好处可能就是不占用额外的内存空间了吧。
2.1算法步骤
首先在未排序序列中找到最小(大)元素,存放到排序序列的起始位置。
再从剩余未排序元素中继续寻找最小(大)元素,然后放到已排序序列的末尾。
重复第二步,直到所有元素均排序完毕。
void Swap(int* a, int* b) { int tmp = *a; *a = *b; *b = tmp; } void SeleteSort(int* a, int n) { int begin = 0; int end = n - 1; while (begin < end)//只要中间有元素就处理 { int mini = begin, maxi = begin; for (int i = begin; i < end; ++i) { if (a[i] < a[mini])//找最小的 { mini = i; } if (a[i] > a[maxi])//找最大的 { maxi = i; } } Swap(&a[begin], &a[mini]); if (begin == maxi)//考虑特殊情况 { maxi = mini; } Swap(&a[end], &a[maxi]); end--; begin++; } }
这里有一种特殊情况,假如第一个数是最大的,一共六个数,第三个数是最小的,我先把小的换到了第一个数,再把大的换到数列尾部的时候其实换的是最小数。这里我们对他进行处理,让最大值取的是交换后最小值的下标。
eg: 9 8 3 1 5 6
找大下标是0,找小下标是3,先交换了a[0]和a[3],原数组就变成了 1 8 3 9 5 6
再放大的就变成了1 8 3 9 5 1
3.插入排序
概念:有一个已经有序的数据序列,要求在这个已经排好的数据序列中插入一个数,但要求插入后此数据序列仍然有序,这是不是很像斗地主里面的发牌,每发一张牌我们就会把他按一定的顺序排列整齐。
3.1 算法步骤
概念:
将第一待排序序列第一个元素看做一个有序序列,把第二个元素到最后一个元素当成是未排序序列。
从头到尾依次扫描未排序序列,将扫描到的每个元素插入有序序列的适当位置。(如果待插入的元素与有序序列中的某个元素相等,则将待插入元素插入到相等元素的后面。)
void InsertSort(int *a,int n) { //假设[0,end]有序,end+1位置的值插入进去,让[0,end+1]有序 for (int i = 0; i <n-1; ++i)//控制end的终止 { int end=i; int tmp = a[end+1];//防止覆盖 while (end >= 0) { if (a[end] > tmp) { a[end + 1] = a[end]; --end; } else { break; } } a[end + 1] = tmp;//无论是while循环结束还是比所有数都小,都这样处理(tmp>=end,或者tmp比前面都小) } }
tips:解释一下else以及后面的一点内容
想像如果第一个数到第n-1个数都比要插入的数据来的小,end将持续一直往前减,但不会造成越界,因为,我只是用end来控制排序的终止,并不进行访问,无论是循环终止或者是在已经有序的数字中插入一个最大数,我们都对前面的数不进行处理。
4.希尔排序
概念:
希尔排序,也称递减增量排序算法,是插入排序的一种更高效的改进版本。但希尔排序是非稳定排序算法。
希尔排序是基于插入排序的以下两点性质而提出改进方法的:
- 插入排序在对几乎已经排好序的数据操作时,效率高,即可以达到线性排序的效率;
- 但插入排序一般来说是低效的,因为插入排序每次只能将数据移动一位;
希尔排序的基本思想是:先将整个待排序的记录序列分割成为若干子序列分别进行直接插入排序,待整个序列中的记录"基本有序"时,再对全体记录进行依次直接插入排序。
4.1算法步骤
选择一个增量序列 t1,t2,……,tk,其中 ti > tj, tk = 1;
按增量序列个数 k,对序列进行 k 趟排序;
每趟排序,根据对应的增量 ti,将待排序列分割成若干长度为 m 的子序列,分别对各子表进行直接插入排序。仅增量因子为 1 时,整个序列作为一个表来处理,表长度即为整个序列的长度。
相当于两步 预排序+排序 相较于插入排序多了一次预排序
这里我们定义一个gap来表示按照多少进行分组,有点类似于python里面的切片
我们不妨一想,插入排序在数组接近于有序的情况下是效率很好的
如果这个时候gap越大,也就意味着越大的数在预排序中将越快移到最后,
同样的gap越小,也就意味着最大的数更慢落到最后,但是数字会更接近于有序,
综上所述我们使用如下方式书写代码。
void ShellSort(int *a,int n)//特殊的插入排序 { //gap给多少呢 int gap = n; while (gap > 1)//log2或者3,每次除以2或者3 { gap /= 3+1; //这里防止gap为0,和下面那种取其一就可以 gap /= 2; //gap>1时是预排序 //gap==1时是插入排序 for (int i = 0; i < n - gap; i++) //gap很大时O(N),gap很小,接近有序,差不多也是O(N) { int end = i; int tmp = a[end + gap]; while (end >= 0) { if (a[end] > tmp) { a[end + gap] = a[end]; end -= gap; } else { break; } } a[end + gap] = tmp; } }
5.堆排序(要求学过二叉树基础)
概念:
堆排序(Heapsort)是指利用堆这种数据结构所设计的一种排序算法。堆积是一个近似完全二叉树的结构,并同时满足堆积的性质:即子结点的键值或索引总是小于(或者大于)它的父节点。堆排序可以说是一种利用堆的概念来排序的选择排序。分为两种方法:
- 大顶堆:每个节点的值都大于或等于其子节点的值,在堆排序算法中用于升序排列;
- 小顶堆:每个节点的值都小于或等于其子节点的值,在堆排序算法中用于降序排列;
堆排序的平均时间复杂度为 Ο(nlogn)。
5.1算法步骤
- 创建一个堆 H[0……n-1];
- 使用向下调整算法:从根节点开始,选出左右孩子中小的一个,跟父亲比较,比父亲小就交换,然后继续往下调。
- 这里列出父亲和孩子的关系
leftchild=parent*2+1
rightchild=parent*2+2
parent=(child-1)/2
tips:向下调整算法的前提是该二叉树的子树是有序的。
void AdjustDown(int* a, int n, int root) { int parent = root; int child = parent * 2 + 1;//默认指向左孩子 while (child < n) { if (child + 1 < n && a[child + 1] > a[child])//防止极端越界 { child += 1; } if (a[child] > a[parent]) { Swap(&a[child], &a[parent]); parent = child; child = parent * 2 + 1; } else { break; } } }
首先我们要先建堆,我们可以对某个完全二叉树(数组想象成完全二叉树)的除叶子结点进行向下调整算法,这样就可以建堆成功了。
还有一个问题,如果我们要排升序要建大堆还是建小堆呢,很多人第一反应是建小堆,这样第一个元素就是排好的,但是我们也要想清楚一个问题,如果建的是小堆,后面的数据是否都为无序的,我们需要再次建堆,建堆的时间复杂度是O(N),这样就不值得了.
举例说明:如果这里建小堆 0 3 1 4 5 6 2 9 7 8
拿掉最小的数之后,3 1 4 5 6 2 9 7 8只能再次建堆去获取次小的数,如果第二个数做跟,其他数的关系全乱了,只能重新建堆,综合这个原因,我们采用建大堆的方式,虽然破坏了一定的结构,但是能维持大堆,每次排好最大的数,再进行一次向下调整算法即可。
void AdjustDown(int* a, int n, int root) { int parent = root; int child = parent * 2 + 1;//默认指向左孩子 while (child < n) { if (child + 1 < n && a[child + 1] > a[child])//防止极端越界 { child += 1; } if (a[child] > a[parent]) { Swap(&a[child], &a[parent]); parent = child; child = parent * 2 + 1; } else { break; } } } void HeapSort(int* a, int n)//1.建大堆 1.2对非叶子结点向下调整 2. { for (int i = (n - 1 - 1) / 2; i >= 0; --i) { //升序建大堆 O(N),错位相减法,用节点数*最多调整次数 2^0*(h-1) AdjustDown(a, n, i); } int end = n - 1; while (end > 0) { Swap(&a[0], &a[end]); AdjustDown(a, end, 0);//原来是n个数现在是n-1 --end; } }
5.归并排序
概念:
归并排序(Merge sort)是建立在归并操作上的一种有效的排序算法。该算法是采用分治法(Divide and Conquer)的一个非常典型的应用。
作为一种典型的分而治之思想的算法应用,归并排序的实现由两种方法:
- 自上而下的递归(所有递归的方法都可以用迭代重写,所以就有了第 2 种方法);
- 自下而上的迭代;
这里我们可以采用递归的方式实现,或者非递归的方式实现。
因为递归有一个致命的缺点,由于栈的空间非常小,在递归过深层的时候会产生栈溢出,程序崩溃,这里给一个简单的例子,不妨跑一下10000试试体会一下。
//递归实现1+到n int f(int n) { return n < 1 ? 1 : f(n - 1) + n; }
动图不能理解可以通过这一张静态图来理解。
void _MergeSort(int* a, int left, int right,int*tmp) { if (left <= right) { return; } int mid = (left + right) >> 2; //假设[left,mid] [mid+1,right]有序,就可以归并了 _MergeSort(a, left, mid, tmp); _MergeSort(a, mid+1, right, tmp); //归并 int begin1 = left, end1 = mid; int begin2 = mid + 1, end2 = right; int index = left; while (begin1 <= end1 && begin2 <= end2)//while 是继续条件 { if (a[begin1] < a[begin2]) { tmp[index++] = a[begin1++]; } else { tmp[index++] = a[begin2++]; } } while (begin1 <= end1)//看谁没结束 { tmp[index++] = a[begin1++]; } while (begin2 <= end2) { tmp[index++] = a[begin2++]; } //拷贝回去 for (int i = left; i <= right; i++) { a[i] = tmp[i]; } void MergeSort(int* a, int n) { int* tmp = (int*)malloc(sizeof(int) * n); _MergeSort(a, 0, n - 1, tmp); free(tmp); }
5.2非递归的归并排序
下回讲解,情况非常复杂
void MergeSortNonR(int* a, int n) { int* tmp = (int*)malloc(sizeof(int) * n); int gap = 1;//每组数据的个数 while (gap < n) { for (int i = 0; i < n; i += 2 * gap) { //[i,i+gap-1][i+gap,i+2*gap-1] int begin1 = i, end1 = i + gap - 1; int begin2 = i + gap, end2 = i + 2 * gap - 1; //分情况 //归并过程中,右半区间可能不存在 if (begin2 >= n) { break; } //右半区间存在,算多了,修正一下 if (end2 >= n) { end2 = n-1; } //左半区间可能也不够 int index = i; while (begin1 <= end1 && begin2 <= end2)//while 是继续条件 { if (a[begin1] < a[begin2]) { tmp[index++] = a[begin1++]; } else { tmp[index++] = a[begin2++]; } } while (begin1 <= end1)//看谁没结束 { tmp[index++] = a[begin1++]; } while (begin2 <= end2) { tmp[index++] = a[begin2++]; } //拷贝回去 for (int j = i; j <= end2; j++) { a[j] = tmp[j]; } gap *= 2; } }
6.快速排序
快速排序使用分治法(Divide and conquer)策略来把一个串行(list)分为两个子串行(sub-lists)。
快速排序又是一种分而治之思想在排序算法上的典型应用。本质上来看,快速排序应该算是在冒泡排序基础上的递归分治法。
算法最差情况是O(n^2),在数组已经有序的情况下呈现,平均为O(N*logN)
6.1 算法步骤
- 从数列中挑出一个元素,称为 "基准"(pivot);
- 重新排序数列,所有元素比基准值小的摆放在基准前面,所有元素比基准值大的摆在基准的后面(相同的数可以到任一边)。在这个分区退出之后,该基准就处于数列的中间位置。这个称为分区(partition)操作;
- 递归地(recursive)把小于基准值元素的子数列和大于基准值元素的子数列排序;
一共有大约三种单趟实现方式,挖坑法,左右指针法,前后指针法。
挖坑法就是假设第一个值是坑(基准值),我们在后面找比它小的值交换,原来小的值的地方就是新的坑,依次递归下去。
左右指针法和他的思路类似。
前后指针法 :前后指针详解
挖坑法
int PartSort1(int* a, int left, int right) { if (left >= right) { return; } //int index = GetMidIndex(a, left, right); //Swap(&a[index], &a[left]); int begin = left; int end = right - 1; int pivot = begin; int key = a[begin]; while (begin < end) { //右边找小的放左边 while (begin < end && a[end] >= key) { --end; } //小的放左边坑里面 a[pivot] = a[end]; pivot = end;//形成新的坑 while (begin < end && a[begin] <= key) { ++begin; } a[pivot] = a[begin]; pivot = begin;//新的坑 } pivot = begin; a[pivot] = key; return pivot; }
左右指针法(不建议使用,坑较多)
int PartSort2(int* a, int left, int right) { int index = GetMidIndex(a, left, right); Swap(&a[index], &a[left]); int begin = left; int end = right; int keyi = begin; while (begin < end) { //找小 while (begin < end && a[end] >= a[keyi]) //如果后面不是>=和<=就会发生死循环 例如512……5……589 { --end; } //找大 while (begin < end && a[begin] <= a[keyi]) { ++begin; } Swap(&a[begin], &a[end]); } Swap(&a[begin], &a[keyi]); return begin; }
前后指针法
int PartSort3(int* a, int left, int right)//前后指针法 { //cur prev //cur去找小 找到就停下来然后++prev再交换,最后把prev和keyi交换 int index = GetMidIndex(a, left, right); Swap(&a[index], &a[left]); int begin = left; int end = right; int keyi = left; int prev = left; int cur = left + 1; while (cur <= right) { /*if (a[cur] < a[keyi]) { ++prev;*///优化 if(a[cur] < a[keyi] && ++prev!=cur) { Swap(&a[prev], &a[cur]); } ++cur; } Swap(&a[keyi], &a[prev]); return prev; }
快排的优化1
考虑到快排在遇到两种排好序的 数组会退化成O(N*N),我们给到一个三数取中算法进行优化,让这种情况的可能性降低。
int GetMidIndex(int* a, int left, int right) { int mid = (left+right) >> 1; if (a[left] < a[mid]) { if (a[mid] < a[right]) { return mid; } else if (a[left]>a[right]) { return left; } else { return right; } } else //a[left]>a[mid] { if (a[mid] > a[right]) { return mid; } else if (a[left] < a[right]) { return left; } else { return right; } } }
快排的优化2
考虑到快排在递归层次较深时会栈溢出,这里给出小区间优化算法,降低了故障率。
//数据过大,对最下层调用做一定优化 (优化不大,大概几毫秒),小区间优化要根据数据量 if (pivot - 1 - left > 10) { QuickSort(a, left, pivot - 1); } else { InsertSort(a + left, pivot - 1 - left + 1); } if (right-(pivot+1)>10) { QuickSort(a, pivot + 1, right); } else { InsertSort(a + pivot + 1, right - pivot-1+1); }
非递归实现(挖坑快排,归并)
非递归快排(借用栈的结构和接口)
void QuickSortNonR(int* a,int n) { ST st; StackInit(&st); StackPush(&st, n - 1);//后进先出,先出左要先入右 StackPush(&st, 0); while (!StackEmpty(&st)) { int left = StackTop(&st); StackPop(&st); int right = StackTop(&st); StackPop(&st); int keyIndex = PartSort1(a,left,right); //[left,keyindex-1] keyindex [keyindex+1,right] if (keyIndex + 1 < right) { StackPush(&st, right); //实际上只是把下标压栈进去 第二趟和第三趟就类似于队列,从左到右 StackPush(&st, keyIndex + 1);//实际上目前对于现在的计算机来说,栈帧那点消耗微不足道,主要是怕栈溢出 } if (left < keyIndex - 1) { StackPush(&st, keyIndex-1); StackPush(&st, left); } } StackDestory(&st); }
非递归归并排序
void MergeSortNonR(int* a, int n) { int* tmp = (int*)malloc(sizeof(int) * n); int gap = 1;//每组数据的个数 while (gap < n) { for (int i = 0; i < n; i += 2 * gap) { //[i,i+gap-1][i+gap,i+2*gap-1] int begin1 = i, end1 = i + gap - 1; int begin2 = i + gap, end2 = i + 2 * gap - 1; //分情况 //归并过程中,右半区间可能不存在 if (begin2 >= n) { break; } //右半区间存在,算多了,修正一下 if (end2 >= n) { end2 = n-1; } //左半区间可能也不够 int index = i; while (begin1 <= end1 && begin2 <= end2)//while 是继续条件 { if (a[begin1] < a[begin2]) { tmp[index++] = a[begin1++]; } else { tmp[index++] = a[begin2++]; } } while (begin1 <= end1)//看谁没结束 { tmp[index++] = a[begin1++]; } while (begin2 <= end2) { tmp[index++] = a[begin2++]; } //拷贝回去 for (int j = i; j <= end2; j++) { a[j] = tmp[j]; } gap *= 2; } } }//如果不是2468这样的,比如说九个数字,就会产生越界 //左区间存在,右区间不存在,不需要归并
总结
本文详细讲解了数据结构的结构基本算法,希望热心的各位能及时提醒笔者所发生的错误,让笔者及时改正,总结不易,最后能不能给个免费的三连呢,这对我很重要。