记一次心血来潮的优化
无题
为了备考,最近一直在刷leetcode。在做每日一题2080.区间内查询数字的频率的时候,使用哈希表和二分查找。于是慢悠悠地写完之后,交上去一发,发现:
class RangeFreqQuery { private: unordered_map<int, vector<int> > dataWithIndex; public: RangeFreqQuery(vector<int>& arr) { for(int i = 0; i < arr.size(); i++) { dataWithIndex[arr[i]].push_back(i); } } // 两次二分查找需要有一边不取等号,即小于和小于等于。 // 设存在两个相等的数,则我们需要在middle都指向两个值时,使得一个二分的左值小于它,一个二分的左值等于它。 // 那么最后一步就需要从判断中修改。如果下界是<=,那么left将大于mid。上界是<,则right将小于mid。 // 所以下界应该为<,right将小于mid。上界为<=,left将大于mid。 int lowerBound(int value, int target) { int left = 0, right = dataWithIndex[value].size() - 1; while(left <= right) { int middle = (left+right) / 2; if(dataWithIndex[value][middle] < target) { left = middle + 1; } else { right = middle - 1; } } return left; } int upperBound(int value,int target) { int left = 0, right = dataWithIndex[value].size() - 1; while(left <= right) { int middle = (left+right) / 2; if(dataWithIndex[value][middle] <= target) { left = middle + 1; } else { right = middle - 1; } } return left; } int query(int left, int right, int value) { if(dataWithIndex[value].size() == 0) return 0; int leftBound = lowerBound(value,left); int rightBound = upperBound(value,right); return rightBound - leftBound; } }; /** * Your RangeFreqQuery object will be instantiated and called as such: * RangeFreqQuery* obj = new RangeFreqQuery(arr); * int param_1 = obj->query(left,right,value); */
为什么这么慢呢?百思不得其解中。于是开始尝试改造自己的代码让它加速。经过反复观察,我们发现了一个我们写该题时出现的第一个问题,也是一个很重要的问题。
反复在二分查找中对unordered_map进行索引查找
正常来说我们的哈希表的平均时间复杂度为O(1)。但实际上这反应的是平均的查询情况,而不是每次查询均为O(1)。因此在二分查找中反复使用哈希表进行索引会导致实际上的二分查找的时间复杂度变成
于是我们尝试先构造一个vector,然后将vector的引用传入:
vector<int> vec = dataWithIndex[value]; if(vec.size() == 0) return 0; int leftBound = lowerBound(vec,value,left); int rightBound = upperBound(vec,value,right);
结果出现了TLE!超出了时间限制。
大规模调用query函数以及对vector构造和析构开销
在这道题目中,多次调用了query函数。而每次我们执行vector<int> vec = dataWithIndex[value];
时,会反复构造新的vector,并且在执行函数结束后调用析构函数。大量的构造和析构造成了大量的时间开销,最终使得query退化成线性时间复杂度,导致超时。
于是我们直接传入哈希表对应的vector引用即可。
class RangeFreqQuery { private: unordered_map<int, vector<int> > dataWithIndex; public: RangeFreqQuery(vector<int>& arr) { for(int i = 0; i < arr.size(); i++) { dataWithIndex[arr[i]].push_back(i); } } // 两次二分查找需要有一边不取等号,即小于和小于等于。 // 设存在两个相等的数,则我们需要在middle都指向两个值时,使得一个二分的左值小于它,一个二分的左值等于它。 // 那么最后一步就需要从判断中修改。如果下界是<=,那么left将大于mid。上界是<,则right将小于mid。 // 所以下界应该为<,right将小于mid。上界为<=,left将大于mid。 int lowerBound(vector<int> & vec, int & value, int & target) { int left = 0, right = vec.size() - 1; while(left <= right) { int middle = (left+right) / 2; if(vec[middle] < target) { left = middle + 1; } else { right = middle - 1; } } return left; } int upperBound(vector<int> & vec, int & value,int & target) { int left = 0, right = vec.size() - 1; while(left <= right) { int middle = (left+right) / 2; if(vec[middle] <= target) { left = middle + 1; } else { right = middle - 1; } } return left; } int query(int left, int right, int value) { if(dataWithIndex[value].size() == 0) return 0; int leftBound = lowerBound(dataWithIndex[value],value,left); int rightBound = upperBound(dataWithIndex[value],value,right); return rightBound - leftBound; } }; /** * Your RangeFreqQuery object will be instantiated and called as such: * RangeFreqQuery* obj = new RangeFreqQuery(arr); * int param_1 = obj->query(left,right,value); */
学习STL中奇特的二分查找写法
还是很不满意我们的时间复杂度,为什么无法到达前50%呢(魔怔了),于是我们参考了前面执行迅速的代码,发现他们执行了stl中的lower_bound
和upper_bound
进行查找。于是我们来阅读了LLVM中关于这个函数的实现:
template <class _AlgPolicy, class _Iter, class _Type, class _Proj, class _Comp> _LIBCPP_NODISCARD _LIBCPP_HIDE_FROM_ABI _LIBCPP_CONSTEXPR_SINCE_CXX20 _Iter __lower_bound_bisecting( _Iter __first, const _Type& __value, typename iterator_traits<_Iter>::difference_type __len, _Comp& __comp, _Proj& __proj) { while (__len != 0) { auto __l2 = std::__half_positive(__len); _Iter __m = __first; _IterOps<_AlgPolicy>::advance(__m, __l2); if (std::__invoke(__comp, std::__invoke(__proj, *__m), __value)) { __first = ++__m; __len -= __l2 + 1; } else { __len = __l2; } } return __first; }
我们可以发现它接受了vector的begin和end前向迭代器,并且从中计算出迭代器之间的距离(std::distance)来得到一个区间长度。利用区间长度和起始迭代器的移动进行二分,直到长度为0,返回第一个迭代器。于是我们将代码优化成:
class RangeFreqQuery { private: unordered_map<int, vector<int> > dataWithIndex; public: RangeFreqQuery(vector<int>& arr) { for(int i = 0; i < arr.size(); i++) { dataWithIndex[arr[i]].push_back(i); } } // 两次二分查找需要有一边不取等号,即小于和小于等于。 // 设存在两个相等的数,则我们需要在middle都指向两个值时,使得一个二分的左值小于它,一个二分的左值等于它。 // 那么最后一步就需要从判断中修改。如果下界是<=,那么left将大于mid。上界是<,则right将小于mid。 // 所以下界应该为<,right将小于mid。上界为<=,left将大于mid。 template<class U, class V, class W> auto lowerBound(U begin, V end, W len, int value) -> decltype(begin) { while(len != 0) { auto l2 = len / 2; auto first = begin; advance(first, l2); if(*first < value) { begin = ++ first; len -= l2 + 1; } else { len = l2; } } return begin; } template<class U, class V, class W> auto upperBound(U begin, V end, W len, int value) -> decltype(begin) { while(len != 0) { auto l2 = len / 2; auto first = begin; advance(first, l2); if(*first <= value) { begin = ++first; len -= l2 + 1; } else { len = l2; } } return begin; } int query(int left, int right, int value) { if(dataWithIndex[value].size() == 0) return 0; auto begin = dataWithIndex[value].begin(); auto end = dataWithIndex[value].end(); auto length = distance(begin, end); auto leftBound = lowerBound(begin, end, length, left); auto rightBound = upperBound(begin, end, length, right); int res = distance(leftBound, rightBound); return res; } };
目前我还不太清楚为什么这么实现会较经典的left和right更快一些。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 全程不用写代码,我用AI程序员写了一个飞机大战
· MongoDB 8.0这个新功能碉堡了,比商业数据库还牛
· 记一次.NET内存居高不下排查解决与启示
· DeepSeek 开源周回顾「GitHub 热点速览」
· 白话解读 Dapr 1.15:你的「微服务管家」又秀新绝活了