徳島ゲーム開発ごっこ 技術ブログ

ゲームを作るために役に立ったり立たなかったりする技術を学んでいきます!

【C#,LINQ】GroupBy~配列やリストをグループ化したいとき~

 C#のLINQの関数であるGroupBy()の使い方についてです。
 配列やリストの要素をグループ化することが出来ます。
f:id:urahimono:20180721072327p:plain


この記事には.NET Framework 4.6.1を使用しています。

IGroupingでグループ化だ

 「はーい、仲のいい子でチームを作ってー。」
 学生時代の悪夢のような思い出ですね。

 まあ、チームを作れるようなリア充連中は放っておいて、配列やリストの要素を特定のグループで分けたい時があると思います。
 そんなときはLINQのGroupBy()を使ってみてください。

public static IEnumerable<IGrouping<TKey, TSource>> GroupBy<TSource, TKey>( this IEnumerable<TSource> source, Func<TSource, TKey> keySelector );
Enumerable.GroupBy(TSource, TKey) メソッド (IEnumerable(TSource), Func(TSource, TKey)) (System.Linq)

 GroupBy()を使うことで、IGrouping型にいい感じにグループ化してくれます。

IGrouping
IGrouping(TKey, TElement) インターフェイス (System.Linq)

 実際にGroupBy()を使いながら見ていきましょう。
 以下のようなクラスを用意します。

Program.cs

private class Parameter
{
    public string   Team    { get; set; }
    public string   Name    { get; set; }
    public int      Age     { get; set; }

    public override string ToString()
    {
        return string.Format( "Team:{0}, Name:{1}, Age:{2}", Team, Name, Age );
    }
}

 このクラスを配列で作り、データとしておき、Teamのプロパティを使ってグループ化してみましょう。

 GroupBy()の使い方は、第一引数にどのプロパティでグループ化するかの処理を指定してあげるだけです。
 今回はラムダ式を用いて、Teamプロパティを指定してあげています。

Program.cs

using System.Linq;
using System.Collections;
using System.Collections.Generic;

public static class Program
{
    private class Parameter
    {
        public string   Team    { get; set; }
        public string   Name    { get; set; }
        public int      Age     { get; set; }

        public override string ToString()
        {
            return string.Format( "Team:{0}, Name:{1}, Age:{2}", Team, Name, Age );
        }
    }

    static void Main( string[] args )
    {
        // 人物データ
        Parameter[] data    = new Parameter[]
        {
            new Parameter() { Team = "A", Name = "正一郎", Age = 35 },
            new Parameter() { Team = "A", Name = "清次郎", Age = 27 },
            new Parameter() { Team = "A", Name = "誠三郎", Age = 27 },
            new Parameter() { Team = "B", Name = "征史郎", Age = 18 },
        };

        // チーム名でグループ化するよ。
        IEnumerable<IGrouping<string, Parameter>> groupList = data.GroupBy( param => param.Team );

        // 結果発表
        System.Console.WriteLine( "data:{0}", data.Text() );

        foreach( var group in groupList )
        {
            System.Console.WriteLine( "group:{0}", group.Key );
            foreach( var value in group )
            {
                System.Console.WriteLine( "\t{0}", value );
            }
        }

        // 入力待ち用
        System.Console.ReadKey();
    }

    /// <summary>
    /// 簡易的なコレクションのテキスト取得処理
    /// </summary>
    public static string Text( this IEnumerable i_source )
    {
        string text = string.Empty;
        foreach( var value in i_source )
        {
            text += string.Format( "[{0}], ", value );
        }
        return text;
    }

} // class Program

data:[Team:A, Name:正一郎, Age:35], [Team:A, Name:清次郎, Age:27], [Team:A, Name:誠三郎, Age:27], [Team:B, Name:征史郎, Age:18],
group:A
Team:A, Name:正一郎, Age:35
Team:A, Name:清次郎, Age:27
Team:A, Name:誠三郎, Age:27
group:B
Team:B, Name:征史郎, Age:18

 チームごとにデータがグループ化されているのが確認できます。

