C++排序后保留原始位置:平行数组与std::unique实战解析
2026/9/10 3:08:38 网站建设 项目流程

上周写周报的时候遇到一个挺典型的排序问题:有一组成绩数据,要按分数从高到低输出排名,但输出结果里必须带上选手原来的编号。我第一反应直接std::sort一把梭,排完发现编号全乱了,整个人愣在原地。后来才意识到,排序会改变元素位置,而我们需要的是"排序后还原原始下标"。这个问题看着简单,但一旦数据里有重复值,还要保证输出的原数据位置不重复,就得把std::unique和平行数组结合起来才能干净利落地解决。这篇就把整个思路、代码细节和踩坑过程完整拆开讲一遍,给正被"排序后找原位置"折磨的朋友一个可直接抄作业的参考。

1. 这个需求到底在解决什么问题

先说清楚需求本身。假设你手里有一个原始数组,比如一组比赛成绩{3, 1, 4, 1, 5, 9, 2, 6, 5},现在要求按成绩从大到小输出,并且输出时每组数据后面要标注它在原数组里的位置(也就是下标)。

这个需求看起来人畜无害,但真正动手写代码的时候会发现一个尴尬的事实:std::sort排序过程中会移动元素,排序完成后,你只拿到了"值有序"的数组,但完全不知道这个值原来在哪个位置。如果你只是要一个排好序的成绩单,那没问题;但如果是要做排名表格、要关联编号,或者后续要用这个位置去索引其他数据,那排序完了位置就彻底丢了。

更麻烦的是,这个需求里还有一句"原数据位置不重复"。什么叫位置不重复?有两种理解:

理解一:每个唯一的值,只输出一次它的位置。比如成绩 5 出现了两次(下标 4 和下标 8),但我们只输出其中一个位置,代表这个成绩所在的地方。这种诉求常见于"统计有哪些不同的分数,以及每个分数首次出现的位置"。

理解二:输出的位置列表里不能有相同的下标。这个其实是排序后的天然要求——排序会改变顺序,但每个元素的位置信息排列起来不应该有重复,否则说明逻辑错了。

从标题看,重点落在std::unique上,所以更贴近理解一:先把数组从大到小排序,再用std::unique去重,同时还能知道每个去重后的值在原数组中的位置。平行数组在这里扮演的角色,就是"值排序时,把原位置跟着值一起搬动",这样才能保证unique之后依然拿得到正确的位置信息。

很多人一开始卡住,是因为把这个问题想成了"排序 + 去重"两个独立步骤。实际上,难点不在排序,也不在去重,而在"如何在排序和去重过程中始终绑定原始位置"。一旦想通了这一点,方案就很清晰了。

2. 平行数组:让数据值和位置一起排序

要解决"排序后还能找到原位置"的问题,最直接也最常用的思路就是平行数组(parallel arrays)。所谓平行数组,本质上是"用一份额外的数据,来记录当前元素和原始位置的对应关系"。当主数据在排序时发生移动,位置数据跟着同步移动,这样排序完成之后,你依然能拿到每个值对应的原始下标。

2.1 索引数组法:最轻量的实现

假设原始数据存在data里,我们不直接对data排序,而是另外创建一个idx数组,里面存的是原始下标0, 1, 2, ...。然后对这个idx数组排序,排序的比较器不比较idx本身,而是去比较data[idx[i]]的大小。

代码长这样:

#include <algorithm> #include <iostream> #include <vector> int main() { std::vector<int> data = {3, 1, 4, 1, 5, 9, 2, 6, 5}; std::vector<int> idx(data.size()); for (size_t i = 0; i < data.size(); ++i) { idx[i] = static_cast<int>(i); } // 对 idx 排序,比较的是 data 中对应位置的值 std::sort(idx.begin(), idx.end(), [&](int a, int b) { return data[a] > data[b]; // 从大到小 }); std::cout << "排序后的原位置: "; for (int i : idx) { std::cout << i << " "; } std::cout << std::endl; std::cout << "对应值: "; for (int i : idx) { std::cout << data[i] << " "; } std::cout << std::endl; return 0; }

