文章目录
简介
本文介绍算法与数据结构中几种常见的排序算法。排序算法的功能是将一个无序的序列处理为有序的序列,序列内的元素可以为数字或字母等其他形式的元素,本文主要介绍针对数字的排序。非特殊说明,排序结果均为从小到大的形式,序列存储在数组中,初始序列为[2, 7, 1, 5, 9, 8, 6]
。
1. 选择类排序
1.1 简单选择排序
简单选择排序是一种选择算法,从头至尾遍历序列找出最小的一个元素,同第一个元素交换;接着找出第二小、第三小…的元素,最终序列整体有序。举例如下:
原始数组[2, 7, 1, 5, 9, 8, 6]
现在以第一趟排序为例说明:从头到尾开始遍历数组,找到最小元素为1,同第一个元素2交换;接着重复同样的操作以至数组整体有序。
第一趟:1 7 2 5 9 8 6
第二趟:1 2 7 5 9 8 6
第三趟:1 2 5 7 9 8 6
第四趟:1 2 5 6 9 8 7
第五趟:1 2 5 6 7 8 9
void SelectSort(vector<int> &nums) {
int k, temp;
for (int i = 0; i < nums.size(); i++) {
k = i;
for (int j = i + 1; j < nums.size(); j++) {
if (nums[j] < nums[k]) {
k = j;
}
}
temp = nums[i];
nums[i] = nums[k];
nums[k] = temp;
}
}
算法中用变量k表示一趟排序过程中最小元素的位置。简单选择排序的时间复杂度:由算法思想可知,简单选择排序的时间复杂度与初始序列无关,时间复杂度为 O ( n 2 ) O(n^2) O(n2)。空间复杂度为 O ( 1 ) O(1) O(1)。同时,简单选择排序是一种不稳定的排序算法(排序算法稳定是指在一个数组中相同的两个元素在排序前后的相对位置不变,否则算法是不稳定的)。
1.2 堆排序
堆是一种数据结构,是一棵完全二叉树。堆的特点是:任何一个非叶子节点的值都不大于(不小于)其左右孩子节点的值;若父节点的值较小,则这种堆为小顶堆,若父节点的值较大,则这种堆为大顶堆。使用堆排序算法首先需要建立一个堆,按照从小到大排序时首先建立一个大顶堆。下面是建立一个大顶堆和堆排序的过程:
(1)将原始数组[2, 7, 1, 5, 9, 8, 6]中的元素依次填入二叉树中:
(2)按照1、7、2的顺序调整堆。1比其两个子节点值8和6均小,不满足大顶堆条件,与其中较大值8交换;7比右子节点9小,不满足大顶堆的条件,节点7和节点9交换;2比其两个子节点值9和8均小,不满足大顶堆条件,与其中较大值9交换。此时,节点2比其两个子节点5和7均小,不满足大顶堆条件,与其中较大值7交换。建完大顶堆后,得到二叉树:
对应的数组为[9, 7, 8, 5, 2, 1, 6]
(3)将堆顶元素9和最后一个关键字6交换,完成第一趟排序。9达到最终位置,然后将除9外的元素重新按照大顶堆的定义调整。此时调整节点6和节点8的位置,得到二叉树:
注:此时大顶堆内不包含元素9。
(4)接着将元素栈顶元素8和最后一个关键字1交换,完成第二趟排序。重复以上步骤,直至堆内只剩下一个元素。此时完成堆排序的全部过程。得到二叉树:
对应的数组为[1, 2, 5, 6, 7, 8, 9]。
这里采用二叉树的顺序存储方式。顺序存储结构即用一个数组(下标从1开始)来存储一棵二叉树,这种方式适合用于存储完全二叉树。假如采用一个数组R
存储上述小顶堆的元素[1, 2, 5, 6, 7, 8, 9]
,对于某个节点,如果其在数组中索引为 i ,则其左孩子为 2 * i 、右孩子为 2 * i+1(如果存在)。
首先定义Adjust函数调整数组R
在low到high的范围内R[low]
的位置使其满足堆的性质。
void Adjust(vector<int> &R, int low, int high) {
int i = low, j = 2 * i;
int temp = R[i];
while (j <= high)
{
if (j < high&&R[j] < R[j + 1]) {
j++;
}
if (temp < R[j]) {
R[i] = R[j];
i = j;
j = 2 * i;
}
else
{
break;
}
}
R[i] = temp;
}
首先将需要调整的节点R[low]
存放在变量temp中,然后定义变量 j 指向其左右孩子中较大者。如果temp小于 j 所指元素,则赋值(暂时交换);否则,结束循环。最后,将temp赋值到指定位置。
void HeapSort(vector<int> &nums) {
int i;
int temp;
for (i = (nums.size() - 1) / 2; i >= 1; i--) {
Adjust(nums, i, nums.size());
}
for (i = nums.size() - 1; i >= 2; i--) {
temp = nums[1];
nums[1] = nums[i];
nums[i] = temp;
Adjust(nums, 1, i - 1);
}
}
在堆排序函数中,第一个循环用于建堆(阅读上述建堆的过程);第二个循环将堆顶节点不断地同后面节点交换,完成堆排序。
对于Adjust函数,对每个节点调整的时间复杂度为 O ( log 2 n ) O(\log_2n) O(log2n)。在HeapSort函数中,第一个循环的时间复杂度为 O ( log 2 n ) × n / 2 O(\log_2n)×n/2 O(log2n)×n/2;第二个循环的时间复杂度为 O ( log 2 n ) × ( n − 1 ) O(\log_2n)×(n-1) O(log2n)×(n−1)。所以,堆排序的时间复杂度为 O ( n log 2 n ) O(n\log_2n) O(nlog2n)。空间复杂度为 O ( 1 ) O(1) O(1)。同时,堆排序是一种不稳定的排序算法。
2. 交换类排序
2.1 冒泡排序
冒泡排序,顾名思义,排序过程中数字就像泡泡一样冒出。将数组按照从小到大排序时,通过两两比较相邻数字后将较大的数字交换到后面;每一趟排序后,较大的数字将集中在数组右端。举例如下:
原始数组[2, 7, 1, 5, 9, 8, 6]
现在以第一趟排序为例说明:2和7比较,7较大,位置不变;7和1比较,7较大,交换位置;7和5比较,7较大,交换位置;7和9比较,9较大,位置不变;9和8比较,9较大,交换位置;9和6比较,9较大,交换位置。
第一趟:2 1 5 7 8 6 9
第二趟:1 2 5 7 6 8 9
第三趟:1 2 5 6 7 8 9
此时得到最终结果,排序结束。
void BubbleSort(vector<int> &nums) {
int i, j, temp;
bool flag;
for (i = nums.size() - 1; i > 0; i--) {
flag = false;
for (j = 1; j <= i; j++) {
if (nums[j - 1] > nums[j]) {
temp = nums[j - 1];
nums[j - 1] = nums[j];
nums[j] = temp;
flag = true;
}
}
if (!flag) {
return;
}
}
}
由于冒泡排序在排序过程中是从后往前有序,第一层循环由后往前遍历,第二层循环由前往后遍历。同时设置一个标志flag,如果在一趟比较后没有发生交换,则表示序列已经有序,算法停止。
冒泡排序的时间复杂度:最好的情况下,原始数列有序,只进行最外层循环,为 O ( n ) O(n) O(n);最坏的情况下,原始数列无序,为 O ( n 2 ) O(n^2) O(n2);平均时间复杂度为 O ( n 2 ) O(n^2) O(n2)。空间复杂度为 O ( 1 ) O(1) O(1)。同时,冒泡排序是一种稳定的排序算法。
2.2 快速排序
快速排序算法采用了分治的思想,每次将数组分为两个子数组,然后采用递归的思想对每个子数组再次划分。快速排序的思想是:每次选取一个基准元素,经过一趟快速排序后,比基准元素小的在其左边;比基准元素大的在其右边。举例如下:
原始数组[2, 7, 1, 5, 9, 8, 6]
现在以第一趟排序为例说明:将2选为基准元素,6和2比较,6较大,位置不变;8和2比较,8较大,位置不变;9和2比较,9较大,位置不变;5和2比较,5较大,位置不变;1和2比较,2较大,交换位置;7和2比较,7较大,交换位置。
第一趟:1 2 7 5 9 8 6
第二趟:1 2 6 5 7 8 9
第三趟:1 2 5 6 7 8 9
此时得到最终结果,排序结束。
void QuickSort(vector<int> &nums, int low, int high) {
int temp;
int i = low, j = high;
if (low < high)
{
temp = nums[low];
while (i < j)
{
while (nums[j] > temp&&i < j) {
j--;
}
if (i < j) {
nums[i] = nums[j];
i++;
}
while (nums[i] < temp&&i < j) {
i++;
}
if (i < j) {
nums[j] = nums[i];
j--;
}
}
nums[i] = temp;
QuickSort(nums, low, i - 1);
QuickSort(nums, i + 1, high);
}
}
快速排序采用分治的思想每次将数组分为两个子数组,将数组的前面元素或者后面元素同基准元素比较以确定是否需要交换,整体采用递归的思想实现。
快速排序的时间复杂度:由快速排序的思想可知,在原始数组越接近无序时,时间复杂度最好为 O ( n log 2 n ) O(n\log_2n) O(nlog2n);在原始数组越接近有序时,时间复杂度最坏为 O ( n 2 ) O(n^2) O(n2);平均时间复杂度为 O ( n log 2 n ) O(n\log_2n) O(nlog2n)。空间复杂度为 O ( log 2 n ) O(\log_2n) O(log2n)。同时,快速排序是一种不稳定的排序算法。
3. 插入类排序
3.1 直接插入排序
直接插入排序从前往后遍历依次使序列有序。相应地,初始条件下,第一个元素有序;判断第一个元素和第二个元素是否有序,如果无序,则调整二者位置;然后依次往下判断。举例如下:
原始数组[2, 7, 1, 5, 9, 8, 6]
现在以前三趟排序为例说明:初始条件下只有一个元素2是有序的;接着判断2和7也是有序的;接着判断2, 7, 1是无序的,应该将1插入到2前面完成前面三个元素的有序。
第一趟:2 7 1 5 9 8 6
第二趟:2 7 1 5 9 8 6
第三趟:1 2 7 5 9 8 6
第四趟:1 2 5 7 9 8 6
第五趟:1 2 5 7 9 8 6
第六趟:1 2 5 7 8 9 6
第七趟:1 2 5 6 7 8 9
此时得到最终结果,排序结束。
void InsertSort(vector<int> &nums) {
for (int i = 1; i < nums.size(); i++) {
int temp = nums[i];
int j = i - 1;
while (j >= 0 && temp < nums[j]) {
nums[j + 1] = nums[j];
j--;
}
nums[j + 1] = temp;
}
}
直接插入排序的时间复杂度:最好的情况下,原始数列有序,只进行最外层循环,为 O ( n ) O(n) O(n);最坏的情况下,原始数列无序,为 O ( n 2 ) O(n^2) O(n2);平均时间复杂度为 O ( n 2 ) O(n^2) O(n2)。空间复杂度为 O ( 1 ) O(1) O(1)。同时,直接插入排序是一种稳定的排序算法。
3.2 希尔排序
希尔排序又叫做缩小增量排序。其基本思想是将原始序列分解成几个子序列,分别对子序列排序。而增量的选取影响分解方式,如果增量为1,则希尔排序退化为直接插入排序。举例如下:
为了更加直观了解希尔排序的过程,本次选用的原始数组为[2, 7, 1, 5, 9, 8, 6, 4, 10, 3]
(1)首先选择增量为5将序列分为几个子序列:
子序列1:2 8
子序列2: 7 6
子序列3: 1 4
子序列4: 5 10
子序列5: 9 3
使用直接插入排序对每个子序列排序得到:
子序列1:2 8
子序列2: 6 7
子序列3: 1 4
子序列4: 5 10
子序列5: 3 9
得到第一趟的排序结果为[2, 6, 1, 5, 3, 8, 7, 4, 10, 9]
(2)选择增量为3将序列分为几个子序列:
子序列1:2 5 7 9
子序列2: 6 3 4
子序列3: 1 8 10
使用直接插入排序对每个子序列排序得到:
子序列1:2 5 7 9
子序列2: 3 4 6
子序列3: 1 8 10
得到第二趟的排序结果为[2, 3, 1, 5, 4, 8, 7, 6, 10, 9]
(3)此时,得到的序列已经基本有序。选择增量为1即序列不划分,采用直接插入排序得到:[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
此时得到最终结果,排序结束。
假设增量以5、3、1的方式选取。
void ShellSort(vector<int> &nums) {
for (int delta = 5; delta > 0; delta -= 2) {
for (int i = 0; i < delta; i++) {
for (int j = i + delta; j < nums.size(); j += delta) {
int temp = nums[j];
int k = j - delta;
while (k >= 0 && temp < nums[k]) {
nums[k + delta] = nums[k];
k -= delta;
}
nums[k + delta] = temp;
}
}
}
}
取增量为delta。希尔排序的代码类似于直接插入排序,只是每次需要根据增量值对多个子数组(可在同一个数组内进行,也可以分开进行)进行排序。
希尔排序的时间复杂度和是根据增量的选取方式而决定的,这里不作讨论。空间复杂度为 O ( 1 ) O(1) O(1)。同时,希尔排序是一种不稳定的排序算法。
4. 其他类排序
4.1 归并排序
归并排序的核心思想是分治,把一个复杂的问题分成多个相同或相似的问题,然后将子问题继续划分,直到子问题可以简单地求解。归并排序的思路:首先将每个元素看作一个序列,有序;然后将元素两两合并形成子序列并排序,使得每个子序列均有序;然后将子序列继续合并,重复以上步骤直到序列整体有序。举例如下:
原始数组[2, 7, 1, 5, 9, 8, 6]
(1)初始有七个序列,显然每个子序列都是有序的:
子序列1:2
子序列2:7
子序列3:1
子序列4:5
子序列5:9
子序列6:8
子序列7:6
(2)现两两归并,形成若干二元组,并对每个二元组排序:
子序列1:2 7
子序列2:1 5
子序列3:8 9
子序列4:6
(3)继续两两归并,形成若干四元组,并排序:
子序列1:1 2 5 7
子序列2:6 8 9
(4)最后一次归并,并排序:
序列:1 2 5 6 7 8 9
此时得到最终结果,排序结束。
首先定义Merge函数,其作用是将数组R
的[low, mid]和[mid+1, high]合并,并使合并后的序列有序。
void Merge(vector<int> &R, int low, int mid, int high) {
int *temp = new int[high - low + 1];
int i = low, j = mid + 1, k = 0;
while (i <= mid && j <= high)
{
if (R[i] <= R[j]) {
temp[k++] = R[i++];
}
else
{
temp[k++] = R[j++];
}
}
while (i <= mid)
{
temp[k++] = R[i++];
}
while (j <= high)
{
temp[k++] = R[j++];
}
for (int t = 0; t < high - low + 1; t++) {
R[low + t] = temp[t];
}
delete[]temp;
}
首先定义一个temp
数组,长度为hihg-low+1。第一个while循环将较小的数字放入temp
数组中;第二个while循环和第三个while循环将未处理完的元素放入temp
数组中;最后将temp
数组的内容复制给R
,从而使R
的[low, high]部分有序。
下面是归并排序的主体部分,采用递归书写。
void MergeSort(vector<int> &nums, int low, int high) {
if (low < high) {
int mid = low + (high - low) / 2;
MergeSort(nums, low, mid);
MergeSort(nums, mid + 1, high);
// 用于将数组nums的区间[low,mid]和[mid,high]合并排序
Merge(nums, low, mid, high);
}
}
归并排序的时间复杂度为 O ( n log 2 n ) O(n\log_2n) O(nlog2n)。空间复杂度为 O ( n ) O(n) O(n)。同时,归并排序是一种稳定的排序算法。
4.2 基数排序/桶排序
基数排序的是思想是多关键字排序,将要排序的元素按照某种规则分配至桶内,通过多次入桶和出桶以达到排序的目的。这里的“桶”实质上是一个先进先出的队列。
为了更加直观了解基数排序的过程,本次选用的原始数组为[348, 119, 631, 30, 579, 284, 57, 169, 8, 20]。由于数字只有0~9共10位,所以这里需要10个“桶”。
(1)第一趟排序,根据每个数字的个位依次将其分配到指定桶中:
桶0:30 20
桶1:631
桶2:空
桶3:空
桶4:284
桶5:空
桶6:空
桶7:57
桶8:348 8
桶9:119 579 169
按照队列先进先出的规则从桶0到桶9开始收集数字,得到序列[30, 20, 631, 284, 57, 348, 8, 119, 579, 169]
(2)第二趟排序,根据每个数字的十位依次将其分配到指定桶中(如果数字只有一位,则使其十位为零,下同):
桶0:8
桶1:119
桶2:20
桶3:30 631
桶4:348
桶5:57
桶6:169
桶7:579
桶8:284
桶9:空
得到序列[8, 119, 20, 30, 631, 348, 57, 169, 579, 284]
(3)第三趟排序,根据每个数字的百位依次将其分配到指定桶中:
桶0:8 20 30 57
桶1:119 169
桶2:284
桶3:348
桶4:空
桶5:579
桶6:631
桶7:空
桶8:空
桶9:空
得到序列[8, 20, 30, 57, 119, 169, 284, 348, 579, 631]。此时序列有序,基数排序完成。
设n为关键字的位数(上述例子中为3),r为构成关键字的基的个数(上述例子中为10),d为排序过程中的趟数。
基数排序的时间复杂度为 O ( d ( n + r ) ) O(d(n+r)) O(d(n+r))。空间复杂度为 O ( r ) O(r) O(r)。同时,基数排序是一种稳定的排序算法。
4.3 计数排序
计数排序的思想是:对一个待排列表A进行排序,排序结果存储在另一个列表B中。首先,针对A中的每个关键字,扫描A;统计表中有多少个关键字比该关键字小,统计出数值c,则该关键字在序列B中的位置为c,即B[c]。举例如下:
原始数组A=[2, 7, 1, 5, 9, 8, 6],定义一个B
扫描数组A中的每个关键字:首先是数字2,比2小的关键字有1个(1),则2在数组B中的位置为1,即B[1]=2;数字7,比7小的关键字有4个(2、1、5、6),则7在B中的位置为4,即B[4]=7,依次类推得到:
B=[1, 2, 5, 6, 7, 8]
此时得到最终结果,排序结束。
代码一:
void CountSort(vector<int> &nums) {
int count;
vector<int> B(nums.size(), -1);
for (int i = 0; i < nums.size(); i++) {
count = 0;
for (int j = 0; j < nums.size(); j++) {
if (nums[j] < nums[i]) {
count++;
}
}
while (B[count] != -1) {
count++;
}
B[count] = nums[i];
}
for (int k = 0; k < nums.size(); k++) {
nums[k] = B[k];
}
}
代码中的while循环用于处理待排序列中含有重复关键字的情况。此时计数排序的时间复杂度为 O ( n 2 ) O(n^2) O(n2)。空间复杂度为 O ( n ) O(n) O(n)。
代码二:
void CountSort(vector<int> &nums) {
vector<int> count(10, 0);
for (int i = 0; i < nums.size(); i++) {
count[nums[i]]++;
}
int id = 0;
for (int j = 0; j < count.size(); j++) {
while (count[j] > 0)
{
nums[id] = j;
id++;
count[j]--;
}
}
}
首先声明一个count数组,数组的每个元素都是零,其中数组的长度k是待排序列的范围([0, k],上述例子中[0, 9]共10个数,所以count数组的长度是10)内的整数范围;第一个循环给count数组赋值;第二个for循环遍历count数组,同时使用while循环给原始nums数组赋值。此时计数排序的时间复杂度为 O ( n ) O(n) O(n)。空间复杂度为 O ( n ) O(n) O(n)。
如果待排数组的元素含有负数,计数排序需要对count数组特殊处理。 代码三:
void CountSort(vector<int> &nums) {
int ma = INT_MIN;
int mi = INT_MAX;
for (int i = 0; i < nums.size(); i++) {
ma = max(ma, nums[i]);
mi = min(mi, nums[i]);
}
int offset = 0 - mi;
vector<int> count(ma - mi + 1, 0);
for (int j = 0; j < nums.size(); j++) {
count[nums[j] + offset]++;
}
int id = 0;
for (int k = 0; k < count.size(); k++) {
while (count[k] > 0)
{
nums[id] = k - offset;
id++;
count[k]--;
}
}
}
首先第一个循环用于找到nums数组中的最大值和最小值以确定count数组的长度。定义一个变量offset表示存储负数时的偏移(偏移由数组中的最小值确定,例如-5为nums数组中的最小值,则-5存储在count数组的0处;1存储在count数组的6处,依次类推),最后给数组nums赋值时再根据offset对count数组反处理。此时计数排序的时间复杂度为 O ( n ) O(n) O(n)。空间复杂度为 O ( k ) O(k) O(k)(其中k是待排序列中元素的范围)。同时,计数排序是一种稳定的排序。
5. 排序算法总结
- 经过一趟排序,能够保证一个关键字达到最终位置,简单选择排序、堆排序、冒泡排序、快速排序
- 排序算法的关键字比较次数与数组的原始序列无关,简单选择排序、直接插入排序
- 排序算法的趟数与数组的原始序列无关,冒泡排序、快速排序
- 基数排序适合场景中的关键字数较多而组成关键字的元素范围很小
- 计数排序的时间复杂度和空间复杂度都非常高效,但对待排序列元素有限制
(时间)最好 | 最坏 | 平均 | 空间 | 稳定性 | |
---|---|---|---|---|---|
简单选择排序 | O ( n 2 ) O(n^2) O(n2) | O ( n 2 ) O(n^2) O(n2) | O ( n 2 ) O(n^2) O(n2) | O ( 1 ) O(1) O(1) | 否 |
堆排序 | O ( n log 2 n ) O(n\log_2n) O(nlog2n) | O ( n log 2 n ) O(n\log_2n) O(nlog2n) | O ( n log 2 n ) O(n\log_2n) O(nlog2n) | O ( 1 ) O(1) O(1) | 否 |
冒泡排序 | O ( n ) O(n) O(n) | O ( n 2 ) O(n^2) O(n2) | O ( n 2 ) O(n^2) O(n2) | O ( 1 ) O(1) O(1) | 是 |
快速排序 | O ( n log 2 n ) O(n\log_2n) O(nlog2n) | O ( n 2 ) O(n^2) O(n2) | O ( n log 2 n ) O(n\log_2n) O(nlog2n) | O ( log 2 n ) O(\log_2n) O(log2n) | 否 |
直接插入排序 | O ( n ) O(n) O(n) | O ( n 2 ) O(n^2) O(n2) | O ( n 2 ) O(n^2) O(n2) | O ( 1 ) O(1) O(1) | 是 |
希尔排序 | – | – | – | O ( 1 ) O(1) O(1) | 否 |
归并排序 | O ( n log 2 n ) O(n\log_2n) O(nlog2n) | O ( n log 2 n ) O(n\log_2n) O(nlog2n) | O ( n log 2 n ) O(n\log_2n) O(nlog2n) | O ( n ) O(n) O(n) | 是 |
基数排序/桶排序 | O ( d ( n + r ) ) O(d(n+r)) O(d(n+r)) | O ( d ( n + r ) ) O(d(n+r)) O(d(n+r)) | O ( d ( n + r ) ) O(d(n+r)) O(d(n+r)) | O ( r ) O(r) O(r) | 是 |
计数排序 | O ( n ) O(n) O(n) | O ( n 2 ) O(n^2) O(n2) | O ( n ) O(n) O(n) | O ( k ) O(k) O(k) | 是 |
参考
- 率辉. 2019版数据结构高分笔记[M]. 北京:机械工业出版社. 2018.1.
- 王道论坛. 2019年数据结构考研复习指导[M]. 北京:电子工业出版社, 2018.4.
- https://blog.youkuaiyun.com/afei__/article/details/82959924.