What is the best way to group groupings of groupings?

Viewed 189

So recently I ran into a problem, my team and I need to take a list of objects, and group them by conditions, then that group by more conditions, then that group by even more conditions, and so on for 7 or so levels. After thinking on it for a few days I finally came up with sort of a tree structure, although each level is manually defined (mainly for ease of reading, because once it is programed it will be set in stone). What is the best method to handle for this, and if possible, why? Here’s what I have so far using a list of random integers. The checks are: divisible by 2, divisible by 3, and divisible by 5 in that order (although the order of conditions don’t matter for the requirements):

Here's the code for the random list of integers plus the TopNode class

Random rand = new Random();
List<int> ints = new List<int>();
for (int i = 0; i < 10; i++)
{
   ints.Add(rand.Next(0, 10000001));
}

TopNode node = new TopNode(ints);

Here's the rest of the code for the top node class

public class TopNode 
{ 
    public Even Even { get; set; }
    public Odd Odd { get; set; }
    public TopNode(List<int> ints)
    {
        var even = ints.Where(x => x % 2 == 0).ToList();
        var odd = ints.Where(x => x % 2 != 0).ToList();
        if (even.Count > 0)
        {
            Even = new Even(even);
        }
        if (odd.Count > 0)
        {
            Odd = new Odd(odd);
        }
    }
} 

public class Even {
    public Mulitple3 Mulitple3 { get; set; }
    public NotMulitple3 NotMulitple3 { get; set; }
    public Even(List<int> ints)
    {
        var multiple = ints.Where(x => x % 3 == 0).ToList();
        var not = ints.Where(x => x % 3 != 0).ToList();
        if (multiple.Count > 0)
        {
            Mulitple3 = new Mulitple3(multiple);
        }
        if (not.Count > 0)
        {
            NotMulitple3 = new NotMulitple3(not);
        }
    }
} 

public class Odd {
    public Mulitple3 Mulitple3 { get; set; }
    public NotMulitple3 NotMulitple3 { get; set; }
    public Odd(List<int> ints)
    {
        var multiple = ints.Where(x => x % 3 == 0).ToList();
        var not = ints.Where(x => x % 3 != 0).ToList();
        if (multiple.Count > 0)
        {
            Mulitple3 = new Mulitple3(multiple);
        }
        if (not.Count > 0)
        {
            NotMulitple3 = new NotMulitple3(not);
        }
    }
}

public class Mulitple3
{
    public Multiple5 Multiple5 { get; set; }
    public NotMultiple5 NotMultiple5 { get; set; }
    public Mulitple3(List<int> ints)
    {
        var multiple = ints.Where(x => x % 5 == 0).ToList();
        var not = ints.Where(x => x % 5 != 0).ToList();
        if (multiple.Count > 0)
        {
            Multiple5 = new Multiple5(multiple);
        }
        if (not.Count > 0)
        {
            NotMultiple5 = new NotMultiple5(not);
        }
    }
}

public class NotMulitple3
{
    public Multiple5 Multiple5 { get; set; }
    public NotMultiple5 NotMultiple5 { get; set; }
    public NotMulitple3(List<int> ints)
    {
        var multiple = ints.Where(x => x % 5 == 0).ToList();
        var not = ints.Where(x => x % 5 != 0).ToList();
        if (multiple.Count > 0)
        {
            Multiple5 = new Multiple5(multiple);
        }
        if (not.Count > 0)
        {
            NotMultiple5 = new NotMultiple5(not);
        }
    }
}

public class Multiple5
{
    public List<int> ints { get; set; }
    public Multiple5(List<int> ints)
    {
        this.ints = ints;
    }
}

public class NotMultiple5
{
    public List<int> ints { get; set; }
    public NotMultiple5(List<int> ints)
    {
        this.ints = ints;
    }
}
4 Answers

The simplest tree is just an IEnumerable<IEnumerable<...>> and you can form it using GroupBy.

Here's a simple example that groups some integers into a tree based on divisibility by 2, 3 and 5. It prints:

{{{{1,7,23,29},{5}},{{3,9,87,21}}},{{{4,8,34,56}},{{78},{30}}}}

.

public static void Main()
{
    int[] input = new int[]{1, 3, 4, 5, 7, 8, 9, 23, 34, 56, 78, 87, 29, 21, 2*3*5};

    // TREE
    var groupedTree = input.GroupBy(x => x % 2 == 0)
        .Select(g => g.GroupBy(x => x % 3 == 0)
        .Select(h => h.GroupBy(x => x % 5 == 0)));

    Console.WriteLine(Display(groupedTree));
}

