目录
一、插入排序
基本思想:每次将一个待排序的记录按其关键字大小插入到前面已排好序的子序列中,直到全部记录插入完成。
由插入排序的思想可以引申出三个重要的排序算法:直接插入排序、折半插入排序和希尔排序。
(一)、直接插入排序
基本思想
1)查找出 L(i) 在 L[1...i-1]中的插入位置k;
2)将 L[k ... i - l ]中的所有元素依次后移一个位置;
3 )将 L(i) 复制到 L(k)。
public void insertSort(int[] arr){
for (int i = 1; i < arr.length; i++) { //n-1次插入
if (arr[i]<arr[i-1]){
int temp=arr[i],j = i-1;
for (; j>=0&&temp<arr[j] ; j--) { //查找插入位置,并向后移位
arr[j+1]=arr[j];
}
arr[j+1]=temp;
}
}
}
性能分析
(1)空间效率:而空间复杂度。
(2)时间效率:在排序过程中,向有序子表中逐个地插入元素的操作进行了 n-1 趟,每趟操作都分为比较关键字和移动元素,而比较次数和移动次数取决于待排序表的初始状态。
- 最好时间复杂度:。【正序】
- 最坏时间复杂度:比较次数,移动次数。【逆序】
- 平均时间复杂度:。
(3)稳定性:由于每次插入元素时总是从后向前先比较再移动,所以不会出现相同元素相对位置发生变化的情况,即直接插入排序是一个稳定的排序方法。
(4)适用性:直接插入排序算法适用于顺序存储和链式存储的线性表。为链式存储时,可以从前往后查找指定元素位置。
- 适用于基本有序的排序表和数据量不大的排序表。
(二)、折半插入排序
基本思想
对直接插入排序算法进行改进:将比较和移动分离,先用折半查找被插入元素位置,再统一移动被插入元素位置之后的所有元素。减少了比较元素的次数。
public void insertSort(int[] arr){
for (int i = 1; i < arr.length; i++) { //n-1次插入
int temp=arr[i];
//折半查找
int l=0,r=i-1;
while (l<=r){
int mid=(l+r)/2;
if (arr[mid]>temp){
r=mid-1;
}else {
l=mid+1;
}
}
//统一后移元素,空出插入位置
for (int j=i-1; j>r ; j--) {
arr[j+1]=arr[j];
}
arr[r+1]=temp;
}
}
性能分析
(1)时间效率:
- 比较次数与待排序表的初始状态无关,仅取决于表中的元素个数n;
- 元素的移动次数并未改变,它依赖于待排序表的初始状态。因此,折半插入排序的时间复杂度仍为。
(2)适用性:对于数据量不很大的排序表,折半插入排序往往能表现出很好的性能。
- 仅适合于顺序存储结构。
(3)稳定性:折半插入排序是一种稳定的排序方法。
(三)、希尔排序
直接插入排序若待排序列为“正序”时, 其时间复杂度可提高至,希尔排序正是基于此利用局部有序性改进而得来的,又称缩小增量排序。
基本思想
- 先将待排序表分割成若干形如 L[i,i+d,…,i+kd] 的 “特殊”子表,即把相隔某个“增量”的记录组成一个子表,对各个子表分别进行直接插入排序;
- 当整个表中的元素已呈“基本有序”时,再对全体记录进行一次直接插入排序。
到目前为止,尚未求得一个最好的增量序列, 希尔提出的方法是,并且最后一个增量等于1。
public void shellSort(int[] arr){
int n=arr.length;
//步长变化
for (int d = n/2; d >=1 ; d/=2) {
//对每个子表进行直接插入排序
for (int i = d; i < n ; i++) {
if (arr[i]<arr[i-d]){
int temp=arr[i],j = i-d;
for (; j>=0&&temp<arr[j] ; j-=d) { //查找插入位置,并向后移位
arr[j+d]=arr[j];
}
arr[j+d]=temp;
}
}
}
}
性能分析
(1)空间效率:而空间复杂度。
(2)时间效率:由于希尔排序的时间复杂度依赖于增量序列的函数,这涉及数学上尚未解决的难题,所以其时间复杂度分析比较困难。
- 当n在某个特定范围时,希尔排序的时间复杂度约为。
- 在最坏情况下希尔排序的时间复杂度为 。
(3)稳定性:当相同关键字的记录被划分到不同的子表时,可能会改变它们之间的相对次序,因此希尔排序是一种不稳定的排序方法。
(4)适用性:希尔排序算法仅适用于线性表为顺序存储的情况。
二、交换排序
所谓交换,是指根据序列中两个元素关键字的比较结果来对换这两个记录在序列中的位置。
(一)、冒泡排序
基本思想
【每次将最小/大元素,通过依次交换顺序,放到首/尾位。】
- 从后往前(或从前往后)两两比较相邻元素的值,若为逆序, 则交换它们,直到序列比较完。我们称它为第一趟冒泡,结果是将最小的元素交换到待排序列的第一个位置(或将最大的元素交换到待排序列的最后一个位置);
- 下一趟冒泡时,前一趟确定的最小元素不再参与比较,每趟冒泡的结果是把序列中的最小元素(或最大元素)放到了序列的最终位置。
- ……这样最多做n - 1趟冒泡就能把所有元素排好序。
- 如若有一趟没有元素交换位置,则可提前说明已排好序。
public void bubbleSort(int[] arr){
//n-1 趟冒泡
for (int i = 0; i < arr.length-1; i++) {
boolean flag=false;
//冒泡
for (int j = arr.length-1; j >i ; j--) {
if (arr[j-1]>arr[j]){
swap(arr,j-1,j);
flag=true;
}
}
//本趟遍历后没有发生交换,说明表已经有序
if (!flag){
return;
}
}
}
private void swap(int[] arr,int i,int j){
int temp=arr[i];
arr[i]=arr[j];
arr[j]=temp;
}
性能分析
(1)空间效率:空间复杂度为。
(2)时间效率:
- 最好情况下的时间复杂度为;【顺序序列只需比较n-1次】
- 最坏情况下的时间复杂度为;【逆序序列比较次,移动次】
- 平均时间复杂度为。
(3)稳定性:稳定。
(二)、快速排序
基本思想
【基于分治法,确定枢轴元素位置】
- 在待排序表中任取一个元素 pivot 作为枢轴 (或基准,通常取首元素),
- 通过一趟排序将待排序表划分为独立的两部分和,使 得中的所有元素小于pivot、中的所有元素大于等于则pivot放在了其最终位置上,这个过程称为一趟快速排序(或一次划分)。
- 然后分别递归地对两个子表重复上述过程,直至每部分内只有一个元素或空为止,即所有元素放在了其最终位置上。
public void quickSort(int arr[], int begin, int end) {
if (begin >= end) {
return;
}
//划分为满足上述条件的两个子表,返回基准位置
int pos=partition(arr,begin,end);
//对两个子表进行递归排序
quickSort(arr, begin, pos - 1);
quickSort(arr, pos + 1, end);
}
//划分左右子序列,返回基准位置
private int partition(int[] arr,int i,int j){
int temp = arr[i];
while (i < j) {
//在右边找到小于temp的放到左边
while (i < j && arr[j] > temp) {
j--;
}
arr[i] = arr[j];
//在左边找到大或等于于temp的放到右边
while (i < j && arr[i] <= temp) {
i++;
}
arr[j] = arr[i];
}
//将temp放在剩下的基准位置i、j均可
arr[i] = temp;
return i;
}
性能分析
(1)空间效率:由于快速排序是递归的,需要借助一个递归工作栈来保存每层递归调用的必要信息 ,其容量应与递归调用的最大深度一致。
- 最好情况下,空间复杂度为;
- 最坏情况下,因为要进行 n-1 次递归调用,所以栈的深度为 ;
- 平均情况下,栈的深度为。
(2)时间效率:快速排序的运行时间与划分是否对称有关。快速排序是所有内部排序算法中平均性能最优的排序算法。
- 最坏情况下,时间复杂度为;【两个区域分别包含 n- 1 个 元 素 和 0 个元素,这种最大程度的不对称性发生在每层递归上;即对应于初始排序表基本有序或基本逆序时】
- 最好情况下,时间复杂度为;【每次得到最平衡的划分,得到的两个子问题的大小都不可能大于 n/2 】
- 平均情况下,时间复杂度为;平均情况下的运行时间与其最佳情况下的运行时间很接近。
(3)有很多方法可以提高算法的效率:
- 尽量选取一个可以将数据中分的枢轴元素,如从序列的头尾及中间选取三个元素,再取这三个元素的中间值作为最终的枢轴元素;
- 随机地从当前表中选取枢轴元素,这样做可使得最坏情况在实际排序中几乎不会发生。
(4)稳定性 :在划分算法中,若右端区间有两个关键字相同,且均小于基准值的记录,则在交换到左端区间后,它们的相对位置会发生变化,即快速排序是一种不稳定的排序方法。
三、选择排序
基本思想::每一趟(如第 i 趟)在后面 n-i+1 个待排序元素中选取关键字最小的元素,作为有序子序列的第1个元素,直到第 n-1 趟做完,待排序元素只剩下1个,就不用再选了。
(一)、简单选择排序
基本思想
找到最小的与第一个元素交换,从而从右向左确定小到大。
public void selectSort(int arr[]) {
//n-1趟
for (int i = 0; i < arr.length - 1; i++) {
int min = i;
//找后续最小值位置
for (int j = i + 1; j < arr.length; j++) {
if (arr[min] > arr[j]) {
min = j;
}
}
//交换
if (min != i) {
int temp=arr[i];
arr[i] = arr[min];
arr[min] = temp;
}
}
}
性能分析
(1)空间效率:。
(2)时间效率:在简单选择排序过程中,元素移动的操作次数很少,不会超过次,最好的情况是移动0次,此时对应的表已经有序;但元素间比较的次数与序列的初始状态无关,始终是次,因此,时间复杂度始终是。
(3)稳定性:在 第 i 趟找到最小元素后,和第 i 个元素交换,可能会导致第 i 个元素与其含有相同关键字元素的相对位置发生改变。因此,简单选择排序是一种不稳定的排序方法。
(二)、堆排序
堆
定义:n个关键字序列称为堆,当且仅当该序列满足:
- ① 且 或
- ② 且
如果将该一维数组视为一棵完全二叉树,
- 满足①则为大根堆(大顶堆):任意非根结点的值小于等于其双亲结点值;
- 满足②则为小根堆(小顶堆):任意非根结点的值大于等于其双亲结点值;
堆排序基本思想
- 将存放在建成初始堆,由于堆本身的特点(以大顶堆为例),堆顶元素就是最大值;
- 输出堆顶元素,然后将堆底元素送入堆顶,此时根结点已不满足大顶堆的性质,堆被破坏,将堆顶元素向下调整使其继续保持大顶堆的性质;
- 再输出堆顶元素。如此重复,直到堆中仅剩一个元素为止。
可见堆排序需要解决两个问题:①如何将无序序列构造成初始堆?②输出堆顶元素后,如何将剩余元素调整成新的堆?
构造初始堆
- 对第个结点为根的子树筛选(对于大根堆,若根结点的关键字小于左右孩子中关键字较大者,则交换),使该子树成为堆。
- 之后向前依次对各结点( 〜 1 )为根的子树进行筛选交换,交换后可能会破坏下一级的堆,于是继续采用上述方法构造下一级的堆,直到以该结点为根的子树构成堆为止。
- 反复利用上述调整堆的方法建堆,直到根结点。
堆排序
输出堆顶元素后,将堆的最后一个元素与堆顶元素交换,此时堆的性质被破坏,需要向下进行筛选。
将根结点 09 与左右孩子较大者 78 进行交换,交换后可能破坏孩子 78 的堆的性质,需要继续向下调整交换。【和构造初始堆算法一致】
代码
public void heapSort(int[] arr){
//建大根堆
buildMaxHeap(arr);
//调整
for (int i = arr.length-1; i > 0 ; i--) {
//将最大的调整到未排序序列末尾
swap(arr,0,i);
//调整剩余的i个元素[0…i-1],排除已经排好的序列[i…len-1]
headAdjust(arr,0,i);
}
}
public void buildMaxHeap(int[] arr){
//调整所有非单节点子树
for (int i = (arr.length-1)/2; i >=0 ; i--) {
headAdjust(arr,i,arr.length);
}
}
//将元素k为根的子树向下进行调整,len限制长度以便形成排序时 排除数组末尾已经排好的序列
public void headAdjust(int[] arr,int k,int len){
int temp=arr[k];
//找并空出合适位置[k的左子树是2*(k+1)-1、右子树是2*(k+1)]
for (int child = 2*k+1; child <len ; child=2*child+1) {
//选出左右孩子中较大的那个
if (child+1<len&&arr[child]<arr[child+1]){
child=child+1;
}
if (temp>=arr[child]){
break;
}else {
//交换,空出位置
arr[k]=arr[child];
k=child; //保存被空出的位置
}
}
//放入最终位置
arr[k]=temp;
}
private void swap(int[] arr,int i,int j){
int temp=arr[i];
arr[i]=arr[j];
arr[j]=temp;
}
插入
- 先将新结点放在堆的末端;
- 再对这个新结点向上执行调整操作。(不断与父节点比较)
删除
- 先用堆底元素代替被删除元素
- 然后从被删除元素位置不断进行向下调整操作。【和构造初始堆时的向下调整算法一致】
性能分析
(1)空间效率:。
(2)时间效率:
- 建堆时间为 :【建堆调整的时间与树高有关,第i层的每个元素比较次数不超过 。在建含n个元素的堆时,关键字的比较总次数不超过】;
- 之后有 n-1 次向下调整操作,每次调整的时间复杂度为;
- 故在最好、最坏和平均情况下,堆排序的时间复杂度为 。
(3)稳定性:进行筛选时,有可能把后面相同关键字的元素调整到前面,所以堆排序算法是一种不稳定的排序方法。
(4)适用性:堆排序适合关键字较多的情况(如n> 1000)。例如,在 1 亿个数中选出前100个最大值? 首先使用一个大小为100的数组,读入前100个数,建立小顶堆,而后依次读入余下的数, 若小于堆顶则舍弃,否则用该数取代堆顶并重新调整堆,待数据读取完毕,堆中 100 个数即为所求。
四、归并排序
基本思想
“归并”的含义是将两个或两个以上的有序表组合成一个新的有序表。
2 路归并排序:
假定待排序表含有n个记录,则可将其视为 n个有序的子表,每个子表的长度为 1,然后两两归并,得到个长度为2或1的有序表;继续两两归并……如此重复,直到合并成一个长度为n的有序表为止。
实现思路(分治)
- 分解:将含有n 个元素的待排序表分成各含n/2个元素的子表,采用 2 路归并排序算法对两个子表递归地进行排序。
- 合并:合并两个已排序的子表得到排序结果。
Merge():将前后相邻的两个有序表归并为一个有序表。实现时复制到辅助数组,然后在按顺序把元素填回去原数组。
public void mergeSort(int[] arr){
mergeSort(arr,0,arr.length-1);
}
public void mergeSort(int[] arr,int low,int high){
if (low<high){
int mid=(low+high)/2;
mergeSort(arr,low,mid);
mergeSort(arr,mid+1,high);
//归并
merge(arr,low,mid,high);
}
}
//low、mid、high表示数组下标
public void merge(int[] arr,int low,int mid,int high){
//借助辅助数组克隆low-high数组
int[] arr2=Arrays.copyOfRange(arr,low,high+1);
//从k=0开始填入两数组中相对最小的那个元素
int i = 0,j=mid-low+1,k=low;
for (; i <= mid-low&&j<=high-low; k++) {
if (arr2[i]<=arr2[j]){
arr[k]=arr2[i++];
}else {
arr[k]=arr2[j++];
}
}
while (i<=mid-low){
arr[k++]=arr2[i++];
}
while (j<=high-low){
arr[k++]=arr2[j++];
}
}
性能分析
(1)空间效率:Merge()操作中,辅助空间刚好为n个单元,所以算法的空间复杂度为。
(2)时间效率:每趟归并的时间复杂度为。共需进行趟归并,所以算法的时间复杂度为。
- 一般而言,对于N个元素进行 k 路归并排序时,排序的趟数 m 满足,从而,这和2路归并是一致的。
(3)稳定性:由于Merge()操作不会改变相同关键字记录的相对次序,所以 2路归并排序算法是一种稳定的排序方法。
五、基数排序
基本思想
基数排序是一种很特别的排序方法,它不基于比较和移动进行排序,而基于关键字各位的大小进行排序。基数排序是一种借助多关键字排序的思想对单逻辑关键字进行排序的方法。
假设长度为 n 的线性表中每个结点的关键字由 d 元组组成,满足。其中为最主位关键字,为最次位关键字。
为实现多关键字排序,通常有两种方法:第一种是最高位优先(MSD) 法,按关键字位权重递减依次逐层划分成若干更小的子序列,最后将所有子序列依次连接成一个有序序列。第二种是最低位优先(LSD)法,按关键字权重递增依次进行排序,最后形成一个有序序列。
以 r 为基数的最低位优先基数排序的过程:
- 分配:使用 r 个队列,依次考察线性表中的每个结点,若的关键字 , 就把放进队列中。
- 收集:把各个队列中的结点依次首尾相接,得到新的结点序列,从而组成新的线性表。
通常采用链式基数排序,假设对如下10个记录进行排序:(基数是10)
第一趟:以“个位”进行“分配”
第二趟:以“十位”进行“分配”
第三趟:以“百位”进行“分配”【和上述一致】
性能分析
(1)空间效率:一趟排序需要的辅助存储空间为 r(r 个队列:r 个队头指针和 r 个队尾指针),但以后的排序中会重复使用这些队列,所以基数排序的空间复杂度为。
(2)时间效率:基数排序需要进行 d 趟分配和收集,一趟分配需要,一趟收集需要。所以基数排序的时间复杂度为, 它与序列的初始状态无关。
(3)稳定性:对于基数排序算法而言,很重要一点就是按位排序时必须是稳定的。因此,这也保
证了基数排序的稳定性。
(4)适用性:
①数据元素的关键字可以方便地拆分为d组,且d较小【反例:给5个人的身份证号排序】
②每组关键字的取值范围不大,即r较小【反例:给中文人名排序】
③数据元素个数n较大【擅长:给十亿人的身份证号排序】
六、内部算法比较和应用
1、比较排序算法的下界
基尔排序不是基于比较的排序算法。
比较排序的决策树
决策树是一棵完全二叉树。每个节点用 i:j 标记,表示让元素与比较,一旦确定其大小就可进行后续比较。如图是3个元素的增序决策树。
最坏情况的下界
一个比较排序算法的最坏情况比较次数就等于决策树的高度。且,决策树的每种排列都是以叶节点的形式出现。
那么假设有n个元素进行比较排序,则其共有 n! 个排列(决策树的叶子节点),又因为完全二叉树的叶子节点最多为个。
所以,决策树的高度。()
综上,在最坏情况下,任何比较算法都需要做次比较。
堆排序和归并排序都是渐进最优的比较排序算法。
2、比较
(1)时间复杂度
简单选择排序、直接插入排序、冒泡排序平均情况下的时间复杂度都为,且实现过程也较简单,但直接插入排序和冒泡排序最好情况下的时间复杂度可以达到,而简单选择排序则与序列的初始状态无关。
希尔排序作为插入排序的拓展,对较大规模的排序都可以达到很高的效率,但目前未得出其精确的渐近时间。
堆排序利用了一种称为堆的数据结构,可在线性时间内完成建堆,且在内完成排序过程。
快速排序基于分治的思想,虽然最坏情况下快速排序时间会达到。但快速排序平均性能可以达到。在实际应用中常常优于其他排序算法。
归并排序同样基于分治的思想,但由于其分割子序列与初始序列的排列无关,因此它的最好、最坏和平均时间复杂度均为。
(2)空间复杂度
简单选择排序、插入排序、冒泡排序、希尔排序、堆排序 都仅需要借助常数个辅助空间。
快速排序 在空间上只使用一个小的辅助栈,用于实现递归,平均情况下大小为,当然在最坏情况下可能会增长到。
2路归并排序 在合并操作中需要借助较多的辅助空间用于元素复制,大小为,虽然有方法能克服这个缺点,但其代价是算法会很复杂而且时间复杂度会增加。
(3)过程特征
在排序过程中,每趟都能确定一个元素在其最终位置的有冒泡排序、简单选择排序、堆排序、快速排序,其中前三种都能形成全局有序的子序列,快速排序能确定枢轴元素的最终位置。
每趟冒泡 和 选择 都会有一个最大元素(或最小)放在最终位置。
直接插入排序只能使前n个元素有序。
- 元素的移动次数与关键字的初始排列次序无关:基数排序
- 元素的比较次数初始序列无关:选择排序、折半插入排序
- 算法时间复杂度与初始序列无关:选择排序、堆排序、归并排序、基数排序
- 算法的排序趟数与初始序列无关的是:插入排序、选择排序、归并排序、基数排序
3、应用
通常情况,对排序算法的比较和应用应考虑以下情况。
1)选取排序方法需要考虑的因素
①待排序的元素数目n。
②元素本身信息量的大小。
③关键字的结构及其分布情况。
④稳定性的要求。
⑤语言工具的条件,存储结构及辅助空间的大小等。
2)排序算法小结
- 若文件的初始状态已按关键字基本有序,则选用直接插入或冒泡排序为宜。
- 当记录本身信息量较大时,为避免耗费大量时间移动记录,可用链表作为存储结构。
- 对于中等规模(n<=1000)的数据,希尔排序是一种很好的选择。
排序算法 | 适用条件 | 备注及原因 | |
简单选择排序 | n较小(<=10000) | 记录本身信息量较大 | 直接插入排序所需的记录移动次数较简单选择排序的多 |
直接插入排序 /冒泡排序 | 关键字基本有序 | ||
快速排序 | n较大 | 当待排序的关键字随机分布时,快速排序的平均时间最短 | |
堆排序 | 辅助空间少于快速排序,并且不会出现快速排序可能出现的最坏情况 | ||
归并排序 | 排序稳定 | 之前介绍的从单个记录起进行两两归并的排序算法并不值得提倡,通常可以将它和直接插入排序结合在一起使用。先利用直接插入排序求得较长的有序子文件,然后两两归并。直接插入排序是稳定的,因此改进后的归并排序仍是稳定的 | |
基数排序 | 记录的关键字位数较少且可以分解时,采用基数排序较好。 |