-2

There are prices set for certain time periods... I'm having trouble coming up with an algorithm to determine the lowest price for a specific time period.

I'm doing this with a list of objects, where the object has properties DateTime StartDate, DateTime EndDate, decimal Price.

For example, two price sets and their active date ranges:

A. 09/26/16 - 12/31/17 at $20.00
B. 12/01/16 - 12/31/16 at $18.00

You can see that B is inside the A time period and is lower.

I need that converted to this:

A. 09/26/16 - 11/30/16 at $20.00
B. 12/01/16 - 12/31/16 at $18.00
C. 01/01/17 - 12/31/17 at $20.00

It has to work for any number of date ranges and combinations. Has anyone come across anything I can manipulate to get the result I need? Or any suggestions?

Edit: My data structure:

public class PromoResult
{
    public int ItemId { get; set; }
    public decimal PromoPrice { get; set; }
    public DateTime StartDate { get; set; }
    public DateTime EndDate { get; set; }
    public int PromoType { get; set; } // can ignore this...
}
justiceorjustus
  • 2,017
  • 1
  • 19
  • 42
  • Stack Overflow is more focused on helping solve issues in implementations you have come up with rather than providing new implementations. – Kelson Ball Dec 22 '16 at 19:25
  • Oh, lordy, lordy... I have struggled with this problem mightily. I sort of have a solution, but I'm not sure you're going to like it. – Chris Berger Dec 22 '16 at 19:28
  • @Chris Berger I'll take it. I've been dealing with merging date ranges and fixing them all day, this is the last part I need to the puzzle... and it hasn't been pretty so far. – justiceorjustus Dec 22 '16 at 19:29
  • So, I'm going through my code trying to find my solutions and I realize that my implementation is entirely in SQL, not in C#... Edit: and also, it's super complicated for very complex cases, and additionally specifically works for one date at a time... – Chris Berger Dec 22 '16 at 19:30
  • SQL might be better. I'm grabbing a distinct ``ItemId, StartDate, EndDate, Price, PriceType`` and manipulating it in C#. It may be better in SQL, if possible... but I've already been down a complicated road to get to my question here. Basically, I had to combine date ranges if they overlap or touch and are at the same price. I may be able to convert it to Linq. – justiceorjustus Dec 22 '16 at 19:36
  • So, what I would do is take my SQL that works on a specific date, cross apply it with a list of dates, and then merge the dates into a range. I will try to simplify my SQL and post it below. – Chris Berger Dec 22 '16 at 19:38

5 Answers5

3

This is a great case for using Linq. Assuming your price range object is called PriceRecord...

You will need to create a list of all dates and then filter down to price records that are between two consecutive dates. An implementation might look something like this:

    public static IEnumerable<PriceRecord> ReduceOverlaps(IEnumerable<PriceRecord> source)
    {
        // Get a list of all edges of date ranges
        // edit, added OrderBy (!)
        var edges = source.SelectMany(record => new[] { record.StartDate, record.EndDate }).OrderBy(d => d).ToArray();
        // iterate over pairs of edges (i and i-1)
        for (int i = 1; i < edges.Length; i++)
        {
            // select min price for range i-1, i
            var price = source.Where(r => r.StartDate <= edges[i - 1] && r.EndDate >= edges[i]).Select(r => r.Price).Min();
            // return a new record from i-1, i with price
            yield return new PriceRecord() { StartDate = edges[i - 1], EndDate = edges[i], Price = price };
        }
    }

I haven't tested this and you may need to tinker with the comparison operators, but it may be a good starting point. I have now tested the code, the example here works with the data in the question.

Feel free to propose edits to improve this example.