グループ化した要素の型も変えるんだ

 さて、先ほどはParameterクラスごとグループ化しましたが、このグループ化した際にどんな要素にするかも指定することができます。

public static IEnumerable<IGrouping<TKey, TElement>> GroupBy<TSource, TKey, TElement>( this IEnumerable<TSource> source, Func<TSource, TKey> keySelector, Func<TSource, TElement> elementSelector );
Enumerable.GroupBy(TSource, TKey, TElement, TResult) メソッド (IEnumerable(TSource), Func(TSource, TKey), Func(TSource, TElement), Func(TKey, IEnumerable(TElement), TResult)) (System.Linq)

 使い方は、GroupBy()の第二引数に、グループ化後の要素の形を指定する処理を記述してあげることです。
 以下の例では、ParameterNameのプロパティのみをグループ化後の要素として使うように、ラムダ式で指定しています。

Program.cs

using System.Linq;
using System.Collections;
using System.Collections.Generic;

public static class Program
{
    private class Parameter
    {
        public string   Team    { get; set; }
        public string   Name    { get; set; }
        public int      Age     { get; set; }

        public override string ToString()
        {
            return string.Format( "Team:{0}, Name:{1}, Age:{2}", Team, Name, Age );
        }
    }

    static void Main( string[] args )
    {
        // 人物データ
        Parameter[] data    = new Parameter[]
        {
            new Parameter() { Team = "A", Name = "正一郎", Age = 35 },
            new Parameter() { Team = "A", Name = "清次郎", Age = 27 },
            new Parameter() { Team = "A", Name = "誠三郎", Age = 27 },
            new Parameter() { Team = "B", Name = "征史郎", Age = 18 },
        };

        // チーム名でグループ化するよ。
        // グループ化したデータは名前情報だけでいいよ。
        IEnumerable<IGrouping<string, string>> groupList = data.GroupBy( 
                                                            param => param.Team,
                                                            value => value.Name );

        // 結果発表
        System.Console.WriteLine( "data:{0}", data.Text() );

        foreach( var group in groupList )
        {
            System.Console.WriteLine( "group:{0}", group.Key );
            foreach( var value in group )
            {
                System.Console.WriteLine( "\t{0}", value );
            }
        }

        // 入力待ち用
        System.Console.ReadKey();
    }

    /// <summary>
    /// 簡易的なコレクションのテキスト取得処理
    /// </summary>
    public static string Text( this IEnumerable i_source )
    {
        string text = string.Empty;
        foreach( var value in i_source )
        {
            text += string.Format( "[{0}], ", value );
        }
        return text;
    }

} // class Program

data:[Team:A, Name:正一郎, Age:35], [Team:A, Name:清次郎, Age:27], [Team:A, Name:誠三郎, Age:27], [Team:B, Name:征史郎, Age:18],
group:A
正一郎
清次郎
誠三郎
group:B
征史郎

グループの型も自分で考えるんだ

 さて、さきほどまではGroupBy()を使ったら、IGroupingのシーケンスとしてグループ化されていました。
 ですが、「IGroupingを使いたくない!」「独自の形式でグループ化したい!」という意見もあるかと思います。

 そういう場合でも、GroupBy()は対応してくれます。

public static IEnumerable<TResult> GroupBy<TSource, TKey, TResult>( this IEnumerable<TSource> source, Func<TSource, TKey> keySelector, Func<TKey, IEnumerable<TSource>, TResult> resultSelector );
Enumerable.GroupBy(TSource, TKey, TResult) メソッド (IEnumerable(TSource), Func(TSource, TKey), Func(TKey, IEnumerable(TSource), TResult)) (System.Linq)

 実際に使ってみましょう。
 まず、グループ化した情報をまとめる用のクラスを以下のように作成します。

Program.cs

private class GroupData
{
    public string       ID          { get; set; }
    public Parameter[]  ParamList   { get; set; }
}

 そして、このGroupBy()の第二引数に、GroupDataクラスにデータをまとめる処理を記述すれば、IGroupingの代わりに、このGroupDataクラスがグループ化のクラスとして使用されます。
 以下の例ではこの処理をラムダ式で記述しています。

