I had some fun solving The Weekly Challenge #374, here’s how it went.
Task 1
asks us to find all vowel substrings of a given string, where a vowel substring is any substring that:
-
only contains vowels, and
-
contains all vowels. [1]
I’m solving in K, and the first tempting approach is to just find all substrings and filter by the appropriate predicate. Is it short? Yes. Fast? Depends on the string’s length, but I’m tempted to say no.
solve: ({~|/(|/^?)/'1|:\(x;"aeiou")}')#,/,\'|',\|:
solve "aeiou"
solve "aaeeeiioouu"
solve "aeiouuaxaeiou"
solve "uaeiou"
solve "aeioaeioa"
Ok but like, how does it work?
For the reader in a hurry, if you’re only interested in good and readable solutions skip ahead to the next section. I promise it gets better.
,/,\'|',\|: is "all substrings": it works by composing a bunch of operations, right to left.
-
|is reverse, but for k grammar reasons it needs to be spelled|:in this context to disambiguate with its binary use -
,\is a scan with an operation of concatenation, so it gives all prefixes (but we’re reversed, so it’s actually the reverses of all suffixes) -
|'is reverse each, so now we have all suffixes -
,\'same as before, but on each: we have all prefixes of each suffix -
,/is fold with concatenation, so we have all prefixes of all suffixes. Hooray!
{~|/(|/^?)/'1|:\(x;"aeiou")} is the vowel-string predicate:
-
{ }introduce a unary function wherexis the argument. -
1|:\(x;"aeiou")generates the two pairs(x;"aeiou")and("aeiou";x) -
(|/^?)/'for each pair, checks if any elements of the right element are not in the left element, so if there’s anything in the set-wise difference -
~|/checks if both results are false.
({~|/(|/^?)/'1|:\(x;"aeiou")}')#,/,\'|',\|: keeps from all substrings only those for which the predicate is true.
Not exactly rocket surgery, but it works. Now for solving the problem efficiently, which is the actually fun challenge:
Better solution
We’re aiming for a solution that is as close to being linear in both time and space as possible, however that’s not possible in this case
because the output itself might be larger than that. For example the string "aeiou…u" has an all vowel substring for each length
between 5 and the total length of the input. "a…aeiou…u" has a quadratic number of substrings, and the sum of their lengths is
cubic in the length of the argument. Ok, we allow ourselves a linear factor in the total size of the result.
The core of this solution is to find, for each position where a substring could end, for which starting positions the resulting substrings are all-vowel substrings. It’s easy to see that this is a contiguous range.
Suppose we are at position \(i\) in the string: let \(j_c\) be the latest position of a consonant before or at \(i\), and \(j_a,j_e,j_i,j_o,j_u\) the latest positions of each respective vowel before or at \(i\). then it follows that any substring ending after the letter at \(i\) must start strictly after \(j_c\) and at or before all of the \(j_a,j_e,j_i,j_o,j_u\). This would give a fairly straight-forward sequential implementation like:
void substrings(char const *s) {
static char const v[] = "aeiou";
int j[6] = {-1,-1,-1,-1,-1,-1};
for (int i = 0; s[i]; ++i) {
int k = strchrnul(v,s[i]) - v;
j[k] = i;
if (k < 5) {
// can't contain a consonant
int begin = j[5];
// must contain all vowels
int end = fold_min(j, 5);
for (; begin < end; ++begin) {
printf("%*s\n", i + 1 - begin, s + begin);
}
}
}
}
but sequential is not what we’re going for here, so what can we do? let’s write some K.
Parallel solution?
First, we would like to classify (?) all characters as either one of the vowels, or a consonant.
we prepend (,) one element for each of the character classes for good measure, we’ll see later how that
helps.
class: "aeiou"?"aeiouz",x
Then we make a dictionary (=) out of that, so that we have the sorted list of
positions at which each class occurs. we know the order of the keys because we prepended the elements earlier, so we
can just take the values. Also our positions are relative to the padded string, so we subtract the length of the prefix.
Remember the first five position lists are for the vowels, the last is for the consonants.
occurrences: -6+.=class
Now we find all the candidate end positions: they’re just the positions where (&)
the vowels are.
end: &~^6_class
For each end position, we want to find the position where the closest previous element of each class is.
We can do this by binary searching (') all of the end
positions in each (\:) of the occurrence lists,
which should be a \(O(m * log n)\) operation, however a smart interpreter could do it in
\(O(m + log n)\) given that the list of end points is sorted, so I’m going to say that’s the
complexity of this step. Remember those sentinel values we added at the beginning? they make sure the search
always succeeds, and the very first consonant occurs at position -1.
previous: occurrences {x@x'y}\: end
Now the first valid position to begin a substring is right after the previous occurrence of a consonant,
and the first invalid position is right after the minimum (&) of the previous
occurrences for the vowels. We enumerate (!) the valid beginning positions
for a vowel substring, then we construct the ranges explicitly and use them to index into the original array.
(vowel;,consonant): 0 5_previous
begins: 1+consonant+!'(1+&/vowel)-1+consonant
substrings: x@ {x+!y-x}.',/begins,''ends+1
A couple of simplifications later our solution becomes:
{
class: "aeiou"?"aeiouz",x
occurrences: -6+.=class
ends: &~^6_class
(vowel;,consonant): 0 5_ occurrences {1+x@x'y}\: ends
begins: consonant+!'(&/vowel)-consonant
: x@ {x+!y-x}.',/begins,''ends+1
}
How is this parallel?
Ok, the k interpreter I wrote this for is completely sequential, but all operations I described can be implemented in parallel with
techniques such as simple work batching for arithmetic, binary searching and classifying, and parallel gather operations for the calls to
where and group (the key set is known and small), so that the entire thing (without the very last step of building all of
the substrings) could be implemented very efficiently on a GPU. K lets me iterate about parallel algorithms quickly and easily,
because most of its primitive operations are just very simple parallel algorithms.