Kelson Ball
  • 948
  • 1
  • 13
  • 30
  • I think I like this solution. Has definite potential. – Chris Berger Dec 22 '16 at 19:52
  • This does look promising... giving it a shot but have to make some changes first. I'll let you know. – justiceorjustus Dec 22 '16 at 20:10
  • I've been playing around with it using my example and it is slightly different from the result I'm looking for in the original post. There needs to be a 1 day gap between StartDates and EndDates. This is returning ``A: 9/26/2016 - 12/1/2016, B: 12/1/2016 - 12/31/2016, C: 12/31/2016-12/31/2017`` where A and C aren't correct. Not sure if I should create another method to fix it or if there is something that can be changed in this one... I messed with it quite a bit. – justiceorjustus Dec 22 '16 at 20:49
  • You could used `EndDate = edges[i].AddDays(-1)` to push the last day of the range back one. – Kelson Ball Dec 22 '16 at 21:09
  • I did try that and it cuts a day off of each EndDate when really only the inner ranges need the day cut off. Returned: ``A: 9/26/2016 - 11/30/2016, B: 12/1/2016 - 12/30/2016, C: 12/31/2016-12/30/2017`` – justiceorjustus Dec 22 '16 at 21:19
  • You are dealing with a case of the fence post problem then. http://stackoverflow.com/questions/6857502/elegant-solutions-to-the-fencepost-problem-with-strings – Kelson Ball Dec 22 '16 at 21:53
1

Doesn't directly answer your question, but here is some SQL that I used to solve a similar problem I had (simplified down a bit, as I was also dealing with multiple locations and different price types):

SELECT RI.ItemNmbr, RI.UnitPrice, RI.CasePrice
    , RP.ProgramID
    , Row_Number() OVER (PARTITION BY RI.ItemNmbr,
                         ORDER BY CASE WHEN RI.UnitPrice > 0 
                                       THEN RI.UnitPrice
                                       ELSE 1000000 END ASC
                                  , CASE WHEN RI.CasePrice > 0
                                         THEN RI.CasePrice
                                         ELSE 1000000 END ASC
                                  , RP.EndDate DESC
                                  , RP.BeginDate ASC
                                  , RP.ProgramID ASC) AS RowNumBtl
    , Row_Number() OVER (PARTITION BY RI.UnitPrice, 
                         ORDER BY CASE WHEN RI.CasePrice > 0 
                                       THEN RI.CasePrice
                                       ELSE 1000000 END ASC
                                  , CASE WHEN RI.UnitPrice > 0
                                         THEN RI.UnitPrice
                                         ELSE 1000000 END ASC
                                  , RP.EndDate DESC
                                  , RP.BeginDate ASC
                                  , RP.ProgramID ASC) AS RowNumCase
  FROM RetailPriceProgramItem AS RI
    INNER JOIN RetailPriceMaster AS RP
        ON RP.ProgramType = RI.ProgramType AND RP.ProgramID = RI.ProgramID
  WHERE RP.ProgramType='S'
        AND RP.BeginDate <= @date AND RP.EndDate >= @date
                    AND RI.Active=1

I select from that where RowNumBtl=1 for the UnitPrice and RowNumCase=1 for the CasePrice. If you then create a table of dates (which you can do using a CTE), you can cross apply on each date. This is a bit inefficient, since you only need to test at border conditions between date ranges, so... good luck with that.

Chris Berger
  • 557
  • 4
  • 14
1

I will use 2 functions DateRange and GroupSequenceWhile

List<PromoResult> promoResult = new List<PromoResult>()
{
    new PromoResult() {  PromoPrice=20, StartDate = new DateTime(2016, 9, 26),EndDate=new DateTime(2017, 12, 31)},
    new PromoResult() {  PromoPrice=18, StartDate = new DateTime(2016, 12, 1),EndDate=new DateTime(2016, 12, 31)}
};

var result = promoResult.SelectMany(x => DateRange(x.StartDate, x.EndDate, TimeSpan.FromDays(1))
                                         .Select(y => new { promo = x, date = y }))
            .GroupBy(x => x.date).Select(x => x.OrderBy(y => y.promo.PromoPrice).First())
            .OrderBy(x=>x.date)
            .ToList();

var final = result.GroupSequenceWhile((x, y) => x.promo.PromoPrice == y.promo.PromoPrice)
            .Select(g => new { start = g.First().date, end = g.Last().date, price = g.First().promo.PromoPrice })
            .ToList();

foreach (var r in final)
{
    Console.WriteLine(r.price + "$ " + r.start.ToString("MM/dd/yy", CultureInfo.InvariantCulture) + " " + r.end.ToString("MM/dd/yy", CultureInfo.InvariantCulture));
}