运行结果:

排序后的原位置: 5 7 4 8 2 0 6 1 3 对应值: 9 6 5 5 4 3 2 1 1

注意看,idx排序后的第一个元素是 5,意味着原数组第 5 个位置的值 9 排在第一位。第二个元素是 7,也就是原数组第 7 个位置的值 6。整个过程中data数组本身没有动过,位置信息全部通过idx的下标间接访问。

这个方案的优点很明显:不需要复制原始数据,只操作一个整型索引数组,内存开销小,适合大对象或者大数组的场景。缺点也很直接:lambda 里通过data[a]访问值时,容易让人产生混淆——尤其是当你习惯了直接对data排序时,会下意识写成return a > b;,那排的就是下标本身了,整个结果就全错了。这个点我后面会专门展开讲。

2.2 结构体绑定法:工程上更推荐

索引数组方案虽然轻量,但可读性弱一些。工程上更常见的做法是定义一个包含"值"和"原位置"两个字段的结构体,然后把结构体数组交给排序算法。

#include <algorithm> #include <iostream> #include <vector> struct Item { int value; int pos; // 原始位置 }; int main() { std::vector<int> data = {3, 1, 4, 1, 5, 9, 2, 6, 5}; std::vector<Item> items; items.reserve(data.size()); for (size_t i = 0; i < data.size(); ++i) { items.push_back({data[i], static_cast<int>(i)}); } // 从大到小排序:先比较值,值相等再比较位置(保证稳定输出) std::sort(items.begin(), items.end(), [](const Item& a, const Item& b) { if (a.value != b.value) { return a.value > b.value; } return a.pos < b.pos; }); for (const auto& it : items) { std::cout << "值=" << it.value << " 原位置=" << it.pos << std::endl; } return 0; }

输出:

值=9 原位置=5 值=6 原位置=7 值=5 原位置=4 值=5 原位置=8 值=4 原位置=2 值=3 原位置=0 值=2 原位置=6 值=1 原位置=1 值=1 原位置=3

这种写法最大的好处是直观:值在哪,位置就跟在哪,数据结构本身表达了"绑定"语义,排序逻辑也清楚。实际业务代码里,很多团队就喜欢这种方式,因为后面维护的人一看Item就知道每个元素承载了两个信息。

代价是如果你原始数组里存的是超大对象(比如几百字节的自定义类型),把整个对象拷进Item会带来额外的拷贝开销。这时候可以退回到索引数组方案,或者用std::reference_wrapper/ 指针。但一般场景下,结构体绑定法完全够用。

2.3 std::pair 的单行写法

如果你不想为了一个小场景定义一个结构体,std::pair是天然的平行数组容器。pair的排序规则是先看first,再比较second,正好符合"值优先、位置次之"的需求。

#include <algorithm> #include <iostream> #include <utility> #include <vector> int main() { std::vector<int> data = {3, 1, 4, 1, 5, 9, 2, 6, 5}; std::vector<std::pair<int, int>> vp; // (值, 原位置) for (size_t i = 0; i < data.size(); ++i) { vp.emplace_back(data[i], static_cast<int>(i)); } std::sort(vp.begin(), vp.end(), [](const auto& a, const auto& b) { return a.first > b.first; // 按值从大到小 }); for (const auto& [v, p] : vp) { std::cout << v << " -> " << p << std::endl; } return 0; }

注意,std::pair的默认operator<是"先 first 后 second",但你现在要的是从大到小,所以还是得写 lambda。如果不写 lambda,那就std::sort之后再用std::greater<>(),但那样second也会被降序,值相等时位置就变成从大到小了,输出的确定性稍差一些。所以还是建议写 lambda 自定义。

