Solutions and Discussion for Perl Quiz of the Week #8

Mark Jason Dominus <[email protected]> Fri, 20 Dec 2002 00:30:30 -0500
Newsgroups gmane.comp.lang.perl.qotw.quiz-of-the-week
Organization Plover Systems
Message-ID <[email protected]>
Sample solutions and discussion
Perl Quiz of The Week #8 (20021211)

        Bill Gosper, a famous programmer, once said that a good way to
        manufacture word puzzles was to look through the dictionary
        for a word that contains a sequence of four letters that does
        not appear in any other word.  Then the puzzle is to guess the
        word, given only the four letters.

        For example, what common English word contains the contiguous
        sequence of the four letters 'acur'?  (Gosper says that you
        see this word every week, but that it will take you a month to
        figure out what it is.)

        Write a Perl program which, given a dictionary, generates two
        output files, 'questions' and 'answers'.  'questions' should
        contain every sequence of four letters that appears in exactly
        one word of the dictionary, one sequence per line.  'answers'
        should contain the corresponding words that contain the
        sequences, in the same order, again one per line.

        For example, given the trivial dictionary containing only

                arrows
                carrots
                give
                me

        The outputs should be:

                'questions'             'answers'

                carr                    carrots
                give                    give
                rots                    carrots
                rows                    arrows
                rrot                    carrots
                rrow                    arrows

        Of course, 'arro' does not appear in the output, since it is
        found in more than one word.



Here's a sample program, provided by Jonathan Scott Duff.  I trimmed
it a little.

        # Well, I see bunches of other people posting their solutions to the
        # regular quiz, so here's mine:
        # 
        #!/usr/bin/perl

        $SEG_LENGTH = 4;

        while (<>) {
           chomp;
           next if /\W/;		
           $w = lc $_;
           %w = map { substr($w,$_,$SEG_LENGTH) => 1 } 
                    0..length($w)-$SEG_LENGTH;

           for $w (keys %w) { 
              $wordmap{$w} = exists $wordmap{$w} ? undef : $_; 
           }
        }

        open(Q,">questions") or die;
        open(A,">answers") or die;
        for (sort keys %wordmap) {
           next unless defined $wordmap{$_};
           print Q "$_\n";
           print A "$wordmap{$_}\n"
        }
        close Q; close A;



The main data structure in the program is the hash %wordmap.  Keys in
%wordmap are strings of length 4.  The value associated with a key $k
is the word in which $k appears, if $k appears in only one word, and
an undefined value if $k appears in more than one word.

The program first converts each input word to all lowercase, and then
uses 'map' to construct a hash, %w, whose keys are the length-4
segments of the word.  For example, if the word is 'phlebotomy', the
hash is

        ('phle' => 1,
         'hleb' => 1,
         'lebo' => 1,
         'ebot' => 1,
         'boto' => 1,
         'otom' => 1,
         'tomy' => 1,
        )

The 1's aren't significant'; they're just placeholders.  Using a hash
in this way is a common Perl idiom for representing a set of strings.
The program then loops over the keys, looking up each one in %wordmap.
If a key was already in wordmap, then this is at least the second time
it has been seen, so the program sets the associated value to 'undef',
to indicate that it has appeared more than once.  If the key isn't in
%wordmap yet, then it's inserted into %wordmap, and the associated
value is the single word in which it has appeared.

After generating %wordmap, the program writes out the questions and
answers files, sipping over any elements of %wordmap whose values are
undefined.  

*. Since the values in the %w hash are never used or examined at all,
   it might seem that we could dispense with them, replacing

           %w = map { substr($w,$_,$SEG_LENGTH) => 1 } ... ; 
                    

           for $w (keys %w) { 
              ...
           }
   with

           @w = map { substr($w,$_,$SEG_LENGTH) } ... ;

           for $w (@w) { 
              ...
           }

   This was a common error in the submitted programs.  The problem it
   causes occurs with words like 'alfalfa' and 'lightweight' which
   contain the same sequence of four letters more than once.  

   The second version of the code sets @w to

        ('alfa', 'lfal', 'falf', 'alfa')

   and then iterates over this list, processing 'alfa' twice.  It then
   erroneously marks 'alfa' in %wordmap as appearing in two words when
   in fact it has appeared twice in only one word.  To avoid this, we
   must be sure to process each sequence of four letters at most once
   per word.  Storing the sequences as keys in the %w hash ensures
   this, because hash keys are unique.  The %w generated for 'alfalfa'
   is

        ('alfa' => 1, 'lfal' => 1, 'falf' => 1)

   and so iterating over the keys processes 'alfa' only once.

