Letter Boxed validator

The New York Times has a daily online game called Letter Boxed (the link is behind a paywall; the game is also described here), presented on a square as follows: You are given 4 groups of 3 letters (each group corresponds to one side on the picture); no letter appears twice. The aim of the game is to find words made of those 12 letters (and those letters only) such that:

• Each word is at least 3 letters long;
• Consecutive letters cannot be from the same side;
• The last letter of a word becomes the first letter of the next word;
• All letters are used at least once (letters can be reused).

In this challenge, you are given the letters and a list of words. The goal is to check whether the list of words is a valid Letter Boxed solution.

Input

Input consists of (1) 4 groups of 3 letters and (2) a list of words. It can be in any suitable format.

Output

A truthy value if the list of words is a valid solution to the Letter Boxed challenge for those 4×3 letters, and a falsey value otherwise.

Test cases

Groups of letters={{I,C,O}, {M,R,E}, {G,N,S}, {A,P,L}}.

Truthy values

• PILGRIMAGE, ENCLOSE
• CROPS, SAIL, LEAN, NOPE, ENIGMA

Falsey values

• PILGRIMAGE, ECONOMIES (can't have CO since they are on the same side)
• CROPS, SAIL, LEAN, NOPE (G and M have not been used)
• PILGRIMAGE, ENCLOSURE (U is not one of the 12 letters)
• ENCLOSE, PILGRIMAGE (last letter of 1st word is not first letter of 2nd word)
• SCAMS, SO, ORGANISE, ELOPE (all words must be at least 3 letters long).

Note that in this challenge, we do not care whether the words are valid (part of a dictionary).

Scoring:

This , lowest score in bytes wins!

• @TFeld no letter appears twice – feersum Apr 15 at 11:06
• A truthy value if the list of words is a valid solution to the Letter Boxed challenge for those 4×3 letters, and a falsey value otherwise. For Python (and most other languages, I expect), both [] and 0 are falsey. Can we output either, or must our output be consistent? – Artemis Fowl Apr 15 at 23:35
• @ArtemisFowl Either is fine. – Robin Ryder Apr 16 at 6:46
• I thought so, but my question was: can we mix them? – Artemis Fowl Apr 16 at 15:03
• @ArtemisFowl Yes, you can mix them. – Robin Ryder Apr 16 at 15:05

JavaScript (ES6),  130  126 bytes

Takes input as (letters)(words). Returns $$\0\$$ or $$\1\$$.

L=>W=>L.every(a=>a.every(x=>(W+'').match(x,a.map(y=>s+='|'+x+y))),p=s=1)&W.every(w=>w&&p|w==p&!w.match(s,p=w.slice(-1)))

Try it online!

Step 1

We first iterate over $$\L\$$ to build a pipe-separated string $$\s\$$ consisting of all invalid pairs of letters. While doing so, we also make sure that each letter appears at least once in some word.

L.every(a =>              // for each group of letter a[] in L[]:
a.every(x =>            //   for each letter x in a[]:
(W + '')              //     coerce W[] to a string
.match(               //     and test whether ...
x,                  //       ... x can be found in it
a.map(y =>          //       for each letter y in a[]:
s += '|' + x + y  //         append '|' + x + y to s
)                   //       end of map()
)                     //     end of match()
),                      //   end of inner every()
p = s = 1               //   start with p = s = 1
)                         // end of outer every()

Step 2

We now iterate over $$\W\$$ to test each word.

W.every(w =>              // for each word w in W[]:
w &&                 //   is this word at least 3 characters long?
p |                     //   is it the first word? (p = 1)
w == p &             //   or does it start with the last letter of the previous word?
!w.match(               //   and finally make sure that ...
s,                    //     ... it doesn't contain any invalid pair of letters
p = w.slice(-1)       //     and update p to the last letter of w
)                       //   end of match()
)                         // end of every()

Jelly, 30 29 bytes

FQṢ=Ṣ},i@€€’:3Iʋ,Ẉ>2ɗ,U=ḢɗƝ{Ȧ

Try it online!

A dyadic link that takes the list of words as left argument and the flattened list of letters in the box as the right argument. It returns 1 for true and 0 for false.

Explanation

F                               | Flatten the word list
Q                              | Unique
Ṣ                             | Sort
=                            | Is equal to
Ṣ}                          |   The sorted letterbox letters
,        ʋ                | Pair this with the following:
i@€€                     |   The index of each letter of each word in the letterbox
’                    |   Decrease by 1
:3                  |   Integer divide by 3
I                 |   Differences between consecutive ones (will be zero if any two consecutive letters in a word from same side of box)
,   ɗ           | Pair everything so far with the following:
Ẉ>2            |   Whether length of each input word is greater than 2
,   ɗƝ{    | Pair everything so far with the following, applied to each neighbouring pair of the input word list
U         |   Upend (reverse) first word
=        | Compare characters to second
Ḣ       |   Take first (i.e. last character of first word equals first character of second)
Ȧ   | Flatten all of the above and check there are no false values

05AB1E, 373533323129 28 bytes

εk3÷üÊ}DO2@¹ü«εüQO}²{¹˜êQ)˜P