三种写法没有绝对的好坏,我的习惯是:数据结构简单用小规模场景,直接std::pair;如果后续要在多个函数里传递、或者需要加更多字段(比如排名、原始数据之外的相关属性),提前定义结构体更省心;如果原始数组是只读的大型容器,尤其是存着字符串、图片路径这类重对象,索引数组更合适。

3. std::unique 的去重逻辑与边界处理

排序解决了,接下来就是去重。标题里点名了std::unique,这里必须说透它的工作原理和使用陷阱。

3.1 unique 只处理相邻重复

std::unique的全名你应该见过:template<class ForwardIt> ForwardIt unique(ForwardIt first, ForwardIt last);。它做的事是移除掉连续且相等的元素,只保留每组相等元素中的第一个,然后返回一个迭代器,指向"去重后新逻辑末尾"的下一个位置。

关键在于"连续"两个字。如果数组是{1, 2, 2, 3, 3, 1},直接调用unique,会把中间的2, 2去重成23, 3去重成3,但开头和结尾的两个1不会合并,因为它们在物理上不相邻。这就是为什么std::unique之前必须先排序——排序让所有相等的元素站到一起,unique才真正能达到"全局去重"的效果。

放到这个场景里,我们排序后得到的idx数组是这样的:

5 7 4 8 2 0 6 1 3

对应的值:

9 6 5 5 4 3 2 1 1

可以看到两个 5 相邻、两个 1 相邻。这时unique就能把它们各自压缩成一个。

3.2 去重后的 size 调整与 resize 陷阱

std::unique有一个特别容易让新手懵的行为:它不会真的删除容器里的元素,只是把重复的"逻辑末尾"移动了。换句话说,调用完unique之后,容器里那些重复元素的旧值还在,但已经处于逻辑有效范围之外。

所以要拿到真正干净去重后的容器,必须配合erase使用,标准写法是:

auto it = std::unique(idx.begin(), idx.end()); idx.erase(it, idx.end());

这就是常说的erase-remove 惯用法的 unique 变体。如果你忘了erase,你会发现idx.size()根本没变小,打印出来还和原来一样长,但前面 N 个元素确实是去重后的结果。表面上看排序输出了重复位置,实际上是你没把"逻辑末尾"之后的多余残留清掉。

另外一个容易犯错的地方是我们这里不是直接对idx去重,而是要对data[idx[i]]去重。也就是说,两个位置,哪怕idx[i]不同,只要它们对应的data值相等,就应该算重复,只保留一个。这种情况下std::unique默认的operator==完全没用,必须给它传自定义的二元谓词:

auto it = std::unique(idx.begin(), idx.end(), [&](int a, int b) { return data[a] == data[b]; }); idx.erase(it, idx.end());

这个自定义谓词的逻辑是:如果data[a]data[b]相等,就认为这两个元素重复,保留a,丢弃b。由于排序已经把所有相同值的元素排在一起了,这里能正确把所有等值位置压缩成一个。

去重后,idx里每个元素就对应"一个唯一值"的原始位置,正好满足"原数据位置不重复"的需求。

3.3 自定义去重规则的注意事项

在使用自定义unique谓词时,有几个细节值得注意:

第一,谓词必须满足等价关系。unique底层是相邻比较,如果谓词返回true就认为"相等"。但如果你传入的谓词是不对称的(比如data[a] >= data[b]),结果是未定义的,可能得到完全错误的位置列表。所以自定义谓词一定要设计成"我判断的是这两个值是否相等"而不是"比较大小"。

第二,分组去重保留的是每组第一个。在我们这个场景里,如果两个位置的值相同,unique保留的是排序后靠前的那个。由于排序时对相同值我们通常按位置升序排列(也就是位置小的排在前面),那unique之后保留的就是"第一次出现(最小下标)"的位置。如果你希望保留的规则不同,比如保留最后一次出现的位置,就需要在比较器中提前调整顺序,让希望被保留的那个元素排在前面。

