①排序的基本概念
假设有n个记录的序列为{r1, r2, …, rn},其对应的关键字分别为{k1, k2, k3, …, kn},需要确定1, 2, …, n的一种排列p1, p2, …, pn,使得其对应的关键字满足,kp1 ≤ kp2 ≤ … ≤ kpn,即使得序列称为一个按关键字有序的序列{rp1, rp2, … rpn},这样的操作就叫做排序。
如:序列关键字为{2, 3, 5, 1},对其进行升序排列后,得到的结果为{1, 2, 3, 5}。
②排序的稳定性
排序不仅可以针对主关键字排序(一个主关键字对应一条唯一的记录),也可以针对次关键字排序(一条次关键字对应多条记录),因为排序的记录序列中可能出现两个或两个以上的关键字相等的记录,排序结果可能出现不唯一的情况,因此我们给出了稳定与不稳定排序的定义如下:
假设ki = kj(i ≤ i ≤ n, 1 ≤ j ≤ n, i ≠ j),且在排序前的序列中ri领先于rj(i < j)。如果排序后ri仍领先rj,则称为所用的排序方法是稳定的;反之,可能使得排序后的序列中rj领先于ri,则称所用的排序方法是不稳定的。
如:下记录表对分数进行升序排列。
未排序时张三在钱六前面,对于稳定排序后,张三依旧在钱六前面;然而对于不稳定排序,可能出现张三在钱六前面,也可能出现钱六在张三前面。
③内排序与外排序
内排序:在排序整个过程中,待排序的所有记录全部放置在内存中。
外排序:由于排序的记录太多,不能同时放在内存中,整个排序过程需要在内外存之间多次交换数据才能进行。
④影响排序算法的性能的因素
冒泡排序是一种交换排序,它的基本思想是:两两比较相邻记录的关键字,如果反序则交换,直到没有反序的记录为止。
每一次冒泡将序列未排序部分的最大值冒泡到未排序部分序列末尾。
如:待排序序列是{9,5,1,8,3},序列长度为5。
先来看第一次冒泡过程:
黄色为待排序序列,红色代表已经排序完成的部分,每一次冒泡是对上一次冒泡后的待排序序列进行排序。 整个排序过程如下图:
其C++代码如下:
void bubbleSort(vector<int> &nums) {
//设记录序列长度为n
//外循环,冒泡次数为n-1次
for (int i = 0; i < nums.size() - 1; ++i) {
//内循环,每一次冒泡需要比较n - i次
for (int j = 1; j < nums.size() - i; ++j) {
if (nums[j] < nums[j - 1]) {
swap(nums[j], nums[j - 1]);
}
}
}
}
冒泡排序时间复杂度为:O(n^2)。
冒泡排序空间复杂度为:O(1)。
简单选择排序算法就是通过n - i次关键字间的比较,从n - i + 1个记录中选出关键字最小的记录,并和第i(1 ≤ i ≤ n)个记录交换。
每次从待排序列种选出一个最小值,然后与待排序序列的第一个元素互换,直到全部待排序列数据排完即可。
如:待排序序列为{9,5,1,8,3},序列长度为5
先来看一次选择过程:
黄色为待排序序列,红色代表已经排序完成的部分,每一次选择是对上一次选择后的待排序序列进行排序。 整个排序过程如下图:
其C++代码如下:
void selectSort(vector<int> &nums) {
//设记录序列长度为n
//外循环,选择次数为n-1次
for (int i = 0; i < nums.size() - 1; ++i) {
int min = i; //选择待排序序列的第一个元素作为每次选择开始的最小值
//内循环,每一次选择需要比较n - i - 1次
for (int j = i + 1; j < nums.size(); ++j) {
if (nums[j] < nums[min]) {
min = j;
}
}
if (min != i) {
swap(nums[i], nums[min]);
}
}
}
直接选择排序时间复杂度为:O(n^2)。 虽然都是O(n ^2),但简单选择排序性能略优于冒泡排序。
直接选择排序空间复杂度为:O(1)。
直接插入排序的基本操作是将未排序好序列的第一个元素插入到已经排好序的序列中。
如:待排序序列为{9,5,1,8,3},序列长度为5
先来看一次插入过程,黄色为待排序序列,红色代表已经排序完成的部分,前面4个元素已经排序好了,将最后一个待排元素3插入到排序序列中:
整个排序过程如下:
C++代码如下:
void insertSort(vector<int> &nums) {
//设记录序列长度为n
//外循环,插入次数为n - 1次
for (int i = 0; i < nums.size() - 1; ++i) {
int end = i; //end为已排序序列最后一个元素下标
int temp = nums[i + 1]; //temp用来保存待插入元素
//内循环,最多循环i次
while (end >= 0) {
if (nums[end] > temp) {
nums[end + 1] = nums[end];
--end;
} else {
break;
}
}
nums[end + 1] = temp;
}
}
直接插入排序时间复杂度为:O(n^2)。 虽然都是O(n ^2),但直接插入排序性能优于简单选择排序和冒泡排序。
直接插入排序空间复杂度为:O(1)。
直接插入排序在记录序列本身就是基本有序或者记录序列比较短时效率比较高,然而这两个条件比较苛刻,但是没有条件我们可以创造条件。
我们可以把待排序序列分为若干个子序列,此时每个子序列的记录序列就比较短了,对这些子序列分别进行插入排序,当整个序列都基本有序时,对整个序列进行一次直接插入排序,这样效率就提高了。
希尔排序的思想是,先选定一个小于N的整数gap作为第一增量,将所有距离为gap的元素分在同一组,对每一组元素分别进行插入排序,这样就能做到基本有序。然后取比N小的gap重复上述操作,直到gap = 1,相当于对整个序列进行了一次直接插入排序,排序完成。
例如:待排序序列为{9,1,5,8,3,7,4,6},序列长度为8.
整个排序过程如下:
C++代码如下:
void shellSort(vector<int>& nums) {
int n = nums.size();
int gap = n;
while (gap > 1) {
gap = gap / 2;
for (int i = 0; i < n - gap; ++i) {
int end = i;
int temp = nums[end + gap];
while (end >= 0) {
if (nums[end] > temp) {
nums[end + gap] = nums[end] ;
end -= gap;
} else {
break;
}
}
nums[end + gap] = temp;
}
}
}
希尔时间复杂度为:O(n^⅔)。 终于使得时间复杂度超越了O(n^2)。
直接插入排序空间复杂度为:O(1)。
堆是具有下列性质的完全二叉树:
如下图的大顶堆和小顶堆
二叉树的编号结点具有如下性质,对于任意编号索引大于0的结点,有:
堆的定义用上面的性质来描述的话,则有:
堆排序就是利用堆进行排序的方法,升序排序用大顶堆,降序排序用小顶堆。 以升序排序为例,它的基本思想是:
因此,堆排序算法主要要解决两个问题:
①构造堆
构造堆的主要思路:每插入一个结点都要满足新的堆为大顶堆。每次新插入的数据都与父结点进行比较,如果插入的数比父节点大,则与父节点交换,否则一直向上交换,直到小于等于父节点,或者来到了顶堆。
例如:待排序序列为{1,5,9,8,3},序列长度为5,将其构造成一个大顶堆
整个构造过程如下:
然而这种构造方法需要额外的空间来存放堆,那么我们有什么方法可以直接对给定的数组进行调整成一个堆呢?
②调整一个数组变成一个堆
如:对vector
如果从根节点向下调整,选择左右孩子中较大的一个交换,那么根节点得出的结果很可能不是最大的值。如上图,根节点1左右孩子中选择较大的交换,那么根的值变成8,并不是最大值。
因此,只能从倒数第二层开始向上进行调整。 过程如下:
最后一个结点的编号为4,其双亲结点编号(4 - 1) / 2 = 1,对1结点进行调整。5的左右孩子较大的值为9,则与9交换。
编号1的前一个结点是编号0的结点即根节点,拿顶端的数与其左右孩子较大的数进行比较,如果顶端的数大于左右孩子较大的数,则停止;如果小于左右孩子较大的数,则交换。然后继续与下面的孩子进行比较。
③固定堆顶元素并将剩余元素重新调整一个堆
主要思路是:将堆顶与最后一个元素进行交换并固定。将其余数组重新构造成一个大顶堆,即拿顶端的数与其左右孩子较大的数进行比较,如果顶端的数大于左右孩子较大的数,则停止;如果小于左右孩子较大的数,则交换。然后继续与下面的孩子进行比较。
对上述堆进行重排,过程如下:
其C++代码如下:
//选定的结点与其左右孩子较大的数进行比较,如果选定的结点大于左右孩子较大的数,则停止;如果小于左右孩子较大的数,则交换。然后继续与下面的孩子进行比较。
void adjust(vector<int> &nums, int i, int len) {
while (i * 2 + 1 <= len) {
int leftChild = 2 * i + 1;
int rightChild = 2 * i + 2;
int large = 0;
if (leftChild <= len && nums[leftChild] > nums[i]) {
large = leftChild;
} else {
large = i;
}
if (rightChild <= len && nums[rightChild] > nums[large]) {
large = rightChild;
}
if (large != i) {
swap(nums[i], nums[large]);
i = large;
} else {
break;
}
}
}
//从倒数第二层向上建立大顶堆
void buildMaxHeap(vector<int> &nums, int len) {
for (int i = len / 2; i >= 0; --i) {
adjust(nums, i, len);
}
}
void heapSort(vector<int> &nums) {
int len = nums.size() - 1;
buildMaxHeap(nums, len);
for (int i = len; i >= 1; --i) {
swap(nums[i], nums[0]);
len -= 1;
adjust(nums, 0, len);
}
}
堆排序时间复杂度为:O(nlogn)。
堆排序空间复杂度为:O(1)。
堆排序是一种不稳定排序。
如果我们统计全国收入中位数、最大值等,一般中央都要划到各个省,各个省又要划到各个市,各个市又划到各个街道分开统计各自的一小部分数据,然后逐渐合并到中央,就得到了全国的数据。归并排序的思想也是如此。
归并排序的原理是:结社初始序列中含有n个记录,则可以堪称n个有序的子序列,每个子序列的长度为1,然后两两合并,得到n/2个有序的子序列;再两两归并,…,如此重复,直至得到一个长度为n的有序序列为止。
如:序列{16,7,13,10,9,15,3,2}进行归并排序。
C++代码如下:
vector<int> tmp;
void mergeSort(vector<int> &nums, int left, int right) {
if (left >= right) return;
int mid = (left + right) / 2;
mergeSort(nums, left, mid);
mergeSort(nums, mid + 1, right);
int i = 1, j = mid + 1;
int cnt = 0;
while (i <= mid && j <= right) {
if (nums[i] <= nums[j]) {
tmp[cnt++] = nums[i++];
} else {
tmp[cnt++] = nums[j++];
}
}
while (i <= mid) {
tmp[cnt++] = nums[i++];
}
while (j <= right) {
tmp[cnt++] = nums[j++];
}
for (int i = 0; i < right - 1 + 1; ++i) {
nums[i + 1] =tmp[i];
}
}
堆排序时间复杂度为:O(nlogn)。
堆排序空间复杂度为:需要一个临时空间存放归并好的区间的数据。
堆排序是一种稳定排序。
快速排序是对冒泡排序的优化,其基本思想是:通过一趟排序将待排记录分割成独立的两部分,其中一部分记录的关键字比另一部分的关键字小,则可分别对这两部分记录继续进行排序,以达到整个序列有序的目的。
具体来说就是选择一个关键字,然后想办法把它放到一个位置,使得它左边的关键字都比它小,右边的关键字都比它大,这样的关键字我们称为枢轴(pivot)。具体做法如下:
从下面的例子来说明。
例:对数组{50,10,90,30,70,40,80,60,20}进行排序。
过程如下:
C++代码如下:
//一趟排序将序列划分成枢轴pivot左右两部分,并返回枢轴pivot位置
int partition(vector<int>& nums, int left, int right) {
int pivot = nums[left];
while (left <= right) {
while (left < right && nums[right] >= pivot) {
--right;
}
swap(nums[left], nums[right]);
while (left < right && nums[left] <= pivot) {
++left;
}
swap(nums[left], nums[right]);
if (left == right) break;
}
return left;
}
//快速排序
void quickSort(vector<int>& nums, int left, int right) {
if (left < right) {
int pivot = partition(nums, left, right);
quickSort(nums, left, pivot - 1);
quickSort(nums, pivot + 1, right);
}
}
最优情况下,快速排序时间复杂度为O(nlogn)。
然而,在最坏的情况下,待排序的序列是正序或者倒序,每次划分只比上次划分少一个记录的子序列就变成了斜树,效率会大大下降,时间复杂度退化为O(n^2)。
这就需要对快速排序进行改进,我们可以随机取一个关键字作为我们的枢轴pivot。
C++代码如下:
//一趟排序将序列划分成枢轴pivot左右两部分,并返回枢轴pivot位置
int partition(vector<int>& nums, int left, int right) {
int random = rand() % (right - left + 1) + left;
int pivot = nums[random];
while (left <= right) {
while (left < right && nums[right] >= pivot) {
--right;
}
swap(nums[left], nums[right]);
while (left < right && nums[left] <= pivot) {
++left;
}
swap(nums[left], nums[right]);
if (left == right) break;
}
return left;
}
//快速排序
void quickSort(vector<int>& nums, int left, int right) {
if (left < right) {
int pivot = randPartition(nums, left, right);
quickSort(nums, left, pivot - 1);
quickSort(nums, pivot + 1, right);
}
}
快速排序时间复杂度为:O(nlogn)。
快速排序空间复杂度为:O(1)
快速排序是一种不稳定排序。