-2 bytes by taking inspiration of the ê approach @Emigna used in his 05AB1E answer.
-3 bytes thanks to @Grimy.

Takes a list of list of characters for the words as first input, and the flattened list of twelve letters as second input.

Explanation:

ε         # Map over the character-lists y of the (implicit) input-list of words:
k        #  Get the index of each character in the (implicit) input-list of letters
3÷      #  Integer-divide each index by 3
üÊ    #  Check for each overlapping pair of integers that they are NOT equal
}D        # After the map: duplicate the resulting list
O       #  Get the sum of each inner list of truthy/falsey values
2@     #  And check that each is larger than 2 (so all words had at least 3 letters)
¹ü        # Get all overlapping pairs of character-lists from the input-list of words:
«       #  And merge them together to a flattened list of characters
ε   }  # Map over those merged character lists:
üQ    #  Check for each overlapping pair of characters in the list that they are equal
O   #  And take the sum of this (where we'd expect 1/truthy if the last character of
#  the first word and the first character of the second word are equal)
#  (NOTE: This could fail for inputs with identical adjacent characters,
#   but the earlier check of εk3÷üÊ} already covers for this)
²{        # Push the input-list of letters, and sort them
¹˜      # Push the input-list of list of word-letters, flattened,
ê     # and then uniquified and sorted as well
Q    # And check if both lists of characters are the same
)˜        # Then wrap everything on the stack into a list, and deep flatten it
P       # And check if everything is truthy by taking the product
# (which is output implicitly as result)
• @Grimy Ah, that first comment is indeed an obvious one. I've just changed it to a character array, so now that indeed works where it wouldn't before when the words were still strings. That second approach of merge, check pair equality, sum is quite brilliant, though! :D Thanks (as always). – Kevin Cruijssen Sep 18 at 12:40
• Another -1: ¹€g3@ -> DO2@ after the first check (TIO) – Grimy Sep 18 at 12:47
• @Grimy Another nice one, thanks. We're now below the Jelly answer of 29. :) – Kevin Cruijssen Sep 18 at 13:04

05AB1E, 42 bytes

εg2›}P¹εεUIεXå}ƶO}üÊP}P¹ü‚εнsθQ}P¹Jê²JêQP

Try it online!

• It's not much, but a byte can be saved by removing all P after the maps, and use )˜P at the end. 41 bytes Nice approach with ê however! Saved 2 bytes in my 05AB1E answer. – Kevin Cruijssen Apr 15 at 13:52

Python 2, 171 bytes

lambda l,w:(set(sum(l,[]))==set(''.join(w)))*all(a[-1]==bfor a,b in zip(w,w[1:]))*all((a in g)+(b in g)<2for x in w for a,b in zip(x,x[1:])for g in l)*min(map(len,w))>2

Try it online!

Jelly, 34 bytes