第三,unique返回值要在 erase 之前存好。如果你写成idx.erase(std::unique(...), idx.end());虽然也是合法的,但可读性稍差。更关键的是,unique返回的迭代器在erase之前是有效的,但如果你在unique之后、erase之前又往容器里插入或删除了其他元素,这个迭代器可能失效,导致未定义行为。工程上建议把这两步分开写。

4. 从大到小排列时的自定义比较器设计

排序方向看似一句话的事,但真正写代码时,比较器的设计藏着很多决定结果正确性的细节,尤其是和数据去重逻辑混在一起时,更要谨慎。

4.1 默认排序方向与比较器写法

std::sort默认使用operator<,也就是升序排列。要实现从大到小,通常有三种办法:

  1. std::greater<T>()
std::sort(v.begin(), v.end(), std::greater<int>());
  1. 传 lambda:
std::sort(v.begin(), v.end(), [](int a, int b) { return a > b; });
  1. 反转迭代器(不推荐):
std::sort(v.rbegin(), v.rend()); // 实际是升序,但因为是反向遍历,结果变成降序

对我们的场景来说,idx里的元素是下标,不能直接用std::greater<int>(),因为那样比较的是下标而不是data的值。所以要写 lambda,并且 lambda 里要去解引用原始数据数组:

std::sort(idx.begin(), idx.end(), [&](int a, int b) { return data[a] > data[b]; });

4.2 比较器中的相等情况处理

这里有个很多人容易忽略的问题:如果data[a] == data[b]时,上面这个 lambda 会返回falsestd::sort认为两者等价,它们的相对顺序是不确定的。

为什么"不确定"会有问题?因为后面unique去重时,保留的是每组等价元素中的第一个。如果两个相同值的元素在排序结果中顺序不稳定,那保留的位置就会时而是下标小的,时而是下标大的,导致输出结果不确定。这在单次运行里看不出问题,但换一个编译器版本、换一份数据、甚至换一个平台,结果可能就变了。

要保证输出确定性,比较器里应该处理相等情况:值相等时,再按位置升序排,这样unique之后保留下来的必然是"最先出现的那个位置"。

