排序算法可以分为内部排序和外部排序,内部排序是数据记录在内存中进行排序。而外部排序是因为排序的数据很大,一次不能容纳全部的排序记录,在排序过程中需要访问外存。常见的排序算法有:冒泡排序、选择排序、插入排序、希尔排序、归并排序、快速排序、堆排序、计数排序、桶排序、基数排序。各排序算法之间的比较见下图:
- n: 数据规模
- k: 桶的个数
- in-place: 占用常数内存,不占用外部内存
- out-place: 占用外部内存
- 稳定性: 排序后两个相等键值的顺序和排序之前他们的顺序相同
- 冒泡排序: 数据对象:数组;描述:(无序区,有序区),从无序区通过交换找到最大元素放到有序区前端。
- 选择排序:数据对象:数组、链表;描述:(有序区、无序区),在一个无序区里找一个最小的元素跟在有序区的后面。对数组:比较较多,换得少。
- 插入排序:数据对象:数组、链表;描述:(有序区、无序区),把无序区的第一个元素插入到有序区的合适的位置。对数组:比较较少,换得多。
- 希尔排序:数据对象:数组;描述:每一轮按照事先决定的间隔进行插入排序,间隔会依次缩小,最后一次一定要是1。
- 归并排序:数据对象:数组、链表;描述:把数据分成两段,从两段中逐个选最小的元素移入新数据段的末尾。可以从上到下或从下到上进行。
- 快速排序:数据对象:数组;描述:(小数,基准元素,大数),在区间中随意挑选一个元素作为基准将小于基准的元素放在基准之前,大于基准的元素放在基准之后,在分别对小数区和大数区进行排序。
- 堆排序:数据对象:数组;描述:(最大堆,有序区),从堆顶把根卸放在有序区之前,再恢复堆。
- 计数排序:数据对象:数组、链表;描述:统计小于等于该元素值的元素的个数i,于是该元素放在目标数组的索引i位(i>.=0)
- 桶排序:数据对象:数组、链表;描述:将值为i的元素放入i号桶,最后依次把桶里的元素倒出来。
- 基数排序:数据对象:数组、链表;描述:一种多关键字的排序算法,可用桶排序实现。
关于时间复杂度
平方阶排序(O(n2))各类简单排序:冒泡排序、选择排序、插入排序。
O(n1+§)排序,§是介于0-1之间的常数,希尔排序。
线性对数阶(O(nlog2n))排序:归并排序、快速排序、堆排序。
线性阶(O(n))排序:计数排序、桶排序、基数排序。
关于稳定性
稳定的排序:冒泡排序、插入排序、归并排序、计数排序、桶排序和基数排序。
不稳定的排序:选择排序、希尔排序、快速排序和堆排序。
冒泡排序
冒泡排序(Bubble sort)也是一种简单直观的排序算法。它重复地走访要排序的数列,一次比较两个元素,如果他们的顺序错误就把他们交换过来。走访数列的工作是重复地进行直到没有再需要交换,也就是说该数列已经排序完成。这个算法的名字由来是`越小的元素经过交换慢慢“浮”到数列的顶端。
- 算法步骤
- 比较相邻的元素,如果第一个比第二个大,就交换他们两个。
- 对每一对相邻元素作同样的工作,从开始第一对到结尾的最后一对,这步做完后,最后的元素会是最大的数。
- 针对所有元素重复以上的步骤,除了最后一个。
- 持续每次越来越少的元素重复上面的步骤直到没有任何一对数字需要比较。
- 动图演示
- 什么时候最快
当输入的数据已经是正序时 - 什么时候最慢
当输入的数据是反序时 - c++代码实现
#include<iostream>
using namespace std;
//整数或浮点数皆可使用,若要使用类(class)或结构体(struct)时,
//必须重载大于(>)运算符。
template<typename T>
void bubble_sort(T arr[], int len)
{
for (int i = 0; i < len - 1; i++)
for (int j = 0; j < len - 1 - i; j++){
if (arr[j] > arr[j + 1])
swap(arr[j], arr[j + 1]);
}
}
int main(int argc, char** argv)
{
int arr[] = { 61, 17, 29, 22, 34, 60, 72, 21, 50, 1, 62 };
int len = static_cast<int>(sizeof(arr) / sizeof(arr[0]));
bubble_sort(arr, len);
for (int i = 0; i < len; i++)
cout << arr[i] << ' ';
cout << endl;
return 0;
}
选择排序
选择排序是一种简单直接的排序算法,无论什么数据进去都是O(n2)的时间复杂度。所以用到它的时候,数据规模越小越好,唯一的好处可能就是不占用额外的内存空间了吧。
- 算法步骤
- 首先在未排序序列中找到最小(大)元素,存放到排序序列的起始位置。
- 再从剩余未排序元素中继续寻找最小(大)元素,然后放到已排序序列的末尾。
- 重复第二步,直到所有元素均排序完毕。
- 动图演示
- c++代码实现
#include<iostream>
using namespace std;
template<typename T>
void selectSort(T arr[], T len)
{
for (int i = 0; i < len - 1; i++){
int minIndex = i;
for (int j = i + 1; j < len; j++){
if (arr[j] < arr[minIndex])
minIndex = j;
}
swap(arr[i], arr[minIndex]);
}
}
int main(int argc, char** argv)
{
int arr[] = { 61, 17, 29, 22, 34, 60, 72, 21, 50, 1, 62 };
int len = static_cast<int>(sizeof(arr) / sizeof(arr[0]));
selectSort(arr, len);
for (int i = 0; i < len; i++)
cout << arr[i] << ' ';
cout << endl;
return 0;
}
插入排序
插入排序的代码实现虽然没有冒泡排序和选择排序那么简单粗暴,但它的原理是最容易理解的了,因为只要打过扑克牌的人都应该能够秒懂。插入排序是一种最简单直观的排序算法。它的工作原理是通过构建有序序列,对于未排序数据,在已排序序列中从后向前扫描,找到相应位置并插入。
插入排序和冒泡排序一样,也有一种优化算法,叫做拆半插入。
- 算法步骤
- 将第一待排序序列第一个元素看做一个有序序列,把第二个元素到最后一个元素当成是未排序序列。
- 从头到尾依次扫描未排序序列,将扫描到的每个元素插入有序序列的适当位置。(如果待插入的元素与有序序列中的某个元素相等,则将待插入元素插入到相等元素的后面。)
- 动图演示
- c++代码实现
#include<iostream>
using namespace std;
//整数或浮点数皆可使用,若要使用类(class)或结构体(struct)时,
//必须重载大于(>)运算符。
template<typename T>
void insertSort(T arr[], int len)
{
for (int i = 1; i < len; i++){
int key = arr[i];
int j = i - 1;
while (j>=0 && key<arr[j]){
arr[j + 1] = arr[j];
j--;
}
arr[j + 1] = key;
}
}
int main(int argc, char** argv)
{
int arr[] = { 61, 17, 29, 22, 34, 60, 72, 21, 50, 1, 62 };
int len = static_cast<int>(sizeof(arr) / sizeof(arr[0]));
insertSort(arr, len);
for (int i = 0; i < len; i++)
cout << arr[i] << ' ';
cout << endl;
return 0;
}
希尔排序
希尔排序,也称递减递增排序算法,是插入排序的一种更高效的改进版本。但希尔排序是非稳定排序算法。
希尔排序是基于插入排序的以下两点性质而提出改进方法的:
- 插入排序在对几乎已经排好序的数据操作时,效率高,即可以达到线性排序的效率;
- 但插入排序一般来说是低效的,因为插入排序每次只能将数据移动一位;
希尔排序的基本思想是:先将整个待排序的记录序列分割成为若干子序列分别进行直接插入排序,待整个序列中的记录“基本有序”时,再对全体记录进行依次插入排序。
- 算法步骤
- 选择一个增量序列t1,t2,…,tk,其中ti>tj,tk = 1;
- 按增量序列个数k,对序列进行k趟排序;
- 每趟序列,根据对应的增量ti,将待排序序列分割成若干长度为m的子序列,分别对各个子表进行直接插入排序。仅增加因子为1时,整个序列作为一个表来处理,表长度即为整个序列的长度。
- 动图演示
- c++代码实现
#include<iostream>
using namespace std;
//整数或浮点数皆可使用,若要使用类(class)或结构体(struct)时,
//必须重载大于(>)运算符。
template<typename T>
void shellSort(T arr[], int len)
{
int h = 1;
while (h < len / 3)
{
h = 3 * h + 1;
}
while (h >= 1)
{
//将数组变成h有序
for (int i = h; i < len; i++){
//将a[i]插入到a[i-h]、a[i-2*h]、a[i-3*h] ...
for (int j = i; j >= h && arr[j] < arr[j - h]; j -= h)
swap(arr[j], arr[j - h]);
}
h = h / 3;
}
}
int main(int argc, char** argv)
{
int arr[] = { 61, 17, 29, 22, 34, 60, 72, 21, 50, 1, 62 };
int len = static_cast<int>(sizeof(arr) / sizeof(arr[0]));
shellSort(arr, len);
for (int i = 0; i < len; i++)
cout << arr[i] << ' ';
cout << endl;
return 0;
}
归并排序
归并排序(Merge sort)是建立在归并操作上的一种有效的排序算法。该算法是采用分治法(Divide and Conquer)的一个非常典型的应用。
作为一种典型的分而治之思想的算法应用,归并排序的实现有两种方法:
- 自上而下的递归(所有递归的方法都可以用迭代重写,所以有了第二种方法)
- 自下而上的迭代;
选择排序一样,归并排序的性能不受输入数据的影响,但表现比选择排序好的多,因为始终都是O(nlogn)的时间复杂度。代价是需要额外的内存空间。
- 算法步骤
- 申请空间,使其大小为两个已经排序序列之和,该空间用来存放合并后的序列
- 设定两个指针,最初位置分别为两个已经排序序列的起始位置。
- 比较两个指针所指向的元素。选择相对小的元素放入到合并空间,并移动指针到下一个位置;
- 重复步骤3直到某一指针达到序列的结尾;
- 将另一个序列所剩下的所有元素直接复制到合并序列尾。
- 动图演示
- C++ 代码实现
#include<iostream>
#include<vector>
using namespace std;
void Merge(vector<int> &arr, int front, int mid, int end)
{
//preconditions:
//arr[front ... mid] is sorted
//arr[mid+1 ... end] is sorted
//copy arr[front ... mid] to LeftSubArray
//copy arr[mid+1 ... end] to RightSubArray
vector<int> LeftSubArray(arr.begin() + front, arr.begin() + mid + 1);
vector<int> RightSubArray(arr.begin() + mid + 1, arr.begin() + end + 1);
int idxLeft = 0, idxRight = 0;
LeftSubArray.insert(LeftSubArray.end(), numeric_limits<int>::max());
RightSubArray.insert(RightSubArray.end(), numeric_limits<int>::max());
//Pick min of LeftSubArray[idxLeft] and RightSubArray[idxRight], and put into Array[i]
for (int i = front; i <= end; i++)
{
if (LeftSubArray[idxLeft] < RightSubArray[idxRight])
{
arr[i] = LeftSubArray[idxLeft];
idxLeft++;
}
else
{
arr[i] = RightSubArray[idxRight];
idxRight++;
}
}
}
void MergeSort(vector<int> &arr, int front, int end)
{
if (front >= end)
return;
int mid = (front + end) / 2;
MergeSort(arr, front, mid);
MergeSort(arr, mid + 1, end);
Merge(arr, front, mid, end);
}
int main(int argc, char** argv)
{
vector<int> arr = { 61, 17, 29, 22, 34, 60, 72, 21, 50, 1, 62 };
int len = arr.size();
MergeSort(arr, 0, len - 1);
for (int i = 0; i < len; i++)
cout << arr[i] << ' ';
cout << endl;
return 0;
}
快速排序
快速排序是由东尼 霍尔所发展的一种排序算法。在平均状况下,排序n个项目要O(nlogn)次比较。在最坏状况下则需要O(n2)比较,但这种状况并不常见。事实上,快速排序通常明显比其他O(nlogn)算法更快,因为它的内部循环(inner loop)可以在大部分的架构上很有效地实现出来。
快速排序使用分治法(Divide and conquer)策略来把一个串行(list)分为两个子串行(sub-list)。快速排序又是一种分而治之思想在排序算法上的典型应用。本质上来看,快速排序应该算是在冒泡排序基础上的递归分治法。
快速排序的名字起的是简单粗暴,因为一听到这个名字你就知道它存在的意义,就是快,而且效率高!他是处理大数据最快的排序算法之一了。虽然Worst Case的时间复杂度达到了O(n2),但是人家就是优秀,在大多数情况下都比平均时间复杂度O(nlogn)的排序算法表现要更好,可是为什么呢?
快速排序的最坏运行情况是O(n2),比如说顺序数列的快排。但它的平摊期望时间是O(nlogn),且O(nlogn)记号中隐含的常数因子很小,比复杂度稳定等于O(nlogn)的归并排序要小很多。所以,对绝大多数顺序性较弱的随机数列而言,快速排序总是优于归并排序。
- 算法步骤
- 从数列中挑选一个元素,称为“基准(pivot)”;
- 重新排序数列,所有元素比基准值小的摆放在基准前面,所有元素比基准值大的摆在基准的后面(相同的数可以到任一边)。在这个分区退出之后,该基准就处于数列的中间位置。这个成为分区(partition)操作;
- 递归地(recursive)把小于基准值元素的子数列和大于基准值元素的子数列排序;
- 动图演示
- c++代码实现
#include<iostream>
using namespace std;
//算法导论分割
int partiton(int A[], int p, int r)
{
int x = A[r];
int i = p - 1;;
for (int j = p; j < r; j++)
{
if (A[j] <= x)
{
i++;
swap(A[i], A[j]);
}
}
swap(A[i + 1], A[r]);
return i + 1;
}
void quickSort(int A[], int p, int r)
{
if (p < r)
{
int q = partiton(A, p, r);
quickSort(A, p, q - 1);
quickSort(A, q + 1, r);
}
}
int main(int argc, char** argv)
{
int arr[] = { 61, 17, 29, 22, 34, 60, 72, 21, 50, 1, 62 };
int len = int (sizeof(arr) / sizeof(arr[0]));
quickSort(arr, 0, len - 1);
for (int i = 0; i < len; i++)
cout << arr[i] << ' ';
cout << endl;
return 0;
}
堆排序
堆排序(HeapSort)是指利用堆这种数据结构所设计的一种排序算法。堆积的一种排序算法。堆积是一个近似完全二叉树的结构,并同时满足堆积的性质:即子节点的键值或索引总是小于(或大于)它的父节点。堆排序可以说是一种利用堆的概念来排序的选择排序。分为两种方法:
- 大顶堆: 每个节点的值都大于或等于其子节点的值,在堆排序算法中用于升序排序;
- 小顶堆: 每个节点的值都小于或等于其子节点的值,在堆排序算法中用于降序排列;
堆排序的平均时间复杂度为O(nlogn).
- 算法步骤
- 创建一个堆 H[0, … , n-1];
- 把堆首(最大)和堆尾互换;
- 把堆的尺寸缩小1,并调用shift_down(0),目的是把新的数组顶端数据调整到相应位置;
- . 重复步骤2,直到堆的尺寸为1。
- 动图演示
- c++代码实现
#include<iostream>
#include<algorithm>
using namespace std;
//维持堆的性质
void MaxHeapify(int A[], int i, int len)
{
int l = 2 * (i + 1) - 1;
int r = 2 * (i + 1);
int largest;
//选出A[i]、A[l]、A[r]的最大下标
if (l < len && A[l] > A[i])
largest = l;
else
largest = i;
if (r < len && A[r] > A[largest])
largest = r;
if (largest != i){
swap(A[i], A[largest]);
MaxHeapify(A, largest, len);
}
}
//建堆
void BuildMaxHeap(int A[], int len)
{
for (int i = len / 2 - 1; i >= 0; i--)
MaxHeapify(A, i, len);
}
//堆排序
void HeapSort(int A[], int len)
{
BuildMaxHeap(A, len);
for (int i = len-1; i > 0; i--){
swap(A[0], A[i]);
MaxHeapify(A, 0, i);
}
}
int main(int argc, char** argv)
{
int arr[] = { 61, 17, 29, 22, 34, 60, 72, 21, 50, 1, 62 };
int len = (int) sizeof(arr) / sizeof(int);
HeapSort(arr, len);
for (int i = 0; i < len; i++)
cout << arr[i] << ' ';
cout << endl;
return 0;
}
计数排序
计数排序的核心在于将输入的数据值转化为键存储在额外开辟的数组空间中。作为一种线性时间复杂度的排序,计数排序要求输入的数据必须要确定范围的整数。
- 计数排序的特征
当输入的元素是n个0-k的整数时,它的运行时间是O(n+k)。计数排序不是比较排序,排序的速度快于任何比较排序算法。
由于用来计数的数组C的长度取决于待排序数组中数据的范围(等于待排序数组的最大值与最小值的差加1),这使得计数排序对于范围很大的数组,需要大量时间和内存。例如:计数排序是用来排序0到100之间的数组的最好的算法,但是它不适合按字母顺序排序人名。但是,计数排序可以用在 基数排序中的算法来排序数据范围很大的数组。
通俗地理解,例如10个年龄不同的人,统计出有8个人的年龄比A小,那A的年龄就排在第9位,用这个方法可以得到其他每个人的位置,也就排好了序。当然,年龄有重复时需要特殊处理(保证稳定性),这就是为什么最后要反向填充目标数组,以及将每个数字的统计减1的原因。
- 算法步骤
- 找出待排序的数组中最大和最小的元素
- 统计数组中每个值为i的元素出现的次数,存入数组C的第i项
- 对所有的计数累加(从C中的每一个元素开始,每一项和前一项相加)
- 反向填充目标数组:将每个元素i放在新数组的C(i)项,每放一个元素就将C(i)减1
- 动图演示
- c++代码实现
#include<iostream>
#include<random>
#include<ctime>
using namespace std;
void CountingSort(int *arr, int *sorted_arr,int len)
{
int *count_arr = (int *)malloc(sizeof(int)*(100));
for (int i = 0; i < 100; i++)
count_arr[i] = 0;
for (int i = 0; i < len; i++)
count_arr[arr[i]]++;
for (int i = 1; i < 100; i++)
count_arr[i] += count_arr[i - 1];
for (int i = len - 1; i >= 0; i--)
sorted_arr[--count_arr[arr[i]]] = arr[i];
for (int i = 0; i < len; i++)
arr[i] = sorted_arr[i];
free(count_arr);
}
int main(int argc, char** argv)
{
int n = 10;
int *arr = (int *)malloc(sizeof(int)*(n));
int *sort_arr = (int *)malloc(sizeof(int)*(n));
srand(time(0));
for (int i = 0; i < n; i++){
arr[i] = rand() % 100;
}
for (int i = 0; i < n; i++)
cout << arr[i] << ' ';
cout << endl;
CountingSort(arr, sort_arr, n);
for (int i = 0; i < n; i++)
cout << sort_arr[i] << ' ';
cout << endl;
free(arr);
free(sort_arr);
return 0;
}
桶排序
桶排序是计数排序的升级版。它利用了函数的映射关系,高效与否的关键就在于这个映射函数的确定。为了使桶排序更加高效,我们需要做到这两点:
- 在额外空间充足的情况下,尽量增加桶的数量
- 使用的映射函数能够将输入的N个数据均匀的分配到K个桶中
同时,对于桶中元素的排序,选择何种比较排序算法对于性能的影响至关重要。
- 什么时候最快
当输入的数据可以均匀的分配到每一个桶中 - 什么时候最慢
当输入的数据被分配到同一个桶中 - 示意图
然后元素在每一个桶中排序:
c++代码实现
#include<iterator>
#include<iostream>
#include<vector>
using namespace std;
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 (curr != NULL && 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 (head1 != NULL && head2 != NULL)
{
if (head1->mData <= head2->mData)
{
dummy->mNext = head1;
head1 = head1->mNext;
}
else
{
dummy->mNext = head2;
head2 = head2->mNext;
}
dummy = dummy->mNext;
}
if (head1 != NULL) dummy->mNext = head1;
if (head2 != NULL) dummy->mNext = head2;
return dummyNode.mNext;
}
void BucketSort(int arr[],int len)
{
vector<ListNode*> buckets(BUCKET_NUM, (ListNode *)(0));
for (int i = 0; i < len; 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 < len; i++)
{
arr[i] = head->mData;
head = head->mNext;
}
}
int main(int argc, char** argv)
{
int arr[] = { 61, 17, 29, 22, 34, 60, 72, 21, 50, 1, 62 };
int len = static_cast<int>(sizeof(arr) / sizeof(arr[0]));
BucketSort(arr, len);
for (int i = 0; i < len; i++)
cout << arr[i] << ' ';
cout << endl;
return 0;
}
基数排序
基数排序是一种非比较型整数排序算法,其原理是将整数按位数切割成不同的数字,然后按每个位数分别比较。由于整数也可以表达字符串(比如名字或日期)和特定格式的浮点数,所以基数排序也不是只能使用于整数。
- 基数排序 vs 计数排序 vs 桶排序
基数排序有两种方法:
这三种排序算法都利用了桶的概念,但对桶的使用方法上有明显差异
- 基数排序:根据键值的每位数字来分配桶
- 计数排序:每个桶只存储单一键值
- 桶排序:每个桶存储一定范围的数值
- LSD基数排序动图演示
- c++代码实现
#include<iostream>
using namespace std;
//求数据的最大位数
int maxbit(int arr[], int len)
{
int d = 1;
const int p = 10;
int maxData = arr[0];
for (int i = 1; i < len; i++)
{
if (maxData < arr[i])
maxData = arr[i];
}
while (maxData >= p)
{
maxData /= p;
++d;
}
return d;
}
void radixSort(int arr[], int len)
{
int d = maxbit(arr, len);
int *temp = new int[len];
int *count = new int[10];//计数器
int radix = 1;
for (int i = 1; i <= d; i++)//进行d次排序
{
for (int j = 0; j < 10; j++)
count[j] = 0;
for (int j = 0; j < len; j++)
{
int k = (arr[j] / radix) % 10;
count[k]++;
}
for (int j = 1; j < 10; j++)
{
count[j] += count[j - 1];//将tmp中的位置依次分配给每个桶
}
for (int j = len - 1; j >= 0; j--)//将所有桶中记录依次收集到tmp中
{
int k = (arr[j] / radix) % 10;
temp[count[k] - 1] = arr[j];
count[k]--;
}
for (int j = 0; j < len; j++)//将临时数组的内容复制到arr中
arr[j] = temp[j];
radix *= 10;
}
delete temp;
delete count;
}
int main(int argc, char** argv)
{
int arr[] = { 61, 17, 29, 22, 34, 60, 72, 21, 50, 1, 62 };
int len = (int) sizeof(arr) / sizeof(arr[0]);
radixSort(arr, len);
for (int i = 0; i < len; i++)
cout << arr[i] << ' ';
cout << endl;
return 0;
}