0%

解析经典排序算法

总结常用的排序算法,比如快速排序、选择排序、桶排序、堆排序、冒泡排序,分别进行原理分析,复杂度分析,使用场景区分,适用的数据结构,并进行对比总结。

首先总结表如下:

排序方法 平均时间复杂度 最好情况 最坏情况 空间复杂度 是否稳定 排序方式
冒泡排序 $O(n^2)$ $O(n)$ $O(n^2)$ $O(1)$ 稳定 内部排序
选择排序 $O(n^2)$ $O(n^2)$ $O(n^2)$ $O(1)$ 不稳定 内部排序
插入排序 $O(n^2)$ $O(n)$ $O(n^2)$ $O(1)$ 稳定 内部排序
希尔排序 $O(n\log n)$ $O(n\log ^2 n)$ $O(n\log ^2 n)$ $O(1)$ 不稳定 内部排序
快速排序 $O(n\log n)$ $O(n\log n)$ $O(n^2)$ $O(\log n)$ 不稳定 内部排序
归并排序 $O(n\log n)$ $O(n\log n)$ $O(n\log n)$ $O(n)$ 稳定 外部排序
堆排序 $O(n\log n)$ $O(n\log n)$ $O(n\log n)$ $O(1)$ 不稳定 内部排序
桶排序 $O(n+k)$ $O(n+k)$ $O(n^2)$ $O(k)$ 稳定 外部排序
计数排序 $O(n+k)$ $O(n+k)$ $O(n+k)$ $O(k)$ 稳定 外部排序

1 冒泡排序

1.1 算法简述

两两比较,选出较大值,每一轮选出单轮最大值到数组末端,这个过程就像泡泡变大的过程,所以交冒泡算法。

代码

1
2
3
4
5
6
7
8
9
10
11
12
void bubbleSort(vector<int>& nums) {
for (int i = 0; i < nums.size() - 1; i++) {
//int flag = 0;
for (int j = 0; j < nums.size() - 1 - i; j++) {
if (nums[j] > nums[j + 1]) {
swap(nums[j], nums[j + 1]);
//flag = 1;
}
}
//if(flag == 0) return;
}
}

1.2 优化

定义一个flag,只要进行交换就置为1.当某一次内层for循环没有交换过flag为0,那么说明已经排序好了

1.3 最好情况

数组已经是正序排列时,仅有比较,没有交换

1.4 最坏情况

数组是反序排列时,每一轮都需要交换

2选择排序

选择排序是一种简单直观的排序算法,无论什么数据进去都是 O(n²) 的时间复杂度。所以用到它的时候,数据规模越小越好。唯一的好处可能就是不占用额外的内存空间了吧。

2.1 算法简述

首先在未排序序列中找到最小(大)元素,存放到排序序列的起始位置。

再从剩余未排序元素中继续寻找最小(大)元素,然后放到已排序序列的末尾。

重复第二步,直到所有元素均排序完毕。

2.2 代码

1
2
3
4
5
6
7
8
9
void selection_sort(vector<int>& nums) {
for (int i = 0; i < nums.size(); i++) {
int min = i;
for (int j = i + 1; j < nums.size(); j++) {
if (nums[min] > nums[j]) min = j;
}
swap(nums[min], nums[i]);
}
}

3 插入排序

插入排序的代码实现虽然没有冒泡排序和选择排序那么简单粗暴,但它的原理应该是最容易理解的了,因为只要打过扑克牌的人都应该能够秒懂。插入排序是一种最简单直观的排序算法,它的工作原理是通过构建有序序列,对于未排序数据,在已排序序列中从后向前扫描,找到相应位置并插入。

插入排序和冒泡排序一样,也有一种优化算法,叫做拆半插入。

3.1 算法简述

将第一待排序序列第一个元素看做一个有序序列,把第二个元素到最后一个元素当成是未排序序列。

从头到尾依次扫描未排序序列,将扫描到的每个元素插入有序序列的适当位置。(如果待插入的元素与有序序列中的某个元素相等,则将待插入元素插入到相等元素的后面。)