// Hack code to dump the tree
public static string DisplaySequence(IEnumerable items) => "{" + string.Join(",", items.Cast<object>().Select(x => Display(x))) + "}";
public static string Display(object item) => item is IEnumerable seq ? DisplaySequence(seq) : item.ToString();

I also created a tree class, but I used a class to hold each condition, and an array of conditions to handle the grouping. Each condition is expected to return an int to create the grouping. Then the tree class can step through the conditions to group each level. To make the tree uniform, I kept a list of members at each level which is then split into the next level.

public class Condition<T> {
    public string[] Values;
    public Func<T, int> Test;

    public Condition(string[] values, Func<T, int> test) {
        Values = values;
        Test = test;
    }
}

public class Level {
    public static Level<T> MakeTree<T>(IEnumerable<T> src, Condition<T>[] conditions) => new Level<T>(src, conditions);
    public static IEnumerable<int> MakeKey<T>(Condition<T>[] conditions, params string[] values) {
        for (int depth = 0; depth < values.Length; ++depth)
            yield return conditions[depth].Values.IndexOf(values[depth]);
    }
}

public class Level<T> {
    public string Value;
    public Level<T>[] NextLevels;
    public List<T> Members;

    public Level(string value, List<T> members) {
        Value = value;
        Members = members;
        NextLevels = null;
    }

    public Level(IEnumerable<T> src, Condition<T>[] conditions) : this("ALL", src.ToList()) => GroupOneLevel(this, 0, conditions);

    public void GroupOneLevel(Level<T> parent, int depth, Condition<T>[] conditions) {
        var condition = conditions[depth];
        var nextLevels = new Level<T>[condition.Values.Length];
        for (int j2 = 0; j2 < condition.Values.Length; ++j2) {
            nextLevels[j2] = new Level<T>(condition.Values[j2], new List<T>());
        }

        for (int j2 = 0; j2 < parent.Members.Count; ++j2) {
            var member = parent.Members[j2];
            nextLevels[condition.Test(member)].Members.Add(member);
        }
        parent.NextLevels = nextLevels;
        if (depth + 1 < conditions.Length)
            for (int j3 = 0; j3 < condition.Values.Length; ++j3)
                GroupOneLevel(nextLevels[j3], depth + 1, conditions);
    }

    public List<T> MembersForKey(IEnumerable<int> values) {
        var curLevel = this;
        foreach (var value in values)
            curLevel = curLevel.NextLevels[value];

        return curLevel.Members;
    }
}

For your example, you can use this like:

var conditions = new[] {
        new Condition<int>(new[] { "Even", "Odd" }, n => n & 1),
        new Condition<int>(new[] { "Div3", "NOTDiv3" }, n => n % 3 == 0 ? 0 : 1),
        new Condition<int>(new[] { "Div5", "NOTDiv5" }, n => n % 5 == 0 ? 0 : 1)
    };

var ans = Level.MakeTree(ints, conditions);

And you can lookup a particular part of the tree with:

var evenDiv3 = ans.MembersForKey(Level.MakeKey(conditions, "Even", "Div3"));

My suggestion is to create a collection class that can filter your objects, and returns instances of itself so that the filtering can continue deeper. For example lets assume that your objects are of type MyObject:

class MyObject
{
    public int Number { get; }
    public MyObject(int number) => this.Number = number;
    public override string ToString() => this.Number.ToString();
}

Here is an example of the filtering collection MyCollection, that supports filtering for Odd, Even, Multiple3 and NonMultiple3. The lookups required are created lazily, to avoid allocating memory for searches that will never be requested:

class MyCollection : IEnumerable<MyObject>
{
    private readonly IEnumerable<MyObject> _source;

    private readonly Lazy<ILookup<bool, MyObject>> _multiple2Lookup;
    private readonly Lazy<MyCollection> _even;
    private readonly Lazy<MyCollection> _odd;

    private readonly Lazy<ILookup<bool, MyObject>> _multiple3Lookup;
    private readonly Lazy<MyCollection> _multiple3;
    private readonly Lazy<MyCollection> _nonMultiple3;

    public MyCollection Even => _even.Value;
    public MyCollection Odd => _odd.Value;

    public MyCollection Multiple3 => _multiple3.Value;
    public MyCollection NonMultiple3 => _nonMultiple3.Value;

