Repository: classickit (difficulty: medium)

Issue:
next_permutation gets stuck on inputs with repeated values. Observed: next_permutation([1, 2, 2]) returns [1, 2, 2], the unchanged input, but per the docstring the successor is [2, 1, 2]. next_permutation([2, 1, 1]) returns [2, 1, 1] instead of wrapping around to [1, 1, 2] (it is the last permutation of that multiset). next_permutation([2, 3, 1, 1]) returns [2, 3, 1, 1] instead of [3, 1, 1, 2]. Because the function can hand back its own input, driver code that repeatedly applies it to enumerate all distinct permutations of a multiset in increasing lexicographic order never terminates and never reaches some permutations. Distinct-element inputs are fine: next_permutation([1, 2, 3]) gives [1, 3, 2] and next_permutation([3, 2, 1]) wraps to [1, 2, 3]. The function must keep returning a new list without modifying its argument.

Source files:
core.py: """Lexicographic permutation stepping.

next_permutation(seq) returns the permutation of the list seq that follows
seq in lexicographic order, as a new list; seq itself is not modified.
If seq is the last permutation (sorted in descending order), the first
permutation (sorted ascending) is returned, so repeated application walks
every distinct permutation exactly once, in increasing lexicographic
order, before wrapping around to the start. Duplicate elements must be
handled: the distinct permutations of [1, 2, 2] in order are [1, 2, 2],
[2, 1, 2], [2, 2, 1], so next_permutation([1, 2, 2]) is [2, 1, 2] and
next_permutation([2, 2, 1]) wraps to [1, 2, 2].
"""


def next_permutation(seq):
    """Return the lexicographically next permutation of seq as a new list."""
    perm = list(seq)
    n = len(perm)
    i = n - 2
    while i >= 0 and perm[i] > perm[i + 1]:
        i -= 1
    if i < 0:
        perm.reverse()
        return perm
    j = n - 1
    while j > i and perm[j] <= perm[i]:
        j -= 1
    perm[i], perm[j] = perm[j], perm[i]
    perm[i + 1:] = reversed(perm[i + 1:])
    return perm


Test files:
test_core.py: import itertools
import random

from core import next_permutation


def brute_next(seq):
    """Brute reference: successor in the sorted list of distinct permutations."""
    perms = sorted(set(itertools.permutations(seq)))
    idx = perms.index(tuple(seq))
    return list(perms[(idx + 1) % len(perms)])


def test_pinned_duplicates():
    assert next_permutation([1, 2, 2]) == [2, 1, 2]
    assert next_permutation([2, 1, 1]) == [1, 1, 2]
    assert next_permutation([2, 3, 1, 1]) == [3, 1, 1, 2]
    assert next_permutation([1, 2, 3]) == [1, 3, 2]
    assert next_permutation([3, 2, 1]) == [1, 2, 3]
    original = [1, 2, 2]
    next_permutation(original)
    assert original == [1, 2, 2]


def test_full_cycle_enumerates_all_distinct_permutations():
    for multiset in ([1, 1, 2, 3], [1, 2, 2], [2, 1, 1]):
        start = sorted(multiset)
        expected = sorted(set(itertools.permutations(start)))
        cur = list(start)
        seen = [tuple(cur)]
        for _ in range(len(expected) - 1):
            cur = next_permutation(cur)
            seen.append(tuple(cur))
        assert len(set(seen)) == len(expected)
        assert seen == expected
        assert next_permutation(cur) == start


def test_random_spot_checks_vs_brute():
    rng = random.Random(20220)
    for _ in range(200):
        n = rng.randint(1, 6)
        seq = [rng.randint(1, 3) for _ in range(n)]
        assert next_permutation(seq) == brute_next(seq)


Patch:
--- a/core.py
+++ b/core.py
@@ -17,7 +17,7 @@
     perm = list(seq)
     n = len(perm)
     i = n - 2
-    while i >= 0 and perm[i] > perm[i + 1]:
+    while i >= 0 and perm[i] >= perm[i + 1]:
         i -= 1
     if i < 0:
         perm.reverse()