0

I have a situation where I need to evenly distribute N items across M slots. Each item has its own distribution %. For discussion purposes say there are three items (a,b,c) with respective percentages of (50,25,25) to be distributed evenly across 20 slots. Hence 10 X a,5 X b & 5 X c need to be distributed. The outcome would be as follows:

 1. a
 2. a
 3. c
 4. b
 5. a
 6. a
 7. c
 8. b
 9. a
 10. a
 11. c
 12. b
 13. a
 14. a
 15. c
 16. b
 17. a
 18. a
 19. c
 20. b

The part that I am struggling with is that the number of slots, number of items and percentages can all vary, of course the percentage would always total up to 100%. The code that I wrote resulted in following output, which is always back weighted in favour of item with highest percentage. Any ideas would be great.

 1. a
 2. b
 3. c
 4. a
 5. b
 6. c
 7. a
 8. b
 9. c
 10. a
 11. c
 12. b
 13. a
 14. b
 15. c
 16. a
 17. a
 18. a
 19. a
 20. a

Edit This is what my code currently looks like. Results in back weighted distribution as I mentioned earlier. For a little context, I am trying to evenly assign commercials across programs. Hence every run with same inputs has to result in exactly the same output. This is what rules out the use of random numbers.

foreach (ListRecord spl in lstRecords){

    string key = spl.AdvertiserName + spl.ContractNumber + spl.AgencyAssignmentCode;

    if (!dictCodesheets.ContainsKey(key)){

        int maxAssignmentForCurrentContract = weeklyList.Count(c => (c.AdvertiserName == spl.AdvertiserName) && (c.AgencyAssignmentCode == spl.AgencyAssignmentCode)
                                               && (c.ContractNumber == spl.ContractNumber) && (c.WeekOf == spl.WeekOf));


        int tmpAssignmentCount = 0;

        for (int i = 0; i < tmpLstGridData.Count; i++)
        {
            GridData gData = tmpLstGridData[i];

            RotationCalculation commIDRotationCalc = new RotationCalculation();
            commIDRotationCalc.commercialID = gData.commercialID;

            commIDRotationCalc.maxAllowed = (int)Math.Round(((double)(maxAssignmentForCurrentContract * gData.rotationPercentage) / 100), MidpointRounding.AwayFromZero);
            tmpAssignmentCount += commIDRotationCalc.maxAllowed;

            if (tmpAssignmentCount > maxAssignmentForCurrentContract)
            {
                commIDRotationCalc.maxAllowed -= 1;
            }


            if (i == 0)
            {
                commIDRotationCalc.maxAllowed -= 1;
                gridData = gData;
            }

            commIDRotationCalc.frequency = (int)Math.Round((double)(100/gData.rotationPercentage));

            if (i == 1)
            {
                commIDRotationCalc.isNextToBeAssigned = true;
            }

            lstCommIDRotCalc.Add(commIDRotationCalc);
        }

        dictCodesheets.Add(key, lstCommIDRotCalc);

    }else{

            List<RotationCalculation> lstRotCalc = dictCodesheets[key];

            for (int i = 0; i < lstRotCalc.Count; i++)
            {

                if (lstRotCalc[i].isNextToBeAssigned)
                {
                    gridData = tmpLstGridData.Where(c => c.commercialID == lstRotCalc[i].commercialID).FirstOrDefault();
                    lstRotCalc[i].maxAllowed -= 1;

                    if (lstRotCalc.Count != 1)
                    {
                        if (i == lstRotCalc.Count - 1 && lstRotCalc[0].maxAllowed > 0)
                        {
                            //Debug.Print("In IF");
                            lstRotCalc[0].isNextToBeAssigned = true;
                            lstRotCalc[i].isNextToBeAssigned = false;

                            if (lstRotCalc[i].maxAllowed == 0)
                            {
                                lstRotCalc.RemoveAt(i);
                            }

                            break;
                        }
                        else
                        {
                            if (lstRotCalc[i + 1].maxAllowed > 0)
                            {
                                //Debug.Print("In ELSE");
                                lstRotCalc[i + 1].isNextToBeAssigned = true;
                                lstRotCalc[i].isNextToBeAssigned = false;

                                if (lstRotCalc[i].maxAllowed == 0)
                                {
                                    lstRotCalc.RemoveAt(i);
                                }

                                break;
                            }
                        }
                    }

                }
            }

        }

}

Edit 2 Trying to clear up my requirement here. Currently, because item 'a' is to be assigned 10 times which is the highest among all three items, towards the end of distribution, items 16 - 20 all have been assigned only 'a'. As has been asked in comments, I am trying to achieve a distribution that "looks" more even.

