算法导论其三:排序
目录
排序问题是编程中的重要基石,排序中用到的很多思想也非常有借鉴价值。
对比
名称 | 平均时间复杂度 | 最坏时间复杂度 | 空间复杂度 | 是否稳定 | 类型 |
---|---|---|---|---|---|
选择排序 | $O(n^2)$ | $O(n^2)$ | $O(1)$ | 稳定 | 基于比较 |
插入排序 | $O(n^2)$ | $O(n^2)$ | $O(1)$ | 数组不稳定,链表稳定(根据实现) | 基于比较 |
希尔(Shell)排序 | $O(n^{1.3})$ | $O(n^2)$ | $O(1)$ | 不稳定 | 基于比较 |
堆排序 | $O(nlogn)$ | $O(nlogn)$ | $O(1)$ | 不稳定 | 基于比较 |
冒泡排序 | $O(n^2)$ | $O(n^2)$ | $O(1)$ | 稳定 | 基于比较 |
快速排序 | $O(nlogn)$ | $O(n^2)$ | $O(logn)$ | 不稳定 | 基于比较 |
归并排序 | $O(nlogn)$ | $O(nlogn)$ | $O(n)$ | 稳定 | 基于比较 |
基数排序 | $O(d(r+n))$ | $O(d(r+n))$ | $O(rd+n)$ | 稳定 | 非比较 |
桶排序 | $O(nlog\frac{n}{k})$ | $O(nlog\frac{n}{k})$ | $O(n)$ | 稳定 | 非比较 |
计数排序 | $O(n)$ | $O(n)$ | $O(n)$ | 稳定 | 非比较 |
注:其中桶排序数据存疑。
基于比较的
-
插入排序
- 步骤:
- 取未排序区间的第一个数
- 从后向前,一边移动已排序区间,一边判断能否在此位置插入
- 重复直到结束
- 变种-希尔(Shell)排序:选择合适的增量序列,甚至能够在最坏情况下降低复杂度
- 选择一个gap(如length/2),将数组按照gap划分{0,gap,2*gap…},{1,1+gap,1+2*gap…}
- 每个序列内部使用简单插入排序
- 降低gap大小直至1
- 优秀的增量序列选择,如hibbard增量,$\{1,3,7,15…\},h_{i+1}=2h_{i}+1$,经复杂证明,最坏时间复杂度也低于$O(n^2)$【未确认】
- 步骤:
-
归并排序
- 步骤:
- 二分区间,递归直到当前区间只有少量元素,完成小区间上的排序(具体实现任意)
- 合并左右两个有序区间,需要额外空间
- 重复直到整个区间合并完成
- 额外意义:
- 归并排序的思想会用在一些区间类型的动态规划问题里。比如求逆序对儿($x_i > x_j$且$i< j$)的个数,左右区间合并的时候,可以额外计算很多有意义的信息。
- 步骤:
-
堆排序
- 名词解释:
- 堆(heap)最早是在堆排序中提出的,后来扩展了代表一类内存区域的含义。注意区分。
- (二叉)堆是一种完全二叉树,根据使用场景分为大顶堆和小顶堆。大顶堆中父节点是子树中最大的。小顶堆反之。
- 堆也用于实现优先队列。
- 步骤:
- 建立初始堆。
- 循环移动堆顶到序列尾端,并恢复堆性质。
// 堆化 void heapify(vector<int>& input, size_t i, size_t count) { while (i<count) { if (i * 2 + 2 < count && input[i * 2 + 2] == max(max(input[i * 2 + 2], input[i * 2 + 1]), input[i])) { // right child bigger swap(input[i * 2 + 2], input[i]); i = i * 2 + 2; } else if (i * 2 + 1 < count && input[i * 2 + 1] == max(input[i * 2 + 1], input[i])) { // left child bigger swap(input[i * 2 + 1], input[i]); i = i * 2 + 1; } else { break; } } } void heap_sort(vector<int>& input) { for (size_t i = (input.size() - 1) / 2; i > 0; i--) heapify(input, i, input.size()); heapify(input, 0, input.size()); for (size_t i = 1; i != input.size(); ++i) { swap(input[0], input[input.size() - i]); heapify(input, 0, input.size() - i); } }
- 多bb一句,计算一下初始化建堆的时间复杂度。
- 单次heapify是$O(logn)$,调用了$O(n)$,是$O(nlogn)$吗?不是!
- 第k层节点(根为第0层)在进行heapify时,最多进行$\lceil logn \rceil -(k+1)$次向下交换。
- 第k层节点数量为$2^k$个
- 交换次数基本等价于时间复杂度,总交换次数结合2和3可知,等差×等比。结果是$O(n)$
- 名词解释:
-
快速排序
- 非常经典的算法,分治法的杰出代表。虽然有小瑕疵,但经过改造目前仍然被广泛应用。
- 步骤:
- Partition!根据主元将序列分为两部分,一部分小于主元,一部分大于主元。
- 同理分别处理两部分
//很妙的单向扫描写法(循环不变式:i的下一个是大于pivot) template<typename ForwardIt> void quick_sort(ForwardIt begin, ForwardIt end) { //partition auto i = begin, j = begin + 1; auto pivot = *begin; for (;j!=end;++j) { if (*j < pivot) { std::iter_swap(++i, j); } } std::iter_swap(i, begin); //递归 if (begin < i) quick_sort(begin, i); if (i + 1 < end) quick_sort(i + 1, end); }
- 其中,选择主元并划分的步骤,就叫做Partition,这种思想经常用到。
-
随机化快速排序
- 快排的经典问题,在于非随机化的主元选取方式,会导致一些“恰好”的攻击例子,降低时间效率到$O(n^2)$。
- 比如每次选取第一个为主元,则输入已经按升序排列好,那么等价于选择排序。
- 使用随机函数,随机化选取主元,移动主元到开始位置,复用上面的quick_sort。
- 快排的经典问题,在于非随机化的主元选取方式,会导致一些“恰好”的攻击例子,降低时间效率到$O(n^2)$。
基于计数
-
基数排序
- 一种最初用在老式穿孔卡上的算法。从分解数字为不同位,并按照每一位进行排序的思路。
- 步骤:
- 从最低位开始,以最低位为排序元素,对数组元素排序。(一般采用计数排序等稳定排序)
- 循环直到取到最高位
- 关于表格中的复杂度,d代表位数,r代表每一位的取值范围大小,n为待排序规模。
- 多说一句:关于2.1种采用稳定排序的必要性,排序稳定性决定了基数排序的正确性(比如在某位相等时,只有排序顺序稳定则最终结果才一定是正确的)
-
桶排序
- 概况:当输入符合均匀分布时,可以在线性期望时间复杂度下运行。桶就是一系列有大小顺序的区间,算法的实现效率关键在于桶的设计。(准确的说,只要各个桶的元素数量的平方和与总元素数成线性关系即可)
- 步骤:
- 将数据按桶分配到不同的桶中
- 每个桶内部排序(例如插入排序)
- 将各个桶直接顺序相连
- 为什么使用插入排序还可以是线性期望时间复杂度?
- 具体证明可查看算法导论相关章节
- 核心思路:
- 总体上是证明$T(n)=\Theta(n)+\sum_{i=0}^{n-1}O(n_{i}^2)$的期望为线性。其中$n_{i}$代表第i个桶中的元素个数。
- 关键在于证明$\sum_{i=0}^{n-1}O(E[n_{i}^2])$是线性的。(E代表期望)
- 推理的关键在于令$n_i=\sum_{j=1}^{n}X_{ij}$,其中$X_{ij}=I\{A[j]$落在桶$i$中$\}$。$X_{ij}$是指示器随机变量(参考算法导论其一:分析)。
-
计数排序
- 概况:假设n个输入元素中的每一个都是介于0到k之间的整数,此处k为某个整数。当$k=O(n)$时,计数排序的运行时间是$\Theta(n)$。
- 基本思路是对于每一个输入元素x,计数小于x的元素个数。从而直接将x放在指定的位置。
std::vector<int> count_sort(std::vector<int>& input) { std::vector<int> ret(input.size(), 0); auto minmaxElement = std::minmax_element(input.begin(), input.end()); std::vector<unsigned int> count(*minmaxElement.second - *minmaxElement.first + 1, 0); for (auto& t : input) { count[t - *minmaxElement.first]++; } for (size_t i = 1; i != count.size(); ++i) { count[i] += count[i - 1]; } for (auto& t : input) { cout << count[t - *minmaxElement.first] - 1 << endl; ret[count[t - *minmaxElement.first] - 1] = t; count[t - *minmaxElement.first]--; } return ret; }
多说几句
- 基于比较的排序,最优的渐进时间复杂度就是$O(nlogn)$,这一点是有证明的(决策树模型)。