C++ / ACM 自学笔记 · v5.0

(四) 排序与离散化

前缀和按位置累积,遇到按数值查找、比较大小或给稀疏数值编号时,先建立顺序会更方便。这里继续从 STL 的排序接口追问它如何减少查找工作。

1. 常见排序思路

方法 思路 时间复杂度 特点
选择排序 每轮找剩余元素的最小值 \(O(n^2)\) 入门直观,通常不稳定
插入排序 将当前元素插入已有序部分 最坏 \(O(n^2)\) 有序时可达 \(O(n)\),可稳定实现
归并排序 分成两半排序后合并 \(O(n \log n)\) 可稳定实现,常需 \(O(n)\) 辅助空间
快速排序 按枢轴划分后递归 平均 \(O(n \log n)\),最坏 \(O(n^2)\) 划分策略影响性能

竞赛通常直接使用 sort,比较次数为 \(O(n\log n)\)。如果两条同分记录还要保持输入时的先后顺序,就使用 stable_sort,它会保留等价元素的相对顺序。

稳定是指排序键相同的元素,排序后相对顺序不变。vector 使用 sort 按位置移动元素;list 使用成员 list::sort() 调整链表中的节点连接。

练习题: 洛谷 P1177 — 排序。

2. 归并排序模板

下面使用半开区间 [left, right):

题目原文(自拟): 给定整数数组,将元素按非递减顺序排列,保留所有重复元素。用归并排序实现。

分析: 把区间拆成两半,递归排序,再按两段的当前最小值合并。区间长度为 0 或 1 时已经有序。

归并排序的分解与合并过程

两段排好序后,只比较各自尚未取出的第一个值;相等时先取左段。

合并时逐轮比较两个指针,写入后只移动被选一侧

样例 [3,1,2,1] 的两半排好后为 [1左,3] 与 [1右,2]。第一轮相等,按代码先取左侧 1;随后依次比较 3 与 1、3 与 2,取右侧的 1、2;右段耗尽,复制左段剩余的 3。左右标签只用于追踪相等元素来源,最终输出仍是 [1,1,2,3],相等时先取左段使归并保持稳定。

合并为什么只看两段的第一个剩余元素? 两段内部已经有序,所以各自的第一个剩余元素都是该段剩余部分的最小值。比较这两个值,把较小者写入 temp,它就一定是全部剩余元素中的最小值。重复这一步,已写部分始终有序,且不会漏掉元素。

对 [3,1,2,1],左右分别排成 [1,3] 和 [1,2]。合并时先取左边的 1,再取右边的 1,然后取 2,最后补上 3。相等时先取左段,配合各段内部也保持原相对次序,便得到稳定排序;若只排序整数,稳定性的差别不明显,排序带编号记录时就能看出来。

temp 只作为当前合并结果,完成后要复制回 values[left…right),因为上层递归合并读取的是 values。长度 0 或 1 的区间不用再拆分,这既是递归终点,也保证最终能够结束。

#include <vector>
using namespace std;

void mergeSort(vector<int> &values, vector<int> &temp, int left, int right) {
    if (right - left <= 1) {
        return;
    }
    // 把半开区间拆为两半,分别排序后再合并。
    int mid = left + (right - left) / 2;
    mergeSort(values, temp, left, mid);
    mergeSort(values, temp, mid, right);

    // i、j 读取左右有序段,k 写入辅助数组。
    int i = left, j = mid, k = left;
    while (i < mid && j < right) {
        if (values[i] <= values[j]) {
            temp[k++] = values[i++]; // 相等时先取左边,保持稳定
        } else {
            temp[k++] = values[j++];
        }
    }
    while (i < mid) {
        temp[k++] = values[i++];
    }
    while (j < right) {
        temp[k++] = values[j++];
    }
    for (int index = left; index < right; ++index) {
        values[index] = temp[index];
    }
}

调用时先创建同样长度的辅助数组:vector<int> temp(values.size());,再调用 mergeSort(values, temp, 0, static_cast<int>(values.size()));。

归并排序体现“分解、解决子问题、合并”的分治思想。若合并时右侧元素小于当前左侧元素,左侧未取出的 mid - i 个元素都会与它形成逆序对,可以据此扩展为逆序对计数。

样例与解释: [3,1,2,1] 排序后为 [1,1,2,3];重复的两个 1 都保留。时间 \(O(n\log n)\),辅助数组空间 \(O(n)\),递归栈深 \(O(\log n)\)。

练习题: 洛谷 P1908 — 逆序对。

3. 离散化

数据值很大,但我们只关心大小关系时,可以把它们映射成紧凑下标:

题目原文(自拟): 给定整数数组,将不同值按升序依次编号为 0,1,...,输出原数组每个元素的编号。相同原值必须得到相同编号。

分析: 先在副本中排序去重,再用 lower_bound 找每个值在去重数组中的位置。

保留需要的关系,去掉无用的数值跨度。 [100,-5,100,20] 如果按数值当数组下标,会遇到负数与很大的范围;按值排序去重后只剩 [-5,20,100],用它们的位置 0、1、2 就能表示大小次序。回到原顺序便得到 [2,0,2,1]。

unique 只把连续相等元素压到前面并返回新的逻辑末尾,不直接缩短容器,所以先排序让相同值相邻,再用 erase 删除尾部。lower_bound 找原值的第一处位置;原值都来自这份副本,所以一定能找到对应排名。

排名之间的差不能当坐标距离:原值 -5 到 20 距离为 25,新排名只差 1。处理区间长度、面积等问题时,需要保留排序后的原坐标,用相邻原坐标的差计算长度。

排序去重后的排名映射

相同原值映射到相同排名,大小关系保留;排名差不等于原坐标距离。

#include <algorithm>
#include <vector>
using namespace std;

vector<int> compress(const vector<int> &values) {
    // 复制后排序去重,得到每种原值对应的有序排名。
    vector<int> sorted = values;
    sort(sorted.begin(), sorted.end());
    sorted.erase(unique(sorted.begin(), sorted.end()), sorted.end());

    vector<int> rank;
    for (int x : values) {
        rank.push_back(
            static_cast<int>(lower_bound(sorted.begin(), sorted.end(), x) - sorted.begin()));
    }
    return rank;
}

例:[100, -5, 100, 20] 映射为 [2, 0, 2, 1]。离散化保留大小关系和相等关系,不保留数值差距,不能直接用新下标计算原坐标距离。时间 \(O(n \log n)\),空间 \(O(n)\)。

练习题: 洛谷 P1496 — 火烧赤壁(排序、区间合并或离散化练习)。

查阅笔记正文与全部题解 · Ctrl / ⌘ K