终于迎来了最后一部分(排序)了,整个王卓老师的数据结构就算是一刷完成了,但是也才是数据结构的开始而已,以后继续与诸位共勉 (PS.记得继续守护家人们的健康当然还有你自己的)。用三根美味的烤香肠开始吧。。。
将无序序列排成一个有序序列(由小到大或由大到小)的运算。
如果参加排序的数据结点包含多个数据域,那么排序往往是针对其中某个域而言
存储介质:
比较器个数:
主要操作:
辅助空间:
稳定性:
自然性:
插入排序:直接插入排序、折半插入排序、希尔排序
交换排序:冒泡排序、快速排序
选择排序:简单选择排序、堆排序
归并排序: 2 一路归并排序
基数排序
按排序所需工作量
简单的排序方法: T(n)=O(n2)
基数排序: T(n)=O(d.n)
先进的排序方法: T(n)=O(nlogn)
存储结构 - 以顺序表记录序列
基本操作:有序插入
在有序序列中插入一个元素,保持序列有序,有序长度不断增加。
起初, a[0] 是长度为 1 的子序列。然后,逐一将 a[1]至 a[n-1] 插入到有序子序列中。
在插入 a[i]前,数组 a 的前半段( a[0] ~ a[i-1]) 是有序段,后半段( a[i] ~a[n-1]) 是停留于输入次序的无序段
插入 a[i]使 a[0] ~ a[i-1] 保持有序,也就是要为 a[i] 找到有序位置 j( 0 <= j <= i ),将 a[i] 插入在 a[j] 的位置上。
插入位置:中间,前面,后面
用顺序查找法查找插入位置
另外当我们插入i=6号位置的时候,如果i的值比前面一位(5号位)大,因为前面已经是有序了,所以i一定比前面所有元素都大,那就没有比较再赋值到哨兵位置比较了。
void InsertSort(Sqlist &L) {
int i,j;
for (i=2; i<=L.length; ++i) {
if (L.r[i].key < L.r[i-1].key) { //若需插入元素比前面元素大就不用比较,反之要将L.r[i]插入有序子表排序
L.r[0] = L.r[i]; // 复制为哨兵
for (j=i-1; L.r[0].key<L.r[j].key; --j) {
L.r[j+1] = L.r[j]; //记录后移
}
L.r[j+1] = L.r[0]; //插入到正确位置
}
}
}
实现排序的基本操作有两个:1. "比较"序列中两个关键字的大小;2. "移动"记录。
时间复杂度结论:
原始数据越接近有序,排序速度越快
最坏情况下(输入数据是逆有序的)Tw(n)=O(n2)
平均情况下,耗时差不多是最坏情况的一半 Te(n)=O(n2)
要提高查找速度
减少元素的比较次数
减少元素的移动次数
查找位置时采用折半查找法
void BInsertSort(SqList &L) {
for (i=2; i<=L.length; ++i) { //依次插入第2~n个元素
L.r[0]=L.r[i]; //待插入元素放入哨兵位置
low=1; high=i-1; //二分法查找插入位置
while (low<=high) {
mid=(low+high)/2;
if (L.r[0].key<L.r[mid].key) {
high=mid-1;
} else {
low=mid+1;
}
} // 循环结束,high+1为插入位置
for (j=i-1; j>=high+1; --j;) { //将(high+1)~(i-1)的元素后移,空出(high+1)位置
L.r[j+1]=L.r[j];
}
L.r[high+1]=L.r[0]; //正确位置插入元素i
}
}
折半插入排序的平均性能 > 直接插入排序
它所需要的关键码比较次数与待排序对象序列的初始排列无关,仅依赖于对象个数。在插入第 i 个对象时,需要经过 ⌊ l o g 2 i ⌋ \lfloor log~2~i\rfloor ⌊log 2 i⌋+1 次关键码比较,才能确定它应插入的位置,
折半插入排序的对象移动次数与直接插入排序相同,依赖于对象初始排列
时间复杂度为 O(n2)
空间复杂度为 O(1)
是一种稳定的排序方法
先将整待排记录序列分割成若干子序列,分别进行直接插入排序,待整个序列中的记录“基本有序”时,再对全体记录进行一次直接插入排序。
排序思路:
特点:
算法:
void ShellSort(SqList &L, int dlta[], int t) {
// dlta[t]存放步长dk的值,按增量序列dlta[0..t-1]对顺序表L做希尔排序
for (k=0; k<t; ++k) { //t为多少趟
ShellInsert(L, dlta[k]); //一趟增量为dlta[k]的插入排序
}
}
void ShellInsert(SqList &L, int dk) {
//对顺序表L进行一趟增量为dk的Shell排序,dk为步长因子
for (i=dk+1; i<=L.length; ++i) {
if (L.r[i].key<L.r[i-dk].key) {
L.r[0]=L.r[i];
for (j=i-dk; j>0 && (L.r[0].key<L.r[j].key); j=j-dk) {
L.r[j+dk] = L.r[j];
}
L.r[j+dk] = L.r[0];
}
}
}
希尔排序法是一种不稳定的排序算法。
Note: 如何选择最佳 d 序列,目前尚未解决, 最后一个增量值必须为1,没有除了1之外的公因子,不宜在链式存储结构上实现。
基于简单交换思想:每趟不断将记录两两比较,并按"前小后大"规则交换
总结:
//冒泡排序算法
void Bubble_Sort(SqList &L) {
int m,i; RedType x; // 交换时所需临时空间
for (m=1; m<=n-1; m++) { //总共m=n-1趟
for (i=1; i<=n-m; i++) { //每趟比较n-m趟
if (L.r[i].key > L.r[i+1].key) { //发生逆序,交换位置
x = L.r[i];
L.r[i] = L.r[i+1];
L.r[i+1] = x;
}
}
}
}
优点:每趟结束时,不仅能挤出一个最大值到最后面位置,还能同时部分理顺其他元素。
如何提高效率:一旦某一趟比较时不出现记录交换,说明已排好序了就可以结束本算法。
//冒泡排序算法改进版(添加是否发生交换的标记)
void Bubble_Sort(SqList &L) {
int m,i,flag=1; RedType x; // flag作为是否有交换的标记
for (m=1; (flag=1) && (m<=n-1); m++) { //总共m=n-1趟
flag=0; // 初始化为0,即没有交换
for (i=1; i<=n-m; i++) { //每趟比较n-m趟
if (L.r[i].key > L.r[i+1].key) { //发生逆序,交换位置
flag=1; //发生交换后flag做标记,这样可以进行下一轮,如果没交换则为0,上层趟数循环结束
x = L.r[i];
L.r[i] = L.r[i+1];
L.r[i+1] = x;
}
}
}
}
– 改进的交换排序
选定一个中间数作为参考,所有元素与之比较,小的调到其左边(从左往右放),大的调到其右边(从右往左放)。
(枢轴)中间数:可以是第一个数、最后一个数、最中间一个数、任选一个数等
==> 上面的方法在元素量很大的情况下会非常费空间,因为需要一个额外的表空间来存放数据。
改进:直接利用0号空间存放中间数
void main() {
QSort(L,1,L.length);
}
void QSort(SqList &L, int low, int high) { //对顺序表L快速排序
if (low < high) { //长度>1
pivotloc=Partition(L, low, high); //将L.r[low...high]一分为二,pivotloc为枢轴元素排好序的位置
QSort(L, low, pivotloc-1); //对低子表递归排序
QSort(L, pivotloc+1, high); //对高子表递归排序
}
}
int Partition(SqList &L, int low, int high) {
L.r[0]=L.r[low]; //放置pivot到0号位置
pivotkey=L.r[low].key; //方便比较计算中枢的值
while (low < high) {
while ((low < high)&&(pivotkey <= L.r[high].key)) --high; //先找high位的值放到空的low去
L.r[low]=L.r[high];
while ((low < high)&&(pivotkey >= L.r[low].key)) ++low; //再找low位的值放到空的high去
L.r[high]=L.r[low];
}
L.r[low]=L.r[0]; // 所有找完后low=high,将此位置返回给pivotloc
return low;
}
时间复杂度:可以证明,平均计算时间O(nlog2n)
实验结果表明:就平均计算时间而言,快速排序是我们所讨论的所有内排序方法中最好的一个.
空间复杂度:快速排序不是原地排序
快速排序不适于对原本有序或基本有序的记录序列进行排序(有序的记录在一次划分之后得到的其中一个子序列的长度会为0,这个时候就退化成没有改进措施的冒泡排序)。
划分元素的选取是影响时间性能的关键
输入数据次序越乱,所选划分元素值的随机性越好,排序速度越快,快速排序不是自然排序方法。
改变划分元素的选取方法,至多只能改变算法平均情况的下的时间性能,无法改变最坏情况下的时间性能。即最坏情况下,快速排序的时间复杂性总是O(n2)
基本思想:在待排序的数据中选出最大(小)的元素放在其最终的位置。
void SeletSort(SqList &L) {
for (i=1; i<L.length; ++i) { //n个元素需执行n-1趟
k=i; RedType x; //临时变量用于位置交换
for (j=i+1; j<=L.length; j++) { //每趟比较n-i次
if (L.r[j].key < L.r[k].key) { //当发现较小值时记录其位置
k=j;
}
}
if (k!=i) { //将找到的位置和第i个元素位置交换
x=L.r[i];
L.r[i]=L.r[k];
L.r[k]=x;
}
}
}
记录移动次数:
最好情况: 0
最坏情况: 3(n-1)
比较次数,无论待排序列处于什么状态,选择排序所需进行的"比较"次数都相同 n(n-1)/2 (比较次数为n-1+n-2+…+1)
简单选择排序是不稳定排序
空间复杂度:需要一个辅助空间O(1)
从堆的定义可以看出,堆实质是满足如下性质的完全二叉树
若在输出堆顶的最小值(最大值)后,使得剩余 n - 1 个元素的序列重又建成一个堆,则得到 n 个元素的次小值(次大值)“如此反复,便能得到一个有序序列,这个过程称之为堆排序。
如何在输出堆顶元素后,调整剩余元素为一个新的堆?
小根堆
a. 堆顶元素之同以堆中最后一个元素替代之,
b. 然后将根结点值与左、右子树的根结点值进行比较,并与其中小者进行交换
c. 重复上述操作,直至叶子结点,将得到新的堆,称这个从堆顶至叶子的调整过程为"筛选"
算法:
void HeapAdjust(elem R[], int s, int m) {
/*已知R[s...m]中记录的关键字除了R[s]之外均满足堆的定义,本函数调整R[s]关键字,是R[s...m]成为一个大根堆*/
rc=R[s]; //将根结点值放在rc中
for (j=2*s; j<=m; j*=2) { //循环开始沿着较大的孩子节点向下筛选
if (j<m && (R[j]<R[j+1])) ++j; //哪个孩子大就选哪个结点,左孩子大就取j,右孩子大就取j+1
if (rc >= R[j]) break; // 当根节点大于孩子结点直接结束for循环(因为其他元素已经满足大根堆定义了)
R[s]=R[j]; //将该大孩子结点值和此时根节点值互换,重新置for循环的j值为下面子树上的孩子结点位置,循环继续直到比较完
s=j;
}
R[s]=rc; //最后将rc(原根节点)放到最终筛选出来的位置s上
}
堆的调整:对一个无序序列反复筛选就可以得到一个堆,即:从一个无序序列建堆的过程就是一个反复"筛选"的过程。
如何由一个无序序列建成一个堆?
单结点的二叉树是堆;
在完全二叉树中所有以叶子结点(序号 i > n/2 )为根的子树是堆。
这样,我们只需依次将以序号为,n/2, n/2-1, …,1 的结点为根的子树调整为堆即可。即:对应由 n 个元素组成的无序序列,"筛选"只需从第n/2 个元素开始。
建立思想:
由于堆实质上是一个线形表,那么我们可以顶序存储一个堆
创建一个小根堆的例:有关键字为 49 , 38 , 65 , 97 , 76 , 1 3 , 27 , 49 的一组记录,将其按关键字调整为一个小根堆。
将初始无序的 R[1]到 R[n]建成小根堆可用以下语句实现,
for (i=n/2; i<=1; i--) {
HeapAdjust(R, i, n);
}
堆排序
堆排序就是利用完全二叉树中父结点与孩子结点之间的内在关系来排序的。
void HeapSort (elem R[]) { //对R[1]到R[n-1]进行堆排序
int i;
for (i=n/2; i>=1; i--) {
HeapAdjust(R, i, n); //创建初始堆
}
for (i=n; i>1; i--) { //进行n-1趟排序
Swap(R[1], R[i]); //根与最后一个元素交换(相当于依次把元素有序从后往前放)
HeapAdjust(R, i, i-1); //对R[1]到R[i-1]重新建堆
}
}
算法性能分析:
初始堆化所需时间不超过 O(n)
排序阶段(不含初始堆化)
堆排序的时间主要耗费在建初始堆和调整建新堆时进行的反复筛选上。堆排序在最坏情况下,其时间复杂度也为 O(nlogn), 这是堆排序的最大优点。无论待排序列中的记录是正序还是逆序排列,都不会使堆排序处于"最好"或"最坏"的状态。
另外,堆排序仅需一个记录大小供交换用的辅助存储空间。
然而堆排序是一种不稳定的排序方法,它不适用于待排序记录个数 n较少的情况,但对于 n 较大的文件还是很有效的。
将两个或两个以上的有序子序列"归并"为一个有序序列,内部排序中通常采用2-路归并排序(每次归并2个元素)
即:将两个位置相邻的有序子序列 R[l…m]和 R[m+1…n]归并为一个有序序列 R[l…n]
整个归并排序仅需 ⌈ l o g 2 n ⌉ \lceil log~2~n \rceil ⌈log 2 n⌉趟(向上取整加一)
设 R[low] - R[mid]
和 R[mid+1] - R[high]
为相邻,归并成一个有序序列R1[low] - R1[high]
若 SR[i].key <= SR[j].key
, 则 TR[k] = RS[i]; k++; i++;
否则, TR[k]=SR[j];k++; j++;
时间效率:O(nlog2n)
空间效率: O(n) 因为需要一个与原始序列同样大小的辅助序列 (R1) 。这正是此算法的缺点。
稳定性:稳定
分配+收集
也叫桶排序或箱排序:设置若干个箱子,将关键字为 k 的记录放入第 k 个箱子,然后在按序号将非空的连接。
数字是有范围的,均由 0 - 9这十个数字组成,则只需设置十个箱子,相继按个、十、百….进行排序.
时间效率:O(k*(n+m))
(n为扔出去元素个数,m为收回桶的个数,每一趟需要做n+m
次(一扔一收),k个关键字,所以总共需要k*(n+m)
)
空间效率O(n+m)
稳定性:稳定
缺点:关键字的范围必须是一定的
按平均的时间性能来分,有三类排序方法:
当待排记录序列按关键顺序有序时,直接插入排序和冒泡排序能达到O(n)时间复杂度;而对于快速排序而言,这是最不好的情况,此时的时间性能退化为 O(n2),因此是应该尽量避免的情况。
简单选择排序、堆排序和归并排序的时间性能不随记录序列中关键字的分布而改变。
指的是排序过程中所需的辅助空间大小
To Be Continued for Next Series, 共勉~