Skip to content

Commit e2356e7

Browse files
BridgeARaddaleax
authored andcommitted
assert: improve deepEqual Set and Map worst case
This change improves the algorithm for the worst case from O(n^2) to O(n log n) by using a lazily initiated set with object or not strict equal primitives keys. In addition a few comments got fixed and a statement simplified. PR-URL: #14258 Reviewed-By: Refael Ackermann <[email protected]>
1 parent 9252b8c commit e2356e7

File tree

2 files changed

+254
-86
lines changed

2 files changed

+254
-86
lines changed

lib/assert.js

+170-85
Original file line numberDiff line numberDiff line change
@@ -33,11 +33,6 @@ const errors = require('internal/errors');
3333

3434
const assert = module.exports = ok;
3535

36-
// At present only the three keys mentioned above are used and
37-
// understood by the spec. Implementations or sub modules can pass
38-
// other keys to the AssertionError's constructor - they will be
39-
// ignored.
40-
4136
// All of the following functions must throw an AssertionError
4237
// when a corresponding condition is not met, with a message that
4338
// may be undefined if not provided. All assertion methods provide
@@ -112,9 +107,9 @@ function areSimilarRegExps(a, b) {
112107
return a.source === b.source && a.flags === b.flags;
113108
}
114109

115-
// For small buffers it's faster to compare the buffer in a loop.
116-
// The c++ barrier takes the advantage of the faster compare otherwise.
117-
// 300 was the number after which compare became faster.
110+
// For small buffers it's faster to compare the buffer in a loop. The c++
111+
// barrier including the Buffer.from operation takes the advantage of the faster
112+
// compare otherwise. 300 was the number after which compare became faster.
118113
function areSimilarTypedArrays(a, b) {
119114
const len = a.byteLength;
120115
if (len !== b.byteLength) {
@@ -221,21 +216,15 @@ function looseDeepEqual(actual, expected) {
221216
return false;
222217
}
223218
if (util.isDate(actual) && util.isDate(expected)) {
224-
if (actual.getTime() !== expected.getTime()) {
225-
return false;
226-
}
227-
return true;
219+
return actual.getTime() === expected.getTime();
228220
}
229221
if (util.isRegExp(actual) && util.isRegExp(expected)) {
230-
if (!areSimilarRegExps(actual, expected)) {
231-
return false;
232-
}
233-
return true;
222+
return areSimilarRegExps(actual, expected);
234223
}
235224
const actualTag = objectToString(actual);
236225
const expectedTag = objectToString(expected);
237226
if (actualTag === expectedTag) {
238-
if (!isFloatTypedArrayTag(actualTag) && !isObjectOrArrayTag(actualTag) &&
227+
if (!isObjectOrArrayTag(actualTag) && !isFloatTypedArrayTag(actualTag) &&
239228
ArrayBuffer.isView(actual)) {
240229
return areSimilarTypedArrays(actual, expected);
241230
}
@@ -314,29 +303,38 @@ function innerDeepEqual(actual, expected, strict, memos) {
314303
return areEq;
315304
}
316305

317-
function setHasSimilarElement(set, val1, usedEntries, strict, memo) {
318-
if (set.has(val1)) {
319-
if (usedEntries !== null)
320-
usedEntries.add(val1);
321-
return true;
322-
}
323-
324-
// In strict mode the only things which can match a primitive or a function
325-
// will already be detected by set.has(val1).
326-
if (strict && (typeof val1 !== 'object' || val1 === null))
327-
return false;
328-
329-
// Otherwise go looking.
306+
function setHasEqualElement(set, val1, strict, memo) {
307+
// Go looking.
330308
for (const val2 of set) {
331-
if (!usedEntries.has(val2) && innerDeepEqual(val1, val2, strict, memo)) {
332-
usedEntries.add(val2);
309+
if (innerDeepEqual(val1, val2, strict, memo)) {
310+
// Remove the matching element to make sure we do not check that again.
311+
set.delete(val2);
333312
return true;
334313
}
335314
}
336315

337316
return false;
338317
}
339318

319+
// Note: we actually run this multiple times for each loose key!
320+
// This is done to prevent slowing down the average case.
321+
function setHasLoosePrim(a, b, val) {
322+
const altValues = findLooseMatchingPrimitives(val);
323+
if (altValues === undefined)
324+
return false;
325+
326+
var matches = 1;
327+
for (var i = 0; i < altValues.length; i++) {
328+
if (b.has(altValues[i])) {
329+
matches--;
330+
}
331+
if (a.has(altValues[i])) {
332+
matches++;
333+
}
334+
}
335+
return matches === 0;
336+
}
337+
340338
function setEquiv(a, b, strict, memo) {
341339
// This code currently returns false for this pair of sets:
342340
// assert.deepEqual(new Set(['1', 1]), new Set([1]))
@@ -348,59 +346,124 @@ function setEquiv(a, b, strict, memo) {
348346
if (a.size !== b.size)
349347
return false;
350348

351-
// This is a set of the entries in b which have been consumed in our pairwise
352-
// comparison.
353-
//
349+
// This is a lazily initiated Set of entries which have to be compared
350+
// pairwise.
351+
var set = null;
354352
// When the sets contain only value types (eg, lots of numbers), and we're in
355-
// strict mode, we don't need to match off the entries in a pairwise way. In
356-
// that case this initialization is done lazily to avoid the allocation &
357-
// bookkeeping cost. Unfortunately, we can't get away with that in non-strict
358-
// mode.
359-
let usedEntries = strict === true ? null : new Set();
360-
361-
for (const val1 of a) {
362-
if (usedEntries === null && typeof val1 === 'object')
363-
usedEntries = new Set();
364-
365-
// If the value doesn't exist in the second set by reference, and its an
366-
// object or an array we'll need to go hunting for something thats
367-
// deep-equal to it. Note that this is O(n^2) complexity, and will get
368-
// slower if large, very similar sets / maps are nested inside.
369-
// Unfortunately there's no real way around this.
370-
if (!setHasSimilarElement(b, val1, usedEntries, strict, memo))
353+
// strict mode or if all entries strictly match, we don't need to match the
354+
// entries in a pairwise way. In that case this initialization is done lazily
355+
// to avoid the allocation & bookkeeping cost.
356+
for (const val of a) {
357+
// Note: Checking for the objects first improves the performance for object
358+
// heavy sets but it is a minor slow down for primitives. As they are fast
359+
// to check this improves the worst case scenario instead.
360+
if (typeof val === 'object' && val !== null) {
361+
if (set === null) {
362+
set = new Set();
363+
}
364+
// If the specified value doesn't exist in the second set its an not null
365+
// object (or non strict only: a not matching primitive) we'll need to go
366+
// hunting for something thats deep-(strict-)equal to it. To make this
367+
// O(n log n) complexity we have to copy these values in a new set first.
368+
set.add(val);
369+
} else if (!b.has(val) && (strict || !setHasLoosePrim(a, b, val))) {
371370
return false;
371+
}
372+
}
373+
374+
if (set !== null) {
375+
for (const val of b) {
376+
// In non-strict-mode we have to check if a primitive value is already
377+
// matching and only if it's not, go hunting for it.
378+
if (typeof val === 'object' && val !== null) {
379+
if (!setHasEqualElement(set, val, strict, memo))
380+
return false;
381+
} else if (!a.has(val) && (strict || !setHasLoosePrim(b, a, val))) {
382+
return false;
383+
}
384+
}
372385
}
373386

374387
return true;
375388
}
376389

377-
function mapHasSimilarEntry(map, key1, item1, usedEntries, strict, memo) {
378-
// To be able to handle cases like:
379-
// Map([[1, 'a'], ['1', 'b']]) vs Map([['1', 'a'], [1, 'b']])
380-
// or:
381-
// Map([[{}, 'a'], [{}, 'b']]) vs Map([[{}, 'b'], [{}, 'a']])
382-
// ... we need to consider *all* matching keys, not just the first we find.
390+
function findLooseMatchingPrimitives(prim) {
391+
var values, number;
392+
switch (typeof prim) {
393+
case 'number':
394+
values = ['' + prim];
395+
if (prim === 1 || prim === 0)
396+
values.push(Boolean(prim));
397+
return values;
398+
case 'string':
399+
number = +prim;
400+
if ('' + number === prim) {
401+
values = [number];
402+
if (number === 1 || number === 0)
403+
values.push(Boolean(number));
404+
}
405+
return values;
406+
case 'undefined':
407+
return [null];
408+
case 'object': // Only pass in null as object!
409+
return [undefined];
410+
case 'boolean':
411+
number = +prim;
412+
return [number, '' + number];
413+
}
414+
}
383415

384-
// This check is not strictly necessary. The loop performs this check, but
385-
// doing it here improves performance of the common case when reference-equal
386-
// keys exist (which includes all primitive-valued keys).
387-
if (map.has(key1) && innerDeepEqual(item1, map.get(key1), strict, memo)) {
388-
if (usedEntries !== null)
389-
usedEntries.add(key1);
390-
return true;
416+
// This is a ugly but relatively fast way to determine if a loose equal entry
417+
// actually has a correspondent matching entry. Otherwise checking for such
418+
// values would be way more expensive (O(n^2)).
419+
// Note: we actually run this multiple times for each loose key!
420+
// This is done to prevent slowing down the average case.
421+
function mapHasLoosePrim(a, b, key1, memo, item1, item2) {
422+
const altKeys = findLooseMatchingPrimitives(key1);
423+
if (altKeys === undefined)
424+
return false;
425+
426+
const setA = new Set();
427+
const setB = new Set();
428+
429+
var keyCount = 1;
430+
431+
setA.add(item1);
432+
if (b.has(key1)) {
433+
keyCount--;
434+
setB.add(item2);
391435
}
392436

393-
if (strict && (typeof key1 !== 'object' || key1 === null))
437+
for (var i = 0; i < altKeys.length; i++) {
438+
const key2 = altKeys[i];
439+
if (a.has(key2)) {
440+
keyCount++;
441+
setA.add(a.get(key2));
442+
}
443+
if (b.has(key2)) {
444+
keyCount--;
445+
setB.add(b.get(key2));
446+
}
447+
}
448+
if (keyCount !== 0 || setA.size !== setB.size)
394449
return false;
395450

396-
for (const [key2, item2] of map) {
397-
// The first part is checked above.
398-
if (key2 === key1 || usedEntries.has(key2))
399-
continue;
451+
for (const val of setA) {
452+
if (!setHasEqualElement(setB, val, false, memo))
453+
return false;
454+
}
455+
456+
return true;
457+
}
400458

459+
function mapHasEqualEntry(set, map, key1, item1, strict, memo) {
460+
// To be able to handle cases like:
461+
// Map([[{}, 'a'], [{}, 'b']]) vs Map([[{}, 'b'], [{}, 'a']])
462+
// ... we need to consider *all* matching keys, not just the first we find.
463+
for (const key2 of set) {
401464
if (innerDeepEqual(key1, key2, strict, memo) &&
402-
innerDeepEqual(item1, item2, strict, memo)) {
403-
usedEntries.add(key2);
465+
innerDeepEqual(item1, map.get(key2), strict, memo)) {
466+
set.delete(key2);
404467
return true;
405468
}
406469
}
@@ -411,21 +474,45 @@ function mapHasSimilarEntry(map, key1, item1, usedEntries, strict, memo) {
411474
function mapEquiv(a, b, strict, memo) {
412475
// Caveat: In non-strict mode, this implementation does not handle cases
413476
// where maps contain two equivalent-but-not-reference-equal keys.
414-
//
415-
// For example, maps like this are currently considered not equivalent:
416477
if (a.size !== b.size)
417478
return false;
418479

419-
let usedEntries = strict === true ? null : new Set();
420-
421-
for (const [key, item] of a) {
422-
if (usedEntries === null && typeof key === 'object')
423-
usedEntries = new Set();
424-
425-
// Just like setEquiv above, this hunt makes this function O(n^2) when
426-
// using objects and lists as keys
427-
if (!mapHasSimilarEntry(b, key, item, usedEntries, strict, memo))
480+
var set = null;
481+
482+
for (const [key, item1] of a) {
483+
// By directly retrieving the value we prevent another b.has(key) check in
484+
// almost all possible cases.
485+
const item2 = b.get(key);
486+
if (item2 === undefined) {
487+
// Just like setEquiv above but in addition we have to make sure the
488+
// values are also equal.
489+
if (typeof key === 'object' && key !== null) {
490+
if (set === null) {
491+
set = new Set();
492+
}
493+
set.add(key);
494+
// Note: we do not have to pass memo in this case as at least one item
495+
// is undefined.
496+
} else if ((!innerDeepEqual(item1, item2, strict) || !b.has(key)) &&
497+
(strict || !mapHasLoosePrim(a, b, key, memo, item1))) {
498+
return false;
499+
}
500+
} else if (!innerDeepEqual(item1, item2, strict, memo) &&
501+
(strict || !mapHasLoosePrim(a, b, key, memo, item1, item2))) {
428502
return false;
503+
}
504+
}
505+
506+
if (set !== null) {
507+
for (const [key, item] of b) {
508+
if (typeof key === 'object' && key !== null) {
509+
if (!mapHasEqualEntry(set, a, key, item, strict, memo))
510+
return false;
511+
} else if (!a.has(key) &&
512+
(strict || !mapHasLoosePrim(b, a, key, memo, item))) {
513+
return false;
514+
}
515+
}
429516
}
430517

431518
return true;
@@ -437,12 +524,10 @@ function objEquiv(a, b, strict, keys, memos) {
437524
if (isSet(a)) {
438525
if (!isSet(b) || !setEquiv(a, b, strict, memos))
439526
return false;
440-
} else if (isSet(b)) {
441-
return false;
442527
} else if (isMap(a)) {
443528
if (!isMap(b) || !mapEquiv(a, b, strict, memos))
444529
return false;
445-
} else if (isMap(b)) {
530+
} else if (isSet(b) || isMap(b)) {
446531
return false;
447532
}
448533

0 commit comments

Comments
 (0)