排序
排序是非常常见的任务:有些排序并不直接解决问题,但是可以起到加速作用;有些算法依赖排序。
解决了从小到大排序,就能解决从大到小排序。这是因为我们可以取相反数进行排序。
- 选择排序
我们重复遍历数组,每次找到当前的最大值并将其加入有序序列的末尾,只要进行 n 次这个数组就是有序的。
void select_sort (int num[], int n) {
for (int i = 0; i < n; ++i) {
int pos = 0;
for (int j = 1; j < n - i; ++j) {
if (num[j] > num[pos]) pos = j;
}
swap(num[pos], num[n - i - 1]);
}
}
- 冒泡排序(这个最容易写)
我们重复遍历数组,对于每一次遍历,依次比较相邻的两个元素。若它们的顺序错误,则交换它们的位置。
容易知道,对于每一次遍历,当前最大的元素都会被找到并放在右边的区域。所以在 n 次遍历之后,整个数组一定会是有序的。
代码实现:
void bubble_sort (int num[], int n) {
for (int i = 0; i < n; ++i) {
for (int j = 0; j < n - i - 1; ++j) {
if (num[j] > num[j + 1]) swap(num[j], num[j + 1]);
}
}
}
代码细节:因为在 k 次遍历后,数组前 k 大的元素都被找出了,因此在内层循环中我们可以不考虑后 k 个元素,即循环边界中的 -i。
我们可以设置一个标记 flag 记录在上一次遍历中是否有元素交换。若没有元素交换,这就说明整个数组已经有序,不用再进行后续的步骤了。
- 插入排序
类比卢老爷打斗地主的过程,我们不断从牌堆中抽牌,并且维护一个手牌的有序序列,当我们的牌抽完时,我们的手牌就是有序的。具体一点来说,我们要实现“往一个有序序列中插入元素并维持有序性”的操作。
代码实现:
int num[100005], ans[100005], n, tot;
void insert(int k) {
int pos;
for (pos = 0; pos < tot; ++pos) {
if (k <= ans[pos]) break;
}
for (int i = tot - 1; i >= pos; --i) {
ans[i + 1] = ans[i];
}
ans[pos] = k;
++tot;
}
void insert_sort() {
for (int i = 0; i < n; ++i) {
insert(num[i]);
}
}
代码细节:
- 对于待排序的每一个元素,执行插入操作
- 找到待插入的位置 pos
- 将后面的元素全部后移一位
- 在 pos 处插入元素
我们注意到,num 数组中待排序的元素个数和 ans 数组中已排序的元素个数的总和就是总元素个数,因为每一个元素不是排过序的就是待排序的。因此,我们可以对代码的空间占用进行优化。具体一点来说,我们正好可以把数组 num 被拿走元素空下来的部分存放有序序列。
代码实现:
void insert(int num[], int tot) {
int pos, tmp = num[tot];
for (pos = 0; pos < tot; ++pos) {
if (tmp <= num[pos]) break;
}
for (int i = tot - 1; i >= pos; --i) {
num[i + 1] = num[i];
}
num[pos] = tmp;
}
void insert_sort(int num[], int n) {
for (int i = 0; i < n; ++i) {
insert(num, i);
}
}
注意到我们改变了函数的接口,这种写法的优势更大:它可以使函数的泛用性更好,继而可以更方便的使用和更省力的维护。
代码复用(code reuse):指的是我们尽可能重复使用自己已经写过的代码。(单一责任原则、封装)想要减少调试时间,要注意代码的可复用性。
- 复杂度
程序运行需要使用时间和空间,我们使用时间复杂度和空间复杂度来描述一个算法。时间复杂度一般用 O 记号分析数量级,空间复杂度一般可以算出精确值。
这三种算法其实都很慢。它们的时间复杂度为 。
已经证明:基于比较的排序,它们的复杂度最好也只能做到 ,下面是两种达到这种复杂度的排序算法:
- 归并排序
归并排序是基于分治思想的排序方法。对于一个待排序数组,我们把它分成左半与右半两个部分,并分别对它们进行归并排序。最后我们把两个有序数组归并为一整个有序数组。
归并有序数组 A 和 B 的方法为:
- 若 A 和 B 中都有剩余元素,将它们头部较小的元素取出放入有序序列
- 若 A 中没有剩余元素,将 B 中所有元素按顺序加入序列
- 若 B 中没有剩余元素,将 A 中所有元素按顺序加入序列
以上是双路归并的步骤,实际上我们也可以将数组划分成多个部分进行多路归并,它们的原理是一致的。
代码实现:
void merge (int num[], int left, int mid, int right) {
int pointer1 = left, pointer2 = mid, tot = 0;
static int helper[100005];
while (pointer1 < mid && pointer2 < right) {
if (num[pointer1] >= num[pointer2]) helper[tot++] = num[pointer2++];
else helper[tot++] = num[pointer1++];
}
while (pointer1 < mid) helper[tot++] = num[pointer1++];
while (pointer2 < right) helper[tot++] = num[pointer2++];
for (int i = 0; i < tot; ++i) {
num[left + i] = helper[i];
}
}
void merge_sort (int num[], int left, int right) {
if (left + 1 == right) return;
int mid = (left + right) / 2;
merge_sort(num, left, mid);
merge_sort(num, mid, right);
merge(num, left, mid, right);
}
代码细节:我们的区间一般都是左闭右开区间,为了实现归并,我们设置两个指针分别指向两个有序数组的头部。我们还需要一些辅助空间存放有序数组并在最后将其复制进原数组。
代码主要是 merge 函数比较消耗时间,merge 函数的时间复杂度为 。我们设对长度为 n 的数组进行归并的时间复杂度为 ,那么我们有:
根据主定理,时间复杂度为 。
- 快速排序
快速排序的思想是:我们每次选择数组中的一个“哨兵”(通常指被拿来和其他东西作比较的元素),把比这个数小的放到它左边,把比这个数大的放到它右面,这样我们就有了“数组有序”的一个必要条件。如果对于每一个元素我们都有这种性质,那么这个数组就是有序的。
显而易见,哨兵的选择很有讲究。如果我们每一固定把数组的首个元素作为哨兵,那么对于单调递减的数组,这个算法会退化成为 的。为了保证效率,我们采用随机化的方式选择哨兵。
有一点需要注意:我们只考虑了“比哨兵大”和“比哨兵小”两种元素并对它们递归处理。而对于和哨兵相等的元素,我们没有必要让它们进入递归,只要直接复制即可,这实际上是进行了一次三分。
void quick_sort(int num[], int left, int right) {
if (right - left <= 1) return;
static int helper[10005];
int guard = num[left + (rand() % (right - left))], pointer1 = left, pointer2 = right;
for (int i = left; i < right; ++i) {
if (guard > num[i]) helper[pointer1++] = num[i];
else if (guard < num[i]) helper[--pointer2] = num[i];
}
for (int i = left; i < pointer1; ++i) {
num[i] = helper[i];
}
for (int i = pointer2; i < right; ++i) {
num[i] = helper[i];
}
for (int i = pointer1; i < pointer2; ++i) {
num[i] = guard;
}
quick_sort(num, left, pointer1);
quick_sort(num, pointer2, right);
}
- 额外的评价标准
稳定排序:如果在排序前后,具有相同关键字的对象之间的相对次序不变,那么这个排序算法是稳定的,反之是不稳定的。
原地排序:不申请多余的存储空间的排序算法称为原地排序。