Perl Weekly Challenge: Week 183
Challenge 1:
Unique Array
You are given list of arrayrefs.
Write a script to remove the duplicate arrayrefs from the given list.
Example 1
Input: @list = ([1,2], [3,4], [5,6], [1,2])
Output: ([1,2], [3,4], [5,6])
Example 2
Input: @list = ([9,1], [3,7], [2,5], [2,5])
Output: ([9, 1], [3,7], [2,5])
The guts of my solution in Raku is a function called printUnique()
shown below.
sub printUnique(@list) {
say
q{(},
@list.unique(with => { $^a eqv $^b})
.map({ q{[} ~ $_.join(q{,}) ~ q{]} })
.join(q{, }),
q{)};
}
Most of it deals with pretty printing the output into the form shown in the examples but the key part is this line:
@list.unique(with => { $^a eqv $^b})
The .unique()
method does all the heavy lifting for us. If our array elements were simple data types
we could use it as is but as we are dealing with array refs, we have to provide a custom comparator via the
with
parameter that uses the eqv
operator to determine if two array refs are equal.
As is so often the case, Perl doesn't have the unique()
function built in so we either have to use a CPAN module or write our own. I chose the latter. The standard way of deduplicating an array (as seen in, for example, this Perl Maven article) is to assign its elements to
the keys of a hash. Two equivalent arrays will have the same key so by the time you are finished, the keys
of the hash will be the unique elements of the array.
sub unique {
my %seen;
return grep { !$seen{join q{}, @{$_}}++ } @_;
}
One change I had to make things easier was to use join()
to convert the array ref into a string and then compare strings for uniqueness. The result is nevertheless the same.
Challenge 2:
Date Difference
You are given two dates, $date1
and $date2
in the format YYYY-MM-DD
.
Write a script to find the difference between the given dates in terms on years
and days
only.
Example 1
Input: $date1 = '2019-02-10'
$date2 = '2022-11-01'
Output: 3 years 264 days
Example 2
Input: $date1 = '2020-09-15'
$date2 = '2022-03-29'
Output: 1 year 195 days
Example 3
Input: $date1 = '2019-12-31'
$date2 = '2020-01-01'
Output: 1 day
Example 4
Input: $date1 = '2019-12-01'
$date2 = '2019-12-31'
Output: 30 days
Example 5
Input: $date1 = '2019-12-31'
$date2 = '2020-12-31'
Output: 1 year
Example 6
Input: $date1 = '2019-12-31'
$date2 = '2021-12-31'
Output: 2 years
Example 7
Input: $date1 = '2020-09-15'
$date2 = '2021-09-16'
Output: 1 year 1 day
Example 8
Input: $date1 = '2019-09-15'
$date2 = '2021-09-16'
Output: 2 years 1 day
This is one of those date problems whose solution seems deceptively simple but can be difficult get completely correct. Happily, Perls DateTime
module gives you a complete toolkit for solving this type of thing with a minimum of fuss.
The first thing we have to do is convert the input which arrives from command line arguments in the form YYYY-MM-DD
into DateTime
objects. The parseDate()
function does that.
sub parseDate {
my ($date) = @_;
my ($year, $month, $day) = split /-/, $date;
return DateTime->new(
year => $year,
month => $month,
day => $day
);
}
my $dt1 = parseDate($date1);
my $dt2 = parseDate($date2);
DateTime
overloads the -
operator to give the difference between two objects in the form of
a DateTime::Duration
object. The years()
method of DateTime::Duration
provides us with the first
piece of information we wanted, the difference between the two dates in years.
my $years = ($dt1 - $dt2)->years;
days requires a little more work.
my $days;
The first thing we need to do is adjust the dates so they are both in the same year.
if ($dt1->year > $dt2->year) {
$dt1 = $dt1->subtract(years => $years);
} else {
$dt2 = $dt2->subtract(years => $years);
}
Now we can use DateTime
s delta_days()
method to determine how many days difference there
is between the two dates.
$days = $dt2->delta_days($dt1)->in_units('days');
It irks me to no end when programs don't display output correctly so yet get answers like
0 years 1 days
instead of
1 day
The next block of code formats the output so it is properly pluralized and only the values that exist are displayed.
my @output;
if ($years) {
push @output, ( $years, 'year' . ($years == 1 ? q{} : 's') );
}
if ($days) {
push @output, ( $days, 'day' . ($days == 1 ? q{} : 's') );
}
say join q{ }, @output;
Surprisingly, Raku is less versatile than Perl when it comes to dates and times so I had to jump through some extra hoops to get the proper results.
parseDate()
atleast is pretty much the same as in Perl except it returns Date
objects.
sub parseDate (Str $date) {
my ($year, $month, $day) = $date.split(q{-});
return Date.new($year, $month, $day);
}
my $dt1 = parseDate($date1);
my $dt2 = parseDate($date2);
To simplify some of the subsequent calculations, I found it expedient to ensure the first date is always earlier than the second date.
if $dt1.year > $dt2.year {
($dt1, $dt2) = ($dt2, $dt1);
}
Subtracting one Date
from another doesn't give you a duration object (Raku has a Duration
class
but it does something else altogether.) Instead you get a count in days. For some reason I don't recall
now, I didn't trust it to give me the right number of days so instead I subtracted the dates daycount
which
is a count of number of days since the epoch. I took the absolute value of this subtraction to avoid negative
results. As this can only happend if $dt2
is earlier than $dt1
and I've already taken steps to ensure that
won't happen, this is redundant I guess.
my $days = ($dt2.daycount - $dt1.daycount).abs;
Here's how I should have written that line:
my $days = $dt2 - $dt1;
Initially, we set the difference in years as 0.
my $years = 0;
The reason is that we are not completely finished with the calculation of difference in days. In my calculation
of years I was assuming a year has 365 days but that is not always true. In a leap year there are 366 days. So
we account for this by seeing how many leap years there are between $dt1
and $dt2
and adding 1 day for each one.
my $leapDays = ($dt1.year .. $dt2.year).grep({ Date.new(year => $_).is-leap-year}).elems;
But wait there's more! If $dt1
was a leap year but after February 29, we should not add a day. The same if
$dt2
is a leap year and comes before February 29.
if $dt1.is-leap-year && $dt1 > Date.new($dt1.year, 2, 29) {
$leapDays--;
}
if $dt2.is-leap-year && $dt2 < Date.new($dt2.year, 2, 29) {
$leapDays--;
}
Finally, we subtract the correct amount of leap days from $days
.
$days -= $leapDays;
It would be so nice if Raku took care of this kind of stuff for you. After submitting my solutions, I looked at other peoples, and atleast one challenger has got this wrong and I don't blame him. It's very easy to make a mistake.
If the number of days difference is greater than 365, the number of years difference is greater than 0 and we find
the exact number by dividing $days
by 365. (div
if you don't know, does integer division so we don't need to
worry about fractional years.) The number of days difference has to be adjusted too.
if $days >= 365 {
$years = $days div 365;
$days %= 365;
}
The code to display output works the same as in Perl.
my @output;
if $years {
@output.push( $years, 'year' ~ ($years == 1 ?? q{} !! 's') );
}
if $days {
@output.push( $days, 'day' ~ ($days == 1 ?? q{} !! 's') );
}
@output.join(q{ }).say;