【问题标题】:Is there any efficiency advantage in using minmax_element over min_element and max_element together?与 min_element 和 max_element 一起使用 minmax_element 是否有任何效率优势?
【发布时间】:2016-10-27 11:37:59
【问题描述】:

std::minmax_element :返回一个对,其中包含一个指向最小元素的迭代器作为第一个元素,一个指向最大元素的迭代器作为第二个元素。

std::min_element :返回一个迭代器,指向范围 [first, last) 中的最小元素。

std::max_element :返回一个迭代器,指向范围 [first, last) 中的最大元素。


std::minmax_element 是否使用完整列表的排序 来实现此目的?

处理来自std::minmax_element返回对的开销是否足够?

【问题讨论】:

    标签: c++ max min


    【解决方案1】:

    您不必担心std::minmax_element 进行任何排序。它以它被遍历的确切方式离开范围。它更高效的原因是它可以在一次通过中找到最大值和最小值,而当分别查找最大值和最小值时,您必须进行两次完整遍历。

    std::minmax_element 具有 max(floor(3/2(N−1)), 0) 的复杂性,其中 std::max_elementstd::min_element 分别是 max(N-1,0),因此使用 std::minmax_element 的操作减少了大约 25%

    也有区别,std::minmax_element 找到最后一个最大的元素,而std::max_element 找到第一个最大的元素。

    因此,如果您需要查找范围的最小值和最大值,则应使用std::minmax_element。如果您只需要最小值或最大值,那么您应该使用专用版本。使用即将到来的 C++17 标准和结构化绑定,处理来自 std::minmax_element 的返回将变得更加容易。你会写的

    auto [min, max] = std::minmax_element(...);
    

    现在该对的第一个元素存储在min 中,第二个元素存储在max 中。

    【讨论】:

    • 咳咳:减少了 25% 的操作。大约 1½N 与 2N。
    • @MartinBonner 哇。我把那个搞砸了。谢谢
    • 有一件事让我很困惑。我可以看到在许多情况下它如何需要更少的比较,例如如果当前元素大于max_so_far,那么我们不需要与min_so_far 进行比较。这是因为如果大于max_so_far,它就不能小于min_so_far。因此,保存了一个比较。但我不明白它怎么可能只有1.5N。 (在与min_so_farmax_so_far 进行比较之前,是否先将每个元素与前一个元素进行比较?)
    • @AaronMcDaid 如果您想查看可能的实现,请参阅this
    • @AaronMcDaid:就像锦标赛m, M, a, b -> m < Ma < b,所以minmin(m, a)maxmax(M, b)。所以逐对比较新元素。
    【解决方案2】:

    其他答案都很好。我想补充一点关于minmax_element 必须如何工作的内容,但这也有助于解释为什么它(通常)比单独运行min_elementmax_element 工作得更好,并讨论它的一些具体情况没有表现更好。

    如果我们考虑一个简单的实现,您将维护一个最大值和最小值(以及它们相应的迭代器)并简单地遍历范围,将每个值与最小值和最大值进行比较并调整根据需要。但是,这会给您总共 2N 次比较;虽然它可能比遍历列表两次(由于更好地使用局部性)表现得更好,但规范要求(大约)3/2 N 比较。那怎么可能呢?

    它通过处理对而不是单个项目来工作。取范围内的前两项(#0 和#1),我们可以比较它们并将最大值分配给最大值,将最小值分配给最小值。然后,我们比较接下来的两项(#3 和#4)来决定哪一项更大;我们将较大的一个与最大值进行比较,将较小的一个与最小值进行比较,并根据需要更新最大值/最小值。然后,我们对每一对额外的对(#5 和 #6,然后是 #7 和 #8,依此类推)重复此过程。

    因此,每对都需要进行 3 次比较 - 相互比较,然后是当前最大值的最高值,以及当前最小值的最低值。这将所需的比较次数减少到 3/2 N!

    然而,根据下面的 cmets,应该注意的是,当使用比较便宜的类型(或比较器)时,这种“改进的”算法在现代处理器上往往会产生比原始版本更差的性能 - 特别是,范围超过vector<int> 或类似:每对的两个元素之间的比较具有不可预测的结果,导致处理器中的分支预测失败(尽管这仅在数据或多或少随机排序时才成立);当前的编译器并不总是将分支转换为条件传输,因为它们可能会这样做。此外,编译器更难向量化更复杂的算法。

    理论上,我认为,C++ 库实现可以为 minmax_element 函数提供重载,该函数使用默认比较器的原始(int 等)元素类型的朴素算法。虽然标准要求对比较次数进行限制,但如果无法观察到这些比较的效果,那么实际计算的数字并不重要,只要时间复杂度相同(在这两种情况下都是 O(N)) .但是,虽然这可能会为随机排序的数据提供更好的性能,但在数据排序时可能会产生更差的性能。

    考虑到以上所有因素,一个简单的测试用例(如下)显示了一个有趣的结果:对于随机排序的数据,分别使用min_elementmax_element 实际上可以稍微快一点使用minmax_element但是,对于已排序的数据,minmax_element 比单独使用 min_elementmax_element 快得多。在我的系统(Haswell 处理器)上(使用gcc -O3 -std=c++11 -march=native,GCC 版本 5.4 编译),示例运行分别显示 min/max 为 692 毫秒,minmax 组合为 848 毫秒。当然,运行之间存在一些差异,但这些值似乎很典型。

    注意:

    • 性能差异很小,不太可能成为实际程序中的主导因素;
    • 差异取决于编译器优化;未来,结果很可能会逆转;
    • 对于更复杂的数据类型(或者更确切地说是更复杂的比较器),结果可能会相反,因为在这种情况下,较少的比较可能会带来显着的改进。
    • 当样本数据是有序的而不是随机的(在下面的程序中将 v.push_back(r(gen)) 替换为 v.push_back(i))时,性能非常不同:对于单独的 min/max,大约为 728 毫秒,而对于组合的 minmax,它会下降到 246 毫秒。

    代码:

    #include <iostream>
    #include <vector>
    #include <algorithm>
    #include <random>
    #include <chrono>
    
    constexpr int numEls = 100000000;
    
    void recresult(std::vector<int> *v, int min, int max)
    {
       // Make sure the compiler doesn't optimize out the values: 
       __asm__ volatile (
           ""
           :
           : "rm"(v), "rm"(min), "rm"(max)
       );
    }
    
    int main(int argc, char **argv)
    {
        using namespace std;
    
        std::mt19937 gen(0);
        uniform_int_distribution<> r(0, 100000);
    
    
        vector<int> v;
        for (int i = 0; i < numEls; i++) {
            v.push_back(r(gen));
        }
    
        // run once for warmup
        int min = *min_element(v.begin(), v.end());
        int max = *max_element(v.begin(), v.end());
        recresult(&v, min, max);
    
        // min/max separately:
        {
            auto starttime = std::chrono::high_resolution_clock::now();
            for (int i = 0; i < 5; i++) {
            int min = *min_element(v.begin(), v.end());
                int max = *max_element(v.begin(), v.end());
                recresult(&v, min, max);
            }
            auto endtime = std::chrono::high_resolution_clock::now();
            auto millis = std::chrono::duration_cast<std::chrono::milliseconds>(endtime - starttime).count();
    
            cout << "min/max element: " << millis << " milliseconds." << endl;
        }
    
        // run once for warmup
        auto minmaxi = minmax_element(v.begin(), v.end());
        recresult(&v, *(minmaxi.first), *(minmaxi.second));
    
        // minmax together:
        {
            auto starttime = std::chrono::high_resolution_clock::now();
            for (int i = 0; i < 5; i++) {
            minmaxi = minmax_element(v.begin(), v.end());
            recresult(&v, *(minmaxi.first), *(minmaxi.second));
            }
            auto endtime = std::chrono::high_resolution_clock::now();
            auto millis = std::chrono::duration_cast<std::chrono::milliseconds>(endtime - starttime).count();
    
            cout << "minmax element: " << millis << " milliseconds." << endl;
        }
    
        return 0;
    }
    

    【讨论】:

    • 有趣的事实:在现代处理器上,对于元素按随机顺序排列的向量,由于分支预测,这些 3N/2 比较比进行简单的 2N 比较花费的时间要长得多。
    • @MarcGlisse 这很有趣。虽然我会认为在许多现代处理器上你可以在没有实际分支的情况下实现它(使用例如条件加载指令) - 这些会受到同样的惩罚吗?
    • 预测良好的分支非常便宜。您可以尝试各种技巧,但如果您使用 3n/2 技巧成功地用标量代码击败了幼稚版本,我会感到惊讶。
    • 使用 -fprofile-generate/-fprofile-use 已经将运行时间减少了 30%。这些函数非常不方便优化,因为它们返回的是位置,而不是值。请注意,对于矢量化,2n 版本比 3n/2 版本更容易(这无论如何都没有带来任何好处,因为计算最小值和最大值都是 2 次操作)。
    • @PeterCordes gcc.gnu.org/bugzilla/show_bug.cgi?id=78151 用于*min_element 的缺失向量化。
    【解决方案3】:

    是的。您只对范围进行一次迭代,而不是两次。

    【讨论】:

    • 好吧,你的意思是内部实现维护了两个标志:minimum_till_nowmaximum_till_now,并在全遍历结束时返回?
    • 是的,实际上使用您提到的另外两个来实现这个算法是没有意义的。话虽如此,看看 NathanOliver 的回答 - 不同之处还在于比较的次数。
    【解决方案4】:

    std::minmax_element 复杂度:

    最多 max(floor(3/2(N−1)), 0) 次应用谓词,其中 N = std::distance(first, last)。

    std::min_element 复杂度(与max_element 相同):

    恰好是 max(N-1,0) 次比较,其中 N = std::distance(first, last)。

    忽略maxfloor,我们得到:

    (N-1) * 2 vs 3/2 (N-1)
    

    因此,通过使用minmax_element,您将获得3/4 所需的比较,而使用max_element + min_element 或更好。

    minmax_element 使用&lt; 运算符的传递性,它知道如果它正在更新最小值,它不需要通过一次比较两个元素来比较最大值,即如果a &lt; b 那么我们只需要检查min(a, current_min)max(b, current_max),反之亦然。

    另外值得注意的是:

    这个算法不同于std::make_pair(std::min_element(), std::max_element()),不仅在效率上,还在于这个算法找到最后一个最大的元素而std::max_element找到第一个最大的元素。

    【讨论】:

    • 您对提高效率的解释“如果它正在更新最小值,则不需要比较最大值”是不准确的。如果这样做,它只会稍微提高效率,因为大多数元素不会更新最小值。相反,该函数在所有情况下都提供了 25% 的改进,它使用了不同的策略(有关更多信息,请参见 davmac 的答案)。
    • @MarcvanLeeuwen 够公平,更新答案。
    猜你喜欢
    • 1970-01-01
    • 2017-07-29
    • 2017-03-04
    • 1970-01-01
    • 2011-10-21
    • 2016-05-20
    • 2015-10-12
    • 1970-01-01
    • 2012-10-18
    相关资源
    最近更新 更多