feat: add IndexedPriorityQueue implementation and tests (#7062)

* feat: add IndexedPriorityQueue implementation and tests

* mod : clang-format

* Fix Checkstyle naming for IndexedPriorityQueue tests

* Align IndexedPriorityQueue tests with Checkstyle and clang-format
This commit is contained in:
SeungHyeok Yun
2025-11-17 03:49:30 +09:00
committed by GitHub
parent c6880c195d
commit cff5d3662e
2 changed files with 677 additions and 0 deletions

View File

@@ -0,0 +1,327 @@
package com.thealgorithms.datastructures.heaps;
import java.util.Arrays;
import java.util.Comparator;
import java.util.IdentityHashMap;
import java.util.Objects;
import java.util.function.Consumer;
/**
* An addressable (indexed) min-priority queue with O(log n) updates.
*
* <p>Key features:
* <ul>
* <li>Each element E is tracked by a handle (its current heap index) via a map,
* enabling O(log n) {@code remove(e)} and O(log n) key updates
* ({@code changeKey/decreaseKey/increaseKey}).</li>
* <li>The queue order is determined by the provided {@link Comparator}. If the
* comparator is {@code null}, elements must implement {@link Comparable}
* (same contract as {@link java.util.PriorityQueue}).</li>
* <li>By default this implementation uses {@link IdentityHashMap} for the index
* mapping to avoid issues with duplicate-equals elements or mutable equals/hashCode.
* If you need value-based equality, switch to {@code HashMap} and read the caveats
* in the class-level Javadoc carefully.</li>
* </ul>
*
* <h2>IMPORTANT contracts</h2>
* <ul>
* <li><b>Do not mutate comparator-relevant fields of an element directly</b> while it is
* inside the queue. Always use {@code changeKey}/{@code decreaseKey}/{@code increaseKey}
* so the heap can be restored accordingly.</li>
* <li>If you replace {@link IdentityHashMap} with {@link HashMap}, you must ensure:
* (a) no two distinct elements are {@code equals()}-equal at the same time in the queue, and
* (b) {@code equals/hashCode} of elements remain stable while enqueued.</li>
* <li>{@code peek()} returns {@code null} when empty (matching {@link java.util.PriorityQueue}).</li>
* <li>Not thread-safe.</li>
* </ul>
*
* <p>Complexities:
* {@code offer, poll, remove(e), changeKey, decreaseKey, increaseKey} are O(log n);
* {@code peek, isEmpty, size, contains} are O(1).
*/
public class IndexedPriorityQueue<E> {
/** Binary heap storage (min-heap). */
private Object[] heap;
/** Number of elements in the heap. */
private int size;
/** Comparator used for ordering; if null, elements must be Comparable. */
private final Comparator<? super E> cmp;
/**
* Index map: element -> current heap index.
* <p>We use IdentityHashMap by default to:
* <ul>
* <li>allow duplicate-equals elements;</li>
* <li>avoid corruption when equals/hashCode are mutable or not ID-based.</li>
* </ul>
* If you prefer value-based semantics, replace with HashMap<E,Integer> and
* respect the warnings in the class Javadoc.
*/
private final IdentityHashMap<E, Integer> index;
private static final int DEFAULT_INITIAL_CAPACITY = 11;
public IndexedPriorityQueue() {
this(DEFAULT_INITIAL_CAPACITY, null);
}
public IndexedPriorityQueue(Comparator<? super E> cmp) {
this(DEFAULT_INITIAL_CAPACITY, cmp);
}
public IndexedPriorityQueue(int initialCapacity, Comparator<? super E> cmp) {
if (initialCapacity < 1) {
throw new IllegalArgumentException("initialCapacity < 1");
}
this.heap = new Object[initialCapacity];
this.cmp = cmp;
this.index = new IdentityHashMap<>();
}
/** Returns current number of elements. */
public int size() {
return size;
}
/** Returns {@code true} if empty. */
public boolean isEmpty() {
return size == 0;
}
/**
* Returns the minimum element without removing it, or {@code null} if empty.
* Matches {@link java.util.PriorityQueue#peek()} behavior.
*/
@SuppressWarnings("unchecked")
public E peek() {
return size == 0 ? null : (E) heap[0];
}
/**
* Inserts the specified element (O(log n)).
* @throws NullPointerException if {@code e} is null
* @throws ClassCastException if {@code cmp == null} and {@code e} is not Comparable,
* or if incompatible with other elements
*/
public boolean offer(E e) {
Objects.requireNonNull(e, "element is null");
if (size >= heap.length) {
grow(size + 1);
}
// Insert at the end and bubble up. siftUp will maintain 'index' for all touched nodes.
siftUp(size, e);
size++;
return true;
}
/**
* Removes and returns the minimum element (O(log n)), or {@code null} if empty.
*/
@SuppressWarnings("unchecked")
public E poll() {
if (size == 0) {
return null;
}
E min = (E) heap[0];
removeAt(0); // updates map and heap structure
return min;
}
/**
* Removes one occurrence of the specified element e (O(log n)) if present.
* Uses the index map for O(1) lookup.
*/
public boolean remove(Object o) {
Integer i = index.get(o);
if (i == null) {
return false;
}
removeAt(i);
return true;
}
/** O(1): returns whether the queue currently contains the given element reference. */
public boolean contains(Object o) {
return index.containsKey(o);
}
/** Clears the heap and the index map. */
public void clear() {
Arrays.fill(heap, 0, size, null);
index.clear();
size = 0;
}
// ------------------------------------------------------------------------------------
// Key update API
// ------------------------------------------------------------------------------------
/**
* Changes comparator-relevant fields of {@code e} via the provided {@code mutator},
* then restores the heap in O(log n) by bubbling in the correct direction.
*
* <p><b>IMPORTANT:</b> The mutator must not change {@code equals/hashCode} of {@code e}
* if you migrate this implementation to value-based indexing (HashMap).
*
* @throws IllegalArgumentException if {@code e} is not in the queue
*/
public void changeKey(E e, Consumer<E> mutator) {
Integer i = index.get(e);
if (i == null) {
throw new IllegalArgumentException("Element not in queue");
}
// Mutate fields used by comparator (do NOT mutate equality/hash if using value-based map)
mutator.accept(e);
// Try bubbling up; if no movement occurred, bubble down.
if (!siftUp(i)) {
siftDown(i);
}
}
/**
* Faster variant if the new key is strictly smaller (higher priority).
* Performs a single sift-up (O(log n)).
*/
public void decreaseKey(E e, Consumer<E> mutator) {
Integer i = index.get(e);
if (i == null) {
throw new IllegalArgumentException("Element not in queue");
}
mutator.accept(e);
siftUp(i);
}
/**
* Faster variant if the new key is strictly larger (lower priority).
* Performs a single sift-down (O(log n)).
*/
public void increaseKey(E e, Consumer<E> mutator) {
Integer i = index.get(e);
if (i == null) {
throw new IllegalArgumentException("Element not in queue");
}
mutator.accept(e);
siftDown(i);
}
// ------------------------------------------------------------------------------------
// Internal utilities
// ------------------------------------------------------------------------------------
/** Grows the internal array to accommodate at least {@code minCapacity}. */
private void grow(int minCapacity) {
int old = heap.length;
int pref = (old < 64) ? old + 2 : old + (old >> 1); // +2 if small, else +50%
int newCap = Math.max(minCapacity, pref);
heap = Arrays.copyOf(heap, newCap);
}
@SuppressWarnings("unchecked")
private int compare(E a, E b) {
if (cmp != null) {
return cmp.compare(a, b);
}
return ((Comparable<? super E>) a).compareTo(b);
}
/**
* Inserts item {@code x} at position {@code k}, bubbling up while maintaining the heap.
* Also maintains the index map for all moved elements.
*/
@SuppressWarnings("unchecked")
private void siftUp(int k, E x) {
while (k > 0) {
int p = (k - 1) >>> 1;
E e = (E) heap[p];
if (compare(x, e) >= 0) {
break;
}
heap[k] = e;
index.put(e, k);
k = p;
}
heap[k] = x;
index.put(x, k);
}
/**
* Attempts to bubble up the element currently at {@code k}.
* @return true if it moved; false otherwise.
*/
@SuppressWarnings("unchecked")
private boolean siftUp(int k) {
int orig = k;
E x = (E) heap[k];
while (k > 0) {
int p = (k - 1) >>> 1;
E e = (E) heap[p];
if (compare(x, e) >= 0) {
break;
}
heap[k] = e;
index.put(e, k);
k = p;
}
if (k != orig) {
heap[k] = x;
index.put(x, k);
return true;
}
return false;
}
/** Bubbles down the element currently at {@code k}. */
@SuppressWarnings("unchecked")
private void siftDown(int k) {
int n = size;
E x = (E) heap[k];
int half = n >>> 1; // loop while k has at least one child
while (k < half) {
int child = (k << 1) + 1; // assume left is smaller
E c = (E) heap[child];
int r = child + 1;
if (r < n && compare(c, (E) heap[r]) > 0) {
child = r;
c = (E) heap[child];
}
if (compare(x, c) <= 0) {
break;
}
heap[k] = c;
index.put(c, k);
k = child;
}
heap[k] = x;
index.put(x, k);
}
/**
* Removes the element at heap index {@code i}, restoring the heap afterwards.
* <p>Returns nothing; the standard {@code PriorityQueue} returns a displaced
* element in a rare case to help its iterator. We don't need that here, so
* we keep the API simple.
*/
@SuppressWarnings("unchecked")
private void removeAt(int i) {
int n = --size; // last index after removal
E moved = (E) heap[n];
E removed = (E) heap[i];
heap[n] = null; // help GC
index.remove(removed); // drop mapping for removed element
if (i == n) {
return; // removed last element; done
}
heap[i] = moved;
index.put(moved, i);
// Try sift-up first (cheap if key decreased); if no movement, sift-down.
if (!siftUp(i)) {
siftDown(i);
}
}
}

View File

@@ -0,0 +1,350 @@
package com.thealgorithms.datastructures.heaps;
import java.util.Comparator;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
/**
* Tests for {@link IndexedPriorityQueue}.
*
* Notes:
* - We mainly use a Node class with a mutable "prio" field to test changeKey/decreaseKey/increaseKey.
* - The queue is a min-heap, so smaller "prio" means higher priority.
* - By default the implementation uses IdentityHashMap so duplicate-equals objects are allowed.
*/
public class IndexedPriorityQueueTest {
// ------------------------
// Helpers
// ------------------------
/** Simple payload with mutable priority. */
static class Node {
final String id;
int prio; // lower is better (min-heap)
Node(String id, int prio) {
this.id = id;
this.prio = prio;
}
@Override
public String toString() {
return id + "(" + prio + ")";
}
}
/** Same as Node but overrides equals/hashCode to simulate "duplicate-equals" scenario. */
static class NodeWithEquals {
final String id;
int prio;
NodeWithEquals(String id, int prio) {
this.id = id;
this.prio = prio;
}
@Override
public boolean equals(Object o) {
if (!(o instanceof NodeWithEquals)) {
return false;
}
NodeWithEquals other = (NodeWithEquals) o;
// Intentionally naive equality: equal if priority is equal
return this.prio == other.prio;
}
@Override
public int hashCode() {
return Integer.hashCode(prio);
}
@Override
public String toString() {
return id + "(" + prio + ")";
}
}
private static IndexedPriorityQueue<Node> newNodePQ() {
return new IndexedPriorityQueue<>(Comparator.comparingInt(n -> n.prio));
}
// ------------------------
// Basic operations
// ------------------------
@Test
void testOfferPollWithIntegersComparableMode() {
// cmp == null -> elements must be Comparable
IndexedPriorityQueue<Integer> pq = new IndexedPriorityQueue<>();
Assertions.assertTrue(pq.isEmpty());
pq.offer(5);
pq.offer(1);
pq.offer(3);
Assertions.assertEquals(3, pq.size());
Assertions.assertEquals(1, pq.peek());
Assertions.assertEquals(1, pq.poll());
Assertions.assertEquals(3, pq.poll());
Assertions.assertEquals(5, pq.poll());
Assertions.assertNull(pq.poll()); // empty -> null
Assertions.assertTrue(pq.isEmpty());
}
@Test
void testPeekAndIsEmpty() {
IndexedPriorityQueue<Node> pq = newNodePQ();
Assertions.assertTrue(pq.isEmpty());
Assertions.assertNull(pq.peek());
pq.offer(new Node("A", 10));
pq.offer(new Node("B", 5));
pq.offer(new Node("C", 7));
Assertions.assertFalse(pq.isEmpty());
Assertions.assertEquals("B(5)", pq.peek().toString());
}
@Test
void testRemoveSpecificElement() {
IndexedPriorityQueue<Node> pq = newNodePQ();
Node a = new Node("A", 10);
Node b = new Node("B", 5);
Node c = new Node("C", 7);
pq.offer(a);
pq.offer(b);
pq.offer(c);
// remove by reference (O(log n))
Assertions.assertTrue(pq.remove(b));
Assertions.assertEquals(2, pq.size());
// now min should be C(7)
Assertions.assertEquals("C(7)", pq.peek().toString());
// removing an element not present -> false
Assertions.assertFalse(pq.remove(b));
}
@Test
void testContainsAndClear() {
IndexedPriorityQueue<Node> pq = newNodePQ();
Node a = new Node("A", 2);
Node b = new Node("B", 3);
pq.offer(a);
pq.offer(b);
Assertions.assertTrue(pq.contains(a));
Assertions.assertTrue(pq.contains(b));
pq.clear();
Assertions.assertTrue(pq.isEmpty());
Assertions.assertFalse(pq.contains(a));
Assertions.assertNull(pq.peek());
}
// ------------------------
// Key updates
// ------------------------
@Test
void testDecreaseKeyMovesUp() {
IndexedPriorityQueue<Node> pq = newNodePQ();
Node a = new Node("A", 10);
Node b = new Node("B", 5);
Node c = new Node("C", 7);
pq.offer(a);
pq.offer(b);
pq.offer(c);
// current min is B(5)
Assertions.assertEquals("B(5)", pq.peek().toString());
// Make A more important: 10 -> 1 (smaller is better)
pq.decreaseKey(a, n -> n.prio = 1);
// Now A should be at the top
Assertions.assertEquals("A(1)", pq.peek().toString());
}
@Test
void testIncreaseKeyMovesDown() {
IndexedPriorityQueue<Node> pq = newNodePQ();
Node a = new Node("A", 1);
Node b = new Node("B", 2);
Node c = new Node("C", 3);
pq.offer(a);
pq.offer(b);
pq.offer(c);
// min is A(1)
Assertions.assertEquals("A(1)", pq.peek().toString());
// Make A worse: 1 -> 100
pq.increaseKey(a, n -> n.prio = 100);
// Now min should be B(2)
Assertions.assertEquals("B(2)", pq.peek().toString());
}
@Test
void testChangeKeyChoosesDirectionAutomatically() {
IndexedPriorityQueue<Node> pq = newNodePQ();
Node a = new Node("A", 10);
Node b = new Node("B", 20);
Node c = new Node("C", 30);
pq.offer(a);
pq.offer(b);
pq.offer(c);
// Decrease B to 0 -> should move up
pq.changeKey(b, n -> n.prio = 0);
Assertions.assertEquals("B(0)", pq.peek().toString());
// Increase B to 100 -> should move down
pq.changeKey(b, n -> n.prio = 100);
Assertions.assertEquals("A(10)", pq.peek().toString());
}
@Test
void testDirectMutationWithoutChangeKeyDoesNotReheapByDesign() {
// Demonstrates the contract: do NOT mutate comparator fields directly.
IndexedPriorityQueue<Node> pq = newNodePQ();
Node a = new Node("A", 5);
Node b = new Node("B", 10);
pq.offer(a);
pq.offer(b);
// Illegally mutate priority directly
a.prio = 100; // worse than b now, but heap wasn't notified
// The heap structure is unchanged; peek still returns A(100) (was A(5) before)
// This test documents the behavior/contract rather than relying on it.
Assertions.assertEquals("A(100)", pq.peek().toString());
// Now fix properly via changeKey (no change in value, but triggers reheap)
pq.changeKey(a, n -> n.prio = n.prio);
Assertions.assertEquals("B(10)", pq.peek().toString());
}
// ------------------------
// Identity semantics & duplicates
// ------------------------
@Test
void testDuplicateEqualsElementsAreSupportedIdentityMap() {
IndexedPriorityQueue<NodeWithEquals> pq = new IndexedPriorityQueue<>(Comparator.comparingInt(n -> n.prio));
NodeWithEquals x1 = new NodeWithEquals("X1", 7);
NodeWithEquals x2 = new NodeWithEquals("X2", 7); // equals to X1 by prio, but different instance
// With IdentityHashMap internally, both can coexist
pq.offer(x1);
pq.offer(x2);
Assertions.assertEquals(2, pq.size());
// Poll twice; both 7s should be returned (order between x1/x2 is unspecified)
Assertions.assertEquals(7, pq.poll().prio);
Assertions.assertEquals(7, pq.poll().prio);
Assertions.assertTrue(pq.isEmpty());
}
// ------------------------
// Capacity growth
// ------------------------
@Test
void testGrowByManyInserts() {
IndexedPriorityQueue<Integer> pq = new IndexedPriorityQueue<>();
int n = 100; // beyond default capacity (11)
for (int i = n; i >= 1; i--) {
pq.offer(i);
}
Assertions.assertEquals(n, pq.size());
// Ensure min-to-max order when polling
for (int expected = 1; expected <= n; expected++) {
Integer v = pq.poll();
Assertions.assertEquals(expected, v);
}
Assertions.assertTrue(pq.isEmpty());
Assertions.assertNull(pq.poll());
}
// ------------------------
// remove/contains edge cases
// ------------------------
@Test
void testRemoveHeadAndMiddleAndTail() {
IndexedPriorityQueue<Node> pq = newNodePQ();
Node a = new Node("A", 1);
Node b = new Node("B", 2);
Node c = new Node("C", 3);
Node d = new Node("D", 4);
pq.offer(a);
pq.offer(b);
pq.offer(c);
pq.offer(d);
// remove head
Assertions.assertTrue(pq.remove(a));
Assertions.assertFalse(pq.contains(a));
Assertions.assertEquals("B(2)", pq.peek().toString());
// remove middle
Assertions.assertTrue(pq.remove(c));
Assertions.assertFalse(pq.contains(c));
Assertions.assertEquals("B(2)", pq.peek().toString());
// remove tail (last)
Assertions.assertTrue(pq.remove(d));
Assertions.assertFalse(pq.contains(d));
Assertions.assertEquals("B(2)", pq.peek().toString());
// remove last remaining
Assertions.assertTrue(pq.remove(b));
Assertions.assertTrue(pq.isEmpty());
Assertions.assertNull(pq.peek());
}
// ------------------------
// Error / edge cases for coverage
// ------------------------
@Test
void testInvalidInitialCapacityThrows() {
Assertions.assertThrows(IllegalArgumentException.class, () -> new IndexedPriorityQueue<Integer>(0, Comparator.naturalOrder()));
}
@Test
void testChangeKeyOnMissingElementThrows() {
IndexedPriorityQueue<Node> pq = newNodePQ();
Node a = new Node("A", 10);
Assertions.assertThrows(IllegalArgumentException.class, () -> pq.changeKey(a, n -> n.prio = 5));
}
@Test
void testDecreaseKeyOnMissingElementThrows() {
IndexedPriorityQueue<Node> pq = newNodePQ();
Node a = new Node("A", 10);
Assertions.assertThrows(IllegalArgumentException.class, () -> pq.decreaseKey(a, n -> n.prio = 5));
}
@Test
void testIncreaseKeyOnMissingElementThrows() {
IndexedPriorityQueue<Node> pq = newNodePQ();
Node a = new Node("A", 10);
Assertions.assertThrows(IllegalArgumentException.class, () -> pq.increaseKey(a, n -> n.prio = 15));
}
}