记一次心血来潮的优化

无题

为了备考,最近一直在刷leetcode。在做每日一题2080.区间内查询数字的频率的时候,使用哈希表和二分查找。于是慢悠悠地写完之后,交上去一发,发现:

img

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)。因此在二分查找中反复使用哈希表进行索引会导致实际上的二分查找的时间复杂度变成 \(O(k\log{n})\),很大程度上拖垮了我们的时间复杂度。
于是我们尝试先构造一个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引用即可。

img

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_boundupper_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,返回第一个迭代器。于是我们将代码优化成:

img

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更快一些。

std::lower_bound 和 std::set::lower_bound

2025.03.20 update
同学遇到了一个问题:在相同的代码逻辑下,为什么std::lower_bound(a.begin(), a.end(), val) 要显著慢于 a.lower_bound(val) 呢?
答案在于:set是一个RBTree(红黑树),一个弱平衡二叉树。采用其自带的lower_bound时,只需要进行左右节点的访问(迭代器)。但是set是一个双向而非随机迭代器,只能通过一步步自增来达到迭代器中点(而不能一步执行到达)。因此显著慢于树上进行的二分查找。

C++ Difference between std::lower_bound and std::set::lower_bound?

双向迭代器
随机迭代器

posted @ 2025-02-18 10:45  木木ちゃん  阅读(47)  评论(0)    收藏  举报