白话数据结构系列文章目录
基本概念篇
1. 入门概述
2. 复杂度
3. 数组&链表
4. 栈&堆
5. 排序算法
6. 查找算法
编程思想篇
实际问题篇
1. 约瑟夫环
一、前言
在介绍完一些基本的数据结构后,接下来介绍一些常见的算法,例如排序、查找、递归等。
本节先介绍一下数据结构里应该说是所有人(包括我这种半途而废的人)都会看的排序部分,我会按照不同的时间复杂度进行介绍,分为O(n2),O(nlogn),O(n)三种。
二、前置条件
C语言基础
三、本文参考资料
大话数据结构
数据结构与算法之美
百度
四、正文部分
4.1 排序概念
4.1.1 概念
排序是计算机内经常进行的一种操作,其目的是将一组“无序”的记录序列调整为“有序”的记录序列。
排序分为内部排序和外部排序。
若整个排序过程不需要访问外存便能完成,则称此类排序问题为内部排序。
反之,若参加排序的记录数量很大,整个序列的排序过程不可能在内存中完成,则称此类排序问题为外部排序。
4.1.2 平均时间复杂度
平均时间复杂度就是加权平均期望时间复杂度
对于包含 n 个数据的数组,这 n 个数据就有 n! 种排列方式。不同的排列方式,排序算法执行的时间肯定是不同的。
如果用概率论方法定量分析平均时间复杂度,涉及的数学推理和计算就会很复杂。
有序度是数组中具有有序关系的元素对的个数。
有序元素对用数学表达式表示就是这样:
- 有序元素对:a[i] <= a[j], 如果i < j。(有序指的是从小到大排序)
同理,对于一个倒序排列的数组,比如 6,5,4,3,2,1,有序度是 0;
对于一个完全有序的数组,比如 1,2,3,4,5,6,有序度就是 n*(n-1)/2,也就是 15。即满有序度
- 排列组合
从N个元素中任意抽取2个元素,其形成的就是一个有序对(因为当前数组是一个完全有序数序),
所以根据排列组合公式可以得出C(n,2)=C(n,n-2)=n!/2!(n-2)!=n(n-1)(n-2)!/2(n-2)!=n(n-1)/2- 排列:
从n个不同元素中,任取m个不同的元素按照一定的顺序排成一列,叫做从n个不同元素中取出m个元素的一个排列
从n个不同元素中取出m个元素的所有排列的个数,叫做从n个不同元素中取出m个元素的排列数,用符号A(n,m)表示。
排队问题甲乙两人排队,先排甲,那么站法是甲乙,先排乙,那么站法乙甲,是两种不同的排法,和先排还是后排的顺序有关,所以是A(2,2)=2种
- 组合
从n个不同元素中,任取m个元素并成一组,叫做从n个不同元素中取出m个元素的一个组合;
从n个不同元素中取出m个元素的所有组合的个数,叫做从n个不同元素中取出m个元素的组合数。用符号 C(n,m) 表示。
从甲乙两个球中选2个,无论先取甲,在是先取乙,取到的两个球都是甲和乙两个球,和先后取的顺序无关,所以是C(2,2)=1种
- 排列:
逆序度的定义正好跟有序度相反(默认从小到大为有序)
- 逆序元素对:a[i] > a[j], 如果i < j。
逆序度 = 满有序度 - 有序度
我们排序的过程就是一种增加有序度,减少逆序度的过程,最后达到满有序度,就说明排序完成了。
- 要排序的数组的初始状态是 4,5,6,3,2,1 ,
- 其中,有序元素对有 (4,5) (4,6)(5,6),所以有序度是 3。
- n=6,所以排序完成之后终态的满有序度为 n*(n-1)/2=15。
4.1.3 算法性能
算法的性能可以通过时间复杂度来分析,但是,这种复杂度分析是比较偏理论的,如果我们深究的话,实际上时间复杂度并不等于代码实际的运行时间。
O(n2) 时间复杂度的算法并不一定比 O(nlogn) 的算法执行时间长。
时间复杂度代表的是一个增长趋势,如果画成增长曲线图,你会发现 O(n2)比 O(nlogn) 要陡峭,也就是说增长趋势要更猛一些。
但是,我们前面讲过,在大 O 复杂度表示法中,我们会省略低阶、系数和常数,
也就是说,O(nlogn) 在没有省略低阶、系数、常数之前可能是 O(knlogn + c),而且 k 和 c 有可能还是一个比较大的数。
假设 k=1000,c=200,当我们对小规模数据(比如 n=100)排序时,n2的值实际上比 knlogn+c 还要小。
knlogn+c = 1000 * 100 * log100 + 200 远大于10000
n^2 = 100*100 = 10000
4.2 O(n2)
4.2.1 冒泡排序法
4.2.1.1 知识点
-
操作步骤
冒泡排序只会操作相邻的两个数据。
每次冒泡操作都会对相邻的两个元素进行比较,看是否满足大小关系要求。 如果不满足就让它俩互换。
一次冒泡会让至少一个元素移动到它应该在的位置,重复 n 次,就完成了 n 个数据的排序工作。 -
核心思想:
第一次冒泡就可以得到最小值 / 最大值的位置应该放什么数字
每次比较仅为了确定一个位置可以摆放什么数字,而非一个数字应该摆在什么位置 -
结束条件:
直到把最小的那一个放到最后一位(从大到小排序)
当一次完整冒泡所有元素就不发生交换时,则可以认为所有元素均以有序 -
稳定:
在冒泡排序中,只有交换才可以改变两个元素的前后顺序。
–> 稳定的排序算法 -
交换次数:
冒泡排序包含两个操作原子,比较和交换。
每交换一次,有序度就加 1。
不管算法怎么改进,交换次数总是确定的,即为逆序度,也就是n*(n-1)/2–初始有序度。此例中就是 15–3=12,要进行 12 次交换操作。 -
时间复杂度:
对于包含 n 个数据的数组进行冒泡排序,平均交换次数是多少呢?
最坏情况下,初始状态的有序度是 0,所以要进行 n*(n-1)/2 次交换。
最好情况下,初始状态的有序度是 n*(n-1)/2,就不需要进行交换。
取中间值 n*(n-1)/4,来表示初始有序度既不是很高也不是很低的平均情况。换句话说,平均情况下,需要 n*(n-1)/4 次交换操作,比较操作肯定要比交换操作多,而复杂度的上限是 O(n2),所以平均情况下的时间复杂度就是 O(n2)。
冒泡排序不管怎么优化,元素交换的次数是一个固定值,是原始数据的逆序度。
4.2.1.2 代码实现
// 冒泡排序,a表示数组,n表示数组大小
public void bubbleSort(int[] a, int n) {
if (n <= 1) return;
for (int i = 0; i < n; ++i) {
// 提前退出冒泡循环的标志位
boolean flag = false;
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;
flag = true; // 表示有数据交换
}
}
if (!flag) break; // 没有数据交换,提前退出
}
}
4.2.2 插入排序法
一个有序的数组,我们往里面添加一个新的数据后,只要遍历数组,找到数据应该插入的位置将其插入即可继续保持数据有序。
4.2.2.1 知识点
-
核心思想:
取未排序区间中的元素,在已排序区间中找到合适的插入位置将其插入,并保证已排序区间数据一直有序。
说是插入,实质上是不断相邻交换,将数值交换至正确位置 -
步骤:
首先,我们将数组中的数据分为两个区间,已排序区间和未排序区间。
初始已排序区间只有一个元素,就是数组的第一个元素。
重复这个过程,直到未排序区间中元素为空,算法结束。
-
操作次数
插入排序也包含两种操作,一种是元素的比较,一种是元素的移动。
对于不同的查找插入点方法(从头到尾、从尾到头),元素的比较次数是有区别的。
但对于一个给定的初始序列,移动操作的次数总是固定的,就等于逆序度。
插入排序是同样的,不管怎么优化,元素移动的次数也等于原始数据的逆序度。 -
时间复杂度
-
如果我们从尾到头在有序数据组里面查找插入位置,每次只需要比较一个数据就能确定插入的位置。所以这种情况下,最好是时间复杂度为 O(n)。注意,这里是从尾到头遍历已经有序的数据。
-
如果数组是倒序的,每次插入都相当于在数组的第一个位置插入新的数据,所以需要移动大量的数据,所以最坏情况时间复杂度为 O(n2)。
-
所以,对于插入排序来说,每次插入操作都相当于在数组中插入一个数据,复杂度为O(n),循环执行 n 次插入操作,所以平均时间复杂度为O(n2)。
-
4.2.2.2 代码实现
// 插入排序,a表示数组,n表示数组大小
public void insertionSort(int[] a, int n)
{
if (n <= 1) return;
for (int i = 1; i < n; ++i) {
int value = a[i];
// 每次j都会进行更新
int j = i - 1;
// 查找插入的位置
for (; j >= 0; --j) {
if (a[j] > value) {
a[j+1] = a[j]; // 数据移动
} else {
break; // 相信之前均有序
}
}
a[j+1] = value; // 插入数据
}
}
4.2.3 选择排序
4.2.3.1 知识点
-
步骤
选择排序每次会从未排序区间中找到最小的元素,将其放到已排序区间的末尾。 -
核心思想
交换 + 比较
每次将一个坑里摆入剩余未排序区最小的元素,每次排序是针对每个坑而言,不断填坑
-
稳定度
选择排序是一种不稳定的排序算法。
选择排序每次都要找剩余未排序元素中的最小值,并和前面的元素交换位置,这样破坏了稳定性。
比如 5,8,5,2,9 这样一组数据,使用选择排序算法来排序的话,第一次找到最小元素 2,与第一个 5 交换位置,那第一个 5 和中间的 5 顺序就变了,所以就不稳定了。
正是因此,相对于冒泡排序和插入排序,选择排序就稍微逊色了。
4.2.3.2 相关代码
void print(int a[], int n ,int i){
cout<<"第"<<i+1 <<"趟 : ";
for(int j= 0; j<8; j++){
cout<<a[j] <<" ";
}
cout<<endl;
}
/**
* 数组的最小值
*
* @return int 数组的键值
*/
int SelectMinKey(int a[], int n, int i)
{
int k = i;
for(int j=i+1 ;j< n; ++j) {
if(a[k] > a[j]) k = j;
}
return k;
}
/**
* 选择排序
*
*/
void selectSort(int a[], int n){
int key, tmp;
for(int i = 0; i< n; ++i) {
key = SelectMinKey(a, n,i); //选择最小的元素
if(key != i){
tmp = a[i]; a[i] = a[key]; a[key] = tmp; //最小元素与第i位置元素互换
}
print(a, n , i);
}
}
int main(){
int a[8] = {3,1,5,7,2,4,9,6};
cout<<"初始值:";
for(int j= 0; j<8; j++){
cout<<a[j] <<" ";
}
cout<<endl<<endl;
selectSort(a, 8);
print(a,8,8);
}
4.2.4 希尔排序
4.2.4.1 知识点
希尔排序,也称递减增量排序算法,是插入排序的一种更高效的改进版本。
-
核心思想
- 基本有序:小的关键字基本在前面,大的基本在后面,不大不小的基本在中间,如{2,1,3,6,4,7,5,8,9}
- 采取跳跃分割的策略:将相距某个“增量”的记录组成一个子序列,这样才能保证在子序列内分别进行直接插入排序后,得到的结果是基本有序而不是局部有序
- 先将整个待排序的记录序列分割成为若干子序列分别进行直接插入排序,待整个序列中的记录“基本有序”时,再对全体记录进行依次直接插入排序。
-
算法操作:
因为插入排序每次只能操作一个元素,效率低。元素个数N,取奇数k=N/2,将下标差值为k的数分为一组(一组元素个数看总元素个数决定),在组内构成有序序列,再取k=k/2,将下标差值为k的数分为一组,构成有序序列,直到k=1,然后再进行直接插入排序。 -
算法描述:
- 初始化增量为数组长度
- 循环遍历直到增量长度等于1
取增量长度为当前增量长度 / 3 + 1(该算式目前仍未得出最好)
inc = inc / 3 + 1;
将当前增量 + 1作为i开始,遍历之后的所有数组
若当前数组元素L.r[i]小于增量差值数组元素L.r[i - inc]
将L.r[i]暂存到L.r[0]
记录后移,查找插入的位置
遍历j = i - inc,每次减去一个增量,直到j小等于0,或是L.r[0]大等于L.r[j]
(for (j = i - inc; j > 0 && L->r[0] < L->r[j]; j -= inc))
将L.r[j]赋值给L.r[j + inc]
最后将L.r[0]赋值给L.r[j + inc]
-
稳定性:
非稳定算法
4.2.4.2 相关代码
void print(int a[], int n ,int i){
cout<<i <<":";
for(int j= 0; j<8; j++){
cout<<a[j] <<" ";
}
cout<<endl;
}
/**
* 直接插入排序的一般形式
*
* @param int dk 缩小增量,如果是直接插入排序,dk=1
*
*/
void ShellInsertSort(int a[], int n, int dk)
{
for(int i= dk; i<n; ++i){
if(a[i] < a[i-dk]){ //若第i个元素大于i-1元素,直接插入。小于的话,移动有序表后插入
int j = i-dk;
int x = a[i]; //复制为哨兵,即存储待排序元素
a[i] = a[i-dk]; //首先后移一个元素
while(x < a[j]){ //查找在有序表的插入位置
a[j+dk] = a[j];
j -= dk; //元素后移
}
a[j+dk] = x; //插入到正确位置
}
print(a, n,i );
}
}
// 先按增量d(n/2,n为要排序数的个数进行希尔排序
void shellSort(int a[], int n){
int dk = n/2;
while( dk >= 1 ){
ShellInsertSort(a, n, dk);
dk = dk/2;
}
}
int main(){
int a[8] = {3,1,5,7,2,4,9,6};
//ShellInsertSort(a,8,1); //直接插入排序
shellSort(a,8); //希尔插入排序
print(a,8,8);
}
4.3 O(nlogn)
归并排序的处理过程是由下到上的,先处理子问题,然后再合并。
而快排正好相反,它的处理过程是由上到下的,先分区,然后再处理子问题。
归并排序虽然是稳定的、时间复杂度为 O(nlogn) 的排序算法,但是它是非原地排序算法。主要原因是合并函数无法在原地执行。
快速排序通过设计巧妙的原地分区函数,可以实现原地排序,解决了归并排序占用太多内存的问题。
4.3.1 归并排序
如果要排序一个数组,我们先把数组从中间分成前后两部分,然后对前后两部分分别排序,再将排好序的两部分合并在一起,这样整个数组就都有序了。
4.3.1.1 知识点
归并排序(Merge sort,台湾译作:合并排序)是建立在归并操作上的一种有效的排序算法。该算法是采用分治法(Divide and Conquer)的一个非常典型的应用。
-
分治思想
归并排序使用的就是分治思想。
分治,顾名思义,就是分而治之,将一个大问题分解成小的子问题来解决。小的子问题解决了,大问题也就解决了。
分治是一种解决问题的处理思想,递归是一种编程技巧,这两者并不冲突。 -
稳定度
在合并的过程中,如果 A[p…q]和 A[q+1…r]之间有值相同的元素,可以先把 A[p…q]中的元素放入 tmp 数组。
这样就保证了值相同的元素,在合并前后的先后顺序不变。
所以,归并排序是一个稳定的排序算法。 -
时间复杂度
T(a) = T(b) + T© + K
其中 K 等于将两个子问题 b、c 的结果合并成问题 a 的结果所消耗的时间。假设对 n 个元素进行归并排序需要的时间是 T(n),那分解成两个子数组排序的时间都是 T(n/2)。
merge() 函数合并两个有序子数组的时间复杂度是 O(n)。所以,套用前面的公式,归并排序的时间复杂度的计算公式就是:
T(1) = C; n=1时,只需要常量级的执行时间,所以表示为C。 T(n) = 2*T(n/2) + n; n>1
merge() 函数合并两个有序子数组的时间复杂度是 O(n)。
不断将n拆解为n/2,最终可以得到k=log2n,代入得到T(n)=Cn+nlog2n.T(n) = 2*T(n/2) + n = 2*(2*T(n/4) + n/2) + n = 4*T(n/4) + 2*n = 4*(2*T(n/8) + n/4) + 2*n = 8*T(n/8) + 3*n = 8*(2*T(n/16) + n/8) + 3*n = 16*T(n/16) + 4*n ...... = 2^k * T(n/2^k) + k * n T(n/2^k)=T(1) --> n/2^k=1 --> k=log2n
如果我们用大 O 标记法来表示的话,T(n) 就等于 O(nlogn)
归并排序的执行效率与要排序的原始数组的有序程度无关,所以其时间复杂度是非常稳定的,不管是最好情况、最坏情况,还是平均情况,时间复杂度都是 O(nlogn)。
-
空间复杂度
在任意时刻,CPU 只会有一个函数在执行,也就只会有一个临时的内存空间在使用。
临时内存空间最大也不会超过 n 个数据的大小,所以空间复杂度是 O(n)。
4.3.1.2 相关代码
- 算法步骤
- 申请空间,使其大小为两个已经排序序列之和,该空间用来存放合并后的序列
- 设定两个指针,最初位置分别为两个已经排序序列的起始位置
- 比较两个指针所指向的元素,选择相对小的元素放入到合并空间,并移动指针到下一位置
- 重复步骤3直到某一指针达到序列尾
- 将另一序列剩下的所有元素直接复制到合并序列尾
-
总体代码
- 递推公式:merge_sort(p…r) = merge(merge_sort(p…q), merge_sort(q+1…r))
- 终止条件:p >= r 不用再继续分解,其中下标 q 等于 p 和 r 的中间位置,也就是 (p+r)/2
/* 归并排序算法, A是数组,n表示数组大小 */ merge_sort(A, n) { merge_sort_c(A, 0, n-1) //从第0个元素开始,到第n-1个元素,共n个元素 } /* 递归调用函数 */ merge_sort_c(A, p, r) { /* 递归终止条件 */ // 因为递归的过程是逐级分裂的过程; // 具体可描述为:一个大区间A,拆分为两个左右小区间z1,y2;之后再对z1(左),y2(右), 进行各自拆分的过程; // 直至z1(左)或y2无法拆分(只剩下一个元素,无法形成区间),也就是P >= R. // 什么情况下会大于?处理右半分支的时候,取半q就有可能比p小 if p >= r then return /* 取p到r之间的中间位置q */ q = (p+r) / 2 /* 分治递归 */ merge_sort_c(A, p, q) merge_sort_c(A, q+1, r) /* 将A[p...q]和A[q+1...r]合并为A[p...r] */ /* 当上面的return被触发时,merge第一个执行的是两个元素的合并排序,接着是四个元素的合并排序 */ merge(A[p...r], A[p...q], A[q+1...r]) }
-
merges实现
如图所示,我们申请一个临时数组 tmp,大小与 A[p…r]相同。
我们用两个游标 i 和 j,分别指向 A[p…q]和 A[q+1…r]的第一个元素。
比较这两个元素 A[i]和 A[j],如果 A[i]<=A[j],我们就把 A[i]放入到临时数组 tmp,并且 i 后移一位,否则将 A[j]放入到数组 tmp,j 后移一位。
继续上述比较过程,直到其中一个子数组中的所有数据都放入临时数组中,再把另一个数组中的数据依次加入到临时数组的末尾(因为此时剩余之前排过,必定有序),
这个时候,临时数组中存储的就是两个子数组合并之后的结果了。
最后再把临时数组 tmp 中的数据拷贝到原数组 A[p…r]中。merge(A[p...r], A[p...q], A[q+1...r]) { var i := p,j := q+1,k := 0 // 初始化变量i, j, k var tmp := new array[0...r-p] // 申请一个大小跟A[p...r]一样的临时数组 while i<=q AND j<=r do { if A[i] <= A[j] { tmp[k++] = A[i++] // i++等于i:=i+1 } else { tmp[k++] = A[j++] } } // 判断哪个子数组中有剩余的数据 var start := i,end := q if j<=r then start := j, end:=r // 将剩余的数据拷贝到临时数组tmp while start <= end do { tmp[k++] = A[start++] } // 将tmp中的数组拷贝回A[p...r] for i:=0 to r-p do { A[p+i] = tmp[i] } }
4.3.2 快速排序
4.3.2.1 知识点
-
思想
如果要排序数组中下标从 p 到 r 之间的一组数据,我们选择 p 到 r 之间的任意一个数据作为 pivot(分区点)。
我们遍历 p 到 r 之间的数据,将小于 pivot 的放到左边,将大于 pivot 的放到右边,将 pivot 放到中间。
经过这一步骤之后,数组 p 到 r 之间的数据就被分成了三个部分,前面 p 到 q-1 之间都是小于 pivot 的,中间是 pivot,后面的 q+1 到 r 之间是大于 pivot 的。根据分治、递归的处理思想,我们可以用递归排序下标从 p 到 q-1 之间的数据和下标从 q+1 到 r 之间的数据,直到区间缩小为 1,就说明所有的数据都有序了。
-
稳定性:
因为分区的过程涉及交换操作,如果数组中有两个相同的元素,比如序列 6,8,7,6,3,5,9,4,
在经过第一次分区操作之后,两个 6 的相对先后顺序就会改变。
所以,快速排序并不是一个稳定的排序算法。 -
时间复杂度:
快排也是用递归来实现的。
对于递归代码的时间复杂度,我前面总结的公式,这里也还是适用的。-
最好情况时间复杂度:
如果每次分区操作,都能正好把数组分成大小接近相等的两个小区间,那快排的时间复杂度递推求解公式跟归并是相同的。
所以,快排的时间复杂度也是 O(nlogn)。 -
最坏情况时间复杂度:
但是,公式成立的前提是每次分区操作,我们选择的 pivot 都很合适,正好能将大区间对等地一分为二。
但实际上这种情况是很难实现的。
如果数组中的数据原来已经是有序的了,比如 1,3,5,6,8。
如果我们每次选择最后一个元素作为 pivot,那每次分区得到的两个区间都是不均等的。
我们需要进行大约 n 次分区操作,才能完成快排的整个过程。
每次分区我们平均要扫描大约 n/2 个元素,这种情况下,快排的时间复杂度就从 O(nlogn) 退化成了 O(n2) 。 -
平均情况时间复杂度:
假设每次分区操作都将区间分成大小为 9:1 的两个小区间。我们继续套用递归时间复杂度的递推公式,就会变成这样:
T(1) = C; n=1时,只需要常量级的执行时间,所以表示为C。
T(n) = T(n/10) + T(9*n/10) + n; n>1 -
事实上,快速排序通常明显比其他Ο(n log n) 算法更快,因为它的内部循环(inner loop)可以在大部分的架构上很有效率地被实现出来。
-
4.3.2.2 相关代码
递推公式:
quick_sort(p…r) = quick_sort(p…q-1) + quick_sort(q+1… r)
–> 子集排序完已有序,合并时不必再次排序
终止条件:
p >= r
// 快速排序,A是数组,n表示数组的大小
quick_sort(A, n) {
quick_sort_c(A, 0, n-1)
}
// 快速排序递归函数,p,r为下标
quick_sort_c(A, p, r) {
if p >= r then return
q = partition(A, p, r) // 获取分区点,并根据分区点进行分区
quick_sort_c(A, p, q-1)
quick_sort_c(A, q+1, r)
}
partition() 分区函数就是随机选择一个元素作为 pivot(一般情况下,可以选择 p 到 r 区间的最后一个元素),然后对 A[p…r]分区,函数返回 pivot 的下标。
如果我们不考虑空间消耗的话,partition() 分区函数可以写得非常简单。
我们申请两个临时数组 X 和 Y,遍历 A[p…r],
将小于 pivot 的元素都拷贝到临时数组 X,将大于 pivot 的元素都拷贝到临时数组 Y,最后再将数组 X 和数组 Y 中数据顺序拷贝到 A[p…r]。
如果我们希望快排是原地排序算法,那它的空间复杂度得是 O(1),那 partition() 分区函数就不能占用太多额外的内存空间,我们就需要在 A[p…r]的原地完成分区操作。
partition(A, p, r) {
pivot := A[r]
i := p
for j := p to r-1 do { //每次判断都伴随j++
if A[j] < pivot { //小于则i都++
swap A[i] with A[j]
i := i+1
}
}
/* 所有都挪完后,最终对调 */
swap A[i] with A[r]
/* 返回i此时的下标 */
return i
}
我们通过游标 i 把 A[p…r-1]分成两部分。
A[p…i-1]的元素都是小于 pivot 的,我们暂且叫它“已处理区间”,A[i…r-1]是“未处理区间”。
我们每次都从未处理的区间 A[i…r-1]中取一个元素 A[j],与 pivot 对比,如果小于 pivot,则将其加入到已处理区间的尾部,也就是 A[i]的位置。
数组的插入操作还记得吗?在数组某个位置插入元素,需要搬移数据,非常耗时。
当时我们也讲了一种处理技巧,就是交换,在 O(1) 的时间复杂度内完成插入操作。
这里我们也借助这个思想,只需要将 A[i]与 A[j]交换,就可以在 O(1) 时间复杂度内将 A[j]放到下标为 i 的位置。
4.3.2.3 快排优化
为什么最坏情况下快速排序的时间复杂度是 O(n2) 呢?
如果数据原来就是有序的或者接近有序的,每次分区点都选择最后一个数据,那快速排序算法就会变得非常糟糕,时间复杂度就会退化为 O(n2) 。
实际上,这种 O(n2) 时间复杂度出现的主要原因还是因为我们分区点选得不够合理。
最理想的分区点是:被分区点分开的两个分区中,数据的数量差不多。
-
三数取中法
我们从区间的首、尾、中间,分别取出一个数,然后对比大小,取这 3 个数的中间值作为分区点。
这样每间隔某个固定的长度,取数据出来比较,将中间值作为分区点的分区算法,肯定要比单纯取某一个数据更好。
但是,如果要排序的数组比较大,那“三数取中”可能就不够了,可能要“五数取中”或者“十数取中”。 -
随机法
随机法就是每次从要排序的区间中,随机选择一个元素作为分区点。
这种方法并不能保证每次分区点都选的比较好,但是从概率的角度来看,也不大可能会出现每次分区点都选得很差的情况,所以平均情况下,这样选的分区点是比较好的。
时间复杂度退化为最糟糕的 O(n2) 的情况,出现的可能性不大。
为了避免快速排序里,递归过深而堆栈过小,导致堆栈溢出
第一种是限制递归深度。一旦递归过深,超过了我们事先设定的阈值,就停止递归。
第二种是通过在堆上模拟实现一个函数调用栈,手动模拟递归压栈、出栈的过程,这样就没有了系统栈大小的限制。
4.4 O(n)
线性排序
这三个算法是非基于比较的排序算法,都不涉及元素之间的比较操作。
4.4.1 桶排序
-
核心思想
将要排序的数据分到几个有序的桶里,每个桶里的数据再单独进行排序。桶内排完序之后,再把每个桶里的数据按照顺序依次取出,组成的序列就是有序的了。
如果要排序的数据有 n 个,我们把它们均匀地划分到 m 个桶内,每个桶里就有 k=n/m 个元素。
每个桶内部使用快速排序,时间复杂度为 O(k * logk)。m 个桶排序的时间复杂度就是 O(m * k * logk),因为 k=n/m,所以整个桶排序的时间复杂度就是 O(n*log(n/m))。
当桶的个数 m 接近数据个数 n 时,log(n/m) 就是一个非常小的常量,这个时候桶排序的时间复杂度接近 O(n)。 -
使用条件
- 首先,要排序的数据需要很容易就能划分成 m 个桶,并且,桶与桶之间有着天然的大小顺序。
这样每个桶内的数据都排序完之后,桶与桶之间的数据不需要再进行排序。 - 其次,数据在各个桶之间的分布是比较均匀的。
如果数据经过桶的划分之后,有些桶里的数据非常多,有些非常少,很不平均,那桶内数据排序的时间复杂度就不是常量级了。
在极端情况下,如果数据都被划分到一个桶里,那就退化为 O(nlogn) 的排序算法了。
- 首先,要排序的数据需要很容易就能划分成 m 个桶,并且,桶与桶之间有着天然的大小顺序。
-
外部排序
桶排序比较适合用在外部排序中。所谓的外部排序就是数据存储在外部磁盘中,数据量比较大,内存有限,无法将数据全部加载到内存中。比如说我们有 10GB 的订单数据,我们希望按订单金额(假设金额都是正整数)进行排序,但是我们的内存有限,只有几百 MB,没办法一次性把 10GB 的数据都加载到内存中。
这个时候该怎么办呢?我们可以先扫描一遍文件,看订单金额所处的数据范围。
假设经过扫描之后我们得到,订单金额最小是 1 元,最大是 10 万元。
我们将所有订单根据金额划分到 100 个桶里,第一个桶我们存储金额在 1 元到 1000 元之内的订单,第二桶存储金额在 1001 元到 2000 元之内的订单,以此类推。
每一个桶对应一个文件,并且按照金额范围的大小顺序编号命名(00,01,02…99)。理想的情况下,如果订单金额在 1 到 10 万之间均匀分布,那订单会被均匀划分到 100 个文件中,
每个小文件中存储大约 100MB 的订单数据,我们就可以将这 100 个小文件依次放到内存中,用快排来排序。
等所有文件都排好序之后,我们只需要按照文件编号,从小到大依次读取每个小文件中的订单数据,并将其写入到一个文件中,
那这个文件中存储的就是按照金额从小到大排序的订单数据了。订单按照金额在 1 元到 10 万元之间并不一定是均匀分布的 ,所以 10GB 订单数据是无法均匀地被划分到 100 个文件中的。
**有可能某个金额区间的数据特别多,划分之后对应的文件就会很大,没法一次性读入内存。**这又该怎么办呢?针对这些划分之后还是比较大的文件,我们可以继续划分,
比如,订单金额在 1 元到 1000 元之间的比较多,我们就将这个区间继续划分为 10 个小区间,1 元到 100 元,101 元到 200 元,201 元到 300 元…901 元到 1000 元。
如果划分之后,101 元到 200 元之间的订单还是太多,无法一次性读入内存,那就继续再划分,直到所有的文件都能读入内存为止。
4.4.2 计数排序
计数排序其实是桶排序的一种特殊情况
当要排序的 n 个数据,所处的范围并不大的时候,比如最大值是 k,我们就可以把数据划分成 k 个桶。每个桶内的数据值都是相同的,省掉了桶内排序的时间。
桶内的数据都是分数相同的考生,所以并不需要再进行排序。
我们只需要依次扫描每个桶,将桶内的考生依次输出到一个数组中,就实现了 50 万考生的排序。
因为只涉及扫描遍历操作,所以时间复杂度是 O(n)。
跟桶排序非常类似,只是桶的大小粒度不一样。
计数排序只能用在数据范围不大的场景中,如果数据范围 k 比要排序的数据 n 大很多,就不适合用计数排序了。
而且,计数排序只能给非负整数排序,如果要排序的数据是其他类型的,要将其在不改变相对大小的情况下,转化为非负整数。
4.4.3 基数排序
基数排序对要排序的数据是有要求的,需要可以分割出独立的“位”来比较,而且位之间有递进的关系,如果 a 数据的高位比 b 数据大,那剩下的低位就不用比较了。
除此之外,每一位的数据范围不能太大,要可以用线性排序算法来排序,否则,基数排序的时间复杂度就无法做到 O(n) 了。
五、总结
冒泡排序、选择排序,可能就纯粹停留在理论的层面了,学习的目的也只是为了开拓思维,实际开发中应用并不多,但是插入排序还是挺有用的。三种实现代码都非常简单,对于小规模数据的排序,用起来非常高效。但是在大规模数据排序的时候,这个时间复杂度还是稍微有点高,所以更倾向于用时间复杂度为 O(nlogn) 的排序算法。
归并排序和快速排序是两种稍微复杂的排序算法,它们用的都是分治的思想,代码都通过递归来实现,过程非常相似。理解归并排序的重点是理解递推公式和 merge() 合并函数。同理,理解快排的重点也是理解递推公式,还有 partition() 分区函数。归并排序算法是一种在任何情况下时间复杂度都比较稳定的排序算法,这也使它存在致命的缺点,即归并排序不是原地排序算法,空间复杂度比较高,是 O(n)。正因为此,它也没有快排应用广泛。快速排序算法虽然最坏情况下的时间复杂度是 O(n2),但是平均情况下时间复杂度都是 O(nlogn)。不仅如此,快速排序算法时间复杂度退化到 O(n2) 的概率非常小,我们可以通过合理地选择 pivot 来避免这种情况。
桶排序、计数排序、基数排序对要排序的数据都有比较苛刻的要求,应用不是非常广泛。但是如果数据特征比较符合这些排序算法的要求,应用这些算法,会非常高效,线性时间复杂度可以达到 O(n)。桶排序和计数排序的排序思想是非常相似的,都是针对范围不大的数据,将数据划分成不同的桶来实现排序。基数排序要求数据可以划分成高低位,位之间有递进关系。比较两个数,我们只需要比较高位,高位相同的再比较低位。而且每一位的数据范围不能太大,因为基数排序算法需要借助桶排序或者计数排序来完成每一个位的排序工作。