并非所有内部排序算法都要基于比较操作,事实上,基数排序就不基于比较。
void insertSort(ElemType A[], int n){
int i, j;
for(i = 2; i <= n; ++i)
{
if(A[i].key < A[i-1].key)
{
A[0] = A[i];
for(j = i-1; A[0].key < A[j]; --j)
{
A[j+1] = A[j];
}
A[j+1] = A[0];
}//if
}//for
}
虽然折半插入排序算法的时间复杂度也为O(n2),但对于数据量较小的排序表,折半插入排序往往能表现出很好的性能。值得注意的是,大部分排序算法都仅适用于顺序存储的线性表。
void insertSort(ElemType A[], int n){
for(i = 2; i <= n; ++i)
{
A[0] = A[i];
low = 1;
high = i-1;
while(low <= high)
{
mid = (low + high)/2;
if(A[mid].key > A[0].key)
high = mid -1;
else
low = mid + 1;
}
for(j = i - 1; j >= high + 1; --j)
A[j + 1] = A[j];
A[high + 1] = A[0];
}
}
折半插入排序仅减少了比较次数,约为O(nlog2n),该比较次数与待排序的表的初始状态无关,仅取决于表中的元素个数n;而元素的移动次数并未改变,它依赖于待排序表的初始状态。因此,折半插入排序的时间复杂度仍为O(n2)。
折半插入排序是一种稳定的排序方法。
void shellSort(ElemType A[], int n){
//对顺序表做希尔插入排序,本算法和直接插入排序相比,做了以下修改:
//1.前后记录位置的增量是dk,不是1
//2.A[0]只是暂存单元,不是哨兵,当j<=0时,插入位置已到
for(dk = n/2; dk >= 1; dk = dk/2)
{
for(i = dk + 1; i <= n; ++i)
{
if(A[i].key < A[i-dk].key)
{
A[0] = A[i];
for(j = i-dk; j > 0 && A[0].key < A[j].key; j -= dk)
{
/*这个循环体的操作保证了全部序列中相隔dk个元素的位置上的n/dk个元素相对有序*/
A[j + dk] = A[j];
}
A[j + dk] = A[0];
}//if
}
}
}
void bubbleSort(ElemType A[], int n){
//用冒泡排序法将序列A中的元素按从小到大排列
for(i = 0; i < n-1; ++i)
{
flag = false;
for(j = n-1; j > i; --j)
{
if(A[j-1].key > A[j].key)
{
swap(A[j-1], A[j]);
flag = true;
}
}
if(flag == false)
return; //本趟遍历后没有发生交换,说明表已经有序。
}
}
空间效率:仅使用了常数个辅助单元,因而空间复杂度为O(1)。
时间效率:当初始序列有序时,显然第一趟冒泡后flag依然为false(本趟冒泡没有元素交换),从而直接跳出循环,比较次数为n-1,移动次数为0,从而最好情况下的时间复杂度为O(n);当初始序列为逆序时,需要进行n-1趟排序,第i趟排序要进行n-1次关键字的比较,而且每次比较都必须移动元素3次来交换元素位置。
比 较 次 数 = ∑ i = 1 n − 1 ( n − i ) = n ( n − 1 ) 2 , 移 动 次 数 = ∑ i = 1 n − 1 3 ( n − i ) = 3 n ( n − 1 ) 2 比较次数 = \sum_{i=1}^{n-1}(n-i) = \frac{n(n-1)}2, \quad 移动次数 = \sum_{i=1}^{n-1}3(n-i) =\frac{3n(n-1)}2 比较次数=i=1∑n−1(n−i)=2n(n−1),移动次数=i=1∑n−13(n−i)=23n(n−1)
从而,最坏时间复杂度为O(n2),其平均时间复杂度也为O(n2)。
冒泡排序中所产生的有序子序列一定是全局有序的,这样每趟排序都会一个元素放置在其最终的位置上。
void quickSort(ElemType A[], int low, int high){
if(low < high)
{
int pivotpos = partition(A, low, high);
quickSort(A, low, pivotpos - 1);
quickSort(A, pivotpos + 1, high);
}
}
int partition(ElemType A[], int low, int high){
//严蔚敏《数据结构》教材中的划分算法(一趟排序过程)
ElemType pivot = A[low]; //将当前表中第一个元素设为枢轴值,对表进行划分。
while(low < high)
{
while(low < high && A[high] >= pivot) //注意比较的是下标值
{
--high;
}
A[low] = A[high];
while(low < high && A[low] <= pivot) //注意比较的是下标值
{
++low;
}
}//while(low < high)
A[low] = pivot;
return low;
}
在快速排序算法中,并不产生有序子序列,但每趟排序后会将一个元素(基准元素)放到其最终位置上。
void selectSort(ElemType A[], int n){
//对表A做简单选择排序,A[]从0开始存放元素
for(i = 0; i < n-1; ++i)
{
min = i;
for(j = i+1; j < n; ++j)
{
if(A[j] < A[min])
min = j;
}
if(min != i)
swap(A[i], A[min]);
}
}
堆排序是一种树形选择排序方法,其特点是:在排序过程中,将L视为一棵完全二叉树的顺序存储结构,利用完全二叉树中双亲结点和孩子结点之间的内在关系,在当前无序区中选择关键字最大(或最小)的元素。
非叶结点大于左右孩子结点的堆称为大根堆,非叶结点小于左右孩子结点的堆称为小根堆。在大根堆中,最大元素存放在根结点中,小根堆中,最小元素存放在根结点。堆经常被用来实现优先级队列,优先级队列在操作系统的作业调度和其他领域有广泛的应用。
⭐️对初始序列建堆,是一个反复筛选的过程。n个结点的完全二叉树,最后一个非叶结点是第 ⌊ n / 2 ⌋ \lfloor n/2 \rfloor ⌊n/2⌋个结点,对第 ⌊ n / 2 ⌋ \lfloor n/2 \rfloor ⌊n/2⌋个结点为根的子树筛选(对于大根堆,若根节点的关键字小于左右孩子中关键字较大者,则交换),使该子树成为堆。之后向前依次对各结点( ⌊ n / 2 ⌋ \lfloor n/2 \rfloor ⌊n/2⌋-1~1)为根的子树进行筛选,看该结点值是否大于其左右子结点的值,若不大于,则将左右子结点中较大者与之交换,交换后可能会破坏下一级的堆,于是继续采用上述方法构造下一级堆,直到以该结点为根的子树构成堆为止。反复利用上述调整堆的方法建堆,直到根结点。
初始堆的建立并不是逐个添加结点构成堆的结构,而是先排成一个完全二叉树,再调整成为堆。
void buildMaxHeap(ElemType A[], int len){
for(int i = len/2; i > 0; --i)
{
adjustDown(A, i, len);
}
}
void adjustDown(ElemType A[], int k, int len){
//函数adjustDown将元素k向下进行调整
A[0] = A[k];
for(i = 2*k; i <= len; i *= 2)
{
if(i < len && A[i] < A[i+1])
++i;
if(A[0] >= A[i])
{
break;
}
else
{
A[k] = A[i];
k = i;
}
}//for
A[k] = A[0];
}
可以证明再元素个数为n的序列上建堆,其时间复杂度为O(n),这说明可以在线性时间内将一个无序数组中组建成一个大根堆。
由于堆本身的特点(以大根堆为例),堆顶元素就是最大值。输出堆顶元素后,通常将堆底元素送入堆顶,此时根结点已不满足大根堆的性质,堆被破坏,将对顶元素向下调整使其继续保持大根堆的性质,再输出堆顶元素,直至队中仅剩下一个元素为止。
void heapSort(ElemType A[], int len){
buildMaxHeap(A, len); //初始建堆
for(i = len; i > 1; --i)
{
swap(A[i], A[j]);
adjustDown(A, 1, i-1);
}//for
}
//下面是向上调整堆的算法
void adjustUp(ElemType A[], int k){
A[0] = A[k];
int i = k/2;
while(i > 0 && A[i] < A[0])
{
A[k] = A[i];
k = i;
i = k/2;
}//while
A[k] = A[0];
}
ElemType *B = (ElemType *)malloc(sizeof(ElemType) * (n+1)); //辅助数组
void merge(Elemtype A[], int low, int mid, int high){
//表A的两段A[low...mid]和A[mid+1...high]各自有序,将它们合并成一个有序表
for(int k = low; k <= high; ++k)
{
B[k] = A[k];
}
for(i = low; j = mid + 1, k = i; i <= mid && j <= high; ++k)
{
//比较两端元素“开头”的数字,每次将较小的元素放入辅助数组
if(B[i] <= B[j])
{
A[k] = B[i++];
}
else
{
A[k] = B[j++];
}
}//for
//以下两个循环只有一个会执行
while(i <= mid) A[k++] = B[i++];
while(j <= high) A[k++] = B[j++];
}
void mergeSort(ElemType A[], int low, int high){
if(low < high)
{
int mid = (low + high)/2;
mergeSort(A, low, mid);
mergeSort(A, mid+1, high);
merge(A, low, mid, high);
}
}
二路归并排序算法的性能分析如下:
基数排序是一种不基于比较进行排序,而采用多关键字排序思想(即基于关键字各位的大小进行排序),借助“分配”和“收集”两种操作对单逻辑关键字进行排序。基数排序又分为最高位优先(MSD)和最低位优先(LSD)排序。
【注意】
算法种类 | 最好时间复杂度 | 平均时间复杂度 | 最坏时间复杂度 | 空间复杂度 | 稳定性 |
---|---|---|---|---|---|
直接插入排序 | O(n) | O(n2) | O(n2) | O(1) | 稳定 |
折半插入排序 | O(n2) | O(1) | 稳定 | ||
希尔排序 | O(n) | O(n1.3) | O(n2) | O(1) | 不稳定 |
冒泡排序 | O(n) | O(n2) | O(n2) | O(1) | 稳定 |
快速排序 | O(nlog2n) | O(nlog2n) | O(n2) | 平均:O(log2n);最坏:O(n) | 不稳定 |
简单选择排序 | O(n2) | O(n2) | O(n2) | O(1) | 不稳定 |
堆排序 | O(nlog2n) | O(nlog2n) | O(nlog2n) | O(n) | 不稳定 |
归并排序 | O(nlog2n) | O(nlog2n) | O(nlog2n) | O(n) | 稳定 |
基数排序 | O(d(n+r)) | O(d(n+r)) | O(d(n+r)) | O® | 稳定 |