簡體   English   中英

如何通過鍵有效地合並 k 個排序的成對鍵/值向量?

[英]How to efficiently merge k sorted pairwise key/value vectors by keys?

我想通過鍵合並k個排序的成對鍵/值向量。 通常,向量的大小n非常大(例如, n >= 4,000,000,000 )。

考慮以下k = 2的示例:

// Input
keys_1 = [1, 2, 3, 4], values_1 = [11, 12, 13, 14]
keys_2 = [3, 4, 5, 6], values_2 = [23, 24, 25, 26]

// Output
merged_keys = [1, 2, 3, 3, 4, 4, 5, 6], merged_values = [11, 12, 13, 23, 14, 24, 25, 26]

由於__gnu_parallel::multiway_merge是一種高效的k路合並算法,我嘗試利用最先進的 zip 迭代器 ( https://github.com/dpellegr/ZipIterator ) 來“組合”鍵值對向量。

#include <iostream>
#include <vector>
#include <parallel/algorithm>

#include "ZipIterator.hpp"

int main(int argc, char* argv[]) {
  std::vector<int> keys_1   = {1, 2, 3, 4};
  std::vector<int> values_1 = {11, 12, 13, 14};
  std::vector<int> keys_2   = {3, 4, 5, 6};
  std::vector<int> values_2 = {23, 24, 25, 26};

  std::vector<int> merged_keys(8);
  std::vector<int> merged_values(8);

  auto kv_it_1 = Zip(keys_1, values_1);
  auto kv_it_2 = Zip(keys_2, values_2);
  auto mkv_it = Zip(merged_keys, merged_values);

  auto it_pairs = {std::make_pair(kv_it_1.begin(), kv_it_1.end()),
                   std::make_pair(kv_it_2.begin(), kv_it_2.end())};

  __gnu_parallel::multiway_merge(it_pairs.begin(), it_pairs.end(), mkv_it.begin(), 8, std::less<>());
  
  for (size_t i = 0; i < 8; ++i) {
    std::cout << merged_keys[i] << ":" << merged_values[i] << (i == 7 ? "\n" : ", ");
  }

  return 0;
}

但是,我收到各種編譯錯誤(使用-O3構建):

錯誤:無法綁定類型為 std::__iterator_traits<ZipIter<__gnu_cxx::__normal_iterator<int*, std::vector<int, std::allocator >>, __gnu_cxx::__normal_iterator<int*, std 的非常量左值引用::vector<int, std::allocator >> >, void>::value_type&' {aka 'std::tuple<int, int>&'} 到 std::tuple<int, int> 類型的右值'

錯誤:無法轉換 'ZipIter<__gnu_cxx::__normal_iterator<int*, std::vector<int, std::allocator >>, __gnu_cxx::__normal_iterator<int*, std::vector<int, std::allocator >> >::reference*' {aka 'ZipRef<int, int> '} 到 '_ValueType ' {aka 'std::tuple<int, int>*'}

是否可以修改ZipIterator使其工作?

或者是否有一種更有效的方法來通過鍵合並k個排序的成對鍵/值向量?

考慮替代方案

  1. 使用int keyint value成員以及operator<operator<=運算符定義KeyValuePair struct 將鍵/值向量的元素移動到std::vector<KeyValuePair>中。 std::vector<KeyValuePair>上調用__gnu_parallel::multiway_merge 將合並的元素移回鍵/值向量中。 [判決:執行緩慢,memory 高開銷,即使使用-O3 ]
  2. 使用std::merge(std::execution::par_unseq, kv_it_1.begin(), kv_it_1.end(), kv_it_2.begin(), kv_it_2.end(), mkv_it.begin()); 而不是__gnu_parallel::multiway_merge [結論:僅支持兩個鍵/值向量]

是否可以修改 ZipIterator 使其工作?

是的,但它需要修補__gnu_parallel::multiway_merge 錯誤的來源是這一行

      /** @brief Dereference operator.
      *  @return Referenced element. */
      typename std::iterator_traits<_RAIter>::value_type&
      operator*() const
      { return *_M_current; }

這是 _GuardedIterator 的成員_GuardedIterator - multiway_merge實現中使用的輔助結構。 它包裝_RAIter class ,在您的情況下是ZipIter 根據定義,當迭代器被取消引用時 ( *_M_current ),返回表達式的類型應該是reference類型。 但是,此代碼期望它是value_type& 在大多數情況下,這些是相同的類型。 事實上,當您取消引用一個項目時,您希望獲得對這個項目的引用。 但是,使用 zip 迭代器是不可能的,因為它的元素是虛擬的,它們是動態創建的。 這就是為什么ZipIterreference類型根本不是引用類型,它實際上是一個名為ZipRef的值類型

  using reference = ZipRef<std::remove_reference_t<typename std::iterator_traits<IT>::reference>...>;

與(非常討厭的) vector<bool>一起使用的做法有點相同。

因此, ZipIterator或您如何使用該算法沒有問題,這是對算法本身的重要要求。 下一個問題是,我們能擺脫它嗎?

答案是肯定的。 您可以更改_GuardedIterator::operator*()以返回reference而不是value_type& 然后你會在這一行有一個編譯錯誤:

      // Default value for potentially non-default-constructible types.
      _ValueType* __arbitrary_element = 0;

      for (_SeqNumber __t = 0; __t < __k; ++__t)
        {
          if(!__arbitrary_element
             && _GLIBCXX_PARALLEL_LENGTH(__seqs_begin[__t]) > 0)
            __arbitrary_element = &(*__seqs_begin[__t].first);
        }

這里元素的地址被用於一些__arbitrary_element 我們可以存儲此元素的副本,因為我們知道ZipRef的復制成本很低,而且它是默認構造的:

      // Local copy of the element
      _ValueType __arbitrary_element_val;
      _ValueType* __arbitrary_element = 0;

      for (_SeqNumber __t = 0; __t < __k; ++__t)
        {
          if(!__arbitrary_element
             && _GLIBCXX_PARALLEL_LENGTH(__seqs_begin[__t]) > 0) {
            __arbitrary_element_val = *__seqs_begin[__t].first;
            __arbitrary_element = &__arbitrary_element_val;
          }
        }

之后,您可以將__gnu_parallel::multiway_mergeZipIterator一起使用。

由於您需要較低的 memory 開銷,一種可能的解決方案是讓multiway_merge算法僅對唯一范圍標識符和范圍索引進行操作,並將比較和復制運算符作為 lambda 函數提供。 這樣,合並算法就完全獨立於實際使用的容器類型和鍵值類型。

這是一個 C++17 解決方案,它基於此處描述的基於堆的算法:

#include <cstdint>
#include <functional>
#include <initializer_list>
#include <iostream>
#include <iterator>
#include <queue>
#include <vector>

using range_type = std::pair<std::uint32_t,std::size_t>;

void multiway_merge(
    std::initializer_list<std::size_t> range_sizes,
    std::function<bool(const range_type&, const range_type&)> compare_func,
    std::function<void(const range_type&)> copy_func)
{
    // lambda compare function for priority queue of ranges
    auto queue_less = [&](const range_type& range1, const range_type& range2) {
        // reverse comparison order of range1 and range2 here,
        // because we require the smallest element to be on top
        return compare_func(range2, range1);
    };
    // create priority queue from all non-empty ranges
    std::priority_queue<
        range_type, std::vector<range_type>, 
        decltype(queue_less)> queue{ queue_less };
    for (std::uint32_t range_id = 0; range_id < range_sizes.size(); ++range_id) {
        if (std::data(range_sizes)[range_id] > 0) {
            queue.emplace(range_id, 0);
        }
    }
    // merge ranges until priority queue is empty
    while (!queue.empty()) {
        range_type top_range = queue.top();
        queue.pop();
        copy_func(top_range);
        if (++top_range.second != std::data(range_sizes)[top_range.first]) {
            // re-insert non-empty range
            queue.push(top_range);
        }
    }
}


int main() {
    std::vector<int> keys_1   = { 1, 2, 3, 4 };
    std::vector<int> values_1 = { 11, 12, 13, 14 };
    std::vector<int> keys_2   = { 3, 4, 5, 6, 7 };
    std::vector<int> values_2 = { 23, 24, 25, 26, 27 };

    std::vector<int> merged_keys;
    std::vector<int> merged_values;

    multiway_merge(
        { keys_1.size(), keys_2.size() },
        [&](const range_type& left, const range_type& right) {
            if (left == right) return false;
            switch (left.first) {
                case 0:
                    return keys_1[left.second] < keys_2[right.second];
                case 1:
                    return keys_2[right.second] < keys_1[left.second];
            }
            return false;
        },
        [&](const range_type& range) {
            switch (range.first) {
                case 0:
                    merged_keys.push_back(keys_1[range.second]);
                    merged_values.push_back(values_1[range.second]);
                    break;
                case 1:
                    merged_keys.push_back(keys_2[range.second]);
                    merged_values.push_back(values_2[range.second]);
                    break;
            }
        });
    // copy result to stdout
    std::cout << "keys: ";
    std::copy(
        merged_keys.cbegin(), merged_keys.cend(), 
        std::ostream_iterator<int>(std::cout, " "));
    std::cout << "\nvalues: ";
    std::copy(
        merged_values.cbegin(), merged_values.cend(), 
        std::ostream_iterator<int>(std::cout, " "));
    std::cout << "\n";
}

該算法的時間復雜度為O(n log(k)) ,空間復雜度為O(k) ,其中n是所有范圍的總大小, k是范圍的數量。

所有輸入范圍的大小都需要作為初始化列表傳遞。 該示例僅傳遞示例中的兩個輸入范圍。 將示例擴展到兩個以上的范圍很簡單。

因為我在路上,所以沒有時間測試這個。 它只是一段簡潔的代碼來說明一種可能的解決方案:

class Solution{
    
    std::unordered_map<int,std::set<int>> keyValues;
    std::set<int> outputKeys;
    int outputVectorSize = 0;
    
public:
    
    void addKeyValueSet(const std::vector<int>& keys,const std::vector<int>& values){
        for(int i=0;i<keys.size();i++){
            const int& key = keys.at(i);
            outputKeys.insert(keys.at(i));
            if(keyValues.find(key)==keyValues.end()){
                std::set<int> newSet;
                keyValues[key] = newSet;
            }
            auto& newSet = keyValues[key];
            newSet.insert(values.at(i));
        }
    }
};

看起來您想對兩件事進行排序:

  • 按鍵
  • 如果有多個具有相同鍵的鍵值對,也對值進行排序。

這意味着,如果有多個具有相同鍵的鍵值對,則需要對鍵和值進行排序。

有序集將在您插入值時對其進行排序。 添加所有值后,您可以遍歷鍵並按排序順序提取該鍵的相應值集。

Memory 明智的做法是,您將所有值存儲一次,將鍵存儲兩次。 性能將取決於有序集中的插入。

你將不得不實施一個適合你所擁有的確切情況的人,並且如果你有能力分配 arrays 的完整副本或接近完整副本,那么具有如此大的 arrays 多重威脅可能不是那么好,你可以做的一個優化是使用大頁面並確保您正在訪問的 memory 未被分頁(如果您計划滿負荷運行,則不進行交換是不理想的)。

這個簡單的低 memory 示例工作得很好,很難擊敗順序i/o ,它的主要瓶頸是realloc的使用,當將使用的值從arrs轉移到ret時,每個step_size進行多次reallocs但是只有一個很昂貴, ret.reserve()會消耗“大量”時間,因為縮短緩沖區始終可用,但擴展緩沖區可能不可用,操作系統可能需要進行多次 memory 移動。

#include <vector>
#include <chrono>
#include <stdio.h>

template<typename Pair, typename bool REVERSED = true>
std::vector<Pair> multi_merge_lm(std::vector<std::vector<Pair>>& arrs, float step){
    size_t final_size = 0, max, i;
    for (i = 0; i < arrs.size(); i++){
        final_size += arrs[i].size();
    }

    float original = (float)final_size;
    size_t step_size = (size_t)((float)(final_size) * step);

    printf("Merge of %zi (%zi bytes) with %zi step size \n", 
        final_size, sizeof(Pair), step_size
    );
    printf("Merge operation size %.*f mb + %.*f mb \n",
        3, ((float)(sizeof(Pair) * (float)final_size) / 1000000),
        3, ((float)(sizeof(Pair) * (float)final_size * step) / 1000000)
    );

    std::vector<Pair> ret;
    while (final_size --> 0){

        for (max = 0, i = 0; i < arrs.size(); i++){
            // select the next biggest item from all the arrays
            if (arrs[i].back().first > arrs[max].back().first){
                max = i;
            }
        }

        // This does not actualy resize the vector 
        // unless the capacity is too small
        ret.push_back(arrs[max].back());
        arrs[max].pop_back();

        // This check could be extracted of the while
        // with a unroll and sort to little
        for (i = 0; i < arrs.size(); i++){
            if (arrs[i].empty()){
                arrs[i] = arrs.back();
                arrs.pop_back();
                break;
            }
        }

        if (ret.size() == ret.capacity()) {
            // Remove the used memory from the arrs and
            // realloc more to the ret
            for (std::vector<Pair>& chunk : arrs){
                chunk.shrink_to_fit();
            }
            ret.reserve(ret.size() + step_size);

            // Dont move this to the while loop, it will slow down
            // the execution, leave it just for debugging
            printf("\rProgress %i%c / Merge size %zi", 
                (int)((1 - ((float)final_size / original) ) * 100), 
                '%', ret.size()
            );
        }
    }

    printf("\r%*c\r", 40, ' ');
    ret.shrink_to_fit();
    arrs.clear();

    if (REVERSED){
        std::reverse(ret.begin(), ret.end());
    }

    return ret;
}

int main(void) {

    typedef std::pair<uint64_t, uint64_t> Pair;

    int inc = 1;
    int increment = 100000;
    int test_size = 40000000;
    float step_size = 0.05f;

    auto arrs = std::vector<std::vector<Pair>>(5);
    for (auto& chunk : arrs){

        // makes the arrays big and asymmetric and adds 
        // some data to check if it works
        chunk.resize(test_size + increment * inc++);
        for (int i = 0; i < chunk.size(); i++){
            chunk[i] = std::make_pair(i, i * -1);
        }

    }
    printf("Generation done \n");

    auto start = std::chrono::steady_clock::now();
    auto merged = multi_merge_lm<Pair>(arrs, step_size);
    auto end = std::chrono::steady_clock::now();

    printf("Time taken: %lfs \n", 
        (std::chrono::duration<double>(end - start)).count()
    );
    for (size_t i = 1; i < merged.size(); i++){
        if (merged[i - 1] > merged[i]){
            printf("Miss placed at index: %zi \n", i - 1);
        }
    }

    merged.clear();
    return 0;
}
Merge of 201500000 (16 bytes) with 10075000 step size
Merge operation size 3224.000 mb + 161.200 mb
Time taken: 166.197639s

通過探查器(在我的例子中是ANDuProf )運行它表明調整大小非常昂貴,您使step_size越大,它變得越有效。

代碼的 ANDuProf 運行 (名稱是重復的,因為它們來自調用相同函數的代碼的不同部分,在本例中,是標准函數進行的調用)

這次重新運行是 0.5 倍,它快了約 2 倍,但現在 function 消耗的 memory 比以前多 10 倍,你應該記住這個值不是通用的,它們可能會根據你運行的硬件而改變,但比例不會會改變那么多。

Merge of 201500000 (16 bytes) with 100750000 step size
Merge operation size 3224.000 mb + 1612.000 mb
Time taken: 72.062857s

你不應該忘記的另外兩件事是std::vector動態的,它的實際大小可能更大, O2不能真正對堆 memory 訪問做很多優化,如果你不能讓它安全,那么指令只能等待。

我幾乎不記得這個,但你可能會發現它有幫助 - 我很確定我已經看到合並 K 排序鏈表問題。 它使用類似於分而治之的東西,並且接近對數時間復雜度。 我懷疑是否有可能獲得更好的時間復雜度。

這背后的邏輯是盡量減少對合並列表的迭代。 如果您合並第一個和第二個列表,那么將它與第三個列表合並需要遍歷更長的合並列表。 此方法通過首先合並所有小列表,然后通過合並 1 次合並列表移動到(我喜歡稱之為)“第二層合並”來避免這種情況。

這樣,如果列表的平均長度為 n,則最多需要執行 logn 個迭代器,從而導致 K*log(n) 復雜度,其中 K 是列表的數量。

抱歉有點“不太精確”,但我認為您可能會發現這條信息很有幫助。 雖然,我不熟悉 gnu 的multiway_merge ,所以無論我說什么也可能毫無用處。

暫無
暫無

聲明:本站的技術帖子網頁,遵循CC BY-SA 4.0協議,如果您需要轉載,請注明本站網址或者原文地址。任何問題請咨詢:yoyou2525@163.com.

 
粵ICP備18138465號  © 2020-2024 STACKOOM.COM