8

Is there any known data structure that provides O(1) random access, without using a contiguous block of memory of size O(N) or greater? This was inspired by this answer and is being asked for curiosity's sake rather than for any specific practical use case, though it might hypothetically be useful in cases of a severely fragmented heap.

Community
  • 1
  • 1
dsimcha
  • 67,514
  • 53
  • 213
  • 334
  • To specifically address some of the confusion from the `std::vector` question: most of the time people talk about this in the context of treating `&vector[0]` as a C array. Pathological vector implementations could break C array compatibility while easily meeting C++98's O(1) random access requirement by simply storing elements in reverse order. – jamesdlin Jan 20 '10 at 05:12
  • That *defect* has been fixed in C++03. It's really not worth debating might-haves for a known defect that was never misunderstood in real-world libraries. See Stroustrup's comments at http://www.velocityreviews.com/forums/showpost.php?p=1484525&postcount=8 –  Jan 20 '10 at 06:12
  • Right, I was merely explaining why C++98's requirements only implied contiguous memory rather than necessitating contiguous memory (which is what prompted this question). I totally agree it's a non-issue. – jamesdlin Jan 20 '10 at 06:38
  • @jamesdlin: Ah, I didn't see that confusion from the question. –  Jan 20 '10 at 07:39

4 Answers4

5

Yes, here's an example in C++:

template<class T>
struct Deque {
  struct Block {
    enum {
      B = 4*1024 / sizeof(T), // use any strategy you want
                              // this gives you ~4KiB blocks
      length = B
    };
    T data[length];
  };
  std::vector<Block*> blocks;

  T& operator[](int n) {
    return blocks[n / Block::length]->data[n % Block::length]; // O(1)
  }

  // many things left out for clarity and brevity
};

The main difference from std::deque is this has O(n) push_front instead of O(1), and in fact there's a bit of a problem implementing std::deque to have all of:

  1. O(1) push_front
  2. O(1) push_back
  3. O(1) op[]

Perhaps I misinterpreted "without using a contiguous block of memory of size O(N) or greater", which seems awkward. Could you clarify what you want? I've interpreted as "no single allocation that contains one item for every item in the represented sequence", such as would be helpful to avoid large allocations. (Even though I do have a single allocation of size N/B for the vector.)

