From 04b05e725d09fe077c12a67be5639f57b5612e9f Mon Sep 17 00:00:00 2001 From: Igor Rudenko Date: Mon, 3 Aug 2026 15:03:00 +0300 Subject: [PATCH] [COLLECTIONS-897] Add LexicographicPermutationIterator Add an Iterator> that generates the permutations of a collection in lexicographical order, complementing PermutationIterator, which uses the Steinhaus-Johnson-Trotter ordering. Elements are ordered by their natural ordering, or by a Comparator supplied to the two-argument constructor, which also allows permuting elements that do not implement Comparable. Each call to next() advances by the standard next-permutation step: locate the pivot, swap it with its successor, then reverse the descending tail. Equal elements are not distinguished, so an input with duplicates yields fewer than n! permutations. An empty collection yields exactly one empty list, as 0! = 1. remove() is unsupported. Comparator dispatch follows the java.util.TreeMap pattern of testing the comparator field for null on each comparison; benchmarking showed no measurable difference against normalizing null to Comparator.naturalOrder() in the constructor. Tests extend AbstractIteratorTest to cover the Iterator contract, and add cases for lexicographical exhaustivity, duplicate handling, custom and reverse comparators, non-Comparable elements, stream traversal, exhaustion, and equals/hashCode. --- .../LexicographicPermutationIterator.java | 198 ++++++++++ .../LexicographicPermutationIteratorTest.java | 343 ++++++++++++++++++ 2 files changed, 541 insertions(+) create mode 100644 src/main/java/org/apache/commons/collections4/iterators/LexicographicPermutationIterator.java create mode 100644 src/test/java/org/apache/commons/collections4/iterators/LexicographicPermutationIteratorTest.java diff --git a/src/main/java/org/apache/commons/collections4/iterators/LexicographicPermutationIterator.java b/src/main/java/org/apache/commons/collections4/iterators/LexicographicPermutationIterator.java new file mode 100644 index 0000000000..3389db68b2 --- /dev/null +++ b/src/main/java/org/apache/commons/collections4/iterators/LexicographicPermutationIterator.java @@ -0,0 +1,198 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.commons.collections4.iterators; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.Comparator; +import java.util.Iterator; +import java.util.List; +import java.util.NoSuchElementException; +import java.util.Objects; + +/** + * This iterator creates permutations of an input collection, using the + * lexicographical order. + *

+ * The iterator might return fewer than n! permutations of the input collection, + * because duplicated permutations are skipped: equal elements are not + * distinguished from one another. + * The {@code remove()} operation is not supported, and will throw an + * {@code UnsupportedOperationException}. + *

+ *

+ * NOTE: in case an empty collection is provided, the iterator will + * return exactly one empty list as result, as 0! = 1. + *

+ * + * @param the type of the objects being permuted + * @see PermutationIterator + * @since 4.6.0 + */ +public class LexicographicPermutationIterator implements Iterator> { + + /** + * The comparator used to define order of generation, + * or null if it uses the natural ordering. + */ + private final Comparator comparator; + + /** + * Next permutation to return. When a permutation is requested + * this instance is provided and the next one is computed. + */ + private List nextPermutation; + + /** + * Standard constructor for this class, using the natural ordering of the elements. + * + * @param collection The collection to generate permutations for + * @throws NullPointerException if collection is null + */ + public LexicographicPermutationIterator(final Collection collection) { + this(collection, null); + } + + /** + * Constructs an instance using the given comparator to order the elements. + * + * @param collection The collection to generate permutations for + * @param comparator The comparator used to define the order of generation, + * or null to use the natural ordering of the elements + * @throws NullPointerException if collection is null + */ + public LexicographicPermutationIterator(final Collection collection, final Comparator comparator) { + Objects.requireNonNull(collection, "collection"); + nextPermutation = new ArrayList<>(collection); + this.comparator = comparator; + } + + /** + * Indicates if there are more permutation available. + * + * @return true if there are more permutations, otherwise false + */ + @Override + public boolean hasNext() { + return nextPermutation != null; + } + + /** + * Returns the next permutation of the input collection. + * + * @return A list of the permutator's elements representing a permutation + * @throws NoSuchElementException if there are no more permutations + */ + @Override + public List next() { + if (!hasNext()) { + throw new NoSuchElementException(); + } + + final int size = nextPermutation.size(); + List nextP = null; + + // find the pivot: the rightmost element that is smaller than its successor. + // if there is none the current permutation is the last one in lexicographical order + int i = size - 2; + while (i >= 0 && compareElements(nextPermutation.get(i), nextPermutation.get(i + 1)) >= 0) { + --i; + } + + if (i >= 0) { + // find the rightmost element greater than the pivot; the tail is descending, + // so this is the pivot's successor in the remaining elements + int j = size - 1; + while (j >= i && compareElements(nextPermutation.get(i), nextPermutation.get(j)) >= 0) { + --j; + } + + // swap the pivot with its successor, then reverse the descending tail + // into ascending order to obtain the smallest larger permutation + nextP = new ArrayList<>(nextPermutation); + Collections.swap(nextP, i, j); + final List subList = nextP.subList(i + 1, nextP.size()); + Collections.reverse(subList); + } + + final List result = nextPermutation; + nextPermutation = nextP; + return result; + } + + /** + * Always throws {@link UnsupportedOperationException}. + * + * @throws UnsupportedOperationException Always thrown. + */ + @Override + public void remove() { + throw new UnsupportedOperationException("remove() is not supported"); + } + + /** + * Compares this iterator to another for equality. Two iterators are equal when + * they use equal comparators and are positioned at an equal next permutation. + * + * @param o The object to compare to this instance + * @return true if the given object is an equal iterator, otherwise false + */ + @Override + public boolean equals(final Object o) { + if (this == o) { + return true; + } + + if (o == null || getClass() != o.getClass()) { + return false; + } + + final LexicographicPermutationIterator that = (LexicographicPermutationIterator) o; + return Objects.equals(comparator, that.comparator) && Objects.equals(nextPermutation, that.nextPermutation); + } + + /** + * Returns a hash code consistent with {@link #equals(Object)}. Note that the + * hash code changes as the iterator advances. + * + * @return A hash code for this instance + */ + @Override + public int hashCode() { + return Objects.hash(comparator, nextPermutation); + } + + /** + * Compares two elements using the comparator, or their natural ordering if no + * comparator was supplied. + * + * @param e1 The first element to compare + * @param e2 The second element to compare + * @return a negative integer, zero, or a positive integer as the first element + * is less than, equal to, or greater than the second + * @throws ClassCastException if no comparator was supplied and the elements are + * not mutually {@link Comparable} + */ + @SuppressWarnings("unchecked") + private int compareElements(final E e1, final E e2) { + return comparator == null + ? ((Comparable) e1).compareTo(e2) + : comparator.compare(e1, e2); + } + +} diff --git a/src/test/java/org/apache/commons/collections4/iterators/LexicographicPermutationIteratorTest.java b/src/test/java/org/apache/commons/collections4/iterators/LexicographicPermutationIteratorTest.java new file mode 100644 index 0000000000..c20dd8a13b --- /dev/null +++ b/src/test/java/org/apache/commons/collections4/iterators/LexicographicPermutationIteratorTest.java @@ -0,0 +1,343 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.commons.collections4.iterators; + +import static java.util.Collections.emptyList; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Comparator; +import java.util.Iterator; +import java.util.List; +import java.util.NoSuchElementException; +import java.util.Objects; +import java.util.stream.Collectors; +import java.util.stream.StreamSupport; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * Test class for LexicographicPermutationIterator. + */ +class LexicographicPermutationIteratorTest extends AbstractIteratorTest> { + + /** + * A comparator that orders nothing, identified only by an id, used to check that + * equal comparators make equal iterators. + * + * @param the type of the objects compared + */ + private static final class CustomComparator implements Comparator { + + private final int id; + + CustomComparator(final int id) { + this.id = id; + } + + @Override + public int compare(final T o1, final T o2) { + return 0; + } + + @Override + public boolean equals(final Object o) { + if (this == o) { + return true; + } + + if (o == null || getClass() != o.getClass()) { + return false; + } + + final CustomComparator cmp = (CustomComparator) o; + return id == cmp.id; + } + + @Override + public int hashCode() { + return id; + } + } + + /** + * A value holder that deliberately does not implement {@link Comparable}, used to + * check that a supplied comparator is honored. + * + * @param the type of the wrapped value + */ + private static final class NonComparableObject { + + private final T value; + + NonComparableObject(final T value) { + this.value = value; + } + + @Override + public boolean equals(final Object o) { + if (this == o) { + return true; + } + + if (o == null || getClass() != o.getClass()) { + return false; + } + + final NonComparableObject that = (NonComparableObject) o; + return Objects.equals(value, that.value); + } + + T getValue() { + return value; + } + + @Override + public int hashCode() { + return Objects.hash(value); + } + } + + @SuppressWarnings("boxing") // OK in test code + protected Character[] testArray = { 'A', 'B', 'C' }; + + protected List testList; + + @Override + public LexicographicPermutationIterator makeEmptyIterator() { + return new LexicographicPermutationIterator<>(new ArrayList<>()); + } + + @Override + public LexicographicPermutationIterator makeObject() { + return new LexicographicPermutationIterator<>(testList); + } + + @BeforeEach + public void setUp() { + testList = new ArrayList<>(); + testList.addAll(Arrays.asList(testArray)); + } + + @Override + public boolean supportsEmptyIterator() { + return false; + } + + @Override + public boolean supportsRemove() { + return false; + } + + @Test + void testCustomComparator() { + final Iterator> permutationIterator = new LexicographicPermutationIterator<>(Arrays.asList('C', 'B', 'A'), + Comparator.reverseOrder()); + + assertTrue(permutationIterator.hasNext()); + assertEquals(Arrays.asList('C', 'B', 'A'), permutationIterator.next()); + + assertTrue(permutationIterator.hasNext()); + assertEquals(Arrays.asList('C', 'A', 'B'), permutationIterator.next()); + + assertTrue(permutationIterator.hasNext()); + assertEquals(Arrays.asList('B', 'C', 'A'), permutationIterator.next()); + + assertTrue(permutationIterator.hasNext()); + assertEquals(Arrays.asList('B', 'A', 'C'), permutationIterator.next()); + + assertTrue(permutationIterator.hasNext()); + assertEquals(Arrays.asList('A', 'C', 'B'), permutationIterator.next()); + + assertTrue(permutationIterator.hasNext()); + assertEquals(Arrays.asList('A', 'B', 'C'), permutationIterator.next()); + + assertFalse(permutationIterator.hasNext()); + } + + @Test + void testCustomComparatorWithNonComparableObjects() { + final Iterator>> permutationIterator = + new LexicographicPermutationIterator<>(Arrays.asList( + new NonComparableObject<>('A'), + new NonComparableObject<>('B')), Comparator.comparing(NonComparableObject::getValue)); + + assertTrue(permutationIterator.hasNext()); + assertEquals(Arrays.asList( + new NonComparableObject<>('A'), + new NonComparableObject<>('B')), permutationIterator.next()); + + assertTrue(permutationIterator.hasNext()); + assertEquals(Arrays.asList( + new NonComparableObject<>('B'), + new NonComparableObject<>('A')), permutationIterator.next()); + + assertFalse(permutationIterator.hasNext()); + } + + @Test + void testDuplicatedPermutationsAreSkipped() { + final Iterator> permutationIterator = new LexicographicPermutationIterator<>(Arrays.asList('A', 'A', 'B', 'B')); + + assertTrue(permutationIterator.hasNext()); + assertEquals(Arrays.asList('A', 'A', 'B', 'B'), permutationIterator.next()); + + assertTrue(permutationIterator.hasNext()); + assertEquals(Arrays.asList('A', 'B', 'A', 'B'), permutationIterator.next()); + + assertTrue(permutationIterator.hasNext()); + assertEquals(Arrays.asList('A', 'B', 'B', 'A'), permutationIterator.next()); + + assertTrue(permutationIterator.hasNext()); + assertEquals(Arrays.asList('B', 'A', 'A', 'B'), permutationIterator.next()); + + assertTrue(permutationIterator.hasNext()); + assertEquals(Arrays.asList('B', 'A', 'B', 'A'), permutationIterator.next()); + + assertTrue(permutationIterator.hasNext()); + assertEquals(Arrays.asList('B', 'B', 'A', 'A'), permutationIterator.next()); + + assertFalse(permutationIterator.hasNext()); + } + + @Test + void testEmptyCollection() { + final Iterator> permutationIterator = makeEmptyIterator(); + + // there is one permutation for an empty set: 0! = 1 + assertTrue(permutationIterator.hasNext()); + assertTrue(permutationIterator.next().isEmpty()); + + assertFalse(permutationIterator.hasNext()); + } + + @Test + void testEqualsForEqualCollections() { + final Iterator> one = new LexicographicPermutationIterator<>(emptyList()); + final Iterator> another = new LexicographicPermutationIterator<>(emptyList()); + + assertEquals(one, another); + } + + @Test + void testEqualsForEqualCollectionsAndComparators() { + final Iterator> one = new LexicographicPermutationIterator<>(emptyList(), new CustomComparator<>(42)); + final Iterator> another = new LexicographicPermutationIterator<>(emptyList(), new CustomComparator<>(42)); + + assertEquals(one, another); + } + + @Test + void testHashCodeForEqualCollections() { + final Iterator> one = new LexicographicPermutationIterator<>(emptyList()); + final Iterator> another = new LexicographicPermutationIterator<>(emptyList()); + + assertEquals(one.hashCode(), another.hashCode()); + } + + @Test + void testHashCodeForEqualCollectionsAndComparators() { + final Iterator> one = new LexicographicPermutationIterator<>(emptyList(), new CustomComparator<>(42)); + final Iterator> another = new LexicographicPermutationIterator<>(emptyList(), new CustomComparator<>(42)); + + assertEquals(one.hashCode(), another.hashCode()); + } + + @Test + void testNonComparableElementsThrow() { + final Iterator>> permutationIterator = new LexicographicPermutationIterator<>( + Arrays.asList( + new NonComparableObject<>('A'), + new NonComparableObject<>('B'))); + + assertTrue(permutationIterator.hasNext()); + assertThrows(ClassCastException.class, permutationIterator::next); + } + + @Test + void testPermutationException() { + final Iterator> permutationIterator = new LexicographicPermutationIterator<>(Arrays.asList('A', 'B')); + + assertTrue(permutationIterator.hasNext()); + assertEquals(Arrays.asList('A', 'B'), permutationIterator.next()); + + assertTrue(permutationIterator.hasNext()); + assertEquals(Arrays.asList('B', 'A'), permutationIterator.next()); + + // asking for another permutation should throw an exception + assertFalse(permutationIterator.hasNext()); + assertThrows(NoSuchElementException.class, permutationIterator::next); + } + + /** + * test checking that all the permutations are returned in lexicographical order + */ + @Test + void testPermutationExhaustivity() { + final Iterator> permutationIterator = makeObject(); + + assertTrue(permutationIterator.hasNext()); + assertEquals(Arrays.asList('A', 'B', 'C'), permutationIterator.next()); + + assertTrue(permutationIterator.hasNext()); + assertEquals(Arrays.asList('A', 'C', 'B'), permutationIterator.next()); + + assertTrue(permutationIterator.hasNext()); + assertEquals(Arrays.asList('B', 'A', 'C'), permutationIterator.next()); + + assertTrue(permutationIterator.hasNext()); + assertEquals(Arrays.asList('B', 'C', 'A'), permutationIterator.next()); + + assertTrue(permutationIterator.hasNext()); + assertEquals(Arrays.asList('C', 'A', 'B'), permutationIterator.next()); + + assertTrue(permutationIterator.hasNext()); + assertEquals(Arrays.asList('C', 'B', 'A'), permutationIterator.next()); + + assertFalse(permutationIterator.hasNext()); + } + + @Test + void testRemoveThrows() { + final Iterator> permutationIterator = makeObject(); + + assertTrue(permutationIterator.hasNext()); + assertThrows(UnsupportedOperationException.class, permutationIterator::remove); + } + + @Test + void testStreamOfPermutations() { + final Iterable> iterable = this::makeObject; + + final List> allPermutations = StreamSupport.stream(iterable.spliterator(), false) + .collect(Collectors.toList()); + + assertEquals(Arrays.asList( + Arrays.asList('A', 'B', 'C'), + Arrays.asList('A', 'C', 'B'), + Arrays.asList('B', 'A', 'C'), + Arrays.asList('B', 'C', 'A'), + Arrays.asList('C', 'A', 'B'), + Arrays.asList('C', 'B', 'A')), allPermutations); + } + +}