Program.cs

using System.Linq;
using System.Collections;
using System.Collections.Generic;

public static class Program
{
    private class Parameter
    {
        public string   Team    { get; set; }
        public string   Name    { get; set; }
        public int      Age     { get; set; }

        public override string ToString()
        {
            return string.Format( "Team:{0}, Name:{1}, Age:{2}", Team, Name, Age );
        }
    }

    private class GroupData
    {
        public string       ID          { get; set; }
        public Parameter[]  ParamList   { get; set; }
    }

    static void Main( string[] args )
    {
        // 人物データ
        Parameter[] data    = new Parameter[]
        {
            new Parameter() { Team = "A", Name = "正一郎", Age = 35 },
            new Parameter() { Team = "A", Name = "清次郎", Age = 27 },
            new Parameter() { Team = "A", Name = "誠三郎", Age = 27 },
            new Parameter() { Team = "B", Name = "征史郎", Age = 18 },
        };

        // チーム名でグループ化するよ。
        IEnumerable<GroupData> groupList = data.GroupBy(
                                                    param => param.Team,
                                                    ( groupID, paramList ) => new GroupData() { ID = groupID, ParamList = paramList.ToArray() } );

        // 結果発表
        System.Console.WriteLine( "data:{0}", data.Text() );

        foreach( var group in groupList )
        {
            System.Console.WriteLine( "group:{0}", group.ID );
            foreach( var value in group.ParamList )
            {
                System.Console.WriteLine( "\t{0}", value );
            }
        }

        // 入力待ち用
        System.Console.ReadKey();
    }

    /// <summary>
    /// 簡易的なコレクションのテキスト取得処理
    /// </summary>
    public static string Text( this IEnumerable i_source )
    {
        string text = string.Empty;
        foreach( var value in i_source )
        {
            text += string.Format( "[{0}], ", value );
        }
        return text;
    }

} // class Program

data:[Team:A, Name:正一郎, Age:35], [Team:A, Name:清次郎, Age:27], [Team:A, Name:誠三郎, Age:27], [Team:B, Name:征史郎, Age:18],
group:A
Team:A, Name:正一郎, Age:35
Team:A, Name:清次郎, Age:27
Team:A, Name:誠三郎, Age:27
group:B
Team:B, Name:征史郎, Age:18

 いい感じにグループ化されています。
 ただ、この作ったGroupDataクラスを他で使う必要がないのなら、匿名型にしてしまったほうが楽かもしれません。
 以下の例では、GroupDataクラスを用いず、匿名型を使用しています。

Program.cs

using System.Linq;
using System.Collections;
using System.Collections.Generic;

public static class Program
{
    private class Parameter
    {
        public string   Team    { get; set; }
        public string   Name    { get; set; }
        public int      Age     { get; set; }

        public override string ToString()
        {
            return string.Format( "Team:{0}, Name:{1}, Age:{2}", Team, Name, Age );
        }
    }

    static void Main( string[] args )
    {
        // 人物データ
        Parameter[] data    = new Parameter[]
        {
            new Parameter() { Team = "A", Name = "正一郎", Age = 35 },
            new Parameter() { Team = "A", Name = "清次郎", Age = 27 },
            new Parameter() { Team = "A", Name = "誠三郎", Age = 27 },
            new Parameter() { Team = "B", Name = "征史郎", Age = 18 },
        };

        // チーム名でグループ化するよ。
        var groupList = data.GroupBy(
                                param => param.Team,
                                ( groupID, paramList ) => new { ID = groupID, ParamList = paramList.ToArray() } );

        // 結果発表
        System.Console.WriteLine( "data:{0}", data.Text() );

        foreach( var group in groupList )
        {
            System.Console.WriteLine( "group:{0}", group.ID );
            foreach( var value in group.ParamList )
            {
                System.Console.WriteLine( "\t{0}", value );
            }
        }

        // 入力待ち用
        System.Console.ReadKey();
    }