Ṫ=¥/ƝḢ€Ạȧ⁸Fe€ⱮZḄ;IẠƊȧF}fƑF{
ẈṂ>2ȧç

A dyadic Link accepting the words on the left and the letter groups on the right which yields 1 if valid and 0 if not.

Try it online! Or see the test-suite.

import Data.List
l&w=all((>2).length)w&&c w&&all(l!)w&&(h l)%(h w)
h=concat
l%w=null[x|x<-l,xnotElemw]
l!(a:b:c)=a#l?(b#l)&&l!(b:c)
l!_=1>0
Just a?Just b=a/=b
_?_=1<0
c#l=findIndex(elem c)l
c _=1>0

Try it online!

Not the best score. Some Haskell guru will probably be able to get this under 100 bytes.

Usage

["ICO","MRE","GNS","APL"]&["CROPS", "SAIL", "LEAN", "NOPE", "ENIGMA"]

Explanation

import Data.List
l&w = all((>2).length)w &&      -- Every word has length > 2
c w &&                    -- Every word ends with the same letter as the next one starts with
all(l!)w &&               -- For every word: Consecutive letters are on different sides (and must exist on a side)
(h l)%(h w)               -- All letters are used

h=concat                        -- Just a shorthand

l%w=null[x|x<-l,xnotElemw]    -- The letters of l, with all letters of w removed, is empty

l!(a:b:c)=a#l?(b#l)&&l!(b:c)    -- Sides of the first two letters are different, recurse from second letter
l!_=1>0                         -- Until fewer than 2 letters remain

Just a?Just b=a/=b              -- Both sides must be different
_?_=1<0                         -- And must exist

c#l=findIndex(elem c)l          -- Find the side of letter c

c(a:b:t)=last a==head b&&c(b:t) -- Last letter of the first word must be same as first letter of second word, recurse starting from second word
c _=1>0                         -- Until there are fewer than 2 words

A different Haskell variation, exactly the same size as @Paul Mutser's :)

import Data.List
f x=filter(\a->length a>1)$concatMap subsequences x g=nub.concat.f p l(x:y)=foldl(\(m,n)c->(c,n&&length c>2&&(not$any(isInfixOfc)(f l))&&last m==head c))(x,True)y
z l w=null(g l\\g w)&&null(g w\\g l)&&(snd$p l w) Try it online! Ungolfed -- generate all invalid substrings f :: [String] -> [String] f xs = filter (\x -> length x > 1)$ concatMap subsequences xs

-- utility function to flatten and remove duplicates
g :: [String] -> String
g  = nub $concat$ f

-- verify that all conditions are satisfied along the list
p :: [String] -> [String] -> (String, Bool)
p l (x:xs) = foldl (\(m,n) c -> (c , n && length c > 2 && (not $any (isInfixOf` c)(f l)) && last m == head c)) (x, True) xs -- put all the pieces together and consume input z :: [String] -> [String] -> Bool z l w = null (g l \\ g w) && null (g w \\ g l) && (snd$ p l w)

Ruby, 126 bytes

->l,w{(/(_|^)..(_|$)/!~s=w*?_)&&!!s.chars.uniq&&/__|^_|_$|(_.*)\1/!~s.gsub(/(.)_\1/,'\1').chars.map{|x|l.grep(/#{x}/)}*?_}

Try it online!

• Nice, when I first saw the challenge I tried to do something similar, but gave up with a score somewhere in 140-ies. BTW, save a byte by dropping parentheses after grep. – Kirill L. Apr 17 at 18:04
• This doesn't work when the last word is 1 or 2 letters long, e.g. puts f[l,['PILGRIMAGE','ENCLOSE','EG']] returns true instead of false. – Robin Ryder May 4 at 11:58
• You are right, fixed. – G B May 4 at 21:15

Java (JDK), 188 bytes

g->w->{var v=0<1;int x=0,l,i=0,j,p,z,y=w;for(;i<w.length;i++)for(l=w[i].length,v&=y==w[i]&l>2,j=0,p=-9;j<l;v&=z>=0&z/3!=p/3,x|=2<<(p=z))z=g.indexOf(y=w[i][j++]);return v&x==8190;}

Try it online!

Explanations

g->w->{     // Lambda accepting letter groups as a string and a list of words, in the form of an array of char arrays.
var v=0<1;     // Validity variable
int x=0,       // The letter coverage (rule 4)
l,         // The length of w[i]
i=0,       // The w iterator
j,         // The w[i] iterator
p,         // The previous group
z,         // The current group
y=w; // The previous character
for(;i<w.length;i++) // For each word...
for(
l=w[i].length,     // make a shortcut for the length
v&=y==w[i]&l>2, // check if the last character of the previous word is the same as the first of the current.
// Also, check if the length is at least 3
j=0,               // Reset the iteration
p=-9               // Set p to an impossible value.
;
j<l                //
;
v&=z>=0&z/3!=p/3,  // Check that each letter of the word is in the letter pool,
//  and that the current letter group isn't the same as the previous one.
x|=2<<(p=z)      // After the checks, assign z to p,
//  and mark the letter of the pool as used.
)
z=g.indexOf(y=w[i][j++]); // Assign the current letter to y so that it contains the last at the end of the loop.
//  and fetch the position of the letter in the pool.
return v&x==8190; // Return true if all matched
//  and if the rule 4 is enforced.
}

Credits

• -2 bytes thanks to ceilingcat

Charcoal, 63 bytes

⌊⁺⁺⁺⭆η›Ｌι²⭆⪫ηω№⪫θωι⭆⪫θω№⪫ηωι⭆η⭆ι⎇μ¬⁼Φθ№νλΦθ№ν§ι⊖μ∨¬κ⁼§ι⁰§§η⊖κ±¹

Try it online! Link is to verbose version of code. Explanation:

⌊⁺⁺⁺

Concatenate the below expressions and output 0 if any of them includes a 0 otherwise 1.

⭆η›Ｌι²

For each word in the solution output whether its length is at least 3.

⭆⪫ηω№⪫θωι

For each letter in the solution output whether it appears in the puzzle.

⭆⪫θω№⪫ηωι

For each letter in the puzzle output whether it appears in the solution.

⭆η⭆ι⎇μ¬⁼Φθ№νλΦθ№ν§ι⊖μ∨¬κ⁼§ι⁰§§η⊖κ±¹

For each letter in the solution check that the previous letter is not in the same group, unless it is the first letter of a word, in which case check that it is equal to the last letter of the previous word, unless it is the first letter of the solution, in which case just ignore it.

Python 2, 168 156 bytes

lambda l,w,J=''.join:(set(J(w))==set(J(l)))*all((v<1or u[-1]==v)*u[2:]*(2>(x in p)+(y in p))for u,v in zip(w,w[1:]+)for x,y in zip(u,u[1:])for p in l)

Try it online!

Returns 1 for truthy, 0 for falsey.