注:该文章为经典文章,属于转载,个人对其中的格式进行了整理,并对代码进行了C++语言的实现。标记为原创,为了个人分类与查阅时方便操作。引用地址在最后。
一、排序算法综述
1.1 排序的定义
对一序列的对象根据某个关键字进行排序。
1.2 术语说明
- 稳定: 如果a原本在b前面,而a=b,排序之后a仍然在b的前面。
- 不稳定: 如果a原本在b前面,而a=b,排序之后a却可能出现在b的后面。
- 内排序: 所有排序操作都在内存中完成。
- 外排序: 由于数据太大,因此把数据放在磁盘中,而排序通过磁盘和内存的数据传输才能够进行。
- 时间复杂度: 一个算法执行所耗费的时间。
- 空间复杂度: 运算完一个程序所耗费的内存的大小。
1.3 算法总结
在上图中:
- n: 数据规模。
- k: “桶”的个数。
- In-place: 占用常用内存,不占用额外内存。
- Out-place: 占用额外内存。
1.4 算法分类
1.5 比较和非比较的区别
1.5.1 比较排序
常见的快速排序、归并排序、堆排序、冒泡排序等属于比较排序。在排序的最终结果里,元素之间的次序依赖于它们之间的比较,才能确定自己的位置。
在冒泡排序之类的排序中,问题规模为n,又因为需要比较n次,所以平均问题时间复杂度为O(n²)。
在归并排序、快速排序之类的排序中,问题规模通过分治法消减为logN次,所以时间复杂度平均O(nlogn)。
比较排序的优势是:
- 适用于各种规模的数据,也不在乎数据的分布,都可以进行排序。也就是说,比较排序适用于一切需要排序的情况。
1.5.2 非比较排序
计数排序、基数排序、桶排序则属于非比较排序。非比较排序是通过确定每个元素之前,应该有多少个元素,以此来进行排序。针对数组arr,计算arr之前有多少个元素,则唯一确定了arr在排序后数组中的位置。
非比较排序只要确定每个元素之前的已有的元素个数即可。所有一次遍历即可解决,时间复杂度为O(n)。
非比较排序的优势是:
- 非比较排序的时间复杂度较低,但由于非比较排序需要占用空间来确定唯一位置。所以对数据规模和数据分布有一定的要求。
二、详细的排序算法
2.1 冒泡排序(Bubble Sort)
冒泡排序是一种简单的排序算法。它会重复地走访过要排序的数列,一次比较两个元素,如果它们的顺序有误,就将它们交换过来。
走访数列的工作是重复地进行,知道没有在需要交换,也就是说,该数列已经排序完成。
因此,之所以称之为“冒泡排序”,是因为越小的元素会进过交换而慢慢地“浮”到数列的顶端。
2.1.1 算法描述
- step1:比较相邻的两个元素。如果第一个比第二个大,就交换它们两个。
- step2:对每一对相邻元素做同样的工作,从开始的一对到结尾的最后一对,这样在最后的元素就会是最大的元素。
- step3:针对所有的元素进行重复上述的步骤,除了最后一个。
- step4:重复1~3,直到排序完成。
2.1.2 动图演示
2.1.3 代码实现
void bubbleSort(vector<int> &nums) {
if(nums.size() == 0)
return;
for(int i = 0;i < nums.size();i++)
for(int j = 0;j < nums.size()-1-i;j++)
if(nums[j+1]<nums[j])
swap(nums[j], nums[j+1]);
return;
}
2.1.4 算法分析
- 最佳情况:T(n) = O(n)
- 最差情况:T(n) = O(n²)
- 平均情况:T(n) = O(n²)
2.2 选择排序(Selection Sort)
选择排序是表现最稳定的算法之一,因为无论什么数据,都是O(n²)的时间复杂度。因此用到这个算法时,数据规模越小越好。唯一的好处可能就是不占用额外的内存空间。
选择排序(Selection Sort)是一种简单直观的排序算法。它的工作原理时:
- 首先在未排序的序列中找到最小(最大)元素,存放到排序序列的起始位置。
- 然后,再从剩余的未排序的元素中继续寻找最小(最大)的元素,放到已经排序的序列末尾。
- 以此类推,直到所有元素均排序完成。
2.2.1 算法描述
n个记录的的直接选择排序,可以经过n-1趟得到排序结果。算法的描述如下:
- 初始状态下,无序区为R[1, n],有序区为空;
- 在第i趟排序(i = 1,2,3,…,n-1)开始时,当前有序区和无序区分别是R[1,2,…,i-1]和R(i,…,n)。在该趟中,从无序区中选出关键字最小的记录R[k],并让其与无序区的第1个元素R交换,使得R[1,2,…,i]和R[i+1,…,n)分别变为,个数增加1的新有序区,和个数减少1的新无序区。
- 在n-1趟之后,数组就有序了。
2.2.2 动图演示
2.2.3 代码实现
void selectionSort(vector<int> &nums) {
if(nums.size() == 0)
return;
for(int i = 0;i < nums.size();i++)
{
int minIndex = i;
for(int j = i;j < nums.size();j++)
if(nums[j] < nums[minIndex])
minIndex = j;
swap(nums[i], nums[minIndex]);
}
return;
}
2.2.4 算法分析
- 最佳情况:T(n) = O(n²)
- 最差情况:T(n) = O(n²)
- 平均情况:T(n) = O(n²)
2.3 插入排序(Insertion Sort)
插入排序(Insertion Sort)的算法描述的是一种简单直观的排序算法。它的工作原理是通过构建有序序列,对于未排序数据,在已排序序列中从后往前扫描,找到相应位置并插入。
插入排序在实现上,通常采用in-place排序(即只需用到O(1)的额外空间的排序),因而在从后向前扫描过程中,需要反复把已排序元素逐步向后挪位,为最新元素提供插入空间。
2.3.1 算法描述
一般来说,插入排序都采用in-place在数组上实现。具体算法描述如下:
- step1:从第一个元素开始,该元素可以认为已经被排序;
- step2:取出下一个元素,在已经排序的元素在已经排序的元素序列中从后往前扫描;
- step3:如果该元素(已排序)大于新元素,则将该元素移动到下一个位置;
- step4:重复step3,直到找到已排序的元素小于或者等于新元素的位置;
- step5:将新元素插入到该元素之后;
- step6:重复步骤2~5。
2.3.2 动图演示
2.3.3 代码实现
void insertionSort(vector<int> &nums) {
if(nums.size() == 0)
return;
int current = 0;
for(int i = 0;i < nums.size()-1;i++)
{
current = nums[i+1];
int preIndex = i;
while(preIndex >= 0 && current < nums[preIndex])
{
nums[preIndex + 1] = nums[preIndex];
preIndex--;
}
nums[preIndex + 1] = current;
}
return;
}
2.3.4 算法分析
- 最佳情况:T(n) = O(n)
- 最差情况:T(n) = O(n²)
- 平均情况:T(n) = O(n²)
2.4 希尔排序(Shell Sort)
希尔排序是希尔(Donald Shell)在1959提出的一种排序算法。希尔排序也是一种插入排序,它是简单插入排序进过改进后的一个更有效的版本,也称为缩小增量排序,同时也是突破O(n²)的第一批算法之一。它与插入排序的不同之处在于:
- 它会优先比较距离较远的元素。因此,希尔排序有称为缩小增量排序。
希尔排序是把记录按下表的一定增量分组,对每组使用直接插入排序算法进行排序;随着增量的逐渐减少,每组包含的关键词越来越多,当增量减至1时,整个文件正好被分为一组,算法终止。
2.4.1 算法描述
对于希尔排序,首先选择增量gap = length/2,缩小增量继续以gap = gap/2的方式,对于这种增量我们可以用一个序列来表示:
- { n/2, (n/2)/2, …, 1 },称之为增量序列。
先将整个待排序的记录序列分割成为若干子序列分别进行直接插入排序,具体算法描述:
- 选择一个增量序列t1, t2, …, tk,其中ti > tj, tk = 1;
- 按增量序列个数k,对序列进行k趟排序;
- 每趟排序,根据对应的增量ti,将待排序列分割成若干长度为m的子序列,分别对各子表进行直接插入排序。仅增量因子为1时,整个序列作为一个表来处理,表的长度即为整个序列的长度。
2.4.2 过程演示
2.4.3 代码实现
void shellSort(vector<int> &nums) {
if(nums.size() == 0)
return;
int len = nums.size();
int tmp = 0;
int gap = len / 2;
while(gap > 0)
{
for(int i = gap; i < len; i++)
{
//直接插入排序
tmp = nums[i];
int preIndex = i - gap;
while(preIndex >= 0 && nums[preIndex] > tmp)
{
nums[preIndex + gap] = nums[preIndex];
preIndex -= gap;
}
nums[preIndex + gap] = tmp;
}
gap /= 2;
}
return;
}
2.4.4 算法分析
- 最佳情况:T(n) = O(nlogn)
- 最差情况:T(n) = O(nlogn)
- 平均情况:T(n) = O(nlogn)
2.5 归并排序(Merge Sort)
和选择排序一样,归并排序的性能不受输入数据的影响,但是表现比选择排序好很多。代价是需要额外的内存空间。
归并排序是建立在归并操作上的一种有效有效的排序算法。这个算法是采用了分治法(Divide and Conquer) 的一个非常典型的应用。归并排序是一种稳定的排序方法。将已有序的子序列合并,得到完全有序的序列:
- 先使得每个子序列有序,再使得子序列有序。若将两个有序表合并成一个有序表,称为2-路归并。
2.5.1 算法描述
- 将长度为n的输入序列分成长度为n/2的子序列;
- 把这两个子序列分别采用归并排序;
- 将两个排序好的子序列合并成一个最终的排序序列。
2.5.2 动图演示
2.5.3 代码实现
vector<int> merge(vector<int> left, vector<int> right)
{
vector<int> res (left.size()+right.size(), 0);
for(int index = 0, i = 0, j = 0; index < res.size();index++)
{
if(i >= left.size()) //左边的遍历完了
res[index] = right[j++];
else if(j >= right.size()) //右边的遍历完了
res[index] = left[i++];
else if(left[i] > right[j]) //左边的当前元素比右边的大
res[index] = right[j++];
else //右边的当前元素比左边的大
res[index] = left[i++];
}
return res;
}
vector<int> mergeSort(vector<int> nums)
{
if(nums.size() < 2)
return nums;
int mid = (int)nums.size()/2;
vector<int> left (nums.begin(), nums.begin()+mid);
vector<int> right (nums.begin()+mid, nums.end());
return merge(mergeSort(left), mergeSort(right));
}
2.5.4 算法分析
- 最佳情况:T(n) = O(n)
- 最差情况:T(n) = O(nlogn)
- 平均情况:T(n) = O(nlogn)
2.6 快速排序(Quick Sort)
快速排序的基本思想:
- 通过一趟排序,将待续的部分独立的两部分,其中一部分的关键字均比另一部分关键字小,则可分别对这两部分记录继续进行排序,以达到整个序列有序。
2.6.1 算法描述
快速排序使用分治法将一个串分成两个子串。具体的算法描述如下:
- 从数列中挑出一个元素,称为 “基准”(pivot);
- 重新排序数组,所有元素比基准值小的元素摆在基准的前面,所有元素比基准大的元素摆在基准后面(其中,相同的数可以放在任意一边)。在这个分区退出之后,该基准就处于数列的“中间位置”。这个过程称为分区(partition)操作。
- 递归地(recursive) 把小于基准值元素的子数列和大于基准值元素的子数列排序。
2.6.2 原理详解
对于快排,细说起来,其实就是给基准元素找到正确索引位置的过程。
一般来说,取第一个元素作为基准元素,然后建立一个临时变量去存储这个基准数据。然后,从数组两端扫描数组,并设置两个指标:
- low指向起始位置;
- high指向末尾;
首先从后半部分开始:
- 如果扫描到的值大于基准数据,就让high减1;
- 如果发现有元素比该基准数据的值小,就将high位置的值赋值给low位置。
然后开始从前往后扫描:
- 如果扫描到的值小于基准数据,就让low加1;
- 如果发现有元素比该基准元素的值大,就将low位置的值赋给high位置。
然后再开始从前往后遍历,直到low = high结束循环。此时,low或者high的小标就是基准数据就在数组中的正确的位置。
于是,这样一边下来,比基准数大的元素都会跑到基准的右边,比基准数小的元素跑到基准左边,基准数正好在其正确的位置。
接着,用递归的办法分别对前半部分和后半部分排序。
2.6.3 代码实现
int partition(vector<int> &nums, int low, int high)
{
//定义基准元素
int pivotValue = nums[low];
while(low < high)
{
//当队尾的元素大于等于基准数据时,向前挪动high指针
while(low < high && nums[high] >= pivotValue)
high--;
//如果队尾元素小于基准数据时,需要将其赋值给low
nums[low] = nums[high];
//当队首元素小于等于基准数据时,向前挪动low指针
while(low < high && nums[low] <= pivotValue)
low++;
//当队首元素大于基准数据时,需要将其赋值给high
nums[high] = nums[low];
}
//跳出循环时low和high相等,此时的low或high就是基准数据的正确索引位置
//由原理部分可以很清楚的知道low位置的值并不是基准数据,所以需要将基准数据赋值给nums[low]
nums[low] = pivotValue;
return low; //返回pivot的正确位置
}
void quickSort(vector<int> &nums, int low, int high)
{
if(low < high)
{
//寻找基准数据的正确索引
int index = partition(nums, low, high);
//进行迭代,分别对前后两部分使用快排
quickSort(nums, 0, index - 1);
quickSort(nums, index + 1, high);
}
}
2.6.4 算法分析
- 最佳情况:T(n) = O(nlogn)
- 最差情况:T(n) = O(n²)
- 平均情况:T(n) = O(nlogn)
2.7 堆排序(Heap Sort)
堆排序(Heap Sort)是指利用堆这种数据结构设计的一种排序算法。
堆积是一个近似完全的二叉树,并且同时满足堆积的性质:
- 即子节点的键值或索引总是小于(或者大于)它的父节点。
2.7.1 算法描述
- 将初始待排序的关键字序列(R1, R2, …, Rn)构建成大顶堆,此堆为初始的无序区;
- 将堆顶元素R[1]与最后一个元素R[n]交换,此时得到了新的无序区(R1, R2, …, Rn-1)和新的有序区(Rn),并且满足R[1,2…n-1]<=R[n];
- 由于交换后新的堆顶R[1]可能违反堆的性质,因此需要对当前无序区(R1,R2,……Rn-1)调整为新堆,然后再次将R[1]与无序区最后一个元素交换,得到新的无序区(R1,R2….Rn-2)和新的有序区(Rn-1,Rn)。不断重复此过程直到有序区的元素个数为n-1,则整个排序过程完成。
2.7.2 动图演示
2.7.3 代码实现
//声明全局变量,用于记录数组的长度
static int len;
//调整是一个堆变成最大堆
void adjustHeap(vector<int> &nums, int i)
{
int maxIndex = i;
//如果有左子树,且左子树大于父节点,则将最大指针指向左子树
if(i*2 < len && nums[i*2] > nums[maxIndex])
maxIndex = i*2;
//如果有右子树,且右子树大于父节点,则将最大指针指向右子树
if(i*2+1 < len && nums[i*2+1] > nums[maxIndex])
maxIndex = i*2 + 1;
//如果父节点不是最大值,则将父节点与最大值交换,并且递归调整与父节点交换的位置
if(maxIndex != i)
{
swap(nums[maxIndex], nums[i]);
adjustHeap(nums, maxIndex);
}
}
//建立最大堆
void buildMaxHeap(vector<int> &nums)
{
//从最后一个非叶子节点开始向上构建最大堆
for(int i = (len-1)/2; i >= 0;i--)
adjustHeap(nums, i);
}
//堆排序算法
void heapSort(vector<int> &nums)
{
len = nums.size();
if(len < 1)
return;
//1,构建一个大顶堆
buildMaxHeap(nums);
//2,循环对堆收尾(最大值)于末位交换,然后再重新调整最大堆
while(len > 0)
{
swap(nums[0], nums[len-1]);
len--;
adjustHeap(nums, 0);
}
return;
}
2.7.4 算法分析
- 最佳情况:T(n) = O(nlogn)
- 最差情况:T(n) = O(nlogn)
- 平均情况:T(n) = O(nlogn)
2.8 计数排序(Counting Sort)
计数排序,其核心在于将输入的数据转化为键存储在额外开辟的数组空间中。作为一种线性时间复杂度的排序,计数排序要求输入的数据必须是有确定范围的整数。
计数排序是一种稳定的排序算法。计数排序使用了一个额外的数组C,其中:
- 第i个元素是待排序数组A中值等于i的元素的个数。
然后根据数组C来将A中的元素排到正确的位置。
- 它只能对整数排序。
2.8.1 算法描述
- 找出待排序的数组中最大和最小的元素;
- 统计数组中每个值为i的元素出现的次数,存储到数组C的第i项中;
- 对所有的计数累加(从C中的第一个元素开始,每一项和前一项相加);
- 反向填充数组:将每个元素i放在新数组的第C(i)项,每放一个元素就将C(i)减去1。
2.8.2 动图演示
2.8.3 代码实现
void countingSort(vector<int> &nums)
{
if(nums.size() == 0)
return;
int bias = 0;
int minValue = nums[0], maxValue = nums[0];
for(auto n : nums)
{
minValue = min(minValue, n);
maxValue = max(maxValue, n);
}
bias = 0 - minValue;
vector<int> bucket (maxValue - minValue + 1, 0);
//统计每个元素出现的次数
for(auto n : nums)
bucket[n + bias]++;
int index = 0, i = 0;
while(index < nums.size())
{
if(bucket[i] != 0){
nums[index] = i - bias;
bucket[i]--;
index++;
}
else
i++;
}
return;
}
2.8.4 算法分析
- 当输入的元素是n个0到k之间的整数时,它的运行时间是O(n+k)。计数排序不是比较排序,排序的速度快于任何比较排序算法。
- 由于用来计数的数组C的长度取决于待排序数组中的数据的范围(等于待排序数组的最大值与最小值的差加上1),这使得计数排序对于数据范围很大数组,需要大量时间和内存。
- 最佳情况:T(n) = O(n+k)
- 最差情况:T(n) = O(n+k)
- 平均情况:T(n) = O(n+k)
2.9 桶排序(Bucket Sort)
桶排序是计数排序的升级版。它利用了函数的映射关系,高效与否的关键在于映射函数的确定。
桶排序(Bucket Sort)的工作原理是:
- 假设输入数据服从均匀分布,将数据分到有限的桶里,每个桶分别排序(有可能再使用别的排序算法)。
2.9.1 算法描述
- 人为设置一个BucketSize,作为每个桶能够防放置多少个不同的数值(假如当BucketSize == 5,则说明桶可以存放{1,2,3,4,5}这几种数字,但是容量不限,比如可以存放100个3);
- 遍历输入数据,并且把数据一个一个放到对应的桶中;
- 对每个不是空的桶进行排序:既可以使用其他的排序算法,也可以使用递归使用桶排序。
- 从不是空的桶里把排好序的数据拼接起来。
需要注意的是,如果递归使用桶排序为各个桶排序,则当桶数量为1时要手动减少BucketSize增加下一循环桶的数量,否则会陷入死循环,导致内存溢出。
2.9.2 图片演示
2.9.3 算法分析
桶排序最好情况下使用线性时间O(n),桶排序的时间复杂度,取决于各个桶之间数据进行排序的时间复杂度,因为其他部分的时间复杂度都为O(n)。很显然,桶划分的越小,各个桶之间的数据越少,排序所用的时间也越少。但是空间消耗会增大。
- 最佳情况:T(n) = O(n+k)
- 最差情况:T(n) = O(n+k)
- 平均情况:T(n) = O(n²)
2.10 基数排序(Radix Sort)
基数排序也是非比较的排序算法,对每一位进行排序,从最低位开始排序,复杂度为O(kn),其中n为数组长度,k为数组中的数的的最大的位数。
基数排序是按照低位先排序,然后收集;再按照高位排序,然后再收集;依次类推,直到最高位。
有时候有些属性是有优先级顺序的,先按低优先级排序,再按高优先级排序。最后的次序就是高优先级高的在前,高优先级相同的低优先级高的在前。基数排序基于分别排序,分别收集,所以是稳定的。
2.10.1 算法描述
- 取得数组中的最大数,并取得位数;
- nums为原始数组,从最低位开始每个位组成radix数组;
- 为radix进行计数排序(利用计数排序使用于小范围的特点);
2.10.2 动图演示
2.10.3 算法分析
- 最佳情况:T(n) = O(n*k)
- 最差情况:T(n) = O(n*k)
- 平均情况:T(n) = O(n*k)
基数排序有两种方法:
- MSD 从高位开始进行排序
- LSD 从低位开始进行排序
对于基数排序、计数排序、桶排序:
这三种都利用了桶的概念,但是在使用方法上有差异:
- 基数排序:根据键值的每位数字来分配桶。
- 计数排序:每个桶只存储单一键值。
- 桶排序:每个桶存储一定范围的数值。
参考文章地址:
十大经典排序算法最强总结