    /// <summary>
    /// 簡易的なコレクションのテキスト取得処理
    /// </summary>
    public static string Text( this IEnumerable i_source )
    {
        string text = string.Empty;
        foreach( var value in i_source )
        {
            text += string.Format( "[{0}], ", value );
        }
        return text;
    }

} // class Program

data:[Team:A, Name:正一郎, Age:35], [Team:A, Name:清次郎, Age:27], [Team:A, Name:誠三郎, Age:27], [Team:B, Name:征史郎, Age:18],
group:A
Team:A, Name:正一郎, Age:35
Team:A, Name:清次郎, Age:27
Team:A, Name:誠三郎, Age:27
group:B
Team:B, Name:征史郎, Age:18

グループ化した要素の型も変えるんだ

 IGroupingを使わない場合でも、グループ化する要素をどうするかを指定することができます。

public static IEnumerable<TResult> GroupBy<TSource, TKey, TElement, TResult>( this IEnumerable<TSource> source, Func<TSource, TKey> keySelector, Func<TSource, TElement> elementSelector, Func<TKey, IEnumerable<TElement>, TResult> resultSelector );
Enumerable.GroupBy(TSource, TKey, TResult) メソッド (IEnumerable(TSource), Func(TSource, TKey), Func(TKey, IEnumerable(TSource), TResult), IEqualityComparer(TKey)) (System.Linq)

 引数が多くなってきましたが、

  • 第一引数:どのプロパティでグループ化するかを指定する処理
  • 第二引数:グループ化後に要素としてどんな情報を使うかを指定する処理
  • 第三引数:グループ化した情報をどうするかを指定する処理

 このように指定していきましょう。

Program.cs

using System.Linq;
using System.Collections;
using System.Collections.Generic;

public static class Program
{
    private class Parameter
    {
        public string   Team    { get; set; }
        public string   Name    { get; set; }
        public int      Age     { get; set; }

        public override string ToString()
        {
            return string.Format( "Team:{0}, Name:{1}, Age:{2}", Team, Name, Age );
        }
    }

    static void Main( string[] args )
    {
        // 人物データ
        Parameter[] data    = new Parameter[]
        {
            new Parameter() { Team = "A", Name = "正一郎", Age = 35 },
            new Parameter() { Team = "A", Name = "清次郎", Age = 27 },
            new Parameter() { Team = "A", Name = "誠三郎", Age = 27 },
            new Parameter() { Team = "B", Name = "征史郎", Age = 18 },
        };

        // チーム名でグループ化するよ。
        // グループ化したデータは名前情報だけでいいよ。
        var groupList = data.GroupBy(
                                param => param.Team,
                                value => value.Name,
                                ( groupID, names ) => new { ID = groupID, Names = names } );

        // 結果発表
        System.Console.WriteLine( "data:{0}", data.Text() );

        foreach( var group in groupList )
        {
            System.Console.WriteLine( "group:{0}", group.ID );
            foreach( var value in group.Names )
            {
                System.Console.WriteLine( "\t{0}", value );
            }
        }

        // 入力待ち用
        System.Console.ReadKey();
    }

    /// <summary>
    /// 簡易的なコレクションのテキスト取得処理
    /// </summary>
    public static string Text( this IEnumerable i_source )
    {
        string text = string.Empty;
        foreach( var value in i_source )
        {
            text += string.Format( "[{0}], ", value );
        }
        return text;
    }

} // class Program

data:[Team:A, Name:正一郎, Age:35], [Team:A, Name:清次郎, Age:27], [Team:A, Name:誠三郎, Age:27], [Team:B, Name:征史郎, Age:18],
group:A
清次郎
正一郎
誠三郎
group:B
征史郎

グループ化の比較処理を自作する

 さきほどまで使っていたParameterクラスを以下のように変更します。

Program.cs

private class Parameter
{
    public float    Number  { get; set; }
    public string   Name    { get; set; }
    public int      Age     { get; set; }

