views:

426

answers:

5

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.

+3  A: 

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
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
Well, so are the other ideas so far...
ephemient
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
@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
@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
@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
@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
@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
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 }`, and they may end up outperforming some of the other proposed O(1) answers even for large data sets. Contrast binary trees.
Jason Orendorff
@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
@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
@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
+6  A: 

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.)

Roger Pate
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
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
@dsimcha: No, length is a compile time constant.
dmckee
@dmckee: the elements are not stored in "a [single] contiguous block of memory", how is it not addressing the question?
Roger Pate
I take it back, I misread the code.
dmckee
@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.
Roger Pate
@Roger: Ok, you're right, I misread it, but isn't blocks O(N)? Wouldn't blocks.size() == N / B?
dsimcha
@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.)
Roger Pate
@"Roger Pate": you should have just answered std::deque to avoid the confusion.
ceretullis
@"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).
ceretullis
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
@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).
Roger Pate
@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.)
Roger Pate
@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
* I mean, never shrinks besides the smaller "lead" blocks which ultimately free as much space as the final block size.
Potatoswatter
Potatoswatter: It seems like O(1) random access would then be a mess to implement.
Jason Orendorff
+1  A: 
Potatoswatter
For deque::op[] it is required than "an implementation shall provide these operations for all container types shown in the 'container' column, and shall implement them so as to take amortized constant time." [23.1.1/12] But as I've already stated in my answer, it can't do that if it has O(1) push_front and O(1) push_back, and these operations are considered more important with op[] being a really fast O(n). (I consider this a defect in the standard, and only a small one, but I don't know what the relevant DRs are.)
Roger Pate
@roger: Aha, missed that, thanks! Well… it's not a defect in the standard if it's possible to implement. I don't see anything in http://www.open-std.org/jtc1/sc22/wg21/docs/lwg-toc.html. Certainly SGI-derived libraries should post a big warning, though. The structure I proposed in the comments to your answer has some rough edges with overflowing and underflowing the index, but nothing that should really keep it from working far as I can tell. Just stick two of 'em back-to-back and track the meeting point with modulo arithmetic in a `size_t`.
Potatoswatter
oops, forget the overflow issue. I forgot that the indexes of the elements change when you manipulate the front.
Potatoswatter
A: 

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

kyoryu
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
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
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.
Roger Pate
@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
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
A: 

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