OUTPUT:

20$ 09/26/16 11/30/16
18$ 12/01/16 12/31/16
20$ 01/01/17 12/31/17

Algorithm:

1- create a <day,price> tuple for each item in promoResult list

2- group this tuples by day and select min price

3- order this tuples by date

4- select the starting and ending day when there is a change in price in consecutive days


IEnumerable<DateTime> DateRange(DateTime start, DateTime end, TimeSpan period)
{
    for (var dt = start; dt <= end; dt = dt.Add(period))
    {
        yield return dt;
    }
}

public static IEnumerable<IEnumerable<T>> GroupSequenceWhile<T>(this IEnumerable<T> seq, Func<T, T, bool> condition) 
{
    List<T> list = new List<T>();
    using (var en = seq.GetEnumerator())
    {
        if (en.MoveNext())
        {
            var prev = en.Current;
            list.Add(en.Current);

            while (en.MoveNext())
            {
                if (condition(prev, en.Current))
                {
                    list.Add(en.Current);
                }
                else
                {
                    yield return list;
                    list = new List<T>();
                    list.Add(en.Current);
                }
                prev = en.Current;
            }

            if (list.Any())
                yield return list;
        }
    }
}
L.B
  • 114,136
  • 19
  • 178
  • 224
  • I'm modifying this and I really think it is way above my understanding. Does this work for infinite number of date ranges / prices or only two? – justiceorjustus Dec 22 '16 at 21:21
  • @justiceorjustus just concat the input ranges. see the `range1.Concat(range2....)` So there is no limit... – L.B Dec 22 '16 at 21:36
  • I still don't understand. How can I do it with a List instead of individual variables? Or a list of my own date ranges / prices? – justiceorjustus Dec 22 '16 at 21:42
  • @justiceorjustus this is all I can do. if you post your data structure showing how you represent date ranges and the related price I can give an answer that may be more clear...(I don't see any code in your post, just a request) – L.B Dec 22 '16 at 21:49
  • I really appreciate your help. I edited to include my data structure. I actually have 3 types of PromoType's per ItemId, but that property can be ignored. I have been running it for each type of PromoType individually so it doesn't get confusing. – justiceorjustus Dec 22 '16 at 21:55
  • @justiceorjustus I updated the answer according to your definition *PromoResult*.. and also tested it... (sorry for lot of linq) – L.B Dec 22 '16 at 22:16
  • Trust me, my attempts had a lot more linq than that. I appreciate you taking the time to help me with this. I can't play with it more tonight, but tomorrow I'll work on implementing and understanding it. Now that you've modified it to what I have, it makes some more sense to me now. :D Thanks. – justiceorjustus Dec 22 '16 at 23:33
  • Let us [continue this discussion in chat](http://chat.stackoverflow.com/rooms/131358/discussion-between-justiceorjustus-and-l-b). – justiceorjustus Dec 23 '16 at 14:40
0

I would start with the ranges in date order based on starting date, add the first entry as a range in its entirety so:

09/26/16 - 12/31/17 at $20.00
TBD:
12/01/16 - 12/31/16 at $18.00

Next grab the next range you have, if it overlaps with the previous one, split the overlap (there are few kinds of overlaps, make sure to handle them all) taking the minimum value for the overlapped region:

09/26/16 - 11/30/16 at $20.00
12/01/16 - 12/31/16 at $18.00
TBD:
01/01/17 - 12/31/17 at $20.00

Note that you don't have the last one yet as you would take any splits that occur after and put them back into your sorted list of "yet to be compared" items.

Guvante
  • 18,775
  • 1
  • 33
  • 64
-3

Try this

lets say we have:

public class DatePrice
{
    public DateTime StartDate { get; set; }
    public DateTime EndDate { get; set; }
    public decimal Price { get; set; }
}

and

IList<DatePrice> list = new List<DatePrice>(); // populate your data from the source..
var lowestPriceItem = list.OrderBy(item => item.Price).First();

should give you the lowest price item.

Yawar Murtaza
  • 3,655
  • 5
  • 34
  • 40