From 4c7087ccdbafccec91207d4c8ac2c39b33ccc27e Mon Sep 17 00:00:00 2001 From: Misko Hevery Date: Fri, 22 Nov 2019 20:15:14 -0800 Subject: [PATCH] refactor(core): Add array modification functions. (#34804) This change introduces several functions for manipulating items in an array in an efficient (binary search) way. - `arraySplice` a faster version of `Array.splice()`. - `arrayInsert` a faster version of `Array.splice(index, 0, value)`. - `arrayInsert2` a faster version of `Array.splice(index, 0, value1, value2)`. - `arrayInsertSorted` a way to insert a value into sorted list. - `arrayRemoveSorted` a way to remove a value from a sorted list. - `arrayIndexOfSorted` a way to find a value in a sorted list. - `ArrayMap` Efficient implementation of `Map` as an `Array`. - `arrayMapSet`, `arrayMapGet`, `arrayMapIndexOf`, and `arrayMapDelete` for manipulating `ArrayMap`s. PR Close #34804 --- packages/core/src/util/array_utils.ts | 268 +++++++++++++++++++- packages/core/test/util/array_utils_spec.ts | 177 ++++++++++++- 2 files changed, 432 insertions(+), 13 deletions(-) diff --git a/packages/core/src/util/array_utils.ts b/packages/core/src/util/array_utils.ts index 9587ae1dbf..6c7fc38047 100644 --- a/packages/core/src/util/array_utils.ts +++ b/packages/core/src/util/array_utils.ts @@ -6,6 +6,8 @@ * found in the LICENSE file at https://angular.io/license */ +import {assertLessThanOrEqual} from './assert'; + /** * Equivalent to ES6 spread, add each item to an array. * @@ -70,4 +72,268 @@ export function newArray(size: number, value?: T): T[] { list.push(value !); } return list; -} \ No newline at end of file +} + +/** + * Remove item from array (Same as `Array.splice()` but faster.) + * + * `Array.splice()` is not as fast because it has to allocate an array for the elements which were + * removed. This causes memory pressure and slows down code when most of the time we don't + * care about the deleted items array. + * + * https://jsperf.com/fast-array-splice (About 20x faster) + * + * @param array Array to splice + * @param index Index of element in array to remove. + * @param count Number of items to remove. + */ +export function arraySplice(array: any[], index: number, count: number): void { + const length = array.length - count; + while (index < length) { + array[index] = array[index + count]; + index++; + } + while (count--) { + array.pop(); // shrink the array + } +} + +/** + * Same as `Array.splice(index, 0, value)` but faster. + * + * `Array.splice()` is not fast because it has to allocate an array for the elements which were + * removed. This causes memory pressure and slows down code when most of the time we don't + * care about the deleted items array. + * + * https://jsperf.com/fast-array-splice (About 20x faster) + * + * @param array Array to splice. + * @param index Index in array where the `value` should be added. + * @param value Value to add to array. + */ +export function arrayInsert(array: any[], index: number, value: any): void { + ngDevMode && assertLessThanOrEqual(index, array.length, 'Can\'t insert past array end.'); + let end = array.length; + while (end > index) { + const previousEnd = end - 1; + array[end] = array[previousEnd]; + end = previousEnd; + } + array[index] = value; +} + +/** + * Same as `Array.splice2(index, 0, value1, value2)` but faster. + * + * `Array.splice()` is not fast because it has to allocate an array for the elements which were + * removed. This causes memory pressure and slows down code when most of the time we don't + * care about the deleted items array. + * + * https://jsperf.com/fast-array-splice (About 20x faster) + * + * @param array Array to splice. + * @param index Index in array where the `value` should be added. + * @param value1 Value to add to array. + * @param value2 Value to add to array. + */ +export function arrayInsert2(array: any[], index: number, value1: any, value2: any): void { + ngDevMode && assertLessThanOrEqual(index, array.length, 'Can\'t insert past array end.'); + let end = array.length; + if (end == index) { + // inserting at the end. + array.push(value1, value2); + } else if (end === 1) { + // corner case when we have less items in array than we have items to insert. + array.push(value2, array[0]); + array[0] = value1; + } else { + end--; + array.push(array[end - 1], array[end]); + while (end > index) { + const previousEnd = end - 2; + array[end] = array[previousEnd]; + end--; + } + array[index] = value1; + array[index + 1] = value2; + } +} + +/** + * Insert a `value` into an `array` so that the array remains sorted. + * + * NOTE: + * - Duplicates are not allowed, and are ignored. + * - This uses binary search algorithm for fast inserts. + * + * @param array A sorted array to insert into. + * @param value The value to insert. + * @returns index of the inserted value. + */ +export function arrayInsertSorted(array: string[], value: string): number { + let index = arrayIndexOfSorted(array, value); + if (index < 0) { + // if we did not find it insert it. + index = ~index; + arrayInsert(array, index, value); + } + return index; +} + +/** + * Remove `value` from a sorted `array`. + * + * NOTE: + * - This uses binary search algorithm for fast removals. + * + * @param array A sorted array to remove from. + * @param value The value to remove. + * @returns index of the removed value. + * - positive index if value found and removed. + * - negative index if value not found. (`~index` to get the value where it should have been + * inserted) + */ +export function arrayRemoveSorted(array: string[], value: string): number { + const index = arrayIndexOfSorted(array, value); + if (index >= 0) { + arraySplice(array, index, 1); + } + return index; +} + + +/** + * Get an index of an `value` in a sorted `array`. + * + * NOTE: + * - This uses binary search algorithm for fast removals. + * + * @param array A sorted array to binary search. + * @param value The value to look for. + * @returns index of the value. + * - positive index if value found. + * - negative index if value not found. (`~index` to get the value where it should have been + * located) + */ +export function arrayIndexOfSorted(array: string[], value: string): number { + return _arrayIndexOfSorted(array, value, 0); +} + + +/** + * `ArrayMap` is an array where even positions contain keys and odd positions contain values. + * + * `ArrayMap` provides a very efficient way of iterating over its contents. For small + * sets (~10) the cost of binary searching an `ArrayMap` has about the same performance + * characteristics that of a `Map` with significantly better memory footprint. + * + * If used as a `Map` the keys are stored in alphabetical order so that they can be binary searched + * for retrieval. + * + * See: `arrayMapSet`, `arrayMapGet`, `arrayMapIndexOf`, `arrayMapDelete`. + */ +export interface ArrayMap extends Array { __brand__: 'array-map'; } + +/** + * Set a `value` for a `key`. + * + * @param arrayMap to modify. + * @param key The key to locate or create. + * @param value The value to set for a `key`. + * @returns index (always even) of where the value vas set. + */ +export function arrayMapSet(arrayMap: ArrayMap, key: string, value: V): number { + let index = arrayMapIndexOf(arrayMap, key); + if (index >= 0) { + // if we found it set it. + arrayMap[index | 1] = value; + } else { + index = ~index; + arrayInsert2(arrayMap, index, key, value); + } + return index; +} + +/** + * Retrieve a `value` for a `key` (on `undefined` if not found.) + * + * @param arrayMap to search. + * @param key The key to locate. + * @return The `value` stored at the `key` location or `undefined if not found. + */ +export function arrayMapGet(arrayMap: ArrayMap, key: string): V|undefined { + const index = arrayMapIndexOf(arrayMap, key); + if (index >= 0) { + // if we found it retrieve it. + return arrayMap[index | 1] as V; + } + return undefined; +} + +/** + * Retrieve a `key` index value in the array or `-1` if not found. + * + * @param arrayMap to search. + * @param key The key to locate. + * @returns index of where the key is (or should have been.) + * - positive (even) index if key found. + * - negative index if key not found. (`~index` (even) to get the index where it should have + * been inserted.) + */ +export function arrayMapIndexOf(arrayMap: ArrayMap, key: string): number { + return _arrayIndexOfSorted(arrayMap as string[], key, 1); +} + +/** + * Delete a `key` (and `value`) from the `ArrayMap`. + * + * @param arrayMap to modify. + * @param key The key to locate or delete (if exist). + * @returns index of where the key was (or should have been.) + * - positive (even) index if key found and deleted. + * - negative index if key not found. (`~index` (even) to get the index where it should have + * been.) + */ +export function arrayMapDelete(arrayMap: ArrayMap, key: string): number { + const index = arrayMapIndexOf(arrayMap, key); + if (index >= 0) { + // if we found it remove it. + arraySplice(arrayMap, index, 2); + } + return index; +} + + +/** + * INTERNAL: Get an index of an `value` in a sorted `array` by grouping search by `shift`. + * + * NOTE: + * - This uses binary search algorithm for fast removals. + * + * @param array A sorted array to binary search. + * @param value The value to look for. + * @param shift grouping shift. + * - `0` means look at every location + * - `1` means only look at every other (even) location (the odd locations are to be ignored as + * they are values.) + * @returns index of the value. + * - positive index if value found. + * - negative index if value not found. (`~index` to get the value where it should have been + * inserted) + */ +function _arrayIndexOfSorted(array: string[], value: string, shift: number): number { + let start = 0; + let end = array.length >> shift; + while (end !== start) { + const middle = start + ((end - start) >> 1); // find the middle. + const current = array[middle << shift]; + if (value === current) { + return (middle << shift); + } else if (current > value) { + end = middle; + } else { + start = middle + 1; // We already searched middle so make it non-inclusive by adding 1 + } + } + return ~(end << shift); +} diff --git a/packages/core/test/util/array_utils_spec.ts b/packages/core/test/util/array_utils_spec.ts index 9754b10f1b..89e61c1866 100644 --- a/packages/core/test/util/array_utils_spec.ts +++ b/packages/core/test/util/array_utils_spec.ts @@ -6,20 +6,173 @@ * found in the LICENSE file at https://angular.io/license */ -import {flatten} from '../../src/util/array_utils'; +import {ArrayMap, arrayIndexOfSorted, arrayInsert, arrayInsert2, arrayInsertSorted, arrayMapDelete, arrayMapGet, arrayMapIndexOf, arrayMapSet, arrayRemoveSorted, arraySplice, flatten} from '../../src/util/array_utils'; -describe('flatten', () => { +describe('array_utils', () => { - it('should flatten an empty array', () => { expect(flatten([])).toEqual([]); }); + describe('flatten', () => { - it('should flatten a flat array', () => { expect(flatten([1, 2, 3])).toEqual([1, 2, 3]); }); + it('should flatten an empty array', () => { expect(flatten([])).toEqual([]); }); - it('should flatten a nested array depth-first', () => { - expect(flatten([1, [2], 3])).toEqual([1, 2, 3]); - expect(flatten([[1], 2, [3]])).toEqual([1, 2, 3]); - expect(flatten([1, [2, [3]], 4])).toEqual([1, 2, 3, 4]); - expect(flatten([1, [2, [3]], [4]])).toEqual([1, 2, 3, 4]); - expect(flatten([1, [2, [3]], [[[4]]]])).toEqual([1, 2, 3, 4]); - expect(flatten([1, [], 2])).toEqual([1, 2]); + it('should flatten a flat array', () => { expect(flatten([1, 2, 3])).toEqual([1, 2, 3]); }); + + it('should flatten a nested array depth-first', () => { + expect(flatten([1, [2], 3])).toEqual([1, 2, 3]); + expect(flatten([[1], 2, [3]])).toEqual([1, 2, 3]); + expect(flatten([1, [2, [3]], 4])).toEqual([1, 2, 3, 4]); + expect(flatten([1, [2, [3]], [4]])).toEqual([1, 2, 3, 4]); + expect(flatten([1, [2, [3]], [[[4]]]])).toEqual([1, 2, 3, 4]); + expect(flatten([1, [], 2])).toEqual([1, 2]); + }); }); -}); \ No newline at end of file + + describe('fast arraySplice', () => { + function expectArraySplice(array: any[], index: number) { + arraySplice(array, index, 1); + return expect(array); + } + + it('should remove items', () => { + expectArraySplice([0, 1, 2], 0).toEqual([1, 2]); + expectArraySplice([0, 1, 2], 1).toEqual([0, 2]); + expectArraySplice([0, 1, 2], 2).toEqual([0, 1]); + }); + }); + + describe('arrayInsertSorted', () => { + function expectArrayInsert(array: any[], index: number, value: any) { + arrayInsert(array, index, value); + return expect(array); + } + + function expectArrayInsert2(array: any[], index: number, value1: any, value2: any) { + arrayInsert2(array, index, value1, value2); + return expect(array); + } + + it('should insert items', () => { + expectArrayInsert([], 0, 'A').toEqual(['A']); + expectArrayInsert([0], 0, 'A').toEqual(['A', 0]); + expectArrayInsert([0], 1, 'A').toEqual([0, 'A']); + expectArrayInsert([0, 1, 2], 0, 'A').toEqual(['A', 0, 1, 2]); + expectArrayInsert([0, 1, 2], 1, 'A').toEqual([0, 'A', 1, 2]); + expectArrayInsert([0, 1, 2], 2, 'A').toEqual([0, 1, 'A', 2]); + expectArrayInsert([0, 1, 2], 3, 'A').toEqual([0, 1, 2, 'A']); + }); + + it('should insert items', () => { + expectArrayInsert2([], 0, 'A', 'B').toEqual(['A', 'B']); + expectArrayInsert2([0], 0, 'A', 'B').toEqual(['A', 'B', 0]); + expectArrayInsert2([0], 1, 'A', 'B').toEqual([0, 'A', 'B']); + expectArrayInsert2([0, 1, 2], 0, 'A', 'B').toEqual(['A', 'B', 0, 1, 2]); + expectArrayInsert2([0, 1, 2], 1, 'A', 'B').toEqual([0, 'A', 'B', 1, 2]); + expectArrayInsert2([0, 1, 2], 2, 'A', 'B').toEqual([0, 1, 'A', 'B', 2]); + expectArrayInsert2([0, 1, 2], 3, 'A', 'B').toEqual([0, 1, 2, 'A', 'B']); + expectArrayInsert2(['height', '1px', 'width', '2000px'], 0, 'color', 'red').toEqual([ + 'color', 'red', 'height', '1px', 'width', '2000px' + ]); + }); + }); + + describe('arrayInsertSorted', () => { + + it('should insert items don\'t allow duplicates', () => { + let a; + a = ['a', 'c', 'e', 'g', 'i']; + expect(arrayInsertSorted(a, 'a')).toEqual(0); + expect(a).toEqual(['a', 'c', 'e', 'g', 'i']); + + a = ['a', 'c', 'e', 'g', 'i']; + expect(arrayInsertSorted(a, 'b')).toEqual(1); + expect(a).toEqual(['a', 'b', 'c', 'e', 'g', 'i']); + + a = ['a', 'c', 'e', 'g', 'i']; + expect(arrayInsertSorted(a, 'c')).toEqual(1); + expect(a).toEqual(['a', 'c', 'e', 'g', 'i']); + + a = ['a', 'c', 'e', 'g', 'i']; + expect(arrayInsertSorted(a, 'd')).toEqual(2); + expect(a).toEqual(['a', 'c', 'd', 'e', 'g', 'i']); + + a = ['a', 'c', 'e', 'g', 'i']; + expect(arrayInsertSorted(a, 'e')).toEqual(2); + expect(a).toEqual(['a', 'c', 'e', 'g', 'i']); + }); + }); + + + + describe('arrayRemoveSorted', () => { + + it('should remove items', () => { + let a; + a = ['a', 'b', 'c', 'd', 'e']; + expect(arrayRemoveSorted(a, 'a')).toEqual(0); + expect(a).toEqual(['b', 'c', 'd', 'e']); + + a = ['a', 'b', 'c', 'd', 'e']; + expect(arrayRemoveSorted(a, 'b')).toEqual(1); + expect(a).toEqual(['a', 'c', 'd', 'e']); + + a = ['a', 'b', 'c', 'd', 'e']; + expect(arrayRemoveSorted(a, 'c')).toEqual(2); + expect(a).toEqual(['a', 'b', 'd', 'e']); + + a = ['a', 'b', 'c', 'd', 'e']; + expect(arrayRemoveSorted(a, 'd')).toEqual(3); + expect(a).toEqual(['a', 'b', 'c', 'e']); + + a = ['a', 'b', 'c', 'd', 'e']; + expect(arrayRemoveSorted(a, 'e')).toEqual(4); + expect(a).toEqual(['a', 'b', 'c', 'd']); + }); + }); + + + describe('arrayIndexOfSorted', () => { + it('should get index of', () => { + const a = ['a', 'b', 'c', 'd', 'e']; + expect(arrayIndexOfSorted(a, 'a')).toEqual(0); + expect(arrayIndexOfSorted(a, 'b')).toEqual(1); + expect(arrayIndexOfSorted(a, 'c')).toEqual(2); + expect(arrayIndexOfSorted(a, 'd')).toEqual(3); + expect(arrayIndexOfSorted(a, 'e')).toEqual(4); + }); + }); + + describe('ArrayMap', () => { + it('should support basic operations', () => { + const map: ArrayMap = [] as any; + + expect(arrayMapIndexOf(map, 'A')).toEqual(~0); + + expect(arrayMapSet(map, 'B', 1)).toEqual(0); + expect(map).toEqual(['B', 1]); + expect(arrayMapIndexOf(map, 'B')).toEqual(0); + + expect(arrayMapSet(map, 'A', 0)).toEqual(0); + expect(map).toEqual(['A', 0, 'B', 1]); + expect(arrayMapIndexOf(map, 'B')).toEqual(2); + expect(arrayMapIndexOf(map, 'AA')).toEqual(~2); + + expect(arrayMapSet(map, 'C', 2)).toEqual(4); + expect(map).toEqual(['A', 0, 'B', 1, 'C', 2]); + + expect(arrayMapGet(map, 'A')).toEqual(0); + expect(arrayMapGet(map, 'B')).toEqual(1); + expect(arrayMapGet(map, 'C')).toEqual(2); + expect(arrayMapGet(map, 'AA')).toEqual(undefined); + + expect(arrayMapSet(map, 'B', -1)).toEqual(2); + expect(map).toEqual(['A', 0, 'B', -1, 'C', 2]); + + expect(arrayMapDelete(map, 'AA')).toEqual(~2); + expect(arrayMapDelete(map, 'B')).toEqual(2); + expect(map).toEqual(['A', 0, 'C', 2]); + expect(arrayMapDelete(map, 'A')).toEqual(0); + expect(map).toEqual(['C', 2]); + expect(arrayMapDelete(map, 'C')).toEqual(0); + expect(map).toEqual([]); + }); + }); +});