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{)};
}

(Full code on Github.)

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{}, @{$_}}++ } @_;
}

(Full code on Github.)

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 DateTimes 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;

(Full code on Github.)

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;

(Full code on Github.)