diff --git a/src/main/java/de/comparus/opensource/longmap/LongMapImpl.java b/src/main/java/de/comparus/opensource/longmap/LongMapImpl.java index 2f0b54b..83f83b1 100644 --- a/src/main/java/de/comparus/opensource/longmap/LongMapImpl.java +++ b/src/main/java/de/comparus/opensource/longmap/LongMapImpl.java @@ -1,43 +1,346 @@ package de.comparus.opensource.longmap; -public class LongMapImpl implements LongMap { +import java.lang.reflect.Array; +import java.util.Arrays; +import java.util.Iterator; +import java.util.Objects; + +/** + * Since Arrays have int based index - it is not possible to accommodate all possible range of long keys into such. + * Thus, internal storage is bounded to Integer.MAX_VALUE - 8 max size. + * Also since keys() and values() return an array this implementation should only be capable of storing a subset of long keys range. + * In case max size is exceeded - an exception is thrown upon attempt to put new key value pairs. + * This implementation is not thread safe. + */ +public class LongMapImpl implements LongMap, Iterable.LongBucket> { + + /*https://stackoverflow.com/questions/3038392/do-java-arrays-have-a-maximum-size*/ + private static final int ARRAY_MAX_SIZE = Integer.MAX_VALUE - 8; + private static final int DEFAULT_CAPACITY = 8; + private static final float DEFAULT_LOAD_FACTOR = 0.75f; + private static final int RESIZE_FACTOR = 2; + final float loadFactor; + private int bucketCount; + private LongBucket[] buckets; + + public LongMapImpl() { + this(DEFAULT_CAPACITY, DEFAULT_LOAD_FACTOR); + } + + public LongMapImpl(int capacity, float loadFactor) { + this.loadFactor = loadFactor; + this.buckets = new LongBucket[capacity]; + } + public V put(long key, V value) { - return null; + if (bucketCount == ARRAY_MAX_SIZE) { + throw new IllegalStateException(String.format( + "Current instance of Map is overloaded. It can only store %s mappings at max.", + ARRAY_MAX_SIZE + )); + } + + if (isThresholdExceeded(key)) { + resize(); + } + + int bucketIndex = getIndex(key); + LongBucket currentBucket = buckets[bucketIndex]; + if (currentBucket == null) { + buckets[bucketIndex] = new LongBucket<>(key, value); + bucketCount++; + return null; + } + + return currentBucket.put(key, value); } + @Override public V get(long key) { - return null; + int index = getIndex(key); + return buckets[index] == null + ? null + : buckets[index].get(key); } + @Override public V remove(long key) { + int index = getIndex(key); + + LongBucket currentBucket = buckets[index]; + if (currentBucket != null) { + if (currentBucket.getKey() == key) { + V result = currentBucket.getValue(); + buckets[index] = currentBucket.getCollision(); + bucketCount--; + return result; + } else { + return currentBucket.remove(key); + } + } return null; } + @Override public boolean isEmpty() { - return false; + return bucketCount == 0; } + @Override public boolean containsKey(long key) { - return false; + return get(key) != null; } + @Override public boolean containsValue(V value) { + for (LongBucket bucket : this) { + if (bucket.getValue() == value || (bucket.getValue() != null && bucket.getValue().equals(value))) { + return true; + } + } return false; } - public long[] keys() { - return null; + @Override + public V[] values() { + if (bucketCount == 0) { + //impossible to instantiate a generic array since element type is not known at runtime + return null; + } + V[] values = (V[]) Array.newInstance(this.iterator().next().getValue().getClass(), bucketCount); + int valueIndex = bucketCount; + for (LongBucket bucket : this) { + values[--valueIndex] = bucket.getValue(); + } + return values; } - public V[] values() { - return null; + @Override + public long[] keys() { + if (bucketCount == 0) { + //just to stay consistent with values() + return null; + } + long[] keys = new long[bucketCount]; + int keyIndex = bucketCount; + for (LongBucket bucket : this) { + keys[--keyIndex] = bucket.getKey(); + } + return keys; } + @Override public long size() { - return 0; + return bucketCount; } + @Override public void clear() { + Arrays.fill(buckets, null); + bucketCount = 0; + } + + @Override + public Iterator> iterator() { + return new CollisionAwareLongBucketIterator(); + } + + private void resize() { + if (buckets.length == ARRAY_MAX_SIZE) { + return; + } + + LongBucket[] newBuckets = new LongBucket[getNewSize()]; + for (LongBucket currentBucket : buckets) { + while (currentBucket != null) { + int newIndex = getIndex(currentBucket.getKey(), newBuckets); + LongBucket collision = currentBucket.getCollision(); + currentBucket.setCollision(null); + + //possible collision + LongBucket rehashedBucket = newBuckets[newIndex]; + if (rehashedBucket == null) { + newBuckets[newIndex] = currentBucket; + } else { + rehashedBucket.collide(currentBucket); + } + currentBucket = collision; + } + } + this.buckets = newBuckets; + } + + private int getIndex(long key) { + return getIndex(key, buckets); + } + + private int getNewSize() { + if (buckets.length == 0) { + return DEFAULT_CAPACITY; + } + return ARRAY_MAX_SIZE / RESIZE_FACTOR > buckets.length + ? buckets.length * RESIZE_FACTOR + : ARRAY_MAX_SIZE; + } + + private boolean isThresholdExceeded(long key) { + return !containsKey(key) + && (buckets.length == 0 || (int) (buckets.length * loadFactor) <= bucketCount); + } + + private int getIndex(long key, LongBucket[] storage) { + return (Long.hashCode(key) & 0x7FFFFFFF) % storage.length; + } + + /** + * A collision aware iterator which navigates either through bucket storage + * or down the collision linked list if available. + * It does not track concurrent modifications, so in case of concurrent storage modification it + * may produce unexpected results. + */ + class CollisionAwareLongBucketIterator implements Iterator> { + + private LongBucket next; + private int currentIndex = 0; + + public CollisionAwareLongBucketIterator() { + next = getNext(); + } + + @Override + public boolean hasNext() { + return next != null; + } + + @Override + public LongBucket next() { + if (next == null) { + throw new IllegalStateException("There is no next element to iterate upon."); + } + + LongBucket current = next; + next = getNext(); + return current; + } + + private LongBucket getNext() { + if (next != null && next.getCollision() != null) { + return next.getCollision(); + } + + while (currentIndex < buckets.length) { + if (buckets[currentIndex] != null) { + return buckets[currentIndex++]; + } + currentIndex++; + } + return null; + } + + } + + /** + * Represents a key-value pair. + * Also keeps a link to downstream collision if any and delegates to it in case of key mismatch for e.g. put(), get() operations + */ + class LongBucket { + + private final long key; + private T value; + private LongBucket collision; + + private LongBucket(long key, T value) { + this.key = key; + this.value = value; + } + + public final long getKey() { + return key; + } + + public final T getValue() { + return value; + } + + public final void setValue(T newValue) { + this.value = newValue; + } + + @Override + public final String toString() { + return key + "=" + value; + } + + @Override + public final int hashCode() { + return Objects.hash(key, value); + } + + public LongBucket getCollision() { + return collision; + } + + public void setCollision(LongBucket collision) { + this.collision = collision; + } + + public T remove(long key) { + if (collision == null) { + return null; + } + + if (collision.getKey() == key) { + T removed = collision.getValue(); + collision = collision.getCollision(); + bucketCount--; + return removed; + } else { + return collision.remove(key); + } + } + + public T get(long key) { + if (this.key == key) { + return this.value; + } + if (collision != null) { + return collision.get(key); + } + return null; + } + + public T put(long key, T value) { + if (this.getKey() == key) { + T result = this.getValue(); + this.setValue(value); + return result; + } + + if (collision != null) { + return collision.put(key, value); + } + + collision = new LongBucket<>(key, value); + bucketCount++; + return null; + } + + /** + * Convenience method for resizing and rehashing purposes + * + * @param anotherBucket - collision + */ + public void collide(LongBucket anotherBucket) { + if (collision != null) { + collision.collide(anotherBucket); + } else { + collision = anotherBucket; + } + } + } + /*package private for testing purposes*/ + int getCapacity() { + return buckets.length; } } diff --git a/src/test/java/de/comparus/opensource/longmap/LongMapImplBasicTest.java b/src/test/java/de/comparus/opensource/longmap/LongMapImplBasicTest.java new file mode 100644 index 0000000..6680c91 --- /dev/null +++ b/src/test/java/de/comparus/opensource/longmap/LongMapImplBasicTest.java @@ -0,0 +1,245 @@ +package de.comparus.opensource.longmap; + +import org.junit.Before; +import org.junit.Test; + +import java.util.Arrays; +import java.util.Random; +import java.util.stream.LongStream; + +import static org.junit.Assert.*; + +public class LongMapImplBasicTest { + + private static final String VALUE_PREFIX = "value_"; + private LongMapImpl instance; + + @Before + public void init() { + instance = new LongMapImpl<>(); + } + + @Test + public void when_put_new_then_store_correctly() { + + putRandom(100); + + assertEquals(100, instance.size()); + assertEquals(256, instance.getCapacity()); + } + + @Test + public void when_put_existing_then_update_correctly() { + + int mappingsNumber = 100; + long[] keys = putRandom(mappingsNumber); + Arrays.stream(keys) + .forEach(key -> { + String newValue = VALUE_PREFIX + key + "updated"; + String oldValue = instance.put(key, newValue); + String updatedValue = instance.get(key); + + assertEquals(VALUE_PREFIX + key, oldValue); + assertEquals(newValue, updatedValue); + }); + assertEquals(mappingsNumber, instance.size()); + assertEquals(256, instance.getCapacity()); + } + + @Test + public void when_get_then_return_correctly() { + + long[] keys = putRandom(100); + Arrays.stream(keys) + .forEach(key -> { + String value = instance.get(key); + assertNotNull(value); + assertEquals(VALUE_PREFIX + key, value); + }); + } + + @Test + public void when_get_and_map_is_empty_then_return_null() { + assertNull(instance.get(100L)); + } + + @Test + public void when_get_with_wrong_key_then_return_null() { + put(6); + assertNull(instance.get(7)); + } + + @Test + public void when_remove_then_delete_correctly() { + + int mappingsNumber = 100; + int toBeRemoved = 25; + long[] keys = putRandom(mappingsNumber); + Arrays.stream(keys) + .skip(50) + .limit(toBeRemoved) + .forEach(key -> { + String removed = instance.remove(key); + assertNotNull(removed); + assertEquals(VALUE_PREFIX + key, removed); + assertNull(instance.get(key)); + }); + + assertEquals(mappingsNumber - toBeRemoved, instance.size()); + } + + @Test + public void when_remove_with_wrong_key_then_no_delete() { + put(6); + String removed = instance.remove(10); + + assertNull(removed); + assertEquals(6, instance.size()); + } + + @Test + public void when_empty_then_return_true_otherwise_false() { + + assertTrue(instance.isEmpty()); + + put(2); + assertFalse(instance.isEmpty()); + + instance.remove(0); + instance.remove(1); + assertTrue(instance.isEmpty()); + + put(2); + instance.clear(); + assertTrue(instance.isEmpty()); + + put(2); + instance.remove(0); + assertFalse(instance.isEmpty()); + } + + @Test + public void when_contains_key_then_return_true_otherwise_false() { + long[] keys = putRandom(10); + assertTrue(instance.containsKey(keys[5])); + + instance.remove(keys[5]); + assertFalse(instance.containsKey(keys[5])); + + instance.clear(); + assertFalse(instance.containsKey(keys[5])); + } + + @Test + public void when_contains_value_then_return_true_otherwise_false() { + long[] keys = putRandom(10); + String valueOne = VALUE_PREFIX + keys[3]; + String valueTwo = VALUE_PREFIX + keys[7]; + assertTrue(instance.containsValue(valueOne)); + assertTrue(instance.containsValue(valueTwo)); + assertFalse(instance.containsValue(VALUE_PREFIX + "wrong")); + + instance.remove(keys[3]); + assertFalse(instance.containsValue(valueOne)); + + instance.clear(); + assertFalse(instance.containsValue(valueTwo)); + } + + @Test + public void when_contains_null_value_then_return_true() { + instance.put(100L, null); + assertTrue(instance.containsValue(null)); + + instance.put(100L, "val"); + assertFalse(instance.containsValue(null)); + } + + @Test + public void when_get_keys_then_return_correct_keys_array() { + + long[] expectedKeys = Arrays.stream(putRandom(40)).sorted().toArray(); + long[] actualKeys = Arrays.stream(instance.keys()).sorted().toArray(); + assertEquals(instance.size(), actualKeys.length); + assertArrayEquals(expectedKeys, actualKeys); + + instance.remove(expectedKeys[10]); + instance.remove(expectedKeys[25]); + + long[] actualUpdatedKeys = Arrays.stream(instance.keys()).sorted().toArray(); + long[] expectedUpdatedKeys = Arrays.stream(expectedKeys) + .filter(key -> key != expectedKeys[10] && key != expectedKeys[25]) + .sorted() + .toArray(); + assertEquals(instance.size(), expectedUpdatedKeys.length); + assertArrayEquals(expectedUpdatedKeys, actualUpdatedKeys); + + instance.clear(); + long[] emptyKeys = instance.keys(); + assertNull(emptyKeys); + } + + @Test + public void when_get_values_then_return_correct_value_array() { + int mappingsNumber = 20; + long[] keys = putRandom(mappingsNumber); + + String[] actualValues = Arrays.stream(instance.values()).sorted().toArray(String[]::new); + String[] expectedValues = Arrays.stream(keys) + .mapToObj(key -> VALUE_PREFIX + key) + .sorted() + .toArray(String[]::new); + assertEquals(instance.size(), actualValues.length); + assertArrayEquals(expectedValues, actualValues); + + instance.remove(keys[11]); + instance.remove(keys[19]); + + String[] actualUpdatedValues = Arrays.stream(instance.values()).sorted().toArray(String[]::new); + String[] expectedUpdatedValues = Arrays.stream(keys) + .filter(key -> key != keys[11] && key != keys[19]) + .mapToObj(key -> VALUE_PREFIX + key) + .sorted() + .toArray(String[]::new); + assertEquals(actualUpdatedValues.length, expectedUpdatedValues.length); + assertEquals(instance.size(), actualUpdatedValues.length); + assertArrayEquals(expectedUpdatedValues, actualUpdatedValues); + + instance.clear(); + String[] emptyValues = instance.values(); + assertNull(emptyValues); + } + + @Test + public void when_get_size_return_correct_value() { + assertEquals(0, instance.size()); + + long[] keys = putRandom(100); + assertEquals(100, instance.size()); + + instance.remove(keys[2]); + instance.remove(keys[10]); + instance.remove(keys[59]); + assertEquals(97, instance.size()); + + instance.clear(); + assertEquals(0, instance.size()); + } + + + private void put(long mappingsNumber) { + LongStream + .range(0L, mappingsNumber) + .forEach(key -> instance.put(key, VALUE_PREFIX + key)); + } + + private long[] putRandom(long mappingsNumber) { + Random random = new Random(); + return LongStream + .range(0L, mappingsNumber) + .map(l -> random.nextLong()) + .peek(key -> instance.put(key, VALUE_PREFIX + key)) + .toArray(); + } + +} \ No newline at end of file diff --git a/src/test/java/de/comparus/opensource/longmap/LongMapImplCollisionsTest.java b/src/test/java/de/comparus/opensource/longmap/LongMapImplCollisionsTest.java new file mode 100644 index 0000000..0a5febe --- /dev/null +++ b/src/test/java/de/comparus/opensource/longmap/LongMapImplCollisionsTest.java @@ -0,0 +1,91 @@ +package de.comparus.opensource.longmap; + +import org.junit.Before; +import org.junit.Test; + +import java.util.Arrays; +import java.util.Random; +import java.util.stream.LongStream; + +import static org.junit.Assert.*; + +public class LongMapImplCollisionsTest { + + private static final String VALUE_PREFIX = "value_"; + LongMapImpl instance; + + @Before + public void init() { + //prevent resizing, enforce collisions + instance = new LongMapImpl<>(16, 100.0F); + } + + @Test + public void when_put_with_collisions_then_store_correctly() { + putRandom(200); + + assertEquals(16, instance.getCapacity()); + assertEquals(200, instance.size()); + } + + @Test + public void when_get_with_collisions_then_return_correctly() { + long[] keys = putRandom(200); + Arrays.stream(keys).forEach(key -> { + String value = instance.get(key); + assertEquals(VALUE_PREFIX + key, value); + }); + assertEquals(16, instance.getCapacity()); + assertEquals(200, instance.size()); + } + + @Test + public void when_get_keys_with_collisions_then_return_correctly() { + long[] expectedKeys = Arrays.stream(putRandom(200)).sorted().toArray(); + long[] actualKeys = Arrays.stream(instance.keys()).sorted().toArray(); + + assertArrayEquals(expectedKeys, actualKeys); + assertEquals(16, instance.getCapacity()); + assertEquals(200, instance.size()); + } + + @Test + public void when_get_values_with_collisions_then_return_correctly() { + long[] keys = putRandom(200); + String[] actualValues = Arrays.stream(instance.values()).sorted().toArray(String[]::new); + String[] expectedValues = Arrays.stream(keys).mapToObj(key -> VALUE_PREFIX + key).sorted().toArray(String[]::new); + + assertArrayEquals(expectedValues, actualValues); + assertEquals(16, instance.getCapacity()); + assertEquals(200, instance.size()); + } + + @Test + public void when_remove_values_with_collisions_then_delete_correctly() { + int mappingsNumber = 200; + int toBeRemoved = 45; + long[] keys = putRandom(mappingsNumber); + Arrays.stream(keys) + .skip(150) + .limit(toBeRemoved) + .forEach(key -> { + String removed = instance.remove(key); + assertNotNull(removed); + assertEquals(VALUE_PREFIX + key, removed); + assertNull(instance.get(key)); + }); + + assertEquals(mappingsNumber - toBeRemoved, instance.size()); + assertEquals(16, instance.getCapacity()); + } + + private long[] putRandom(long mappingsNumber) { + Random random = new Random(); + return LongStream + .range(0L, mappingsNumber) + .map(l -> random.nextLong()) + .peek(key -> instance.put(key, VALUE_PREFIX + key)) + .toArray(); + } + +} \ No newline at end of file