Madcap
  • 81
  • 1
  • 9
  • 3
    Just shuffle the collection after figuring out the math? Also, it would help us if you posted your code. – maccettura Jun 21 '17 at 19:32
  • 1
    Is "without random numbers" a requirement or are you assuming it won't work based on your posted results? – D Stanley Jun 21 '17 at 19:33
  • 2
    "which is always back weighted in favour of item with highest percentage" well yeah, isn't that the point of the percentages? Aren't you going to get twice as many As on average? – D Stanley Jun 21 '17 at 19:34
  • 1
    Why is your desired output `aacb...` and not `aabc...`? – Jan Köhler Jun 21 '17 at 19:35
  • @maccettura - So wish I paid attention in math classes now! Also added the code. – Madcap Jun 21 '17 at 20:31
  • @dev can you try to be more clear and concise as to what exactly you want the output to be? When you say its backweighted, what does that mean? Do you just want a more even distribution so that the output looks more "distributed"? – maccettura Jun 21 '17 at 20:33
  • @DStanley - No, having higher % means more allocations for that item but that does not have to result in back weighted distribution. Please see differences between desired outcome and current outcome. Also, every run with same inputs has to result in exactly the same output, which does not seem possible with random #s – Madcap Jun 21 '17 at 20:35
  • @maccettura Exactly! You got it. Edited the question to add explanation of my requirements. – Madcap Jun 21 '17 at 20:42
  • 1
    If you use a random number seed it will be the same output with the same input. https://msdn.microsoft.com/en-us/library/ctssatww(v=vs.110).aspx – David Jun 21 '17 at 20:43
  • @khlr Either output works, condition being that the code should spit out exactly the same pattern of assignment every time for same inputs. – Madcap Jun 21 '17 at 20:46
  • @dev OK I see what you're saying - sounds like the problem is you're choosing one with equal probability, and when the lower probability items get used up all that's left are the higher ones. A better approach would be to segment the numbers (0..1) based on the probability distribution and choose an item based on that. e.g. if the random number is between 0.0 and 0.5 choose A, between 0.5 and 0.75 choose B, else choose C. – D Stanley Jun 21 '17 at 20:49
  • @David Interesting! Gonna try it. – Madcap Jun 21 '17 at 20:50

5 Answers5

4

One way to look at this problem is as a multi-dimensional line drawing problem. So I used Bresenham's line algorithm to create the distribution:

public static IEnumerable<T> GetDistribution<T>( IEnumerable<Tuple<T, int>> itemCounts )
{
    var groupCounts = itemCounts.GroupBy( pair => pair.Item1 )
                                .Select( g => new { Item = g.Key, Count = g.Sum( pair => pair.Item2 ) } )
                                .OrderByDescending( g => g.Count )
                                .ToList();

    int maxCount = groupCounts[0].Count;
    var errorValues = new int[groupCounts.Count];

    for( int i = 1; i < errorValues.Length; ++i )
    {
        var item = groupCounts[i];
        errorValues[i] = 2 * groupCounts[i].Count - maxCount;
    }

    for( int i = 0; i < maxCount; ++i )
    {
        yield return groupCounts[0].Item;

        for( int j = 1; j < errorValues.Length; ++j )
        {
            if( errorValues[j] > 0 )
            {
                yield return groupCounts[j].Item;
                errorValues[j] -= 2 * maxCount;
            }

            errorValues[j] += 2 * groupCounts[j].Count;
        }
    }
}

The input is the actual number of each item you want. This has a couple advantages. First it can use integer arithmetic, which avoids any rounding issues. Also it gets rid of any ambiguity if you ask for 10 items and want 3 items evenly distributed (which is basically just the rounding issue again).

Kyle
  • 6,500
  • 2
  • 31
  • 41
1

Here's one with no random number that gives the required output.

using System;
using System.Collections.Generic;

public class Program
{
    public static void Main()
    {
        // name, percentage
        Dictionary<string, double> distribution = new Dictionary<string,double>();

        // name, amount if one more were to be distributed
        Dictionary<string, int> dishedOut = new Dictionary<string, int>();

        //Initialize
        int numToGive = 20;
        distribution.Add("a", 0.50);
        distribution.Add("b", 0.25);
        distribution.Add("c", 0.25);

        foreach (string name in distribution.Keys)
            dishedOut.Add(name, 1);

        for (int i = 0; i < numToGive; i++)
        {
            //find the type with the lowest weighted distribution
            string nextUp = null;
            double lowestRatio = double.MaxValue;
            foreach (string name in distribution.Keys)
                if (dishedOut[name] / distribution[name] < lowestRatio)
                {
                    lowestRatio = dishedOut[name] / distribution[name];
                    nextUp = name;
                }

            //distribute it
            dishedOut[nextUp] += 1;
            Console.WriteLine(nextUp);
        }

        Console.ReadLine();
    }
}
Tobbs
  • 1,120
  • 1
  • 6
  • 15
  • Thanks! This should do the trick. Just need to do a little tweaking. Your code misses frequency limit for some scenarios. For e.g. did testing with numToGive = 54 and percentages A=50%, B=25%, C=25%, it distributed 28 X A instead of 27. – Madcap Jun 22 '17 at 18:12
  • For numToGive = 54, it works if you change the percentage for "b" to (double)14/54 and the percentage for "c" to (double)13/54. But I guess it gets mixed up if the percentages don't all work out exactly. I'll let you find a fix for that. :) – Tobbs Jun 22 '17 at 18:45
  • Sorry couldn't reply earlier. Had made the adjustment. Here's what I did: `int tmpCount = (int)Math.Round(((double)(numToGive * distribution[nextUp])), MidpointRounding.AwayFromZero); if (dishedOut[nextUp] == tmpCount) { dishedOut.Remove(nextUp); distribution.Remove(nextUp); }else { dishedOut[nextUp] += 1; } ` – Madcap Jun 26 '17 at 14:37
