现有答案调查
你问是否有“更有效的方法”。但是您所说的高效是什么意思,您的要求是什么?
Potatoswatter 的 answer 可以在 O(N²) 时间内使用 O(1) 额外空间,并且不会改变重新排序向量.
chmike 和 rcgldr 给出的答案使用 O(N) 时间和 O(1) 额外空间,但他们通过改变重新排序向量来实现这一点.
您的原始答案分配新空间,然后将数据复制到其中,而Tim MB 建议使用移动语义。然而,移动仍然需要一个地方来移动东西,并且像std::string 这样的对象既有长度变量也有指针。换句话说,基于移动的解决方案需要为任何对象分配O(N),为新向量本身分配O(1)。我在下面解释为什么这很重要。
保留重新排序向量
我们可能想要那个重新排序的向量!排序成本 O(N log N)。但是,如果您知道您将以相同的方式对多个向量进行排序,例如在 Structure of Arrays (SoA) 上下文中,您可以排序一次然后重复使用结果。这样可以节省很多时间。
您可能还想对数据进行排序然后取消排序。拥有重新排序向量允许您执行此操作。这里的一个用例是在 GPU 上执行基因组测序,其中通过批量处理相似长度的序列来获得最大的速度效率。我们不能依赖用户按此顺序提供序列,因此我们先排序然后再取消排序。
那么,如果我们想要所有世界中最好的怎么办:O(N) 处理没有额外分配的成本,但也没有改变我们的排序向量(我们毕竟,可能想要重用)?要找到那个世界,我们需要问:
为什么多余的空间不好?
您可能不想分配额外空间的原因有两个。
首先是你没有太多的工作空间。这可能在两种情况下发生:您使用的是内存有限的嵌入式设备。通常这意味着您正在处理小型数据集,因此 O(N²) 解决方案在这里可能很好。但是,当您使用 非常 大型数据集时,也会发生这种情况。在这种情况下,O(N²) 是不可接受的,您必须使用一种 O(N) 变异解决方案。
额外空间不好的另一个原因是分配很昂贵。对于较小的数据集,它可能比实际计算成本更高。因此,实现效率的一种方法是消除分配。
大纲
当我们改变排序向量时,我们这样做是为了表明元素是否在它们的置换位置。我们可以使用位向量来指示相同的信息,而不是这样做。但是,如果我们每次都分配位向量,那将是昂贵的。
相反,我们可以通过将位向量每次重置为零来清除它。但是,这会导致每次函数使用的额外 O(N) 成本。
相反,我们可以将“版本”值存储在向量中,并在每次使用函数时递增。这给了我们O(1) 访问权限,O(1) 清晰,以及摊销的分配成本。这类似于persistent data structure。不利的一面是,如果我们过于频繁地使用排序函数,则需要重置版本计数器,尽管这样做的 O(N) 成本已摊销。
这就提出了一个问题:版本向量的最佳数据类型是什么?位向量最大化缓存利用率,但每次使用后都需要完全 O(N) 重置。 64 位数据类型可能永远不需要重置,但缓存利用率很低。实验是解决这个问题的最好方法。
两种排列方式
我们可以将排序向量视为具有两种意义:向前和向后。在前向意义上,向量告诉我们元素的去向。在向后的意义上,向量告诉我们元素来自哪里。由于排序向量隐含地是一个链表,向后的意义需要O(N) 额外的空间,但是,同样,我们可以摊销分配成本。依次应用这两种感官会让我们回到原来的顺序。
性能
在我的“Intel(R) Xeon(R) E-2176M CPU @ 2.70GHz”上运行单线程,对于长度为 32,767 个元素的序列,以下代码每次重新排序大约需要 0.81 毫秒。
代码
带有测试的两种感官的完整注释代码:
#include <algorithm>
#include <cassert>
#include <random>
#include <stack>
#include <stdexcept>
#include <vector>
///@brief Reorder a vector by moving its elements to indices indicted by another
/// vector. Takes O(N) time and O(N) space. Allocations are amoritzed.
///
///@param[in,out] values Vector to be reordered
///@param[in] ordering A permutation of the vector
///@param[in,out] visited A black-box vector to be reused between calls and
/// shared with with `backward_reorder()`
template<class ValueType, class OrderingType, class ProgressType>
void forward_reorder(
std::vector<ValueType> &values,
const std::vector<OrderingType> &ordering,
std::vector<ProgressType> &visited
){
if(ordering.size()!=values.size()){
throw std::runtime_error("ordering and values must be the same size!");
}
//Size the visited vector appropriately. Since vectors don't shrink, this will
//shortly become large enough to handle most of the inputs. The vector is 1
//larger than necessary because the first element is special.
if(visited.empty() || visited.size()-1<values.size());
visited.resize(values.size()+1);
//If the visitation indicator becomes too large, we reset everything. This is
//O(N) expensive, but unlikely to occur in most use cases if an appropriate
//data type is chosen for the visited vector. For instance, an unsigned 32-bit
//integer provides ~4B uses before it needs to be reset. We subtract one below
//to avoid having to think too much about off-by-one errors. Note that
//choosing the biggest data type possible is not necessarily a good idea!
//Smaller data types will have better cache utilization.
if(visited.at(0)==std::numeric_limits<ProgressType>::max()-1)
std::fill(visited.begin(), visited.end(), 0);
//We increment the stored visited indicator and make a note of the result. Any
//value in the visited vector less than `visited_indicator` has not been
//visited.
const auto visited_indicator = ++visited.at(0);
//For doing an early exit if we get everything in place
auto remaining = values.size();
//For all elements that need to be placed
for(size_t s=0;s<ordering.size() && remaining>0;s++){
assert(visited[s+1]<=visited_indicator);
//Ignore already-visited elements
if(visited[s+1]==visited_indicator)
continue;
//Don't rearrange if we don't have to
if(s==visited[s])
continue;
//Follow this cycle, putting elements in their places until we get back
//around. Use move semantics for speed.
auto temp = std::move(values[s]);
auto i = s;
for(;s!=(size_t)ordering[i];i=ordering[i],--remaining){
std::swap(temp, values[ordering[i]]);
visited[i+1] = visited_indicator;
}
std::swap(temp, values[s]);
visited[i+1] = visited_indicator;
}
}
///@brief Reorder a vector by moving its elements to indices indicted by another
/// vector. Takes O(2N) time and O(2N) space. Allocations are amoritzed.
///
///@param[in,out] values Vector to be reordered
///@param[in] ordering A permutation of the vector
///@param[in,out] visited A black-box vector to be reused between calls and
/// shared with with `forward_reorder()`
template<class ValueType, class OrderingType, class ProgressType>
void backward_reorder(
std::vector<ValueType> &values,
const std::vector<OrderingType> &ordering,
std::vector<ProgressType> &visited
){
//The orderings form a linked list. We need O(N) memory to reverse a linked
//list. We use `thread_local` so that the function is reentrant.
thread_local std::stack<OrderingType> stack;
if(ordering.size()!=values.size()){
throw std::runtime_error("ordering and values must be the same size!");
}
//Size the visited vector appropriately. Since vectors don't shrink, this will
//shortly become large enough to handle most of the inputs. The vector is 1
//larger than necessary because the first element is special.
if(visited.empty() || visited.size()-1<values.size());
visited.resize(values.size()+1);
//If the visitation indicator becomes too large, we reset everything. This is
//O(N) expensive, but unlikely to occur in most use cases if an appropriate
//data type is chosen for the visited vector. For instance, an unsigned 32-bit
//integer provides ~4B uses before it needs to be reset. We subtract one below
//to avoid having to think too much about off-by-one errors. Note that
//choosing the biggest data type possible is not necessarily a good idea!
//Smaller data types will have better cache utilization.
if(visited.at(0)==std::numeric_limits<ProgressType>::max()-1)
std::fill(visited.begin(), visited.end(), 0);
//We increment the stored visited indicator and make a note of the result. Any
//value in the visited vector less than `visited_indicator` has not been
//visited.
const auto visited_indicator = ++visited.at(0);
//For doing an early exit if we get everything in place
auto remaining = values.size();
//For all elements that need to be placed
for(size_t s=0;s<ordering.size() && remaining>0;s++){
assert(visited[s+1]<=visited_indicator);
//Ignore already-visited elements
if(visited[s+1]==visited_indicator)
continue;
//Don't rearrange if we don't have to
if(s==visited[s])
continue;
//The orderings form a linked list. We need to follow that list to its end
//in order to reverse it.
stack.emplace(s);
for(auto i=s;s!=(size_t)ordering[i];i=ordering[i]){
stack.emplace(ordering[i]);
}
//Now we follow the linked list in reverse to its beginning, putting
//elements in their places. Use move semantics for speed.
auto temp = std::move(values[s]);
while(!stack.empty()){
std::swap(temp, values[stack.top()]);
visited[stack.top()+1] = visited_indicator;
stack.pop();
--remaining;
}
visited[s+1] = visited_indicator;
}
}
int main(){
std::mt19937 gen;
std::uniform_int_distribution<short> value_dist(0,std::numeric_limits<short>::max());
std::uniform_int_distribution<short> len_dist (0,std::numeric_limits<short>::max());
std::vector<short> data;
std::vector<short> ordering;
std::vector<short> original;
std::vector<size_t> progress;
for(int i=0;i<1000;i++){
const int len = len_dist(gen);
data.clear();
ordering.clear();
for(int i=0;i<len;i++){
data.push_back(value_dist(gen));
ordering.push_back(i);
}
original = data;
std::shuffle(ordering.begin(), ordering.end(), gen);
forward_reorder(data, ordering, progress);
assert(original!=data);
backward_reorder(data, ordering, progress);
assert(original==data);
}
}