- 【单调栈模板】下一个更大元素 I
记录的时候用数组和哈希表有什么区别?
- @ 2025-12-23 14:42:48
记录的时候用数组和哈希表有什么区别?
这是一个非常深入且具有实战意义的问题。在 NOI 竞赛中,选择数组还是哈希表,往往决定了你的程序是“稳过”还是“在超时的边缘疯狂试探”。
作为教练,我把这两者的区别总结为:“空间换时间的极致” vs “通用灵活性”。
一、 形象化类比:找房间
假设我们要存储每个数字的“大哥”是谁。
1. 数组(Array):像是一条整齐的街道
- 做法:数字是几,你就去几号房。比如数字是
100,你就直接推开100号房的门,把大哥的名字存进去。 - 特点:推门即达。你不需要查表,不需要询问。
- 条件:街道必须足够长(值域范围 MAXV 不能太大)。
2. 哈希表(unordered_map):像是一个写字楼的咨询台
- 做法:你拿着数字
100去问前台:“请问100对应的记录在哪里?”前台通过一种算法(哈希函数)计算出位置,然后带你过去。 - 特点:多了一个环节。虽然平均也是 ,但那个“计算过程”和“处理冲突过程”非常耗时。
二、 核心区别对比表
| 特性 | 数组 (Array) | 哈希表 (std::unordered_map) |
|---|---|---|
| 访问速度 | 极快(真正的一步到位) | 较慢(有哈希计算和冲突处理的常数开销) |
| 空间消耗 | 取决于数值的范围 (Value Range) | 取决于元素的个数 (Element Count) |
| 适用场景 | 数值较小且连续(如 ) | 数值巨大或稀疏(如 ) |
| 稳定性 | 极高(时间恒定) | 一般(遇到精心构造的恶意数据会退化为 ) |
三、 启发式思维:在草稿纸上分析复杂度
当你读完题,准备动笔写代码前,请先在草稿纸上写下这两行:
- 看值域(Value Range):题目说 。
- 算一下: 个
int占多少内存? - 计算: 字节 KB。
- 结论:内存极小!果断用数组。
- 算一下: 个
- 看时间(Time Limit):如果题目给了 的超短时限。
- 数组访问是 CPU 的一个指令。
- 哈希表可能是几十个指令。
- 结论:为了卡进时限,必用数组。
四、 NOI 竞赛中的“潜规则”
在信息学奥赛中,关于这两者的使用有几条金牌选手的经验法则:
1. “能开数组就开数组”
如果值域在 以内,数组是神。它不仅快,而且不容易写出 Bug。
2. “离散化”是数组的救星
如果数字很大(比如 ),但总共只有 个数,我们可以先进行离散化(把大数字映射成小排位),然后继续用数组。
3. 警惕 std::map (注意不是 unordered_map)
std::map的底层是红黑树,访问一次是 。- 在 NOI 这种对时间极其敏感的地方,如果在循环里嵌套
map,非常容易喜提 TLE。
五、 总结读题关键词
在读题时,通过这两个关键词来决定用谁:
- 关键词 A:“数值范围/值域”
- 看到 、:数组(静态分配,最稳)。
- 看到 、:哈希表(或先离散化再用数组)。
- 关键词 B:“互不相同”
- 本题提到“元素互不相同”,这保证了数值和下标可以一一对应,是使用“数值数组”做映射的前提。
教练的一句话秘籍:
“在 NOI 里,数组是你的贴身佩剑,快且准;哈希表是你的瑞士军刀,虽然功能全,但关键时刻可能会卡壳。”
什么是离散化?
“离散化”这个词听起来很高级,但在 NOI 竞赛中,它其实就是一个 “排队领编号” 的过程。
当你手里的数字很大(比如 ),数组放不下,但你又想享受数组那种 访问的快感时,离散化就是你的救星。
一、 生活化类比:马拉松选手的号码牌
想象你正在组织一场马拉松。选手的身份证号(原始数值)可能有 18 位那么长:
- 选手 A 的身份证号:
1101051990... - 选手 B 的身份证号:
3201022000... - 选手 C 的身份证号:
4403012005...
如果你想给每个选手准备一个储物柜,你不可能开出 个柜子,那地球都装不下。
离散化的做法是:
- 把这 3 个选手的身份证号拿出来,按从小到大排个序。
- 给最小的人发
1号胸牌,第二小的发2号,第三小的发3号。 - 结果:身份证号那个巨大的数字,就被缩减成了
1, 2, 3。这就叫“离散化”。
二、 启发式推演:在草稿纸上模拟
假设题目给的 nums2 变得非常夸张:
nums2 = [1000000000, 500, 9999999] (这些数字数组存不下!)
请跟着我在草稿纸上做这三步:
第一步:排序(找出大小关系)
把数字从小到大排列:
500, 9999999, 1000000000
第二步:去重(保证每个值唯一)
如果里面有重复的数字,只保留一个。(本题已经保证互不相同,这一步可省略,但在一般 NOI 题中必须要做)。
第三步:映射(领取新编号)
我们建立一个对应的关系表:
500编号19999999编号21000000000编号3
现在,原来的数组就变成了:
[3, 1, 2]
这个新数组里的数字最大才 3,你可以开一个 int a[5] 的数组,轻轻松松处理它们!
三、 为什么离散化对单调栈有用?
回到我们的“下一个更大元素”题目。如果数值很大,我们不能直接用 next_greater[1000000000]。
离散化后的处理流程:
- 把
nums2里的数领到新编号。 - 用这些新编号进单调栈。
- 由于编号的大小顺序和原数字的大小顺序是一模一样的(比如
3 > 1对应10亿 > 500),所有的比较逻辑(x > s.top())完全不受影响。 - 存答案的时候,用
next_greater[新编号]即可。
四、 竞赛代码怎么实现离散化?
在 C++14 中,我们通常用“三步走”策略:
// 1. 复制一份原数组并排序
vector<int> temp = nums2;
sort(temp.begin(), temp.end());
// 2. 去重(NOI 常用套路)
temp.erase(unique(temp.begin(), temp.end()), temp.end());
// 3. 找新编号(用二分查找,速度极快)
int get_id(int val) {
// lower_bound 返回第一个大于等于 val 的位置
return lower_bound(temp.begin(), temp.end(), val) - temp.begin() + 1;
}
五、 复杂度思考过程
- 空间:从原来的“值域有多大,空间就有多大”变成了“有多少个数,空间就有多大”。哪怕值域是 ,只要只有 个数,我们就只占 空间。
- 时间:多了排序 和二分查找 的开销。在 级别时,这个开销完全可以接受。
六、 总结读题关键词
什么时候你要在心里大喊一声“需要离散化了!”?
- “数值很大,但个数很少”:比如 ,但 。
- “只关心大小关系,不关心具体数值”:比如找“下一个更大元素”、“逆序对”、“区间内有多少个数”。
- “需要用值作为数组下标”:这是最明显的信号。
教练的一句话:
“离散化就是把巨大的、稀疏的‘身份证号’,变成紧凑的、连续的‘排队序号’,它是连接‘海量数值’与‘高效数组’的桥梁。”
详细解释这里的去重写法
// 2. 去重(NOI 常用套路)
temp.erase(unique(temp.begin(), temp.end()), temp.end());
这一行代码 temp.erase(unique(temp.begin(), temp.end()), temp.end()); 是 C++ 竞赛编程中最经典、最常用的“套路”之一,被称为 Erase-Unique 惯用法。
在离散化中,它的作用是:把排好序的数组中重复的元素删掉,只保留一份,从而得到一个纯净的“值域映射表”。
为了让你彻底理解,我们把它拆解成三个部分来“解剖”。
第一步:前提条件——必须先排序
unique 函数只能处理相邻的重复元素。
如果你的数组是 [3, 1, 3],unique 发现 3 和 1 不一样,1 和 3 不一样,它就什么都不做。
所以,在使用这一行之前,必须先执行 sort(temp.begin(), temp.end());。
第二步:unique 到底做了什么?(伪删除)
这是最容易误解的地方:unique 并没有真正删掉元素,它只是在做“搬运工”。
想象一个具体的例子:
假设 temp = [1, 2, 2, 3, 3, 3, 5](已排序)。
unique开始扫描,发现重复的2和3。- 它把不重复的元素往挪到前面,覆盖掉重复的部分。
- 执行完后,数组变成了:
[1, 2, 3, 5, ?, ?, ?]。- 前面的
1, 2, 3, 5是我们要的。 - 后面的
?, ?, ?是残留下来的垃圾数据(长度没变)。
- 前面的
- 关键返回值:
unique会返回一个迭代器(指针),指向第一个“垃圾数据”的位置(即例子中第一个?的位置)。
第三步:erase 完成“真删除”
vector::erase(first, last) 的作用是:删掉从 first 到 last 之间的所有元素。
first:就是unique返回的那个指向第一个垃圾数据的指针。last:就是temp.end()。
合起来看(草稿纸模拟过程):
初始状态:
[ 1, 2, 2, 3, 3, 3, 5 ]
^ ^
begin end
1. 执行 unique 后:
[ 1, 2, 3, 5, | 3, 3, 5 ]
^ ^
it(返回) end
(前4个是去重后的结果)
2. 执行 erase(it, end) 后:
[ 1, 2, 3, 5 ]
^ ^
begin end
(垃圾数据被彻底清理,size 变小了)
四、 为什么 NOI 选手一定要写这一行?
- 空间效率:离散化时,如果原数据有 100 万个
5,去重后只占 1 个位置,极大地节省了映射表的大小。 - 二分查找准确性:后面我们要用
lower_bound找编号,如果表里有重复的数,找出来的编号就乱了。 - 时间复杂度:
unique是 的,它只遍历一遍。erase也是 的。- 配合
sort的 ,整套去重操作非常高效。
五、 启发式思考:如果手写这个过程怎么写?
在草稿纸上,如果不调用 STL,你可以尝试写出这个逻辑(这也是面试或初级 OI 经常考的):
int j = 0;
for (int i = 0; i < n; ++i) {
if (i == 0 || a[i] != a[i-1]) {
a[j++] = a[i]; // 只有和前一个不一样的才往“新家”搬
}
}
// 最后的有效长度就是 j
你会发现,unique 的内部原理其实就是这个双指针移动的过程。
六、 总结读题关键词
当你看到以下要求时,脑子里要瞬间弹出这一行代码:
- “统计有多少个不同的数”:
unique后看size()。 - “进行离散化预处理”:必须去重才能建立唯一映射。
- “集合运算”:求并集、交集前通常需要先去重。
教练的叮嘱:
“
sort+unique+erase是离散化的‘三剑客’。记住,unique只是把垃圾堆到了后院,erase才是把垃圾车叫来拉走。”
1 条评论
-
tobyforever LV 5 SU @ 2025-12-23 15:39:36
前面的离散化里,
// 1. 复制一份原数组并排序 vector temp = nums2;
我的疑问: 这里是引用还是拷贝?
在 C++ 中,这是一个非常基础但又极其重要的细节。我直接给出结论:
这里是深拷贝 (Deep Copy)。
也就是说,执行完这一行后,
temp和nums2是两个完全独立的数组。你在temp上进行排序、删除、修改,绝对不会影响到nums2原有的顺序和内容。
一、 为什么在离散化中“拷贝”是必须的?
在我们的“下一个更大元素”题目(或者任何离散化场景)中,我们需要:
- 原数组 (
nums2):保持原来的顺序,因为单调栈是按照“谁在谁右边”来计算的。 - 副本数组 (
temp):打乱顺序进行排序,用来建立“数值 排名”的映射表。
如果你误用了引用,比如写成
vector<int>& temp = nums2;,那么当你对temp排序时,nums2的顺序也乱了,单调栈逻辑就会彻底崩盘。
二、 内存里发生了什么?(草稿纸示意图)
假设
nums2占用了内存地址0x1001开始的空间。- 执行
vector<int> temp = nums2;时:- 系统会在内存中(比如
0x2001)开辟一块同样大小的新空间。 - 把
nums2里的每一个数字,挨个复制到新空间里。 - 代价:时间复杂度 ,空间复杂度 。
- 系统会在内存中(比如
三、 各种写法的区别(避坑指南)
在竞赛中,一定要分清这几种写法:
写法 类型 效果 vector<int> a = b;拷贝 独立副本。修改 a不影响b。vector<int>& a = b;引用 a是b的别名。修改a就是修改b。vector<int>* a = &b;指针 a指向b的地址。void func(vector<int> v)值传递 函数内部会产生一份新的拷贝(慢)。 void func(vector<int>& v)引用传递 函数内部直接操作原数组(快)。
四、 性能优化:什么时候“拷贝”会成为负担?
在 NOI 比赛中,如果 :
- 执行
temp = nums2会消耗大约 40MB 内存和一定的时间。 - 如果你的内存限制(Memory Limit)非常紧(比如 64MB),这种拷贝操作可能导致你 MLE (Memory Limit Exceeded)。
金牌选手的省空间技巧: 如果你不需要保留原数组,或者原数组以后再也不用了,可以使用 C++11 的 移动语义 (Move):
vector<int> temp = std::move(nums2);这就像是把nums2仓库的钥匙直接给了temp,不产生任何拷贝。但注意,执行后nums2就变空了,不能再使用。
五、 总结读题关键词
在编写涉及多个数组的操作时,请时刻问自己:
- “我是否需要保护原数组的顺序?” 需要拷贝。
- “我是不是只是想给数组换个名字方便调用?” 用引用
&。 - “这个数组以后还会用到吗?” 不用了就考虑
move或者直接在原数组上操作。
教练的提醒:
“在 NOI 考场上,默认的
=赋值是安全的(拷贝),但要警惕它带来的空间翻倍。对于本题 的规模,放心拷贝即可。”❤️ 1 - 原数组 (
- 1
信息
- ID
- 19362
- 时间
- 1000ms
- 内存
- 256MiB
- 难度
- 10
- 标签
- 递交数
- 2
- 已通过
- 1
- 上传者