    public override string ToString()
    {
        return string.Format( "Number:{0}, Name:{1}, Age:{2}", Number, Name, Age );
    }
}

 このParameterクラスのNumberを使ってグループ化してみましょう。

Program.cs

using System.Linq;
using System.Collections;
using System.Collections.Generic;

public static class Program
{
    private class Parameter
    {
        public float    Number  { get; set; }
        public string   Name    { get; set; }
        public int      Age     { get; set; }

        public override string ToString()
        {
            return string.Format( "Number:{0}, Name:{1}, Age:{2}", Number, Name, Age );
        }
    }

    static void Main( string[] args )
    {
        // 人物データ
        Parameter[] data    = new Parameter[]
        {
            new Parameter() { Number = 3.64f, Name = "正一郎", Age = 35 },
            new Parameter() { Number = 3.85f, Name = "清次郎", Age = 27 },
            new Parameter() { Number = 3.72f, Name = "誠三郎", Age = 27 },
            new Parameter() { Number = 1.78f, Name = "征史郎", Age = 18 },
        };

        // 数値情報でグループ化するよ。
        var groupList = data.GroupBy( param => param.Number );

        // 結果発表
        System.Console.WriteLine( "data:{0}", data.Text() );

        foreach( var group in groupList )
        {
            System.Console.WriteLine( "group:{0}", group.Key );
            foreach( var value in group )
            {
                System.Console.WriteLine( "\t{0}", value );
            }
        }

        // 入力待ち用
        System.Console.ReadKey();
    }

    /// <summary>
    /// 簡易的なコレクションのテキスト取得処理
    /// </summary>
    public static string Text( this IEnumerable i_source )
    {
        string text = string.Empty;
        foreach( var value in i_source )
        {
            text += string.Format( "[{0}], ", value );
        }
        return text;
    }

} // class Program

data:[Number:3.64, Name:正一郎, Age:35], [Number:3.85, Name:清次郎, Age:27], [Number:3.72, Name:誠三郎, Age:27], [Number:1.78, Name:征史郎, Age:18],
group:3.64
Number:3.64, Name:正一郎, Age:35
group:3.85
Number:3.85, Name:清次郎, Age:27
group:3.72
Number:3.72, Name:誠三郎, Age:27
group:1.78
Number:1.78, Name:征史郎, Age:18

 全部バラバラのグループになりました。
 そりゃ、Numberの値が全部違うので当然です。

 ただNumberの値が近いものも多いですね。
 近い値のものはいっそ同じグループとしてまとめることはできないでしょうか。
 (そもそも、浮動小数を使ってグループ分けをしているのが問題ではあるのですが……)

 そんなときはグループを分ける際の値の比較処理を自作してしまいましょう。

 IEqualityComparerを継承したクラスに比較処理を作ります。
IEqualityComparer
IEqualityComparer(T) インターフェイス (System.Collections.Generic)

Program.cs

private class GroupComparer : IEqualityComparer<float>
{
    public bool Equals( float i_lhs, float i_rhs )
    {
        return (int)System.Math.Floor( i_lhs ) == (int)System.Math.Floor( i_rhs );
    }

    public int GetHashCode( float i_value )
    {
        int value = (int)System.Math.Floor( i_value );
        return value.GetHashCode();
    }
}

 今回の場合は、float型の場合は小数点以下の数値を無視して判定するような処理を記述してみました。
 これをGroupBy()に指定します。

public static IEnumerable<IGrouping<TKey, TSource>> GroupBy<TSource, TKey>( this IEnumerable<TSource> source, Func<TSource, TKey> keySelector, IEqualityComparer<TKey> comparer );
Enumerable.GroupBy(TSource, TKey) メソッド (IEnumerable(TSource), Func(TSource, TKey), IEqualityComparer(TKey)) (System.Linq)

public static IEnumerable<IGrouping<TKey, TElement>> GroupBy<TSource, TKey, TElement>( this IEnumerable<TSource> source, Func<TSource, TKey> keySelector, Func<TSource, TElement> elementSelector, IEqualityComparer<TKey> comparer );
Enumerable.GroupBy(TSource, TKey, TElement, TResult) メソッド (IEnumerable(TSource), Func(TSource, TKey), Func(TSource, TElement), Func(TKey, IEnumerable(TElement), TResult), IEqualityComparer(TKey)) (System.Linq)

