Flux Permutations Adaptor (1/4)
Using C++ coroutines for a simple permutations adaptor.
I’ve been using the Flux C++ library for a while now, ever since hearing Tristan Brindle talk about it on the ADSP podcast. As a brief introdution for the unintroduced, Flux is a library for “sequence-oriented programming, similar in spirit to C++20 ranges, Python itertools, and Rust iterators.” Flux provides an easy API to apply algorithms to sequences. For example, this is how you might sum all even integers from 0 to 10 using Flux.
1
2
3
4
auto sum = flux::ints() // [0, 1, 2, 3, ...]
.take(10) // [0, 1, 2, 3, ..., 10]
.filter(is_even) // [0, 2, 4, 6, 8, 10]
.sum(); // 30
Flux is also an open source project, and I thought it would a great library to make a
contribution to. I saw on GitHub that there was a request for a permutations
adaptor
I thought this would be something that I could handle, so I commented on the GitHub issue that
I would try to implement it. After a busy Christmas season I was able to get a working
implementation. These four articles detail how I implemented the adaptor.
The Idea
A permutations adapator (or iterator, or function, etc.) is a common tool in other iteration libraries. The adaptor takes in an input sequence, and returns a sequence made up of sequences that are a permutation of the previous sequence. A basic example would be as follows (in Python).
1
2
3
4
5
6
7
8
9
import itertools as it
for permutation in it.permutations([1, 2]):
print(permutation)
""" Output:
(1, 2)
(2, 1)
"""
The goal would be to add an adaptor to Flux such that operations like the previous example would be possible.
1
2
3
flux::ints() // [1, 2, 3, ...]
.take(2) // [1, 2]
.permutations(); // [[1, 2], [2, 1]]
Defining a Permutation
Before doing some implementation, let’s spend a few minutes trying to understand what a permutation actually is, and how the word is used in mathematics and programming. The Wikipedia page for permutation is displayed from a mathematics perspective, and states as follows.
- Permutation (Wikipedia)
- In mathematics, a permutation of a set can mean one of two different things: an arrangement of its members in a sequence or linear order, or the act or process of changing the linear order of an ordered set. (Wikipedia/Permutation)
Our adaptor will fulfil both definitions. We want the adaptor to (1) produce every permutation of the input sequence and (2) perform the process of generating new permutations from the sequence. Based on this we’ll make some definitions for our adaptor.
- Input Sequence
- The original sequence to perform the permutations on. This must be a
flux::bounded_sequence
, because there have to be a finite number of elements to permute. - Output Sequence
- A sequence of sub-sequences where the sub-sequences are all of the permutations of the input sequence. The order of the sub-sequence is in lexicographical order.
- Sub-Sequence
- A sequence of length
r
that contains a permutation of the input sequence.
The Simplest Permutations Adaptor
While the Python itertools
and Rust itertools
iterators allow the user to specify the length of each sub-sequence, the simplest permutations
adaptor will always output sub-sequences the length of the original sequence.
Flux allows for C++ coroutines to be used to specify simple adaptors, which is great for quickly developing a new adaptor. For a introduction to coroutines I would recommend Joel Schumacher’s Yet Another C++ Coroutine Tutorial or David Mazieres’ My tutorial and take on C++20 Coroutines. The basics for using a coroutine to create a Flux adaptor are as follows:
1
2
3
4
5
6
7
8
9
10
11
12
// Construction
auto adaptor_name = []<flux::sequence Seq>(Seq seq)
-> flux::generator<flux::element_t<Seq>>
{
// Implementation
};
// Usage
flux::ints()
.take(2)
._(adaptor_name)
. //...
The flux::generator
type implements the flux::sequence
functionality we need, and the _
function
applies the adaptor to the input sequence.
Now to create the permutations adaptor we’ll consider what steps we need to take. First, we’ll need to cache the input sequence. We do this because each output-subsequence will be composed of the input values and the input sequence may be a single pass input so that if we don’t cache it we’ll only be able to produce the first output.
1
2
3
4
5
6
auto permutations_adaptor = []<flux::sequence Seq>(Seq seq)
-> flux::generator</* subsequence type */>
{
std::vector<flux::value_t<Seq>> cache;
for (auto &&elem : seq) { cache.emplace_back(elem); }
};
Now we need to consider what the subsequence type should be. Since we want our output to be a sequence
of subsequences, and we already have our input values cached in a std::vector
we might as well just
have the subsequence type be a std::vector
as well. There are other types we could use in order to
limit extraneous memory allocation, but we’ll talk about that another day.
1
2
3
auto permutations_adaptor = []<flux::sequence Seq>(Seq seq)
-> flux::generator<std::vector<flux::value_t<Seq>>>
// ...
Producing the First Sub-Sequence
The first permutation in the sequence is the easiest, as it’s just the exact same as the input sequence.
To return the first sequence, we’ll use co_yield
to return the first element.
1
2
3
4
5
6
7
8
auto permutations_adaptor = []<flux::sequence Seq>(Seq seq)
-> flux::generator<std::vector<flux::value_t<Seq>>>
{
std::vector<flux::value_t<Seq>> cache;
for (auto &&elem : seq) { cache.emplace_back(elem); }
co_yield cache; // first subsequence
};
Producing Following Sub-Sequences
To get later sequences we can use the std::next_permutation
to produce the permutations. According to
the documentation the function
permutes the input sequence to the next permutation, and returns true if there is another permutation
possible, or false if all permutations have been exhausted. We can use the return value of
std::next_permutation
to determine if we should continue producing elements and permute the elements
in one step.
1
2
3
4
5
6
7
8
9
10
auto permutations_adaptor = /* function signature */
{
/* cache input */
co_yield cache; // first subsequence
while (std::next_permutation(cache.begin(), cache.end())) {
co_yield cache; // following subsequences
}
};
If we do a simple test with this adaptor and compare it with the Rust and Python implementations, we see that for simple input sequences we get the correct result! (See the examples on compiler explorer)
Language | Output |
---|---|
C++ | [[0, 1, 2], [0, 2, 1], [1, 0, 2], [1, 2, 0], [2, 0, 1], [2, 1, 0]] |
Python | [(0, 1, 2), (0, 2, 1), (1, 0, 2), (1, 2, 0), (2, 0, 1), (2, 1, 0)] |
Rust | [[0, 1, 2], [0, 2, 1], [1, 0, 2], [1, 2, 0], [2, 0, 1], [2, 1, 0]] |
The number of possible permutations can be caculated using the following formula:
\[P(n,r) = \frac{n!}{(n - r)!}\]If we calculate that for an input of size three (e.g. size of [0, 1, 2]
) and a sub-sequence size
of three as well, we calculate that the number of permutations should be six.
This just verifies the output is the correct length.
Let’s add another test though really quick, we’ll do the input sequence [2,1,0]
.
Language | Output |
---|---|
C++ | [[2, 1, 0]] |
Python | [(2, 1, 0), (2, 0, 1), (1, 2, 0), (1, 0, 2) (0, 2, 1), (0, 1, 2)] |
Rust | [[2, 1, 0], [2, 0, 1], [1, 2, 0], [1, 0, 2] [0, 2, 1], [0, 1, 2]] |
Well, that’s clearly not correct. If we step through the code using lldb
(or any other debugger) the
issue becomes apparent: std::next_permutation
returns false on the first call to it, so the adaptor
only returns the very first sub-sequence in the result, which is not what we expected. Let’s return
back to the documentation.
Permutes the range [first, last) into the next permutation. Returns true if such a “next permutation” exists; otherwise transforms the range into the lexicographically first permutation (as if by std::sort) and returns false.
cppreference/next_permutation
This function does the permutation in-place on the input sequence, using the ordering of the values
of the sequence itself. Since the input sequence is sorted in reverse order ([2, 1, 0]
) then the
next lexicographical permutation of the sequence should be [0, 1, 2]
(the sequence in sorted order).
Clearly, we can see that the Python and Rust sequences don’t return this as the next value. The second
value of both of other implementations is [2, 0, 1]
, which isn’t the next lexicographical permutation
of the input sequence. So what is going on here?
I pulled up the source for both the Python and the Rust implementations, and I found that both implementations don’t actually permute the input based off the input values, but rather they permute the input based off the indices of the input. Here’s an example:
1
2
3
4
5
6
7
8
9
Input:
[2, 1, 0]
Permutations based off value:
[[2, 0, 1], [0, 1, 2], [0, 2, 1], [1, 0, 2], ...]
Permutations based off index:
Values: [[2, 1, 0], [2, 0, 1], [1, 2, 0], ...]
Indices: [[0, 1, 2], [0, 2, 1], [1, 0, 2], ...]
Based on this information, we can change our adaptor in a few ways. First, we need to keep track of all of the permuted indices of the input sequence. Secondly, we need a way to reindex the cached input sequence by the permuted indices. Let’s write the reindex function first.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
using std::vector;
template<typename T>
auto reindex_sequence(const vector<T> &input,
const vector<size_t> &indices
) -> vector<T>
{
vector<T> output;
for (const auto &index : indices) {
output.push_back(input[index]);
}
return output;
}
Great! Now that we have a function that can take in the vectors of indices and inputs and return a vector with the input values ordered by the indices vector. Let’s add indices into our adaptor now.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
auto permutations_adaptor = []<flux::sequence Seq>(Seq seq)
-> flux::generator<std::vector<flux::value_t<Seq>>>
{
std::vector<flux::value_t<Seq>> cache;
for (auto &&elem : seq) { cache.emplace_back(elem); }
std::vector<size_t> indices(cache.size());
std::iota(indices.begin(), indices.end(), 0);
co_yield cache; // first subsequence
while (std::next_permutation(indices.begin(), indices.end())) {
// following subsequences
co_yield reindex_sequence(cache, indices);
}
};
Great! Our output now matches our reference implementations in Python and Rust.
Language | Output |
---|---|
C++ | [[2, 1, 0], [2, 0, 1], [1, 2, 0], [1, 0, 2] [0, 2, 1], [0, 1, 2]] |
Python | [(2, 1, 0), (2, 0, 1), (1, 2, 0), (1, 0, 2) (0, 2, 1), (0, 1, 2)] |
Rust | [[2, 1, 0], [2, 0, 1], [1, 2, 0], [1, 0, 2] [0, 2, 1], [0, 1, 2]] |
With that complete, our simple permutations adaptor is now complete! To follow the process of adding this adaptor into the official Flux library read the follow up articles to this one.
Thanks for reading!
Complete Adaptor
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
// Made with Flux 0.4.0 and C++20
#include <algorithm>
#include <iostream>
#include <numeric>
#include <flux.hpp>
// Return a vector containing a permutation of the
// input values in the order of the indices given in
// the indices vector
template<typename T>
auto reindex_sequence(const std::vector<T> &input,
const std::vector<size_t> &indices
) -> std::vector<T>
{
std::vector<T> output;
for (const auto &index : indices) {
output.push_back(input[index]);
}
return output;
}
// Generates all permutations of a given input sequence
auto permutations_adaptor = []<flux::sequence Seq>(Seq seq)
-> flux::generator<std::vector<flux::value_t<Seq>>>
{
// cache the input
std::vector<flux::value_t<Seq>> cache;
for (auto &&elem : seq) { cache.emplace_back(elem); }
// generate the indices vector from [0, n) where
// n is the size of the input sequence
std::vector<size_t> indices(cache.size());
std::iota(indices.begin(), indices.end(), 0);
co_yield cache; // first subsequence
// permute the indices, and return the input reindexed by
// the indices
while (std::next_permutation(indices.begin(), indices.end())) {
// following subsequences
co_yield reindex_sequence<flux::value_t<Seq>>(cache, indices);
}
};