skip to Main Content

In javascript a Map allows uniquely retrieving a "value" based on a single associated "key" – like so:

let m = new Map();
m.set('a', 'my value');

let v = m.get('a'); // v === 'my value'

How can we implement a data structure which uses multiple unordered keys to retrieve an associated value? In such a case the "key" value would always be an array. Let’s call this a "CompoundMap". Let’s allow a CompoundMap to be instantiated with a "width" value, giving a fixed-size for its compound keys. Like Map, the values used in CompoundMap keys should be able to be anything (e.g. String, Number, Object, Regex, ClientRequest… whatever) E.g.:

let cm = new CompoundMap({ width: 2 });
cm.set([ 'a', 1 ], 'my value');

let v1 = cm.get([ 'a', 1 ]); // v1 === 'my value'
let v2 = cm.get([ 1, 'a' ]); // v2 === 'my value'
let v3 = cm.get([ 'a', 2 ]); // v3 === undefined
let v4 = cm.get([ 'b', 1 ]); // v4 === undefined
let v5 = cm.get([ 2, 'a' ]); // v5 === undefined
let v6 = cm.get([ 1, 'b' ]); // v6 === undefined

One way I can think of is to use a nested trees of Maps:

class CompoundMap {
  
  constructor({ width, entries }) {
    
    if (!width && entries) width = entries[0]?.[0]?.length;
    if (!width) throw Error('Must provide "width"');
    
    Object.assign(this, { width, tree: new Map() });
    
    for (let [ cmpKey, val ] of entries ?? []) this.set(cmpKey, val);
    
  }
  
  * treeSearch(tree, cmpKey) {
    
    if (cmpKey.length === 0) throw Error('Compound key should have at least one item');
    
    // A `cmpKey` with length 1 is our "base case"; return the "tree" we reached, and the final key
    if (cmpKey.length === 1) return yield { tree, finalKey: cmpKey[0] };
    
    for (let [ n, key ] of cmpKey.entries()) {
      
      let subtree = tree.get(key);
      
      // If no `subtree`, `key` doesn't apply at this current depth
      if (!subtree) continue;
      
      // We found a `subtree` - we still need to process the remaining key, which is `cmpKey` with `key` removed
      let cmpKeyMinusKey = (() => {
        let copy = [ ...cmpKey ];
        copy.splice(n, 1); // `n` is the index of `key` in `copy`
        return copy;
      })();
      
      // Now recursively search for the rest of the key! Note `yield*` helps us implement backtracking - a
      // search down some specific chain of subtrees may not succeed, but the overall search could still
      // succeed when another chain of subtrees is able to fully consume the `cmpKey`
      // (This is also why the runtime for this approach will be horrible...)
      yield* this.treeSearch(subtree, cmpKeyMinusKey);
      
    }
    
  }
  
  set(cmpKey, val) {
    
    if (cmpKey.length !== this.width) throw Error(`Compound key must have exactly ${this.width} item(s)`);
    
    let preexisting = this.treeSearch(this.tree, cmpKey).next().value;
    
    if (preexisting) {
      
      // Overwrite existing item
      // `this.treeSearch` yields `{ tree, finalKey }`, so overwriting is very simple
      let { tree, finalKey } = preexisting;
      tree.set(finalKey, val);
      
    } else {
      
      // Write new item
      let tree = this.tree;
      for (let k of cmpKey.slice(0, -1)) {
        if (!tree.has(k)) tree.set(k, new Map());
        tree = tree.get(k);
      }
      
      tree.set(cmpKey.at(-1), val);
      
    }
    
    return this;
    
  }
  
  get(cmpKey) {
    
    if (cmpKey.length !== this.width) throw Error(`Compound key must have exactly ${this.width} item(s)`);
    
    let preexisting = this.treeSearch(this.tree, cmpKey).next().value;
    if (!preexisting) return undefined;
    
    let { tree, finalKey } = preexisting;
    return tree.get(finalKey);
    
  }
  
}

let obj1 = { desc: 'obj' };
let obj2 = { desc: 'obj' };
let tests = [
  
  {
    entries: [
      { cmpKey: [ 'a', 1 ], val: 'my value' },
    ],
    lookups: [
      { cmpKey: [ 'a', 1 ], expected: 'my value' },
      { cmpKey: [ 1, 'a' ], expected: 'my value' },
      { cmpKey: [ 'a', 2 ], expected: undefined },
      { cmpKey: [ 'b', 1 ], expected: undefined },
      { cmpKey: [ 2, 'a' ], expected: undefined },
      { cmpKey: [ 1, 'b' ], expected: undefined }
    ]
  },

  {
    entries: [
      { cmpKey: [ 'a', 'b', 'c' ], val: obj1 },
      { cmpKey: [ 'c', 'd', 'e' ], val: obj2 }
    ],
    lookups: [
      { cmpKey: [ 'a', 'd', 'e' ], expected: undefined },
      { cmpKey: [ 'c', 'b', 'a' ], expected: obj1 },
      { cmpKey: [ 'd', 'e', 'c' ], expected: obj2 }
    ]
  }
  
];

for (let { entries, lookups } of tests) {
  
  let cm = new CompoundMap({ entries: entries.map(({ cmpKey, val }) => [ cmpKey, val ]) });
  
  for (let { cmpKey, expected } of lookups) {
    
    let received = cm.get(cmpKey);
    
    if (received !== expected) {
      console.log(`Test Failed! Expected [ ${cmpKey.join(', ')} ] -> ${JSON.stringify(expected)} ... BUT GOT ${JSON.stringify(received)}`);
    } else {
      console.log(`Test Passed! [ ${cmpKey.join(', ')} ] -> ${JSON.stringify(expected)}`);
    }
    
  }
  
}

If you follow the above logic, however, you will see that the runtime is horrible – I think it’s at least exponential (O(2^n)). Can a CompoundMap be implemented more efficiently?

If we could somehow map arbitrary values to BigInts, through a function like getUniqueIntFor, we could map compound keys to single unique Strings:

let compoundKey = [ 1, 2, 'j', /my regex/u, { arr: [] }, new HttpServer() ];
let uniqueString = compoundKey
  .map(anyValue => getUniqueIntFor(anyValue).toString(36))
  .sort()
  .join('.');

Given this ability, it’s arbitrary to implement CompoundMap. But how to implement getUniqueIntFor?

Is this idea worthwhile?

Otherwise, how can I implement CompoundMap more efficiently?

2

Answers


  1. Chosen as BEST ANSWER

    I implemented this and pushed it to github and npm - I decided to reference-count the WeakMap entries; this allows entries to be removed earlier, when the user has deleted keys, but not yet dropped all references to the items in those keys.


  2. You can easily sort the keys (the sorting could be optimized of course), that makes working with the tree very simple.

    class CompoundMap{
      #idxs = new Map;
      #tree = new Map;
      constructor(entries){
        for(const entry of entries) this.set(entry[0], entry[1]);
      }
      get(keys){
        const sorted = this.sortKeys(keys);
        let tree = this.#tree;
        for(const key of sorted){
          tree = tree.get(key)?.tree
          if(!tree) return;
        }
        return tree.value;
      }
      sortKeys(keys){
        let idx;
        return keys.map(key => (idx = this.#idxs.get(key) ?? (this.#idxs.set(key, idx = this.#idxs.size), idx), idx)).sort((a,b) => a-b);
      }
      set(keys,val){
        const sorted = this.sortKeys(keys);
        let tree = this.#tree;
        for(const key of sorted){
          let next = tree.get(key);
          if(!next){
            next = {tree: new Map};
            tree.set(key, next);
          }
          tree = next.tree;
        }
        tree.value = val;
      }
    }
    
    let obj1 = { desc: 'obj' };
    let obj2 = { desc: 'obj' };
    let tests = [
      
      {
        entries: [
          { cmpKey: [ 'a', 1 ], val: 'my value' },
        ],
        lookups: [
          { cmpKey: [ 'a', 1 ], expected: 'my value' },
          { cmpKey: [ 1, 'a' ], expected: 'my value' },
          { cmpKey: [ 'a', 2 ], expected: undefined },
          { cmpKey: [ 'b', 1 ], expected: undefined },
          { cmpKey: [ 2, 'a' ], expected: undefined },
          { cmpKey: [ 1, 'b' ], expected: undefined }
        ]
      },
    
      {
        entries: [
          { cmpKey: [ 'a', 'b', 'c' ], val: obj1 },
          { cmpKey: [ 'c', 'd', 'e' ], val: obj2 }
        ],
        lookups: [
          { cmpKey: [ 'a', 'd', 'e' ], expected: undefined },
          { cmpKey: [ 'c', 'b', 'a' ], expected: obj1 },
          { cmpKey: [ 'd', 'e', 'c' ], expected: obj2 }
        ]
      }
      
    ];
    
    for (let { entries, lookups } of tests) {
      
      let cm = new CompoundMap(entries.map(({ cmpKey, val }) => [ cmpKey, val ]) );
      
      for (let { cmpKey, expected } of lookups) {
    
        let received = cm.get(cmpKey);
        
        if (received !== expected) {
          console.log(`Test Failed! Expected [ ${cmpKey.join(', ')} ] -> ${JSON.stringify(expected)} ... BUT GOT ${JSON.stringify(received)}`);
        } else {
          console.log(`Test Passed! [ ${cmpKey.join(', ')} ] -> ${JSON.stringify(expected)}`);
        }
        
      }
      
    }
    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search