0
//AABC AABC…            
int totalA = 10         
int totalB = 5          
int totalC = 5          
int totalItems = 20 //A+B+C         

double frequencyA = totalA / totalItems; //0.5          
double frequencyB = totalB / totalItems; //0.25         
double frequencyC = totalC / totalItems; //0.25         

double filledA = frequencyA;            
double filledB = frequencyB;            
double filledC = frequencyC;            

string output = String.Empty;           

while(output.Length < totalItems)
{       
    filledA += frequencyA;      
    filledB += frequencyB;      
    filledC += frequencyC;      

    if(filledA >= 1)
    {       
        filledA -= 1;   
        output += "A";
        if(output.Length == totalItems){break;}
    }
    if(filledB >= 1)    
    {   
        filledB -= 1    
        output += "B";
        if(output.Length == totalItems){break;}
    }
    if(filledC >= 1)        
    {
        filledC -= 1    
        output += "C";
        if(output.Length == totalItems){break;}
    }
}
SED
  • 311
  • 1
  • 11
  • 1
    How does that work with percentages 0.4, 0.3 and 0.3? First item will not be anything. Edit: You would have to loop until `totalItems` items are found. Also, you should only print one item in each for step. I am not sure though if that even works then. Just consider percentages 0.6, 0.3 and 0.1, A will be printed much more often than wonted and it would nearly never print C. Cause whenever A is not matching it matches B und after that B is already over 1 again. – Wolfsblvt Jun 21 '17 at 20:00
  • @Wolfsblvt edited my answer to be a while loop instead of for loop. Some iterations may not do anything other than increase the filledA, filledB, and filledC values. also note that there are 3 "if" statements that do not break or return so it could possibly fill 0, 1, 2, or all 3 in any given iteration. – SED Jun 21 '17 at 20:09
  • 2
    Then you have to add the while condition to every if block as well, otherwise you could end with 21 or 22 items. – Wolfsblvt Jun 21 '17 at 20:13
0

This answer was mostly stolen and lightly adapted for your use from here

My idea is that you distribute your items in the simplest way possible without care of order, then shuffle the list.

public static void ShuffleTheSameWay<T>(this IList<T> list)  
{  
    Random rng = new Random(0);  
    int n = list.Count;  
    while (n > 1) {  
        n--;  
        int k = rng.Next(n + 1);  
        T value = list[k];  
        list[k] = list[n];  
        list[n] = value;  
    }  
}

Fiddle here

maccettura
  • 10,514
  • 3
  • 28
  • 35
  • Please not that you have to check at the end if there really are 20 items found. Your code would return 18 elements for a distribution with `1/3 (aka 0.33...)`, and similar things happen for other distributions as well. Also in your fiddle, never compare double against a real value, always check wished precision. Take this fiddle, which totally sums to `1.0` but would throw an exception cause of double precision: https://dotnetfiddle.net/sw3VYe – Wolfsblvt Jun 21 '17 at 20:22
  • @Wolfsblvt the OP indicated they wrote code that distributes fine, but they felt that their algorithm was flawed because it "back-filled" the item with the highest percentage (which made the output look to the OP like it wasn't "random" enough). Since OP gave absolutely no code, I had to "fake" something real quick to illustrate my answer which was: **Just simply do the math, and shuffle the collection after**. I was not attempting to do the distribution in a production way, just enough to illustrate. – maccettura Jun 21 '17 at 20:26
  • But wasn't a requirement of OPs question to distribute exactly 20 items? Of course, OP has provided sparse information, but if shuffle was all he needed, then I don't understand the question really. I thought he was searching for an algorithm to produce evenly distributed elements. – Wolfsblvt Jun 21 '17 at 20:29
0

Instead of a truly random number generator, use a fixed seed, so that the program has the same output every time you run it (for the same input). In the code below, the '0' is the seed, which means the 'random' numbers generated will always be the same each time the program is run.

 Random r = new Random(0);
David
  • 1,743
  • 1
  • 18
  • 25