From 070b80c28de67557c0e807c61de36e60cc04c55f Mon Sep 17 00:00:00 2001 From: Tom Payne Date: Tue, 19 Mar 2013 06:30:03 +0100 Subject: [PATCH] Add ol.structs.IntegerSet --- src/ol/structs/integerset.js | 333 ++++++++++++++ test/spec/ol/structs/integerset.test.js | 552 ++++++++++++++++++++++++ 2 files changed, 885 insertions(+) create mode 100644 src/ol/structs/integerset.js create mode 100644 test/spec/ol/structs/integerset.test.js diff --git a/src/ol/structs/integerset.js b/src/ol/structs/integerset.js new file mode 100644 index 0000000000..bdccb8e854 --- /dev/null +++ b/src/ol/structs/integerset.js @@ -0,0 +1,333 @@ +// FIXME refactor to use a packed array of integers to reduce GC load + +goog.provide('ol.structs.IntegerRange'); +goog.provide('ol.structs.IntegerSet'); + +goog.require('goog.array'); +goog.require('goog.asserts'); + + +/** + * @typedef {{start: number, stop: number}} + */ +ol.structs.IntegerRange; + + +/** + * @param {ol.structs.IntegerRange} range1 Range 1. + * @param {ol.structs.IntegerRange} range2 Range 2. + * @return {number} Compare. + */ +ol.structs.IntegerRange.compare = function(range1, range2) { + return range1.start - range2.start || range1.stop - range2.stop; +}; + + + +/** + * A set of integers represented as a set of integer ranges. + * This implementation is designed for the case when the number of distinct + * integer ranges is small. + * @constructor + * @param {Array.=} opt_ranges Ranges. + */ +ol.structs.IntegerSet = function(opt_ranges) { + + /** + * @private + * @type {Array.} + */ + this.ranges_ = goog.isDef(opt_ranges) ? opt_ranges : []; + + if (goog.DEBUG) { + this.assertValid(); + } + +}; + + +/** + * @param {Array.} arr Array. + * @return {ol.structs.IntegerSet} Integer set. + */ +ol.structs.IntegerSet.unpack = function(arr) { + var n = arr.length; + goog.asserts.assert(n % 2 === 0); + var ranges = new Array(n / 2); + var rangeIndex = 0; + var i; + for (i = 0; i < n; i += 2) { + ranges[rangeIndex++] = { + start: arr[i], + stop: arr[i + 1] + }; + } + return new ol.structs.IntegerSet(ranges); +}; + + +/** + * @param {number} addStart Start. + * @param {number} addStop Stop. + */ +ol.structs.IntegerSet.prototype.addRange = function(addStart, addStop) { + goog.asserts.assert(addStart <= addStop); + if (addStart == addStop) { + return; + } + var range = {start: addStart, stop: addStop}; + goog.array.binaryInsert(this.ranges_, range, ol.structs.IntegerRange.compare); + this.compactRanges_(); +}; + + +/** + * FIXME empty description for jsdoc + */ +ol.structs.IntegerSet.prototype.assertValid = function() { + var arr = this.pack(); + for (i = 1; i < arr.length; ++i) { + goog.asserts.assert(arr[i] > arr[i - 1]); + } +}; + + +/** + * FIXME empty description for jsdoc + */ +ol.structs.IntegerSet.prototype.clear = function() { + this.ranges_.length = 0; +}; + + +/** + * @private + */ +ol.structs.IntegerSet.prototype.compactRanges_ = function() { + var ranges = this.ranges_; + var n = ranges.length; + var rangeIndex = 0; + var lastRange = null; + var i; + for (i = 0; i < n; ++i) { + var range = ranges[i]; + if (range.start == range.stop) { + // pass + } else if (!goog.isNull(lastRange) && + lastRange.start <= range.start && + range.start <= lastRange.stop) { + lastRange.stop = Math.max(lastRange.stop, range.stop); + } else { + lastRange = ranges[rangeIndex++] = range; + } + } + ranges.length = rangeIndex; +}; + + +/** + * Finds the start of smallest range that is at least of length minSize, or -1 + * if no such range exists. + * @param {number} minSize Minimum size. + * @return {number} Index. + */ +ol.structs.IntegerSet.prototype.findRange = function(minSize) { + goog.asserts.assert(minSize > 0); + var ranges = this.ranges_; + var n = ranges.length; + var bestRange = null; + var bestSize, i, size; + for (i = 0; i < n; ++i) { + range = ranges[i]; + size = range.stop - range.start; + if (size == minSize) { + return range.start; + } else if (size > minSize && (goog.isNull(bestRange) || size < bestSize)) { + bestRange = range; + bestSize = size; + } + } + return goog.isNull(bestRange) ? -1 : bestRange.start; +}; + + +/** + * Calls f with each integer range. + * @param {function(this: T, number, number)} f Callback. + * @param {T=} opt_obj The object to be used as the value of 'this' within f. + * @template T + */ +ol.structs.IntegerSet.prototype.forEachRange = function(f, opt_obj) { + var ranges = this.ranges_; + var n = ranges.length; + var i; + for (i = 0; i < n; ++i) { + f.call(opt_obj, ranges[i].start, ranges[i].stop); + } +}; + + +/** + * Calls f with each integer range not in [start, stop) - 'this'. + * @param {number} start Start. + * @param {number} stop Stop. + * @param {function(this: T, number, number)} f Callback. + * @param {T=} opt_obj The object to be used as the value of 'this' within f. + * @template T + */ +ol.structs.IntegerSet.prototype.forEachRangeInverted = + function(start, stop, f, opt_obj) { + goog.asserts.assert(start < stop); + var ranges = this.ranges_; + var n = ranges.length; + if (n === 0) { + f.call(opt_obj, start, stop); + } else { + if (start < ranges[0].start) { + f.call(opt_obj, start, ranges[0].start); + } + var i; + for (i = 1; i < n; ++i) { + f.call(opt_obj, ranges[i - 1].stop, ranges[i].start); + } + if (ranges[n - 1].stop < stop) { + f.call(opt_obj, ranges[n - 1].stop, stop); + } + } +}; + + +/** + * @return {Array.} Array. + */ +ol.structs.IntegerSet.prototype.getArray = function() { + // FIXME this should return the underlying array when the representation is + // FIXME updated to use a packed array + return this.pack(); +}; + + +/** + * Returns the first element in the set, or -1 if the set is empty. + * @return {number} Start. + */ +ol.structs.IntegerSet.prototype.getFirst = function() { + return this.ranges_.length === 0 ? -1 : this.ranges_[0].start; +}; + + +/** + * Returns the first integer after the last element in the set, or -1 if the + * set is empty. + * @return {number} Last. + */ +ol.structs.IntegerSet.prototype.getLast = function() { + var n = this.ranges_.length; + return n === 0 ? -1 : this.ranges_[n - 1].stop; +}; + + +/** + * @return {Array.} Array. + */ +ol.structs.IntegerSet.prototype.getRanges = function() { + // FIXME this should be removed when the implementation is updated to use a + // FIXME packed array + return this.ranges_; +}; + + +/** + * Returns the number of integers in the set. + * @return {number} Size. + */ +ol.structs.IntegerSet.prototype.getSize = function() { + var ranges = this.ranges_; + var n = ranges.length; + var size = 0; + for (i = 0; i < n; ++i) { + size += ranges[i].stop - ranges[i].start; + } + return size; +}; + + +/** + * @return {boolean} Is empty. + */ +ol.structs.IntegerSet.prototype.isEmpty = function() { + return this.ranges_.length === 0; +}; + + +/** + * @return {Array.} Array. + */ +ol.structs.IntegerSet.prototype.pack = function() { + var ranges = this.ranges_; + var n = ranges.length; + var arr = new Array(2 * n); + var i; + for (i = 0; i < n; ++i) { + arr[2 * i] = ranges[i].start; + arr[2 * i + 1] = ranges[i].stop; + } + return arr; +}; + + +/** + * @param {number} removeStart Start. + * @param {number} removeStop Stop. + */ +ol.structs.IntegerSet.prototype.removeRange = + function(removeStart, removeStop) { + // FIXME this could be more efficient + goog.asserts.assert(removeStart <= removeStop); + var ranges = this.ranges_; + var n = ranges.length; + for (i = 0; i < n; ++i) { + var range = ranges[i]; + if (removeStop < range.start || range.stop < removeStart) { + continue; + } else if (range.start > removeStop) { + break; + } + if (removeStart < range.start) { + if (removeStop == range.start) { + break; + } else if (removeStop < range.stop) { + range.start = Math.max(range.start, removeStop); + break; + } else { + ranges.splice(i, 1); + --i; + --n; + } + } else if (removeStart == range.start) { + if (removeStop < range.stop) { + range.start = removeStop; + break; + } else if (removeStop == range.stop) { + ranges.splice(i, 1); + break; + } else { + ranges.splice(i, 1); + --i; + --n; + } + } else { + if (removeStop < range.stop) { + ranges.splice(i, 1, {start: range.start, stop: removeStart}, + {start: removeStop, stop: range.stop}); + break; + } else if (removeStop == range.stop) { + range.stop = removeStart; + break; + } else { + range.stop = removeStart; + } + } + } + this.compactRanges_(); +}; diff --git a/test/spec/ol/structs/integerset.test.js b/test/spec/ol/structs/integerset.test.js new file mode 100644 index 0000000000..3513fde9e0 --- /dev/null +++ b/test/spec/ol/structs/integerset.test.js @@ -0,0 +1,552 @@ +goog.provide('ol.test.structs.IntegerSet'); + + +describe('ol.structs.IntegerSet', function() { + + describe('constructor', function() { + + describe('without an argument', function() { + + it('constructs an empty instance', function() { + var is = new ol.structs.IntegerSet(); + expect(is).to.be.an(ol.structs.IntegerSet); + expect(is.pack()).to.be.empty(); + }); + + }); + + }); + + describe('unpack', function() { + + it('constructs with a valid array', function() { + var is = ol.structs.IntegerSet.unpack([0, 2, 4, 6]); + expect(is).to.be.an(ol.structs.IntegerSet); + expect(is.pack()).to.equalArray([0, 2, 4, 6]); + }); + + it('throws an exception with an odd number of elements', function() { + expect(function() { + var is = ol.structs.IntegerSet.unpack([0, 2, 4]); + }).to.throwException(); + }); + + it('throws an exception with out-of-order elements', function() { + expect(function() { + var is = ol.structs.IntegerSet.unpack([0, 2, 2, 4]); + }).to.throwException(); + }); + + }); + + describe('with an empty instance', function() { + + var is; + beforeEach(function() { + is = new ol.structs.IntegerSet(); + }); + + describe('addRange', function() { + + it('creates a new element', function() { + is.addRange(0, 2); + expect(is.pack()).to.equalArray([0, 2]); + }); + + }); + + describe('findRange', function() { + + it('returns -1', function() { + expect(is.findRange(2)).to.be(-1); + }); + + }); + + describe('forEachRange', function() { + + it('does not call the callback', function() { + var callback = sinon.spy(); + is.forEachRange(callback); + expect(callback).to.not.be.called(); + }); + + }); + + describe('forEachRangeInverted', function() { + + it('does call the callback', function() { + var callback = sinon.spy(); + is.forEachRangeInverted(0, 8, callback); + expect(callback.calledOnce).to.be(true); + expect(callback.args[0]).to.equalArray([0, 8]); + }); + + }); + + describe('getFirst', function() { + + it('returns -1', function() { + expect(is.getFirst()).to.be(-1); + }); + + }); + + describe('getLast', function() { + + it('returns -1', function() { + expect(is.getLast()).to.be(-1); + }); + + }); + + describe('getSize', function() { + + it('returns 0', function() { + expect(is.getSize()).to.be(0); + }); + + }); + + describe('isEmpty', function() { + + it('returns true', function() { + expect(is.isEmpty()).to.be(true); + }); + + }); + + }); + + describe('with a populated instance', function() { + + var is; + beforeEach(function() { + is = ol.structs.IntegerSet.unpack([4, 6, 8, 10, 12, 14]); + }); + + describe('addRange', function() { + + it('inserts before the first element', function() { + is.addRange(0, 2); + expect(is.pack()).to.equalArray([0, 2, 4, 6, 8, 10, 12, 14]); + }); + + it('extends the first element to the left', function() { + is.addRange(0, 4); + expect(is.pack()).to.equalArray([0, 6, 8, 10, 12, 14]); + }); + + it('extends the first element to the right', function() { + is.addRange(6, 7); + expect(is.pack()).to.equalArray([4, 7, 8, 10, 12, 14]); + }); + + it('merges the first two elements', function() { + is.addRange(6, 8); + expect(is.pack()).to.equalArray([4, 10, 12, 14]); + }); + + it('extends middle elements to the left', function() { + is.addRange(7, 8); + expect(is.pack()).to.equalArray([4, 6, 7, 10, 12, 14]); + }); + + it('extends middle elements to the right', function() { + is.addRange(10, 11); + expect(is.pack()).to.equalArray([4, 6, 8, 11, 12, 14]); + }); + + it('merges the last two elements', function() { + is.addRange(10, 12); + expect(is.pack()).to.equalArray([4, 6, 8, 14]); + }); + + it('extends the last element to the left', function() { + is.addRange(11, 12); + expect(is.pack()).to.equalArray([4, 6, 8, 10, 11, 14]); + }); + + it('extends the last element to the right', function() { + is.addRange(14, 15); + expect(is.pack()).to.equalArray([4, 6, 8, 10, 12, 15]); + }); + + it('inserts after the last element', function() { + is.addRange(16, 18); + expect(is.pack()).to.equalArray([4, 6, 8, 10, 12, 14, 16, 18]); + }); + + }); + + describe('clear', function() { + + it('clears the instance', function() { + is.clear(); + expect(is.pack()).to.be.empty(); + }); + + }); + + describe('findRange', function() { + + it('throws an exception when passed a negative size', function() { + expect(function() { + is.findRange(-1); + }).to.throwException(); + }); + + it('throws an exception when passed a zero size', function() { + expect(function() { + is.findRange(0); + }).to.throwException(); + }); + + it('finds the first range of size 1', function() { + expect(is.findRange(1)).to.be(4); + }); + + it('finds the first range of size 2', function() { + expect(is.findRange(2)).to.be(4); + }); + + it('returns -1 when no range can be found', function() { + expect(is.findRange(3)).to.be(-1); + }); + + }); + + describe('forEachRange', function() { + + it('calls the callback', function() { + var callback = sinon.spy(); + is.forEachRange(callback); + expect(callback).to.be.called(); + expect(callback.calledThrice).to.be(true); + expect(callback.args[0]).to.equalArray([4, 6]); + expect(callback.args[1]).to.equalArray([8, 10]); + expect(callback.args[2]).to.equalArray([12, 14]); + }); + + }); + + describe('forEachRangeInverted', function() { + + it('does call the callback', function() { + var callback = sinon.spy(); + is.forEachRangeInverted(0, 16, callback); + expect(callback.callCount).to.be(4); + expect(callback.args[0]).to.equalArray([0, 4]); + expect(callback.args[1]).to.equalArray([6, 8]); + expect(callback.args[2]).to.equalArray([10, 12]); + expect(callback.args[3]).to.equalArray([14, 16]); + }); + + }); + + + describe('getFirst', function() { + + it('returns the expected value', function() { + expect(is.getFirst()).to.be(4); + }); + + }); + + describe('getLast', function() { + + it('returns the expected value', function() { + expect(is.getLast()).to.be(14); + }); + + }); + + describe('getSize', function() { + + it('returns the expected value', function() { + expect(is.getSize()).to.be(6); + }); + + }); + + describe('isEmpty', function() { + + it('returns false', function() { + expect(is.isEmpty()).to.be(false); + }); + + }); + + describe('removeRange', function() { + + it('removes the first part of the first element', function() { + is.removeRange(4, 5); + expect(is.pack()).to.equalArray([5, 6, 8, 10, 12, 14]); + }); + + it('removes the last part of the first element', function() { + is.removeRange(5, 6); + expect(is.pack()).to.equalArray([4, 5, 8, 10, 12, 14]); + }); + + it('removes the first element', function() { + is.removeRange(4, 6); + expect(is.pack()).to.equalArray([8, 10, 12, 14]); + }); + + it('removes the first part of a middle element', function() { + is.removeRange(8, 9); + expect(is.pack()).to.equalArray([4, 6, 9, 10, 12, 14]); + }); + + it('removes the last part of a middle element', function() { + is.removeRange(9, 10); + expect(is.pack()).to.equalArray([4, 6, 8, 9, 12, 14]); + }); + + it('removes a middle element', function() { + is.removeRange(8, 10); + expect(is.pack()).to.equalArray([4, 6, 12, 14]); + }); + + it('removes the first part of the last element', function() { + is.removeRange(12, 13); + expect(is.pack()).to.equalArray([4, 6, 8, 10, 13, 14]); + }); + + it('removes the last part of the last element', function() { + is.removeRange(13, 14); + expect(is.pack()).to.equalArray([4, 6, 8, 10, 12, 13]); + }); + + it('removes the last element', function() { + is.removeRange(12, 14); + expect(is.pack()).to.equalArray([4, 6, 8, 10]); + }); + + it('can remove multiple ranges near the start', function() { + is.removeRange(3, 11); + expect(is.pack()).to.equalArray([12, 14]); + }); + + it('can remove multiple ranges near the start', function() { + is.removeRange(7, 15); + expect(is.pack()).to.equalArray([4, 6]); + }); + + it('throws an exception when passed an invalid range', function() { + expect(function() { + is.removeRange(2, 0); + }).to.throwException(); + }); + + }); + + }); + + describe('with fragmentation', function() { + + var is; + beforeEach(function() { + is = ol.structs.IntegerSet.unpack( + [0, 1, 2, 4, 5, 8, 9, 12, 13, 15, 16, 17]); + }); + + describe('findRange', function() { + + it('finds the first range of size 1', function() { + expect(is.findRange(1)).to.be(0); + }); + + it('finds the first range of size 2', function() { + expect(is.findRange(2)).to.be(2); + }); + + it('finds the first range of size 3', function() { + expect(is.findRange(3)).to.be(5); + }); + + it('returns -1 when no range can be found', function() { + expect(is.findRange(4)).to.be(-1); + }); + + }); + + describe('getFirst', function() { + + it('returns the expected value', function() { + expect(is.getFirst()).to.be(0); + }); + + }); + + describe('getLast', function() { + + it('returns the expected value', function() { + expect(is.getLast()).to.be(17); + }); + + }); + + describe('getSize', function() { + + it('returns the expected value', function() { + expect(is.getSize()).to.be(12); + }); + + }); + + describe('removeRange', function() { + + it('removing an empty range has no effect', function() { + is.removeRange(0, 0); + expect(is.pack()).to.equalArray( + [0, 1, 2, 4, 5, 8, 9, 12, 13, 15, 16, 17]); + }); + + it('can remove elements from the middle of range', function() { + is.removeRange(6, 7); + expect(is.pack()).to.equalArray( + [0, 1, 2, 4, 5, 6, 7, 8, 9, 12, 13, 15, 16, 17]); + }); + + it('can remove multiple ranges', function() { + is.removeRange(2, 12); + expect(is.pack()).to.equalArray([0, 1, 13, 15, 16, 17]); + }); + + it('can remove multiple ranges and reduce others', function() { + is.removeRange(0, 10); + expect(is.pack()).to.equalArray([10, 12, 13, 15, 16, 17]); + }); + + it('can remove all ranges', function() { + is.removeRange(0, 18); + expect(is.pack()).to.equalArray([]); + }); + + }); + + }); + + describe('compared to a slow reference implementation', function() { + + var SimpleIntegerSet = function() { + this.integers_ = {}; + }; + + SimpleIntegerSet.prototype.addRange = function(addStart, addStop) { + var i; + for (i = addStart; i < addStop; ++i) { + this.integers_[i.toString()] = true; + } + }; + + SimpleIntegerSet.prototype.clear = function() { + this.integers_ = {}; + }; + + SimpleIntegerSet.prototype.pack = function() { + var integers = goog.array.map( + goog.object.getKeys(this.integers_), Number); + goog.array.sort(integers); + var arr = []; + var start = -1, stop; + var i; + for (i = 0; i < integers.length; ++i) { + if (start == -1) { + start = stop = integers[i]; + } else if (integers[i] == stop + 1) { + ++stop; + } else { + arr.push(start, stop + 1); + start = stop = integers[i]; + } + } + if (start != -1) { + arr.push(start, stop + 1); + } + return arr; + }; + + SimpleIntegerSet.prototype.removeRange = function(removeStart, removeStop) { + var i; + for (i = removeStart; i < removeStop; ++i) { + delete this.integers_[i.toString()]; + } + }; + + var is, sis; + beforeEach(function() { + is = new ol.structs.IntegerSet(); + sis = new SimpleIntegerSet(); + }); + + it('behaves identically with random adds', function() { + var addStart, addStop, i; + for (i = 0; i < 64; ++i) { + addStart = goog.math.randomInt(128); + addStop = addStart + goog.math.randomInt(16); + is.addRange(addStart, addStop); + sis.addRange(addStart, addStop); + expect(is.pack()).to.equalArray(sis.pack()); + } + }); + + it('behaves identically with random removes', function() { + is.addRange(0, 128); + sis.addRange(0, 128); + var i, removeStart, removeStop; + for (i = 0; i < 64; ++i) { + removeStart = goog.math.randomInt(128); + removeStop = removeStart + goog.math.randomInt(16); + is.removeRange(removeStart, removeStop); + sis.removeRange(removeStart, removeStop); + expect(is.pack()).to.equalArray(sis.pack()); + } + }); + + it('behaves identically with random adds and removes', function() { + var i, start, stop; + for (i = 0; i < 64; ++i) { + start = goog.math.randomInt(128); + stop = start + goog.math.randomInt(16); + if (Math.random() < 0.5) { + is.addRange(start, stop); + sis.addRange(start, stop); + } else { + is.removeRange(start, stop); + sis.removeRange(start, stop); + } + expect(is.pack()).to.equalArray(sis.pack()); + } + }); + + it('behaves identically with random adds, removes, and clears', function() { + var i, p, start, stop; + for (i = 0; i < 64; ++i) { + start = goog.math.randomInt(128); + stop = start + goog.math.randomInt(16); + p = Math.random(); + if (p < 0.45) { + is.addRange(start, stop); + sis.addRange(start, stop); + } else if (p < 0.9) { + is.removeRange(start, stop); + sis.removeRange(start, stop); + } else { + is.clear(); + sis.clear(); + } + expect(is.pack()).to.equalArray(sis.pack()); + } + }); + + }); + +}); + + +goog.require('goog.array'); +goog.require('goog.object'); +goog.require('ol.structs.IntegerSet');