1. A way to fix the problem without introducing another hash appears
   in Ronald Kimball's program.  Ronald's program solves the problem
   more directly:

   for $w (keys %w) { 
      $wordmap{$w} = exists $wordmap{$w} && $_ ne $wordmap{$w} ? undef : $_; 
   }

   The second and subsequent times that the program sees a particular
   sequence, it throws away the stored word only if it's different
   from the current word.

   As written above, the program generates a huge number of
   'uninitialized value' warnings because the 'undef' values stored in
   the hash to indicate a sequence that has been seen two or more
   times are compared with $_.  Ronald's program uses 1 instead of
   undef, so doesn't generate any warnings. Another way to slience the
   code above is to shut off warnings.p

2. Mr. Duff's program, as submitted, actually finds unique sequences
   of 'n' letters, where 'n' defaults to 4, the number specified in
   the question.  If it's run as

        duff.pl -n 2 < dictionary

   it finds digraphs (pairs of letters) that occur in only one word
   each.  To make the code simpler, I trimmed this out and replaced
   the $opt{'n'} parameter with $SEG_LENGTH.

3.  As a trivium, here's the output for n=2:

        bg bw dz fc fj fp fw gj hq hv iy jr lj qa sj 
        vk vs vz wz xb xn xq xs xv xx yj yq zd zg zm zp

   Of these, 13 make good puzzles:

        bg bw fc fp fw gj hq lj sj wz xq xs yj

   The rest are either proper nouns (both English or otherwise)

        hv iy qa vs xb xn xv xx wq zd zg zm zp

   or are of visibly foreign origin ('resident aliens'):

        dz fj vk vz

   or are abbreviations:

        jr

   I think my favorite one is probably 'hq'.


4. People sometimes suggest that Perl's '..' operator should construct
   a backwards-counting range if the second operand is smaller than
   the first.  For example, they say that 4..0 should produce the list
   (4, 3, 2, 1, 0).  At present, it produces the empty list.

   This program demonstrates one of the many reasons why this is a bad
   idea.  Consider this part of the code:

           %w = map { substr($w,$_,$SEG_LENGTH) => 1 } 
                    0..length($w)-$SEG_LENGTH;

   Suppose $SEG_LENGTH is 4 and $w is "cat".  The operands of the '..'
   are 0..-1. With the existing semantics for '..', the '..' generates
   an empty list for 'map' it iterate over, the hash %w becomes empty,
   and the word is effectively skipped--just the right thing.

   With the defective alternative behavior, the '..' would generate
   the list (0, -1), and the 'map' generates the (bizarre) list 
   ('cat', 't').  To get correct behavior, the code would have to be
   adjusted with a special case to check for length($w) < $SEG_LENGTH.

   A similar example concerns this construction:

        @rest = @a[2..$#a];

   Here the intent is to copy the third-through-last elements of @a.
   For example, if @a contains 10 elements, $#a is 9, and @rest gets
   elements 2 through 9.  If @a contains only one element, the '2..1'
   expands to an empty list, and @rest is assigned nothing---which is
   just what was wanted.

   With the alternative semantics, the '2..$#a' expands to (2, 1), and
   @rest is assigned two undefined values.  Again, a special case is
   necessary to guard against precisely the behavior of .. that was
   proposed.  

   (If you do want to count backwards, use something like

        reverse(1..$n) .)

5. As usual, many people submitted programs that did not adhere to the
   interface I asked for  in the question, making various gratuitous
   changes to the input semantics, the output file names, the output
   format, or whatever.  Unlike in the past, I decided not to repair
   these.  

   The changes that puzzled me most were the ones that replaced the
   two output files ('questions' and 'answers') with a single output
   file.  I agree that this is simpler and more natural.  Usually I
   would have specified a single file with two columns.  But in this
   case that format is no good, because when you try to pick out a
   puzzle, you see the answer right next to it, which spoils the fun.

6. One common variation, particularly among the shorter programs, was
   to use a tricky regex to generate the substrings, instead of the
   loop shown above.  For example

        while (/(?=(.{4}))/g) {
          ...
        }

   was a popular trick.  (This iterates the 'while' loop once for each
   four-letter sequence, with $1 set to each sequence in turn.)  

7. When I first tested the programs, I got a surprise.  Everyone's
   programs ran very quickly, except mine, which was by far the
   slowest of the bunch.  I wondered what elementary mistake I must be
   making.  Unfortunately, it turned out to be an error in the test
   apparatus, not a programming mistake in my program. (I had been
   looking forward to discussing it.)  I had forgotten to trim the
   email headers out of the other programs, so mine was the only one
   that wasn't aborting immediately with multiple syntax errors.


Once again, my thanks to everyone who participated.  I will send out a
new quiz on yesterday.