If my answer doesn't fit your definition, then nothing will, unless you artificially limit the container's max size. (I can limit you to LONG_MAX items, store the above blocks in a tree instead, and call that O(1) lookup, for example.)

  • Isn't the length of Block::data O(N)? It's some fraction of N, but it's a constant fraction and therefore O(N). – dsimcha Jan 20 '10 at 01:53
  • This is a contiguous structure: all the elements are there in a line with no gaps. Nice addressing semantics, but not really responsive to the question. – dmckee --- ex-moderator kitten Jan 20 '10 at 01:54
  • @dsimcha: No, length is a compile time constant. – dmckee --- ex-moderator kitten Jan 20 '10 at 01:55
  • 1
    @dmckee: the elements are not stored in "a [single] contiguous block of memory", how is it not addressing the question? –  Jan 20 '10 at 02:00
  • @dsimcha: No, Block::length is constant (I used *B* here, but it could be 42, 3, etc.) and never changes, no matter how many elements are stored. –  Jan 20 '10 at 02:10
  • 1
    @Roger: Ok, you're right, I misread it, but isn't blocks O(N)? Wouldn't blocks.size() == N / B? – dsimcha Jan 20 '10 at 02:45
  • @dsimcha: Yes, perhaps I misinterpreted "without using a contiguous block of memory of size O(N) or greater" which seems awkward. Could you clarify what you want? If my answer doesn't fit your definition, then nothing will, unless you *artificially* limit the container size. (I can limit you to INT_MAX items, store this deque in a tree instead, and call that O(1) lookup, for example.) –  Jan 20 '10 at 03:01
  • @"Roger Pate": you should have just answered std::deque to avoid the confusion. – oz10 Jan 20 '10 at 03:38
  • @"Roger Pate": I also disagree with your statement "and in fact there's a bit of a problem implementing std::deque to have all of" push_front, push_back, and op[] be O(1) - unless you are discussing your specific implementation. If you used linked lists to store the blocks rather than a vector, all of the operations can be done in O(1). – oz10 Jan 20 '10 at 03:44
  • Very cool, and thanks for making me inform myself about the complexity of `std::deque::iterator::operator+`! I would suggest making each block twice the size of the last, though. To find which block has the object, index `blocks` with the number of bits in the index and the block with the bits following the first. The first two objects are in the first block as a special case. – Potatoswatter Jan 20 '10 at 04:27
  • @ceretullis: I tried to avoid confusion *by* giving a specific example. A deque with a LL of blocks has O(N) lookup (with a factor of 1/B, so it's still very small), not O(1). –  Jan 20 '10 at 04:29
  • @Potatoswatter: That works if you only grow at one end or the other, but if you treat the deque as a FIFO (e.g. only use push_back and pop_front) it will break down. (You could have a better example structure for this specific question, however.) –  Jan 20 '10 at 04:32
  • @Roger: Never say never. You can erase blocks as `block.end() < begin()`. When you have just one block, make it the first. Invariantly the second block is the same size as the first. Then the structure never shrinks, but you can replace the first block with the second and avoid growing indefinitely. – Potatoswatter Jan 20 '10 at 04:46
  • * I mean, never shrinks besides the smaller "lead" blocks which ultimately free as much space as the final block size. – Potatoswatter Jan 20 '10 at 04:47
  • Potatoswatter: It seems like O(1) random access would then be a mess to implement. – Jason Orendorff Jan 21 '10 at 13:37
3

You can use a trie where the length of the key is bounded. As lookup in a trie with a key of length m is O(m), if we bound the length of the keys then we bound m and now lookup is O(1).

So think of the a trie where the keys are strings on the alphabet { 0, 1 } (i.e., we are thinking of keys as being the binary representation of integers). If we bound the length of the keys to say 32 letters, we have a structure that we can think of as being indexed by 32-bit integers and is randomly-accessible in O(1) time.

Here is an implementation in C#:

class TrieArray<T> {
    TrieArrayNode<T> _root;

    public TrieArray(int length) {
        this.Length = length;
        _root = new TrieArrayNode<T>();
        for (int i = 0; i < length; i++) {
            Insert(i);
        }
    }

    TrieArrayNode<T> Insert(int n) {
        return Insert(IntToBinaryString(n));
    }

    TrieArrayNode<T> Insert(string s) {
        TrieArrayNode<T> node = _root;
        foreach (char c in s.ToCharArray()) {
            node = Insert(c, node);
        }
        return _root;
    }

    TrieArrayNode<T> Insert(char c, TrieArrayNode<T> node) {
        if (node.Contains(c)) {
            return node.GetChild(c);
        }
        else {
            TrieArrayNode<T> child = new TrieArray<T>.TrieArrayNode<T>();
            node.Nodes[GetIndex(c)] = child;
            return child;
        }

    }

    internal static int GetIndex(char c) {
        return (int)(c - '0');
    }

    static string IntToBinaryString(int n) {
        return Convert.ToString(n, 2);
    }

    public int Length { get; set; }

    TrieArrayNode<T> Find(int n) {
        return Find(IntToBinaryString(n));
    }

    TrieArrayNode<T> Find(string s) {
        TrieArrayNode<T> node = _root;
        foreach (char c in s.ToCharArray()) {
            node = Find(c, node);
        }
        return node;
    }

    TrieArrayNode<T> Find(char c, TrieArrayNode<T> node) {
        if (node.Contains(c)) {
            return node.GetChild(c);
        }
        else {
            throw new InvalidOperationException();
        }
    }

    public T this[int index] {
        get {
            CheckIndex(index);
            return Find(index).Value;
        }
        set {
            CheckIndex(index);
            Find(index).Value = value;
        }
    }

    void CheckIndex(int index) {
        if (index < 0 || index >= this.Length) {
            throw new ArgumentOutOfRangeException("index");
        }
    }

    class TrieArrayNode<TNested> {
        public TrieArrayNode<TNested>[] Nodes { get; set; }
        public T Value { get; set; }
        public TrieArrayNode() {
            Nodes = new TrieArrayNode<TNested>[2];
        }

        public bool Contains(char c) {
            return Nodes[TrieArray<TNested>.GetIndex(c)] != null;

        }

        public TrieArrayNode<TNested> GetChild(char c) {
            return Nodes[TrieArray<TNested>.GetIndex(c)];
        }
    }
}

Here is sample usage:

class Program {
    static void Main(string[] args) {
        int length = 10;
        TrieArray<int> array = new TrieArray<int>(length);
        for (int i = 0; i < length; i++) {
            array[i] = i * i;
        }
        for (int i = 0; i < length; i++) {
            Console.WriteLine(array[i]);
        }
    }
}
jason
  • 236,483
  • 35
  • 423
  • 525
  • 3
    That's bogus - lookup in *any* structure is constant-time once you've bounded the key size. A binary trie at most 32 levels deep is O(1) in the same sense as a binary tree at most 32 levels deep. The tree will be much faster than the trie, though, because it takes much longer to fill it the point where you visit 32 nodes before returning. The trie merely implements worst-case tree behavior after the first insertion. – Potatoswatter Jan 20 '10 at 03:44
  • Absolutely not. Right now the other ideas are hashtables, which have O(1) lookup but might or might not be considered contiguous memory, and an array of pointers to arrays, which absolutely satisfies the question. – Potatoswatter Jan 20 '10 at 05:03
  • @Potatoswatter: What's your point? Theoretical arrays aren't `O(1)` unless you bound the size of the maximum index (because addition isn't `O(1)` unless you bound the operands). I've mocked an array indexable in the same way that arrays are indexable. – jason Jan 20 '10 at 12:37
  • @jason: I can't parse that at all, so I'll repeat: a balanced binary tree with 32-bit keys has at most 32 nodes. The slowest possible lookup operation visits 32 nodes, same as your trie. However, the balanced tree only becomes that slow when it holds more than 2^31 nodes, while your trie is that slow when it holds 1 node. Your trie is always slower than a binary tree. Tries *are* fast for some things, but this isn't such a case. – Potatoswatter Jan 20 '10 at 18:44
  • @jason: If I understand your comment, it's a defense of your O(key length) lookup as addition isn't O(1). But addition is O(log N), not O(N), so you will never get ahead of a theoretical bignum array. – Potatoswatter Jan 20 '10 at 18:49
  • @Potatoswatter: No, my point is that arrays are `O(1)` random access exactly because the indices are bounded. If you accept bounded indices, you have to accept bounded keys. – jason Jan 21 '10 at 00:19
  • @jason: Why say anything about tries, then? You can also store (key,value) pairs in a linked list and it will still be O(1). Likewise, the halting problem for a system with finite memory is O(1). – Potatoswatter Jan 21 '10 at 01:46
  • 1
    All this quibbling is really unhelpful. The fact is that this is a good practical answer to the question. While trie lookups are theoretically O(log n), the constant factor indicating a base-1024 logarithm makes them perform like O(1) in practice, unlike most O(log n) data structures. They can be implemented with straight-line O(1) code like `T& operator[](size_t x) { return table[index0(x)][index1(x)][index2(x)][index3(x)]; }`, and they may end up outperforming some of the other proposed O(1) answers even for large data sets. Contrast binary trees. – Jason Orendorff Jan 21 '10 at 13:49
  • @Jason: Binary tries with fixed-size keys are totally impractical. You are confusing the size of the key with the size of the structure. A binary tree with *key size* `N` has at most `2^N` elements inside it and requires *at most* `N` indirection steps (but in practice far fewer as it stores less data than `2^N`), unlike your trie which requires *exactly* `N` indirections. It's not quibbling. Your structure is just a pessimal binary tree. – Potatoswatter Jan 23 '10 at 21:09
  • @Jason Orendorff: oops, that last reply was for you. Anyway, tries perform well when the key size is variable, and the comparison operation is O(N), and a lexicographical ordering allows folding comparison into the descent. Here it's impractical because comparison is O(1)—just one machine instruction. – Potatoswatter Jan 23 '10 at 21:21
  • 1
    @Potatoswatter: Whoa--you're right, I didn't read the answer closely enough. I thought Jason was proposing a real trie with something like 8-12 bits at each level of the tree, not one bit. You're right, binary tries would be a very silly choice here. – Jason Orendorff Jan 24 '10 at 14:29
0

Well, since I've spent time thinking about it, and it could be argued that all hashtables are either a contiguous block of size >N or have a bucket list proportional to N, and Roger's top-level array of Blocks is O(N) with a coefficient less than 1, and I proposed a fix to that in the comments to his question, here goes:

int magnitude( size_t x ) { // many platforms have an insn for this
    for ( int m = 0; x >>= 1; ++ m ) ; // return 0 for input 0 or 1
    return m;
}

template< class T >
struct half_power_deque {
    vector< vector< T > > blocks; // max log(N) blocks of increasing size
    int half_first_block_mag; // blocks one, two have same size >= 2

    T &operator[]( size_t index ) {
        int index_magnitude = magnitude( index );
        size_t block_index = max( 0, index_magnitude - half_first_block_mag );
        vector< T > &block = blocks[ block_index ];
        size_t elem_index = index;
        if ( block_index != 0 ) elem_index &= ( 1<< index_magnitude ) - 1;
        return block[ elem_index ];
    }
};

template< class T >
struct power_deque {
    half_power_deque forward, backward;
    ptrdiff_t begin_offset; // == - backward.size() or indexes into forward
    T &operator[]( size_t index ) {
        ptrdiff_t real_offset = index + begin_offset;
        if ( real_offset < 0 ) return backward[ - real_offset - 1 ];
        return forward[ real_offset ];
    }
};

half_power_deque implements erasing all but the last block, altering half_first_block_mag appropriately. This allows O(max over time N) memory use, amortized O(1) insertions on both ends, never invalidating references, and O(1) lookup.

Potatoswatter
  • 134,909
  • 25
  • 265
  • 421
  • Woops, the largest block allocated by this scheme is at least 1/4 of the total capacity, and that does qualify as a O(N). Still, this is a nifty idea and should perform better than a deque. (My implementation got uglier than I wanted it to be and I abandoned it… should get back to it…) – Potatoswatter Jul 19 '11 at 06:41
-1

How about a map/dictionary? Last I checked, that's O(1) performance.

kyoryu
  • 12,848
  • 2
  • 29
  • 33
  • A hash map takes O(n) contiguous memory. A tree map or skip list takes O(log n) lookup time (though if you're always sequential, a splay tree can turn that back into O(1) amortized). – ephemient Jan 20 '10 at 05:03
  • Well, sorta. If the types can be accessed by reference, it can be implemented to not take that - the base array doesn't have to be O(n), and the allocations for any overflow certainly don't have to be all contiguous, and the actual stored types can be stored in arbitrary locations if they're stored as refs/pointers. There's some level of language dependence there, though. – kyoryu Jan 20 '10 at 05:21
  • kyoryu is right that you can store a hash map in several different ways that don't require a single contiguous allocation of container size, but lookup is only O(1) if you know certain things about the hash function and the data set. –  Jan 20 '10 at 06:06
  • @Roger: That's true - performance can potentially degrade to O(n), but in general cases I've usually seen it accepted as O(1) (given sufficient info about the data set, you can devise a scheme to effectively get O(1) perf) – kyoryu Jan 21 '10 at 01:00
  • If only you had some data structure that provided O(1) lookup and used non-contiguous memory, you could use *that* for the hash table. – Jason Orendorff Jan 21 '10 at 13:53