【问题标题】:How to zero out array in O(1)?如何在 O(1) 中将数组归零?
【发布时间】:2012-05-29 10:25:22
【问题描述】:

有没有办法将时间复杂度为 O(1) 的数组归零?很明显,这可以通过for-loop,memset来完成。但它们的时间复杂度不是 O(1)。

【问题讨论】:

  • 是什么让您想到可以在 O(1) 时间内完成?我认为在某些特殊情况下,特定于平台的 hack 允许这样做,但我想听听您的推理。
  • 只是为了确保:您了解 O(1) 和“单个代码行”之间的区别吗?
  • @Asaf 我猜他在排除 memset 时会这样做。
  • @ChristianRau - 当我读到 memset 不在其中的问题时......
  • 一个电磁铁可能会在恒定时间内真正清除所有内存。

标签: c++ time-complexity


【解决方案1】:

是的

但是没有任何数组。它需要一个专门为此工作的数组。

template <typename T, size_t N>
class Array {
public:
    Array(): generation(0) {}

    void clear() {
        // FIXME: deal with overflow
        ++generation;
    }

    T get(std::size_t i) const {
        if (i >= N) { throw std::runtime_error("out of range"); }

        TimedT const& t = data[i];
        return t.second == generation ? t.first : T{};
    }

    void set(std::size_t i, T t) {
        if (i >= N) { throw std::runtime_error("out of range"); }

        data[i] = std::make_pair(t, generation);
    }


private:
    typedef std::pair<T, unsigned> TimedT;

    TimedT data[N];
    unsigned generation;
};

原理很简单:

  • 我们使用generation 属性定义一个纪元
  • 当一个项目被设置时,它被设置的纪元被记录
  • 只能看到当前纪元的项目
  • 因此清除等同于增加纪元计数器

该方法有两个问题:

  • 存储增加:对于每个项目,我们存储一个 epoch
  • 代计数器溢出:存在最大时期数

可以使用真正的大整数来阻止后者(uint64_t 以更多存储为代价)。

前者是自然的结果,一种可能的解决方案是使用存储桶来淡化问题,例如将多达 64 个项目与单个计数器关联,并使用位掩码标识哪些在该计数器内有效。


编辑:只是想回到桶的想法。

原始解决方案的每个元素的开销为 8 字节(64 位)(如果已经 8 字节对齐)。根据存储的元素,它可能是也可能不是什么大问题。

如果是大问题,想法是使用桶;当然,就像所有权衡一样,它会进一步减慢访问速度。

template <typename T>
class BucketArray {
public:
     BucketArray(): generation(0), mask(0) {}
     
     T get(std::size_t index, std::size_t gen) const {
         assert(index < 64);

         return gen == generation and (mask & (1 << index)) ?
                data[index] : T{};
     }

     void set(std::size_t index, T t, std::size_t gen) {
         assert(index < 64);

         if (generation < gen) { mask = 0; generation = gen; }

         mask |= (1 << index);
         data[index] = t;
     }

private:
     std::uint64_t generation;
     std::uint64_t mask;
     T data[64];
};

请注意,这个包含固定数量元素的小数组(我们实际上可以对其进行模板化并静态检查它是否低于或等于 64)只有 16 个字节的开销。这意味着我们有每个元素 2 位的开销

template <typename T, size_t N>
class Array {
    typedef BucketArray<T> Bucket;
public:
    Array(): generation(0) {}
    
    void clear() { ++generation; }

    T get(std::size_t i) const {
        if (i >= N) { throw ... }

        Bucket const& bucket = data[i / 64];
        return bucket.get(i % 64, generation);
    }

    void set(std::size_t i, T t) {
        if (i >= N) { throw ... }

        Bucket& bucket = data[i / 64];
        bucket.set(i % 64, t, generation);
    }

private:
    std::uint64_t generation;
    Bucket data[N / 64 + 1];
};

我们将空间开销降低了... 32 倍。现在,该数组甚至可以用于存储char,而在此之前,它可能会令人望而却步。代价是访问变慢了,因为我们得到了一个除法模(什么时候我们会得到一个标准化的操作,一次返回两个结果?)。

