Repository: sagemath/sage

Issue:
a bijectionist's toolkit
<div id="comment:0"></div>

We provide a toolkit for the combinatorialist to help find functions ("statistics") `s: A -> Z` and bijections `A -> B` given sequences of finite sets `A` and `B` that satisfy various constraints.

Depends on #34881
Depends on #34878

CC:  @stumpc5 @tscrim @fchapoton

Component: **combinatorics**

Author: **Alexander Grosz, Tobias Kietreiber, Stephan Pfannerer, Martin Rubey**

Branch/Commit: **[u/mantepse/a_bijectionist_s_toolkit](https://github.com/sagemath/sagetrac-mirror/tree/u/mantepse/a_bijectionist_s_toolkit) @ [`53506aa`](https://github.com/sagemath/sagetrac-mirror/commit/53506aac25706957bfc01aa3fdc1b58a3ddcdf06)**

Reviewer: **Matthias Koeppe, ...**

_Issue created by migration from https://trac.sagemath.org/ticket/33238_




Discussion:

Resolving patch:
diff --git a/src/sage/combinat/all.py b/src/sage/combinat/all.py
--- a/src/sage/combinat/all.py
+++ b/src/sage/combinat/all.py
@@ -27,6 +27,7 @@
 - :ref:`sage.combinat.designs.all`
 - :ref:`sage.combinat.posets.all`
 - :ref:`sage.combinat.words`
+- :ref:`sage.combinat.bijectionist`
 
 Utilities
 ---------
@@ -299,3 +300,6 @@
 
 # Path Tableaux
 lazy_import('sage.combinat.path_tableaux', 'catalog', as_='path_tableaux')
+
+# Bijectionist
+lazy_import('sage.combinat.bijectionist', 'Bijectionist')
diff --git a/src/sage/combinat/bijectionist.py b/src/sage/combinat/bijectionist.py
new file mode 100644
--- /dev/null
+++ b/src/sage/combinat/bijectionist.py
@@ -0,0 +1,3229 @@
+r"""
+A bijectionist's toolkit
+
+AUTHORS:
+
+- Alexander Grosz, Tobias Kietreiber, Stephan Pfannerer and Martin
+  Rubey (2020-2022): Initial version
+
+Quick reference
+===============
+
+.. csv-table::
+    :class: contentstable
+    :widths: 30, 70
+    :delim: |
+
+    :meth:`~Bijectionist.set_statistics` | Declare statistics that are preserved by the bijection.
+    :meth:`~Bijectionist.set_value_restrictions` | Restrict the values of the statistic on an element.
+    :meth:`~Bijectionist.set_constant_blocks` | Declare that the statistic is constant on some sets.
+    :meth:`~Bijectionist.set_distributions` | Restrict the distribution of values of the statistic on some elements.
+    :meth:`~Bijectionist.set_intertwining_relations` | Declare that the statistic intertwines with other maps.
+    :meth:`~Bijectionist.set_quadratic_relation` | Declare that the statistic satisfies a certain relation.
+    :meth:`~Bijectionist.set_homomesic` | Declare that the statistic is homomesic with respect to a given set partition.
+
+
+    :meth:`~Bijectionist.statistics_table` | Print a table collecting information on the given statistics.
+    :meth:`~Bijectionist.statistics_fibers` | Collect elements with the same statistics.
+
+    :meth:`~Bijectionist.constant_blocks` | Return the blocks on which the statistic is constant.
+    :meth:`~Bijectionist.solutions_iterator` | Iterate over all possible solutions.
+    :meth:`~Bijectionist.possible_values` | Return all possible values for a given element.
+    :meth:`~Bijectionist.minimal_subdistributions_iterator` | Iterate over the minimal subdistributions.
+    :meth:`~Bijectionist.minimal_subdistributions_blocks_iterator` | Iterate over the minimal subdistributions.
+
+A guided tour
+=============
+
+Consider the following combinatorial statistics on a permutation:
+
+    * `wex`, the number of weak excedences,
+    * `fix`, the number of fixed points,
+    * `des`, the number of descents (after appending `0`),
+    * `adj`, the number of adjacencies (after appending `0`), and
+    * `llis`, the length of a longest increasing subsequence.
+
+Moreover, let `rot` be action of rotation on a permutation, i.e., the
+conjugation with the long cycle.
+
+It is known that there must exist a statistic `s` on permutations,
+which is equidistributed with `llis` but additionally invariant under
+`rot`.  However, at least very small cases do not contradict the
+possibility that one can even find a statistic `s`, invariant under
+`rot` and such that `(s, wex, fix) \sim (llis, des, adj)`.  Let us
+check this for permutations of size at most `3`::
+
+    sage: N = 3
+    sage: A = B = [pi for n in range(N+1) for pi in Permutations(n)]
+    sage: def alpha1(p): return len(p.weak_excedences())
+    sage: def alpha2(p): return len(p.fixed_points())
+    sage: def beta1(p): return len(p.descents(final_descent=True)) if p else 0
+    sage: def beta2(p): return len([e for (e, f) in zip(p, p[1:]+[0]) if e == f+1])
+    sage: tau = Permutation.longest_increasing_subsequence_length
+    sage: def rotate_permutation(p):
+    ....:     cycle = Permutation(tuple(range(1, len(p)+1)))
+    ....:     return Permutation([cycle.inverse()(p(cycle(i))) for i in range(1, len(p)+1)])
+    sage: bij = Bijectionist(A, B, tau)
+    sage: bij.set_statistics((len, len), (alpha1, beta1), (alpha2, beta2))
+    sage: a, b = bij.statistics_table()
+    sage: table(a, header_row=True, frame=True)
+    +-----------+--------+--------+--------+
+    | a         | α_1(a) | α_2(a) | α_3(a) |
+    +===========+========+========+========+
+    | []        | 0      | 0      | 0      |
+    +-----------+--------+--------+--------+
+    | [1]       | 1      | 1      | 1      |
+    +-----------+--------+--------+--------+
+    | [1, 2]    | 2      | 2      | 2      |
+    +-----------+--------+--------+--------+
+    | [2, 1]    | 2      | 1      | 0      |
+    +-----------+--------+--------+--------+
+    | [1, 2, 3] | 3      | 3      | 3      |
+    +-----------+--------+--------+--------+
+    | [1, 3, 2] | 3      | 2      | 1      |
+    +-----------+--------+--------+--------+
+    | [2, 1, 3] | 3      | 2      | 1      |
+    +-----------+--------+--------+--------+
+    | [2, 3, 1] | 3      | 2      | 0      |
+    +-----------+--------+--------+--------+
+    | [3, 1, 2] | 3      | 1      | 0      |
+    +-----------+--------+--------+--------+
+    | [3, 2, 1] | 3      | 2      | 1      |
+    +-----------+--------+--------+--------+
+
+    sage: table(b, header_row=True, frame=True)
+    +-----------+---+--------+--------+--------+
+    | b         | τ | β_1(b) | β_2(b) | β_3(b) |
+    +===========+===+========+========+========+
+    | []        | 0 | 0      | 0      | 0      |
+    +-----------+---+--------+--------+--------+
+    | [1]       | 1 | 1      | 1      | 1      |
+    +-----------+---+--------+--------+--------+
+    | [1, 2]    | 2 | 2      | 1      | 0      |
+    +-----------+---+--------+--------+--------+
+    | [2, 1]    | 1 | 2      | 2      | 2      |
+    +-----------+---+--------+--------+--------+
+    | [1, 2, 3] | 3 | 3      | 1      | 0      |
+    +-----------+---+--------+--------+--------+
+    | [1, 3, 2] | 2 | 3      | 2      | 1      |
+    +-----------+---+--------+--------+--------+
+    | [2, 1, 3] | 2 | 3      | 2      | 1      |
+    +-----------+---+--------+--------+--------+
+    | [2, 3, 1] | 2 | 3      | 2      | 1      |
+    +-----------+---+--------+--------+--------+
+    | [3, 1, 2] | 2 | 3      | 2      | 0      |
+    +-----------+---+--------+--------+--------+
+    | [3, 2, 1] | 1 | 3      | 3      | 3      |
+    +-----------+---+--------+--------+--------+
+
+    sage: from sage.combinat.cyclic_sieving_phenomenon import orbit_decomposition
+    sage: bij.set_constant_blocks(orbit_decomposition(A, rotate_permutation))
+    sage: bij.constant_blocks()
+    {{[1, 3, 2], [2, 1, 3], [3, 2, 1]}}
+    sage: next(bij.solutions_iterator())
+    {[]: 0,
+     [1]: 1,
+     [1, 2]: 1,
+     [1, 2, 3]: 1,
+     [1, 3, 2]: 2,
+     [2, 1]: 2,
+     [2, 1, 3]: 2,
+     [2, 3, 1]: 2,
+     [3, 1, 2]: 3,
+     [3, 2, 1]: 2}
+
+On the other hand, we can check that there is no rotation invariant
+statistic on non-crossing set partitions which is equidistributed
+with the Strahler number on ordered trees::
+
+    sage: N = 8
+    sage: A = [SetPartition(d.to_noncrossing_partition()) for n in range(N) for d in DyckWords(n)]
+    sage: B = [t for n in range(1, N+1) for t in OrderedTrees(n)]
+    sage: def theta(m): return SetPartition([[i % m.size() + 1 for i in b] for b in m])
+
+Code for the Strahler number can be obtained from FindStat.  The
+following code is equivalent to ``tau = findstat(397)``::
+
+    sage: def tau(T):
+    ....:     if len(T) == 0:
+    ....:         return 1
+    ....:     else:
+    ....:         l = [tau(S) for S in T]
+    ....:         m = max(l)
+    ....:         if l.count(m) == 1:
+    ....:             return m
+    ....:         else:
+    ....:             return m+1
+    sage: bij = Bijectionist(A, B, tau)
+    sage: bij.set_statistics((lambda a: a.size(), lambda b: b.node_number()-1))
+    sage: from sage.combinat.cyclic_sieving_phenomenon import orbit_decomposition
+    sage: bij.set_constant_blocks(orbit_decomposition(A, theta))
+    sage: list(bij.solutions_iterator())
+    []
+
+Next we demonstrate how to search for a bijection, instead An example
+identifying `s` and `S`::
+
+    sage: N = 4
+    sage: A = [dyck_word for n in range(1, N) for dyck_word in DyckWords(n)]
+    sage: B = [binary_tree for n in range(1, N) for binary_tree in BinaryTrees(n)]
+    sage: concat_path = lambda D1, D2: DyckWord(list(D1) + list(D2))
+    sage: concat_tree = lambda B1, B2: concat_path(B1.to_dyck_word(),
+    ....:                                          B2.to_dyck_word()).to_binary_tree()
+    sage: bij = Bijectionist(A, B)
+    sage: bij.set_intertwining_relations((2, concat_path, concat_tree))
+    sage: bij.set_statistics((lambda d: d.semilength(), lambda t: t.node_number()))
+    sage: for D in sorted(bij.minimal_subdistributions_iterator(), key=lambda x: (len(x[0][0]), x)):
+    ....:     ascii_art(D)
+    ( [ /\ ], [ o ] )
+    (           [ o   ] )
+    (           [  \  ] )
+    ( [ /\/\ ], [   o ] )
+    (           [   o ] )
+    ( [  /\  ]  [  /  ] )
+    ( [ /  \ ], [ o   ] )
+    (             [ o     ] )
+    (             [  \    ] )
+    (             [   o   ] )
+    (             [    \  ] )
+    ( [ /\/\/\ ], [     o ] )
+    (             [ o   ] )
+    (             [  \  ] )
+    (             [   o ] )
+    ( [    /\  ]  [  /  ] )
+    ( [ /\/  \ ], [ o   ] )
+    (             [   o   ] )
+    ( [  /\    ]  [  / \  ] )
+    ( [ /  \/\ ], [ o   o ] )
+    (                     [   o,     o ] )
+    (                     [  /      /  ] )
+    ( [           /\   ]  [ o      o   ] )
+    ( [  /\/\    /  \  ]  [  \    /    ] )
+    ( [ /    \, /    \ ], [   o  o     ] )
+
+The output is in a form suitable for FindStat::
+
+    sage: findmap(list(bij.minimal_subdistributions_iterator()))            # optional -- internet
+    0: Mp00034 (quality [100])
+    1: Mp00061oMp00023 (quality [100])
+    2: Mp00018oMp00140 (quality [100])
+
+TESTS::
+
+    sage: N = 4; A = B = [permutation for n in range(N) for permutation in Permutations(n)]
+    sage: def theta(pi): return Permutation([x+1 if x != len(pi) else 1 for x in pi[-1:]+pi[:-1]])
+    sage: def tau(pi):
+    ....:    n = len(pi)
+    ....:    return sum([1 for i in range(1, n+1) for j in range(1, n+1)
+    ....:                if i<j <= pi(i)<pi(j) or pi(i)<pi(j)<i<j])
+    sage: bij = Bijectionist(A, B, tau)
+    sage: bij.set_statistics((len, len))
+    sage: from sage.combinat.cyclic_sieving_phenomenon import orbit_decomposition
+    sage: bij.set_constant_blocks(orbit_decomposition(A, theta))
+    sage: for solution in sorted(bij.solutions_iterator(), key=lambda d: sorted(d.items())):
+    ....:     print(solution)
+    {[]: 0, [1]: 0, [1, 2]: 0, [2, 1]: 0, [1, 2, 3]: 0, [1, 3, 2]: 0, [2, 1, 3]: 0, [3, 2, 1]: 0, [2, 3, 1]: 0, [3, 1, 2]: 1}
+    {[]: 0, [1]: 0, [1, 2]: 0, [2, 1]: 0, [1, 2, 3]: 0, [1, 3, 2]: 0, [2, 1, 3]: 0, [3, 2, 1]: 0, [2, 3, 1]: 1, [3, 1, 2]: 0}
+    {[]: 0, [1]: 0, [1, 2]: 0, [2, 1]: 0, [1, 2, 3]: 1, [1, 3, 2]: 0, [2, 1, 3]: 0, [3, 2, 1]: 0, [2, 3, 1]: 0, [3, 1, 2]: 0}
+
+A test including intertwining relations::
+
+    sage: N = 2; A = B = [dyck_word for n in range(N+1) for dyck_word in DyckWords(n)]
+    sage: def alpha(D): return (D.area(), D.bounce())
+    sage: def beta(D): return (D.bounce(), D.area())
+    sage: def tau(D): return D.number_of_touch_points()
+
+The following looks correct::
+
+    sage: bij = Bijectionist(A, B, tau)
+    sage: bij.set_statistics((lambda d: d.semilength(), lambda d: d.semilength()))
+    sage: for solution in sorted(list(bij.solutions_iterator()), key=lambda d: tuple(sorted(d.items()))):
+    ....:     print(solution)
+    {[]: 0, [1, 0]: 1, [1, 0, 1, 0]: 1, [1, 1, 0, 0]: 2}
+    {[]: 0, [1, 0]: 1, [1, 0, 1, 0]: 2, [1, 1, 0, 0]: 1}
+
+The following looks correct, because `alpha = beta \circ S` forces
+`S([1,0,1,0]) = [1,1,0,0]` and `s = tau \circ S` forces therefore `s([1,0,1,0])
+= \tau(S([1,0,1,0])) = \tau([1,1,0,0]) = 1`::
+
+    sage: bij = Bijectionist(A, B, tau)
+    sage: bij.set_statistics((alpha, beta), (lambda d: d.semilength(), lambda d: d.semilength()))
+    sage: for solution in bij.solutions_iterator():
+    ....:     print(solution)
+    {[]: 0, [1, 0]: 1, [1, 0, 1, 0]: 1, [1, 1, 0, 0]: 2}
+
+Now we introduce a intertwining relation::
+
+    sage: concat_path = lambda D1, D2: DyckWord(list(D1) + list(D2))
+    sage: pi_rho = (2, concat_path, lambda x, y: x+y)
+
+Without `\alpha` and `\beta` but with `\pi` and `\rho` the other values are
+forced because `s([1,0,1,0]) = s(\pi([1,0], [1,0])) = \rho(s([1,0]), s([1,0]))
+= 2`::
+
+    sage: bij = Bijectionist(A, B, tau)
+    sage: bij.set_intertwining_relations(pi_rho)
+    sage: bij.set_statistics((lambda d: d.semilength(), lambda d: d.semilength()))
+    sage: for solution in bij.solutions_iterator():
+    ....:     print(solution)
+    {[]: 0, [1, 0]: 1, [1, 0, 1, 0]: 2, [1, 1, 0, 0]: 1}
+
+Thus the combination of both constraints should be infeasible::
+
+    sage: bij = Bijectionist(A, B, tau)
+    sage: bij.set_statistics((alpha, beta), (lambda d: d.semilength(), lambda d: d.semilength()))
+    sage: bij.set_intertwining_relations(pi_rho)
+    sage: list(bij.solutions_iterator())
+    []
+
+Repeating some tests, but using the constructor instead of set_XXX() methods:
+
+    sage: N = 2; A = B = [dyck_word for n in range(N+1) for dyck_word in DyckWords(n)]
+    sage: def alpha(D): return (D.area(), D.bounce())
+    sage: def beta(D): return (D.bounce(), D.area())
+    sage: def tau(D): return D.number_of_touch_points()
+
+    sage: bij = Bijectionist(A, B, tau, alpha_beta=((lambda d: d.semilength(), lambda d: d.semilength()),))
+    sage: for solution in sorted(list(bij.solutions_iterator()), key=lambda d: tuple(sorted(d.items()))):
+    ....:     print(solution)
+    {[]: 0, [1, 0]: 1, [1, 0, 1, 0]: 1, [1, 1, 0, 0]: 2}
+    {[]: 0, [1, 0]: 1, [1, 0, 1, 0]: 2, [1, 1, 0, 0]: 1}
+
+Constant blocks::
+
+    sage: A = B = 'abcd'
+    sage: def pi(p1, p2): return 'abcdefgh'[A.index(p1) + A.index(p2)]
+    sage: def rho(s1, s2): return (s1 + s2) % 2
+    sage: bij = Bijectionist(A, B, lambda x: B.index(x) % 2, P=[['a', 'c']], pi_rho=((2, pi, rho),))
+    sage: list(bij.solutions_iterator())
+    [{'a': 0, 'b': 1, 'c': 0, 'd': 1}]
+    sage: bij.constant_blocks()
+    {{'a', 'c'}, {'b', 'd'}}
+
+Distributions::
+
+    sage: A = B = [permutation for n in range(4) for permutation in Permutations(n)]
+    sage: tau = Permutation.longest_increasing_subsequence_length
+    sage: bij = Bijectionist(A, B, tau, alpha_beta=((len, len),), elements_distributions=(([Permutation([1, 2, 3]), Permutation([1, 3, 2])], [1, 3]),))
+    sage: for sol in sorted(list(bij.solutions_iterator()), key=lambda d: tuple(sorted(d.items()))):
+    ....:     print(sol)
+    {[]: 0, [1]: 1, [1, 2]: 1, [2, 1]: 2, [1, 2, 3]: 1, [1, 3, 2]: 3, [2, 1, 3]: 2, [2, 3, 1]: 2, [3, 1, 2]: 2, [3, 2, 1]: 2}
+    {[]: 0, [1]: 1, [1, 2]: 1, [2, 1]: 2, [1, 2, 3]: 3, [1, 3, 2]: 1, [2, 1, 3]: 2, [2, 3, 1]: 2, [3, 1, 2]: 2, [3, 2, 1]: 2}
+    {[]: 0, [1]: 1, [1, 2]: 2, [2, 1]: 1, [1, 2, 3]: 1, [1, 3, 2]: 3, [2, 1, 3]: 2, [2, 3, 1]: 2, [3, 1, 2]: 2, [3, 2, 1]: 2}
+    {[]: 0, [1]: 1, [1, 2]: 2, [2, 1]: 1, [1, 2, 3]: 3, [1, 3, 2]: 1, [2, 1, 3]: 2, [2, 3, 1]: 2, [3, 1, 2]: 2, [3, 2, 1]: 2}
+
+Intertwining relations::
+
+    sage: def concat(p1, p2): return Permutation(p1 + [i + len(p1) for i in p2])
+
+    sage: A = B = [permutation for n in range(4) for permutation in Permutations(n)]
+    sage: bij = Bijectionist(A, B, Permutation.number_of_fixed_points, alpha_beta=((len, len),), pi_rho=((2, concat, lambda x, y: x + y),))
+    sage: for solution in sorted(list(bij.solutions_iterator()), key=lambda d: tuple(sorted(d.items()))):
+    ....:     print(solution)
+    {[]: 0, [1]: 1, [1, 2]: 2, [2, 1]: 0, [1, 2, 3]: 3, [1, 3, 2]: 1, [2, 1, 3]: 1, [2, 3, 1]: 0, [3, 1, 2]: 0, [3, 2, 1]: 1}
+    {[]: 0, [1]: 1, [1, 2]: 2, [2, 1]: 0, [1, 2, 3]: 3, [1, 3, 2]: 1, [2, 1, 3]: 1, [2, 3, 1]: 0, [3, 1, 2]: 1, [3, 2, 1]: 0}
+    {[]: 0, [1]: 1, [1, 2]: 2, [2, 1]: 0, [1, 2, 3]: 3, [1, 3, 2]: 1, [2, 1, 3]: 1, [2, 3, 1]: 1, [3, 1, 2]: 0, [3, 2, 1]: 0}
+
+Statistics::
+
+    sage: N = 4; A = B = [permutation for n in range(N) for permutation in Permutations(n)]
+    sage: def wex(p): return len(p.weak_excedences())
+    sage: def fix(p): return len(p.fixed_points())
+    sage: def des(p): return len(p.descents(final_descent=True)) if p else 0
+    sage: bij = Bijectionist(A, B, fix, alpha_beta=((wex, des), (len, len)))
+    sage: for solution in sorted(list(bij.solutions_iterator()), key=lambda d: tuple(sorted(d.items()))):
+    ....:     print(solution)
+    {[]: 0, [1]: 1, [1, 2]: 0, [2, 1]: 2, [1, 2, 3]: 1, [1, 3, 2]: 0, [2, 1, 3]: 0, [2, 3, 1]: 1, [3, 1, 2]: 3, [3, 2, 1]: 1}
+    {[]: 0, [1]: 1, [1, 2]: 0, [2, 1]: 2, [1, 2, 3]: 1, [1, 3, 2]: 0, [2, 1, 3]: 1, [2, 3, 1]: 0, [3, 1, 2]: 3, [3, 2, 1]: 1}
+    {[]: 0, [1]: 1, [1, 2]: 0, [2, 1]: 2, [1, 2, 3]: 1, [1, 3, 2]: 0, [2, 1, 3]: 1, [2, 3, 1]: 1, [3, 1, 2]: 3, [3, 2, 1]: 0}
+    {[]: 0, [1]: 1, [1, 2]: 0, [2, 1]: 2, [1, 2, 3]: 1, [1, 3, 2]: 1, [2, 1, 3]: 0, [2, 3, 1]: 0, [3, 1, 2]: 3, [3, 2, 1]: 1}
+    {[]: 0, [1]: 1, [1, 2]: 0, [2, 1]: 2, [1, 2, 3]: 1, [1, 3, 2]: 1, [2, 1, 3]: 0, [2, 3, 1]: 1, [3, 1, 2]: 3, [3, 2, 1]: 0}
+    {[]: 0, [1]: 1, [1, 2]: 0, [2, 1]: 2, [1, 2, 3]: 1, [1, 3, 2]: 1, [2, 1, 3]: 1, [2, 3, 1]: 0, [3, 1, 2]: 3, [3, 2, 1]: 0}
+
+Value restrictions::
+
+    sage: A = B = [permutation for n in range(4) for permutation in Permutations(n)]
+    sage: tau = Permutation.longest_increasing_subsequence_length
+    sage: alpha_beta = [(len, len)]
+    sage: value_restrictions = [(Permutation([1, 2]), [1]), (Permutation([3, 2, 1]), [2, 3, 4])]
+    sage: bij = Bijectionist(A, B, tau, alpha_beta=alpha_beta, value_restrictions=value_restrictions)
+    sage: for sol in sorted(list(bij.solutions_iterator()), key=lambda d: tuple(sorted(d.items()))):
+    ....:     print(sol)
+    {[]: 0, [1]: 1, [1, 2]: 1, [2, 1]: 2, [1, 2, 3]: 1, [1, 3, 2]: 2, [2, 1, 3]: 2, [2, 3, 1]: 2, [3, 1, 2]: 2, [3, 2, 1]: 3}
+    ...
+    {[]: 0, [1]: 1, [1, 2]: 1, [2, 1]: 2, [1, 2, 3]: 3, [1, 3, 2]: 2, [2, 1, 3]: 2, [2, 3, 1]: 2, [3, 1, 2]: 1, [3, 2, 1]: 2}
+
+    sage: A = B = [permutation for n in range(4) for permutation in Permutations(n)]
+    sage: tau = Permutation.longest_increasing_subsequence_length
+    sage: bij = Bijectionist(A, B, tau, value_restrictions=((Permutation([1, 2]), [4, 5]),))
+    Traceback (most recent call last):
+    ...
+    ValueError: no possible values found for singleton block [[1, 2]]
+
+"""
+# ****************************************************************************
+#       Copyright (C) 2020 Martin Rubey <martin.rubey at tuwien.ac.at>
+#                          Stephan Pfannerer
+#                          Tobias Kietreiber
+#                          Alexander Grosz
+#
+#  Distributed under the terms of the GNU General Public License (GPL)
+#
+#    This code is distributed in the hope that it will be useful,
+#    but WITHOUT ANY WARRANTY; without even the implied warranty of
+#    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+#    General Public License for more details.
+#
+#  The full text of the GPL is available at:
+#
+#                  https://www.gnu.org/licenses/
+# ***************************************************************************
+import itertools
+from collections import namedtuple, defaultdict
+from sage.numerical.mip import MixedIntegerLinearProgram, MIPSolverException
+from sage.rings.integer_ring import ZZ
+from sage.combinat.set_partition import SetPartition, SetPartitions
+from sage.sets.disjoint_set import DisjointSet
+from sage.structure.sage_object import SageObject
+from copy import copy
+from sage.misc.verbose import get_verbose
+
+
+class Bijectionist(SageObject):
+    r"""
+    A toolbox to list all possible bijections between two finite sets
+    under various constraints.
+
+    INPUT:
+
+    - ``A``, ``B`` -- sets of equal size, given as a list
+
+    - ``tau`` -- (optional) a function from ``B`` to ``Z``, in case of
+      ``None``, the identity map ``lambda x: x`` is used
+
+    - ``alpha_beta`` -- (optional) a list of pairs of statistics ``alpha`` from
+      ``A`` to ``W`` and ``beta`` from ``B`` to ``W``
+
+    - ``P`` -- (optional) a partition of ``A``
+
+    - ``pi_rho`` -- (optional) a list of triples ``(k, pi, rho)``, where
+
+      * ``pi`` -- a ``k``-ary operation composing objects in ``A`` and
+      * ``rho`` -- a ``k``-ary function composing statistic values in ``Z``
+
+    - ``elements_distributions`` -- (optional) a list of pairs ``(tA, tZ)``,
+      specifying the distributions of ``tA``
+
+    - ``value_restrictions`` -- (optional) a list of pairs ``(a, tZ)``,
+      restricting the possible values of ``a``
+
+    - ``solver`` -- (optional) the backend used to solve the mixed integer
+      linear programs
+
+    ``W`` and ``Z`` can be arbitrary sets.  As a natural example we may think
+    of the natural numbers or tuples of integers.
+
+    We are looking for a statistic `s: A\to Z` and a bijection `S: A\to B` such
+    that
+
+    - `s = \tau \circ S`: the statistics `s` and `\tau` are equidistributed and
+      `S` is an intertwining bijection.
+
+    - `\alpha = \beta \circ S`: the statistics `\alpha` and `\beta` are
+      equidistributed and `S` is an intertwining bijection.
+
+    - `s` is constant on the blocks of `P`.
+
+    - `s(\pi(a_1,\dots, a_k)) = \rho(s(a_1),\dots, s(a_k))`.
+
+    Additionally, we may require that
+
+    - `s(a)\in Z_a` for specified sets `Z_a\subseteq Z`, and
+
+    - `s|_{\tilde A}` has a specified distribution for specified sets `\tilde A
+      \subset A`.
+
+    If `\tau` is the identity, the two unknown functions `s` and `S` coincide.
+    Although we do not exclude other bijective choices for `\tau`, they
+    probably do not make sense.
+
+    If we want that `S` is graded, i.e. if elements of `A` and `B` have a
+    notion of size and `S` should preserve this size, we can add grading
+    statistics as `\alpha` and `\beta`.  Since `\alpha` and `\beta` will be
+    equidistributed with `S` as an intertwining bijection, `S` will then also
+    be graded.
+
+    In summary, we have the following two commutative diagrams, where `s` and
+    `S` are unknown functions.
+
+    .. MATH::
+
+        \begin{array}{rrl}
+                                          & A \\
+            {\scriptstyle\alpha}\swarrow  & {\scriptstyle S}\downarrow & \searrow{\scriptstyle s}\\
+            W \overset{\beta}{\leftarrow} & B                          & \overset{\tau}{\rightarrow} Z
+        \end{array}
+        \qquad
+        \begin{array}{lcl}
+            A^k                          &\overset{\pi}{\rightarrow} & A\\
+            \downarrow{\scriptstyle s^k} &                           & \downarrow{\scriptstyle s}\\
+            Z^k                          &\overset{\rho}{\rightarrow} & Z\\
+        \end{array}
+
+    .. NOTE::
+
+        If `\tau` is the identity map, the partition `P` of `A` necessarily
+        consists only of singletons.
+
+    .. NOTE::
+
+        The order of invocation of the methods with prefix ``set``, i.e.,
+        :meth:`set_statistics`, :meth:`set_intertwining_relations`,
+        :meth:`set_constant_blocks`, etc., is irrelevant.  Calling any of these
+        methods a second time overrides the previous specification.
+
+    """
+    def __init__(self, A, B, tau=None, alpha_beta=tuple(), P=None,
+                 pi_rho=tuple(), phi_psi=tuple(), Q=None,
+                 elements_distributions=tuple(),
+                 value_restrictions=tuple(), solver=None, key=None):
+        """
+        Initialize the bijectionist.
+
+        TESTS:
+
+        Check that large input sets are handled well::
+
+            sage: A = B = range(20000)
+            sage: bij = Bijectionist(A, B)                                      # long time
+        """
+        # glossary of standard letters:
+        # A, B, Z, W ... finite sets
+        # P ... set partition of A
+        # tA, tB, tZ, tP ... subsets
+        # a in A, b in B, p in P
+        # S: A -> B
+        # alpha: A -> W, beta: B -> W
+        # s: A -> Z, tau: B -> Z
+        # k arity of pi and rho
+        # pi: A^k -> A, rho: Z^k -> Z
+        # a_tuple in A^k
+        self._A = list(A)
+        self._B = list(B)
+        assert len(self._A) == len(set(self._A)), "A must have distinct items"
+        assert len(self._B) == len(set(self._B)), "B must have distinct items"
+        self._bmilp = None
+        self._sorter = {}
+        self._sorter["A"] = lambda x: sorted(x, key=self._A.index)
+        self._sorter["B"] = lambda x: sorted(x, key=self._B.index)
+
+        if tau is None:
+            self._tau = {b: b for b in self._B}
+        else:
+            self._tau = {b: tau(b) for b in self._B}
+        # we store Z as a list to keep an order
+        self._Z = set(self._tau.values())
+        if key is not None and "Z" in key:
+            self._sorter["Z"] = lambda x: sorted(x, key=key["Z"])
+            self._Z = self._sorter["Z"](self._Z)
+        else:
+            try:
+                self._Z = sorted(self._Z)
+                self._sorter["Z"] = lambda x: sorted(x)
+            except TypeError:
+                self._Z = list(self._Z)
+                self._sorter["Z"] = lambda x: list(x)
+        if P is None:
+            P = []
+
+        # set optional inputs
+        self.set_statistics(*alpha_beta)
+        self.set_value_restrictions(*value_restrictions)
+        self.set_distributions(*elements_distributions)
+        self.set_quadratic_relation(*phi_psi)
+        self.set_homomesic(Q)
+        self.set_intertwining_relations(*pi_rho)
+        self.set_constant_blocks(P)
+
+        self._solver = solver
+
+    def set_constant_blocks(self, P):
+        r"""
+        Declare that `s: A\to Z` is constant on each block of `P`.
+
+        .. WARNING::
+
+             Any restriction imposed by a previous invocation of
+             :meth:`set_constant_blocks` will be overwritten,
+             including restrictions discovered by
+             :meth:`set_intertwining_relations` and
+             :meth:`solutions_iterator`!
+
+        A common example is to use the orbits of a bijection acting
+        on `A`.  This can be achieved using the function
+        :meth:`~sage.combinat.cyclic_sieving_phenomenon.orbit_decomposition`.
+
+        INPUT:
+
+        - ``P`` -- a set partition of `A`, singletons may be omitted
+
+        EXAMPLES:
+
+        Initially the partitions are set to singleton blocks.  The
+        current partition can be reviewed using
+        :meth:`constant_blocks`::
+
+            sage: A = B = 'abcd'
+            sage: bij = Bijectionist(A, B, lambda x: B.index(x) % 2)
+            sage: bij.constant_blocks()
+            {}
+
+            sage: bij.set_constant_blocks([['a', 'c']])
+            sage: bij.constant_blocks()
+            {{'a', 'c'}}
+
+        We now add a map that combines some blocks::
+
+            sage: def pi(p1, p2): return 'abcdefgh'[A.index(p1) + A.index(p2)]
+            sage: def rho(s1, s2): return (s1 + s2) % 2
+            sage: bij.set_intertwining_relations((2, pi, rho))
+            sage: list(bij.solutions_iterator())
+            [{'a': 0, 'b': 1, 'c': 0, 'd': 1}]
+            sage: bij.constant_blocks()
+            {{'a', 'c'}, {'b', 'd'}}
+
+        Setting constant blocks overrides any previous assignment::
+
+            sage: bij.set_constant_blocks([['a', 'b']])
+            sage: bij.constant_blocks()
+            {{'a', 'b'}}
+
+        If there is no solution, and the coarsest partition is
+        requested, an error is raised::
+
+            sage: bij.constant_blocks(optimal=True)
+            Traceback (most recent call last):
+            ...
+            StopIteration
+
+        """
+        self._bmilp = None
+        self._P = DisjointSet(self._A)
+        P = sorted(self._sorter["A"](p) for p in P)
+        for p in P:
+            for a in p:
+                self._P.union(p[0], a)
+
+        self._compute_possible_block_values()
+
+    def constant_blocks(self, singletons=False, optimal=False):
+        r"""
+        Return the set partition `P` of `A` such that `s: A\to Z` is
+        known to be constant on the blocks of `P`.
+
+        INPUT:
+
+        - ``singletons`` -- (optional, default: ``False``) whether or not to
+          include singleton blocks in the output
+
+        - ``optimal`` -- (optional, default: ``False``) whether or not to
+          compute the coarsest possible partition
+
+        .. NOTE::
+
+            computing the coarsest possible partition may be
+            computationally expensive, but may speed up generating
+            solutions.
+
+        EXAMPLES::
+
+            sage: A = B = ["a", "b", "c"]
+            sage: bij = Bijectionist(A, B, lambda x: 0)
+            sage: bij.set_constant_blocks([["a", "b"]])
+            sage: bij.constant_blocks()
+            {{'a', 'b'}}
+
+            sage: bij.constant_blocks(singletons=True)
+            {{'a', 'b'}, {'c'}}
+
+        """
+        if optimal:
+            self._forced_constant_blocks()
+        if singletons:
+            return SetPartition(self._P)
+        return SetPartition(p for p in self._P if len(p) > 1)
+
+    def set_statistics(self, *alpha_beta):
+        r"""
+        Set constraints of the form `\alpha = \beta\circ S`.
+
+        .. WARNING::
+
+             Any restriction imposed by a previous invocation of
+             :meth:`set_statistics` will be overwritten!
+
+        INPUT:
+
+        - ``alpha_beta`` -- one or more pairs `(\alpha: A\to W,
+          \beta: B\to W)`
+
+        If the statistics `\alpha` and `\beta` are not
+        equidistributed, an error is raised.
+
+        ALGORITHM:
+
+        We add
+
+        .. MATH::
+
+            \sum_{a\in A, z\in Z} x_{p(a), z} s^z t^{\alpha(a)}
+            = \sum_{b\in B} s^{\tau(b)} t(\beta(b))
+
+        as a matrix equation to the MILP.
+
+        EXAMPLES:
+
+        We look for bijections `S` on permutations such that the
+        number of weak exceedences of `S(\pi)` equals the number of
+        descents of `\pi`, and statistics `s`, such that the number
+        of fixed points of `S(\pi)` equals `s(\pi)`::
+
+            sage: N = 4; A = B = [permutation for n in range(N) for permutation in Permutations(n)]
+            sage: def wex(p): return len(p.weak_excedences())
+            sage: def fix(p): return len(p.fixed_points())
+            sage: def des(p): return len(p.descents(final_descent=True)) if p else 0
+            sage: def adj(p): return len([e for (e, f) in zip(p, p[1:]+[0]) if e == f+1])
+            sage: bij = Bijectionist(A, B, fix)
+            sage: bij.set_statistics((wex, des), (len, len))
+            sage: for solution in sorted(list(bij.solutions_iterator()), key=lambda d: tuple(sorted(d.items()))):
+            ....:     print(solution)
+            {[]: 0, [1]: 1, [1, 2]: 0, [2, 1]: 2, [1, 2, 3]: 1, [1, 3, 2]: 0, [2, 1, 3]: 0, [2, 3, 1]: 1, [3, 1, 2]: 3, [3, 2, 1]: 1}
+            {[]: 0, [1]: 1, [1, 2]: 0, [2, 1]: 2, [1, 2, 3]: 1, [1, 3, 2]: 0, [2, 1, 3]: 1, [2, 3, 1]: 0, [3, 1, 2]: 3, [3, 2, 1]: 1}
+            {[]: 0, [1]: 1, [1, 2]: 0, [2, 1]: 2, [1, 2, 3]: 1, [1, 3, 2]: 0, [2, 1, 3]: 1, [2, 3, 1]: 1, [3, 1, 2]: 3, [3, 2, 1]: 0}
+            {[]: 0, [1]: 1, [1, 2]: 0, [2, 1]: 2, [1, 2, 3]: 1, [1, 3, 2]: 1, [2, 1, 3]: 0, [2, 3, 1]: 0, [3, 1, 2]: 3, [3, 2, 1]: 1}
+            {[]: 0, [1]: 1, [1, 2]: 0, [2, 1]: 2, [1, 2, 3]: 1, [1, 3, 2]: 1, [2, 1, 3]: 0, [2, 3, 1]: 1, [3, 1, 2]: 3, [3, 2, 1]: 0}
+            {[]: 0, [1]: 1, [1, 2]: 0, [2, 1]: 2, [1, 2, 3]: 1, [1, 3, 2]: 1, [2, 1, 3]: 1, [2, 3, 1]: 0, [3, 1, 2]: 3, [3, 2, 1]: 0}
+
+            sage: bij = Bijectionist(A, B, fix)
+            sage: bij.set_statistics((wex, des), (fix, adj), (len, len))
+            sage: for solution in sorted(list(bij.solutions_iterator()), key=lambda d: tuple(sorted(d.items()))):
+            ....:     print(solution)
+            {[]: 0, [1]: 1, [1, 2]: 0, [2, 1]: 2, [1, 2, 3]: 1, [1, 3, 2]: 0, [2, 1, 3]: 1, [2, 3, 1]: 0, [3, 1, 2]: 3, [3, 2, 1]: 1}
+            {[]: 0, [1]: 1, [1, 2]: 0, [2, 1]: 2, [1, 2, 3]: 1, [1, 3, 2]: 1, [2, 1, 3]: 0, [2, 3, 1]: 0, [3, 1, 2]: 3, [3, 2, 1]: 1}
+            {[]: 0, [1]: 1, [1, 2]: 0, [2, 1]: 2, [1, 2, 3]: 1, [1, 3, 2]: 1, [2, 1, 3]: 1, [2, 3, 1]: 0, [3, 1, 2]: 3, [3, 2, 1]: 0}
+
+        Calling this with non-equidistributed statistics yields an error::
+
+            sage: bij = Bijectionist(A, B, fix)
+            sage: bij.set_statistics((wex, fix))
+            Traceback (most recent call last):
+            ...
+            ValueError: statistics alpha and beta are not equidistributed
+
+        TESTS:
+
+        Calling ``set_statistics`` without arguments should restore the previous state::
+
+            sage: N = 3; A = B = [permutation for n in range(N) for permutation in Permutations(n)]
+            sage: def wex(p): return len(p.weak_excedences())
+            sage: def fix(p): return len(p.fixed_points())
+            sage: def des(p): return len(p.descents(final_descent=True)) if p else 0
+            sage: bij = Bijectionist(A, B, fix)
+            sage: bij.set_statistics((wex, des), (len, len))
+            sage: for solution in bij.solutions_iterator():
+            ....:     print(solution)
+            {[]: 0, [1]: 1, [1, 2]: 0, [2, 1]: 2}
+            sage: bij.set_statistics()
+            sage: for solution in sorted(list(bij.solutions_iterator()), key=lambda d: tuple(sorted(d.items()))):
+            ....:     print(solution)
+            {[]: 0, [1]: 0, [1, 2]: 1, [2, 1]: 2}
+            {[]: 0, [1]: 0, [1, 2]: 2, [2, 1]: 1}
+            {[]: 0, [1]: 1, [1, 2]: 0, [2, 1]: 2}
+            {[]: 0, [1]: 1, [1, 2]: 2, [2, 1]: 0}
+            {[]: 0, [1]: 2, [1, 2]: 0, [2, 1]: 1}
+            {[]: 0, [1]: 2, [1, 2]: 1, [2, 1]: 0}
+            {[]: 1, [1]: 0, [1, 2]: 0, [2, 1]: 2}
+            {[]: 1, [1]: 0, [1, 2]: 2, [2, 1]: 0}
+            {[]: 1, [1]: 2, [1, 2]: 0, [2, 1]: 0}
+            {[]: 2, [1]: 0, [1, 2]: 0, [2, 1]: 1}
+            {[]: 2, [1]: 0, [1, 2]: 1, [2, 1]: 0}
+            {[]: 2, [1]: 1, [1, 2]: 0, [2, 1]: 0}
+
+        """
+        self._bmilp = None
+        self._n_statistics = len(alpha_beta)
+        # TODO: do we really want to recompute statistics every time?
+        self._alpha = lambda p: tuple(arg[0](p) for arg in alpha_beta)
+        self._beta = lambda p: tuple(arg[1](p) for arg in alpha_beta)
+
+        # generate fibers
+        self._statistics_fibers = {}
+        for a in self._A:
+            v = self._alpha(a)
+            if v not in self._statistics_fibers:
+                self._statistics_fibers[v] = ([], [])
+            self._statistics_fibers[v][0].append(a)
+
+        for b in self._B:
+            v = self._beta(b)
+            if v not in self._statistics_fibers:
+                raise ValueError(f"statistics alpha and beta do not have the same image, {v} is not a value of alpha, but of beta")
+            self._statistics_fibers[v][1].append(b)
+
+        # check compatibility
+        if not all(len(fiber[0]) == len(fiber[1])
+                   for fiber in self._statistics_fibers.values()):
+            raise ValueError("statistics alpha and beta are not equidistributed")
+
+        self._W = list(self._statistics_fibers)
+
+        # the possible values of s(a) are tau(beta^{-1}(alpha(a)))
+        tau_beta_inverse = {}
+        self._statistics_possible_values = {}
+        for a in self._A:
+            v = self._alpha(a)
+            if v not in tau_beta_inverse:
+                tau_beta_inverse[v] = set(self._tau[b]
+                                          for b in self._statistics_fibers[v][1])
+            self._statistics_possible_values[a] = tau_beta_inverse[v]
+
+    def statistics_fibers(self):
+        r"""
+        Return a dictionary mapping statistic values in `W` to their
+        preimages in `A` and `B`.
+
+        This is a (computationally) fast way to obtain a first
+        impression which objects in `A` should be mapped to which
+        objects in `B`.
+
+        EXAMPLES::
+
+            sage: A = B = [permutation for n in range(4) for permutation in Permutations(n)]
+            sage: tau = Permutation.longest_increasing_subsequence_length
+            sage: def wex(p): return len(p.weak_excedences())
+            sage: def fix(p): return len(p.fixed_points())
+            sage: def des(p): return len(p.descents(final_descent=True)) if p else 0
+            sage: def adj(p): return len([e for (e, f) in zip(p, p[1:]+[0]) if e == f+1])
+            sage: bij = Bijectionist(A, B, tau)
+            sage: bij.set_statistics((len, len), (wex, des), (fix, adj))
+            sage: table([[key, AB[0], AB[1]] for key, AB in bij.statistics_fibers().items()])
+              (0, 0, 0)   [[]]                                [[]]
+              (1, 1, 1)   [[1]]                               [[1]]
+              (2, 2, 2)   [[1, 2]]                            [[2, 1]]
+              (2, 1, 0)   [[2, 1]]                            [[1, 2]]
+              (3, 3, 3)   [[1, 2, 3]]                         [[3, 2, 1]]
+              (3, 2, 1)   [[1, 3, 2], [2, 1, 3], [3, 2, 1]]   [[1, 3, 2], [2, 1, 3], [2, 3, 1]]
+              (3, 2, 0)   [[2, 3, 1]]                         [[3, 1, 2]]
+              (3, 1, 0)   [[3, 1, 2]]                         [[1, 2, 3]]
+
+        """
+        return self._statistics_fibers
+
+    def statistics_table(self, header=True):
+        r"""
+        Provide information about all elements of `A` with corresponding
+        `\alpha` values and all elements of `B` with corresponding
+        `\beta` and `\tau` values.
+
+        INPUT:
+
+        - ``header`` -- (default: ``True``) whether to include a
+          header with the standard Greek letters
+
+        OUTPUT:
+
+        A pair of lists suitable for :class:`~sage.misc.table.table`,
+        where
+
+        - the first contains the elements of `A` together with the
+          values of `\alpha`
+
+        - the second contains the elements of `B` together with the
+          values of `\tau` and `\beta`
+
+        EXAMPLES::
+
+            sage: A = B = [permutation for n in range(4) for permutation in Permutations(n)]
+            sage: tau = Permutation.longest_increasing_subsequence_length
+            sage: def wex(p): return len(p.weak_excedences())
+            sage: def fix(p): return len(p.fixed_points())
+            sage: def des(p): return len(p.descents(final_descent=True)) if p else 0
+            sage: def adj(p): return len([e for (e, f) in zip(p, p[1:]+[0]) if e == f+1])
+            sage: bij = Bijectionist(A, B, tau)
+            sage: bij.set_statistics((wex, des), (fix, adj))
+            sage: a, b = bij.statistics_table()
+            sage: table(a, header_row=True, frame=True)
+            +-----------+--------+--------+
+            | a         | α_1(a) | α_2(a) |
+            +===========+========+========+
+            | []        | 0      | 0      |
+            +-----------+--------+--------+
+            | [1]       | 1      | 1      |
+            +-----------+--------+--------+
+            | [1, 2]    | 2      | 2      |
+            +-----------+--------+--------+
+            | [2, 1]    | 1      | 0      |
+            +-----------+--------+--------+
+            | [1, 2, 3] | 3      | 3      |
+            +-----------+--------+--------+
+            | [1, 3, 2] | 2      | 1      |
+            +-----------+--------+--------+
+            | [2, 1, 3] | 2      | 1      |
+            +-----------+--------+--------+
+            | [2, 3, 1] | 2      | 0      |
+            +-----------+--------+--------+
+            | [3, 1, 2] | 1      | 0      |
+            +-----------+--------+--------+
+            | [3, 2, 1] | 2      | 1      |
+            +-----------+--------+--------+
+            sage: table(b, header_row=True, frame=True)
+            +-----------+---+--------+--------+
+            | b         | τ | β_1(b) | β_2(b) |
+            +===========+===+========+========+
+            | []        | 0 | 0      | 0      |
+            +-----------+---+--------+--------+
+            | [1]       | 1 | 1      | 1      |
+            +-----------+---+--------+--------+
+            | [1, 2]    | 2 | 1      | 0      |
+            +-----------+---+--------+--------+
+            | [2, 1]    | 1 | 2      | 2      |
+            +-----------+---+--------+--------+
+            | [1, 2, 3] | 3 | 1      | 0      |
+            +-----------+---+--------+--------+
+            | [1, 3, 2] | 2 | 2      | 1      |
+            +-----------+---+--------+--------+
+            | [2, 1, 3] | 2 | 2      | 1      |
+            +-----------+---+--------+--------+
+            | [2, 3, 1] | 2 | 2      | 1      |
+            +-----------+---+--------+--------+
+            | [3, 1, 2] | 2 | 2      | 0      |
+            +-----------+---+--------+--------+
+            | [3, 2, 1] | 1 | 3      | 3      |
+            +-----------+---+--------+--------+
+
+        TESTS:
+
+        If no statistics are given, the table should still be able to be generated::
+
+            sage: A = B = [permutation for n in range(3) for permutation in Permutations(n)]
+            sage: tau = Permutation.longest_increasing_subsequence_length
+            sage: bij = Bijectionist(A, B, tau)
+            sage: a, b = bij.statistics_table()
+            sage: table(a, header_row=True, frame=True)
+            +--------+
+            | a      |
+            +========+
+            | []     |
+            +--------+
+            | [1]    |
+            +--------+
+            | [1, 2] |
+            +--------+
+            | [2, 1] |
+            +--------+
+            sage: table(b, header_row=True, frame=True)
+            +--------+---+
+            | b      | τ |
+            +========+===+
+            | []     | 0 |
+            +--------+---+
+            | [1]    | 1 |
+            +--------+---+
+            | [1, 2] | 2 |
+            +--------+---+
+            | [2, 1] | 1 |
+            +--------+---+
+
+        We can omit the header::
+
+            sage: bij.statistics_table(header=True)[1]
+            [['b', 'τ'], [[], 0], [[1], 1], [[1, 2], 2], [[2, 1], 1]]
+            sage: bij.statistics_table(header=False)[1]
+            [[[], 0], [[1], 1], [[1, 2], 2], [[2, 1], 1]]
+
+        """
+        # table for alpha
+        n_statistics = self._n_statistics
+        if header:
+            output_alphas = [["a"] + ["\u03b1_" + str(i) + "(a)"
+                                      for i in range(1, n_statistics + 1)]]
+        else:
+            output_alphas = []
+
+        for a in self._A:
+            if n_statistics > 0:
+                output_alphas.append([a] + list(self._alpha(a)))
+            else:
+                output_alphas.append([a])
+
+        # table for beta and tau
+        if header:
+            output_tau_betas = [["b", "\u03c4"] + ["\u03b2_" + str(i) + "(b)"
+                                                   for i in range(1, n_statistics + 1)]]
+        else:
+            output_tau_betas = []
+        for b in self._B:
+            if n_statistics > 0:
+                output_tau_betas.append([b, self._tau[b]] + list(self._beta(b)))
+            else:
+                output_tau_betas.append([b, self._tau[b]])
+
+        return output_alphas, output_tau_betas
+
+    def set_value_restrictions(self, *value_restrictions):
+        r"""
+        Restrict the set of possible values `s(a)` for a given element
+        `a`.
+
+        .. WARNING::
+
+             Any restriction imposed by a previous invocation of
+             :meth:`set_value_restrictions` will be overwritten!
+
+        INPUT:
+
+        - ``value_restrictions`` -- one or more pairs `(a\in A, \tilde
+          Z\subseteq Z)`
+
+        EXAMPLES:
+
+        We may want to restrict the value of a given element to a
+        single or multiple values.  We do not require that the
+        specified values are in the image of `\tau`.  In some
+        cases, the restriction may not be able to provide a better
+        solution, as for size 3 in the following example. ::
+
+            sage: A = B = [permutation for n in range(4) for permutation in Permutations(n)]
+            sage: tau = Permutation.longest_increasing_subsequence_length
+            sage: bij = Bijectionist(A, B, tau)
+            sage: bij.set_statistics((len, len))
+            sage: bij.set_value_restrictions((Permutation([1, 2]), [1]),
+            ....:                            (Permutation([3, 2, 1]), [2, 3, 4]))
+            sage: for sol in sorted(bij.solutions_iterator(), key=lambda d: sorted(d.items())):
+            ....:     print(sol)
+            {[]: 0, [1]: 1, [1, 2]: 1, [2, 1]: 2, [1, 2, 3]: 1, [1, 3, 2]: 2, [2, 1, 3]: 2, [2, 3, 1]: 2, [3, 1, 2]: 2, [3, 2, 1]: 3}
+            {[]: 0, [1]: 1, [1, 2]: 1, [2, 1]: 2, [1, 2, 3]: 1, [1, 3, 2]: 2, [2, 1, 3]: 2, [2, 3, 1]: 2, [3, 1, 2]: 3, [3, 2, 1]: 2}
+            {[]: 0, [1]: 1, [1, 2]: 1, [2, 1]: 2, [1, 2, 3]: 1, [1, 3, 2]: 2, [2, 1, 3]: 2, [2, 3, 1]: 3, [3, 1, 2]: 2, [3, 2, 1]: 2}
+            {[]: 0, [1]: 1, [1, 2]: 1, [2, 1]: 2, [1, 2, 3]: 1, [1, 3, 2]: 2, [2, 1, 3]: 3, [2, 3, 1]: 2, [3, 1, 2]: 2, [3, 2, 1]: 2}
+            {[]: 0, [1]: 1, [1, 2]: 1, [2, 1]: 2, [1, 2, 3]: 1, [1, 3, 2]: 3, [2, 1, 3]: 2, [2, 3, 1]: 2, [3, 1, 2]: 2, [3, 2, 1]: 2}
+            {[]: 0, [1]: 1, [1, 2]: 1, [2, 1]: 2, [1, 2, 3]: 2, [1, 3, 2]: 1, [2, 1, 3]: 2, [2, 3, 1]: 2, [3, 1, 2]: 2, [3, 2, 1]: 3}
+            {[]: 0, [1]: 1, [1, 2]: 1, [2, 1]: 2, [1, 2, 3]: 2, [1, 3, 2]: 1, [2, 1, 3]: 2, [2, 3, 1]: 2, [3, 1, 2]: 3, [3, 2, 1]: 2}
+            {[]: 0, [1]: 1, [1, 2]: 1, [2, 1]: 2, [1, 2, 3]: 2, [1, 3, 2]: 1, [2, 1, 3]: 2, [2, 3, 1]: 3, [3, 1, 2]: 2, [3, 2, 1]: 2}
+            {[]: 0, [1]: 1, [1, 2]: 1, [2, 1]: 2, [1, 2, 3]: 2, [1, 3, 2]: 1, [2, 1, 3]: 3, [2, 3, 1]: 2, [3, 1, 2]: 2, [3, 2, 1]: 2}
+            {[]: 0, [1]: 1, [1, 2]: 1, [2, 1]: 2, [1, 2, 3]: 2, [1, 3, 2]: 2, [2, 1, 3]: 1, [2, 3, 1]: 2, [3, 1, 2]: 2, [3, 2, 1]: 3}
+            {[]: 0, [1]: 1, [1, 2]: 1, [2, 1]: 2, [1, 2, 3]: 2, [1, 3, 2]: 2, [2, 1, 3]: 1, [2, 3, 1]: 2, [3, 1, 2]: 3, [3, 2, 1]: 2}
+            {[]: 0, [1]: 1, [1, 2]: 1, [2, 1]: 2, [1, 2, 3]: 2, [1, 3, 2]: 2, [2, 1, 3]: 1, [2, 3, 1]: 3, [3, 1, 2]: 2, [3, 2, 1]: 2}
+            {[]: 0, [1]: 1, [1, 2]: 1, [2, 1]: 2, [1, 2, 3]: 2, [1, 3, 2]: 2, [2, 1, 3]: 2, [2, 3, 1]: 1, [3, 1, 2]: 2, [3, 2, 1]: 3}
+            {[]: 0, [1]: 1, [1, 2]: 1, [2, 1]: 2, [1, 2, 3]: 2, [1, 3, 2]: 2, [2, 1, 3]: 2, [2, 3, 1]: 1, [3, 1, 2]: 3, [3, 2, 1]: 2}
+            {[]: 0, [1]: 1, [1, 2]: 1, [2, 1]: 2, [1, 2, 3]: 2, [1, 3, 2]: 2, [2, 1, 3]: 2, [2, 3, 1]: 2, [3, 1, 2]: 1, [3, 2, 1]: 3}
+            {[]: 0, [1]: 1, [1, 2]: 1, [2, 1]: 2, [1, 2, 3]: 2, [1, 3, 2]: 2, [2, 1, 3]: 2, [2, 3, 1]: 3, [3, 1, 2]: 1, [3, 2, 1]: 2}
+            {[]: 0, [1]: 1, [1, 2]: 1, [2, 1]: 2, [1, 2, 3]: 2, [1, 3, 2]: 2, [2, 1, 3]: 3, [2, 3, 1]: 1, [3, 1, 2]: 2, [3, 2, 1]: 2}
+            {[]: 0, [1]: 1, [1, 2]: 1, [2, 1]: 2, [1, 2, 3]: 2, [1, 3, 2]: 2, [2, 1, 3]: 3, [2, 3, 1]: 2, [3, 1, 2]: 1, [3, 2, 1]: 2}
+            {[]: 0, [1]: 1, [1, 2]: 1, [2, 1]: 2, [1, 2, 3]: 2, [1, 3, 2]: 3, [2, 1, 3]: 1, [2, 3, 1]: 2, [3, 1, 2]: 2, [3, 2, 1]: 2}
+            {[]: 0, [1]: 1, [1, 2]: 1, [2, 1]: 2, [1, 2, 3]: 2, [1, 3, 2]: 3, [2, 1, 3]: 2, [2, 3, 1]: 1, [3, 1, 2]: 2, [3, 2, 1]: 2}
+            {[]: 0, [1]: 1, [1, 2]: 1, [2, 1]: 2, [1, 2, 3]: 2, [1, 3, 2]: 3, [2, 1, 3]: 2, [2, 3, 1]: 2, [3, 1, 2]: 1, [3, 2, 1]: 2}
+            {[]: 0, [1]: 1, [1, 2]: 1, [2, 1]: 2, [1, 2, 3]: 3, [1, 3, 2]: 1, [2, 1, 3]: 2, [2, 3, 1]: 2, [3, 1, 2]: 2, [3, 2, 1]: 2}
+            {[]: 0, [1]: 1, [1, 2]: 1, [2, 1]: 2, [1, 2, 3]: 3, [1, 3, 2]: 2, [2, 1, 3]: 1, [2, 3, 1]: 2, [3, 1, 2]: 2, [3, 2, 1]: 2}
+            {[]: 0, [1]: 1, [1, 2]: 1, [2, 1]: 2, [1, 2, 3]: 3, [1, 3, 2]: 2, [2, 1, 3]: 2, [2, 3, 1]: 1, [3, 1, 2]: 2, [3, 2, 1]: 2}
+            {[]: 0, [1]: 1, [1, 2]: 1, [2, 1]: 2, [1, 2, 3]: 3, [1, 3, 2]: 2, [2, 1, 3]: 2, [2, 3, 1]: 2, [3, 1, 2]: 1, [3, 2, 1]: 2}
+
+        However, an error occurs if the set of possible values is
+        empty.  In this example, the image of `\tau` under any
+        legal bijection is disjoint to the specified values.
+
+        TESTS::
+
+            sage: A = B = [permutation for n in range(4) for permutation in Permutations(n)]
+            sage: tau = Permutation.longest_increasing_subsequence_length
+            sage: bij = Bijectionist(A, B, tau)
+            sage: bij.set_value_restrictions((Permutation([1, 2]), [4, 5]))
+            sage: bij._compute_possible_block_values()
+            Traceback (most recent call last):
+            ...
+            ValueError: no possible values found for singleton block [[1, 2]]
+
+            sage: A = B = [permutation for n in range(4) for permutation in Permutations(n)]
+            sage: tau = Permutation.longest_increasing_subsequence_length
+            sage: bij = Bijectionist(A, B, tau)
+            sage: bij.set_constant_blocks([[permutation for permutation in Permutations(n)] for n in range(4)])
+            sage: bij.set_value_restrictions((Permutation([1, 2]), [4, 5]))
+            sage: bij._compute_possible_block_values()
+            Traceback (most recent call last):
+            ...
+            ValueError: no possible values found for block [[1, 2], [2, 1]]
+
+            sage: A = B = [permutation for n in range(4) for permutation in Permutations(n)]
+            sage: tau = Permutation.longest_increasing_subsequence_length
+            sage: bij = Bijectionist(A, B, tau)
+            sage: bij.set_value_restrictions(((1, 2), [4, 5, 6]))
+            Traceback (most recent call last):
+            ...
+            AssertionError: element (1, 2) was not found in A
+
+        """
+        # it might be much cheaper to construct the sets as subsets
+        # of _statistics_possible_values - however, we do not want to
+        # insist that set_value_restrictions is called after
+        # set_statistics
+        self._bmilp = None
+        set_Z = set(self._Z)
+        self._restrictions_possible_values = {a: set_Z for a in self._A}
+        for a, values in value_restrictions:
+            assert a in self._A, f"element {a} was not found in A"
+            self._restrictions_possible_values[a] = self._restrictions_possible_values[a].intersection(values)
+
+    def _compute_possible_block_values(self):
+        r"""
+        Update the dictionary of possible values of each block.
+
+        This has to be called whenever ``self._P`` was modified.
+
+        It raises a :class:`ValueError`, if the restrictions on a
+        block are contradictory.
+
+        TESTS::
+
+            sage: A = B = [permutation for n in range(4) for permutation in Permutations(n)]
+            sage: tau = Permutation.longest_increasing_subsequence_length
+            sage: bij = Bijectionist(A, B, tau)
+            sage: bij.set_value_restrictions((Permutation([1, 2]), [4, 5]))
+            sage: bij._compute_possible_block_values()
+            Traceback (most recent call last):
+            ...
+            ValueError: no possible values found for singleton block [[1, 2]]
+
+        """
+        self._possible_block_values = {}  # P -> Power(Z)
+        for p, block in self._P.root_to_elements_dict().items():
+            sets = ([self._restrictions_possible_values[a] for a in block]
+                    + [self._statistics_possible_values[a] for a in block])
+            self._possible_block_values[p] = _non_copying_intersection(sets)
+            if not self._possible_block_values[p]:
+                if len(block) == 1:
+                    raise ValueError(f"no possible values found for singleton block {block}")
+                else:
+                    raise ValueError(f"no possible values found for block {block}")
+
+    def set_distributions(self, *elements_distributions):
+        r"""
+        Specify the distribution of `s` for a subset of elements.
+
+        .. WARNING::
+
+             Any restriction imposed by a previous invocation of
+             :meth:`set_distributions` will be overwritten!
+
+        INPUT:
+
+        - one or more pairs of `(\tilde A, \tilde Z)`, where `\tilde
+          A\subseteq A` and `\tilde Z` is a list of values in `Z` of
+          the same size as `\tilde A`
+
+        This method specifies that `\{s(a) | a\in\tilde A\}` equals
+        `\tilde Z` as a multiset for each of the pairs.
+
+        When specifying several distributions, the subsets of `A` do
+        not have to be disjoint.
+
+        ALGORITHM:
+
+        We add
+
+        .. MATH::
+
+            \sum_{a\in\tilde A} x_{p(a), z}t^z = \sum_{z\in\tilde Z} t^z,
+
+        where `p(a)` is the block containing `a`, for each given
+        distribution as a vector equation to the MILP.
+
+        EXAMPLES::
+
+            sage: A = B = [permutation for n in range(4) for permutation in Permutations(n)]
+            sage: tau = Permutation.longest_increasing_subsequence_length
+            sage: bij = Bijectionist(A, B, tau)
+            sage: bij.set_statistics((len, len))
+            sage: bij.set_distributions(([Permutation([1, 2, 3]), Permutation([1, 3, 2])], [1, 3]))
+            sage: for sol in sorted(list(bij.solutions_iterator()), key=lambda d: tuple(sorted(d.items()))):
+            ....:     print(sol)
+            {[]: 0, [1]: 1, [1, 2]: 1, [2, 1]: 2, [1, 2, 3]: 1, [1, 3, 2]: 3, [2, 1, 3]: 2, [2, 3, 1]: 2, [3, 1, 2]: 2, [3, 2, 1]: 2}
+            {[]: 0, [1]: 1, [1, 2]: 1, [2, 1]: 2, [1, 2, 3]: 3, [1, 3, 2]: 1, [2, 1, 3]: 2, [2, 3, 1]: 2, [3, 1, 2]: 2, [3, 2, 1]: 2}
+            {[]: 0, [1]: 1, [1, 2]: 2, [2, 1]: 1, [1, 2, 3]: 1, [1, 3, 2]: 3, [2, 1, 3]: 2, [2, 3, 1]: 2, [3, 1, 2]: 2, [3, 2, 1]: 2}
+            {[]: 0, [1]: 1, [1, 2]: 2, [2, 1]: 1, [1, 2, 3]: 3, [1, 3, 2]: 1, [2, 1, 3]: 2, [2, 3, 1]: 2, [3, 1, 2]: 2, [3, 2, 1]: 2}
+
+            sage: bij.constant_blocks(optimal=True)
+            {{[2, 1, 3], [2, 3, 1], [3, 1, 2], [3, 2, 1]}}
+            sage: sorted(bij.minimal_subdistributions_blocks_iterator(), key=lambda d: (len(d[0]), d[0]))
+            [([[]], [0]),
+             ([[1]], [1]),
+             ([[2, 1, 3]], [2]),
+             ([[1, 2], [2, 1]], [1, 2]),
+             ([[1, 2, 3], [1, 3, 2]], [1, 3])]
+
+        We may also specify multiple, possibly overlapping distributions::
+
+            sage: bij.set_distributions(([Permutation([1, 2, 3]), Permutation([1, 3, 2])], [1, 3]),
+            ....:                       ([Permutation([1, 3, 2]), Permutation([3, 2, 1]),
+            ....:                        Permutation([2, 1, 3])], [1, 2, 2]))
+            sage: for sol in sorted(list(bij.solutions_iterator()), key=lambda d: tuple(sorted(d.items()))):
+            ....:     print(sol)
+            {[]: 0, [1]: 1, [1, 2]: 1, [2, 1]: 2, [1, 2, 3]: 3, [1, 3, 2]: 1, [2, 1, 3]: 2, [2, 3, 1]: 2, [3, 1, 2]: 2, [3, 2, 1]: 2}
+            {[]: 0, [1]: 1, [1, 2]: 2, [2, 1]: 1, [1, 2, 3]: 3, [1, 3, 2]: 1, [2, 1, 3]: 2, [2, 3, 1]: 2, [3, 1, 2]: 2, [3, 2, 1]: 2}
+
+            sage: bij.constant_blocks(optimal=True)
+            {{[1], [1, 3, 2]}, {[2, 1, 3], [2, 3, 1], [3, 1, 2], [3, 2, 1]}}
+            sage: sorted(bij.minimal_subdistributions_blocks_iterator(), key=lambda d: (len(d[0]), d[0]))
+            [([[]], [0]),
+             ([[1]], [1]),
+             ([[1, 2, 3]], [3]),
+             ([[2, 3, 1]], [2]),
+             ([[1, 2], [2, 1]], [1, 2])]
+
+        TESTS:
+
+        Because of the current implementation of the output calculation, we do
+        not improve our solution if we do not gain any unique solutions::
+
+            sage: A = B = [permutation for n in range(4) for permutation in Permutations(n)]
+            sage: tau = Permutation.longest_increasing_subsequence_length
+            sage: bij = Bijectionist(A, B, tau)
+            sage: bij.set_statistics((len, len))
+            sage: bij.set_distributions(([Permutation([1, 2, 3]), Permutation([1, 3, 2])], [2, 3]))
+            sage: for sol in sorted(list(bij.solutions_iterator()), key=lambda d: tuple(sorted(d.items()))):
+            ....:     print(sol)
+            {[]: 0, [1]: 1, [1, 2]: 1, [2, 1]: 2, [1, 2, 3]: 2, [1, 3, 2]: 3, [2, 1, 3]: 1, [2, 3, 1]: 2, [3, 1, 2]: 2, [3, 2, 1]: 2}
+            {[]: 0, [1]: 1, [1, 2]: 1, [2, 1]: 2, [1, 2, 3]: 2, [1, 3, 2]: 3, [2, 1, 3]: 2, [2, 3, 1]: 1, [3, 1, 2]: 2, [3, 2, 1]: 2}
+            {[]: 0, [1]: 1, [1, 2]: 1, [2, 1]: 2, [1, 2, 3]: 2, [1, 3, 2]: 3, [2, 1, 3]: 2, [2, 3, 1]: 2, [3, 1, 2]: 1, [3, 2, 1]: 2}
+            {[]: 0, [1]: 1, [1, 2]: 1, [2, 1]: 2, [1, 2, 3]: 2, [1, 3, 2]: 3, [2, 1, 3]: 2, [2, 3, 1]: 2, [3, 1, 2]: 2, [3, 2, 1]: 1}
+            {[]: 0, [1]: 1, [1, 2]: 1, [2, 1]: 2, [1, 2, 3]: 3, [1, 3, 2]: 2, [2, 1, 3]: 1, [2, 3, 1]: 2, [3, 1, 2]: 2, [3, 2, 1]: 2}
+            {[]: 0, [1]: 1, [1, 2]: 1, [2, 1]: 2, [1, 2, 3]: 3, [1, 3, 2]: 2, [2, 1, 3]: 2, [2, 3, 1]: 1, [3, 1, 2]: 2, [3, 2, 1]: 2}
+            {[]: 0, [1]: 1, [1, 2]: 1, [2, 1]: 2, [1, 2, 3]: 3, [1, 3, 2]: 2, [2, 1, 3]: 2, [2, 3, 1]: 2, [3, 1, 2]: 1, [3, 2, 1]: 2}
+            {[]: 0, [1]: 1, [1, 2]: 1, [2, 1]: 2, [1, 2, 3]: 3, [1, 3, 2]: 2, [2, 1, 3]: 2, [2, 3, 1]: 2, [3, 1, 2]: 2, [3, 2, 1]: 1}
+            {[]: 0, [1]: 1, [1, 2]: 2, [2, 1]: 1, [1, 2, 3]: 2, [1, 3, 2]: 3, [2, 1, 3]: 1, [2, 3, 1]: 2, [3, 1, 2]: 2, [3, 2, 1]: 2}
+            {[]: 0, [1]: 1, [1, 2]: 2, [2, 1]: 1, [1, 2, 3]: 2, [1, 3, 2]: 3, [2, 1, 3]: 2, [2, 3, 1]: 1, [3, 1, 2]: 2, [3, 2, 1]: 2}
+            {[]: 0, [1]: 1, [1, 2]: 2, [2, 1]: 1, [1, 2, 3]: 2, [1, 3, 2]: 3, [2, 1, 3]: 2, [2, 3, 1]: 2, [3, 1, 2]: 1, [3, 2, 1]: 2}
+            {[]: 0, [1]: 1, [1, 2]: 2, [2, 1]: 1, [1, 2, 3]: 2, [1, 3, 2]: 3, [2, 1, 3]: 2, [2, 3, 1]: 2, [3, 1, 2]: 2, [3, 2, 1]: 1}
+            {[]: 0, [1]: 1, [1, 2]: 2, [2, 1]: 1, [1, 2, 3]: 3, [1, 3, 2]: 2, [2, 1, 3]: 1, [2, 3, 1]: 2, [3, 1, 2]: 2, [3, 2, 1]: 2}
+            {[]: 0, [1]: 1, [1, 2]: 2, [2, 1]: 1, [1, 2, 3]: 3, [1, 3, 2]: 2, [2, 1, 3]: 2, [2, 3, 1]: 1, [3, 1, 2]: 2, [3, 2, 1]: 2}
+            {[]: 0, [1]: 1, [1, 2]: 2, [2, 1]: 1, [1, 2, 3]: 3, [1, 3, 2]: 2, [2, 1, 3]: 2, [2, 3, 1]: 2, [3, 1, 2]: 1, [3, 2, 1]: 2}
+            {[]: 0, [1]: 1, [1, 2]: 2, [2, 1]: 1, [1, 2, 3]: 3, [1, 3, 2]: 2, [2, 1, 3]: 2, [2, 3, 1]: 2, [3, 1, 2]: 2, [3, 2, 1]: 1}
+
+        Another example with statistics::
+
+            sage: bij = Bijectionist(A, B, tau)
+            sage: def alpha(p): return p(1) if len(p) > 0 else 0
+            sage: def beta(p): return p(1) if len(p) > 0 else 0
+            sage: bij.set_statistics((alpha, beta), (len, len))
+            sage: for sol in sorted(list(bij.solutions_iterator()), key=lambda d: tuple(sorted(d.items()))):
+            ....:     print(sol)
+            {[]: 0, [1]: 1, [1, 2]: 2, [2, 1]: 1, [1, 2, 3]: 2, [1, 3, 2]: 3, [2, 1, 3]: 2, [2, 3, 1]: 2, [3, 1, 2]: 1, [3, 2, 1]: 2}
+            {[]: 0, [1]: 1, [1, 2]: 2, [2, 1]: 1, [1, 2, 3]: 2, [1, 3, 2]: 3, [2, 1, 3]: 2, [2, 3, 1]: 2, [3, 1, 2]: 2, [3, 2, 1]: 1}
+            {[]: 0, [1]: 1, [1, 2]: 2, [2, 1]: 1, [1, 2, 3]: 3, [1, 3, 2]: 2, [2, 1, 3]: 2, [2, 3, 1]: 2, [3, 1, 2]: 1, [3, 2, 1]: 2}
+            {[]: 0, [1]: 1, [1, 2]: 2, [2, 1]: 1, [1, 2, 3]: 3, [1, 3, 2]: 2, [2, 1, 3]: 2, [2, 3, 1]: 2, [3, 1, 2]: 2, [3, 2, 1]: 1}
+
+        The solution above is not unique.  We can add a feasible distribution to force uniqueness::
+
+            sage: bij = Bijectionist(A, B, tau)
+            sage: bij.set_statistics((alpha, beta), (len, len))
+            sage: bij.set_distributions(([Permutation([1, 2, 3]), Permutation([3, 2, 1])], [1, 3]))
+            sage: for sol in bij.solutions_iterator():
+            ....:     print(sol)
+            {[]: 0, [1]: 1, [1, 2]: 2, [2, 1]: 1, [1, 2, 3]: 3, [1, 3, 2]: 2, [2, 1, 3]: 2, [2, 3, 1]: 2, [3, 1, 2]: 2, [3, 2, 1]: 1}
+
+        Let us try to add a distribution that cannot be satisfied,
+        because there is no solution where a permutation that starts
+        with 1 is mapped onto 1::
+
+            sage: bij = Bijectionist(A, B, tau)
+            sage: bij.set_statistics((alpha, beta), (len, len))
+            sage: bij.set_distributions(([Permutation([1, 2, 3]), Permutation([1, 3, 2])], [1, 3]))
+            sage: list(bij.solutions_iterator())
+            []
+
+        The specified elements have to be in `A` and have to be of the same size::
+
+            sage: bij = Bijectionist(A, B, tau)
+            sage: bij.set_statistics((len, len))
+            sage: bij.set_distributions(([Permutation([1, 2, 3, 4])], [1]))
+            Traceback (most recent call last):
+            ...
+            ValueError: element [1, 2, 3, 4] was not found in A
+            sage: bij.set_distributions(([Permutation([1, 2, 3])], [-1]))
+            Traceback (most recent call last):
+            ...
+            ValueError: value -1 was not found in tau(A)
+
+        Note that the same error occurs when an element that is not the first element of the list is
+        not in `A`.
+
+        """
+        self._bmilp = None
+        for tA, tZ in elements_distributions:
+            assert len(tA) == len(tZ), f"{tA} and {tZ} are not of the same size!"
+            for a, z in zip(tA, tZ):
+                if a not in self._A:
+                    raise ValueError(f"element {a} was not found in A")
+                if z not in self._Z:
+                    raise ValueError(f"value {z} was not found in tau(A)")
+        self._elements_distributions = tuple(elements_distributions)
+
+    def set_intertwining_relations(self, *pi_rho):
+        r"""
+        Add restrictions of the form `s(\pi(a_1,\dots, a_k)) =
+        \rho(s(a_1),\dots, s(a_k))`.
+
+        .. WARNING::
+
+             Any restriction imposed by a previous invocation of
+             :meth:`set_intertwining_relations` will be overwritten!
+
+        INPUT:
+
+        - ``pi_rho`` -- one or more tuples `(k, \pi: A^k\to A, \rho:
+          Z^k\to Z, \tilde A)` where `\tilde A` (optional) is a
+          `k`-ary function that returns true if and only if a
+          `k`-tuple of objects in `A` is in the domain of `\pi`
+
+        ALGORITHM:
+
+        The relation
+
+        .. MATH::
+
+            s(\pi(a_1,\dots, a_k)) = \rho(s(a_1),\dots, s(a_k))
+
+        for each pair `(\pi, \rho)` implies immediately that
+        `s(\pi(a_1,\dots, a_k))` only depends on the blocks of
+        `a_1,\dots, a_k`.
+
+        The MILP formulation is as follows.  Let `a_1,\dots,a_k \in
+        A` and let `a = \pi(a_1,\dots,a_k)`.  Let `z_1,\dots,z_k \in
+        Z` and let `z = \rho(z_1,\dots,z_k)`.  Suppose that `a_i\in
+        p_i` for all `i` and that `a\in p`.
+
+        We then want to model the implication
+
+        .. MATH::
+
+           x_{p_1, z_1} = 1,\dots, x_{p_k, z_k} = 1 \Rightarrow x_{p, z} = 1.
+
+        We achieve this by requiring
+
+        .. MATH::
+
+            x_{p, z}\geq 1 - k + \sum_{i=1}^k x_{p_i, z_i}.
+
+        Note that `z` must be a possible value of `p` and each `z_i`
+        must be a possible value of `p_i`.
+
+        EXAMPLES:
+
+        We can concatenate two permutations by increasing the values
+        of the second permutation by the length of the first
+        permutation::
+
+            sage: def concat(p1, p2): return Permutation(p1 + [i + len(p1) for i in p2])
+
+        We may be interested in statistics on permutations which are
+        equidistributed with the number of fixed points, such that
+        concatenating permutations corresponds to adding statistic
+        values::
+
+            sage: A = B = [permutation for n in range(4) for permutation in Permutations(n)]
+            sage: bij = Bijectionist(A, B, Permutation.number_of_fixed_points)
+            sage: bij.set_statistics((len, len))
+            sage: for solution in sorted(list(bij.solutions_iterator()), key=lambda d: tuple(sorted(d.items()))):
+            ....:     print(solution)
+            ...
+            {[]: 0, [1]: 1, [1, 2]: 0, [2, 1]: 2, [1, 2, 3]: 1, [1, 3, 2]: 0, [2, 1, 3]: 0, [2, 3, 1]: 1, [3, 1, 2]: 1, [3, 2, 1]: 3}
+            ...
+            {[]: 0, [1]: 1, [1, 2]: 0, [2, 1]: 2, [1, 2, 3]: 1, [1, 3, 2]: 1, [2, 1, 3]: 3, [2, 3, 1]: 0, [3, 1, 2]: 0, [3, 2, 1]: 1}
+            ...
+
+            sage: bij.set_intertwining_relations((2, concat, lambda x, y: x + y))
+            sage: for solution in sorted(list(bij.solutions_iterator()), key=lambda d: tuple(sorted(d.items()))):
+            ....:     print(solution)
+            {[]: 0, [1]: 1, [1, 2]: 2, [2, 1]: 0, [1, 2, 3]: 3, [1, 3, 2]: 1, [2, 1, 3]: 1, [2, 3, 1]: 0, [3, 1, 2]: 0, [3, 2, 1]: 1}
+            {[]: 0, [1]: 1, [1, 2]: 2, [2, 1]: 0, [1, 2, 3]: 3, [1, 3, 2]: 1, [2, 1, 3]: 1, [2, 3, 1]: 0, [3, 1, 2]: 1, [3, 2, 1]: 0}
+            {[]: 0, [1]: 1, [1, 2]: 2, [2, 1]: 0, [1, 2, 3]: 3, [1, 3, 2]: 1, [2, 1, 3]: 1, [2, 3, 1]: 1, [3, 1, 2]: 0, [3, 2, 1]: 0}
+
+        The domain of the composition may be restricted.  E.g., if we
+        concatenate only permutations starting with a 1, we obtain
+        fewer forced elements::
+
+            sage: in_domain = lambda p1, p2: (not p1 or p1(1) == 1) and (not p2 or p2(1) == 1)
+            sage: bij.set_intertwining_relations((2, concat, lambda x, y: x + y, in_domain))
+            sage: for solution in sorted(list(bij.solutions_iterator()), key=lambda d: tuple(sorted(d.items()))):
+            ....:     print(solution)
+            {[]: 0, [1]: 1, [1, 2]: 2, [2, 1]: 0, [1, 2, 3]: 3, [1, 3, 2]: 0, [2, 1, 3]: 0, [2, 3, 1]: 1, [3, 1, 2]: 1, [3, 2, 1]: 1}
+            {[]: 0, [1]: 1, [1, 2]: 2, [2, 1]: 0, [1, 2, 3]: 3, [1, 3, 2]: 0, [2, 1, 3]: 1, [2, 3, 1]: 0, [3, 1, 2]: 1, [3, 2, 1]: 1}
+            {[]: 0, [1]: 1, [1, 2]: 2, [2, 1]: 0, [1, 2, 3]: 3, [1, 3, 2]: 0, [2, 1, 3]: 1, [2, 3, 1]: 1, [3, 1, 2]: 0, [3, 2, 1]: 1}
+            {[]: 0, [1]: 1, [1, 2]: 2, [2, 1]: 0, [1, 2, 3]: 3, [1, 3, 2]: 0, [2, 1, 3]: 1, [2, 3, 1]: 1, [3, 1, 2]: 1, [3, 2, 1]: 0}
+            {[]: 0, [1]: 1, [1, 2]: 2, [2, 1]: 0, [1, 2, 3]: 3, [1, 3, 2]: 1, [2, 1, 3]: 0, [2, 3, 1]: 0, [3, 1, 2]: 1, [3, 2, 1]: 1}
+            {[]: 0, [1]: 1, [1, 2]: 2, [2, 1]: 0, [1, 2, 3]: 3, [1, 3, 2]: 1, [2, 1, 3]: 0, [2, 3, 1]: 1, [3, 1, 2]: 0, [3, 2, 1]: 1}
+            {[]: 0, [1]: 1, [1, 2]: 2, [2, 1]: 0, [1, 2, 3]: 3, [1, 3, 2]: 1, [2, 1, 3]: 0, [2, 3, 1]: 1, [3, 1, 2]: 1, [3, 2, 1]: 0}
+            {[]: 0, [1]: 1, [1, 2]: 2, [2, 1]: 0, [1, 2, 3]: 3, [1, 3, 2]: 1, [2, 1, 3]: 1, [2, 3, 1]: 0, [3, 1, 2]: 0, [3, 2, 1]: 1}
+            {[]: 0, [1]: 1, [1, 2]: 2, [2, 1]: 0, [1, 2, 3]: 3, [1, 3, 2]: 1, [2, 1, 3]: 1, [2, 3, 1]: 0, [3, 1, 2]: 1, [3, 2, 1]: 0}
+            {[]: 0, [1]: 1, [1, 2]: 2, [2, 1]: 0, [1, 2, 3]: 3, [1, 3, 2]: 1, [2, 1, 3]: 1, [2, 3, 1]: 1, [3, 1, 2]: 0, [3, 2, 1]: 0}
+
+        We can also restrict according to several composition
+        functions.  For example, we may additionally concatenate
+        permutations by incrementing the elements of the first::
+
+            sage: skew_concat = lambda p1, p2: Permutation([i + len(p2) for i in p1] + list(p2))
+            sage: bij.set_intertwining_relations((2, skew_concat, lambda x, y: x + y))
+            sage: for solution in sorted(list(bij.solutions_iterator()), key=lambda d: tuple(sorted(d.items()))):
+            ....:     print(solution)
+            {[]: 0, [1]: 1, [1, 2]: 0, [2, 1]: 2, [1, 2, 3]: 0, [1, 3, 2]: 0, [2, 1, 3]: 1, [2, 3, 1]: 1, [3, 1, 2]: 1, [3, 2, 1]: 3}
+            {[]: 0, [1]: 1, [1, 2]: 0, [2, 1]: 2, [1, 2, 3]: 0, [1, 3, 2]: 1, [2, 1, 3]: 0, [2, 3, 1]: 1, [3, 1, 2]: 1, [3, 2, 1]: 3}
+            {[]: 0, [1]: 1, [1, 2]: 0, [2, 1]: 2, [1, 2, 3]: 1, [1, 3, 2]: 0, [2, 1, 3]: 0, [2, 3, 1]: 1, [3, 1, 2]: 1, [3, 2, 1]: 3}
+
+        However, this yields no solution::
+
+            sage: bij.set_intertwining_relations((2, concat, lambda x, y: x + y), (2, skew_concat, lambda x, y: x + y))
+            sage: list(bij.solutions_iterator())
+            []
+
+        """
+        self._bmilp = None
+        Pi_Rho = namedtuple("Pi_Rho", "numargs pi rho domain")
+        self._pi_rho = []
+
+        for pi_rho_tuple in pi_rho:
+            if len(pi_rho_tuple) == 3:
+                k, pi, rho = pi_rho_tuple
+                domain = None
+            else:
+                k, pi, rho, domain = pi_rho_tuple
+
+            self._pi_rho.append(Pi_Rho(numargs=k, pi=pi, rho=rho, domain=domain))
+
+    set_semi_conjugacy = set_intertwining_relations
+
+    def set_quadratic_relation(self, *phi_psi):
+        r"""
+        Add restrictions of the form `s\circ\psi\circ s = \phi`.
+
+        INPUT:
+
+        - ``phi_psi`` -- (optional) a list of pairs `(\phi, \rho)` where `\phi:
+          A\to Z` and `\psi: Z\to A`
+
+        ALGORITHM:
+
+        We add
+
+        .. MATH::
+
+            x_{p(a), z} = x_{p(\psi(z)), \phi(a)}
+
+        for `a\in A` and `z\in Z` to the MILP, where `\phi:A\to Z`
+        and `\psi:Z\to A`.  Note that, in particular, `\phi` must be
+        constant on blocks.
+
+
+        EXAMPLES::
+
+            sage: A = B = DyckWords(3)
+            sage: bij = Bijectionist(A, B)
+            sage: bij.set_statistics((lambda D: D.number_of_touch_points(), lambda D: D.number_of_initial_rises()))
+            sage: ascii_art(sorted(bij.minimal_subdistributions_iterator()))
+            [ (             [   /\   ] )
+            [ (             [  /  \  ] )  ( [    /\    /\    ]  [  /\      /\/\  ] )
+            [ ( [ /\/\/\ ], [ /    \ ] ), ( [ /\/  \, /  \/\ ], [ /  \/\, /    \ ] ),
+            <BLANKLINE>
+             ( [           /\   ]                     ) ]
+             ( [  /\/\    /  \  ]  [            /\  ] ) ]
+             ( [ /    \, /    \ ], [ /\/\/\, /\/  \ ] ) ]
+            sage: bij.set_quadratic_relation((lambda D: D, lambda D: D))
+            sage: ascii_art(sorted(bij.minimal_subdistributions_iterator()))
+            [ (             [   /\   ] )
+            [ (             [  /  \  ] )  ( [    /\  ]  [  /\/\  ] )
+            [ ( [ /\/\/\ ], [ /    \ ] ), ( [ /\/  \ ], [ /    \ ] ),
+            <BLANKLINE>
+            <BLANKLINE>
+             ( [  /\    ]  [  /\    ] )  ( [  /\/\  ]  [    /\  ] )
+             ( [ /  \/\ ], [ /  \/\ ] ), ( [ /    \ ], [ /\/  \ ] ),
+            <BLANKLINE>
+             ( [   /\   ]             ) ]
+             ( [  /  \  ]             ) ]
+             ( [ /    \ ], [ /\/\/\ ] ) ]
+
+        """
+        self._bmilp = None
+        self._phi_psi = phi_psi
+
+    def set_homomesic(self, Q):
+        """
+        Assert that the average of `s` on each block of `Q` is
+        constant.
+
+        INPUT:
+
+        - ``Q`` -- a set partition of ``A``
+
+        EXAMPLES::
+
+            sage: A = B = [1,2,3]
+            sage: bij = Bijectionist(A, B, lambda b: b % 3)
+            sage: bij.set_homomesic([[1,2], [3]])
+            sage: list(bij.solutions_iterator())
+            [{1: 2, 2: 0, 3: 1}, {1: 0, 2: 2, 3: 1}]
+
+        """
+        self._bmilp = None
+        if Q is None:
+            self._Q = None
+        else:
+            self._Q = SetPartition(Q)
+            assert self._Q in SetPartitions(self._A), f"{Q} must be a set partition of A"
+
+    def _forced_constant_blocks(self):
+        r"""
+        Modify current partition into blocks to the coarsest possible
+        one, meaning that after calling this function for every two
+        distinct blocks `p_1`, `p_2` there exists a solution `s` with
+        `s(p_1)\neq s(p_2)`.
+
+        ALGORITHM:
+
+        First we generate an initial solution.  For all blocks i, j
+        that have the same value under this initial solution, we add
+        the constraint `x[i, z] + x[j, z] <= 1` for all possible
+        values `z\in Z`.  This constraint ensures that the `s` differs
+        on the two blocks.  If this modified problem does not have a
+        solution, we know that the two blocks always have the same
+        value and join them.  Then we save all values of this new
+        solution and continue looking at pairs of blocks that had the
+        same value under all calculated solutions, until no blocks
+        can be joined anymore.
+
+        EXAMPLES:
+
+        The easiest example is given by a constant `tau`, so everything
+        is forced to be the same value:
+
+            sage: A = B = [permutation for n in range(3) for permutation in Permutations(n)]
+            sage: bij = Bijectionist(A, B, lambda x: 0)
+            sage: bij.constant_blocks()
+            {}
+            sage: bij.constant_blocks(optimal=True)                             # indirect doctest
+            {{[], [1], [1, 2], [2, 1]}}
+
+        In this other example we look at permutations with length 2 and 3::
+
+            sage: N = 4
+            sage: A = B = [permutation for n in range(2, N) for permutation in Permutations(n)]
+            sage: def tau(p): return p[0] if len(p) else 0
+            sage: add_n = lambda p1: Permutation(p1 + [1 + len(p1)])
+            sage: add_1 = lambda p1: Permutation([1] + [1 + i for i in p1])
+            sage: bij = Bijectionist(A, B, tau)
+            sage: bij.set_intertwining_relations((1, add_n, lambda x: x + 1), (1, add_1, lambda x: x + 1))
+            sage: bij.set_statistics((len, len))
+
+            sage: bij.constant_blocks()
+            {}
+            sage: bij.constant_blocks(optimal=True)
+            {{[1, 3, 2], [2, 1, 3]}}
+
+        Indeed, ``[1,3,2]`` and ``[2,1,3]`` have the same value in
+        all solutions, but different values are possible::
+
+            sage: pi1 = Permutation([1,3,2]); pi2 = Permutation([2,1,3]);
+            sage: set([(solution[pi1], solution[pi2]) for solution in bij.solutions_iterator()])
+            {(2, 2), (3, 3)}
+
+        Another example involving the cycle type of permutations::
+
+            sage: A = B = [permutation for n in range(4) for permutation in Permutations(n)]
+            sage: bij = Bijectionist(A, B, lambda x: x.cycle_type())
+
+        Let us require that each permutation has the same value as its inverse::
+
+            sage: from sage.combinat.cyclic_sieving_phenomenon import orbit_decomposition
+            sage: P = orbit_decomposition([permutation for n in range(4) for permutation in Permutations(n)], Permutation.inverse)
+            sage: bij.set_constant_blocks(P)
+            sage: bij.constant_blocks()
+            {{[2, 3, 1], [3, 1, 2]}}
+
+            sage: def concat(p1, p2): return Permutation(p1 + [i + len(p1) for i in p2])
+            sage: def union(p1, p2): return Partition(sorted(list(p1) + list(p2), reverse=True))
+            sage: bij.set_intertwining_relations((2, concat, union))
+
+        In this case we do not discover constant blocks by looking at the intertwining_relations only::
+
+            sage: next(bij.solutions_iterator())
+            ...
+            sage: bij.constant_blocks()
+            {{[2, 3, 1], [3, 1, 2]}}
+
+            sage: bij.constant_blocks(optimal=True)
+            {{[1, 3, 2], [2, 1, 3], [3, 2, 1]}, {[2, 3, 1], [3, 1, 2]}}
+
+        TESTS::
+
+            sage: N = 4
+            sage: A = B = [permutation for n in range(N + 1) for permutation in Permutations(n)]
+            sage: def alpha1(p): return len(p.weak_excedences())
+            sage: def alpha2(p): return len(p.fixed_points())
+            sage: def beta1(p): return len(p.descents(final_descent=True)) if p else 0
+            sage: def beta2(p): return len([e for (e, f) in zip(p, p[1:] + [0]) if e == f + 1])
+            sage: tau = Permutation.longest_increasing_subsequence_length
+            sage: def rotate_permutation(p):
+            ....:    cycle = Permutation(tuple(range(1, len(p) + 1)))
+            ....:    return Permutation([cycle.inverse()(p(cycle(i))) for i in range(1, len(p) + 1)])
+            sage: bij = Bijectionist(A, B, tau)
+            sage: bij.set_statistics((alpha1, beta1), (alpha2, beta2))
+            sage: from sage.combinat.cyclic_sieving_phenomenon import orbit_decomposition
+            sage: bij.set_constant_blocks(orbit_decomposition(A, rotate_permutation))
+            sage: P = bij.constant_blocks()
+            sage: P = [sorted(p, key=lambda p: (len(p), p)) for p in P]
+            sage: P = sorted(P, key=lambda p: (len(next(iter(p))), len(p)))
+            sage: for p in P:
+            ....:     print(p)
+            [[1, 3, 2], [2, 1, 3], [3, 2, 1]]
+            [[1, 4, 3, 2], [3, 2, 1, 4]]
+            [[2, 1, 4, 3], [4, 3, 2, 1]]
+            [[1, 2, 4, 3], [1, 3, 2, 4], [2, 1, 3, 4], [4, 2, 3, 1]]
+            [[1, 3, 4, 2], [2, 3, 1, 4], [2, 4, 3, 1], [3, 2, 4, 1]]
+            [[1, 4, 2, 3], [3, 1, 2, 4], [4, 1, 3, 2], [4, 2, 1, 3]]
+            [[2, 4, 1, 3], [3, 1, 4, 2], [3, 4, 2, 1], [4, 3, 1, 2]]
+
+            sage: P = bij.constant_blocks(optimal=True)
+            sage: P = [sorted(p, key=lambda p: (len(p), p)) for p in P]
+            sage: P = sorted(P, key=lambda p: (len(next(iter(p))), len(p)))
+            sage: for p in P:
+            ....:     print(p)
+            [[1], [1, 2], [1, 2, 3], [1, 2, 3, 4]]
+            [[1, 3, 2], [2, 1, 3], [3, 2, 1],
+             [1, 2, 4, 3], [1, 3, 2, 4], [1, 3, 4, 2], [1, 4, 3, 2],
+             [2, 1, 3, 4], [2, 1, 4, 3], [2, 3, 1, 4], [2, 3, 4, 1],
+             [2, 4, 3, 1], [3, 2, 1, 4], [3, 2, 4, 1], [4, 2, 3, 1],
+             [4, 3, 2, 1]]
+            [[1, 4, 2, 3], [2, 4, 1, 3], [3, 1, 2, 4], [3, 1, 4, 2],
+             [3, 4, 2, 1], [4, 1, 3, 2], [4, 2, 1, 3], [4, 3, 1, 2]]
+
+        The permutation `[2, 1]` is in none of these blocks::
+
+            sage: bij.set_constant_blocks(orbit_decomposition(A, rotate_permutation))
+            sage: all(s[Permutation([2, 1])] == s[Permutation([1])] for s in bij.solutions_iterator())
+            False
+
+            sage: all(s[Permutation([2, 1])] == s[Permutation([1, 3, 2])] for s in bij.solutions_iterator())
+            False
+
+            sage: all(s[Permutation([2, 1])] == s[Permutation([1, 4, 2, 3])] for s in bij.solutions_iterator())
+            False
+
+            sage: A = B = ["a", "b", "c", "d", "e", "f"]
+            sage: tau = {"a": 1, "b": 1, "c": 3, "d": 4, "e": 5, "f": 6}.get
+            sage: bij = Bijectionist(A, B, tau)
+            sage: bij.set_distributions((["a", "b"], [1, 1]), (["c", "d", "e"], [3, 4, 5]))
+            sage: bij.constant_blocks()
+            {}
+            sage: bij.constant_blocks(optimal=True)
+            {{'a', 'b'}}
+
+            sage: A = B = ["a", "b", "c", "d", "e", "f"]
+            sage: tau = {"a": 1, "b": 1, "c": 5, "d": 4, "e": 4, "f": 6}.get
+            sage: bij = Bijectionist(A, B, tau)
+            sage: bij.set_distributions((["a", "b"], [1, 1]), (["d", "e"], [4, 4]))
+            sage: bij.constant_blocks(optimal=True)
+            {{'a', 'b'}, {'d', 'e'}}
+
+            sage: A = B = ["a", "b", "c", "d"]
+            sage: tau = {"a": 1, "b": 1, "c": 2, "d": 2}.get
+            sage: bij = Bijectionist(A, B, tau)
+            sage: bij.constant_blocks(optimal=True)
+            {}
+            sage: bij.set_constant_blocks([["a", "b"]])
+            sage: bij.constant_blocks()
+            {{'a', 'b'}}
+            sage: bij.constant_blocks(optimal=True)
+            {{'a', 'b'}, {'c', 'd'}}
+
+        """
+        if self._bmilp is None:
+            self._bmilp = _BijectionistMILP(self)
+
+        solution = next(self._bmilp.solutions_iterator(True, []))
+        # multiple_preimages[tZ] are the blocks p which have the same
+        # value tZ[i] in the i-th known solution
+        multiple_preimages = {(z,): tP
+                              for z, tP in _invert_dict(solution).items()
+                              if len(tP) > 1}
+
+        # _P has to be copied to not mess with the solution process
+        # since we do not want to regenerate the bmilp in each step,
+        # so blocks have to stay consistent during the whole process
+        tmp_P = copy(self._P)
+
+        # check whether blocks p1 and p2 can have different values,
+        # if so return such a solution
+        def different_values(p1, p2):
+            tmp_constraints = [self._bmilp._x[p1, z] + self._bmilp._x[p2, z] <= 1
+                               for z in self._possible_block_values[p1]
+                               if z in self._possible_block_values[p2]]
+            return next(self._bmilp.solutions_iterator(True, tmp_constraints))
+
+        # try to find a pair of blocks having the same value on all
+        # known solutions, and a solution such that the values are
+        # different on this solution
+        def merge_until_split():
+            for tZ in list(multiple_preimages):
+                tP = multiple_preimages[tZ]
+                for i2 in range(len(tP)-1, -1, -1):
+                    for i1 in range(i2):
+                        try:
+                            solution = different_values(tP[i1], tP[i2])
+                        except StopIteration:
+                            tmp_P.union(tP[i1], tP[i2])
+                            if len(multiple_preimages[tZ]) == 2:
+                                del multiple_preimages[tZ]
+                            else:
+                                tP.remove(tP[i2])
+                            break  # skip all pairs (i, j) containing i2
+                        return solution
+
+        while True:
+            solution = merge_until_split()
+            if solution is None:
+                self._P = tmp_P
+                # recreate the MILP
+                self._bmilp = _BijectionistMILP(self,
+                                                self._bmilp._solution_cache)
+                return
+
+            updated_multiple_preimages = defaultdict(list)
+            for tZ, tP in multiple_preimages.items():
+                for p in tP:
+                    updated_multiple_preimages[tZ + (solution[p],)].append(p)
+            multiple_preimages = updated_multiple_preimages
+
+    def possible_values(self, p=None, optimal=False):
+        r"""
+        Return for each block the values of `s` compatible with the
+        imposed restrictions.
+
+        INPUT:
+
+        - ``p`` -- (optional) a block of `P`, or an element of a
+          block of `P`, or a list of these
+
+        - ``optimal`` -- (default: ``False``) whether or not to
+          compute the minimal possible set of statistic values
+
+        .. NOTE::
+
+            Computing the minimal possible set of statistic values
+            may be computationally expensive.
+
+        .. TODO::
+
+            currently, calling this method with ``optimal=True`` does
+            not update the internal dictionary, because this would
+            interfere with the variables of the MILP.
+
+        EXAMPLES::
+
+            sage: A = B = ["a", "b", "c", "d"]
+            sage: tau = {"a": 1, "b": 1, "c": 1, "d": 2}.get
+            sage: bij = Bijectionist(A, B, tau)
+            sage: bij.set_constant_blocks([["a", "b"]])
+            sage: bij.possible_values(A)
+            {'a': {1, 2}, 'b': {1, 2}, 'c': {1, 2}, 'd': {1, 2}}
+            sage: bij.possible_values(A, optimal=True)
+            {'a': {1}, 'b': {1}, 'c': {1, 2}, 'd': {1, 2}}
+
+        The internal dictionary is not updated::
+
+            sage: bij.possible_values(A)
+            {'a': {1, 2}, 'b': {1, 2}, 'c': {1, 2}, 'd': {1, 2}}
+
+        TESTS::
+
+            sage: A = B = ["a", "b", "c", "d"]
+            sage: tau = {"a": 1, "b": 1, "c": 2, "d": 2}.get
+            sage: bij = Bijectionist(A, B, tau)
+            sage: bij.set_constant_blocks([["a", "b"]])
+
+        Test if all formats are really possible::
+
+            sage: bij.possible_values(p="a")
+            {'a': {1, 2}, 'b': {1, 2}}
+            sage: bij.possible_values(p=["a", "b"])
+            {'a': {1, 2}, 'b': {1, 2}}
+            sage: bij.possible_values(p=[["a", "b"]])
+            {'a': {1, 2}, 'b': {1, 2}}
+            sage: bij.possible_values(p=[["a", "b"], ["c"]])
+            {'a': {1, 2}, 'b': {1, 2}, 'c': {1, 2}}
+
+        Test an unfeasible problem::
+
+            sage: A = B = 'ab'
+            sage: bij = Bijectionist(A, B, lambda x: B.index(x) % 2)
+            sage: bij.set_constant_blocks([['a', 'b']])
+            sage: bij.possible_values(p="a")
+            {'a': {0, 1}, 'b': {0, 1}}
+            sage: bij.possible_values(p="a", optimal=True)
+            {'a': set(), 'b': set()}
+
+        """
+        # convert input to set of block representatives
+        blocks = set()
+        if p in self._A:
+            blocks.add(self._P.find(p))
+        elif type(p) is list:  # TODO: this looks very brittle
+            for p1 in p:
+                if p1 in self._A:
+                    blocks.add(self._P.find(p1))
+                elif type(p1) is list:
+                    for p2 in p1:
+                        blocks.add(self._P.find(p2))
+
+        if optimal:
+            if self._bmilp is None:
+                self._bmilp = _BijectionistMILP(self)
+            bmilp = self._bmilp
+            solutions = defaultdict(set)
+            try:
+                solution = next(bmilp.solutions_iterator(True, []))
+            except StopIteration:
+                pass
+            else:
+                for p, z in solution.items():
+                    solutions[p].add(z)
+                for p in blocks:
+                    tmp_constraints = [bmilp._x[p, z] == 0 for z in solutions[p]]
+                    while True:
+                        try:
+                            solution = next(bmilp.solutions_iterator(True, tmp_constraints))
+                        except StopIteration:
+                            break
+                        for p0, z in solution.items():
+                            solutions[p0].add(z)
+                        # veto new value and try again
+                        tmp_constraints.append(bmilp._x[p, solution[p]] == 0)
+
+        # create dictionary to return
+        possible_values = {}
+        for p in blocks:
+            for a in self._P.root_to_elements_dict()[p]:
+                if optimal:
+                    possible_values[a] = solutions[p]
+                else:
+                    possible_values[a] = self._possible_block_values[p]
+
+        return possible_values
+
+    def minimal_subdistributions_iterator(self):
+        r"""
+        Return all minimal subsets `\tilde A` of `A`
+        together with submultisets `\tilde Z` with `s(\tilde A) =
+        \tilde Z` as multisets.
+
+        EXAMPLES::
+
+            sage: A = B = [permutation for n in range(3) for permutation in Permutations(n)]
+            sage: bij = Bijectionist(A, B, len)
+            sage: bij.set_statistics((len, len))
+            sage: for sol in bij.solutions_iterator():
+            ....:     print(sol)
+            {[]: 0, [1]: 1, [1, 2]: 2, [2, 1]: 2}
+            sage: sorted(bij.minimal_subdistributions_iterator())
+            [([[]], [0]), ([[1]], [1]), ([[1, 2]], [2]), ([[2, 1]], [2])]
+
+        Another example::
+
+            sage: N = 2; A = B = [dyck_word for n in range(N+1) for dyck_word in DyckWords(n)]
+            sage: def tau(D): return D.number_of_touch_points()
+            sage: bij = Bijectionist(A, B, tau)
+            sage: bij.set_statistics((lambda d: d.semilength(), lambda d: d.semilength()))
+            sage: for solution in sorted(list(bij.solutions_iterator()), key=lambda d: tuple(sorted(d.items()))):
+            ....:     print(solution)
+            {[]: 0, [1, 0]: 1, [1, 0, 1, 0]: 1, [1, 1, 0, 0]: 2}
+            {[]: 0, [1, 0]: 1, [1, 0, 1, 0]: 2, [1, 1, 0, 0]: 1}
+            sage: for subdistribution in bij.minimal_subdistributions_iterator():
+            ....:     print(subdistribution)
+            ([[]], [0])
+            ([[1, 0]], [1])
+            ([[1, 0, 1, 0], [1, 1, 0, 0]], [1, 2])
+
+        An example with two elements of the same block in a subdistribution::
+
+            sage: A = B = ["a", "b", "c", "d", "e"]
+            sage: tau = {"a": 1, "b": 1, "c": 2, "d": 2, "e": 3}.get
+            sage: bij = Bijectionist(A, B, tau)
+            sage: bij.set_constant_blocks([["a", "b"]])
+            sage: bij.set_value_restrictions(("a", [1, 2]))
+            sage: bij.constant_blocks(optimal=True)
+            {{'a', 'b'}}
+            sage: list(bij.minimal_subdistributions_iterator())
+            [(['a', 'b', 'c', 'd', 'e'], [1, 1, 2, 2, 3])]
+        """
+        # see
+        # https://mathoverflow.net/questions/406751/find-a-subdistribution/406975
+        # and
+        # https://gitlab.com/mantepse/bijection-tools/-/issues/29
+
+        minimal_subdistribution = MixedIntegerLinearProgram(maximization=False, solver=self._solver)
+        D = minimal_subdistribution.new_variable(binary=True)  # the subset of elements
+        V = minimal_subdistribution.new_variable(integer=True)  # the subdistribution
+        minimal_subdistribution.set_objective(sum(D[a] for a in self._A))
+        minimal_subdistribution.add_constraint(sum(D[a] for a in self._A) >= 1)
+
+        if self._bmilp is None:
+            self._bmilp = _BijectionistMILP(self)
+        s = next(self._bmilp.solutions_iterator(False, []))
+        while True:
+            for v in self._Z:
+                minimal_subdistribution.add_constraint(sum(D[a] for a in self._A if s[a] == v) == V[v])
+            try:
+                minimal_subdistribution.solve()
+            except MIPSolverException:
+                return
+            d = minimal_subdistribution.get_values(D, convert=bool, tolerance=0.1)  # a dict from A to {0, 1}
+            new_s = self._find_counterexample(self._A, s, d, False)
+            if new_s is None:
+                values = self._sorter["Z"](s[a] for a in self._A if d[a])
+                yield ([a for a in self._A if d[a]], values)
+
+                # get all variables with value 1
+                active_vars = [D[a] for a in self._A
+                               if minimal_subdistribution.get_values(D[a], convert=bool, tolerance=0.1)]
+
+                # add constraint that not all of these can be 1, thus vetoing
+                # the current solution
+                minimal_subdistribution.add_constraint(sum(active_vars) <= len(active_vars) - 1,
+                                                       name="veto")
+            else:
+                s = new_s
+
+    def _find_counterexample(self, P, s0, d, on_blocks):
+        r"""
+        Return a solution `s` such that ``d`` is not a subdistribution of
+        `s0`.
+
+        INPUT:
+
+        - ``P`` -- the representatives of the blocks, or `A` if
+          ``on_blocks`` is ``False``
+
+        - ``s0`` -- a solution
+
+        - ``d`` -- a subset of `A`, in the form of a dict from `A` to
+          `\{0, 1\}`
+
+        - ``on_blocks`` -- whether to return the counterexample on
+          blocks or on elements
+
+        EXAMPLES::
+
+            sage: A = B = ["a", "b", "c", "d", "e"]
+            sage: tau = {"a": 1, "b": 1, "c": 2, "d": 2, "e": 3}.get
+            sage: bij = Bijectionist(A, B, tau)
+            sage: bij.set_constant_blocks([["a", "b"]])
+            sage: bij.set_value_restrictions(("a", [1, 2]))
+            sage: next(bij.solutions_iterator())
+            {'a': 1, 'b': 1, 'c': 2, 'd': 3, 'e': 2}
+
+            sage: s0 = {'a': 1, 'b': 1, 'c': 2, 'd': 3, 'e': 2}
+            sage: d = {'a': 1, 'b': 0, 'c': 0, 'd': 0, 'e': 0}
+            sage: bij._find_counterexample(bij._A, s0, d, False)
+            {'a': 2, 'b': 2, 'c': 1, 'd': 3, 'e': 1}
+
+        """
+        bmilp = self._bmilp
+        for z in self._Z:
+            z_in_d_count = sum(d[p] for p in P if s0[p] == z)
+            if not z_in_d_count:
+                continue
+
+            # try to find a solution which has a different
+            # subdistribution on d than s0
+            z_in_d = sum(d[p] * bmilp._x[self._P.find(p), z]
+                         for p in P
+                         if z in self._possible_block_values[self._P.find(p)])
+
+            # it is sufficient to require that z occurs less often as
+            # a value among {a | d[a] == 1} than it does in
+            # z_in_d_count, because, if the distributions are
+            # different, one such z must exist
+            tmp_constraints = [z_in_d <= z_in_d_count - 1]
+            try:
+                solution = next(bmilp.solutions_iterator(on_blocks, tmp_constraints))
+                return solution
+            except StopIteration:
+                pass
+
+    def minimal_subdistributions_blocks_iterator(self):
+        r"""
+        Return all representatives of minimal subsets `\tilde P`
+        of `P` together with submultisets `\tilde Z`
+        with `s(\tilde P) = \tilde Z` as multisets.
+
+        .. WARNING::
+
+            If there are several solutions with the same support
+            (i.e., the sets of block representatives are the same),
+            only one of these will be found, even if the
+            distributions are different, see the doctest below.  To
+            find all solutions, use
+            :meth:`minimal_subdistributions_iterator`, which is,
+            however, computationally more expensive.
+
+        EXAMPLES::
+
+            sage: A = B = [permutation for n in range(3) for permutation in Permutations(n)]
+            sage: bij = Bijectionist(A, B, len)
+            sage: bij.set_statistics((len, len))
+            sage: for sol in bij.solutions_iterator():
+            ....:     print(sol)
+            {[]: 0, [1]: 1, [1, 2]: 2, [2, 1]: 2}
+            sage: sorted(bij.minimal_subdistributions_blocks_iterator())
+            [([[]], [0]), ([[1]], [1]), ([[1, 2]], [2]), ([[2, 1]], [2])]
+
+        Another example::
+
+            sage: N = 2; A = B = [dyck_word for n in range(N+1) for dyck_word in DyckWords(n)]
+            sage: def tau(D): return D.number_of_touch_points()
+            sage: bij = Bijectionist(A, B, tau)
+            sage: bij.set_statistics((lambda d: d.semilength(), lambda d: d.semilength()))
+            sage: for solution in sorted(list(bij.solutions_iterator()), key=lambda d: tuple(sorted(d.items()))):
+            ....:     print(solution)
+            {[]: 0, [1, 0]: 1, [1, 0, 1, 0]: 1, [1, 1, 0, 0]: 2}
+            {[]: 0, [1, 0]: 1, [1, 0, 1, 0]: 2, [1, 1, 0, 0]: 1}
+            sage: for subdistribution in bij.minimal_subdistributions_blocks_iterator():
+            ....:     print(subdistribution)
+            ([[]], [0])
+            ([[1, 0]], [1])
+            ([[1, 0, 1, 0], [1, 1, 0, 0]], [1, 2])
+
+        An example with two elements of the same block in a subdistribution::
+
+            sage: A = B = ["a", "b", "c", "d", "e"]
+            sage: tau = {"a": 1, "b": 1, "c": 2, "d": 2, "e": 3}.get
+            sage: bij = Bijectionist(A, B, tau)
+            sage: bij.set_constant_blocks([["a", "b"]])
+            sage: bij.set_value_restrictions(("a", [1, 2]))
+            sage: bij.constant_blocks(optimal=True)
+            {{'a', 'b'}}
+            sage: list(bij.minimal_subdistributions_blocks_iterator())
+            [(['b', 'b', 'c', 'd', 'e'], [1, 1, 2, 2, 3])]
+
+        An example with overlapping minimal subdistributions::
+
+            sage: A = B = ["a", "b", "c", "d", "e"]
+            sage: tau = {"a": 1, "b": 1, "c": 2, "d": 2, "e": 3}.get
+            sage: bij = Bijectionist(A, B, tau)
+            sage: bij.set_distributions((["a", "b"], [1, 2]), (["a", "c", "d"], [1, 2, 3]))
+            sage: sorted(bij.solutions_iterator(), key=lambda d: tuple(sorted(d.items())))
+            [{'a': 1, 'b': 2, 'c': 2, 'd': 3, 'e': 1},
+             {'a': 1, 'b': 2, 'c': 3, 'd': 2, 'e': 1},
+             {'a': 2, 'b': 1, 'c': 1, 'd': 3, 'e': 2},
+             {'a': 2, 'b': 1, 'c': 3, 'd': 1, 'e': 2}]
+            sage: bij.constant_blocks(optimal=True)
+            {{'a', 'e'}}
+            sage: list(bij.minimal_subdistributions_blocks_iterator())
+            [(['a', 'b'], [1, 2]), (['a', 'c', 'd'], [1, 2, 3])]
+
+        Fedor Petrov's example from https://mathoverflow.net/q/424187::
+
+            sage: A = B = ["a"+str(i) for i in range(1, 9)] + ["b"+str(i) for i in range(3, 9)] + ["d"]
+            sage: tau = {b: 0 if i < 10 else 1 for i, b in enumerate(B)}.get
+            sage: bij = Bijectionist(A, B, tau)
+            sage: bij.set_constant_blocks([["a"+str(i), "b"+str(i)] for i in range(1, 9) if "b"+str(i) in A])
+            sage: d = [0]*8+[1]*4
+            sage: bij.set_distributions((A[:8] + A[8+2:-1], d), (A[:8] + A[8:-3], d))
+            sage: sorted([s[a] for a in A] for s in bij.solutions_iterator())
+            [[0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 1, 1, 0, 0, 1],
+             [0, 1, 0, 1, 0, 0, 0, 1, 0, 1, 0, 0, 0, 1, 0],
+             [0, 1, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0],
+             [0, 1, 1, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 1, 0],
+             [0, 1, 1, 0, 0, 0, 1, 0, 1, 0, 0, 0, 1, 0, 0],
+             [1, 0, 0, 1, 0, 0, 0, 1, 0, 1, 0, 0, 0, 1, 0],
+             [1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0],
+             [1, 0, 1, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 1, 0],
+             [1, 0, 1, 0, 0, 0, 1, 0, 1, 0, 0, 0, 1, 0, 0],
+             [1, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0, 0, 1],
+             [1, 1, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 1]]
+
+            sage: sorted(bij.minimal_subdistributions_blocks_iterator())        # random
+            [(['a1', 'a2', 'a3', 'a4', 'a5', 'a5', 'a6', 'a6', 'a7', 'a7', 'a8', 'a8'],
+              [0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1]),
+             (['a3', 'a4', 'd'], [0, 0, 1]),
+             (['a7', 'a8', 'd'], [0, 0, 1])]
+
+        The following solution is not found, because it happens to
+        have the same support as the other::
+
+            sage: D = set(A).difference(['b7', 'b8', 'd'])
+            sage: sorted(a.replace("b", "a") for a in D)
+            ['a1', 'a2', 'a3', 'a3', 'a4', 'a4', 'a5', 'a5', 'a6', 'a6', 'a7', 'a8']
+            sage: set(tuple(sorted(s[a] for a in D)) for s in bij.solutions_iterator())
+            {(0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1)}
+
+        But it is, by design, included here::
+
+            sage: sorted(D) in [d for d, _ in bij.minimal_subdistributions_iterator()]
+            True
+
+        """
+        # see
+        # https://mathoverflow.net/questions/406751/find-a-subdistribution/406975
+        # and
+        # https://gitlab.com/mantepse/bijection-tools/-/issues/29
+        # see https://mathoverflow.net/q/424187 for Fedor Petrov's example
+
+        minimal_subdistribution = MixedIntegerLinearProgram(maximization=False, solver=self._solver)
+        D = minimal_subdistribution.new_variable(integer=True, nonnegative=True)  # the submultiset of elements
+        X = minimal_subdistribution.new_variable(binary=True)  # the support of D
+        V = minimal_subdistribution.new_variable(integer=True, nonnegative=True)  # the subdistribution
+        P = _disjoint_set_roots(self._P)
+        minimal_subdistribution.set_objective(sum(D[p] for p in P))
+        minimal_subdistribution.add_constraint(sum(D[p] for p in P) >= 1)
+        for p in P:
+            minimal_subdistribution.add_constraint(D[p] <= len(self._P.root_to_elements_dict()[p]))
+            minimal_subdistribution.add_constraint(X[p]*len(self._P.root_to_elements_dict()[p]) >= D[p] >= X[p])
+
+        def add_counter_example_constraint(s):
+            for v in self._Z:
+                minimal_subdistribution.add_constraint(sum(D[p] for p in P
+                                                           if s[p] == v) == V[v])
+
+        if self._bmilp is None:
+            self._bmilp = _BijectionistMILP(self)
+
+        s = next(self._bmilp.solutions_iterator(True, []))
+        add_counter_example_constraint(s)
+        while True:
+            try:
+                minimal_subdistribution.solve()
+            except MIPSolverException:
+                return
+            d = minimal_subdistribution.get_values(D, convert=ZZ, tolerance=0.1)  # a dict from P to multiplicities
+            new_s = self._find_counterexample(P, s, d, True)
+            if new_s is None:
+                yield ([p for p in P for _ in range(ZZ(d[p]))],
+                       self._sorter["Z"](s[p]
+                                         for p in P
+                                         for _ in range(ZZ(d[p]))))
+
+                support = [X[p] for p in P if d[p]]
+                # add constraint that the support is different
+                minimal_subdistribution.add_constraint(sum(support) <= len(support) - 1,
+                                                       name="veto")
+            else:
+                s = new_s
+                add_counter_example_constraint(s)
+
+    def _preprocess_intertwining_relations(self):
+        r"""
+        Make ``self._P`` be the finest set partition coarser
+        than ``self._P`` such that composing elements preserves
+        blocks.
+
+        Suppose that `p_1`, `p_2` are blocks of `P`, and `a_1, a'_1
+        \in p_1` and `a_2, a'_2\in p_2`.  Then,
+
+        .. MATH:
+
+            s(\pi(a_1, a_2))
+            = \rho(s(a_1), s(a_2))
+            = \rho(s(a'_1), s(a'_2))
+            = s(\pi(a'_1, a'_2)).
+
+        Therefore, `\pi(a_1, a_2)` and `\pi(a'_1, a'_2)` are in the
+        same block.
+
+        In other words, `s(\pi(a_1,\dots,a_k))` only depends on the
+        blocks of `a_1,\dots,a_k`.
+
+        In particular, if `P` consists only if singletons, this
+        method has no effect.
+
+        .. TODO::
+
+            create one test with one and one test with two
+            intertwining relations
+
+        .. TODO::
+
+            it is not clear whether this method makes sense
+
+        EXAMPLES::
+
+            sage: A = B = 'abcd'
+            sage: bij = Bijectionist(A, B, lambda x: B.index(x) % 2)
+            sage: def pi(p1, p2): return 'abcdefgh'[A.index(p1) + A.index(p2)]
+            sage: def rho(s1, s2): return (s1 + s2) % 2
+            sage: bij.set_intertwining_relations((2, pi, rho))
+            sage: bij._preprocess_intertwining_relations()
+            sage: bij._P
+            {{'a'}, {'b'}, {'c'}, {'d'}}
+
+        Let a group act on permutations::
+
+            sage: A = B = Permutations(3)
+            sage: bij = Bijectionist(A, B, lambda x: x[0])
+            sage: bij.set_intertwining_relations((1, lambda pi: pi.reverse(), lambda z: z))
+            sage: bij._preprocess_intertwining_relations()
+            sage: bij._P
+            {{[1, 2, 3]}, {[1, 3, 2]}, {[2, 1, 3]}, {[2, 3, 1]}, {[3, 1, 2]}, {[3, 2, 1]}}
+
+        """
+        A = self._A
+        P = self._P
+        images = defaultdict(set)  # A^k -> A, a_1,...,a_k +-> {pi(a_1,...,a_k) for all pi}
+        for composition_index, pi_rho in enumerate(self._pi_rho):
+            for a_tuple in itertools.product(*([A]*pi_rho.numargs)):
+                if pi_rho.domain is not None and not pi_rho.domain(*a_tuple):
+                    continue
+                a = pi_rho.pi(*a_tuple)
+                if a in A:
+                    images[a_tuple].add(a)
+
+        # merge blocks
+        something_changed = True
+        while something_changed:
+            something_changed = False
+            # collect (preimage, image) pairs by (representatives) of
+            # the blocks of the elements of the preimage
+            updated_images = defaultdict(set)  # (p_1,...,p_k) to {a_1,....}
+            for a_tuple, image_set in images.items():
+                representatives = tuple(P.find(a) for a in a_tuple)
+                updated_images[representatives].update(image_set)
+
+            # merge blocks
+            for a_tuple, image_set in updated_images.items():
+                image = image_set.pop()
+                while image_set:
+                    P.union(image, image_set.pop())
+                    something_changed = True
+                # we keep a representative
+                image_set.add(image)
+
+            images = updated_images
+
+    def solutions_iterator(self):
+        r"""
+        An iterator over all solutions of the problem.
+
+        OUTPUT: An iterator over all possible mappings `s: A\to Z`
+
+        ALGORITHM:
+
+        We solve an integer linear program with a binary variable
+        `x_{p, z}` for each partition block `p\in P` and each
+        statistic value `z\in Z`:
+
+        - `x_{p, z} = 1` if and only if `s(a) = z` for all `a\in p`.
+
+        Then we add the constraint `\sum_{x\in V} x<|V|`, where `V`
+        is the set containing all `x` with `x = 1`, that is, those
+        indicator variables representing the current solution.
+        Therefore, a solution of this new program must be different
+        from all those previously obtained.
+
+        INTEGER LINEAR PROGRAM:
+
+        * Let `m_w(p)`, for a block `p` of `P`, be the multiplicity
+          of the value `w` in `W` under `\alpha`, that is, the number
+          of elements `a \in p` with `\alpha(a)=w`.
+
+        * Let `n_w(z)` be the number of elements `b \in B` with
+          `\beta(b)=w` and `\tau(b)=z` for `w \in W`, `z \in Z`.
+
+        * Let `k` be the arity of a pair `(\pi, \rho)` in an
+          intertwining relation.
+
+        and the following constraints:
+
+        * because every block is assigned precisely one value, for
+          all `p\in P`,
+
+        .. MATH::
+
+            \sum_z x_{p, z} = 1.
+
+        * because the statistics `s` and `\tau` and also `\alpha` and
+          `\beta` are equidistributed, for all `w\in W` and `z\in Z`,
+
+        .. MATH::
+
+            \sum_p m_w(p) x_{p, z} = n_w(z).
+
+        * for each intertwining relation `s(\pi(a_1,\dots, a_k)) =
+          \rho(s(a_1),\dots, s(a_r))`, and for all `k`-combinations
+          of blocks `p_i\in P` such that there exist `(a_1,\dots,
+          a_k)\in p_1\times\dots\times p_k` with `\pi(a_1,\dots,
+          a_k)\in W` and `z = \rho(z_1,\dots, z_k)`,
+
+        .. MATH::
+
+            x_{p, z} \geq 1-k + \sum_{i=1}^k x_{p_i, z_i}.
+
+        * for each distribution restriction, i.e. a set of elements
+          `\tilde A` and a distribution of values given by integers
+          `d_z` representing the multiplicity of each `z \in Z`, and
+          `r_p = |p \cap\tilde A|` indicating the relative size of
+          block `p` in the set of elements of the distribution,
+
+        .. MATH::
+
+            \sum_p r_p x_{p, z} = d_z.
+
+        EXAMPLES::
+
+            sage: A = B = 'abc'
+            sage: bij = Bijectionist(A, B, lambda x: B.index(x) % 2, solver="GLPK")
+            sage: next(bij.solutions_iterator())
+            {'a': 0, 'b': 1, 'c': 0}
+
+            sage: list(bij.solutions_iterator())
+            [{'a': 0, 'b': 1, 'c': 0},
+             {'a': 1, 'b': 0, 'c': 0},
+             {'a': 0, 'b': 0, 'c': 1}]
+
+            sage: N = 4
+            sage: A = B = [permutation for n in range(N) for permutation in Permutations(n)]
+
+        Let `\tau` be the number of non-left-to-right-maxima of a
+        permutation::
+
+            sage: def tau(pi):
+            ....:    pi = list(pi)
+            ....:    i = count = 0
+            ....:    for j in range(len(pi)):
+            ....:        if pi[j] > i:
+            ....:            i = pi[j]
+            ....:        else:
+            ....:            count += 1
+            ....:    return count
+
+        We look for a statistic which is constant on conjugacy classes::
+
+            sage: P = [list(a) for n in range(N) for a in Permutations(n).conjugacy_classes()]
+
+            sage: bij = Bijectionist(A, B, tau, solver="GLPK")
+            sage: bij.set_statistics((len, len))
+            sage: bij.set_constant_blocks(P)
+            sage: for solution in bij.solutions_iterator():
+            ....:     print(solution)
+            {[]: 0, [1]: 0, [1, 2]: 1, [2, 1]: 0, [1, 2, 3]: 0, [1, 3, 2]: 1, [2, 1, 3]: 1, [3, 2, 1]: 1, [2, 3, 1]: 2, [3, 1, 2]: 2}
+            {[]: 0, [1]: 0, [1, 2]: 0, [2, 1]: 1, [1, 2, 3]: 0, [1, 3, 2]: 1, [2, 1, 3]: 1, [3, 2, 1]: 1, [2, 3, 1]: 2, [3, 1, 2]: 2}
+
+        Changing or re-setting problem parameters clears the internal
+        cache.  Setting the verbosity prints the MILP which is solved.::
+
+            sage: set_verbose(2)
+            sage: bij.set_constant_blocks(P)
+            sage: _ = list(bij.solutions_iterator())
+            Constraints are:
+                block []: 1 <= x_0 <= 1
+                block [1]: 1 <= x_1 <= 1
+                block [1, 2]: 1 <= x_2 + x_3 <= 1
+                block [2, 1]: 1 <= x_4 + x_5 <= 1
+                block [1, 2, 3]: 1 <= x_6 + x_7 + x_8 <= 1
+                block [1, 3, 2]: 1 <= x_9 + x_10 + x_11 <= 1
+                block [2, 3, 1]: 1 <= x_12 + x_13 + x_14 <= 1
+                statistics: 1 <= x_0 <= 1
+                statistics: 0 <= <= 0
+                statistics: 0 <= <= 0
+                statistics: 1 <= x_1 <= 1
+                statistics: 0 <= <= 0
+                statistics: 0 <= <= 0
+                statistics: 1 <= x_2 + x_4 <= 1
+                statistics: 1 <= x_3 + x_5 <= 1
+                statistics: 0 <= <= 0
+                statistics: 1 <= x_6 + 3 x_9 + 2 x_12 <= 1
+                statistics: 3 <= x_7 + 3 x_10 + 2 x_13 <= 3
+                statistics: 2 <= x_8 + 3 x_11 + 2 x_14 <= 2
+            Variables are:
+                x_0: s([]) = 0
+                x_1: s([1]) = 0
+                x_2: s([1, 2]) = 0
+                x_3: s([1, 2]) = 1
+                x_4: s([2, 1]) = 0
+                x_5: s([2, 1]) = 1
+                x_6: s([1, 2, 3]) = 0
+                x_7: s([1, 2, 3]) = 1
+                x_8: s([1, 2, 3]) = 2
+                x_9: s([1, 3, 2]) = s([2, 1, 3]) = s([3, 2, 1]) = 0
+                x_10: s([1, 3, 2]) = s([2, 1, 3]) = s([3, 2, 1]) = 1
+                x_11: s([1, 3, 2]) = s([2, 1, 3]) = s([3, 2, 1]) = 2
+                x_12: s([2, 3, 1]) = s([3, 1, 2]) = 0
+                x_13: s([2, 3, 1]) = s([3, 1, 2]) = 1
+                x_14: s([2, 3, 1]) = s([3, 1, 2]) = 2
+            after vetoing
+            Constraints are:
+                block []: 1 <= x_0 <= 1
+                block [1]: 1 <= x_1 <= 1
+                block [1, 2]: 1 <= x_2 + x_3 <= 1
+                block [2, 1]: 1 <= x_4 + x_5 <= 1
+                block [1, 2, 3]: 1 <= x_6 + x_7 + x_8 <= 1
+                block [1, 3, 2]: 1 <= x_9 + x_10 + x_11 <= 1
+                block [2, 3, 1]: 1 <= x_12 + x_13 + x_14 <= 1
+                statistics: 1 <= x_0 <= 1
+                statistics: 0 <= <= 0
+                statistics: 0 <= <= 0
+                statistics: 1 <= x_1 <= 1
+                statistics: 0 <= <= 0
+                statistics: 0 <= <= 0
+                statistics: 1 <= x_2 + x_4 <= 1
+                statistics: 1 <= x_3 + x_5 <= 1
+                statistics: 0 <= <= 0
+                statistics: 1 <= x_6 + 3 x_9 + 2 x_12 <= 1
+                statistics: 3 <= x_7 + 3 x_10 + 2 x_13 <= 3
+                statistics: 2 <= x_8 + 3 x_11 + 2 x_14 <= 2
+                veto: x_0 + x_1 + x_3 + x_4 + x_6 + x_10 + x_14 <= 6
+            after vetoing
+            Constraints are:
+                block []: 1 <= x_0 <= 1
+                block [1]: 1 <= x_1 <= 1
+                block [1, 2]: 1 <= x_2 + x_3 <= 1
+                block [2, 1]: 1 <= x_4 + x_5 <= 1
+                block [1, 2, 3]: 1 <= x_6 + x_7 + x_8 <= 1
+                block [1, 3, 2]: 1 <= x_9 + x_10 + x_11 <= 1
+                block [2, 3, 1]: 1 <= x_12 + x_13 + x_14 <= 1
+                statistics: 1 <= x_0 <= 1
+                statistics: 0 <= <= 0
+                statistics: 0 <= <= 0
+                statistics: 1 <= x_1 <= 1
+                statistics: 0 <= <= 0
+                statistics: 0 <= <= 0
+                statistics: 1 <= x_2 + x_4 <= 1
+                statistics: 1 <= x_3 + x_5 <= 1
+                statistics: 0 <= <= 0
+                statistics: 1 <= x_6 + 3 x_9 + 2 x_12 <= 1
+                statistics: 3 <= x_7 + 3 x_10 + 2 x_13 <= 3
+                statistics: 2 <= x_8 + 3 x_11 + 2 x_14 <= 2
+                veto: x_0 + x_1 + x_3 + x_4 + x_6 + x_10 + x_14 <= 6
+                veto: x_0 + x_1 + x_2 + x_5 + x_6 + x_10 + x_14 <= 6
+
+            sage: set_verbose(0)
+
+        TESTS:
+
+        An unfeasible problem::
+
+            sage: A = ["a", "b", "c", "d"]; B = [1, 2, 3, 4]
+            sage: bij = Bijectionist(A, B)
+            sage: bij.set_value_restrictions(("a", [1, 2]), ("b", [1, 2]), ("c", [1, 3]), ("d", [2, 3]))
+            sage: list(bij.solutions_iterator())
+            []
+
+        Testing interactions between multiple instances using Fedor Petrov's example from https://mathoverflow.net/q/424187::
+
+            sage: A = B = ["a"+str(i) for i in range(1, 9)] + ["b"+str(i) for i in range(3, 9)] + ["d"]
+            sage: tau = {b: 0 if i < 10 else 1 for i, b in enumerate(B)}.get
+            sage: bij = Bijectionist(A, B, tau)
+            sage: bij.set_constant_blocks([["a"+str(i), "b"+str(i)] for i in range(1, 9) if "b"+str(i) in A])
+            sage: d = [0]*8+[1]*4
+            sage: bij.set_distributions((A[:8] + A[8+2:-1], d), (A[:8] + A[8:-3], d))
+            sage: iterator1 = bij.solutions_iterator()
+            sage: iterator2 = bij.solutions_iterator()
+
+        Generate a solution in iterator1, iterator2 should generate the same solution and vice versa::
+
+            sage: s1_1 = next(iterator1)
+            sage: s2_1 = next(iterator2)
+            sage: s1_1 == s2_1
+            True
+            sage: s2_2 = next(iterator2)
+            sage: s1_2 = next(iterator1)
+            sage: s1_2 == s2_2
+            True
+
+        Re-setting the distribution resets the cache, so a new
+        iterator will generate the first solutions again, but the old
+        iterator continues::
+
+            sage: bij.set_distributions((A[:8] + A[8+2:-1], d), (A[:8] + A[8:-3], d))
+            sage: iterator3 = bij.solutions_iterator()
+
+            sage: s3_1 = next(iterator3)
+            sage: s1_1 == s3_1
+            True
+
+            sage: s1_3 = next(iterator1)
+            sage: len(set([tuple(sorted(s.items())) for s in [s1_1, s1_2, s1_3]]))
+            3
+
+        """
+        if self._bmilp is None:
+            self._bmilp = _BijectionistMILP(self)
+        yield from self._bmilp.solutions_iterator(False, [])
+
+
+class _BijectionistMILP():
+    r"""
+    Wrapper class for the MixedIntegerLinearProgram (MILP).
+
+    This class is used to manage the MILP, add constraints, solve the
+    problem and check for uniqueness of solution values.
+    """
+    def __init__(self, bijectionist: Bijectionist, solutions=None):
+        r"""
+        Initialize the mixed integer linear program.
+
+        INPUT:
+
+        - ``bijectionist`` -- an instance of :class:`Bijectionist`.
+
+        - ``solutions`` -- (optional) a list of solutions of the
+          problem, each provided as a dictionary mapping `(a, z)` to
+          a boolean, such that at least one element from each block
+          of `P` appears as `a`.
+
+        .. TODO::
+
+            it might be cleaner not to pass the full bijectionist
+            instance, but only those attributes we actually use
+
+        TESTS::
+
+            sage: A = B = ["a", "b", "c", "d"]
+            sage: bij = Bijectionist(A, B)
+            sage: from sage.combinat.bijectionist import _BijectionistMILP
+            sage: _BijectionistMILP(bij)
+            <sage.combinat.bijectionist._BijectionistMILP object at ...>
+
+        """
+        # the attributes of the bijectionist class we actually use:
+        # _possible_block_values
+        # _elements_distributions
+        # _W, _Z, _A, _B, _P, _alpha, _beta, _tau, _pi_rho, _phi_psi
+        self._bijectionist = bijectionist
+        # the variables of the MILP are indexed by pairs (p, z), for
+        # p in _P and z an element of _posible_block_values[p].
+        # Thus, _P and _posible_block_values have to be fixed before
+        # creating the MILP.
+        bijectionist._preprocess_intertwining_relations()
+        bijectionist._compute_possible_block_values()
+
+        self.milp = MixedIntegerLinearProgram(solver=bijectionist._solver)
+        self.milp.set_objective(None)
+        indices = [(p, z)
+                   for p, tZ in bijectionist._possible_block_values.items()
+                   for z in tZ]
+        self._x = self.milp.new_variable(binary=True, indices=indices)
+
+        tZ = bijectionist._possible_block_values
+        P = bijectionist._P
+        for p in _disjoint_set_roots(P):
+            self.milp.add_constraint(sum(self._x[p, z] for z in tZ[p]) == 1,
+                                     name=f"block {p}"[:50])
+        self.add_alpha_beta_constraints()
+        self.add_distribution_constraints()
+        self.add_quadratic_relation_constraints()
+        self.add_homomesic_constraints()
+        self.add_intertwining_relation_constraints()
+        if get_verbose() >= 2:
+            self.show()
+
+        self._solution_cache = []
+        if solutions is not None:
+            for solution in solutions:
+                self._add_solution({(P.find(a), z): value
+                                    for (a, z), value in solution.items()})
+
+    def show(self, variables=True):
+        r"""
+        Print the constraints and variables of the MILP together
+        with some explanations.
+
+        EXAMPLES::
+
+            sage: A = B = ["a", "b", "c"]
+            sage: bij = Bijectionist(A, B, lambda x: A.index(x) % 2, solver="GLPK")
+            sage: bij.set_constant_blocks([["a", "b"]])
+            sage: next(bij.solutions_iterator())
+            {'a': 0, 'b': 0, 'c': 1}
+            sage: bij._bmilp.show()
+            Constraints are:
+                block a: 1 <= x_0 + x_1 <= 1
+                block c: 1 <= x_2 + x_3 <= 1
+                statistics: 2 <= 2 x_0 + x_2 <= 2
+                statistics: 1 <= 2 x_1 + x_3 <= 1
+                veto: x_0 + x_3 <= 1
+            Variables are:
+                x_0: s(a) = s(b) = 0
+                x_1: s(a) = s(b) = 1
+                x_2: s(c) = 0
+                x_3: s(c) = 1
+
+        """
+        print("Constraints are:")
+        b = self.milp.get_backend()
+        varid_name = {}
+        for i in range(b.ncols()):
+            s = b.col_name(i)
+            default_name = str(self.milp.linear_functions_parent()({i: 1}))
+            if s and s != default_name:
+                varid_name[i] = s
+            else:
+                varid_name[i] = default_name
+        for i, (lb, (indices, values), ub) in enumerate(self.milp.constraints()):
+            if b.row_name(i):
+                print("    "+b.row_name(i)+":", end=" ")
+            if lb is not None:
+                print(str(ZZ(lb))+" <=", end=" ")
+            first = True
+            for j, c in sorted(zip(indices, values)):
+                c = ZZ(c)
+                if c == 0:
+                    continue
+                print((("+ " if (not first and c > 0) else "") +
+                       ("" if c == 1 else
+                        ("- " if c == -1 else
+                         (str(c) + " " if first and c < 0 else
+                          ("- " + str(abs(c)) + " " if c < 0 else str(c) + " "))))
+                       + varid_name[j]), end=" ")
+                first = False
+            # Upper bound
+            print("<= "+str(ZZ(ub)) if ub is not None else "")
+
+        if variables:
+            print("Variables are:")
+            P = self._bijectionist._P.root_to_elements_dict()
+            for (p, z), v in self._x.items():
+                print(f"    {v}: " + "".join([f"s({a}) = "
+                                              for a in P[p]]) + f"{z}")
+
+    def _prepare_solution(self, on_blocks, solution):
+        r"""
+        Return the solution as a dictionary from `A` (or `P`) to
+        `Z`.
+
+        INPUT:
+
+        - ``on_blocks`` -- whether to return the solution on blocks
+          or on all elements
+
+        TESTS::
+
+            sage: A = B = ["a", "b", "c"]
+            sage: bij = Bijectionist(A, B, lambda x: 0)
+            sage: bij.set_constant_blocks([["a", "b"]])
+            sage: next(bij.solutions_iterator())
+            {'a': 0, 'b': 0, 'c': 0}
+            sage: bmilp = bij._bmilp
+            sage: bmilp._prepare_solution(True, bmilp._solution_cache[0])
+            {'a': 0, 'c': 0}
+
+        """
+        P = self._bijectionist._P
+        tZ = self._bijectionist._possible_block_values
+        mapping = {}  # A -> Z or P -> Z, a +-> s(a)
+        for p, block in P.root_to_elements_dict().items():
+            for z in tZ[p]:
+                if solution[p, z] == 1:
+                    if on_blocks:
+                        mapping[p] = z
+                    else:
+                        for a in block:
+                            mapping[a] = z
+                    break
+        return mapping
+
+    def solutions_iterator(self, on_blocks, additional_constraints):
+        r"""
+        Iterate over all solutions satisfying the additional constraints.
+
+        INPUT:
+
+        - ``additional_constraints`` -- a list of constraints for the
+          underlying MILP
+
+        - ``on_blocks``, whether to return the solution on blocks or
+          on all elements
+
+        TESTS::
+
+            sage: A = B = 'abc'
+            sage: bij = Bijectionist(A, B, lambda x: B.index(x) % 2, solver="GLPK")
+            sage: from sage.combinat.bijectionist import _BijectionistMILP
+            sage: bmilp = _BijectionistMILP(bij)
+            sage: it = bmilp.solutions_iterator(False, [])
+            sage: it2 = bmilp.solutions_iterator(False, [bmilp._x[('c', 1)] == 1])
+            sage: next(it)
+            {'a': 0, 'b': 1, 'c': 0}
+            sage: next(it2)
+            {'a': 0, 'b': 0, 'c': 1}
+            sage: next(it)
+            {'a': 0, 'b': 0, 'c': 1}
+            sage: next(it)
+            {'a': 1, 'b': 0, 'c': 0}
+
+        """
+        i = 0  # the first unconsidered element of _solution_cache
+        while True:
+            # skip solutions which do not satisfy additional_constraints
+            while i < len(self._solution_cache):
+                solution = self._solution_cache[i]
+                i += 1
+                if all(self._is_solution(constraint, solution)
+                       for constraint in additional_constraints):
+                    yield self._prepare_solution(on_blocks, solution)
+                    break
+            else:
+                new_indices = []
+                for constraint in additional_constraints:
+                    new_indices.extend(self.milp.add_constraint(constraint,
+                                                                return_indices=True))
+                try:
+                    self.milp.solve()
+                    # moving this out of the try...finally block breaks SCIP
+                    solution = self.milp.get_values(self._x,
+                                                    convert=bool, tolerance=0.1)
+                except MIPSolverException:
+                    return
+                finally:
+                    b = self.milp.get_backend()
+                    if hasattr(b, "_get_model"):
+                        m = b._get_model()
+                        if m.getStatus() != 'unknown':
+                            m.freeTransform()
+                    self.milp.remove_constraints(new_indices)
+
+                self._add_solution(solution)
+                i += 1
+                assert i == len(self._solution_cache)
+                yield self._prepare_solution(on_blocks, solution)
+                if get_verbose() >= 2:
+                    print("after vetoing")
+                    self.show(variables=False)
+
+    def _add_solution(self, solution):
+        r"""
+        Add the ``solution`` to the cache and an appropriate
+        veto constraint to the MILP.
+
+        INPUT:
+
+        - ``solution`` -- a dictionary from the indices of the MILP to
+          a boolean
+
+        EXAMPLES::
+
+            sage: A = B = ["a", "b"]
+            sage: bij = Bijectionist(A, B)
+            sage: from sage.combinat.bijectionist import _BijectionistMILP
+            sage: bmilp = _BijectionistMILP(bij)
+            sage: bmilp._add_solution({(a, b): a == b for a in A for b in B})
+            sage: bmilp.show()                                                  # random
+            Constraints are:
+                block a: 1 <= x_0 + x_1 <= 1
+                block b: 1 <= x_2 + x_3 <= 1
+                statistics: 1 <= x_1 + x_3 <= 1
+                statistics: 1 <= x_0 + x_2 <= 1
+                veto: x_1 + x_2 <= 1
+            Variables are:
+                x_0: s(a) = b
+                x_1: s(a) = a
+                x_2: s(b) = b
+                x_3: s(b) = a
+
+        """
+        active_vars = [self._x[p, z]
+                       for p in _disjoint_set_roots(self._bijectionist._P)
+                       for z in self._bijectionist._possible_block_values[p]
+                       if solution[(p, z)]]
+        self.milp.add_constraint(sum(active_vars) <= len(active_vars) - 1,
+                                 name="veto")
+        self._solution_cache.append(solution)
+
+    def _is_solution(self, constraint, values):
+        r"""
+        Evaluate the given function at the given values.
+
+        INPUT:
+
+        - ``constraint`` -- a
+          :class:`sage.numerical.linear_functions.LinearConstraint`.
+
+        - ``values`` -- a candidate for a solution of the MILP as a
+          dictionary from pairs `(a, z)\in A\times Z` to `0` or `1`,
+          specifying whether `a` is mapped to `z`.
+
+        EXAMPLES::
+
+            sage: A = B = ["a", "b"]
+            sage: bij = Bijectionist(A, B)
+            sage: from sage.combinat.bijectionist import _BijectionistMILP
+            sage: bmilp = _BijectionistMILP(bij)
+            sage: f = bmilp._x["a", "a"] + bmilp._x["b", "a"] >= bmilp._x["b", "b"] + 1
+            sage: v = {('a', 'a'): 1, ('a', 'b'): 0, ('b', 'a'): 1, ('b', 'b'): 1}
+            sage: bmilp._is_solution(f, v)
+            True
+            sage: v = {('a', 'a'): 0, ('a', 'b'): 0, ('b', 'a'): 1, ('b', 'b'): 1}
+            sage: bmilp._is_solution(f, v)
+            False
+        """
+        index_block_value_dict = {}
+        for (p, z), v in self._x.items():
+            variable_index = next(iter(v.dict()))
+            index_block_value_dict[variable_index] = (p, z)
+
+        def evaluate(f):
+            return sum(coeff if index == -1 else
+                       coeff * values[index_block_value_dict[index]]
+                       for index, coeff in f.dict().items())
+
+        for lhs, rhs in constraint.equations():
+            if evaluate(lhs - rhs):
+                return False
+        for lhs, rhs in constraint.inequalities():
+            if evaluate(lhs - rhs) > 0:
+                return False
+        return True
+
+    def add_alpha_beta_constraints(self):
+        r"""
+        Add constraints enforcing that `(alpha, s)` is equidistributed
+        with `(beta, tau)` and `S` is the intertwining bijection.
+
+        We do this by adding
+
+        .. MATH::
+
+            \sum_{a\in A, z\in Z} x_{p(a), z} s^z t^{\alpha(a)}
+            = \sum_{b\in B} s^{\tau(b)} t(\beta(b))
+
+        as a matrix equation.
+
+        EXAMPLES::
+
+            sage: A = B = [permutation for n in range(3) for permutation in Permutations(n)]
+            sage: bij = Bijectionist(A, B, len)
+            sage: bij.set_statistics((len, len))
+            sage: from sage.combinat.bijectionist import _BijectionistMILP
+            sage: bmilp = _BijectionistMILP(bij)                                # indirect doctest
+            sage: next(bmilp.solutions_iterator(False, []))
+            {[]: 0, [1]: 1, [1, 2]: 2, [2, 1]: 2}
+        """
+        W = self._bijectionist._W
+        Z = self._bijectionist._Z
+        zero = self.milp.linear_functions_parent().zero()
+        AZ_matrix = [[zero] * len(W) for _ in range(len(Z))]
+        B_matrix = [[zero] * len(W) for _ in range(len(Z))]
+
+        W_dict = {w: i for i, w in enumerate(W)}
+        Z_dict = {z: i for i, z in enumerate(Z)}
+
+        for a in self._bijectionist._A:
+            p = self._bijectionist._P.find(a)
+            for z in self._bijectionist._possible_block_values[p]:
+                w_index = W_dict[self._bijectionist._alpha(a)]
+                z_index = Z_dict[z]
+                AZ_matrix[z_index][w_index] += self._x[p, z]
+
+        for b in self._bijectionist._B:
+            w_index = W_dict[self._bijectionist._beta(b)]
+            z_index = Z_dict[self._bijectionist._tau[b]]
+            B_matrix[z_index][w_index] += 1
+
+        for w in range(len(W)):
+            for z in range(len(Z)):
+                self.milp.add_constraint(AZ_matrix[z][w] == B_matrix[z][w],
+                                         name="statistics")
+
+    def add_distribution_constraints(self):
+        r"""
+        Add constraints so the distributions given by
+        :meth:`set_distributions` are fulfilled.
+
+        To accomplish this we add
+
+        .. MATH::
+
+            \sum_{a\in\tilde A} x_{p(a), z}t^z = \sum_{z\in\tilde Z} t^z,
+
+        where `p(a)` is the block containing `a`, for each given
+        distribution as a vector equation.
+
+        EXAMPLES::
+
+            sage: A = B = Permutations(3)
+            sage: tau = Permutation.longest_increasing_subsequence_length
+            sage: bij = Bijectionist(A, B, tau)
+            sage: bij.set_distributions(([Permutation([1, 2, 3]), Permutation([1, 3, 2])], [1, 3]))
+            sage: from sage.combinat.bijectionist import _BijectionistMILP
+            sage: bmilp = _BijectionistMILP(bij)                                # indirect doctest
+            sage: next(bmilp.solutions_iterator(False, []))
+            {[1, 2, 3]: 3,
+             [1, 3, 2]: 1,
+             [2, 1, 3]: 2,
+             [2, 3, 1]: 2,
+             [3, 1, 2]: 2,
+             [3, 2, 1]: 2}
+
+        """
+        Z = self._bijectionist._Z
+        Z_dict = {z: i for i, z in enumerate(Z)}
+        zero = self.milp.linear_functions_parent().zero()
+        for tA, tZ in self._bijectionist._elements_distributions:
+            tA_sum = [zero]*len(Z_dict)
+            tZ_sum = [zero]*len(Z_dict)
+            for a in tA:
+                p = self._bijectionist._P.find(a)
+                for z in self._bijectionist._possible_block_values[p]:
+                    tA_sum[Z_dict[z]] += self._x[p, z]
+            for z in tZ:
+                tZ_sum[Z_dict[z]] += 1
+
+            for a, z in zip(tA_sum, tZ_sum):
+                self.milp.add_constraint(a == z, name=f"d: {a} == {z}")
+
+    def add_intertwining_relation_constraints(self):
+        r"""
+        Add constraints corresponding to the given intertwining
+        relations.
+
+        INPUT:
+
+        - origins, a list of triples `((\pi/\rho, p,
+          (p_1,\dots,p_k))`, where `p` is the block of
+          `\rho(s(a_1),\dots, s(a_k))`, for any `a_i\in p_i`.
+
+        This adds the constraints imposed by
+        :meth:`set_intertwining_relations`,
+
+        .. MATH::
+
+            s(\pi(a_1,\dots, a_k)) = \rho(s(a_1),\dots, s(a_k))
+
+        for each pair `(\pi, \rho)`.  The relation implies
+        immediately that `s(\pi(a_1,\dots, a_k))` only depends on the
+        blocks of `a_1,\dots, a_k`.
+
+        The MILP formulation is as follows.  Let `a_1,\dots,a_k \in
+        A` and let `a = \pi(a_1,\dots,a_k)`.  Let `z_1,\dots,z_k \in
+        Z` and let `z = \rho(z_1,\dots,z_k)`.  Suppose that `a_i\in
+        p_i` for all `i` and that `a\in p`.
+
+        We then want to model the implication
+
+        .. MATH::
+
+           x_{p_1, z_1} = 1,\dots, x_{p_k, z_k} = 1 \Rightarrow x_{p, z} = 1.
+
+        We achieve this by requiring
+
+        .. MATH::
+
+            x_{p, z}\geq 1 - k + \sum_{i=1}^k x_{p_i, z_i}.
+
+        Note that `z` must be a possible value of `p` and each `z_i`
+        must be a possible value of `p_i`.
+
+        EXAMPLES::
+
+            sage: A = B = 'abcd'
+            sage: bij = Bijectionist(A, B, lambda x: B.index(x) % 2)
+            sage: def pi(p1, p2): return 'abcdefgh'[A.index(p1) + A.index(p2)]
+            sage: def rho(s1, s2): return (s1 + s2) % 2
+            sage: bij.set_intertwining_relations((2, pi, rho))
+            sage: from sage.combinat.bijectionist import _BijectionistMILP
+            sage: bmilp = _BijectionistMILP(bij)                                # indirect doctest
+            sage: next(bmilp.solutions_iterator(False, []))
+            {'a': 0, 'b': 1, 'c': 0, 'd': 1}
+        """
+        A = self._bijectionist._A
+        tZ = self._bijectionist._possible_block_values
+        P = self._bijectionist._P
+        for composition_index, pi_rho in enumerate(self._bijectionist._pi_rho):
+            pi_blocks = set()
+            for a_tuple in itertools.product(A, repeat=pi_rho.numargs):
+                if pi_rho.domain is not None and not pi_rho.domain(*a_tuple):
+                    continue
+                a = pi_rho.pi(*a_tuple)
+                if a in A:
+                    p_tuple = tuple(P.find(a) for a in a_tuple)
+                    p = P.find(a)
+                    if (p_tuple, p) not in pi_blocks:
+                        pi_blocks.add((p_tuple, p))
+                        for z_tuple in itertools.product(*[tZ[p] for p in p_tuple]):
+                            rhs = (1 - pi_rho.numargs
+                                   + sum(self._x[p_i, z_i]
+                                         for p_i, z_i in zip(p_tuple, z_tuple)))
+                            z = pi_rho.rho(*z_tuple)
+                            if z in tZ[p]:
+                                c = self._x[p, z] - rhs
+                                if c.is_zero():
+                                    continue
+                                self.milp.add_constraint(c >= 0,
+                                                         name=f"pi/rho({composition_index})")
+                            else:
+                                self.milp.add_constraint(rhs <= 0,
+                                                         name=f"pi/rho({composition_index})")
+
+    def add_quadratic_relation_constraints(self):
+        r"""
+        Add constraints enforcing that `s\circ\phi\circ s =
+        \psi`.
+
+        We do this by adding
+
+        .. MATH::
+
+            x_{p(a), z} = x_{p(\psi(z)), \phi(a)}
+
+        for `a\in A` and `z\in Z`, where `\phi:A\to Z` and `\psi:Z\to
+        A`.  Note that, in particular, `\phi` must be constant on
+        blocks.
+
+        EXAMPLES::
+
+            sage: A = B = DyckWords(3)
+            sage: bij = Bijectionist(A, B)
+            sage: bij.set_statistics((lambda D: D.number_of_touch_points(), lambda D: D.number_of_initial_rises()))
+            sage: ascii_art(sorted(bij.minimal_subdistributions_iterator()))
+            [ (             [   /\   ] )
+            [ (             [  /  \  ] )  ( [    /\    /\    ]  [  /\      /\/\  ] )
+            [ ( [ /\/\/\ ], [ /    \ ] ), ( [ /\/  \, /  \/\ ], [ /  \/\, /    \ ] ),
+            <BLANKLINE>
+             ( [           /\   ]                     ) ]
+             ( [  /\/\    /  \  ]  [            /\  ] ) ]
+             ( [ /    \, /    \ ], [ /\/\/\, /\/  \ ] ) ]
+            sage: bij.set_quadratic_relation((lambda D: D, lambda D: D))   # indirect doctest
+            sage: ascii_art(sorted(bij.minimal_subdistributions_iterator()))
+            [ (             [   /\   ] )
+            [ (             [  /  \  ] )  ( [    /\  ]  [  /\/\  ] )
+            [ ( [ /\/\/\ ], [ /    \ ] ), ( [ /\/  \ ], [ /    \ ] ),
+            <BLANKLINE>
+            <BLANKLINE>
+             ( [  /\    ]  [  /\    ] )  ( [  /\/\  ]  [    /\  ] )
+             ( [ /  \/\ ], [ /  \/\ ] ), ( [ /    \ ], [ /\/  \ ] ),
+            <BLANKLINE>
+             ( [   /\   ]             ) ]
+             ( [  /  \  ]             ) ]
+             ( [ /    \ ], [ /\/\/\ ] ) ]
+
+        """
+        P = self._bijectionist._P
+        for phi, psi in self._bijectionist._phi_psi:
+            for p, block in P.root_to_elements_dict().items():
+                z0 = phi(p)
+                assert all(phi(a) == z0 for a in block), "phi must be constant on the block %s" % block
+                for z in self._bijectionist._possible_block_values[p]:
+                    p0 = P.find(psi(z))
+                    if z0 in self._bijectionist._possible_block_values[p0]:
+                        c = self._x[p, z] - self._x[p0, z0]
+                        if c.is_zero():
+                            continue
+                        self.milp.add_constraint(c == 0, name=f"i: s({p})={z}<->s(psi({z})=phi({p})")
+                    else:
+                        self.milp.add_constraint(self._x[p, z] == 0, name=f"i: s({p})!={z}")
+
+    def add_homomesic_constraints(self):
+        r"""
+        Add constraints enforcing that `s` has constant average
+        on the blocks of `Q`.
+
+        We do this by adding
+
+        .. MATH::
+
+            \frac{1}{|q|}\sum_{a\in q} \sum_z z x_{p(a), z} =
+            \frac{1}{|q_0|}\sum_{a\in q_0} \sum_z z x_{p(a), z},
+
+        for `q\in Q`, where `q_0` is some fixed block of `Q`.
+
+        EXAMPLES::
+
+            sage: A = B = [1,2,3]
+            sage: bij = Bijectionist(A, B, lambda b: b % 3)
+            sage: bij.set_homomesic([[1,2], [3]])                               # indirect doctest
+            sage: list(bij.solutions_iterator())
+            [{1: 2, 2: 0, 3: 1}, {1: 0, 2: 2, 3: 1}]
+        """
+        Q = self._bijectionist._Q
+        if Q is None:
+            return
+        P = self._bijectionist._P
+        tZ = self._bijectionist._possible_block_values
+
+        def sum_q(q):
+            return sum(sum(z*self._x[P.find(a), z] for z in tZ[P.find(a)])
+                       for a in q)
+        q0 = Q[0]
+        v0 = sum_q(q0)
+        for q in Q[1:]:
+            self.milp.add_constraint(len(q0)*sum_q(q) == len(q)*v0, name=f"h: ({q})~({q0})")
+
+
+def _invert_dict(d):
+    """
+    Return the dictionary whose keys are the values of the input and
+    whose values are the lists of preimages.
+
+    INPUT:
+
+    - ``d`` -- a dict
+
+    EXAMPLES::
+
+        sage: from sage.combinat.bijectionist import _invert_dict
+        sage: _invert_dict({1: "a", 2: "a", 3:"b"})
+        {'a': [1, 2], 'b': [3]}
+
+        sage: _invert_dict({})
+        {}
+    """
+    preimages = {}
+    for k, v in d.items():
+        preimages[v] = preimages.get(v, []) + [k]
+    return preimages
+
+
+def _disjoint_set_roots(d):
+    """
+    Return the representatives of the blocks of the disjoint set.
+
+    INPUT:
+
+    - ``d`` -- a :class:`sage.sets.disjoint_set.DisjointSet_of_hashables`
+
+    EXAMPLES::
+
+        sage: from sage.combinat.bijectionist import _disjoint_set_roots
+        sage: d = DisjointSet('abcde')
+        sage: d.union("a", "b")
+        sage: d.union("a", "c")
+        sage: d.union("e", "d")
+        sage: _disjoint_set_roots(d)
+        dict_keys(['a', 'e'])
+    """
+    return d.root_to_elements_dict().keys()
+
+
+def _non_copying_intersection(sets):
+    """
+    Return the intersection of the sets.
+
+    If the intersection is equal to one of the sets, return this
+    set.
+
+    EXAMPLES::
+
+        sage: from sage.combinat.bijectionist import _non_copying_intersection
+        sage: A = set(range(7000)); B = set(range(8000));
+        sage: _non_copying_intersection([A, B]) is A
+        True
+
+        sage: A = set([1,2]); B = set([2,3])
+        sage: _non_copying_intersection([A, B])
+        {2}
+
+    """
+    sets = sorted(sets, key=len)
+    result = set.intersection(*sets)
+    n = len(result)
+    for s in sets:
+        N = len(s)
+        if n < N:
+            return result
+        if s == result:
+            return s
+
+
+"""
+TESTS::
+
+    sage: As = Bs = [[],
+    ....:            [(1,i,j) for i in [-1,0,1] for j in [-1,1]],
+    ....:            [(2,i,j) for i in [-1,0,1] for j in [-1,1]],
+    ....:            [(3,i,j) for i in [-2,-1,0,1,2] for j in [-1,1]]]
+
+Note that adding ``[(2,-2,-1), (2,2,-1), (2,-2,1), (2,2,1)]`` makes
+it take (seemingly) forever::
+
+    sage: def c1(a, b): return (a[0]+b[0], a[1]*b[1], a[2]*b[2])
+    sage: def c2(a): return (a[0], -a[1], a[2])
+
+    sage: bij = Bijectionist(sum(As, []), sum(Bs, []))
+    sage: bij.set_statistics((lambda x: x[0], lambda x: x[0]))
+    sage: bij.set_intertwining_relations((2, c1, c1), (1, c2, c2))
+    sage: l = list(bij.solutions_iterator()); len(l)                            # long time -- (2.7 seconds with SCIP on AMD Ryzen 5 PRO 3500U w/ Radeon Vega Mobile Gfx)
+    64
+
+A brute force check would be difficult::
+
+    sage: prod([factorial(len(A)) for A in As])
+    1881169920000
+
+Let us try a smaller example::
+
+    sage: As = Bs = [[],
+    ....:            [(1,i,j) for i in [-1,0,1] for j in [-1,1]],
+    ....:            [(2,i,j) for i in [-1,1] for j in [-1,1]],
+    ....:            [(3,i,j) for i in [-1,1] for j in [-1,1]]]
+
+    sage: bij = Bijectionist(sum(As, []), sum(Bs, []))
+    sage: bij.set_statistics((lambda x: x[0], lambda x: x[0]))
+    sage: bij.set_intertwining_relations((2, c1, c1), (1, c2, c2))
+    sage: l1 = list(bij.solutions_iterator()); len(l1)
+    16
+    sage: prod([factorial(len(A)) for A in As])
+    414720
+
+    sage: pis = cartesian_product([Permutations(len(A)) for A in As])
+    sage: it = ({a: Bs[n][pi[n][i]-1] for n, A in enumerate(As) for i, a in enumerate(A)} for pi in pis)
+    sage: A = sum(As, [])
+    sage: respects_c1 = lambda s: all(c1(a1, a2) not in A or s[c1(a1, a2)] == c1(s[a1], s[a2]) for a1 in A for a2 in A)
+    sage: respects_c2 = lambda s: all(c2(a1) not in A or s[c2(a1)] == c2(s[a1]) for a1 in A)
+    sage: l2 = [s for s in it if respects_c1(s) and respects_c2(s)]             # long time -- (17 seconds on AMD Ryzen 5 PRO 3500U w/ Radeon Vega Mobile Gfx)
+    sage: sorted(l1, key=lambda s: tuple(s.items())) == l2                      # long time
+    True
+
+Our benchmark example::
+
+    sage: from sage.combinat.cyclic_sieving_phenomenon import orbit_decomposition
+    sage: def alpha1(p): return len(p.weak_excedences())
+    sage: def alpha2(p): return len(p.fixed_points())
+    sage: def beta1(p): return len(p.descents(final_descent=True)) if p else 0
+    sage: def beta2(p): return len([e for (e, f) in zip(p, p[1:]+[0]) if e == f+1])
+    sage: gamma = Permutation.longest_increasing_subsequence_length
+    sage: def rotate_permutation(p):
+    ....:    cycle = Permutation(tuple(range(1, len(p)+1)))
+    ....:    return Permutation([cycle.inverse()(p(cycle(i))) for i in range(1, len(p)+1)])
+
+    sage: N = 5
+    sage: As = [list(Permutations(n)) for n in range(N+1)]
+    sage: A = B = sum(As, [])
+    sage: bij = Bijectionist(A, B, gamma)
+    sage: bij.set_statistics((len, len), (alpha1, beta1), (alpha2, beta2))
+    sage: bij.set_constant_blocks(sum([orbit_decomposition(A, rotate_permutation) for A in As], []))
+
+    sage: P = bij.constant_blocks(optimal=True)
+    sage: P = [sorted(p, key=lambda p: (len(p), p)) for p in P]
+    sage: P = sorted(P, key=lambda p: (len(next(iter(p))), len(p)))
+    sage: for p in P:
+    ....:     print(p)
+    [[1], [1, 2], [1, 2, 3], [1, 2, 3, 4], [1, 2, 3, 4, 5]]
+    [[2, 1], [1, 3, 2], [2, 1, 3], [2, 3, 1], [3, 2, 1], ...
+    [[3, 1, 2], [1, 4, 2, 3], [2, 4, 1, 3], [3, 1, 2, 4], [3, 1, 4, 2], ...
+    [[4, 1, 2, 3], [1, 5, 2, 3, 4], [4, 1, 2, 3, 5], [4, 5, 1, 2, 3], [5, 1, 2, 4, 3], ...
+    [[1, 3, 2, 5, 4], [2, 1, 3, 5, 4], [2, 1, 4, 3, 5], [5, 2, 4, 3, 1], [5, 3, 2, 4, 1]]
+    [[1, 3, 5, 2, 4], [2, 4, 1, 3, 5], [3, 5, 2, 4, 1], [4, 1, 3, 5, 2], [5, 2, 4, 1, 3]]
+    ...
+
+    sage: for d in sorted(bij.minimal_subdistributions_blocks_iterator(), key=lambda d: (len(d[0]), d[0])):
+    ....:     print(d)
+    ([[]], [0])
+    ([[1]], [1])
+    ([[2, 1]], [2])
+    ([[3, 1, 2]], [3])
+    ([[4, 1, 2, 3]], [4])
+    ([[5, 1, 2, 3, 4]], [5])
+    ([[2, 3, 1, 5, 4], [2, 4, 5, 3, 1], [2, 5, 4, 1, 3], [3, 4, 1, 5, 2]], [2, 3, 3, 3])
+    ([[3, 1, 2, 5, 4], [4, 1, 2, 5, 3], [3, 5, 2, 1, 4], [4, 1, 5, 2, 3]], [3, 3, 4, 4])
+    ([[2, 1, 3, 5, 4], [2, 4, 1, 3, 5], [2, 5, 3, 1, 4], [3, 4, 1, 2, 5], [3, 1, 5, 4, 2], [2, 5, 1, 4, 3], [2, 1, 5, 4, 3]], [2, 2, 3, 3, 3, 3, 3])
+
+    sage: l = list(bij.solutions_iterator()); len(l)                            # not tested -- (17 seconds with SCIP on AMD Ryzen 5 PRO 3500U w/ Radeon Vega Mobile Gfx)
+    504
+
+    sage: for a, d in bij.minimal_subdistributions_iterator():                  # not tested
+    ....:     print(sorted(a), sorted(d))
+"""


Test patch: