一、排序的基本概念
1、定义:排序就是重新排列数列中的元素,使其称为满足按关键字有序的序列。序列有序在实际应用中是非常重要的。简单举个例子来说,如果你要进行按值查找操作,那么有序的序列可以采取折半查找法,而无序的序列只能从头到尾依次查找(假定我们没有其他排序算法的知识基础)。
2、算法的稳定性:举例说明,若有一个序列是(12 8 12* 1 4),此时我们可以看到表中有两个值为12的元素,为了方便,我们将一个12加上*来做区分。按照从小到大进行排列后,如果得到的序列为(1 4 8 12 12*),也就是说,原来在前面的12经过排序后仍旧在前面,我们说这个排序算法是稳定的。反之,如果排序后序列为(1 4 8 12* 12),我们就说这个排序算法是不稳定的。
3、根据在排序过程中数据元素是否完全在内存中,可将排序算法分成两类:
(1)内部排序:排序过程中数据元素全部都在内存里面。
(2)外部排序:排序过程中,由于内存不足,数据元素不得不在内、外存之间移动的排序。
4、排序算法大分类图示:
典型例题:对任意7个关键字进行基于比较的排序,至少要进行()次关键字之间的;两两比较。
A、13 B、14 C、15 D、6
答案:A。这里用到一个基本公式,对于任意序列进行基于比较的排序,求最少的比较次数应考虑最坏情况。对任意n个关键字排序的比较次数至少为log2(n!) ,对这个值进行向上取整。
将n代入7,则7! = 5040。取对数进行向上取整后得次数为13次。
5、排序算法性能的衡量标准
在考试过程中,我们经常考的是内部排序,因此接下来的排序算法讲解主要也是讲解内部排序算法。
内部排序在排序过程中常见的操作是关键字之间的比较和移动(基数排序不需要比较)。
排序算法的性能通常使用时间复杂度和空间复杂度来衡量。而时间复杂度主要是由比较和移动的次数决定的。
二、插入排序
插入排序算法的基本思想是:每次将一个待排序的记录按其关键字大小插入到已经排好序的子序列中,直到所有的记录都插入完毕。由插入排序思想可以引申出三种重要的排序算法:直接插入排序、折半插入排序和希尔排序。
(1)直接插入排序【带哨兵】
我们要对L(1..n)进行排序,可以将L(1)视为一个已经有序的待排序序列,然后将L(2)~L(n)依次插入到前面已经排好序的序列。直接插入排序的算法代码如下:
void InsertSort(ElemType A[],int n){
//对具有n个元素的数列进行排序
int i = 0; //计数器
int j = 0; //计数器
for(i = 2;i <= n;i++){ //依次将A[2]~A[n]插入前面已经排好序的序列
if(A[i]<A[i-1] // 若A[i]关键字小于其前驱,将A[i]插入有序表
A[0] = A[i]; //A[0]处放哨兵
for(j = i-1;A[0]<A[j];j--) //从后往前查找待插入位置
A[j+1] = A[j];
A[j+1] = A[0]; //将哨兵处的值复制到插入位置
}
}
下面我们举一组数据来进行直接插入排序示例:L(2,5,1,7,3) 。
直接排序算法的性能分析如下:
空间复杂度:O(1)。仅仅使用了常数个辅助单元。
时间复杂度:O(n*n)。分析过程略。
稳定性:稳定。所举示例没有体现,可以自行举例,手动模拟一遍算法。
适用性:适用于顺序存储和链式存储的线性表。
(2)折半插入排序
折半插入排序是基于直接插入排序进行改进的,它改进的点是使用折半算法来查找元素要进行插入的位置。由于折半算法只使用于顺序存储的线性表,因此折半插入排序也只适用于顺序存储的线性表。折半插入排序的实现代码如下:
void Insert_Bin_Sort(ElemType A[],int n){
//使用折半插入排序对有n个元素的数列进行排序
int i,j = 0; //计数器
for(i = 2;i <= n;i++){
A[0] = A[i];
int low = 1;
int high = i-1; //设置折半查找的范围
while(low <= high){
int mid = (low+high)/2;
if(A[mid] > A[0])
high = mid-1; //在左字表查找
else
low = mid+1; //查找右边字表
}
for(j = i-1;A[0]<A[j];j--) //后移元素找出插入位置
A[j+1] = A[j];
A[j+1] = A[0];
}
}
时间复杂度:仍然为O(n*n)。空间复杂度:O(1)。
稳定性:稳定。
折半插入排序算法和直接插入排序算法没什么太大的不同。所以重点还是在与理解直接插入排序,建议是自己举例手动模拟一遍排序过程。
(3)希尔排序
基本思想:先将排序表分成形如L(i,i+d,i+2d...i+nd)的子表,即把相隔某个“增量”的记录组成一个子表,对各个子表分别进行直接插入排序,然后当表基本有序时,再对全表进行一次直接插入排序。算法实现代码如下:
co
以表L(49,38,65,97,76,13,27,49*,55,04 )为例进行一次希尔排序。排序过程如下:
代码这里不作为重点要求,主要是能进行手动模拟。
性能分析:
空间复杂度:O(1)。
时间复杂度:O(n*n)。
稳定性:不稳定。从以上示例可以清楚地看到其不稳定性。
适用性:仅适用于线性表为顺序存储的情况。
经典例题
1、对5个不同的数据元素进行直接插入排序,最多需要进行比较的次数是()
A、8 B、10 C、15 D、25
答案:B。直接插入排序在最坏情况下要做n(n-1)/2次关键字的比较。当n=5时,比较次数为5*4/2 = 10次。
2、对序列{98,36,-9,0,47,23,1,8,10,7}采用希尔排序,以增量为4进行一趟的排序结果为_______。
答案:{10,7,-9,0,47,23,1,8,98,36}
分析过程:
三、交换排序
所谓交换,指的是根据序列中两个关键字序列的比较结果来对换这两个记录在序列中的位置。常用的交换排序算法主要有冒泡排序和快速排序。
(1)冒泡排序
基本思想:从后往前或者从前往后两两比较相邻元素的值,若为逆序,即A[i-1]>A[i],则交换他们,直到序列比较完。我们称完成了一趟冒泡排序,结果是将最小的元素交换到待排序列的第一个位置(或是将最大的元素交换到待排序列的最后一个元素),由于关键字最小的元素犹如“气泡”一般往上漂浮。因此被称为冒泡排序。冒泡排序实现代码如下:
void BubbleSort(ElemType A[],int n){
//从后往前冒泡排序求升序序列
int i,j;//计数
for(i = 0;i < n-1;i++){
flag = false; //判断本趟排序是否发生交换
for(j = n-1;j > i;j--){ //一趟冒泡过程
if(A[j-1]>A[j]){
ElemType temp = A[j-1];
A[j-1] = A[j]; //若为逆序,进行交换
A[j] = temp;
flag = true; //发生了交换,flag置为true;
}
}
if(flag == false)
return; //如果本趟排序没有发生元素交换,说明序列已经有序,退出循环
}
}
举例L(49,23,15,34,6,23*)进行手动模拟:要求从后往前,排序完成后得到升序序列。
性能分析:
空间复杂度:O(1)。
时间复杂度:O(n*n)。
稳定性:稳定。
使用性:顺序存储和链式存储的线性表都适用。
(2)快速排序
思想:快速排序的思想是基于分治的。在待排序表L(1...n)中,任取一个元素作为枢轴元素pivot,通过一趟排序表将序列划分成两个不同的部分,其中L(1...k-1)中的元素全部小于pivot,L(k+1...n)中的元素全部大于等于pivot。pivot的最终位置在L(k)上。这个过程称为一趟快排,然后分别递归地对两个子表重复上述过程,直到每部分只有一个元素或空为止。算法实现的代码如下:
void QuickSort(ElemType A[],int low,int high){
if(low < high){ //递归跳出的条件
//Partition是划分算法,将表L(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++;
A[high] = A[low];//将比枢轴大的元素移动到右端
}
A[low] = pivot;
return low;
}
以表L(7,2,6,8,2*,10)为例进行代码手动模拟:
性能分析:
空间复杂度:因为快排是递归的,所以需要一个递归工作栈来保存每层递归调用的必要信息。因此空间复杂度为O(n)。
时间复杂度:最坏:O(n*n),最优:O(nlog2n)。
快排是所有排序算法中平均性能最优的算法。
稳定性:不稳定。
经典例题:
1、若采用冒泡排序算法对序列(10,14,26,29,41,52)从大到小排序,则需进行几次比较()
A、3 B、10 C、15 D、25
答案:C。可以发现本序列“逆序”,从后往前进行比较,发现每一趟都需要完整比较,总的次数为5+4+3+2+1 = 15次。
2、下列序列中,()可能是执行第一趟快速排序后得到的序列。
A、{68,11,18,69,23,93,73} B、{68,11,69,23,18,93,73}
C、{93,73,68,11,69,23,18} D、{73,11, 69,23, 18,93, 68}
答案:C。
解析:该序列排序完后从大到小序列为{11,18,23,68,69,,73,93},从小到大序列为{93,73,69,68,23,18,11}。观察可知,A、B、D中的元素没有一个在最终位置上。而选项c有93和73两个元素在排序一趟后在自己的位置上,且73将元素分成了两部分,左部分全都大于等于73,右部分全都小于73。观察其他几个序列,均不满足此要求。
(4) 选择排序
基本思想:(1)首先在未排序序列中找到最小(大)元素,存放到排序序列的起始位置。(2)再从剩余未排序元素中继续寻找最小(大)元素,然后放到已排序序列的末尾。(3)重复第二步,直到所有元素均排序完毕。常用的选择排序算法有简单选择排序和堆排序,其中堆排序是考试重点。
(1)直接选择排序
思想:假设排序表为L(1...n),第i趟排序则是从[i...n]中选取关键字最小的元素与L[i]交换。每一趟排序可以确定一个元素的最终位置,这样经过n-1趟就可以使得整个排序表有序。
算法实现代码如下:
void SelectSort(ElemType A[],int n){
//简单选择排序
int i,j = 0; //计数器,初始化为0
for(i = 0;i < n-1;i++){ //总共进行n-1趟排序就可使数列有序
min = i;
for(j = i+1;j < n;j++)
if(A[j] < A[min]) //选择未排序序列中的最小元素
min = j;
if(min != i){ //更新最小元素位置
temp = A[i];
A[i] = A[min];
A[min] = temp;
}
}
}
手动模拟该算法过程如下:
性能分析:
空间复杂度:O(1)。
时间复杂度:O(n*n)。
稳定性:稳定。
(2)堆排序
堆的定义:堆是具有以下性质的完全二叉树:每个结点的值都大于或等于其左右孩子结点的值,称为大顶堆;或者每个结点的值都小于或等于其左右孩子结点的值,称为小顶堆。
如图所示:
堆排序的思想:首先将一个初识序列构建成大顶堆,这时堆顶元素就是序列中的关键字最大的元素。这时输出堆顶元素,并将堆底元素放入堆顶,此时大根堆的性质被破坏,这时需要将这些元素进行调整重新构建成大根堆,然后输出堆顶元素,如此循坏,直到堆中只剩下一个元素时,排序结束。此时我们将得到一个降序序列。
堆排序的关键是将一个无序序列构建成大根堆。
堆排序的算法实现不是考试重点,因此这里重点复习堆排序的手动模拟实现。
首先说明,建堆操作是从下往上进行,而调整堆的操作是从上往下进行。
下面是堆排序的手动模拟实现:
有L(15,9,7,8,9*),要求对其进行堆排序,且 排序之后的结果为升序。
性能分析:
空间复杂度:O(1)。
时间复杂度:O()。建堆时间O(n),每次调整时间复杂度为O(h)。
稳定性:不稳定。(在我们的例子中是稳定的,这是因为在比较过程中刻意先选择了不带*的数字9,如果在先选择不带*的数字9,那么它将呈现出不稳定的性质,大家可以自己手动模拟一下。)
典型例题:
已知序列25, 13, 10, 12, 9 是大根堆,在序列尾部插入新元素 18,将其再调整为大根堆,调整过程中元素之间进行的比较次数是( )。
A、5 B、1 C、2 D、4
答案:C。这是因为大根堆的插入操作如下:先将新结点放在末端,然后对这个新结点进行向上调整操作(每次与它的父节点进行比较)。所以18插入时的过程如下: