1

Let's say you're working with a large relational data structure in JavaScript and need to access the information quickly, often. Hash tables used behind the scenes in JS exposed somehow should make it possible to access large data structures with O(1) efficiency. But my code example below will demonstrate that it's not nearly 0(1) with normal objects. However, a top answer on SO, discussing the efficiency of arrays (and thus objects), claims that arrays should perform "just like regular object values" leveraging a hash table:

In contrast to most languages, which implement arrays with, well, arrays, in Javascript Arrays are objects, and values are stored in a hashtable, just like regular object values. As such:

  • Access - O(1)
  • Appending - Amortized O(1) (sometimes resizing the hashtable is required; usually only insertion is required)
  • Prepending - O(n) via unshift, since it requires reassigning all the indexes
  • Insertion - Amortized O(1) if the value does not exist. O(n) if you want to shift existing values (Eg, using splice).
  • Deletion - Amortized O(1) to remove a value, O(n) if you want to reassign indices via splice.
  • Swapping - O(1)

A few simple tests show that both objects and maps in JavaScript using V8 offer only O(n) or worse performance, with weak maps sometimes but not always offering O(1):

enter image description here

The chart will look significantly different each time you run it, but the one thing that stays consistent is arrays are the only JavaScript data structure of the four that maintain O(1) access efficiency consistently. Not even Weak Maps consistently offer 0(1).

let count;
let start;
let end;
let access;
let counts = [];

//object speed test

let objData = [];
let obj = {}
count = 0;
// don't raise this above i < 5, the loops will hang
for (let i = 0, j = 100; i < 5; i++, j *= 10) {
  if (i === 1) j -= 100;
  for (let k = 0; k < j; k++) {
    count++;
    obj['s' + count] = true;
  }
  start = performance.now()
  for (let n = 0; n < 1000; n++) {
    // if you access the object iteratively, V8 seems to actually optimize performance massively
    // via prediction, but typically with relational data we have non-iterative use cases.
    // replace the access line with the following to see that performance optimization
    // access = obj['s' + count];
    access = obj['s' + Math.floor(Math.random() * count)];
  }
  end = performance.now()
  objData.push(end - start);
  counts.push(count);
}

//array speed test

let arrData = [];
let arr = []
count = 0;
// don't raise this above i < 5, the loops will hang
for (let i = 0, j = 100; i < 5; i++, j *= 10) {
  if (i === 1) j -= 100;
  for (let k = 0; k < j; k++) {
    count++;
    arr[count] = true;
  }
  start = performance.now()
  for (let n = 0; n < 1000; n++) {
    // if you access the object iteratively, V8 seems to actually optimize performance massively
    // via prediction, but typically with relational data we have non-iterative use cases.
    // replace the access line with the following to see that performance optimization
    // access = obj['s' + count];
    access = arr[Math.floor(Math.random() * count)];
  }
  end = performance.now()
  arrData.push(end - start);
}

// map speed test

let mapData = [];
let map = new Map();
count = 0;
for (let i = 0, j = 100; i < 5; i++, j *= 10) {
  if (i === 1) j -= 100;
  for (let k = 0; k < j; k++) {
    count++;
    map.set('s' + count, true)
  }
  start = performance.now()
  for (let n = 0; n < 1000; n++) {
    access = map.get('s' + Math.floor(Math.random() * count));
  }
  end = performance.now()
  mapData.push(end - start);
}

// weak map speed test

let weakMapData = [];
let weakMap = new WeakMap();
let objs = [];
for (let i = 0; i < 1000000; i++) {
  objs.push({
    data: Math.random()
  });
}
let objsLen = objs.length - 1;
count = 0;
for (let i = 0, j = 100; i < 5; i++, j *= 10) {
  if (i === 1) j -= 100;
  for (let k = 0; k < j; k++) {
    count++;
    weakMap.set(objs[Math.floor(Math.random() * objsLen)], objs[Math.floor(Math.random() * objsLen)]);
  }
  start = performance.now()
  for (let n = 0; n < 1000; n++) {
    access = weakMap.get(objs[Math.floor(Math.random() * objs.length)]);
  }
  end = performance.now()
  weakMapData.push(end - start);
}

let colorSchemes = ['rgb(255, 99, 132)', 'rgb(242, 101, 36)', 'rgb(113, 38, 242)', 'rgb(48, 221, 86)']
var ctx = document.getElementById('myChart').getContext('2d');
var myLineChart = new Chart(ctx, {
  type: 'line',
  data: {
    labels: counts,
    datasets: [{
        label: 'Objects',
        data: objData,
        pointBackgroundColor: colorSchemes[0],
        backgroundColor: 'rgba(0,0,0,0)',
        borderColor: colorSchemes[0],
        borderWidth: 2
      },
      {
        label: 'Maps',
        data: mapData,
        pointBackgroundColor: colorSchemes[1],
        backgroundColor: 'rgba(0,0,0,0)',
        borderColor: colorSchemes[1],
        borderWidth: 2,
      },
      {
        label: 'WeakMaps',
        data: weakMapData,
        pointBackgroundColor: colorSchemes[2],
        backgroundColor: 'rgba(0,0,0,0)',
        borderColor: colorSchemes[2],
        borderWidth: 2,
      },
      {
        label: 'Arrays',
        data: arrData,
        pointBackgroundColor: colorSchemes[3],
        backgroundColor: 'rgba(0,0,0,0)',
        borderColor: colorSchemes[3],
        borderWidth: 2,
      }
    ]
  }
});
<script src="https://cdn.jsdelivr.net/npm/chart.js@2.9.3/dist/Chart.min.js"></script>

