排序算法是面试中常见的问题,不同算法的时间复杂度、稳定性和适用场景各不相同。按照数据量和存储方式可以将排序算法分为 内排序(Internal Sorting)和 外排序(External Sorting)。
内排序是指对所有待排序的数据都可以一次性加载到内存中进行排序。这意味着所有的数据都可以直接在计算机的内存中操作,无需借助外部存储设备(如硬盘)。内排序算法的设计可以更加灵活,因为内存操作速度远远高于磁盘操作速度。常见的内排序算法包括快速排序、归并排序、插入排序、冒泡排序等。
外排序是指对大量数据进行排序时,由于数据无法一次性加载到内存中,需要借助外部存储设备进行排序。通常在外排序中,数据会被分成若干个小块,每次只处理其中一部分数据,然后将部分排序好的数据存储回外部存储设备,再进行合并等操作。外排序算法需要考虑数据的分块、读写外部存储的效率,以及合并有序数据等问题。常见的外排序算法有多路归并排序、置换选择排序等。
本文介绍的都是常见的内排序算法:插入排序、冒泡排序、选择排序、希尔排序、归并排序、快速排序、堆排序、桶排序。
插入排序的核心是将待排序的元素逐个插入到已经排好序的序列中,以构建有序的输出序列:
void inssort(int A[],int n){
for(int i=1;i<n;i++){
for(int j=i;j>0;j--){
if(A[j]<A[j-1]){ //A[j]
swap(A,j,j-1);
}
else{
break; //A[j]已达到正确位置(时间优化)
}
}
}
}
插入排序的平均时间复杂度为 O(n2),最好情况为 O(n),最坏情况为 O(n2),适用于小规模的数据集或者已经基本有序的数据。插入排序的空间复杂度为 O(1),是一种稳定排序算法。
冒泡排序的核心思想是通过比较相邻的元素并交换位置,逐渐将最小的元素 “冒泡” 到正确的位置。冒泡排序的过程类似于冒泡泡沫在水中升起的过程,较大的元素会逐渐向序列的开头 “冒泡”:
void bubsort(int A[],int n){
for(int i=0;i<n-1;i++){
for(int j=n-1;j>i;j--){
if(A[j]<A[j-1]){
swap(A,j,j-1);
}
}
}
}
冒泡排序的平均时间复杂度为 O(n2),最好情况和最坏情况都为 O(n2)。冒泡排序的空间复杂度为 O(1),是一种稳定排序算法。
选择排序的核心思想是在未排序序列中选择最小的元素,然后将它与未排序序列的起始位置交换,使得已排序序列逐步增长:
void selsort(int A[],int n){
for(int i=0;i<n-1;i++){
int index=i;
for(int j=n-1;j>i;j--){
if(A[j]<A[index]){
index=j;
}
}
swap(A,i,index);
}
}
选择排序的平均时间复杂度为 O(n2),最好情况和最坏情况都为 O(n2)。选择排序的空间复杂度为 O(1),是一种稳定排序算法。
希尔排序利用了插入排序最佳时间代价特性,在不相邻的记录之间进行交换和比较。核心思想是将整个序列分成多个较小的子序列来逐步减少序列的无序度,从而在最后一步进行一次插入排序,达到提高排序速度的效果。过程如图:
void inssort2(int A[],int n,int incr){ //n为传入数组长度,incr为数组内元素间隔
for(int i=incr;i<n;i+=incr){ //incr相当于第1位元素,+incr相当于间隔
for(int j=i;(j>=incr)&&(A[j]<A[j-incr]);j-=incr){
int tmp=A[j];
A[j]=A[j-incr];
A[j-incr]=tmp;
cnt1++;
}
}
}
void shellsort(int A[],int n){
for(int i=n/2;i>2;i/=2){ //i表示组数或者间隔
for(int j=0;j<i;j++){ //j表示有i组数据时从0到i遍历
inssort2(&A[j],n-j,i); //对第j组数据进行插入排序(起始位置,长度,间隔)
//第j组数据在A中下标:j,j+i,j+2i...用inssort2中传入参数表示为:&A[j],&A[j]+i,&A[j]+2i...
}
}
inssort2(A,n,1); //对整个数组插入排序
}
希尔排序在现实中没有对应的直观解释,也无法证明其时间复杂度,一般认为平均时间复杂度为 O(n1.5),最好情况为 O(nlogn),最坏情况为 O(n2)。希尔排序的空间复杂度为 O(1),是一种不稳定排序算法。
归并排序的核心思想是 “二分+合并”,即将一个未排序的数组分割成两个子数组,递归地对这两个子数组排好序后再合并为一整个有序的数组:
void mergesort(int A[],int tmp[],int left,int right){ //left表示A[0]下标,right表示A[n-1]下标
if(left==right) return;
int mid=(left+right)/2;
mergesort(A,tmp,left,mid); //向下递归,二分集合并排序
mergesort(A,tmp,mid+1,right);
for(int i=left;i<=right;i++){
tmp[i]=A[i];
}
int i1=left,i2=mid+1; //i1,i2表示两个待合并数组(各自都已排序)首个未排序元素下标
for(int curr=left;curr<=right;curr++){
if(curr==mid+1){ //left数组已完成合并
A[curr]=tmp[i2++];
}
else if(i2>right){ //right数组已完成合并
A[curr]=tmp[i1++];
}
else if(tmp[i1]<tmp[i2]){
A[curr]=tmp[i1++];
}
else{
A[curr]=tmp[i2++];
}
}
}
归并排序的平均时间复杂度为 O(nlogn),最好情况和最坏情况也都为 O(nlogn)。归并排序的空间复杂度为 O(n),是一种稳定排序算法。
快速排序的核心思想是选择一个轴值 pivot,将数组分为小于轴值的部分和大于轴值的部分,然后递归地对这两部分进行排序。轴值的选择会影响算法的效率,一般选取数组的中间点作为轴值。为了设计方便,会将轴值置于数组末端,待排好序后再移动到合适位置:
//partition函数将数组的l+1~r-1划分为两个以轴值为分界的部分,并返回轴值的下标(实际上该位元素与最后一位交换后才是真正的轴值下标)
int partition(int A[],int l,int r,int& pivot){ //l,r为A[]的左右范围,pivot为轴值
do{
while(A[++l]<pivot); //跳过满足小于轴值的元素,终止在大于轴值的地方
while((l<r)&&(A[--r]>pivot));
int tmp=A[r];
A[r]=A[l];
A[l]=tmp;
}while(l<r);
return l;
}
void qsort(int A[],int i,int j){
if(i>=j) return;
int pivotindex=(i+j)/2; //选取轴值
int pivot=A[pivotindex];
A[pivotindex]=A[j]; //将轴值与末尾元素互换
A[j]=pivot;
pivotindex=partition(A,i-1,j,A[j]); //将l+1~r-1按轴值分部,并返回待与轴值交换元素下标
pivot=A[j]; //轴值还在末尾等待交换
A[j]=A[pivotindex];
A[pivotindex]=pivot;
qsort(A,i,pivotindex-1);
qsort(A,pivotindex+1,j);
}
快速排序是迄今为止所有内排序算法中在平均情况下最快的一种,当每个轴值都把数组分成相等的两个部分时,整个算法的时间代价是 Θ \Theta Θ(nlogn)。快速排序的平均时间复杂度为 O(nlogn),最好情况为 O(nlogn),最坏情况为 O(n2)。快速排序的空间复杂度为 O(logn),是一种不稳定排序算法。
堆排序是基于堆数据结构的排序算法,它利用最小堆的性质来实现对数组的排序:
class heap{
private:
int* Heap;
int maxsize;
int n;
void siftdown(int pos){
while(!isLeaf(pos)){
int j=leftchild(pos); //下面j表示的是lc和rc中较小值的下标
int rc=rightchild(pos);
if((rc<n)&&(Heap[rc]<Heap[j])){
j=rc;
}
if(Heap[pos]<Heap[j]){
return;
}
int tmp=Heap[pos];
Heap[pos]=Heap[j];
Heap[j]=tmp;
pos=j;
}
}
public:
heap(int* h,int num,int max){
Heap=h;
n=num;
maxsize=max;
buildHeap();
}
int size(){
return n;
}
bool isLeaf(int pos){
return (pos>=n/2)&&(pos<n);
}
int leftchild(int pos){
return 2*pos+1;
}
int rightchild(int pos){
return 2*pos+2;
}
int parent(int pos){
return (pos-1)/2;
}
void buildHeap(){
for(int i=n/2-1;i>=0;i--){
siftdown(i);
}
}
void insert(const int& it){
int curr=n++;
Heap[curr]=it;
while((curr!=0)&&(Heap[curr]<Heap[parent(curr)])){
int tmp=Heap[curr];
Heap[curr]=Heap[parent(curr)];
Heap[parent(curr)]=tmp;
curr=parent(curr);
}
}
int removefirst(){
n--;
int tmp=Heap[0];
Heap[0]=Heap[n];
Heap[n]=tmp;
if(n!=0){
siftdown(0);
}
return Heap[n];
}
int remove(int pos){
if(pos==(n-1)){
n--;
}
else{
n--;
int tmp=Heap[pos];
Heap[pos]=Heap[n];
Heap[n]=tmp;
while((pos!=0)&&(Heap[pos]<Heap[parent(pos)])){
int tmp=Heap[pos];
Heap[pos]=Heap[parent(pos)];
Heap[parent(pos)]=tmp;
pos=parent(pos);
}
if(n!=0){
siftdown(pos);
}
}
return Heap[n];
}
};
void heapsort(int A[],int n){
int minval;
heap H(A,n,n);
for(int i=0;i<n;i++){
minval=H.removefirst();
}
}
堆排序的平均时间复杂度为 O(nlogn),最好情况和最坏情况也都为 O(nlogn),但比快速排序要慢一个常数因子。堆排序在实际使用时适用于查找第 k 大小的元素,或者用于外排序。堆排序的空间复杂度为 O(1),是一种不稳定排序算法。
桶排序的核心思想是将数组按数值范围放入若干个桶,每个桶对应一定范围的数据,然后对每个桶中的数据使用其他排序算法或递归方式进行排序,最后将所有桶中的数据按顺序合并得到排序结果:
void inssort(int A[],int n){
for(int i=1;i<n;i++){
for(int j=i;j>0;j--){
if(A[j]<A[j-1]){
int tmp=A[j];
A[j]=A[j-1];
A[j-1]=tmp;
}
else{
break;
}
}
}
}
void bucketSort(int A[],int n,int numBuckets){
//创建桶
int buckets[numBuckets][n]; //二维数组,每行表示一个桶
int bucketSizes[numBuckets]; //每个桶中元素的数量
//初始化桶的大小
for(int i=0;i<numBuckets;i++){
bucketSizes[i]=0;
}
//确定数组元素范围
int maxA=-999,minA=999;
for(int i=0;i<n;i++){
if(A[i]>maxA) maxA=A[i];
if(A[i]<minA) minA=A[i];
}
//将元素放入桶中
for(int i=0;i<n;i++){
int bucketGap=ceil((maxA-minA)/(float)numBuckets);
int bucketIndex=(A[i]-minA)/bucketGap;
buckets[bucketIndex][bucketSizes[bucketIndex]++]=A[i];
}
//对每个桶中的元素进行插入排序
for(int i=0;i<numBuckets;i++){
inssort(buckets[i],bucketSizes[i]);
}
// 合并桶中的元素
int index=0;
for(int i=0;i<numBuckets;i++){
for(int j=0;j<bucketSizes[i];j++){
A[index++]=buckets[i][j];
}
}
}
桶排序的平均时间复杂度为 O(n2),最好情况和最坏情况也都为 O(n2),但比插入排序要快一个常数因子。桶排序适用于在已知输入数据的范围内进行排序,若数据分布不均匀也会影响效率。桶排序的空间复杂度为 O(n2),是一种稳定排序算法。