Advertisement
rfv123

q40505794 - Generate optimal list of non-overlapping dates

Nov 15th, 2016
425
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
PHP 16.92 KB | None | 0 0
  1. <?php // http://stackoverflow.com/questions/40505794/how-to-push-the-next-date-time-to-array-which-fits-around-current-date-times-in
  2.  
  3. // see Dynamic programming: http://stackoverflow.com/questions/3243234/algorithm-to-find-the-maximum-sum-in-a-sequence-of-overlapping-intervals
  4.  
  5. // include __DIR__ .'/__bootstrap__.php'; // all my stuff :)
  6.  
  7. /*
  8.  * I tend to:
  9.  *   1) break things down to small functions so it looks like a lot of code.
  10.  *
  11.  *   2) use intermediate variables to store partial results so I can easily use them
  12.  *      in later conditions. Is just easier to read when developing the code
  13.  *
  14.  *   3) Sort lists / Arrays so I can reason about them easier.
  15.  *      It also tends to reduce the number of passes but you have the cost of the sort.
  16.  *
  17.  *   4) If there are more than a couple of 'helper' functions then I will use a 'class'
  18.  *      It is just easier to reason about and saves having to pass data structures about.
  19.  *
  20.  *   5) I will refactor the code later to remove all the redundancy. ;-/
  21.  *      First, get something that works and is easy to understand?  
  22.  */
  23.  
  24. /*
  25.  *  Requirements:
  26.  *  
  27.  *    Input:  
  28.  *        1) given a list of 'chosen' dates
  29.  *        2) A list of 'candidate' dates
  30.  *        
  31.  *    Output:
  32.  *      1) A 'final list of 'none-overlapping' dates
  33.  *
  34.  *         Where:
  35.  *           a) All the chosen dates are in the final list.  !important
  36.  *    
  37.  *           b) NO Date ranges can Overlap.                  !important
  38.  *
  39.  *       !!  c) The 'Maximum amount of time' between the chosen dates
  40.  *              MUST be utilised.                            !important
  41.  *
  42.  *              i.e. if there is a gap of 15 minutes between chosen dates and
  43.  *                   1) There is a date range of 10 numisted that will fit
  44.  *                    
  45.  *                   OR
  46.  *                
  47.  *                   2) Two data ranges that total 15 minutes that fit then
  48.  *                      we must find the two date ranges    
  49.  *                                        
  50.  *
  51.  *           d) The first (least start time) 'chosen' data is a 'start' date
  52.  *              i.e. All candidate dates must be on or after this date.
  53.  *  
  54.  *           e) The last chosen date (max end date) is the 'end date'
  55.  *
  56.  *           f) There can be any number of none-ovelapping chosen dates between the
  57.  *              chosen start and chosen end.
  58.  *            
  59.  *  Nice to have: run in some reasonable amount of time... more on this later...        
  60.  */  
  61.  
  62. /* ---------------------------------------------------------------------------------
  63.  * Class that does all the work...
  64.  */
  65.  
  66. /*
  67.  *  Requirements:
  68.  *  
  69.  *    Input:  
  70.  *        1) given a list of 'chosen' dates
  71.  *        2) A list of 'candidate' dates
  72.  *        
  73.  *    Output:
  74.  *      1) A 'final list of 'none-overlapping' dates
  75.  *
  76.  *         Where:
  77.  *           a) The first 'chosen' data is a 'start' date
  78.  *              i.e. All candidate dates must be on or after this date.
  79.  *
  80.  *           b) No date ranges must ovevlap.  
  81.  */  
  82.  
  83. class ScheduleList
  84. {
  85.     /**
  86.     * A list of all the dates that:
  87.     *   1) After the 'chosen' start date
  88.     *   2) Do not overlap with any 'chosen' date
  89.     *
  90.     * @var array $candidates
  91.     */
  92.     private $candidates = array();    
  93.  
  94.     /**
  95.     * Any date record we didn't use.
  96.     *
  97.     * @var array $unused
  98.     */
  99.     private $unused = array();
  100.        
  101.     /**
  102.     * List of dates that must be included in the 'final' list.
  103.     * The earliest date is assumed to be a start date and everything must be later.
  104.     *
  105.     * @var array $chosen
  106.     */    
  107.     private $chosen = array();
  108.  
  109.     /**
  110.     * List of dates ranges that candidate dates must be between
  111.     *
  112.     * @var array $ranges
  113.     */    
  114.     private $ranges = array();
  115.  
  116.     /**
  117.     * Ordered list of `none overlapping' dates from the chosen and candidates
  118.     *
  119.     * @var array $final
  120.     */    
  121.     private $final = array();
  122.    
  123.    
  124.     /**
  125.     * Current List of paths with associated score
  126.     *
  127.     * The top one is the current best one
  128.     */
  129.     private $currentPath = array();
  130.    
  131.     /**
  132.     * These are the date lists.
  133.     * They will be converted, sorted and filters as required.
  134.     *
  135.     * @param array $chosenDates
  136.     * @param array $candidateDates
  137.     * @return void
  138.     */
  139.     public function __construct(array $chosenDates = array(),
  140.                                 array $candidateDates = array(),
  141.                                 array $ranges = array())
  142.     {
  143.         if (!empty($ranges)) {
  144.             $this->setRanges($ranges);
  145.         }
  146.  
  147.         if (!empty($chosenDates)) {
  148.             $this->setChosen($chosenDates);
  149.         }
  150.        
  151.         if (!empty($candidateDates)) {
  152.             $this->setCandidates($candidateDates);
  153.         }
  154.     }
  155.  
  156.     /**
  157.     * Add the candidates to the final list
  158.     *
  159.     *   Known conditions:
  160.     *     o  Both lists are in start date order
  161.     *     o  No candidates overlap with any chosen date
  162.     *     o  The candidates may overlap with each other - Hmm... need to check...
  163.     *
  164.     *  Note: The '$this->isOverlapsAny' method - as it is used a lot will be expensive (O(n^2))
  165.     *        I can think of ways to reduce that - will happen when it is  refactored ;-/
  166.     *
  167.     * @return array
  168.     */
  169.     public function generateList()
  170.     {
  171.         $bestList = array();
  172.         foreach ($this->ranges as $range) {
  173.  
  174.             $availableList = $this->getAllDatesWithinRange($range);
  175.    
  176.             $whenList = $this->bestFitDates($range,
  177.                         $availableList['chosen'],
  178.                         $availableList['candidates']);            
  179.                      
  180.             $bestList = array_merge($bestList, $whenList);                
  181.         }
  182.            
  183.  
  184.         // sort final by start times - makes it easy to reason about
  185.         usort($bestList,
  186.               function ($when1, $when2) {
  187.                     return $when1['startTime'] - $when2['startTime'];
  188.               });
  189.              
  190.         $this->final = $bestList;
  191.              
  192.         return $bestList;      
  193.     }
  194.  
  195.  
  196.     /**
  197.     * Build and walk a 'decision tree'. This will be 'interesting' ;-/
  198.     *
  199.     * @return array list of dates that will fit in the range
  200.     */
  201.     public function bestFitDates(array $range,
  202.                                  array $chosen,
  203.                                  array $candidates)
  204.     {
  205.           $rangeDuration = $range['duration'];
  206.          
  207.           $totalChosenDuration = 0;
  208.           foreach ($chosen as $when) {
  209.               $totalChosenDuration += $when['duration'];
  210.           }
  211.        
  212.           $this->currentPath = array();
  213.          
  214.           $bestPath = array('path' => $chosen, 'score' => $totalChosenDuration);
  215.           array_push($this->currentPath, $bestPath);
  216.          
  217.           $this->maxDuration($chosen,
  218.                              $candidates,
  219.                              count($candidates) - 1,
  220.                              $rangeDuration - $totalChosenDuration,
  221.                              $totalChosenDuration);
  222.                                          
  223.           return array_pop($this->currentPath)['path'];                                        
  224.     }
  225.  
  226.    
  227.     public function maxDuration($currentWhenList,
  228.                                 $candidates,
  229.                                 $candidateIdx,
  230.                                 $availableDuration,
  231.                                 $totalDuration,
  232.                                 $whichPath = '')
  233.     {              
  234.         // check whether current path has higher score than current
  235.         $topPath = end($this->currentPath);
  236.         if ($totalDuration > $topPath['score']) { // save it
  237.  
  238.           $bestPath = array('path' => $currentWhenList, 'score' => $totalDuration);
  239.           array_push($this->currentPath, $bestPath);
  240.         }
  241.        
  242.         if ($candidateIdx < 0) { // no more candidates
  243.             return true;
  244.         }            
  245.        
  246.         /*
  247.          * Unused path
  248.          */
  249.         $this->maxDuration($currentWhenList,
  250.                            $candidates,
  251.                            $candidateIdx - 1,
  252.                            $availableDuration,
  253.                            $totalDuration,
  254.                            'unused');
  255.        
  256.          
  257.         /*
  258.          * Used path
  259.          */
  260.  
  261.         if ($this->isOverlapAny($candidates[$candidateIdx], $currentWhenList)) {
  262.             return false;
  263.         }
  264.  
  265.         // calculate all the new stuff as we are going to use the candidate
  266.         $newAvailableDuration = $availableDuration - $candidates[$candidateIdx]['duration'];
  267.         $newTotalDuration   = $totalDuration + $candidates[$candidateIdx]['duration'];
  268.  
  269.         if ($newAvailableDuration > 0) {
  270.             $key = $candidates[$candidateIdx]['key'];
  271.             $currentWhenList[$key] = $candidates[$candidateIdx]; // add to list
  272.            
  273.             $this->maxDuration($currentWhenList,
  274.                                $candidates,
  275.                                $candidateIdx - 1,
  276.                                $availableDuration,
  277.                                $newTotalDuration,
  278.                                'used ');
  279.                        
  280.             unset($currentWhenList[$key]); // remove it from the list!                                
  281.                                            
  282.         } else {
  283.             $usedPath = $currentWhenList;
  284.         }
  285.        
  286.         return true;
  287.     }
  288.  
  289.     /**
  290.     * Convert ranges to date range and sort them
  291.     *
  292.     * @param array $chosenDates
  293.     */
  294.     public function setRanges(array $dates)
  295.     {
  296.         $this->ranges = $this->convertDateList($dates);
  297.     }
  298.  
  299.     /**
  300.     * Convert chosen dates to date range and sort them
  301.     *
  302.     * @param array $chosenDates
  303.     */
  304.     public function setChosen(array $dates)
  305.     {
  306.         $this->chosen = $this->convertDateList($dates, true);
  307.     }
  308.  
  309.     /**
  310.     * setter for candidates - will convert to date range
  311.     *
  312.     * @param array $candidateDates
  313.     *
  314.     * @return void;
  315.     */
  316.     public function setCandidates(array $dates)
  317.     {
  318.         $dateList = $this->convertDateList($dates);
  319.        
  320.         // filter the candidates
  321.         $this->filterCandidates($dateList);        
  322.     }
  323.  
  324.  
  325.    /*
  326.     * Convert each date to a dateRange that is easier to check and display
  327.     *
  328.     * o Check each candidate date for ovelapping with any of the 'chosen dates'
  329.     * o Check if before first chosen start data.
  330.     */
  331.     protected function filterCandidates(array $candidates)
  332.     {
  333.         $startTime = $this->ranges[0]['startTime'];
  334.         foreach ($candidates as $candidate) {    
  335.            
  336.             // overlaps with any chosen then ignore it
  337.             if ($this->isOverlapAny($candidate, $this->chosen)) { // ignore it
  338.                 $candidate['fail'] = 'overlap chosen';
  339.                 $this->unused[] = $candidate;  // record failed ones so easy to check
  340.                 continue;    
  341.             }
  342.  
  343.             // ignore if before start time
  344.             if ($candidate['startTime'] <=  $startTime) {
  345.                 $candidate['fail'] = 'before start';
  346.                 $this->unused[] = $candidate;   // record failed ones so easy to check
  347.                 continue;    
  348.             }
  349.  
  350.             if (!$this->insideRangeAny($candidate, $this->ranges)) { // ignore it
  351.                 $candidate['fail'] = 'overlap range';
  352.                 $this->unused[] = $candidate;  // record failed ones so easy to check
  353.                 continue;    
  354.             }
  355.          
  356.    
  357.             $this->candidates[] = $candidate;
  358.         }
  359.     }        
  360.  
  361.  
  362.     public function getAllDatesWithinRange(array $range)
  363.     {
  364.         $whenList = array('chosen' => array(), 'candidates' => array());
  365.        
  366.         foreach ($this->chosen as $when) {
  367.             if ($this->insideRange($when, $range)) {
  368.                 $whenList['chosen'][] = $when;
  369.             }
  370.         }    
  371.  
  372.         foreach ($this->candidates as $when) {
  373.             if ($this->insideRange($when, $range)) {
  374.                 $whenList['candidates'][] = $when;
  375.             }
  376.         }
  377.        
  378.         return $whenList;  
  379.     }
  380.  
  381.  
  382.     /**
  383.      * if start is after other end OR end is before other start then they don't overlap
  384.      *
  385.      * Easiest way is to check that they don't overlap and reverse the result
  386.      */
  387.     public function isOverlap($when1, $when2)
  388.     {
  389.         return ! (    $when1['endTime'] <= $when2['startTime']
  390.                    || $when1['startTime'] >= $when2['endTime']);
  391.     }
  392.    
  393.     /**
  394.     * Check if candidate overlaps any of the dates in the list
  395.     *
  396.     * @param array $candidate
  397.     * @param array $whenList  -- list of non-overlapping dates
  398.     *
  399.     * @return boolean  true if overlaps
  400.     */
  401.     function isOverlapAny(array $candidate, array $whenList)
  402.     {
  403.         foreach ($whenList as $when) {
  404.             if ($this->isOverlap($when, $candidate)) { // ignore it
  405.                return true;
  406.             }
  407.         }
  408.         return false;
  409.     }  
  410.  
  411.     /**
  412.      *  
  413.      */
  414.     public function insideRange(array $when, array $range)
  415.     {
  416.         return   (    $when['startTime'] >= $range['startTime']
  417.                    && $when['endTime'] <= $range['endTime']);
  418.     }
  419.    
  420.     public function insideRangeAny(array $when, array $ranges)
  421.     {
  422.         foreach ($ranges as $range) {
  423.             if ($this->insideRange($when, $range)) {
  424.                return true;
  425.             }
  426.         }
  427.         return false;
  428.     }
  429.  
  430.     public function convertDateList(array $dates, $isChosen = false)
  431.     {
  432.         $list = array();
  433.        
  434.         // convert and sort
  435.         foreach ($dates as $when) {    
  436.             $dtRange = $this->makeDateRange($when, $isChosen);
  437.             $list[] = $dtRange;
  438.         }
  439.  
  440.         if (count($list) > 1) { // sort them so we can easily compare against them
  441.             usort($list,
  442.                   function ($when1, $when2) {
  443.                        return $when1['startTime'] - $when2['startTime'];
  444.                   });  
  445.         }
  446.        
  447.         return $list;
  448.     }
  449.        
  450.     /**
  451.      * Convert to UNIX timestamp as seconds will make the calculations easier
  452.      *
  453.      * The result has:
  454.      *   1) the time as a date object - I can do calculations / format it whatever
  455.      *   2) startTime as epoch seconds
  456.      *   3) endTime as epoch seconds
  457.      *
  458.      * @param array $when
  459.      *
  460.      * @return array  
  461.      */
  462.     public function makeDateRange(array $when, $isChosen = false)
  463.     {
  464.         $dt = \DateTime::createFromFormat('Y-m-d H:i:s', $when['date']);
  465.         $result = array();
  466.         $result['when']   = $dt;
  467.         $result['duration'] = (int) $when['duration'];
  468.         $result['startTime']  = (int) $dt->format('U');
  469.         $result['endTime']  = (int) $result['startTime'] + $when['duration'];
  470.         $result['key'] = $result['startTime'] .'|'. $result['endTime'];
  471.         $result['isChosen'] = $isChosen;
  472.        
  473.         return $result;            
  474.     }
  475.  
  476.     public function displayWhenList(array $whenList, $title = 'whenList', $sort = true)
  477.     {
  478.         if ($sort) {
  479.             usort($whenList,
  480.                   function ($when1, $when2) {
  481.                     return $when1['startTime'] - $when2['startTime'];
  482.                   });
  483.         }        
  484.        
  485.         echo PHP_EOL, PHP_EOL, $title;
  486.        
  487.         foreach ($whenList as $when) {
  488.             $this->displayWhen($when);
  489.         }
  490.         echo '<pre>';
  491.     }
  492.    
  493.     /**
  494.     * Show a date formatted for debugging purposes
  495.     *
  496.     * @param array $when
  497.     * @return void
  498.     */
  499.     public function displayWhen(array $when)
  500.     {
  501.         echo PHP_EOL, 'date: ',   $when['when']->format('Y-m-d H:i:s'),
  502.                       ' len: ',   sprintf('%4d', $when['duration']),
  503.                       ' end: ',   date('Y-m-d H:i:s', $when['endTime']),
  504.                       ' start: ',  $when['startTime'],
  505.                       ' end: ',    $when['endTime'],
  506.                       ' chosen? ', ($when['isChosen'] ? 'true' : 'false'),
  507.                      
  508.                       (!empty($when['fail'])
  509.                          ? ' fail: ' . $when['fail'] : '');
  510.     }
  511.  
  512.  
  513.     /*
  514.      *  `Getters` so you can see what happened
  515.      */
  516.     public function getChosen()     { return $this->chosen; }
  517.     public function getRanges()     { return $this->ranges; }
  518.     public function getUnused()     { return $this->unused; }
  519.     public function getCandidates() { return $this->candidates; }
  520.     public function getFinal()      { return $this->final; }
  521.    
  522.     /**
  523.     * properties - for those of us that like them
  524.     */
  525.     public function __get($name)
  526.     {
  527.         if (property_exists($this, $name)) {
  528.             return $this->$name;
  529.         }        
  530.         return null;
  531.     }
  532. }
Advertisement
Add Comment
Please, Sign In to add comment
Advertisement