Search code examples
c#listalgorithmloopsflatten

flattening a list of contracts based on priority


I have a list of contracts that has a timespan to be finished in. Some contracts are more important than others. If there is an overlap between two contracts we cut down the less important one in a way that will either cut in two, cut it from the start or the end or fully remove it.

My problem is: I've implemented the algorithm but I still have some minor errors which cause a false output.
e.g.

Input:
Start     Ende        Preis  Priorität
1.1.2020  1.3.2020    20,40  1
1.2.2020  31.12.2020  18,00  2
1.6.2020  1.9.2020    16,90  3

Output should be:
Start     Ende        Preis
1.1.2020  31.1.2020   20,40
1.2.2020  31.5.2020   18,00
1.6.2020  1.9.2020    16,90
2.9.2020  31.12.2020  18,00

My Code:

List<Contract> contractList = //get value based on a file and get sorted based on the priority;
List<Contract> resultContracts = new List<Contract>()
{
    contractList[0]
};
int n = contractList.Count;
for (var i = 0; i < contractList.Count; i++)
{

    var lastProcessedContract = contractList[i]; // high priority
    var contractsToRemove = new List<Contract>();
    var contractsToAdd = new List<Contract>();
    for (var j = i + 1;  j < contractList.Count; j++){

        var lessPriorityContract = contractList[j];
        if (contractList[j].priority == contractList[i].priority)
        {
            continue;
        }
        if (lessPriorityContract.start >= lastProcessedContract.start && lessPriorityContract.end <= lastProcessedContract.end)
        /*
         *  ------------------------>
         *           ------>
         * oder
         *  ------------------------>
         *  -------->
         * oder
         *  ------------------------->
         *                    ------->
         * oder
         * --------------------------->
         * --------------------------->
         */
        {
            contractsToRemove.Add(lessPriorityContract);
        }
        else if ((lessPriorityContract.start < lastProcessedContract.start 
                 && (lessPriorityContract.end > lastProcessedContract.start && lessPriorityContract.end <= lastProcessedContract.end)) || 
                 ((lastProcessedContract.start < lessPriorityContract.end && lastProcessedContract.start > lessPriorityContract.start)
                     && lastProcessedContract.end > lessPriorityContract.end))
       /* 
          *     ------------------------>
          *  ------>
          * oder
          *     ------------------------------->
          * ----------------------------------->

        // oder

          *                   -------------->
          *  ------------------------>
        */

        {
            lessPriorityContract.end = lastProcessedContract.start.AddDays(-1);
            resultContracts.Add(lessPriorityContract);
        }
        else if (((lessPriorityContract.start < lastProcessedContract.end && lessPriorityContract.start >= lastProcessedContract.start)
                 && lessPriorityContract.end > lastProcessedContract.end) || (lastProcessedContract.start < lessPriorityContract.start &&
                                                                              (lastProcessedContract.end > lessPriorityContract.start && lastProcessedContract.end < lessPriorityContract.end)))
        /*
         * -------------------->
         *            ------------------------->
         * oder
         * -------------------->
         * ---------------------------------->
         *

        //oder

          *  ------------>
          *       ------------------->
          * 
         */ 
        {
            lessPriorityContract.start = lastProcessedContract.end.AddDays(1);
            resultContracts.Add(lessPriorityContract);
        }
        else if(lessPriorityContract.start < lastProcessedContract.start
                && lessPriorityContract.end > lastProcessedContract.end)
            /*
             *       ------------>
             * --------------------------->
             * oder
             * --------------------------->
             * --------------------------->
             * 
             */
        {
            contractsToRemove.Add(lessPriorityContract);
            var contract = new Contract
            {
                start = lessPriorityContract.start,
                end = lastProcessedContract.start.AddDays(-1),
                priority = lessPriorityContract.priority,
                price = lessPriorityContract.price
            };
            contractsToAdd.Add(contract);

            //contractList.Remove(contractList[i]);
            //contractList.Add(contract);
            resultContracts.Add(contract);
            contract = new Contract
            {
                start = lastProcessedContract.end.AddDays(1),
                end = lessPriorityContract.end,
                priority = lessPriorityContract.priority,
                price = lessPriorityContract.price
            };
            resultContracts.Add(contract);
            contractsToAdd.Add(contract);

            //contractList.Add(contract);
        }
        else
        {
            resultContracts.Add(lessPriorityContract);
        }
    }
    foreach (var contractToRemove in contractsToRemove)
    {
        contractList.Remove(contractToRemove);
        resultContracts.Remove(contractToRemove);
    }

    foreach (var contractToAdd in contractsToAdd)
    {
        contractList.Add(contractToAdd);
    }
    contractList.Sort();
}