public static IEnumerable<TResult> GroupBy<TSource, TKey, TResult>( this IEnumerable<TSource> source, Func<TSource, TKey> keySelector, Func<TKey, IEnumerable<TSource>, TResult> resultSelector, IEqualityComparer<TKey> comparer );
Enumerable.GroupBy(TSource, TKey, TElement) メソッド (IEnumerable(TSource), Func(TSource, TKey), Func(TSource, TElement)) (System.Linq)

public static IEnumerable<TResult> GroupBy<TSource, TKey, TElement, TResult>( this IEnumerable<TSource> source, Func<TSource, TKey> keySelector, Func<TSource, TElement> elementSelector, Func<TKey, IEnumerable<TElement>, TResult> resultSelector, IEqualityComparer<TKey> comparer );
Enumerable.GroupBy(TSource, TKey, TElement) メソッド (IEnumerable(TSource), Func(TSource, TKey), Func(TSource, TElement), IEqualityComparer(TKey)) (System.Linq)

Program.cs

using System.Linq;
using System.Collections;
using System.Collections.Generic;

public static class Program
{
    private class Parameter
    {
        public float    Number  { get; set; }
        public string   Name    { get; set; }
        public int      Age     { get; set; }

        public override string ToString()
        {
            return string.Format( "Number:{0}, Name:{1}, Age:{2}", Number, Name, Age );
        }
    }

    private class GroupComparer : IEqualityComparer<float>
    {
        public bool Equals( float i_lhs, float i_rhs )
        {
            return (int)System.Math.Floor( i_lhs ) == (int)System.Math.Floor( i_rhs );
        }

        public int GetHashCode( float i_value )
        {
            int value = (int)System.Math.Floor( i_value );
            return value.GetHashCode();
        }
    }

    static void Main( string[] args )
    {
        // 人物データ
        Parameter[] data    = new Parameter[]
        {
            new Parameter() { Number = 3.64f, Name = "正一郎", Age = 35 },
            new Parameter() { Number = 3.85f, Name = "清次郎", Age = 27 },
            new Parameter() { Number = 3.72f, Name = "誠三郎", Age = 27 },
            new Parameter() { Number = 1.78f, Name = "征史郎", Age = 18 },
        };

        // 数値情報でグループ化するよ。
        GroupComparer compare = new GroupComparer();
        var groupList = data.GroupBy( param => param.Number, compare );

        // 結果発表
        System.Console.WriteLine( "data:{0}", data.Text() );

        foreach( var group in groupList )
        {
            System.Console.WriteLine( "group:{0}", System.Math.Floor( group.Key ) );
            foreach( var value in group )
            {
                System.Console.WriteLine( "\t{0}", value );
            }
        }

        // 入力待ち用
        System.Console.ReadKey();
    }

    /// <summary>
    /// 簡易的なコレクションのテキスト取得処理
    /// </summary>
    public static string Text( this IEnumerable i_source )
    {
        string text = string.Empty;
        foreach( var value in i_source )
        {
            text += string.Format( "[{0}], ", value );
        }
        return text;
    }

} // class Program

data:[Number:3.64, Name:正一郎, Age:35], [Number:3.85, Name:清次郎, Age:27], [Number:3.72, Name:誠三郎, Age:27], [Number:1.78, Name:征史郎, Age:18],
group:3
Number:3.64, Name:正一郎, Age:35
Number:3.85, Name:清次郎, Age:27
Number:3.72, Name:誠三郎, Age:27
group:1
Number:1.78, Name:征史郎, Age:18

 このとおり、大体3グループと、ほぼ1グループに分けることができました。
 このように自作の比較処理を用いてグループ化することもできます。

 こんな感じでGroupBy()を七兆回ほど使って、いろいろグループ分けしてみてください。

LINQのリンク