寒假讲课Day 12:排序
排序
排序虽然基础,但涉及多种算法思想特别是分治思想、计数思想的综合运用。
基础知识
任何全序集都可以排序,不一定是数,也不一定是升序。例如,可以对结构体排序。
当排序的标准不是被排序对象本身时,可以定义排序的稳定性。如果一个排序算法能够保证键值相等的元素的相对顺序不变,则称该排序算法是稳定的(stable),否则称其为不稳定的(unstable)。
若\(i<j\land a_i>a_j\),则称\((i,j)\)是\(\vec{a}\)的一个逆序对(inverse pair)。\(\sum_{i=0}^{n-1}\sum_{j=i+1}^{n-1}(a_i>a_j)\)称为\(\vec{a}\)的逆序对数。逆序对数不超过\(\dbinom{n}{2}\)。详见OI Wiki - 置换和排列。
一部分排序算法是基于比较的,称为比较排序(comparative sort),另一部分称为非比较排序(non-comparative sort)。
几种排序算法
选择排序
我个人认为选择排序是最贴近人类直觉的排序方法。
选择排序(selection sort)是一种简单直观的排序算法。它的工作原理是每次找出第\(i\)小的元素(同时也是\(a[i,n)\)中最小的元素),然后将这个元素与数组第\(i\)个位置上的元素交换。
void selectionSort(std::vector<int> &arr) {
for (size_t i = 0; i < arr.size(); ++i) {
size_t i_min = i;
for (size_t j = i + 1; j < arr.size(); ++j) {
if (arr[j] < arr[i_min]) {
i_min = j;
}
}
std::swap(arr[i], arr[i_min]);
}
}
时间复杂度:\(\dbinom{n}{2}=\varTheta(n^2)\)。
空间复杂度:\(\varTheta(1)\)。
稳定性:取决于具体实现,上面这种常见的实现形式是不稳定的。
插入排序
插入排序(insertion sort)是将待排列元素划分为「已排序」和「未排序」两部分,每次从「未排序的」元素中选择一个插入到「已排序的」元素中的正确位置。
设已排序\(i\)个元素\(a[0,i)\)。考虑将\(a_i\)插入到有序部分。可以通过交换来实现插入。
void insertionSort(std::vector<int> &arr) {
for (size_t i = 1; i < arr.size(); ++i) {
for (size_t j = i; j && arr[j - 1] > arr[j]; --j) {
std::swap(arr[j - 1], arr[j]);
}
}
}
也可以整体移动元素来插入。
void insertionSort(std::vector<int> &arr) {
for (size_t i = 1; i < arr.size(); ++i) {
int key = arr[i];
size_t j = i;
while (j > 0 && arr[j - 1] > key) {
arr[j] = arr[j - 1];
--j;
}
arr[j] = key;
}
}
时间复杂度:最好\(\varTheta(n)\),最坏\(\varTheta(n^2)\)。
空间复杂度:\(\varTheta(1)\)。
稳定性:稳定。
插入排序在接近有序和规模很小的数据上表现优秀。
冒泡排序
冒泡排序(bubble sort)是一种简单的排序算法,依次比较相邻元素并交换其中不符合顺序的。在算法的执行过程中,较小的元素像是气泡般慢慢「浮」到数列的顶端。
经过\(i\)次扫描后(从\(1\)开始数),数列的末尾\(i\)项必然是最大的\(i\)项,因此冒泡排序最多需要扫描\((n-1)\)遍数组就能完成排序。
void bubbleSort(std::vector<int> &arr) {
for (size_t i = 0; i < arr.size(); ++i) {
for (size_t j = 1; j + i < arr.size(); ++j) {
if (arr[j - 1] > arr[j]) {
std::swap(arr[j - 1], arr[j]);
}
}
}
}
可以进一步优化:如果一遍下来没有元素发生交换,说明已经有序,可以提前终止,不必跑完全部\((n-1)\)轮。
void bubbleSort(std::vector<int> &arr) {
for (size_t i = 0; i < arr.size(); ++i) {
bool swapped = false;
for (size_t j = 1; j < arr.size() - i; ++j) {
if (arr[j - 1] > arr[j]) {
std::swap(arr[j - 1], arr[j]);
swapped = true;
}
}
if (!swapped) break;
}
}
时间复杂度:最好\(\varTheta(n)\)(只需遍历一遍确认有序),最坏\(\varTheta(n^2)\)。
空间复杂度:\(\varTheta(1)\)。
稳定性:稳定。
归并排序
昨天讲过。
void mergeSort(std::vector<int> &arr, size_t beg, size_t end) {
if (beg + 1 >= end) return;
// divide and conquer
size_t mid = beg + (end - beg) / 2;
mergeSort(arr, beg, mid);
mergeSort(arr, mid, end);
// merge
std::vector<int> aux(end - beg);
size_t i = beg, j = mid, k = 0;
while (i < mid && j < end) {
if (arr[i] <= arr[j]) {
aux[k++] = arr[i++];
} else {
aux[k++] = arr[j++];
}
}
while (i < mid) {
aux[k++] = arr[i++];
}
while (j < end) {
aux[k++] = arr[j++];
}
for (k = 0; k < aux.size(); ++k) {
arr[beg + k] = aux[k];
}
}
时间复杂度:\(\varTheta(n\log n)\)(证明需要用主定理(main theorem))。
空间复杂度:\(\varTheta(n)\)。原地归并是可行的,但比较复杂,且会使时间增加到\(\varTheta(n\log^2n)\),一般来说不用。
稳定性:稳定。
快速排序
快速排序(quick sort),又称分区交换排序(partition-exchange sort),简称「快排」,是一种被广泛运用的排序算法(std::sort通常采用快速排序实现),其步骤是:
- 将数列划分(partition)为左右两部分,要求保证相对大小关系,即前一部分的所有元素小于后一部分的所有元素。
- 递归到两个子序列中分别进行快速排序。
- 不用合并,因为此时数列已经完全有序。
实际上,快速排序没有指定应如何具体实现partition,不论是选择划分标准还是划分的过程,都有不止一种实现方法。
最简单的partition方法是直接选取当前子数组的首/尾/中央元素作为主元。
size_t partition(std::vector<int> &arr, size_t beg, size_t end) {
assert(beg < end);
if (beg + 1 == end) return beg;
std::swap(arr[beg], arr[beg + (end - beg) / 2]); // pivot
size_t i = beg + 1;
for (size_t j = beg + 1; j < end; ++j) {
if (arr[j] < arr[beg]) {
std::swap(arr[i], arr[j]);
++i;
}
}
std::swap(arr[beg], arr[i - 1]);
return (i - 1);
}
void quickSort(std::vector<int> &arr, size_t beg, size_t end) {
if (beg + 1 >= end) return;
size_t pivot_idx = partition(arr, beg, end);
quickSort(arr, beg, pivot_idx);
quickSort(arr, pivot_idx + 1, end);
}
不过,以上实现(称为朴素实现)在特殊构造的数据下可以被卡到\(\varTheta(n^2)\),因为其高度依赖所选主元的性质。如果每次选取的主元总是当前范围内的最小值,则每次只会将区间划分为长\(1\)和\((n-1)\)的两部分,时间复杂度退化到\(\varTheta(n^2)\)。常见的优化有:
- 选取首、中、尾三个元素的中位数作为划分的主元,以避免极端数据带来的退化问题。这种主元选取方式称为三数取中(median of three)。
- 序列较短(短于设定的阈值)时,使用常数小的排序算法(例如插入排序),避免在短序列上递归。
- 每趟排序后,将与分界元素相等的元素聚集在分界元素周围,这样可以避免极端数据(如序列中大部分元素都相等)带来的退化。常通过三路划分(小于、等于、大于主元三部分)来实现,采用这种划分方法的快速排序称为三路快速排序(3-way quick sort)。
- 限制递归深度不超过\(\lfloor\log_2n\rfloor\),对于过深的排序改为堆排序。这种排序方式称为内省排序(intro(spective )sort)。
以下是采用三数取中、小区间优化的三路快速排序。
void medianOfThree(std::vector<int> &arr, size_t beg, size_t end) {
size_t mid = beg + (end - beg) / 2;
if (arr[beg] > arr[mid]) std::swap(arr[beg], arr[mid]);
if (arr[beg] > arr[end - 1]) std::swap(arr[beg], arr[end - 1]);
if (arr[mid] > arr[end - 1]) std::swap(arr[mid], arr[end - 1]);
std::swap(arr[mid], arr[beg]);
}
std::pair<size_t, size_t> threeWayPartition(std::vector<int> &arr, size_t beg, size_t end) {
assert(beg < end);
medianOfThree(arr, beg, end);
size_t lt = beg + 1; // arr[beg + 1, lt) < pivot
size_t gt = end; // arr[gt, end) > pivot
size_t i = beg + 1; // arr[lt, i) == pivot
while (i < gt) {
if (arr[i] < arr[beg]) {
std::swap(arr[lt], arr[i]);
++lt;
++i;
} else if (arr[i] > arr[beg]) {
--gt;
std::swap(arr[i], arr[gt]);
} else {
++i;
}
}
std::swap(arr[beg], arr[lt - 1]);
--lt;
return {lt, gt};
}
void insertionSort(std::vector<int> &arr, size_t beg, size_t end) {
for (size_t i = beg + 1; i < end; ++i) {
for (size_t j = i; j > beg && arr[j - 1] > arr[j]; --j) {
std::swap(arr[j - 1], arr[j]);
}
}
}
void threeWayQuickSort(std::vector<int> &arr, size_t beg, size_t end, size_t threshold = 16) {
if (beg + threshold >= end) {
insertionSort(arr, beg, end);
return;
}
auto [lt, gt] = threeWayPartition(arr, beg, end);
threeWayQuickSort(arr, beg, lt, threshold);
threeWayQuickSort(arr, gt, end, threshold);
}
时间复杂度:最好\(\varTheta(n)\)(所有元素都相同),最坏仍为\(\varTheta(n^2)\)(通过特殊构造仍可以使每次选的主元必为区间最值),随机序列的平均时间复杂度为\(\varTheta(n\log n)\)。要将最坏情况也限制在\(\varTheta(n\log n)\),必须采用内省排序。
空间复杂度:最好、平均\(\varTheta(\log n)\),最坏\(\varTheta(n)\)。
稳定性:不稳定。
计数排序
计数思想。仅限于关键字为整数且值域较小的情况。当然,对于非整数但可散列(hash)或值域过大的情况,也可以利用散列函数将其转换为值域较小的整数。
朴素的计数排序如下。
void countingSort(std::vector<int> &arr, int min, int max) {
size_t range = max - min + 1;
std::vector<size_t> cnts(range, 0);
for (const auto &e : arr) {
++cnts[e - min];
}
size_t idx = 0;
for (int i = 0; i < range; ++i) {
for (size_t j = 0; j < cnts[i]; ++j) {
arr[idx] = i + min;
++idx;
}
}
}
不过,这只能用于对整数本身进行排序。如果排序对象是其他的东西(例如结构体),仅以整数为键值,怎么办呢?可以采用以下的方案:
- 计算每个数出现了几次。
- 求出每个数出现次数的前缀和。
- 利用出现次数的前缀和,倒序计算每个数的排名。
利用前缀和信息可以知道每个元素在相等键值的元素中如何排布。
void countingSort(std::vector<int> &arr, int min, int max) {
if (arr.empty()) return;
assert(min <= max);
int range = max - min + 1;
std::vector<size_t> cnts(range, 0);
for (const auto &e : arr) {
++cnts[e - min];
}
for (int i = 1; i < range; ++i) {
cnts[i] += cnts[i - 1];
} // prefix sum
std::vector<int> aux(arr.size());
for (size_t i = arr.size(); i--;) {
aux[cnts[arr[i] - min] - 1] = arr[i];
--cnts[arr[i] - min];
}
arr = std::move(aux);
}
时间复杂度:\(\varTheta(max-min+n)\)。一般来说值域大小是比元素个数大的,所以通常就是\(O(max-min)\)。
空间复杂度:\(\varTheta(max-min+n)\)。
稳定性:稳定。
桶排序
桶排序(bucket sort)可以认为是将计数排序扩展到更大的值域,其步骤为:
- 设置若干个“桶(bucket)”。
- 遍历序列,并将元素一个个放到对应的桶中。
- 对每个桶进行排序。可以用基于比较的排序,也可以用计数排序。
- 从不是空的桶里把元素再放回原来的序列中。
桶排序适用于待排序数据值域较大但分布比较均匀的情况。
void bucketSort(std::vector<int> &arr, int min, int max, int buc_num) {
if (arr.empty()) return;
assert(min <= max && buc_num > 0);
int range = max - min + 1;
int buc_size = (range + buc_num - 1) / buc_num; // ceil(range / buc_num)
std::vector<std::vector<int>> buckets(buc_num);
for (const auto &e : arr) {
int buc_idx = std::min((e - min) / buc_size, buc_num - 1);
buckets[buc_idx].push_back(e);
}
size_t idx = 0;
for (auto &buc : buckets) {
insertionSort(buc);
for (const auto &e : buc) {
arr[idx] = e;
++idx;
}
}
}
桶排序的时空复杂度取决于内层排序的具体实现。当能够保证每个桶中的元素数量都较少时,采用插入排序为好,此时时间复杂度为\(\varTheta(n+n^2/buc\_num+buc\_num)\)。当\(buc\_num\approx n\)时,时间复杂度为\(\varTheta(n)\)。
空间复杂度为\(\varTheta(buc\_num+n)\)。
稳定性取决于内层排序的实现。如果内层排序是稳定的,而且按照元素原始顺序插入桶中,则桶排序仍然稳定。
排序相关STL
std::sort:一般采用内省排序。std::stable_sort:一般用自适应归并排序实现。内存充足时会开辟全量或部分缓冲区,否则进行原地归并排序。std::partial_sort:一般用堆排序实现。std::nth_element。
排序的应用
排序通常仅作为其他算法的预处理步骤。
逆序对计数
排序等价于消除逆序对。
给定一个数组,求其逆序对数。这个问题通常有两种办法:
- 归并排序。
- 维护一个值域上的树状数组(Fenwick/binary indexed tree)或线段树(segment tree)。这两个都是比较高级的数据结构,不讲。
归并排序实现:排序后的数组无逆序对。归并排序的合并操作中,每次后段的元素被作为当前最小值取出时,前段剩余元素个数即是合并操作减少的逆序对数量。
uint64_t mergeSort(std::vector<int> &arr, size_t beg, size_t end) {
if (beg + 1 >= end) return 0;
// divide and conquer
size_t mid = beg + (end - beg) / 2;
uint64_t inv = mergeSort(arr, beg, mid) + mergeSort(arr, mid, end);
// merge
std::vector<int> aux(end - beg);
size_t i = beg, j = mid, k = 0;
while (i < mid && j < end) {
if (arr[i] <= arr[j]) {
aux[k] = arr[i];
++i;
++k;
} else {
inv += (mid - i); // arr[i, mid) > arr[j]
aux[k] = arr[j];
++j;
++k;
}
}
while (i < mid) {
aux[k] = arr[i];
++i;
++k;
}
while (j < end) {
aux[k] = arr[j];
++j;
++k;
}
for (k = 0; k < aux.size(); ++k) {
arr[beg + k] = aux[k];
}
return inv;
}
时间复杂度:\(\varTheta(n\log n)\)。
空间复杂度:\(\varTheta(n)\)。

浙公网安备 33010602011771号