<canvas id="myChart" width="400" height="400"></canvas>

Is the claim correct and my test wrong? Or is the claim inaccurate, and objects can't offer the same access efficiency as arrays? Shouldn't Weak Maps offer 0(1) access as well?

It's not just that answer. The MDN weak map documentation implies better than 0(n) performance on weak maps by pointing out that an inconvenience of the alternative would be 0(n) efficiency.

J.Todd
  • 707
  • 1
  • 12
  • 34
  • 1
    the claim is incorrect. – Jonas Wilms May 10 '20 at 13:16
  • 1
    The answer you cite (or at least the first paragraph of it) is outdated - didn't you read the top-voted comment on it? – Bergi May 10 '20 at 13:20
  • 2
    I can't make much sense of your benchmark. Why are you using integers as keys in the array but strings in objects and Maps? And WeakMaps are not suited for this task (mapping integers to booleans) at all, which is why your test case is doing something completely different (and includes an array access) – Bergi May 10 '20 at 13:23
  • @JonasWilms I actually find it surprising though, because various documentation and articles claim weak maps to offer 0(1) efficiency. Doesn't the [MDN weak map documentation](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/WeakMap) imply better than 0(n) performance on weak maps by pointing out that an inconvenience of the alternative (sets of arrays) would cause 0(n) efficiency? – J.Todd May 10 '20 at 13:23
  • @Bergi see my edit on the end regarding MDN documentation of weak maps. – J.Todd May 10 '20 at 13:25
  • @viziionary hashtables do have O(n) lookup time worst case, it's just the average that is usually far better towards O(1). That's exactly what Maps do offer you. – Jonas Wilms May 10 '20 at 13:29
  • @Bergi as for the discrepancy between the array test and the others, I was showing arrays simply because the answer I cited claimed they're 0(1) and the test shows they are. We can't use strings as keys in arrays, I tested them the only way possible. My point in this question is to uncover how to actually achieve 0(1) access performance in structures outside of arrays, and figure out what claims are right vs wrong. – J.Todd May 10 '20 at 13:29
  • If the engine chooses to use an array datastructure to represent the array, you do have O(1) lookup time. Also continouos accesses are very fast due to low level mechanisms (memory mapping, lookaside buffers). Therefore yes, arrays *can* have O(1) lookup time, although no guarantee exists that engines represent arrays that way. – Jonas Wilms May 10 '20 at 13:31
  • @JonasWilms in the test here, maps even perform worse than objects. I was hoping I could get something close to 0(1) with maps but clearly that's not happening. Edit: And yeah I understand arrays are going to be the fastest structure. What's confusing is why various sources list maps as being potentially closer to 0(1) and they rarely seem to achieve that even in a fairly simple test like this one – J.Todd May 10 '20 at 13:31
  • @Viziionary Yes, WeakMaps offer O(1) amortised lookup time as well. I don't see your test showing otherwise. – Bergi May 10 '20 at 13:31
  • 1
    Btw, your WeakMap test case with `access = map.get(objs[Math.floor(Math.random() * objs.length) + 1]);` is completely broken. Not only does it access the `map` instead of the `weakMap`, it also doesn't access only existing keys (between `0` and `count`) like the other tests, but uses any object from `objs`. And worse, it might even have an out-of-bounds access on `objs` due to the `+1`, where it then passes `undefined` to the map. – Bergi May 10 '20 at 13:36
  • 1
    I think that your test run with just 1000 accesses is not very representative. Have a look at https://mrale.ph/blog/2012/12/15/microbenchmarks-fairy-tale.html – Bergi May 10 '20 at 13:38
  • @Bergi ohh that's why weak map was looking so bad. So weak map is performing close to 0(1) it looks like – J.Todd May 10 '20 at 13:45

1 Answers1

2

guarantees

There is no guarantee for lookup times on arrays and objects. It might be O(1), it might be worse depending on the implementation. Map lookup time is guaranteed to be better than O(n) on average:

Map object must be implemented using either hash tables or other mechanisms that, on average, provide access times that are sublinear on the number of elements in the collection.

reality

As of now, V8 uses various different representations for objects, hashtables for Maps, and hashtables or array lists for arrays. While both array lists and Maps have O(1) lookup time best case, Map lookup times can be worse (if the distribution of the hashes is bad), and object lookup time can be anything from O(1) on a hashtable to a O(n) search in the objects properties.

observation

There is no sense in comparing numeric lookup time to string lookup times.

Jonas Wilms
  • 132,000
  • 20
  • 149
  • 151
  • 2
    Also "sublinear" doesn't mean `O(1)`, it might be logarithmic as well. Real implementations do however use hashmaps (with appropriate hashing) to achieve constant complexity, though. – Bergi May 10 '20 at 14:33