3.2 代码

1
2
3
4
5
6
7
8
9
10
11
12
13
void insertion_sort(vector<int>& nums) {
for (int i = 1; i < nums.size(); i++) {
int num = nums[i];
int j = i - 1;
while (j >= 0 && num < nums[j]) {
// 当j大于0且有序区间值大于num时,向左移动
nums[j + 1] = nums[j];
j--;
}
//优化,折半查找法
nums[j + 1] = num;
}
}

3.3优化

已排序的序列中,序列元素都是按照顺序排列的,直接比较已排序序列的中位数,这个中间的位置将一排序列分为左右两部分,通过一次比较后,就缩小了比较的范围,重复这样的操作,需要插入的元素就找到了合适的位置了。

4 希尔排序

希尔排序,也称递减增量排序算法,是插入排序的一种更高效的改进版本。但希尔排序是非稳定排序算法。

希尔排序是基于插入排序的以下两点性质而提出改进方法的:

插入排序在对几乎已经排好序的数据操作时,效率高,即可以达到线性排序的效率;
但插入排序一般来说是低效的,因为插入排序每次只能将数据移动一位;
希尔排序的基本思想是:先将整个待排序的记录序列分割成为若干子序列分别进行直接插入排序,待整个序列中的记录”基本有序”时,再对全体记录进行依次直接插入排序。

4.1 算法简述

选择一个增量序列 t1,t2,……,tk,其中 ti > tj, tk = 1;

按增量序列个数 k,对序列进行 k 趟排序;

每趟排序,根据对应的增量 ti,将待排序列分割成若干长度为 m 的子序列,分别对各子表进行直接插入排序。仅增量因子为 1 时,整个序列作为一个表来处理,表长度即为整个序列的长度。

4.2 代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void shell_sort(vector<int>& nums) {
int h = 1;
while (3 * h < nums.size()) {
h = 3 * h + 1;
}
while (h >= 1) {
for (int i = h; i < nums.size(); i++) {
for (int j = i; j >= h && nums[j] < nums[j - h]; j -= h) {
swap(nums[j], nums[j - h]);
}
}
h = h / 3;
}
}

5 快速排序

快速排序是由东尼·霍尔所发展的一种排序算法。在平均状况下,排序 n 个项目要 Ο(nlogn) 次比较。在最坏状况下则需要 Ο(n2) 次比较,但这种状况并不常见。事实上,快速排序通常明显比其他 Ο(nlogn) 算法更快,因为它的内部循环(inner loop)可以在大部分的架构上很有效率地被实现出来。

快速排序使用分治法(Divide and conquer)策略来把一个串行(list)分为两个子串行(sub-lists)。

快速排序又是一种分而治之思想在排序算法上的典型应用。本质上来看,快速排序应该算是在冒泡排序基础上的递归分治法。

快速排序的名字起的是简单粗暴,因为一听到这个名字你就知道它存在的意义,就是快,而且效率高!它是处理大数据最快的排序算法之一了。虽然 Worst Case 的时间复杂度达到了 O(n²),但是人家就是优秀,在大多数情况下都比平均时间复杂度为 O(n logn) 的排序算法表现要更好,可是这是为什么呢,我也不知道。好在我的强迫症又犯了,查了 N 多资料终于在《算法艺术与信息学竞赛》上找到了满意的答案:

快速排序的最坏运行情况是 O(n²),比如说顺序数列的快排。但它的平摊期望时间是 O(nlogn),且 O(nlogn) 记号中隐含的常数因子很小,比复杂度稳定等于 O(nlogn) 的归并排序要小很多。所以,对绝大多数顺序性较弱的随机数列而言,快速排序总是优于归并排序。

5.1 算法简述

  1. 从数列中挑出一个元素,称为 “基准”(pivot);

  2. 重新排序数列,所有元素比基准值小的摆放在基准前面,所有元素比基准值大的摆在基准的后面(相同的数可以到任一边)。在这个分区退出之后,该基准就处于数列的中间位置。这个称为分区(partition)操作;

  3. 递归地(recursive)把小于基准值元素的子数列和大于基准值元素的子数列排序;