【讨论】:

  • 非常聪明!您可以通过将其转移到访问数组来将清除数组的成本降低到 O(1)。如果数组每次被清除的次数少于 N 次,那么您的解决方案比支付 N 次清除它要快。我印象深刻。
  • 哦,是的,这比我的要好。
  • 除了使用足够大的类型之外,我认为您可以在技术上解决环绕问题,因为数组中的元素不能超过可能的代数 (SIZE_MAX)。所以每次你增加世代,只设置一个元素的世代为new_generation-1,绕一圈。对于每个元素,可以保证当一代回绕时,它的一代已经向前移动到更近的一代。或者如你所说,实际上没有计算机可以执行2^64 操作。
  • @Sparky:即使数组被更频繁地访问,因此总体成本更高,这种技术也可能意味着clear 满足了一些如果必须清除它就无法满足的实时截止日期实际内存。你会牺牲整体性能以获得任何单个操作的更好的最坏情况,这基本上是实时程序员的生命:-)
  • @SteveJessop:我不明白SIZE_MAX 防止回绕的技巧。假设在两次调用 clear 之间我总是设置 3 个元素,你如何处理它?我认为我们可以向后做。即增加旧元素的生成(不超过当前限制);如果我们只使用可用范围的一半,那么我们可以毫无问题地环绕它。
【解决方案2】:

您不能在小于O(n) 的时间内修改内存中的n 位置(即使您的硬件,对于足够小的n,可能允许对某些对齐良好的内存块进行恒定时间操作,就像闪存一样)。

但是,如果练习的对象有点横向思考,那么您可以编写一个表示“稀疏”数组的类。稀疏数组的一般概念是您保留一个集合(可能是一个map,尽管取决于使用情况,它可能不是全部),并且当您查找索引时,如果它不在基础集合中然后你返回0

如果您可以在 O(1) 中清除底层集合,那么您可以在 O(1) 中清零稀疏数组。清除std::map 通常不是地图大小的恒定时间,因为所有这些节点都需要被释放。但是您可以设计一个可以在O(1) 中清除的集合,方法是将整个树从“我的地图的内容”移动到“我为将来使用而保留的节点树”。缺点只是这个“保留”空间仍然被分配,有点像vector 变小时发生的情况。

【讨论】:

  • 好主意,我也有类似的想法。
  • 好主意,把根放在一边确实是 O(1)。对于稀疏数组,它当然看起来是最好的选择。它还允许我的解决方案没有提出的简单迭代(每个更改都必须在我的跟踪中......)
【解决方案3】:

只要您接受一个非常大的常数因子,当然可以在 O(1) 中将数组归零:

void zero_out_array_in_constant_time(void* a, size_t n)
{
    char* p = (char*) a;
    for (size_t i = 0; i < std::numeric_limits<size_t>::max(); ++i)
    {
        p[i % n] = 0;
    }
}

无论数组的大小如何,这将始终采用相同数量的步骤,因此它是 O(1)。

【讨论】:

  • 理论上是正确的,但总体性能较差。好答案
  • @FredOverflow:鉴于任何实际的计算机都是有限的,您可以扩展您的论点来论证任何合理的计算都可以在 O(1) 中执行。然而,这可能没有抓住重点……
  • 我希望很明显我的回答并不是很严肃:)
【解决方案4】:

没有。

您不能在少于 O(N) 的时间内访问 N 元素集合的每个成员。

正如 Mike Kwan 所观察到的,您可能会将成本从运行时转移到编译时,但这不会改变操作的计算复杂性。

