views:

155

answers:

3

Given a list of ranges ie: 1-3,5,6-4,31,9,19,10,25-20 how can i reduce it to 1-6,9-10,19-25,31 ?

Here is what i've done so far, it seems a little bit complicated, so is there any simpler/clever method to do this.

$in = '1-3,5,6-4,31,9,19,10,25-20';
// Explode the list in ranges
$rs = explode(',', $in);
$tmp = array();
// for each range of the list
foreach($rs as $r) {
    // find the start and end date of the range
    if (preg_match('/(\d+)-(\d+)/', $r, $m)) {
        $start = $m[1];
        $end = $m[2];
    } else {
        // If only one date
        $start = $end = $r;
    }
    // flag each date in an array
    foreach(range($start,$end) as $i) {
        $tmp[$i] = 1;
    }
}
$str = '';
$prev = 999;
// for each date of a month (1-31)
for($i=1; $i<32; $i++) {
    // is this date flaged ?
    if (isset($tmp[$i])) {
        // is output string empty ?
        if ($str == '') {
            $str = $i;
        } else {
            // if the previous date is less than the current minus 1
            if ($i-1 > $prev) {
                // build the new range
                $str .= '-'.$prev.','.$i;
            }
        }
        $prev = $i;
    }
}
// build the last range
if ($i-1 > $prev) {
    $str .= '-'.$prev;
}
echo "str=$str\n";

NB: it must run under php 5.1.6 (i can't upgrade).

FYI : the numbers represent days of month so they are limited to 1-31.

Edit:

From a given range of dates (1-3,6,7-8), i'd like obtain another list (1-3,6-8) where all the ranges are recalculated and ordered.

A: 

You just have to search your data to get what you want. Split the input on the delimiter, in your case ','. Then sort it somehow, this safes you searching left from the current position. Take you first element, check whether it's a range and use the highest number in this range (3 out of 1-3 range or 3 if 3 is a single element) for further comparisions. Then take the 2nd element in your list and check if it's a direct successor of the last element. If yes combine the 1st and 2nd elements/range to a new range. Repeat.

Edit: I'm not sure about PHP but a regular expression is a bit overkill for this problem. Just look for a '-' in your exploded array, then you know it's a range. Sorting the exp. array safes you the backtracking, the stuff you are doing with $prev. You could also explode every element in the exploded array on '-' and check if the resulting array has a size > 1 to learn whether an element is a range or not.

DaClown
Regular expressions is DEFINITELY overkill. From his problem definition we know that the numbers will all be between 1 and 31, which means we're dealing with a finite range. Therefore, algorithmically you'll get the least complexity just by drawing a list of possible values and determining which to include. For instance: an array of 31 booleans.
steven_desu
Yes, I overread the 1-31 range in the question. In this case you are right. But since it's a string comparision you don't safe anything using a bool list, the logic to check the boolean 1,2 and 3 in a bool array from a 1-3 range is just as complex as any other operation that directly creates a new list comparing strings.
DaClown
Well actually where the bool array comes in handy is in building the new list of ranges, as you just have to count from 1-31 and toggle an "included in range" bit. Then it's just a matter of saying `if($include[$i] == TRUE)` 31 times. The reason I didn't end up using an array of booleans in my final answer, however, was that the complexity to create an array of booleans from a list of ranges exceeded the saved complexity later on.
steven_desu
I thought I wrote just that ... anyway bit list are always a good way to handle thing if the range of data is very limited. In this case it probably has no advantage.
DaClown
+1  A: 

Perhaps not the most efficient, but shouldn't be too bad with the limited range of values you're working with:

$in = '1-3,5,6-4,31,9,19,10,25-20';

$inSets = explode(',',$in);
$outSets = array();
foreach($inSets as $inSet) {
    list($start,$end) = explode('-',$inSet.'-'.$inSet);
    $outSets = array_merge($outSets,range($start,$end));
}
$outSets = array_unique($outSets);
sort($outSets);

$newSets = array();
$start = $outSets[0];
$end = -1;
foreach($outSets as $outSet) {
    if ($outSet == $end+1) {
        $end = $outSet;
    } else {
        if ($start == $end) {
            $newSets[] = $start;
        } elseif($end > 0) {
            $newSets[] = $start.'-'.$end;
        }
        $start = $end = $outSet;
    }
}
if ($start == $end) {
    $newSets[] = $start;
} else {
    $newSets[] = $start.'-'.$end;
}
var_dump($newSets);
echo '<br />';
Mark Baker
Thanks a lot, it seems to work great. There was a bug in mine where I can have range like `8-8`
M42
@M42 The `if($start == $end)` near the end of his code solves your bug. I actually spent an hour explaining the derivation of an ideal divide and conquer solution and when I was finished I glanced at Mark's solution and it was **almost the exact same**. You can check out my answer if you want. It's a bit more optimized when forming the list of new ranges.
steven_desu
A: 

Looking at the problem from an algorithmic stand-point, let's consider the limitations that you've put on the problem. All numbers will be from 1-31. The list is a collection of "ranges", each of which is defined by two numbers (start and end). There is no rule for whether start will be more, less than, or equal to end.

Since we have an arbitrarily large list of ranges but a definite means of sorting/organizing these, a divide and conquer strategy may yield the best complexity.

At first I typed out a very long and careful explanation of how I created each step in this algorithm (the dividing portion, the conquering potion, optimizations, etc.) however the explanation got extremely long. To shorten it, here's the final answer:

<?php
   $ranges = "1-3,5,6-4,31,9,19,10,25-20";
   $range_array = explode(',', $ranges);
   $include = array();
   foreach($range_array as $range){
       list($start, $end) = explode('-', $range.'-'.$range); //"1-3-1-3" or "5-5"
       $include = array_merge($include, range($start, $end));
   }
   $include = array_unique($include);
   sort($include);
   $new_ranges = array();
   $start = $include[0];
   $count = $start;
   // And begin the simple conquer algorithm
   for( $i = 1; $i < count($include); $i++ ){
       if( $include[$i] != ($count++) ){
           if($start == $count-1){
               $new_ranges[] = $start;
           } else {
               $new_ranges[] = $start."-".$count-1;
           }
           $start = $include[$i];
           $count = $start;
       }
   }
   $new_ranges = implode(',', $new_ranges);
?>

This should (theoretically) work on arrays of arbitrary length for any positive integers. Negative integers would get tripped up since - is our delimiter for the range.

steven_desu
@steven_desu: thanks for the help, but your solution doesn't work as expected: it doesn't take into account the last day (ie : 31) and doesn't produce a new list of ranges but a simple list.
M42
Does it fail? I admit I didn't test the algorithm, but it looked good to me. Now I want to put it on my own testing server and see what's wrong.
steven_desu