文章
leetcode-217
本文目录 5
LeetCode 217 题解
217 - Duplicate Nums,是一道求解列表里是否有重复数字的题目
乍一看用哈希或者排序算法都可以解决,是个非常简单的题目,但是在实际的速度上都没有达到最优,因此记录一下优化的过程
1. Hash
哈希算法的时间复杂度为,空间复杂度也为,第一遍简单用unordered_map写
bool containsDuplicate(vector<int>& nums) {
unordered_set<int> s;
for (int x : nums) {
// check if the number had appeared before
if (s.count(x)) return true;
s.insert(x);
}
return false;
}
//或者直接利用 insert 的特性
bool containsDuplicate(vector<int>& nums) {
unordered_set<int> s;
s.reserve(nums.size());
for (int x : nums) {
// insert 返回 pair<iterator, bool>
if (!s.insert(x).second) {
return true;
}
}
return false;
}
甚至如果不从中途退出,直接比较最终的结果的话,牺牲一点时间就能得到很”炫炮”的一行代码
bool containDuplicate(vector<int>& nums){
return unordered_set<int>(nums.begin(),nums.end()).size() < nums.size();
}
直接利用cpp提供的unordered_map来写,虽然直观,但是并没有得到很好的结果,其原因是unordered_map的底层是由拉链法实现的。它的组成部分为:
- 桶数组(Bucket Array):一段连续的内存空间,存储指向链表的指针
- 链表节点(Linked Nodes):存储实际元素的节点,通过指针连接
因为是动态分配,所以它存在在堆上,可能占用的是不连续的内存,遍历的时候需要跳跃访存;同时当新元素出现的时候,需要进行一个扩容 (rehash)。而扩容的过程是重新分配一个更大的桶数组(和用数组实现堆/栈相似),并且释放旧的桶数组并重新组织链表。由此可以小小优化一下
bool containsDuplicate(vector<int>& nums) {
unordered_set<int> s;
s.reserve(nums.size()); // 关键优化:预先分配足够空间
for (int x : nums) {
if (s.count(x)) return true;
s.insert(x); // 不会触发 rehash
}
return false;
}
2. Sort
先排序,之后查看相邻的元素是否相同即可
理论上来说根据不同的排序方法,时间复杂度为到不等,比使用hash要更慢,但是实际上测试下来都比unordered_map要慢,
核心因素可能是数组在内存中连续,CPU的L1/L2 Cache对于这种情况读取效率高
bool containsDuplicate(vector<int>& nums) {
//使用内置的 sort (快排)
sort(nums.begin(), nums.end());
for (int i = 0; i < nums.size() - 1; ++i) {
if (nums[i] == nums[i + 1]) return true;
}
return false;
}
发现排序更有效之后,再试试用一些非比较排序的方法来优化,比如基数排序(牺牲空间)
#pragma GCC optimize("Ofast,unroll-loops")
#pragma GCC target("avx2,bmi,bmi2,lzcnt,popcnt")
static const int speedup = []() {
std::ios_base::sync_with_stdio(false);
std::cin.tie(NULL);
return 0;
}();
class Solution {
public:
bool containsDuplicate(vector<int>& nums) {
int n = nums.size();
if (n <= 1) return false;
// 基数排序需要一个辅助数组
vector<int> buffer(n);
int* src = nums.data();
int* dst = buffer.data();
// 针对 32 位整数进行两轮基数排序(每轮 16 位,即 65536 个桶)
// 第一轮:处理低 16 位
int count[65536];
// 第一次 Pass
// 清空计数器
for (int i = 0; i < 65536; ++i) count[i] = 0;
// 分拣,看每个“篮子”里对应要放多少数字
for (int i = 0; i < n; ++i) {
count[(unsigned short)(src[i] ^ 0x80000000)]++; // 偏移符号位处理负数(异或运算反转最高位,改变映射区域)
}
// 将 count 转换为 dst 中的 index
for (int i = 1; i < 65536; ++i) count[i] += count[i - 1];
// 搬运数据
for (int i = n - 1; i >= 0; --i) {
dst[--count[(unsigned short)(src[i] ^ 0x80000000)]] = src[i];
}
// 第二轮:处理高 16 位
swap(src, dst);
for (int i = 0; i < 65536; ++i) count[i] = 0;
for (int i = 0; i < n; ++i) {
count[(unsigned int)((unsigned int)(src[i] ^ 0x80000000) >> 16)]++;
}
for (int i = 1; i < 65536; ++i) count[i] += count[i - 1];
for (int i = n - 1; i >= 0; --i) {
dst[--count[(unsigned int)((unsigned int)(src[i] ^ 0x80000000) >> 16)]] = src[i];
}
// 最终检查相邻元素
for (int i = 0; i < n - 1; ++i) {
if (dst[i] == dst[i + 1]) return true;
}
return false;
}
};
这是目前为止最快的版本,但是还是没有达到 leetcode 排行榜中的最快
3. Customized HASH
基数排序分为两部分进行了处理,并且每一步都包含了初始化和搬运这两个小循环,而 hash 理论上只要搬运一次数据。因此看看有没有更好的 hash 实现方式
之前使用的unordered_map的数据结构是有利于完成之后的查询的一种结构,但是这道题其实只需要存储这一步,因为 hash 的特性会在存储的时候计算出映射,所以这个时候就已经能检验是否重复了。这里尝试了一个简单的线性探测,因为不会涉及到取出这个元素,所以它本身的主聚类导致查找的性能损失的劣势不复存在
class Solution {
public:
bool containsDuplicate(vector<int>& nums) {
int n = nums.size();
if (n < 2) return false;
// 1. 空间换时间:设定哈希表大小为 2 的幂次
// 负载因子控制在 0.5 以下,极大减少探测冲突
int hashSize = 1;
while (hashSize < n * 2) hashSize <<= 1;
int mask = hashSize - 1;
// 2. 使用原始连续数组,分配极快且缓存友好
vector<int> hashTable(hashSize, INT_MIN);
for (int x : nums) {
// 3. 计算哈希:简单的无符号映射 + 位运算取模
unsigned int h = (unsigned int)x;
int idx = h & mask;
// 4. 线性探测:如果位置被占用,直接检查下一个
while (hashTable[idx] != INT_MIN) {
if (hashTable[idx] == x) return true; // 发现重复,立即退出(Early Exit)
idx = (idx + 1) & mask; // 线性向后移动
}
// 5. 填入新值
hashTable[idx] = x;
}
return false;
}
};
几个细节:
- hash表的大小:要是负载因子大于 0.5,就要开辟大于 2*n 的空间,同时要保证 hashsize 为2的幂次,便于后面 计算hash 的优化
- 计算hash:线性探测需要对数字和 hashsize 进行一个取模的运算。而 mod 运算处理起来是一个除法,需要复杂的逻辑单元处理;而按位与
&只是简单的位运算,在汇编的时候只是一个AND指令,运算代价小
总结
最终线性探测方法胜出
另:数据结构的一些算法已经快忘光了