【讨论】:

    【解决方案5】:

    显然不可能在固定的时间长度内初始化任意大小的数组。但是,完全有可能创建一个类似数组的 ADT,它可以在整个使用过程中分摊初始化数组的成本。然而,通常的结构需要 3 倍以上的存储空间。对白衣:

    template <typename T, size_t arr_size>
    class NoInitArray
    {
        std::vector<T> storage;
    
        // Note that 'lookup' and 'check' are not initialized, and may contain
        // arbitrary garbage.
        size_t lookup[arr_size];
        size_t check[arr_size];
    public:
        T& operator[](size_t pos)
        {
            // find out where we actually stored the entry for (*this)[pos].
            // This could be garbage.
            size_t storage_loc=lookup[pos];
    
            // Check to see that the storage_loc we found is valid
            if (storage_loc < storage.size() && check[storage_loc] == pos)
            {
                // everything checks, return the reference.
                return storage[storage_loc];
            }
            else
            {
                // storage hasn't yet been allocated/initialized for (*this)[pos].
                // allocate storage:
                storage_loc=storage.size();
                storage.push_back(T());
    
                // put entries in lookup and check so we can find 
                // the proper spot later:
                lookup[pos]=storage_loc;
                check[storage_loc]=pos;
    
                // everything's set up, return appropriate reference:
                return storage.back();
            }
        }
    };
    

    如果T 是某种不需要销毁的类型(至少在概念上),则可以很容易地添加一个clear() 成员来清空此类数组的内容。

    【讨论】:

      【解决方案6】:

      在运行时不可能将O(1) 中的数组归零。这是直观的,因为没有允许在固定时间内对任意大小的内存块进行值设置的语言机制。您可以做的最接近的是:

      int blah[100] = {0};
      

      这将允许在编译时进行初始化。在运行时,memset 通常是最快的,但会是O(N)。但是,problems 与在特定数组类型上使用 memset 相关联。

      【讨论】:

      • 在整数数组是自动的情况下,这个初始化如何在O(1)中完成?
      • 即使它不是自动的,它所在的数据部分仍然必须在运行时以某种方式复制到内存中。您可以说“在编译时”初始化数组而在运行时仍然没有 O(n) 成本的唯一方法是,如果 (a) 它是 const,并且 (b) 动态链接器通过内存映射相关部分来加载 const 对象O(1) 操作中的可执行文件。
      • 内存映射仍然涉及在某些时候从磁盘复制到实际内存中——尽管不一定在您映射文件时。
      • @edA-qa mort-ora-y:确实。根据使用模式,您可以推迟/摊销在访问其所有元素的整个过程中加载磁盘数据的成本,这已经是O(n)。或者,如果从未访问过足够多的元素,您可能会完全避免成本。无论哪种方式,初始化都是O(1)。如果这足够重要,那么根据“足够”的定义,以后再付费是可以接受的;-)
      • 哦,我忘了考虑另一种情况 - 如果在编译时您正在为某些嵌入式设备构建图像,那么您可以在“真实”操作系统上获得与内存映射相同的效果,没有实际映射任何东西。实际上,初始化数组的成本是在将图像写入设备时支付的。
      【解决方案7】:

      我喜欢 Eli Bendersky 的网页 http://eli.thegreenplace.net/2008/08/23/initializing-an-array-in-constant-time,他的解决方案归功于 Aho、Hopcroft 和 Ullman 的著名著作计算机算法的设计和分析。这确实是初始化的 O(1) 时间复杂度,而不是 O(N)。空间需求是 O(N) 额外的存储空间,但分配这个空间也是 O(1),因为空间充满了垃圾。出于理论上的原因,我喜欢这个,但我认为如果您需要重复初始化一个非常大的数组,并且每次只访问数组中相对较少数量的位置,它对于实现一些算法也可能具有实用价值。 Bendersky 提供了该算法的 C++ 实现。

      一个非常纯粹的理论家可能会开始担心 N 需要 O(log(N)) 个数字,但我忽略了这个细节,这可能需要仔细查看计算机的数学模型。 计算机编程的艺术的第 1 卷可能给出了 Knuth 对这个问题的看法。

      【讨论】:

      【解决方案8】:

      虽然仍然是 O(N),但映射到硬件辅助操作(例如清除整个缓存行或内存页面)的实现可以以

      实际上,在重复史蒂夫·杰索普的想法......

      如果您有硬件支持以同时清除任意大量内存,则可以这样做。如果您设置一个任意大的数组,那么您还可以设置一个具有硬件并行性的任意大内存,以便单个复位引脚同时同时清除每个寄存器。这条线必须由任意大的逻辑门驱动(消耗任意大的功率),电路走线必须任意短(以克服 R/C 延迟)(或超导),但这些事情很常见在异次元空间中。

      【讨论】:

      • 这个想法是完全可行和好的(不知道为什么没有人投票)。您可以在恒定时间将内存归零,它只需要特殊的硬件和正确对齐的块,效果不应该是不利的。即在分配内存时,它保证未被使用(不需要免费),因此可以将其归零。 Sparc 过去(可能现在仍然)支持优化的内存归零以使 java 更快。
      【解决方案9】:

      可以用 O(1) 时间完成,甚至可以用 O(1) 额外空间。

      我解释了 David 在另一个 answer 中提到的 O(1) 时间解决方案,但它使用了 2n 额外内存。

      存在更好的算法,它只需要 1 位额外内存。
      请参阅我刚刚写的关于该主题的Article
      它还解释了大卫提到的算法,还有一些,以及当今最先进的算法。它还具有后者的implementation

      在一个简短的解释中(我将重复这篇文章),它巧妙地采用了 David 的答案中提出的算法,并在原地完成所有操作,同时只使用了(非常)很少的额外内存。

      【讨论】:

        猜你喜欢
        • 1970-01-01
        • 2014-03-23
        • 2010-11-27
        • 1970-01-01
        • 2011-08-03
        • 1970-01-01
        • 2011-11-03
        • 1970-01-01
        • 2016-10-13
        相关资源
        最近更新 更多