5.2 代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// 快速排序
int Paritition(vector<int>& nums, int low, int high) {
//返回基准pivot的nums的序号
int pivot = nums[low];
while (low < high) {
//将nums分为小于基准和大于基准的两侧
while (low < high && nums[high] >= pivot) --high;
nums[low] = nums[high];
while (low < high && nums[low] <= pivot) ++low;
nums[high] = nums[low];
}
nums[low] = pivot;
return low;
}

void QuickSort(vector<int>& nums, int low, int high) {
if (low < high) {
int pivot = Paritition(nums, low, high);
QuickSort(nums, low, pivot - 1);//递归小于基准部分
QuickSort(nums, pivot + 1, high);//递归大于基准部分
}
}

void QuickSort(vector<int>& nums) {
QuickSort(nums, 0, nums.size() - 1);
}

6 归并排序

归并排序(Merge sort)是建立在归并操作上的一种有效的排序算法。该算法是采用分治法(Divide and Conquer)的一个非常典型的应用。

作为一种典型的分而治之思想的算法应用,归并排序的实现由两种方法:

自上而下的递归(所有递归的方法都可以用迭代重写,所以就有了第 2 种方法);
自下而上的迭代;

和选择排序一样,归并排序的性能不受输入数据的影响,但表现比选择排序好的多,因为始终都是 O(nlogn) 的时间复杂度。代价是需要额外的内存空间。

6.1 算法简述

  1. 申请空间,使其大小为两个已经排序序列之和,该空间用来存放合并后的序列;

  2. 设定两个指针,最初位置分别为两个已经排序序列的起始位置;

  3. 比较两个指针所指向的元素,选择相对小的元素放入到合并空间,并移动指针到下一位置;

  4. 重复步骤 3 直到某一指针达到序列尾;

  5. 将另一序列剩下的所有元素直接复制到合并序列尾。

6.2 代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// 归并排序
void Merge(vector<int>& nums, int front, int mid, int end) {
vector<int> LeftSubArray(nums.begin() + front, nums.begin() + mid + 1);
vector<int> RightSubArray(nums.begin() + mid + 1, nums.begin() + end + 1);
int idxLeft = 0, idxRight = 0;
LeftSubArray.insert(LeftSubArray.end(), numeric_limits<int>::max());
RightSubArray.insert(RightSubArray.end(), numeric_limits<int>::max());
for (int i = front; i <= end; i++) {
if (LeftSubArray[idxLeft] < RightSubArray[idxRight]) {
nums[i] = LeftSubArray[idxLeft];
idxLeft++;
}
else {
nums[i] = RightSubArray[idxRight];
idxRight++;
}
}
}

void MergeSort(vector<int>& nums, int front, int end) {
if (front >= end) return;
int mid = (front + end) / 2;
MergeSort(nums, front, mid);
MergeSort(nums, mid + 1, end);
Merge(nums, front, mid, end);
}

void MergeSort(vector<int>& nums) {
MergeSort(nums, 0, nums.size() - 1);
}

7 堆排序

堆排序(Heapsort)是指利用堆这种数据结构所设计的一种排序算法。堆积是一个近似完全二叉树的结构,并同时满足堆积的性质:即子结点的键值或索引总是小于(或者大于)它的父节点。堆排序可以说是一种利用堆的概念来排序的选择排序。分为两种方法:

大顶堆:每个节点的值都大于或等于其子节点的值,在堆排序算法中用于升序排列;
小顶堆:每个节点的值都小于或等于其子节点的值,在堆排序算法中用于降序排列;
堆排序的平均时间复杂度为 Ο(nlogn)。

7.1 算法简述

  1. 创建一个堆 H[0……n-1];

  2. 把堆首(最大值)和堆尾互换;

  3. 把堆的尺寸缩小 1,并调用 shift_down(0),目的是把新的数组顶端数据调整到相应位置;

  4. 重复步骤 2,直到堆的尺寸为 1。