resultContracts.Sort(new ContractComparer()); // Sorting based on start time of the contract
resultContracts = resultContracts.Distinct().ToList();
foreach (var flatElement in resultContracts)
{
    Console.WriteLine(flatElement.ToString());
    Console.WriteLine();
}

resultContracts.Sort();
Console.WriteLine("-------------------------------");
foreach (var flatElement in resultContracts)
{
    Console.WriteLine(flatElement.ToString());
    Console.WriteLine();
}

The Class Contract has attributes(start, end, price, priority) and an overridden Method from Icomparable to help sort based on priority.

I am open to get new efficient and faster algorithms too, otherwise repairing this would be also helpful.

edit: small notice is I am not concerned right now on how many attributes I am outputting as long as I solve the algorithm I can take care of that :)


Solution

  • I recommend taking a step back and generalizing first. At the core your problem is about "cutting" a date range based on another date range. So let's solve that first:

    public record DateRange(DateTime From, DateTime To) {
        public bool Overlaps(DateRange other) => From <= other.To && other.From <= To;
    
        public IEnumerable<DateRange> Cut(DateRange other) {
            if (!Overlaps(other)) {
                yield return this;
            } else if (From >= other.From && To > other.To) {
                yield return this with { From = other.To.AddDays(1) };
            } else if (From < other.From && To <= other.To) {
                yield return this with { To = other.From.AddDays(-1) };
            } else if (From < other.From && To > other.To) {
                yield return this with { To = other.From.AddDays(-1) };
                yield return this with { From = other.To.AddDays(1) };
            }
        }
    }
    

    Now we have a type for date ranges and an operation on them called "cut" which can shorten, split, or completely "remove" a date range based on its overlap with another date range. Or it will leave the date range as is when there is no overlap. I'll add the test class to the end of this answer as a proof of work.

    Because we're working with not only two, but possibly many more date ranges from contracts, we can add another method to DateRange in order to help us:

    public IEnumerable<DateRange> CutAll(IEnumerable<DateRange> others) => others
        .Aggregate(
            Enumerable.Repeat(this, 1),
            (cuts, other) => cuts.SelectMany(s => s.Cut(other)));
    

    This will split a date range into lots of "sub date ranges" if it overlaps with multiple others.

    Your business code now boils down to:

    • Sort contracts by priority (ascending).
    • Iterate over contracts:
      • Cut the current contract's date range with all the date ranges of its successors in the list.
      • Use the date range cut results to create "sub contracts" from the current contract.

    Here's an example how this might look like in (untested) code:

    var contracts = new List<Contract> {
        new(Period: new(From: new(2020, 01, 01), To: new(2020, 03, 01)), Price: 20.40m, Priority: 1),
        new(Period: new(From: new(2020, 02, 01), To: new(2020, 12, 31)), Price: 18.00m, Priority: 2),
        new(Period: new(From: new(2020, 06, 01), To: new(2020, 09, 01)), Price: 16.90m, Priority: 3)
    };
    
    for (var i = 0; i < contracts.Count; i++) {
        var current = contracts[i];
        var successors = contracts[(i + 1)..];
        var subDateRanges = current.Period.CutAll(successors.Select(c => c.Period));
    
        foreach (var dr in subDateRanges) {
            Console.WriteLine($"{dr.From} {dr.To} {current.Price} {current.Priority}");
        }
    }
    
    public record Contract(DateRange Period, decimal Price, int Priority);
    

    namespace Tests;
    
    using FluentAssertions;
    using Xunit;
    
    public static class DateRangeTests {
        public class Overlaps {
            [Fact]
            public void ReturnsFalseWhenRangesDontOverlap1() =>
                Range("2019-01-01", "2019-01-15")
                .Overlaps(Range("2019-01-20", "2019-01-31"))
                .Should().BeFalse();
    
            [Fact]
            public void ReturnsFalseWhenRangesDontOverlap2() =>
                Range("2019-01-20", "2019-01-31")
                .Overlaps(Range("2019-01-01", "2019-01-15"))
                .Should().BeFalse();
    
            [Fact]
            public void ReturnsTrueWhenRangesOverlap1() =>
                Range("2019-01-01", "2019-01-20")
                .Overlaps(Range("2019-01-15", "2019-01-31"))
                .Should().BeTrue();
    
            [Fact]
            public void ReturnsTrueWhenRangesOverlap2() =>
                Range("2019-01-15", "2019-01-31")
                .Overlaps(Range("2019-01-01", "2019-01-20"))
                .Should().BeTrue();
    
            [Fact]
            public void ReturnsTrueWhenRangesOverlap3() =>
                Range("2019-01-01", "2019-01-31")
                .Overlaps(Range("2019-01-10", "2019-01-20"))
                .Should().BeTrue();
    
            [Fact]
            public void ReturnsTrueWhenRangesOverlap4() =>
                Range("2019-01-10", "2019-01-20")
                .Overlaps(Range("2019-01-01", "2019-01-31"))
                .Should().BeTrue();
        }
    
        public class Cut {
            [Fact]
            public void ReturnsLeftSideWhenRightSideDoesNotOverlap() =>
                Range("2019-01-01", "2019-01-10")
                .Cut(Range("2019-01-20", "2019-01-31"))
                .Should().ContainSingle().Which.Should().Be(Range("2019-01-01", "2019-01-10"));
    
            [Fact]
            public void AbsorbsLeftSideWhenRightSideOverlapsCompletely() =>
                Range("2019-01-10", "2019-01-20")
                .Cut(Range("2019-01-01", "2019-01-31"))
                .Should().BeEmpty();
    
            [Fact]
            public void MovesStartOfLeftSideWhenRightSideOverlapsBeginning() =>
                Range("2019-01-10", "2019-01-20")
                .Cut(Range("2019-01-01", "2019-01-15"))
                .Should().ContainSingle().Which.Should().Be(Range("2019-01-16", "2019-01-20"));
    
            [Fact]
            public void MovesEndOfLeftSideWhenRightSideOverlapsEnd() =>
                Range("2019-01-10", "2019-01-20")
                .Cut(Range("2019-01-15", "2019-01-25"))
                .Should().ContainSingle().Which.Should().Be(Range("2019-01-10", "2019-01-14"));
    
            [Fact]
            public void SplitsLeftSideWhenRightSideIsInside() =>
                Range("2019-01-01", "2019-01-31")
                .Cut(Range("2019-01-15", "2019-01-25"))
                .Should().SatisfyRespectively(
                    dr1 => dr1.Should().Be(Range("2019-01-01", "2019-01-14")),
                    dr2 => dr2.Should().Be(Range("2019-01-26", "2019-01-31")));
        }
    
        public class CutAll {
            [Fact]
            public void CutsLeftSideByAllFromRightSide() =>
                Range("2019-02-01", "2019-02-28")
                .CutAll([Range("2019-01-01", "2019-02-03"), Range("2019-02-10", "2019-02-15"), Range("2019-02-25", "2019-03-31")])
                .Should().SatisfyRespectively(
                    dr1 => dr1.Should().Be(Range("2019-02-04", "2019-02-09")),
                    dr2 => dr2.Should().Be(Range("2019-02-16", "2019-02-24")));
    
            [Fact]
            public void ReturnsLeftSideWhenRightSideIsEmpty() =>
                Range("2019-02-01", "2019-02-28")
                .CutAll([])
                .Should().ContainSingle().Which.Should().Be(Range("2019-02-01", "2019-02-28"));
        }
    
        private static DateRange Range(string from, string to) => new(DateTime.Parse(from), DateTime.Parse(to));
    }