std::sort(idx.begin(), idx.end(), [&](int a, int b) { if (data[a] != data[b]) { return data[a] > data[b]; // 主要排序键:值从大到小 } return a < b; // 次要排序键:位置从小到大,保证确定性 });

4.3 稳定排序对位置输出的影响

你可能听说过std::stable_sort,它保证相等元素的相对顺序不变。那能不能用stable_sort替代sort来解决问题?

可以,但要注意语义差异。stable_sort保留的是元素在排序前的相对顺序。拿idx初始数组来说,它本来就是0, 1, 2, ...升序的,用stable_sort按值降序排,值相同的元素会保持原来的位置升序关系;但如果idx初始数组不是这个顺序(比如你之前打乱过),那stable_sort保留的就是"打乱后的顺序",不一定是按位置升序。所以为了可预测性,最好在比较器里显式声明"值相等时按位置升序",而不是依赖stable_sort的保序行为。

性能上,std::stable_sort的空间复杂度是 O(n),在内存受限或数据量大的场景下会有额外开销。能用sort解决的问题,不一定需要stable_sort。我们这种需求,比较器里加一个次要排序键,用普通sort就能完全控制输出顺序,没必要上stable_sort。只有当你希望"排序后原数组中本来在前的仍然在前、本来在后的仍然在后"这种语义时,才真正需要它。

5. 完整可运行的示例代码与输出

前面的理论讲了这么多,最终还是要落到能跑的代码上。这一节给一个完整的、可以直接复制粘贴编译运行的示例,包含平行数组排序、std::unique自定义谓词去重、以及输出验证。

5.1 示例场景:带重复值的数组输出排序后的原位置

完整程序如下:

#include <algorithm> #include <iostream> #include <vector> int main() { // 原始数据,包含重复值 std::vector<int> data = {3, 1, 4, 1, 5, 9, 2, 6, 5}; // 创建索引数组(平行数组的核心) std::vector<int> idx(data.size()); for (int i = 0; i < static_cast<int>(data.size()); ++i) { idx[i] = i; } // 从大到小排序:先按值降序,值相等时按原位置升序 std::sort(idx.begin(), idx.end(), [&](int a, int b) { if (data[a] != data[b]) { return data[a] > data[b]; } return a < b; }); std::cout << "排序后的位置序列(去重前):"; for (int p : idx) { std::cout << " " << p; } std::cout << std::endl; std::cout << "对应的值序列(去重前):"; for (int p : idx) { std::cout << " " << data[p]; } std::cout << std::endl; // 用 std::unique 对 idx 去重:data 值相等视为重复位置 auto new_end = std::unique(idx.begin(), idx.end(), [&](int a, int b) { return data[a] == data[b]; }); idx.erase(new_end, idx.end()); std::cout << "\n去重后结果(每个唯一值只保留一个原位置):\n"; std::cout << "值 原位置\n"; for (int p : idx) { std::cout << data[p] << " " << p << std::endl; } return 0; }

编译运行(假设存为unique_pos.cpp):

g++ -std=c++17 -o unique_pos unique_pos.cpp ./unique_pos

输出:

排序后的位置序列(去重前): 5 7 4 8 2 0 6 1 3 对应的值序列(去重前): 9 6 5 5 4 3 2 1 1 去重后结果(每个唯一值只保留一个原位置): 值 原位置 9 5 6 7 5 4 4 2 3 0 2 6 1 1

验证一下:data[5] = 9,最大;data[7] = 6,次大;data[4] = 5data[8] = 5相等,只保留下标 4;最后data[1] = 1data[3] = 1相等,只保留下标 1。完全符合"从大到小 + 原数据位置不重复"的要求。

5.2 多组测试数据验证

这个例子能处理多种情况,我建议你自己多换几组数据测试,比如:

测试 1:全部元素相同

data = {7, 7, 7, 7}

排序后:idx = {0, 1, 2, 3}(值相等,按位置升序)。unique之后只剩{0},输出:

值 原位置 7 0

结果合理:所有值都一样,去重后只保留第一个位置。

测试 2:全部元素不同

data = {3, 2, 1}

排序后:idx = {0, 1, 2}unique谓词比较data值没有相等的,所以idx不变。输出:

值 原位置 3 0 2 1 1 2

测试 3:包含负数

data = {-5, 3, -5, 0}

排序后(从大到小):idx = {1, 3, 0, 2},对应值{3, 0, -5, -5}unique后:

值 原位置 3 1 0 3 -5 0

负数场景下照样工作。唯一需要留意的是比较器里的data[a] > data[b]对自定义类型可能不适用,那就要在类型里重载operator>或者写合适的比较逻辑。

6. 实际工程应用和踩坑经验

代码写得顺手之后,我越看越觉得这个组合技在真实项目里出现频率很高,而且每次都有人踩到类似的坑。这一节聊聊我的工程观察和个人经验。

6.1 可以立即用上的场景

排行榜和比赛排名。最典型。一组选手成绩,按分数降序排,每个成绩后面要挂选手ID。如果只存了成绩数组,排序之后选手ID关联不上,整个排名表就废了。用平行数组把选手ID绑在下标上,排序后直接通过idx取选手ID,零成本。

图像处理里的非极大值抑制(NMS)。目标检测场景下,先按置信度对候选框从高到低排序,再逐个判断是否抑制。候选框的原始索引在排序后必须保留,否则没法回溯到原始检测结果。这个场景虽然通常用 Python/Numpy 实现,但底层逻辑完全一致——按值排序索引数组,再遍历索引处理。

数据分析和报表生成。比如统计一段文本里各个单词出现的次数,然后按次数降序输出"单词 + 首次出现位置"。先统计频率数组,再对频率做排序 + unique,输出每个单词的首次出现位置,平行数组加 unique 的组合正是标准解法。

数据库排序后需要回到原始记录。虽然数据库里有ORDER BY,但你拿到 C++ 里处理的数据如果是从接口直接拉来的,没有数据库排序能力,那就还是得自己排。需要把记录的下标跟着值一起走,才能回到原始记录里取其他字段。

6.2 我在实际使用中遇到的坑

第一个坑前面提到过,就是比较器写错。我见过最多的错误写法是这样:

std::sort(idx.begin(), idx.end(), [](int a, int b) { return a > b; // 排的是下标,不是 data 的值 });

如果你忘了捕获data,编译器可能会报data未定义,但如果你在 lambda 外定义了一个全局data,这个问题就是静默的——排序结果是"下标从大到小",而不是"值从大到小"。排查起来特别费劲,因为程序不报错,只是结果不对。我的建议是:写完 lambda 之后,先在纸上模拟一遍,确认你比较的是"元素值"而不是"平行数组本身"

第二个坑是uniqueerase分开写的问题。我在早期代码里试过一次只写std::uniqueerase,结果打印idx时发现最后面多了几个重复的旧值,一看就是没删干净。后来养成了习惯:unique之后一定配erase,而且用auto it = std::unique(...);先接住返回值,再erase(it, idx.end()),不要为了省一行代码把两件事挤在一起写,否则出了问题不好定位。

第三个坑是"值相等但位置不重复"的语义混淆。有同事拿着这个需求,直接把data排序 + unique 了,然后打印data里每个元素的下标,发现全是乱序。那是必然的——你对data本身做 unique,元素移动之后下标早就不是原始下标了。正确的做法是始终操作平行数组idxdata只用来提供比较依据,永远不要动它。这个"主数组只读、辅助数组排序"的心智模型,能帮你避免绝大多数逻辑错误。

6.3 性能考量与替代方案

数据量小的时候,随便怎么排都行。但如果你处理的是百万级数据,std::sort的时间复杂度是 O(n log n),索引数组方案里多一次data[a]的间接访问,在缓存命中率上会略逊于直接排结构体,但差不了多少。真正要留意的是做unique时自定义谓词访问data的随机性——因为idx排序后已经按值排列了,data[idx[i]]的访问模式基本是连续的,缓存友好度还不错,问题不大。

如果你的数据量极大,而且不仅要输出"每个唯一值的第一个位置",还要输出"每个唯一值的所有位置列表",那这个方案就不够用了,得考虑std::unordered_map做分组聚合,或者std::map自动按键排序。反之,如果只是要"去重后的排序值列表",那直接对data排序 + 默认unique就够了,根本不用平行数组。方案选型的关键在于你是否需要"原始位置"这个信息,需要才上平行数组,不需要别硬上,否则就是给自己埋坑。

另外一个容易被忽略的点是内存。std::stable_sort需要额外 O(n) 空间,std::sort是原地排序。对于超大数组,如果你用了stable_sort又叠加平行数组的内存开销,可能会直接触发内存不足。我在一次处理近千万级浮点数据时就遇到过这个问题,后来换成std::sort加显式次级比较键,内存瞬间降下来了,输出结果完全一致。所以默认能用sort就不要上stable_sort,除非你真的需要稳定性的语义。

最后再分享一个我自己实际工作里觉得实用的小技巧:如果你处理的数据量不是特别大,又怕排序后丢位置,可以用std::map<int, std::vector<int>>把"值"映射到"所有出现的位置列表"。直接遍历原始数组填充 map,map 本身按键升序排列,再倒序遍历就能实现从大到小。但 map 的节点开销比 vector 大不少,适合数据量小、代码量优先的场景。数据量大、追求性能时,还是这篇文章里的平行数组 +std::unique组合最稳。

说到底,"排序后找到原位置"这个问题,核心不是你会不会sort,而是你有没有建立"数据值和位置信息绑定移动"的思维模型。有了这个模型,std::unique去重、从大到小输出、保留首次出现位置这些细节,就都只是在这个模型上打补丁而已。希望这篇文章能帮你把这个模型彻底建立起来,下次再遇到类似需求,不用再挠头。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询