[英]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
個排序的成對鍵/值向量?
考慮替代方案
int key
和int value
成員以及operator<
和operator<=
運算符定義KeyValuePair
struct
。 將鍵/值向量的元素移動到std::vector<KeyValuePair>
中。 在std::vector<KeyValuePair>
上調用__gnu_parallel::multiway_merge
。 將合並的元素移回鍵/值向量中。 [判決:執行緩慢,memory 高開銷,即使使用-O3
]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 迭代器是不可能的,因為它的元素是虛擬的,它們是動態創建的。 這就是為什么ZipIter
的reference
類型根本不是引用類型,它實際上是一個名為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_merge
與ZipIterator
一起使用。
由於您需要較低的 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
越大,它變得越有效。
(名稱是重復的,因為它們來自調用相同函數的代碼的不同部分,在本例中,是標准函數進行的調用)
這次重新運行是 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.