7.2 代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
void max_heapify(int arr[], int start, int end) {
int dad = start;
int son = dad * 2 + 1;
while (son <= end) {
if (son + 1 <= end && arr[son] < arr[son + 1])
son++;
if (arr[dad] > arr[son])
return;
else {
swap(arr[dad], arr[son]);
dad = son;
son = dad * 2 + 1;
}
}
}

void heap_sort(int arr[], int len) {
for (int i = len / 2 - 1; i >= 0; i--)
max_heapify(arr, i, len - 1);
for (int i = len - 1; i > 0; i--) {
swap(arr[0], arr[i]);
max_heapify(arr, 0, i - 1);
}
}

8 计数排序

计数排序的核心在于将输入的数据值转化为键存储在额外开辟的数组空间中。作为一种线性时间复杂度的排序,计数排序要求输入的数据必须是有确定范围的整数。

当输入的元素是 n 个 0 到 k 之间的整数时,它的运行时间是 Θ(n + k)。计数排序不是比较排序,排序的速度快于任何比较排序算法。

由于用来计数的数组C的长度取决于待排序数组中数据的范围(等于待排序数组的最大值与最小值的差加上1),这使得计数排序对于数据范围很大的数组,需要大量时间和内存。

类似于哈希法

8.1 算法简述

(1)找出待排序的数组中最大和最小的元素
(2)统计数组中每个值为i的元素出现的次数,存入数组C的第i项
(3)对所有的计数累加(从C中的第一个元素开始,每一项和前一项相加)
(4)反向填充目标数组:将每个元素i放在新数组的第C(i)项,每放一个元素就将C(i)减去1

8.2 代码

1

9 桶排序

9.1 算法简述

桶排序是计数排序的升级版。它利用了函数的映射关系,高效与否的关键就在于这个映射函数的确定。为了使桶排序更加高效,我们需要做到这两点:

在额外空间充足的情况下,尽量增大桶的数量
使用的映射函数能够将输入的 N 个数据均匀的分配到 K 个桶中
同时,对于桶中元素的排序,选择何种比较排序算法对于性能的影响至关重要。

9.2 代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
const int BUCKET_NUM = 10;
struct ListNode{
explicit ListNode(int i=0):mData(i),mNext(NULL){}
ListNode* mNext;
int mData;
};

ListNode* insert(ListNode* head,int val){
ListNode dummyNode;
ListNode *newNode = new ListNode(val);
ListNode *pre,*curr;
dummyNode.mNext = head;
pre = &dummyNode;
curr = head;
while(NULL!=curr && curr->mData<=val){
pre = curr;
curr = curr->mNext;
}
newNode->mNext = curr;
pre->mNext = newNode;
return dummyNode.mNext;
}


ListNode* Merge(ListNode *head1,ListNode *head2){
ListNode dummyNode;
ListNode *dummy = &dummyNode;
while(NULL!=head1 && NULL!=head2){
if(head1->mData <= head2->mData){
dummy->mNext = head1;
head1 = head1->mNext;
}else{
dummy->mNext = head2;
head2 = head2->mNext;
}
dummy = dummy->mNext;
}
if(NULL!=head1) dummy->mNext = head1;
if(NULL!=head2) dummy->mNext = head2;

return dummyNode.mNext;
}

void BucketSort(int n,int arr[]){
vector<ListNode*> buckets(BUCKET_NUM,(ListNode*)(0));
for(int i=0;i<n;++i){
int index = arr[i]/BUCKET_NUM;
ListNode *head = buckets.at(index);
buckets.at(index) = insert(head,arr[i]);
}
ListNode *head = buckets.at(0);
for(int i=1;i<BUCKET_NUM;++i){
head = Merge(head,buckets.at(i));
}
for(int i=0;i<n;++i){
arr[i] = head->mData;
head = head->mNext;
}
}

9.3 最好情况

当输入的数据可以均匀的分配到每一个桶中。

9.4 最坏情况

当输入的数据被分配到了同一个桶中。

感谢看到这里,在记录中收获成长,道阻且长