diff --git a/CHANGELOG.md b/CHANGELOG.md index 50ecb90..bc11720 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,22 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ## Unreleased +### Added +- `permuta.permutils.robinson_schensted`, whose functions `permuta.permutils` + exports: + - `rsk` and `rsk_inverse`, the Robinson-Schensted correspondence on words of + distinct letters; + - `evacuation`, Schützenberger's evacuation of standard Young tableaux, and + `evacuate_recording_tableau`, the word with the same insertion tableau and + the evacuated recording tableau; + - `is_knuth_closed` and `is_shape_closed`, whether the permutations avoiding + some patterns are closed under Knuth equivalence, or determined by their RSK + shape. + +### Changed +- The BiSC properties `yt_perm_avoids_22` and `yt_perm_avoids_32` take their + insertion tableau from `rsk`. Their answers are unchanged, and they are faster. + ### Fixed - Fixed bug in `Perm.rtlmax_ltrmin_decomposition` and fixed associated tests. diff --git a/permuta/bisc/perm_properties.py b/permuta/bisc/perm_properties.py index 733ca2e..ecaba56 100644 --- a/permuta/bisc/perm_properties.py +++ b/permuta/bisc/perm_properties.py @@ -1,9 +1,9 @@ -from itertools import islice from typing import List from permuta.patterns.meshpatt import MeshPatt from permuta.patterns.perm import Perm from permuta.permutils.groups import dihedral_group +from permuta.permutils.robinson_schensted import rsk _SMOOTH_PATT = (Perm((0, 2, 1, 3)), Perm((1, 0, 3, 2))) @@ -57,28 +57,6 @@ def in_alternating_group(perm: Perm) -> bool: return perm.count_inversions() % 2 == 0 -def _perm_to_yt(perm: Perm) -> List[List[int]]: - # transform perm to standard young table - - def insert_in_row(i, k): - cur_row = res[i] - found = next(((ind, cur) for ind, cur in enumerate(cur_row) if cur > k), None) - if not found: - cur_row.append(k) - else: - ind, cur = found - cur_row[ind] = k - if len(res) <= i + 1: - res.append([cur]) - else: - insert_in_row(i + 1, cur) - - res: List[List[int]] = [[perm[0]]] if perm else [] - for val in islice(perm, 1, None): - insert_in_row(0, val) - return res - - def _tableau_contains_shape(tab: List[List[int]], shape: List[int]) -> bool: # Return True if the tableaux tab contains the shape return len(tab) >= len(shape) and all(s <= t for s, t in zip(shape, map(len, tab))) @@ -86,12 +64,14 @@ def _tableau_contains_shape(tab: List[List[int]], shape: List[int]) -> bool: def yt_perm_avoids_22(perm: Perm) -> bool: """Returns true if perm's standard young table avoids shape [2,2].""" - return not _tableau_contains_shape(_perm_to_yt(perm), [2, 2]) + insertion_tableau, _ = rsk(perm) + return not _tableau_contains_shape(insertion_tableau, [2, 2]) def yt_perm_avoids_32(perm: Perm) -> bool: """Returns true if perm's standard young table avoids shape [3,2].""" - return not _tableau_contains_shape(_perm_to_yt(perm), [3, 2]) + insertion_tableau, _ = rsk(perm) + return not _tableau_contains_shape(insertion_tableau, [3, 2]) _AV_231_AND_MESH_PATT = ( diff --git a/permuta/permutils/__init__.py b/permuta/permutils/__init__.py index e0e4b41..d945bc6 100644 --- a/permuta/permutils/__init__.py +++ b/permuta/permutils/__init__.py @@ -2,6 +2,14 @@ from .groups import dihedral_group from .insertion_encodable import InsertionEncodablePerms from .polynomial import PolyPerms +from .robinson_schensted import ( + evacuate_recording_tableau, + evacuation, + is_knuth_closed, + is_shape_closed, + rsk, + rsk_inverse, +) from .symmetry import ( all_symmetry_sets, antidiagonal_set, @@ -41,4 +49,10 @@ "rotate_90_clockwise_set", "rotate_180_clockwise_set", "rotate_270_clockwise_set", + "evacuate_recording_tableau", + "evacuation", + "is_knuth_closed", + "is_shape_closed", + "rsk", + "rsk_inverse", ] diff --git a/permuta/permutils/robinson_schensted.py b/permuta/permutils/robinson_schensted.py new file mode 100644 index 0000000..5a96c89 --- /dev/null +++ b/permuta/permutils/robinson_schensted.py @@ -0,0 +1,208 @@ +""" +The Robinson-Schensted correspondence (RSK) on words of distinct letters, +Schützenberger's evacuation of standard Young tableaux, and tests of whether the +permutations avoiding some patterns are closed under Knuth equivalence, or are +determined by their RSK shape. + +A word here is a sequence of distinct integers, of which only the order matters. A +word with a repeated letter, or a tableau that is not standard where a standard one +is asked for, is not checked for, and gives no meaningful answer. +""" + +from bisect import bisect_left +from functools import lru_cache +from typing import FrozenSet, Iterable, Iterator, List, Sequence, Tuple + +from permuta.patterns.perm import Perm + +Tableau = List[List[int]] + + +def rsk(word: Sequence[int]) -> Tuple[Tableau, Tableau]: + """Return the insertion tableau P and the recording tableau Q of a word of + distinct letters, by row insertion. Q holds the step, 1, 2, ..., at which each + cell was added, and both have the same shape, the word's RSK shape. + + Examples: + >>> rsk((1, 3, 0, 2)) + ([[0, 2], [1, 3]], [[1, 2], [3, 4]]) + >>> rsk((7, 2, 9, 4)) + ([[2, 4], [7, 9]], [[1, 3], [2, 4]]) + """ + insertion_tableau: Tableau = [] + recording_tableau: Tableau = [] + for step, letter in enumerate(word, 1): + # insert the letter into the first row, and each letter it bumps into the next + for insertion_row, recording_row in zip(insertion_tableau, recording_tableau): + position = bisect_left(insertion_row, letter) + if position == len(insertion_row): + insertion_row.append(letter) + recording_row.append(step) + break + letter, insertion_row[position] = insertion_row[position], letter + else: + # bumped out of the last row: a new row below it + insertion_tableau.append([letter]) + recording_tableau.append([step]) + return insertion_tableau, recording_tableau + + +def rsk_inverse( + insertion_tableau: Sequence[Sequence[int]], + recording_tableau: Sequence[Sequence[int]], +) -> List[int]: + """Return the word whose insertion and recording tableaux are the given ones, as + rsk gives them: tableaux of the same shape, the recording tableau standard, of + the steps 1 to n. Raise ValueError if their shapes differ; whether the recording + tableau is standard is not checked. The tableaux given are not changed. + + Examples: + >>> rsk_inverse([[0, 2], [1, 3]], [[1, 2], [3, 4]]) + [1, 3, 0, 2] + """ + insertion_rows = [list(row) for row in insertion_tableau] + recording_rows = [list(row) for row in recording_tableau] + if [len(row) for row in insertion_rows] != [len(row) for row in recording_rows]: + raise ValueError("The insertion and recording tableaux have different shapes.") + row_of = { + step: row_index for row_index, row in enumerate(recording_rows) for step in row + } + word = [0] * len(row_of) + for step in range(len(word), 0, -1): + # undo the step: take out the cell it added, and bump its letter back up + row_index = row_of[step] + recording_rows[row_index].pop() + letter = insertion_rows[row_index].pop() + for upper in range(row_index - 1, -1, -1): + # the letter bumped out of this row was the largest one smaller than it + insertion_row = insertion_rows[upper] + position = bisect_left(insertion_row, letter) - 1 + letter, insertion_row[position] = insertion_row[position], letter + word[step - 1] = letter + return word + + +def evacuation(tableau: Sequence[Sequence[int]]) -> Tableau: + """Return Schützenberger's evacuation of a standard Young tableau of the entries + 1 to n: delete the smallest entry and slide the hole out by jeu de taquin, n + times, writing n, n - 1, ... in the cells vacated. It is an involution that + keeps the shape, and the recording tableau of a permutation's reverse-complement + is the evacuation of the permutation's. Whether the tableau is standard is not + checked, and the tableau given is not changed. + + Examples: + >>> evacuation([[1, 2], [3]]) + [[1, 3], [2]] + """ + remaining = [list(row) for row in tableau] + evacuated: Tableau = [[0] * len(row) for row in remaining] + size = sum(len(row) for row in remaining) + for label in range(size, 0, -1): + # the smallest entry is in the corner: slide its hole out of the tableau, + # each time into the smaller of the entries to its right and below it + row, column = 0, 0 + while True: + right_entry = ( + remaining[row][column + 1] if column + 1 < len(remaining[row]) else None + ) + entry_below = ( + remaining[row + 1][column] + if row + 1 < len(remaining) and column < len(remaining[row + 1]) + else None + ) + if right_entry is not None and ( + entry_below is None or right_entry < entry_below + ): + remaining[row][column] = right_entry + column += 1 + elif entry_below is not None: + remaining[row][column] = entry_below + row += 1 + else: + break + remaining[row].pop() + if not remaining[row]: + remaining.pop() + evacuated[row][column] = label + return evacuated + + +def evacuate_recording_tableau(word: Sequence[int]) -> List[int]: + """Return the word whose insertion tableau is the word's, and whose recording + tableau is the evacuation of the word's: RSK^-1(P(w), evac(Q(w))) for the word + w, with P and Q the tableaux rsk gives. The word returned is Knuth equivalent to + w, as its insertion tableau is w's. For w of m letters, its letters in positions + m + 1 - j to m + 1 - i have the RSK shape of w's letters in positions i to j. + The map is an involution. + + Examples: + >>> evacuate_recording_tableau((1, 3, 0, 2)) + [1, 3, 0, 2] + >>> evacuate_recording_tableau((0, 2, 1)) + [2, 0, 1] + """ + insertion_tableau, recording_tableau = rsk(word) + return rsk_inverse(insertion_tableau, evacuation(recording_tableau)) + + +def _knuth_neighbors(perm: Perm) -> Iterator[Perm]: + """Yield the permutations one elementary Knuth move from the permutation: + y z x <-> y x z and x z y <-> z x y, for x < y < z.""" + word = list(perm) + for index in range(len(word) - 2): + first, second, third = word[index], word[index + 1], word[index + 2] + if min(second, third) < first < max(second, third): + yield Perm(word[: index + 1] + [third, second] + word[index + 3 :]) + if min(first, second) < third < max(first, second): + yield Perm(word[:index] + [second, first] + word[index + 2 :]) + + +def is_knuth_closed(basis: Iterable[Perm]) -> bool: + """Return True if the permutations avoiding all the patterns are closed under + Knuth equivalence. It is decided locally: an occurrence that a Knuth move + destroys uses both letters it exchanges, and with the letter that allows the + move it lies in a permutation at most one entry longer than the pattern. The + basis is an iterable of patterns, such as a list, not a single Perm. The answers + are cached by set of patterns. + + Examples: + >>> is_knuth_closed([Perm((0, 2, 1))]) + False + >>> is_knuth_closed([Perm((0, 2, 1)), Perm((2, 0, 1))]) + True + """ + return _is_knuth_closed(frozenset(basis)) + + +# Tilings' Knuth slide asks about the patterns of many tilings in a search, so the +# cache keeps up to 65,536 sets of patterns. +@lru_cache(maxsize=1 << 16) +def _is_knuth_closed(patterns: FrozenSet[Perm]) -> bool: + # a permutation one Knuth move from a pattern, or from one of its one-point + # extensions, that avoids every pattern shows that the avoiders are not closed + return not any( + neighbor.avoids(*patterns) + for pattern in patterns + for container in (pattern, *pattern.coveredby()) + for neighbor in _knuth_neighbors(container) + ) + + +def is_shape_closed(basis: Iterable[Perm]) -> bool: + """Return True if whether a permutation avoids all the patterns depends only on + its RSK shape. It does exactly when the permutations avoiding the patterns, and + those avoiding their inverses, are both closed under Knuth equivalence + (is_knuth_closed of the patterns and of their inverses), as for monotone + patterns. The basis is an iterable of patterns, such as a list, not a single + Perm. + + Examples: + >>> is_shape_closed([Perm((2, 1, 0))]) + True + >>> is_shape_closed([Perm((0, 2, 1)), Perm((2, 0, 1))]) + False + """ + patterns = frozenset(basis) + return _is_knuth_closed(patterns) and _is_knuth_closed( + frozenset(pattern.inverse() for pattern in patterns) + ) diff --git a/tests/bisc/test_perm_properties.py b/tests/bisc/test_perm_properties.py index be18d12..afa8487 100644 --- a/tests/bisc/test_perm_properties.py +++ b/tests/bisc/test_perm_properties.py @@ -1,6 +1,7 @@ +import pytest + from permuta import Perm from permuta.bisc.perm_properties import ( - _perm_to_yt, av_231_and_mesh, baxter, dihedral, @@ -12,6 +13,7 @@ yt_perm_avoids_22, yt_perm_avoids_32, ) +from permuta.permutils.robinson_schensted import rsk def test_smooth(): @@ -153,26 +155,6 @@ def test_in_alternating_group(): assert 2520 == sum(1 for p in Perm.of_length(7) if in_alternating_group(p)) -def test__perm_to_yt(): - z = {i: set() for i in range(8)} - for i in range(8): - for p in Perm.of_length(i): - z[i].add(tuple(map(tuple, _perm_to_yt(p)))) - expected = [1, 1, 2, 4, 10, 26, 76, 232] - for i in range(8): - assert len(z[i]) == expected[i] - assert _perm_to_yt(Perm((1, 0))) == [[0], [1]] - assert _perm_to_yt(Perm((1, 0, 3, 5, 2, 4))) == [[0, 2, 4], [1, 3, 5]] - assert _perm_to_yt(Perm((1, 0, 2, 3))) == [[0, 2, 3], [1]] - assert _perm_to_yt(Perm((3, 1, 4, 0, 2))) == [[0, 2], [1, 4], [3]] - assert _perm_to_yt(Perm((5, 4, 1, 0, 3, 2))) == [[0, 2], [1, 3], [4], [5]] - assert _perm_to_yt(Perm((0, 1))) == [[0, 1]] - assert _perm_to_yt(Perm((0,))) == [[0]] - assert _perm_to_yt(Perm((2, 3, 0, 1))) == [[0, 1], [2, 3]] - assert _perm_to_yt(Perm((1, 2, 3, 0))) == [[0, 2, 3], [1]] - assert _perm_to_yt(Perm((4, 0, 1, 2, 5, 3))) == [[0, 1, 2, 3], [4, 5]] - - def test_yt_perm_avoids_22(): assert yt_perm_avoids_22(Perm()) assert yt_perm_avoids_22(Perm((0,))) @@ -297,3 +279,32 @@ def test_hard_mesh(): assert not hard_mesh(Perm((7, 0, 5, 4, 6, 3, 2, 9, 1, 8))) assert not hard_mesh(Perm((2, 6, 9, 1, 8, 0, 10, 5, 7, 3, 4))) assert 692 == sum(1 for p in Perm.of_length(7) if hard_mesh(p)) + + +def first_two_rows_by_greene(word): + """The first two rows of the RSK shape of a word of distinct letters, by Greene's + theorem and not by rsk: the first row is the length of the longest increasing + subsequence, and the first two together are the most letters in two increasing + subsequences.""" + # the most letters of two increasing subsequences that end in the two letters + best = {(-1, -1): 0} + for letter in word: + for (first, second), count in list(best.items()): + if letter > first: + best[letter, second] = max(best.get((letter, second), 0), count + 1) + if letter > second: + best[first, letter] = max(best.get((first, letter), 0), count + 1) + longest_increasing = max(count for (_, second), count in best.items() if second < 0) + union_of_two = max(best.values()) + return longest_increasing, union_of_two - longest_increasing + + +@pytest.mark.parametrize("size", range(8)) +def test_the_bisc_properties_of_the_shape_against_greene(size): + for perm in Perm.of_length(size): + first_row, second_row = first_two_rows_by_greene(perm) + insertion_tableau, _ = rsk(perm) + rows = [len(row) for row in insertion_tableau] + [0, 0] + assert (first_row, second_row) == (rows[0], rows[1]) + assert yt_perm_avoids_22(perm) == (second_row < 2) + assert yt_perm_avoids_32(perm) == (first_row < 3 or second_row < 2) diff --git a/tests/permutils/test_function_imports.py b/tests/permutils/test_function_imports.py index 01d1f6a..12c6cbe 100644 --- a/tests/permutils/test_function_imports.py +++ b/tests/permutils/test_function_imports.py @@ -1,10 +1,16 @@ from permuta import Perm from permuta.permutils import ( + evacuate_recording_tableau, + evacuation, is_insertion_encodable, is_insertion_encodable_maximum, is_insertion_encodable_rightmost, + is_knuth_closed, is_non_polynomial, is_polynomial, + is_shape_closed, + rsk, + rsk_inverse, ) @@ -92,3 +98,13 @@ def test_functions_insertion_encodable(): Perm((3, 1, 4, 2, 0)), ] ) + + +def test_functions_rsk(): + word = (2, 0, 1) + insertion_tableau, recording_tableau = rsk(word) + assert rsk_inverse(insertion_tableau, recording_tableau) == list(word) + assert evacuation(evacuation(recording_tableau)) == recording_tableau + assert evacuate_recording_tableau(evacuate_recording_tableau(word)) == list(word) + assert is_knuth_closed([Perm((0, 2, 1)), Perm((2, 0, 1))]) + assert not is_shape_closed([Perm((0, 2, 1)), Perm((2, 0, 1))]) diff --git a/tests/permutils/test_robinson_schensted.py b/tests/permutils/test_robinson_schensted.py new file mode 100644 index 0000000..ad7e4f5 --- /dev/null +++ b/tests/permutils/test_robinson_schensted.py @@ -0,0 +1,450 @@ +import random +from collections import defaultdict +from itertools import combinations, permutations + +import pytest + +from permuta import Perm +from permuta.permutils.robinson_schensted import ( + evacuate_recording_tableau, + evacuation, + is_knuth_closed, + is_shape_closed, + rsk, + rsk_inverse, +) + + +def shape(word): + return tuple(len(row) for row in rsk(word)[0]) + + +def perms(size): + return (list(perm) for perm in permutations(range(size))) + + +@pytest.mark.parametrize("size", range(8)) +def test_rsk_and_its_inverse(size): + for perm in perms(size): + insertion_tableau, recording_tableau = rsk(perm) + assert [len(row) for row in insertion_tableau] == [ + len(row) for row in recording_tableau + ] + assert rsk_inverse(insertion_tableau, recording_tableau) == perm + + +def test_rsk_of_words_with_gaps(): + word = [7, 2, 9, 4] + insertion_tableau, recording_tableau = rsk(word) + assert insertion_tableau == [[2, 4], [7, 9]] + assert recording_tableau == [[1, 3], [2, 4]] + assert rsk_inverse(insertion_tableau, recording_tableau) == word + + +@pytest.mark.parametrize("size", range(8)) +def test_evacuation_is_a_shape_preserving_involution(size): + for perm in perms(size): + recording_tableau = rsk(perm)[1] + evacuated = evacuation(recording_tableau) + assert [len(row) for row in evacuated] == [ + len(row) for row in recording_tableau + ] + assert evacuation(evacuated) == recording_tableau + + +@pytest.mark.parametrize("size", range(8)) +def test_evacuation_records_the_reverse_complement(size): + """Schutzenberger: Q(w^#) = evac(Q(w)) for the reverse-complement w^#.""" + for perm in perms(size): + reverse_complement = [size - 1 - value for value in reversed(perm)] + assert rsk(reverse_complement)[1] == evacuation(rsk(perm)[1]) + + +@pytest.mark.parametrize("size", range(8)) +def test_evacuating_the_recording_tableau(size): + """It keeps the insertion tableau, gives every factor the shape of the mirrored + factor of the word, and is an involution.""" + for perm in perms(size): + image = evacuate_recording_tableau(perm) + assert rsk(image)[0] == rsk(perm)[0] + assert evacuate_recording_tableau(image) == perm + for i in range(size): + for j in range(i, size): + assert shape(image[size - 1 - j : size - i]) == shape(perm[i : j + 1]) + + +def test_the_knuth_class_is_kept_on_every_window_of_values(): + for perm in perms(6): + image = evacuate_recording_tableau(perm) + for low in range(6): + for high in range(low, 6): + window = [value for value in perm if low <= value <= high] + image_window = [value for value in image if low <= value <= high] + assert rsk(window)[0] == rsk(image_window)[0] + + +def brute_force_knuth_closed(patterns, length): + for size in range(length + 1): + classes = defaultdict(list) + for perm in permutations(range(size)): + classes[tuple(map(tuple, rsk(perm)[0]))].append(Perm(perm)) + for knuth_class in classes.values(): + if len({perm.avoids(*patterns) for perm in knuth_class}) > 1: + return False + return True + + +def test_knuth_closure_against_brute_force(): + """Any failure of closure shows on permutations at most one entry longer than + the longest pattern, so the brute force to that length decides it.""" + perms_of_length_3 = [Perm(perm) for perm in permutations(range(3))] + perms_of_length_4 = [Perm(perm) for perm in permutations(range(4))] + generator = random.Random(2026) + bases = [ + list(chosen) + for size in range(1, 7) + for chosen in combinations(perms_of_length_3, size) + ] + bases += [ + generator.sample(perms_of_length_4, generator.randint(1, 6)) for _ in range(40) + ] + bases += [ + generator.sample(perms_of_length_3, 1) + + generator.sample(perms_of_length_4, generator.randint(1, 3)) + for _ in range(20) + ] + for basis in bases: + length = max(len(pattern) for pattern in basis) + 1 + assert is_knuth_closed(frozenset(basis)) == brute_force_knuth_closed( + basis, length + ), basis + + +def test_shape_closure(): + def basis(*patterns): + return frozenset(Perm.to_standard(pattern) for pattern in patterns) + + for size in range(1, 6): + assert is_shape_closed(frozenset([Perm(range(size))])) + assert is_shape_closed(frozenset([Perm(range(size - 1, -1, -1))])) + shape_31 = frozenset( + Perm(perm) for perm in permutations(range(4)) if shape(list(perm)) == (3, 1) + ) + assert len(shape_31) == 9 and is_shape_closed(shape_31) + # the Knuth class of 1243: Knuth closed, but not determined by the shape + stable_knuth_class = basis("1243", "1423", "4123") + assert is_knuth_closed(stable_knuth_class) and not is_shape_closed( + stable_knuth_class + ) + assert not is_knuth_closed(basis("132")) + assert is_knuth_closed(basis("132", "312")) + assert not is_shape_closed(basis("132", "312")) + assert is_knuth_closed(frozenset()) and is_shape_closed(frozenset()) + + +def knuth_class_moves(word): + """The words one elementary Knuth move from word, as Knuth stated them: for + x < y < z, y z x <-> y x z and x z y <-> z x y. Written from that statement, not + from the module under test.""" + for i in range(len(word) - 2): + triple = tuple(word[i : i + 3]) + low, middle, high = sorted(triple) + for one, other in [ + ((middle, high, low), (middle, low, high)), + ((low, high, middle), (high, low, middle)), + ]: + for before, after in [(one, other), (other, one)]: + if triple == before: + yield tuple(word[:i]) + after + tuple(word[i + 3 :]) + + +@pytest.mark.parametrize("size", range(8)) +def test_the_insertion_tableau_identifies_the_knuth_classes(size): + """Knuth's theorem: two permutations are Knuth equivalent exactly when their + insertion tableaux are equal. The classes are found here by the moves alone, and + compared with the fibers of rsk's first tableau.""" + by_tableau = defaultdict(set) + for perm in permutations(range(size)): + by_tableau[tuple(map(tuple, rsk(perm)[0]))].add(perm) + seen = set() + for start in permutations(range(size)): + if start in seen: + continue + component, frontier = {start}, [start] + while frontier: + for neighbor in knuth_class_moves(frontier.pop()): + if neighbor not in component: + component.add(neighbor) + frontier.append(neighbor) + seen |= component + assert component == by_tableau[tuple(map(tuple, rsk(start)[0]))] + + +def test_knuth_closure_of_unions_of_knuth_classes(): + """Most sets of patterns are not closed, so the brute force above mostly agrees on + the answer False for patterns of four letters. Unions of Knuth classes are often + closed: the answers True are compared too.""" + classes = {} + for size in (3, 4): + by_tableau = defaultdict(list) + for perm in permutations(range(size)): + by_tableau[tuple(map(tuple, rsk(perm)[0]))].append(Perm(perm)) + classes[size] = list(by_tableau.values()) + generator = random.Random(2027) + closed = 0 + for _ in range(60): + basis = [ + perm + for size in (3, 4) + for knuth_class in generator.sample(classes[size], generator.randint(0, 2)) + for perm in knuth_class + ] + if not basis: + continue + length = max(len(pattern) for pattern in basis) + 1 + expected = brute_force_knuth_closed(basis, length) + assert is_knuth_closed(frozenset(basis)) == expected, basis + closed += expected + assert closed >= 10 + + +def test_the_insertion_tableaux(): + """The insertion tableaux of the permutations of each length are the standard + Young tableaux of that size, as many as the involutions; and some examples.""" + tableaux = {length: set() for length in range(8)} + for length in range(8): + for perm in Perm.of_length(length): + tableaux[length].add(tuple(map(tuple, rsk(perm)[0]))) + expected = [1, 1, 2, 4, 10, 26, 76, 232] + for length in range(8): + assert len(tableaux[length]) == expected[length] + assert rsk(Perm((1, 0)))[0] == [[0], [1]] + assert rsk(Perm((1, 0, 3, 5, 2, 4)))[0] == [[0, 2, 4], [1, 3, 5]] + assert rsk(Perm((1, 0, 2, 3)))[0] == [[0, 2, 3], [1]] + assert rsk(Perm((3, 1, 4, 0, 2)))[0] == [[0, 2], [1, 4], [3]] + assert rsk(Perm((5, 4, 1, 0, 3, 2)))[0] == [[0, 2], [1, 3], [4], [5]] + assert rsk(Perm((0, 1)))[0] == [[0, 1]] + assert rsk(Perm((0,)))[0] == [[0]] + assert rsk(Perm((2, 3, 0, 1)))[0] == [[0, 1], [2, 3]] + assert rsk(Perm((1, 2, 3, 0)))[0] == [[0, 2, 3], [1]] + assert rsk(Perm((4, 0, 1, 2, 5, 3)))[0] == [[0, 1, 2, 3], [4, 5]] + + +def test_the_empty_word_and_a_single_letter(): + assert rsk([]) == ([], []) + assert rsk([5]) == ([[5]], [[1]]) + assert rsk_inverse([], []) == [] + assert rsk_inverse([[5]], [[1]]) == [5] + assert evacuation([]) == [] + assert evacuation([[1]]) == [[1]] + assert evacuate_recording_tableau([]) == [] + assert evacuate_recording_tableau([5]) == [5] + + +@pytest.mark.parametrize("size", range(7)) +def test_only_the_order_of_the_letters_matters(size): + """A word of distinct letters that are not 0, 1, ...: relabeling the letters of a + permutation by an increasing map relabels its insertion tableau and the evacuated + word, and leaves the recording tableau alone.""" + + def relabel(letters): + return [3 * letter - 4 for letter in letters] + + for perm in perms(size): + word = relabel(perm) + insertion_tableau, recording_tableau = rsk(word) + standard_insertion, standard_recording = rsk(perm) + assert insertion_tableau == [relabel(row) for row in standard_insertion] + assert recording_tableau == standard_recording + assert rsk_inverse(insertion_tableau, recording_tableau) == word + assert evacuate_recording_tableau(word) == relabel( + evacuate_recording_tableau(perm) + ) + + +def test_the_tableaux_given_are_not_changed_and_may_be_tuples(): + for perm in perms(5): + insertion_tableau, recording_tableau = rsk(perm) + copies = ( + [list(row) for row in insertion_tableau], + [list(row) for row in recording_tableau], + ) + assert rsk_inverse(insertion_tableau, recording_tableau) == perm + evacuated = evacuation(recording_tableau) + assert (insertion_tableau, recording_tableau) == copies + insertion_tuples = tuple(map(tuple, insertion_tableau)) + recording_tuples = tuple(map(tuple, recording_tableau)) + assert rsk_inverse(insertion_tuples, recording_tuples) == perm + assert evacuation(recording_tuples) == evacuated + + +@pytest.mark.parametrize( + "convert", + [ + list, + tuple, + set, + frozenset, + iter, + lambda patterns: (pattern for pattern in patterns), + ], + ids=["list", "tuple", "set", "frozenset", "iterator", "generator"], +) +def test_the_closure_tests_take_any_iterable_of_patterns(convert): + def patterns(*perms_): + return convert([Perm(perm) for perm in perms_]) + + # Knuth closed, but not determined by the shape + assert is_knuth_closed(patterns((0, 2, 1), (2, 0, 1))) is True + assert is_shape_closed(patterns((0, 2, 1), (2, 0, 1))) is False + # neither + assert is_knuth_closed(patterns((0, 2, 1))) is False + assert is_shape_closed(patterns((0, 2, 1))) is False + # both + assert is_knuth_closed(patterns((2, 1, 0))) is True + assert is_shape_closed(patterns((2, 1, 0))) is True + # the empty basis: everything avoids it + assert is_knuth_closed(patterns()) is True + assert is_shape_closed(patterns()) is True + + +def test_the_closure_tests_ignore_the_order_and_the_duplicates_of_the_patterns(): + pair = [Perm((0, 2, 1)), Perm((2, 0, 1))] + for patterns in ( + pair, + pair[::-1], + pair + pair, + [pair[0], pair[1], pair[0], pair[1], pair[0]], + ): + assert is_knuth_closed(patterns) is True + assert is_shape_closed(patterns) is False + assert is_knuth_closed([pair[0]] * 3) is False + assert is_shape_closed([pair[1]] * 3) is False + + +def brute_force_shape_closed(patterns, length): + for size in range(length + 1): + classes = defaultdict(list) + for perm in permutations(range(size)): + classes[shape(perm)].append(Perm(perm)) + for shape_class in classes.values(): + if len({perm.avoids(*patterns) for perm in shape_class}) > 1: + return False + return True + + +@pytest.mark.parametrize( + "basis", + [ + [Perm()], + [Perm(), Perm((0, 2, 1))], + [Perm((0,))], + [Perm((0,)), Perm((0, 2, 1))], + [Perm((0, 1))], + [Perm((1, 0))], + [Perm((0, 1)), Perm((1, 0))], + [Perm((0, 1)), Perm((0, 2, 1))], + [Perm((1, 0)), Perm((1, 2, 0)), Perm((2, 0, 1, 3))], + ], +) +def test_a_pattern_of_length_at_most_2_closes_the_basis(basis): + """Its avoiders are decreasing, or increasing, or the empty permutation alone, and + Knuth moves and their duals leave these alone: every such basis is closed, and the + empty permutation as a pattern, which nothing avoids, is too.""" + length = max(len(pattern) for pattern in basis) + 1 + assert is_knuth_closed(basis) == brute_force_knuth_closed(basis, length) + assert is_shape_closed(basis) == brute_force_shape_closed(basis, length) + + +def test_shape_closure_against_brute_force(): + """Whether the avoiders are determined by the shape, to one entry beyond the + longest pattern, and so for every length.""" + perms_of_length_3 = [Perm(perm) for perm in permutations(range(3))] + perms_of_length_4 = [Perm(perm) for perm in permutations(range(4))] + generator = random.Random(2028) + bases = [ + list(chosen) + for size in range(1, 4) + for chosen in combinations(perms_of_length_3, size) + ] + bases += [ + generator.sample(perms_of_length_4, generator.randint(1, 8)) for _ in range(30) + ] + bases += [ + generator.sample(perms_of_length_3, 1) + + generator.sample(perms_of_length_4, generator.randint(1, 3)) + for _ in range(15) + ] + closed = 0 + for basis in bases: + length = max(len(pattern) for pattern in basis) + 1 + expected = brute_force_shape_closed(basis, length) + assert is_shape_closed(basis) == expected, basis + closed += expected + assert closed >= 5 + + +def test_a_basis_with_redundant_patterns_is_closed_as_its_minimal_basis_is(): + """The avoiders of a basis do not change when the patterns that contain another + pattern of it are added.""" + perms_of_length_3 = [Perm(perm) for perm in permutations(range(3))] + perms_of_length_4 = [Perm(perm) for perm in permutations(range(4))] + bases = [ + list(chosen) + for size in (1, 2) + for chosen in combinations(perms_of_length_3, size) + ] + bases += [[Perm((0, 1))], [Perm((1, 0))], [Perm((0,))]] + for minimal in bases: + redundant = [ + perm + for perm in perms_of_length_3 + perms_of_length_4 + if perm not in minimal and not perm.avoids(*minimal) + ] + assert redundant + for extended in (minimal + redundant, redundant[::-1] + minimal): + assert is_knuth_closed(extended) == is_knuth_closed(minimal), extended + assert is_shape_closed(extended) == is_shape_closed(minimal), extended + + +@pytest.mark.parametrize( + "insertion_tableau, recording_tableau", + [([[0, 1]], [[1], [2]]), ([[0], [1]], [[1, 2]]), ([[0, 2], [1]], [[1, 2]])], +) +def test_tableaux_of_different_shapes_have_no_word( + insertion_tableau, recording_tableau +): + with pytest.raises( + ValueError, + match="^The insertion and recording tableaux have different shapes[.]$", + ): + rsk_inverse(insertion_tableau, recording_tableau) + + +@pytest.mark.parametrize( + "insertion_tableau, recording_tableau", + [ + # the same number of rows, a row longer in one and shorter in the other + ([[0, 1, 2]], [[1, 2]]), + ([[0, 1]], [[1, 2, 3]]), + ([[0, 2, 3], [1]], [[1, 2], [3]]), + ([[0, 2], [1]], [[1, 2, 3], [4]]), + # the same first row, a lower row longer or shorter + ([[0, 2], [1, 3]], [[1, 2], [3]]), + ([[0, 2], [1]], [[1, 2], [3, 4]]), + # the same size and number of rows, and different row lengths + ([[0, 2, 3], [1]], [[1, 2], [3, 4]]), + ([[0, 2], [1, 3]], [[1, 2, 4], [3]]), + # an empty tableau against a non-empty one, in both orders + ([], [[1]]), + ([[0]], []), + ], +) +def test_tableaux_of_other_different_shapes_have_no_word( + insertion_tableau, recording_tableau +): + with pytest.raises( + ValueError, + match="^The insertion and recording tableaux have different shapes[.]$", + ): + rsk_inverse(insertion_tableau, recording_tableau)