数据结构与算法导论妖怪 之
十大经典排序算法 妖
说起排序,没有人感觉陌生,从小到大我们都一路被形形色色的人怀着各种各样的目的所排名!尤其是学习成绩(手动狗头)。作为驯妖师的我来说,那时被排名所支配的心酸苦辣就是人生!不过今儿给大家念叨的是在计算机编程涉及到的排序算法,就是说,如何运用自己学到的编程知识,实现从数学概念中众多关系中找出排序这种关系,且越快越好,资源利用越高效越好。
首先给简单介绍一下有关算法复杂度的概念,因为在众多排序算法介绍之前,我们想要找到一个合适的算法的时候,该怎么考核呢?这就要涉及算法复杂度的概念,具体包括时间复杂度和空间复杂度,分别表示该算法执行程序语句次数的程度和占用内存空间大小的程度,这里统一用O()来表示,后续给大家详细介绍算法复杂度这只妖怪。
直奔主题,一一降伏这几只排序小妖:
1.冒泡排序:
冒泡排序这只妖怪是个近视眼,因为它只看重局部相邻的两个元素的大小关系,将不符合排序规则的两个相邻元素交换位置,这样经它过滤一遍后的序列,最大值或最小值就确定了(因为只有这样,才符合它过滤的结果,即局部大小关系符合排序规则)。而人们会可爱地称呼它“冒泡排序”,就好比气泡在水中冒出来一样(最值通过每次遍历一个个依次冒了出来)。
(图片来自 https://mp.weixin.qq.com/s/QaflNoxiI_FS4Ij8jg1KrQ)
下面快来看代码实现(python VS C++):
def bubbleSort(arr):
for i in range(1, len(arr)):
for j in range(0, len(arr)-i):
if arr[j] > arr[j+1]:
arr[j], arr[j + 1] = arr[j + 1], arr[j]
return arr
void bubbleSort(int* arr, int len) {
for (int i = 1; i < len; i ++) {
for (int j = 0; j < len - i; j ++) {
if (arr[j] > arr[i]) {
int tmp = arr[j];
arr[j] = arr[i];
arr[i] = tmp;
}
}
}
}
2.选择排序:
选择排序妖类似于冒泡排序,但是它目的性很强,每次过滤序列的时候,只认准最大值或最小值,虽然结果与冒泡一样,但是遍历的意义不一样,它是携带着当前它认为最大值或最小值来与当前元素进行比较,然后对比找当前最大值或最小值,继续向前,颇有“一旦选择了你,就要找到你”的爱情观。
(图片来自 https://mp.weixin.qq.com/s/QaflNoxiI_FS4Ij8jg1KrQ)
下面快来看代码实现(python VS C++):
def selectionSort(arr):
for i in range(len(arr) - 1):
# 记录最小数的索引
minIndex = i
for j in range(i + 1, len(arr)):
if arr[j] < arr[minIndex]:
minIndex = j
# i 不是最小数时,将 i 和最小数进行交换
if i != minIndex:
arr[i], arr[minIndex] = arr[minIndex], arr[i]
return arr
void selectionSort(int* arr, int len) {
for (int i = 0; i < len - 1; i ++) {
for (int j = i + 1; j < len; j ++) {
if (arr[j] < arr[i]) {
int tmp = arr[j];
arr[j] = arr[i];
arr[i] = tmp;
}
}
}
}
3.插入排序:
插入排序妖不难理解,它就是通过不断的新增元素来与之前排好序的元素进行比较,”见缝插针“式地找到自己的容身之地。有一形象的说法就好比大家玩扑克,一张一张起牌然后码牌一样,摸完了你的JQKA顺序也排好了,或者出现”JQKKA“,这个就比较纠结怎么出牌了,哈哈哈
来康康代码怎么实现:
def insertionSort(arr):
for i in range(len(arr)):
preIndex = i-1
current = arr[i]
while preIndex >= 0 and arr[preIndex] > current:
arr[preIndex+1] = arr[preIndex]
preIndex-=1
arr[preIndex+1] = current
return arr
void insertionSort(int* arr, int len) {
for (int i = 0; i < len; i ++) {
int current = arr[i];
int index = i - 1;
while (index >= 0 && arr[index] > current) {
arr[index + 1] = arr[index];
index--;
}
arr[index + 1] = current;
}
}
4.希尔排序:
希尔排序妖,也叫递减增量排序妖,是插入排序妖的近亲,它嫌弃插入排序每次只能移动一位效率低,因此它喜欢将原来序列分组,组内插入,因为组内元素个数较少,插入比较快,然后迭代着增加组内元素个数地再次分组,此时组内元素看是增多了,但是基本有序,这样就比直接插入快多了,直到最后整个序列分为一组计算结束。
什么?!你问我代码怎么写:
def shellSort(arr):
import math
gap=1
while(gap < len(arr)/3):
gap = gap*3+1
while gap > 0:
for i in range(gap,len(arr)):
temp = arr[i]
j = i-gap
while j >=0 and arr[j] > temp:
arr[j+gap]=arr[j]
j-=gap
arr[j+gap] = temp
gap = math.floor(gap/3)
return arr
}
def shellSort(arr):
import math
gap=1
while(gap < len(arr)/3):
gap = gap*3+1
while gap > 0:
for i in range(gap,len(arr)):
temp = arr[i]
j = i-gap
while j >=0 and arr[j] > temp:
arr[j+gap]=arr[j]
j-=gap
arr[j+gap] = temp
gap = math.floor(gap/3)
return arr
}
5.归并排序:
归并排序妖是分治法老妖的徒子徒孙,既可以自上而下的递归,又可以自下而上的迭代实现,和选择排序一样,归并排序的性能不受输入数据的影响,但表现比选择排序好的多,因为始终都是 O(nlogn) 的时间复杂度。代价是需要额外的内存空间。归并排序就是将原序列递归分解成各个子分组,然后简单比较大小就互换位置,然后再迭代着由小分组合并成大的有序分组,直到之后成为一个大分组,就是原来序列的有序序列。
(图片来自 https://mp.weixin.qq.com/s/QaflNoxiI_FS4Ij8jg1KrQ)
来一起看看代码:
def mergeSort(arr):
import math
if(len(arr)<2):
return arr
middle = math.floor(len(arr)/2)
left, right = arr[0:middle], arr[middle:]
return merge(mergeSort(left), mergeSort(right))
def merge(left,right):
result = []
while left and right:
if left[0] <= right[0]:
result.append(left.pop(0));
else:
result.append(right.pop(0));
while left:
result.append(left.pop(0));
while right:
result.append(right.pop(0));
return result
// 归并排序(C++-迭代版)
template<typename T>
void merge_sort(T arr[], int len) {
T* a = arr;
T* b = new T[len];
for (int seg = 1; seg < len; seg += seg) {
for (int start = 0; start < len; start += seg + seg) {
int low = start, mid = min(start + seg, len), high = min(start + seg + seg, len);
int k = low;
int start1 = low, end1 = mid;
int start2 = mid, end2 = high;
while (start1 < end1 && start2 < end2)
b[k++] = a[start1] < a[start2] ? a[start1++] : a[start2++];
while (start1 < end1)
b[k++] = a[start1++];
while (start2 < end2)
b[k++] = a[start2++];
}
T* temp = a;
a = b;
b = temp;
}
if (a != arr) {
for (int i = 0; i < len; i++)
b[i] = a[i];
b = a;
}
delete[] b;
}
// 归并排序(C++-递归版)
template<typename T>
void merge_sort_recursive(T arr[], T reg[], int start, int end) {
if (start >= end)
return;
int len = end - start, mid = (len >> 1) + start;
int start1 = start, end1 = mid;
int start2 = mid + 1, end2 = end;
merge_sort_recursive(arr, reg, start1, end1);
merge_sort_recursive(arr, reg, start2, end2);
int k = start;
while (start1 <= end1 && start2 <= end2)
reg[k++] = arr[start1] < arr[start2] ? arr[start1++] : arr[start2++];
while (start1 <= end1)
reg[k++] = arr[start1++];
while (start2 <= end2)
reg[k++] = arr[start2++];
for (k = start; k <= end; k++)
arr[k] = reg[k];
}
// merge_sort
template<typename T>
void merge_sort(T arr[], const int len) {
T reg[len];
merge_sort_recursive(arr, reg, 0, len - 1);
}
6.快速排序:
快速排序妖可是鼎鼎大名,快速排序又是一种分而治之思想在排序算法上的典型应用。本质上来看,快速排序应该算是在冒泡排序基础上的递归分治法。由于这个比较复杂也相对重要(面试老师经常会拿它来让你现场抓捕,嘤嘤嘤),所以这里简单总结思路:
(1)从数列中挑出一个元素,称为 “基准”(pivot);
(2)重新排序数列,所有元素比基准值小的摆放在基准前面,所有元素比基准值大的摆在基准的后面(相同的数可以到任一边)。在这个分区退出之后,该基准就处于数列的中间位置。这个称为分区(partition)操作;
(3)递归地(recursive)把小于基准值元素的子数列和大于基准值元素的子数列排序;
(图片来自 https://mp.weixin.qq.com/s/QaflNoxiI_FS4Ij8jg1KrQ)
快来学习代码:
def quickSort(arr, left=None, right=None):
left = 0 if not isinstance(left,(int, float)) else left
right = len(arr)-1 if not isinstance(right,(int, float)) else right
if left < right:
partitionIndex = partition(arr, left, right)
quickSort(arr, left, partitionIndex-1)
quickSort(arr, partitionIndex+1, right)
return arr
def partition(arr, left, right):
pivot = left
index = pivot+1
i = index
while i <= right:
if arr[i] < arr[pivot]:
swap(arr, i, index)
index+=1
i+=1
swap(arr,pivot,index-1)
return index-1
def swap(arr, i, j):
arr[i], arr[j] = arr[j], arr[i]
void quickSort(int* arr, int left, int right) {
if (left < right) {
int tmp = arr[left];
int i = left;
int j = right;
while (i < j) {
while (i < j && arr[j] > tmp) {
j--;
}
arr[i] = arr[j];
while (i < j && arr[i] <= tmp) {
i++;
}
arr[j] = arr[i];
}
arr[i] = tmp;
quickSort(arr, left, i - 1);
quickSort(arr, i + 1, right);
}
}
void quickSort(int* arr, int len) {
quickSort(arr, 0, len - 1);
}
这里啰嗦一句在《算法艺术与信息学竞赛》的话:
快速排序的最坏运行情况是 O(n²),比如说顺序数列的快排。但它的平摊期望时间是 O(nlogn),且 O(nlogn) 记号中隐含的常数因子很小,比复杂度稳定等于 O(nlogn) 的归并排序要小很多。所以,对绝大多数顺序性较弱的随机数列而言,快速排序总是优于归并排序。
7.堆排序:
推排序妖可是这十个经典排序算法里面最复杂也最难的一只妖怪,不过理解和运用熟练之后,大有可为,实乃行走江湖利器一只!它可以利用堆这种数据结构妖,堆是一个近似完全二叉树的结构妖,并同时满足堆积的性质:即子结点的键值或索引总是小于(或者大于)它的父节点。堆排序可以说是一种利用堆的概念来排序的选择排序。分为大根堆和小根堆,依次用于升序和降序排列。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-RFovXhbK-1593932053149)(D:\创业\公众号平台\数据结构与算法导论\推排序.gif)]
(图片来自 https://mp.weixin.qq.com/s/QaflNoxiI_FS4Ij8jg1KrQ)
其实驯妖师也没觉得很难,看降伏堆排序妖的代码:
def buildMaxHeap(arr):
import math
for i in range(math.floor(len(arr)/2),-1,-1):
heapify(arr,i)
def heapify(arr, i):
left = 2*i+1
right = 2*i+2
largest = i
if left < arrLen and arr[left] > arr[largest]:
largest = left
if right < arrLen and arr[right] > arr[largest]:
largest = right
if largest != i:
swap(arr, i, largest)
heapify(arr, largest)
def swap(arr, i, j):
arr[i], arr[j] = arr[j], arr[i]
def heapSort(arr):
global arrLen
arrLen = len(arr)
buildMaxHeap(arr)
for i in range(len(arr)-1,0,-1):
swap(arr,0,i)
arrLen -=1
heapify(arr, 0)
return arr
void heapSwap(int* arr, int a, int b) {
int tmp = arr[a];
arr[a] = arr[b];
arr[b] = tmp;
}
void adjustHeap(int* arr, int i, int len) {
int tmp = arr[i];
for (int k = i * 2 + 1; k < len; k = k * 2 + 1) {
if (k + 1 < len && arr[k] < arr[k + 1]) {
k++;
}
if (arr[k] > tmp) {
arr[i] = arr[k];
i = k;
}
else {
break;
}
}
arr[i] = tmp;
}
void heapSort(int* arr, int len) {
for (int i = len / 2 - 1; i >= 0; i --) {
adjustHeap(arr, i, len);
}
for (int i = len - 1; i > 0; i --) {
heapSwap(arr, 0, i);
adjustHeap(arr, 0, i);
}
}
8.计数排序:
计数排序妖是个不安常路出牌的小家伙,它将原始序列的元素值转化为键存储在额外开辟的数组空间中。同时它还挑食,输入的数据必须是有确定范围的整数才可以。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-EH5S4KVY-1593932053150)(D:\创业\公众号平台\数据结构与算法导论\计数排序.gif)]
捕捉代码:
def countingSort(arr, maxValue):
bucketLen = maxValue+1
bucket = [0]*bucketLen
sortedIndex =0
arrLen = len(arr)
for i in range(arrLen):
if not bucket[arr[i]]:
bucket[arr[i]]=0
bucket[arr[i]]+=1
for j in range(bucketLen):
while bucket[j]>0:
arr[sortedIndex] = j
sortedIndex+=1
bucket[j]-=1
return arr
void countSort(int* arr, int len) {
int max = INT_MIN;
for (int i = 0; i < len; i ++) {
if (max < arr[i]) {
max = arr[i];
}
}
int* count = new int[max + 1];
memset(count, 0, sizeof(int) * (max + 1));
for (int i = 0; i < len; i++) {
count[arr[i]]++;
}
int index = 0;
for (int i = 0; i < (max + 1); i ++) {
while (count[i]> 0) {
arr[index++] = i;
count[i]--;
}
}
}
9.桶排序
桶排序妖是计数排序的进化妖。它利用了函数的映射关系来将数据值转为K值,高效与否的关键就在于这个映射函数的确定。为了能够快速和准确抓住这只进化版本的妖怪,我们可以设计尽量多的桶(其实就是放数字的抽屉)和尽可能地均分这些数字。
很简单吧,看看代码:
def bucket_sort(s):
"""桶排序"""
min_num = min(s)
max_num = max(s)
# 桶的大小
bucket_range = (max_num-min_num) / len(s)
# 桶数组
count_list = [ [] for i in range(len(s) + 1)]
# 向桶数组填数
for i in s:
count_list[int((i-min_num)//bucket_range)].append(i)
s.clear()
# 回填,这里桶内部排序直接调用了sorted
for i in count_list:
for j in sorted(i):
s.append(j)
bool compare(int a, int b) {
return a <= b;
}
void bucketSort(int* arr, int len) {
int min = INT_MAX;
int max = INT_MIN;
for (int i = 0; i < len; i ++) {
if (min > arr[i]) {
min = arr[i];
}
if (max < arr[i]) {
max = arr[i];
}
}
float size = (max - min) /1.0 / len;
std::vector<std::vector<int> > count_list(len + 1);
for (int i = 0; i < len; i ++) {
count_list[(arr[i] - min)/size].push_back(arr[i]);
}
int index = 0;
for (int i = 0; i < count_list.size(); i ++) {
std::sort(count_list[i].begin(), count_list[i].end());
for (int j = 0; j < count_list[i].size(); j ++) {
arr[index++] = count_list[i][j];
}
}
}
10.基数排序:
基数排序妖是计数排序,桶排序的近亲,它一种非比较型整数排序算法,其原理是将整数按位数切割成不同的数字,然后按每个位数分别比较。由于整数也可以表达字符串(比如名字或日期)和特定格式的浮点数,所以基数排序也不是只能使用于整数。我们可以简单区分一下它们:
- 基数排序:根据键值的每位数字来分配桶;
- 计数排序:每个桶只存储单一键值;
- 桶排序:每个桶存储一定范围的数值;
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-cESoxf31-1593932053151)(D:\创业\公众号平台\数据结构与算法导论\基数排序.gif)]
(图片来自 https://mp.weixin.qq.com/s/QaflNoxiI_FS4Ij8jg1KrQ)
代码:
def RadixSort(list):
i = 0 #初始为个位排序
n = 1 #最小的位数置为1(包含0)
max_num = max(list) #得到带排序数组中最大数
while max_num > 10**n: #得到最大数是几位数
n += 1
while i < n:
bucket = {} #用字典构建桶
for x in range(10):
bucket.setdefault(x, []) #将每个桶置空
for x in list: #对每一位进行排序
radix =int((x / (10**i)) % 10) #得到每位的基数
bucket[radix].append(x) #将对应的数组元素加入到相 #应位基数的桶中
j = 0
for k in range(10):
if len(bucket[k]) != 0: #若桶不为空
for y in bucket[k]: #将该桶中每个元素
list[j] = y #放回到数组中
j += 1
i += 1
return list
int maxbit(int* data, int n) {
int d = 1;
for (int i = 0; i < n; i ++) {
int c = 1;
int p = data[i];
while (p / 10) {
p /= 10;
c++;
}
if (c > d) {
d = c;
}
}
return d;
}
void radixSort(int*arr, int len) {
int d = maxbit(arr, len);
int r = 1;
int count[10] = {0};
int tmp[10] = {0};
for (int i = 0; i < d; i ++) {
for (int j = 0; j < 10; j++) {
count[j] = 0;
}
for (int j = 0; j < len; j ++) {
int k = arr[j] / r;
int q = k % 10;
count[q]++;
}
for (int j = 1; j < 10; j ++) {
count[j] += count[j - 1];
}
for (int j = len - 1; j >= 0; j --) {
int p = arr[j] / r;
int s = p % 10;
tmp[count[s] - 1] = arr[j];
count[s]--;
}
for (int j = 0; j < len; j ++) {
arr[j] = tmp[j];
}
r *= 10;
}
}
介绍了以上十种经典的排序算法,在这里,驯妖师给大家补充一下其各自算法复杂度和稳定性质(排序后 2 个相等键值的顺序和排序之前它们的顺序相同,就说这个排序算法是稳定的,否则就是不稳定的)的对比表,以加强区分:
(图片来自 https://mp.weixin.qq.com/s/QaflNoxiI_FS4Ij8jg1KrQ)
n:数据规模 k:“桶”的个数 In-place:占用常数内存,不占用额外内存 Out-place:占用额外内存
以上就是今儿驯妖师给大家的叨叨,这只是初步认识,比较浅显地让大家有个感性和一点点理性认识,在实际项目中,如果有用到排序场景,应该依据每种排序算法的特点选择适合的算法,或者几种算法组合的版本,还有一些局部的改进版本等等,还是那句话编程如抓妖,键盘在手,抓妖不止! 哈哈哈
参考链接:
https://mp.weixin.qq.com/s/QaflNoxiI_FS4Ij8jg1KrQ
https://zhuanlan.zhihu.com/p/124356219