数据结构刚好复习到排序部分,排序主要分为三种类型,插入排序、交换排序、选择排序,为了更好理解和记忆,这里我将代码和记录下来,以备遗忘之用,亦可为后人参考~
插入排序的思想在于插入,即把要插入的数提取出来,找到要插入的位置,然后让该位置之后的数往后移动,可以理解为排队的时候,你让前k个人都往后移动一个位置,你站到空出来的位置上。主要类型有直接插入排序,折半插入排序,希尔排序,下面我将一一介绍。
直接插入排序是一个稳定的,时间复杂度O(n2),空间复杂度O(1)(辅助空间)的排序算法,是一种简单直观的排序算法,整体思路就是往排序好的数组插入当前遍历的数字,假设待排序表为L,且L[0]位置为空,实现步骤如下:
是不是很简单?尝试一波代码吧,代码如下:
#include
#include
using namespace std;
class Solution {
public:
void insertSort(vector<int>& A) {
int len = A.size();
int i, j, temp;
for(int i = 1; i < len; i ++) {
if(A[i] < A[i - 1]) {
temp = A[i];
for(j = i - 1; A[j] > temp && j >= 0; j --) {
A[j + 1] = A[j];
}
A[j + 1] = temp;
}
}
}
void print(vector<int>& A) {
for(int i = 0; i < A.size(); i ++) {
cout << A[i] << " ";
}
}
};
int main() {
Solution s;
int a[] = {9, 8, 7, 6, 5, 4, 3, 2, 1};
vector<int> A(a, a + 9);
s.insertSort(A);
s.print(A);
return 0;
}
标准的函数实现形式,还可以在main函数中进行测试,当然,你可能会困惑为什么这里使用temp存储当前遍历的值,而不是用第一个位置进行存储,因为那只是一个思想,如果在真实使用情况下,不是每个数组的第一个位置都是空的(应该说几乎没有),希望你读完代码之后会对直接插入算法有一个更深刻的印象,OK,让我们继续吧。
折半插入排序是一个稳定的,时间复杂度O(n2),空间复杂度O(1)(辅助空间)的排序算法,是在插入排序的基础上进行进一步的优化,当然思想还是没有变化的,只是在查找插入位置的时候使用了折半的思想,也就是二分法的思想,代码如下:
#include
#include
using namespace std;
class Solution {
public:
void halfInsertSort(vector<int>& A) {
int len = A.size();
int i, j, low, high, temp;
for(int i = 1; i < len; i ++) {
low = 0, high = i - 1, temp = A[i];
while(low <= high) {
int mid = (low + high) / 2;
if(A[mid] > temp) {
high = mid - 1;
} else low = mid + 1;
}
for(j = i - 1; j >= high + 1; j --) {
A[j + 1] = A[j];
}
A[high + 1] = temp;
}
}
void print(vector<int>& A) {
for(int i = 0; i < A.size(); i ++) {
cout << A[i] << " ";
}
}
};
int main() {
Solution s;
int a[] = {9, 8, 7, 6, 5, 4, 3, 2, 1};
vector<int> A(a, a + 9);
s.halfInsertSort(A);
s.print(A);
return 0;
}
整体步骤还是很巧妙的,当然并不能从很大程度上对直接插入进行优化,但对数据量不是很大的排序表来说,它往往可以表现出很好的性能。
希尔排序利用了巧妙的思想,可以在一些情况下降低复杂度,空间复杂度为O(1),时间复杂度为O(n1.3)至O(n2),这是一套不稳定的排序方法,但是无伤大雅。大致思想在于分割,每次将数组按照步长进行分割,形成形成 len / step 组子模块,在各自的子模块中进行直接插入排序,然后不断缩小步长范围,代码如下:
#include
#include
using namespace std;
class Solution {
public:
void shellSort(vector<int>& A) {
int len = A.size();
int step, i, j, temp;
for(step = len / 2; step >= 1; step /= 2) {
for(i = step; i < len; i ++) {
if(A[i] < A[i - step]) {
temp = A[i];
for(j = i - step; j >= 0 && A[j] > temp; j -= step) {
A[j + step] = A[j];
}
A[j + step] = temp;
}
}
}
}
void print(vector<int>& A) {
for(int i = 0; i < A.size(); i ++) {
cout << A[i] << " ";
}
}
};
int main() {
Solution s;
int a[] = {9, 8, 7, 6, 5, 4, 3, 2, 1};
vector<int> A(a, a + 9);
s.shellSort(A);
s.print(A);
return 0;
}
听起来很复杂但是实现起来还是很容易的,主要是这个方法的思路实在是太巧妙了!让整个序列慢慢变有序而不是一部分直接有序另一部分直接无序,给人一种豁然开朗的感觉,好了,直接插入算法部分也就介绍完了,接下来就是交换排序部分!让我们开始吧!
交换排序思想在于交换,即遇到不满足排序规则的两个数就要进行一个交换,主要类型有冒泡排序和快速排序,这是两个特别经典常用的算法,前者在于简单易于理解,后者在于时间复杂度化简到了O(nlogn),而且也很简单,下面我将一一介绍。
冒泡排序,顾名思义,就像泡泡一样,每次遍历都把最大的数冒上去(即放到数组的后面),空间复杂度为O(1),时间复杂度为O(n2),这也是一种稳定的算法,代码如下:
#include
#include
using namespace std;
// 交换函数
void swap(int& a, int& b) {
int temp = a;
a = b;
b = temp;
}
class Solution {
public:
void bubbleSort(vector<int>& A) {
int i, j;
int len = A.size();
bool flag;
for(int i = 0; i < len - 1; i ++) {
flag = false;
for(j = 1; j < len - i; j ++) {
if(A[j] < A[j - 1]) {
swap(A[j], A[j - 1]);
flag = true;
}
}
// 提前跳出,避免重复
if(! flag) {
break;
}
}
}
void print(vector<int>& A) {
for(int i = 0; i < A.size(); i ++) {
cout << A[i] << " ";
}
}
};
int main() {
Solution s;
int a[] = {9, 8, 7, 6, 5, 4, 3, 2, 1};
vector<int> A(a, a + 9);
s.bubbleSort(A);
s.print(A);
return 0;
}
首先对于i,目的在于遍历的次数,而不代表位置,所以最大遍历次数为len - 1。其次可以看到对代码还是进行了一些简单的优化,第一点在于j的范围在于 1 —— len - i,因为遍历i 次后最后i个元素时有序的,不需要进行比较了,第二点在于设立了flag,如果当前遍历没有进行比较,那么说明数组已经是有序的了,直接跳出即可。
快速排序算是算法入门级别较难的排序算法了,因为它应用了递归的方法,还有分治的思想,让它简洁且快速,空间复杂度为O(1),时间复杂度为O(nlogn),是一种不稳定的算法,但是在应用时没什么影响。整体思路是先定下一个基准(一般是最左的数),然后从左往右、从右往左找比该数大的和该数小的进行交换,直到碰头,碰头的位置放置该基准,这样基准左边的数都小于该数,右边的都大于该数,接在分别在左右区域各自进行同样操作(递归),代码如下:
#include
#include
using namespace std;
class Solution {
public:
void quickSort(vector<int>& A, int low, int high) {
if(low < high) {
int position = partition(A, low, high);
quickSort(A, low, position - 1);
quickSort(A, position + 1, high);
}
}
int partition(vector<int>& A, int low, int high) {
int position = A[low];
while(low < high) {
while(low < high && A[high] >= position) -- high;
A[low] = A[high];
while(low < high && A[low] <= position) ++ low;
A[high] = A[low];
}
A[low] = position;
return low;
}
void print(vector<int>& A) {
for(int i = 0; i < A.size(); i ++) {
cout << A[i] << " ";
}
}
};
int main() {
Solution s;
int a[] = {9, 8, 7, 6, 5, 4, 3, 2, 1};
vector<int> A(a, a + 9);
s.quickSort(A, 0, A.size() - 1);
s.print(A);
return 0;
}
可能需要注意的地方在交换那里,观察可以发现A[low] = A[high]
,这里直接赋值而没有用中间值存储,这是因为基准数已经用temp存储过了,所以覆盖的是基准值,之后的覆盖也都不会把已有的数抹去(因为前一个覆盖相当于复制总共两份,之后覆盖还剩一份),这也是为什么从右边开始的原因。好了,交换排序结束,选择排序正式开始!
选择排序每次都是选择的是位置,选择最小的位置记录下来,思想上和冒泡很像,只不过是实现++细节上的不同,选择排序有简单选择排序和堆排序,下面我一一介绍。
每次都找最小数的序号,然后与遍历的位置进行交换,可以理解成向前冒泡?代码如下:
#include
#include
using namespace std;
// 交换函数
void swap(int& a, int& b) {
int temp = a;
a = b;
b = temp;
}
class Solution {
public:
void selectSort(vector<int>& A) {
int len = A.size();
int index, i, j;
// 找最小的,放在前面
for(i = 0; i < len - 1; i ++) {
index = i;
for(j = i + 1; j < len; j ++) {
if(A[j] < A[index]) {
index = j;
}
}
if(index != i) {
swap(A[index], A[i]);
}
}
}
void print(vector<int>& A) {
for(int i = 0; i < A.size(); i ++) {
cout << A[i] << " ";
}
}
};
int main() {
Solution s;
int a[] = {9, 8, 7, 6, 5, 4, 3, 2, 1};
vector<int> A(a, a + 9);
s.selectSort(A);
s.print(A);
return 0;
}
没有什么技巧, 纯粹按照步骤去进行,所以也不过多讲解代码了,唯一注意和冒泡不同,这个是遍历一遍之后找到最小的位置才进行交换,认真看一遍就应该没问题。它是一种不稳定的算法,时间复杂度始终是O(n2)。接下来是重中之重,堆排序!
堆排序实现方式是用一维数据的形式实现,但是思想上理解成完全二叉树的形式,堆的种类包括最小堆和最大堆,空间复杂度O(1),时间复杂度O(nlogn),效率是很高的,在实际操作中一般用优先队列实现。当然在堆中,我们只使用根部的节点数值,也就是说,整个一维数组并不是规规矩矩的从大到小或者从小到大排序的,能确定的只是根部节点是最大值或者最小值,这和堆的定义有关,n个关键字序列L[1…n]称为堆,当且仅当该序列满足:
这里以最大堆为例,整体步骤是先建立最大堆,然后调用最大堆函数实现堆排序。代码如下:
#include
#include
using namespace std;
// 交换函数
void swap(int& a, int& b) {
int temp = a;
a = b;
b = temp;
}
class Solution {
public:
void buildMaxHeap(vector<int>& A) {
int len = A.size();
for(int i = len / 2; i > 0; i --) {
headAdjust(A, i, len);
}
}
// 对位置 k 为根的子树进行调整
void headAdjust(vector<int>& A, int k, int len) {
// 保存当前根位置的值
int temp = A[k];
for(int i = k; i < len; i *= 2) {
// 最大的子节点的值
if(i < len && A[i] < A[i + 1]) {
i ++;
}
// 如果比最大子节点还大,不用比较了,直接跳过
if(A[i] < temp) {
break;
} else {// 如果小,子节点上去,修改k的值继续往下
A[k] = A[i];
k = i;
}
}
A[k] = temp;
}
void heapSort(vector<int>& A) {
int len = A.size();
buildMaxHeap(A);
for(int i = len - 1; i > 0; i --) {
swap(A[i], A[0]);
headAdjust(A, 0, i - 1);
}
}
void print(vector<int>& A) {
for(int i = 0; i < A.size(); i ++) {
cout << A[i] << " ";
}
}
};
int main() {
Solution s;
int a[] = {9, 8, 7, 6, 5, 4, 3, 2, 1};
vector<int> A(a, a + 9);
s.heapSort(A);
s.print(A);
return 0;
}
可以看到在堆排序中我用了不少注释来帮助理解,因为确实有亿点点复杂,其核心在于headAdjust上,那我逐步介绍,首先是初始化建立堆,建立堆的过程就要让整个数组符合堆排序的要求,从n/2位置开始向前进行,每到一个节点就需要调用一次headAdjust函数,使以该位置为根的树符合堆的要求,在headAdjust中,从当前(根)开始遍历子树,拿跟和子节点最大的进行比较,如果大,直接break,如果小了,把大的放上去,根拿下来,再接着往下比较。
那堆就不能排序了吗?当然能!只要把每次堆的头放入最后,就能够实现从小到大排序,这也是heapSort的过程。