    public MyCollection(IEnumerable<MyObject> source)
    {
        _source = source;

        _multiple2Lookup = new Lazy<ILookup<bool, MyObject>>(
            () => _source.ToLookup(o => o.Number % 2 == 0));
        _even = new Lazy<MyCollection>(
            () => new MyCollection(_multiple2Lookup.Value[true]));
        _odd = new Lazy<MyCollection>(
            () => new MyCollection(_multiple2Lookup.Value[false]));

        _multiple3Lookup = new Lazy<ILookup<bool, MyObject>>(
            () => _source.ToLookup(o => o.Number % 3 == 0));
        _multiple3 = new Lazy<MyCollection>(
            () => new MyCollection(_multiple3Lookup.Value[true]));
        _nonMultiple3 = new Lazy<MyCollection>(
            () => new MyCollection(_multiple3Lookup.Value[false]));
    }

    public IEnumerator<MyObject> GetEnumerator() => _source.GetEnumerator();
    IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}

Usage example:

var source = Enumerable.Range(1, 20).Select(i => new MyObject(i));
var myObjects = new MyCollection(source);
var filtered = myObjects.Even.NonMultiple3;
Console.WriteLine(String.Join(", ", filtered));

Output:

2, 4, 8, 10, 14, 16, 20

A possible drawback of this approach is that it allows calling both myObjects.Even.NonMultiple3 and myObjects.NonMultiple3.Even. Both queries return the same results, but cause the creation of redundant lookups.

NetMage and Theodor's answers were exactly what I was looking for as per the question. However, due to an oversite in my example code, I neglected to mention that sometimes the answer would return with more than just true or false and instead would return 3 or 4 values (and in very rare occasions one of the return values needs to be grouped and iterated over). This is not a fault of their own, and their work is actually very good and serves good use, but it was an oversite on my part. Due to this of this I decided to go with Ian's and Kyles answers based on the comments and came up with this:

While it's not perfect, it does allow me to return as many values as I want, group by if I need to (defined in the case statements), and if I only need to filter by 2 and not all 3 or need to change the order, I can add them to the conditions array as I need them.

Again thanks for the help and I'm sorry I wasn't clear enough in the question.

Random rand = new Random();
List<int> ints = new List<int>();
for (int i = 0; i < 10000000; i++)
{
   ints.Add(rand.Next(0, 10000001));
}
string[] conditions = new string[] { "even", "div3", "div5" };
var dynamicSort = new Sorted(ints);

public class Sorted
{
    public List<List<int>> returnVal { get; set; }
    public static List<int> Odd(List<int> ints)
    {
        return ints.Where(x => x % 2 != 0).ToList();
    }
    public static List<int> Even(List<int> ints)
    {
        return ints.Where(x => x % 2 == 0).ToList();
    }
    public static List<int> DivThree(List<int> ints)
    {
        return ints.Where(x => x % 3 == 0).ToList();
    }
    public static List<int> NotDivThree(List<int> ints)
    {
        return ints.Where(x => x % 3 != 0).ToList();
    }
    public static List<int> DivFive(List<int> ints)
    {
        return ints.Where(x => x % 5 == 0).ToList();
    }
    public static List<int> NotDivFive(List<int> ints)
    {
        return ints.Where(x => x % 5 != 0).ToList();
    }

    public Sorted(List<int> ints, string[] conditions)
    {
        returnVal = GetSorted(ints, conditions, 0);
    }
    public List<List<int>> GetSorted(List<int>ints, string[] conditions, int index)
    {
        var sortReturn = new List<List<int>>();
        switch (conditions[index].ToLower()) 
        {
            case "even":
            case "odd":
                {
                    if (index == conditions.Length - 1)
                    {
                        sortReturn.Add(Odd(ints));
                        sortReturn.Add(Even(ints));
                    }
                    else
                    {
                        var i = ++index;
                        sortReturn.AddRange(GetSorted(Odd(ints), conditions, i));
                        sortReturn.AddRange(GetSorted(Even(ints), conditions, i));
                    }
                    break;
                }
            case "div3":
            case "notdiv3":
                {
                    if (index == conditions.Length - 1)
                    {
                        sortReturn.Add(DivThree(ints));
                        sortReturn.Add(NotDivThree(ints));
                    }
                    else
                    {
                        var i = ++index;
                        sortReturn.AddRange(GetSorted(DivThree(ints), conditions, i));
                        sortReturn.AddRange(GetSorted(NotDivThree(ints), conditions, i));
                    }
                    break;
                }
            case "div5":
            case "notdiv5":
                {
                    if (index == conditions.Length - 1)
                    {
                        sortReturn.Add(DivFive(ints));
                        sortReturn.Add(NotDivFive(ints));
                    }
                    else
                    {
                        var i = ++index;
                        sortReturn.AddRange(GetSorted(DivFive(ints), conditions, i));
                        sortReturn.AddRange(GetSorted(NotDivFive(ints), conditions, i));
                    }
                    break;
                }
        }
        return sortReturn;
    }
}
Related