

よくある、「ポイント上位X件」をLINQで実現しようとしています。

[code]

    class Member {
        public string Name { get; set; }
        public int Point { get; set; }
    }
    
[/code]

のようなインスタンス配列を普通に、

[code]

    var SelectedMembers = AllMembers
        .OrderByDescending(mem => mem.Point)
        .Take(100);
    
[/code]

とかすると、同一ポイントの人が多数いる場合に正しく抽出できないように思います。  
これを、よくあるポイント順  
1.AAA  
2.BBB  
2.CCC  
4.DDD  
4.EEE  
4.FFF  
のように、指定人数以上になっても抽出し、同一ポイントを加味してグループリスト化するにはどうしたらよいでしょうか？


CC BY-SA 3.0
2015-04-30T23:33:53.453
9717
2016-03-01T17:31:39.850
2015-05-01T02:57:21.610
3516
8912
question
c# linq
LINQで得点上位X件以上を取得し、順位をつけたい


その順位は「自分より上に何人いるか+1」と一致しますから、順位ごとにそれを調べることで可能です。

[code]

    var ranking = AllMembers
        .OrderByDescending(_ => _.Point)
        .GroupBy(_ => _.Point)
        .Select(group => new
            {
                Rank = AllMembers.Count(_ => _.Point > group.Key) + 1,
                Members = group
            });
    
    // 4位以上を抽出するなら ranking.Where(_ => _.Rank <= 4)
    
[/code]

上位から順に人数を足しこんでいけば同順位を数えるだけで済むのですが、これをforeachとか使わずに標準のLINQでやるのは面倒な気がします。

例えばRxやIxの `Scan()` を使うとこういう風に書けます。

[code]

    var ranking = list
        .OrderByDescending(_ => _.Point)
        .GroupBy(_ => _.Point)
        .Scan(new
            {
                Rank = 1,
                Members = new List<Member>()
            },
            (prev, group) => new
                {
                    Rank = prev.Rank + prev.Members.Count(),
                    Members = group.ToList()
                });
    
[/code]


CC BY-SA 3.0
2015-05-01T02:58:57.413
9726
2015-05-01T03:08:20.203
2015-05-01T03:08:20.203
8000
8000
9717
answer


Pointが上位の数をそのまま数えて+1してものが、そのPointの順位になるので、

[code]

    var q1 = AllMembers.OrderByDescending(m => m.Point).Select((m) =>
        new
        {
            Point = m.Point,
            Name = m.Name,
            //Pointが上位の数をそのまま数えれば順位は調べられる
            Rank = AllMembers.Count(m2 => (m2.Point > m.Point)) + 1
        }).Where(ano => ano.Rank<=4);
    
    foreach (var ano in q1)
    {
        Console.WriteLine(string.Format("Rank={0}\tPoint={1}\tName={2}", ano.Rank, ano.Point, ano.Name));
    }
    
[/code]

または、重複を考慮せずにPointを降順に並べてN番目のPointがN位の点数になるので、先に点を調べておくことで計算量を少し減らしてみる。

[code]

    var po= AllMembers.OrderByDescending(m2=>m2.Point).Skip(4-1).FirstOrDefault().Point;
    var q2 = AllMembers.Where(m => m.Point >= po).OrderByDescending(m => m.Point).Select((m) =>
        new
        {
            Point = m.Point,
            Name = m.Name,
            Rank = AllMembers.Count(m2 => (m2.Point > m.Point)) + 1
        });
    
[/code]

とすることも。


CC BY-SA 3.0
2015-05-01T03:16:45.213
9727
2015-05-01T03:16:45.213
4926
9717
answer


LINQのみでやるのは状態の持ち方に無理があるか、計算量が増大する場面なので普通に`foreach`を使った方が良いかと思います。

[code]

    int rank = 0, count = 0, prev = -1; // ←Pointとしてあり得ない値を初期値にする
    foreach (var m in AllMembers.OrderByDescending(_ => _.Point))
    {
        count++;
        if (m.Point != prev)
        {
            if (count > 100)
            {
                break;
            }
            rank = count;
        }
        prev = m.Point;
    
        Console.WriteLine("{0,-3} {1,-3} {2} {3}", count, rank, m.Name, m.Point);
    }
    
[/code]

もっとLINQ部分を増やしたいのであればインデックスを受け取るオーバーロードで

[code]

    int rank = 0, prev = -1;
    AllMembers.OrderByDescending(_ => _.Point)
                .Select((m, i) => new
                        {
                            Rank = prev == (prev = m.Point) ? rank : (rank = i + 1),
                            Value = m
                        })
                .Where(_ => _.Rank <= 100);
    
[/code]

とも書けます。

なお相手先が`EntityFramework`などのORMの場合は通信量を抑えるために

[code]

    var threshold = AllMembers.OrderByDescending(_ => _.Point)
                                .Take(100)
                                .Min(_ => (int?)_.Point);
    AllMembers.Where(_ => _.Point >= threshold);
    
[/code]

のような方針が良いのではないかと思います。


CC BY-SA 3.0
2015-05-01T12:11:41.210
9748
2015-05-01T12:11:41.210
5750
9717
answer

[code]

    var members = new Member[]
    {
        new Member() { Name="H", Point=1 },
        new Member() { Name="G", Point=1 },
        new Member() { Name="F", Point=2 },
        new Member() { Name="E", Point=2 },
        new Member() { Name="D", Point=3 },
        new Member() { Name="C", Point=3 },
        new Member() { Name="B", Point=4 },
        new Member() { Name="A", Point=4 },
    };
    
    var threshold = 5;
    
    var result = members
        .OrderByDescending(x => x.Point)
        .Select((Value, Index) => new { Value, Index })
        .GroupBy(x => x.Value.Point)
        .Select(x => new { Rank = x.First().Index + 1, Items = x.Select(y => y.Value) })
        .Where(x => x.Rank <= threshold);
    
    foreach (var group in result)
    {
        Console.WriteLine(group.Rank.ToString());
    
        foreach (var item in group.Items.OrderBy(x => x.Name))
        {
            Console.WriteLine(item.Name + item.Point);
        }
    }
    
    Console.ReadLine();
    
[/code]


CC BY-SA 3.0
2015-05-12T13:03:34.667
10080
2015-05-12T13:03:34.667
9700
9717
answer


LINQを2回に分けて、TakeWhile を使えばできます。

[code]

    var AllMembers = new[] {
        new { Name = "AAA", Point = 1 },
        new { Name = "BBB", Point = 2 },
        new { Name = "CCC", Point = 2 },
        new { Name = "DDD", Point = 4 },
        new { Name = "EEE", Point = 4 },
        new { Name = "FFF", Point = 4 }
    };
    
    var SortedMembers = 
        AllMembers.OrderByDescending(m => m.Point).ToArray();
    
    // 指定人数まで取り出す
    // （指定人数に達しても同一ポイントのメンバーがいるならそれも取り出す）
    var SelectedMembers = 
        SortedMembers.TakeWhile((m, i) => i < 4 || m.Point == SortedMembers[i - 1].Point);
    
    Console.WriteLine(string.Join(Environment.NewLine, SelectedMembers));
    
[/code]


CC BY-SA 3.0
2016-03-01T17:31:39.850
22713
2016-03-01T17:31:39.850
14334
9717
answer