ETOOBUSY 🚀 minimal blogging for the impatient
PWC089 - Magical Matrix
TL;DR
On with TASK #2 from the Perl Weekly Challenge #089. Enjoy!
The challenge
Write a script to display matrix as below with numbers
1
-9
. Please make sure numbers are used once.[ a b c ] [ d e f ] [ g h i ]
So that it satisfies the following:
a + b + c = 15 d + e + f = 15 g + h + i = 15 a + d + g = 15 b + e + h = 15 c + f + i = 15 a + e + i = 15 c + e + g = 15
The questions
Well… I guess that at last I nagged manwar so much with my silly questions that the challenge is spotless. (Actually, I love the incompleteness of the challenges, they stimulate the thought process).
But now that I think of it… here’s one! Why not call it a Magic square?!?
What a douche I am!
The solution
This challenge has many possible solutions.
The super-lazy one is to just look for the solution somewhere, like Magic square in Wikipedia. From a homework point of view it’s a horrible solution, I know; but from a work perspective I think that reuse is an excellent skill. So there you have it:
#!/usr/bin/env perl
use 5.024;
use warnings;
print {*STDOUT} <<'END';
[ 2 7 6 ]
[ 9 5 1 ]
[ 4 3 8 ]
END
On the other side of the spectrum, that very page on the Magic square
provides a lot of interesting hints on how to build these squares for any
side size, provided it’s different from 2
. So, I guess, this is how this
challenge should be addressed: study these solutions, learn something, find
the most adherent one to the problem and use it.
Alas, the border of this blog is too narrow to do this 🙄
So… I’ll take the middle way, the one that does not really challenge my comfort zone, enjoys a bit of reuse but still it’s general enough to adapt to other situations, like different square sizes.
Yes, I’m talking about constraint programming. We already saw this topic in this blog (e.g. More Constraint Programming) and it seems like the manual case for it: find some values according to some constraints.
The engine for this solution is where I go for reuse: ConstraintSolver.pm will do the job. So… I’m only left with providing a few things to the solver.
The main function
sub magical_matrix ($N) {
my $N2 = $N * $N;
my $solution = solve_by_constraints(
start => {
not_allocated => { map {$_ => 1} 1 .. $N2 },
field => [ (0) x $N2 ],
},
is_done => sub ($state) { keys($state->{not_allocated}->%*) == 0 },
constraints => [
(map {_constraint($N, $_ * $N, 1)} 0 .. ($N - 1)), # rows
(map {_constraint($N, $_, $N)} 0 .. ($N - 1)), # cols
_constraint($N, 0, $N + 1), # main diag
_constraint($N, $N - 1, $N - 1), # other diag
],
search_factory => \&_search_factory,
) or die "cannot find a solution for N = $N\n";
my $field = $solution->{field};
return [map {[splice $field->@*, 0, $N]} 1 .. $N];
}
There are a lot of ways to track the state and iterate through the possibilities; I’m not sure I chose the most efficient one.
There are two data structures in start
(which is the starting state):
not_allocated
keeps track of the integers between 1
and $N * $N
that
were not allocated yet, and field
tracks their position (0
means that a
position in the field has not been allocated yet).
The field
array is easy to treat like a matrix, and in this case even more
so because iterating to find rows, columns and diagonals is only a matter of
coming up with the right distancing between elements in the array.
Figuring out if we’re done in is_done
is easy at this point: just check if
not_allocated
still has something inside.
The constraints are quite basic and I think that more might be added, to
provide more pruning. Anyway, here I put only the strictly necessary ones,
checking that the sum in the right subsets is fine. Each check will
basically be the same, so I use a helper factory function _constraint
to
generate the target constraint sub, passing the size, the starting position
for the subset, and the distance between elements in the subset. As we will
see, this is all we need to check a row (distance between elements is 1
),
a column (distance between elements is $N
, or any of the two diagonals
(distance is $N + 1
or $N - 1
depending on the diagonal).
Last, the search factory to generate new guesses when all constraints are OK but there’s still no solution is handed over to a helper factory function that we will look at shortly.
The constraint
As anticipated, each constraint is basically a check on a specific subset of
elements inside the field array, each comprised of $N
elements.
As an example, the first row starts at index 0
and takes $N
consecutive
items, i.e. the distance between two close items is 1
. Similarly, the
second column will start at index 1
and items will be distanced by $N
inside the field
array.
For this reason, the factory function needs to know:
- how big the side of the [Magical square][] is, i.e.
$N
; - the start index
$start
; - the distance between adjacent indexes
$delta
.
Here is this factory function:
1 sub _constraint ($N, $start, $delta) {
2 my $N2 = $N * $N;
3 my $target_sum = ($N2 + 1) * $N / 2;
4 return sub ($state) {
5
6 my ($field, $not_allocated) = $state->@{qw< field not_allocated >};
7 my $available = $target_sum;
8 my @missing_indexes;
9 my $j = 0;
10 while ($j < $N) {
11 my $i = $start + $delta * $j++;
12 if (my $v = $field->[$i]) { $available -= $v }
13 else { push @missing_indexes, $i }
14 }
15 die "wrong sum, too much" if $available < 0;
16 my $n_missing = scalar @missing_indexes;
17
18 if ($n_missing == 0) { # every value is fixed here, check the sum
19 die 'wrong sum' if $available;
20
21 return 0; # check OK, no change
22 }
23
24 if ($n_missing == 1) { # fix the one that's left
25 die "invalid residual value"
26 unless exists $not_allocated->{$available};
27 delete $not_allocated->{$available};
28 $field->[$missing_indexes[0]] = $available;
29 return 1; # yes, we did one change
30 }
31
32 return 0; # no change happened
33 }
34 }
Variable $target_sum
(line 3) is the sum we want in each row, column, and
diagonal. It’s the sum of all numbers in the [Magical square], divided by
the number of rows (or columns, of course), i.e.:
Variable $available
(line 7) keeps track of how much sum is left in the
specific subset. When all locations have been assigned, this must be 0
,
i.e. all of the $target_sum
has been allocated. Variable
@missing_indexes
(line 8) tracks which positions in the subset have not
been allocated.
The first loop (lines 10 through 14) scans the subset and:
- removes the value from
$available
if it has been assigned (line 12) - records the missing index otherwise (line 13).
When done, there’s a first sanity check: if the sum is too big then we have to backtrack (line 15).
At this point we might have that:
- all positions have been allocated (line 18): here we just have to check
that the sum is correct, i.e. that
$available
has dropped down to0
(line 19); - only one position is left empty (line 24): in this case we know that the
only possible value for this position is
$available
, because otherwise the sum will not be right. Hence, we check that this is indeed a value that we still have to allocate (line 25 and 26), then remove from the pool of unassigned values (line 27), fix in the field (line 28) and return1
to mark that we did some pruning (so that the outer loop will know that the constraints have to be run again because of the change).
Guessing factory
The last piece of code is the search factory to guess values for positions when we have squeezed everything from the constraints.
1 sub _search_factory ($state) {
2 my %not_allocated = $state->{not_allocated}->%*;
3 my @candidates = keys %not_allocated;
4 my $current = undef;
5
6 my @field = $state->{field}->@*;
7 my $pos = undef;
8 for my $i (0 .. $#field) {
9 next if $field[$i];
10 $pos = $i;
11 last;
12 }
13 die 'no unassigned position (WTF?!?)' unless defined $pos;
14
15
16
17 return sub ($state) {
18 return 0 unless @candidates;
19
20 $not_allocated{$current} = 1 if defined $current;
21 $current = shift @candidates;
22 delete $not_allocated{$current};
23
24 $field[$pos] = $current;
25 $state->{field} = [@field];
26 $state->{not_allocated} = { %not_allocated };
27
28
29 return 1;
30 };
31 }
The logic is the following:
- we keep a list of
@candidates
, i.e. values that have not been allocated yet (line 2 to 4) - we select an empty position in the field (lines 6 through 13)
- in this specific search, we will iterate all the possible values for the
@candidates
inside that specific empty position.
The last bullet is implemented by the returned sub (line 17 through 30),
that takes care to adjust the input $state
to set the right values.
A little improvement
This implementation has a lot of space for improvement. For example, there might be smarter constraints that yield more pruning. Or a better way to select the empty spot for the search function; or a better way to iterate through the candidates.
One thing that was apparent, though, is that the check for good subsets was repeated over and over, even when successful. Hence, we can do better.
In the state, we track an additional hash keeping track of subsets that are
fine (aptly named fine
):
start => {
not_allocated => { map {$_ => 1} 1 .. $N2 },
field => [ (0) x $N2 ],
fine => {},
},
In the search factory, we make sure that a copy of this hash is available in the sub-searches, but is not propagated during backtracks (otherwise it would mess things up).
Last, in the constraints, we will use it to cut a constraint if it is already successful:
1 sub _constraint ($N, $start, $delta) {
2 my $N2 = $N * $N;
3 my $target_sum = ($N2 + 1) * $N / 2;
4 return sub ($state) {
5 return 0 if $state->{fine}{"$start-$delta"};
6 my ($field, $not_allocated) = $state->@{qw< field not_allocated >};
7 my $available = $target_sum;
8 my @missing_indexes;
9 my $j = 0;
10 while ($j < $N) {
11 my $i = $start + $delta * $j++;
12 if (my $v = $field->[$i]) { $available -= $v }
13 else { push @missing_indexes, $i }
14 }
15 die "wrong sum, too much" if $available < 0;
16 my $n_missing = scalar @missing_indexes;
17
18 if ($n_missing == 0) { # every value is fixed here, check the sum
19 die 'wrong sum' if $available;
20 $state->{fine}{"$start-$delta"} = 1;
21 return 0; # check OK, no change
22 }
23
24 if ($n_missing == 1) { # fix the one that's left
25 die "invalid residual value"
26 unless exists $not_allocated->{$available};
27 delete $not_allocated->{$available};
28 $field->[$missing_indexes[0]] = $available;
29 return 1; # yes, we did one change
30 }
31
32 return 0; # no change happened
33 }
34 }
Line 5 exits immediately if the specific constraint is already fine, while line 20 sets the fine flag for the specific start/delta combination if the test is successful and all items have been allocated.
The whole thing, at the very last!
Here is the whole code, if you’re interested:
#!/usr/bin/env perl
use 5.024;
use warnings;
use experimental qw< postderef signatures >;
no warnings qw< experimental::postderef experimental::signatures >;
use Storable 'dclone';
my $M = magical_matrix(shift || 3);
say {*STDOUT} '[ ', (map { sprintf '%3d', $_ } $_->@*), ' ]' for $M->@*;
sub magical_matrix ($N) {
my $N2 = $N * $N;
my $solution = solve_by_constraints(
start => {
not_allocated => { map {$_ => 1} 1 .. $N2 },
field => [ (0) x $N2 ],
fine => {},
},
is_done => sub ($state) { keys($state->{not_allocated}->%*) == 0 },
constraints => [
(map {_constraint($N, $_ * $N, 1)} 0 .. ($N - 1)), # rows
(map {_constraint($N, $_, $N)} 0 .. ($N - 1)), # cols
_constraint($N, 0, $N + 1), # main diag
_constraint($N, $N - 1, $N - 1), # other diag
],
search_factory => \&_search_factory,
) or die "cannot find a solution for N = $N\n";
my $field = $solution->{field};
return [map {[splice $field->@*, 0, $N]} 1 .. $N];
}
sub _search_factory ($state) {
my %not_allocated = $state->{not_allocated}->%*;
my @candidates = keys %not_allocated;
my $current = undef;
my @field = $state->{field}->@*;
my $pos = undef;
for my $i (0 .. $#field) {
next if $field[$i];
$pos = $i;
last;
}
die 'no unassigned position (WTF?!?)' unless defined $pos;
my %fine = $state->{fine}->%*;
return sub ($state) {
return 0 unless @candidates;
$not_allocated{$current} = 1 if defined $current;
$current = shift @candidates;
delete $not_allocated{$current};
$field[$pos] = $current;
$state->{field} = [@field];
$state->{not_allocated} = { %not_allocated };
$state->{fine} = { %fine };
return 1;
};
}
sub _constraint ($N, $start, $delta) {
my $N2 = $N * $N;
my $target_sum = ($N2 + 1) * $N / 2;
return sub ($state) {
return 0 if $state->{fine}{"$start-$delta"};
my ($field, $not_allocated) = $state->@{qw< field not_allocated >};
my $available = $target_sum;
my @missing_indexes;
my $j = 0;
while ($j < $N) {
my $i = $start + $delta * $j++;
if (my $v = $field->[$i]) { $available -= $v }
else { push @missing_indexes, $i }
}
die "wrong sum, too much" if $available < 0;
my $n_missing = scalar @missing_indexes;
if ($n_missing == 0) { # every value is fixed here, check the sum
die 'wrong sum' if $available;
$state->{fine}{"$start-$delta"} = 1;
return 0; # check OK, no change
}
if ($n_missing == 1) { # fix the one that's left
die "invalid residual value"
unless exists $not_allocated->{$available};
delete $not_allocated->{$available};
$field->[$missing_indexes[0]] = $available;
return 1; # yes, we did one change
}
return 0; # no change happened
}
}
sub solve_by_constraints {
my %args = (@_ && ref($_[0])) ? %{$_[0]} : @_;
my @reqs = qw< constraints is_done search_factory start >;
exists($args{$_}) || die "missing parameter '$_'" for @reqs;
my ($constraints, $done, $factory, $state, @stack) = @args{@reqs};
my $logger = $args{logger} // undef;
while ('necessary') {
last if eval { # eval - constraints might complain loudly...
$logger->(validating => $state) if $logger;
my $changed = -1;
while ($changed != 0) {
$changed = 0;
$changed += $_->($state) for @$constraints;
$logger->(pruned => $state) if $logger;
} ## end while ($changed != 0)
$done->($state) || (push(@stack, $factory->($state)) && undef);
};
$logger->(backtrack => $state, $@) if $logger;
while (@stack) {
last if $stack[-1]->($state);
pop @stack;
}
return unless @stack;
} ## end while ('necessary')
return $state;
} ## end sub solve_by_constraints
Good by and… stay safe!