資料分組:使用 HashSet 或 GroupBy + SelectMany 取得各分組的前 N 筆資料

資料分組:使用 HashSet 或 GroupBy + SelectMany 取得各分組的前 N 筆資料

前言

最近遇到一個需求,要將多欄位的資料分組,再將各分組的資料排序後,取出前 N 筆資料出來。

舉例假想情境如下:

  • 有兩個隊伍:Blue Team 和 Red Team
  • 比賽項目有三種:Black Jack、Poker Hand 和 Solitaire
  • 需求:取出每一種比賽項目,每個隊伍最高分的資料

需求圖例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
public static void Main()
{
var list = GetSampleData();
}

public List<Player> GetSampleData()
{
return new List<Player>
{
new Player { PlayerId="001", PlayerName="Sam", Team="Blue Team", Game="Black Jack", Score=798 },
new Player { PlayerId="002", PlayerName="Jack", Team="Blue Team", Game="Poker Hand", Score=823 },
new Player { PlayerId="003", PlayerName="Tiffany", Team="Red Team", Game="Black Jack", Score=627 },
new Player { PlayerId="004", PlayerName="Betty", Team="Red Team", Game="Poker Hand", Score=803 },
new Player { PlayerId="005", PlayerName="Jessica", Team="Red Team", Game="Solitaire", Score=858 },
new Player { PlayerId="006", PlayerName="Mia", Team="Red Team", Game="Poker Hand", Score=943 },
new Player { PlayerId="007", PlayerName="Tom", Team="Blue Team", Game="Black Jack", Score=661 },
new Player { PlayerId="008", PlayerName="Cindy", Team="Red Team", Game="Solitaire", Score=735 },
new Player { PlayerId="009", PlayerName="Jenny", Team="Red Team", Game="Black Jack", Score=513 },
new Player { PlayerId="010", PlayerName="Ken", Team="Blue Team", Game="Solitaire", Score=672 },
new Player { PlayerId="011", PlayerName="Joey", Team="Blue Team", Game="Poker Hand", Score=957 },
new Player { PlayerId="012", PlayerName="Mary", Team="Red Team", Game="Black Jack", Score=759 },
new Player { PlayerId="013", PlayerName="John", Team="Blue Team", Game="Solitaire", Score=724 },
};
}

public class Player
{
public string PlayerId { get; set; }
public string PlayerName { get; set; }
public string Team { get; set; }
public string Game { get; set; }
public int Score { get; set; }
}

印象中,三年前回鍋軟體開發,自學 C# 時有用 LINQ 做過類似的練習,之後一直沒機會遇到這種使用情境,時間久便忘記要怎麼寫了。

想不起來 LINQ 要怎麼寫,當下先用想到的方式解決,假日把 LINQ 的寫法也研究回來,現在整理成筆記,將來就不用再花時間重做功課了。

按照時間序,總共研究出三種寫法:

  • 方法一:使用 HashSet 處理

  • 方法二:使用 LINQ - ToHashSet() 處理

  • 方法三:使用 LINQ - GroupBy() 與 SelectMany() 處理

不想看過程的話,可以直接跳到 方法三:使用 LINQ - GroupBy() 與 SelectMany() 處理


方法一:使用 HashSet 處理

這個做法用到之前在 C# 覆寫 Equals 方法,為何要覆寫 GetHashCode 方法 提過的規則,HashSet 在加入資料時,遇到重複會略過的特性。

實作的概念:

在 Player 類別覆寫 Equals() 和 GetHashCode() 方法,自訂判斷重複的檢查條件,讓 HashSet<Player> 在 Add() 時略過不要的資料即可。

將 List<Player> 先做好 Score 的降冪排序,再逐筆加進 HashSet<Player> 內,讓 HashSet 內只剩下每組 Team + Game 不重複的資料,且該筆資料是該分組最高分就完成了。

按照範例的需求,要用 Team 和 Game 屬性當做分組的條件,覆寫的方法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public class Player
{
public string PlayerId { get; set; }
public string PlayerName { get; set; }
public string Team { get; set; }
public string Game { get; set; }
public int Score { get; set; }

public override bool Equals(object obj)
{
bool result = false;

if (obj is Player)
{
var input = (Player)obj;
// 加入至 HashSet<Player> 時,相同 Team + Game 的組合會被視為重複而略過
result = (Team == input.Team && Game == input.Game);
}
return result;
}

public override int GetHashCode()
{
return new { Team, Game }.GetHashCode();
}
}

但我直覺認為不該覆寫 Player 類別,資料分組不屬於 Player 類別的工作。

我覺得可以再新增一個處理資料分組專用的 WinnerPlayer,讓他繼承 Player 所有屬性,然後覆寫 WinnerPlayer 類別的 Equals()、GetHashCode() 方法,再利用 HashSet<WinnerPlayer> 的 Add() 方法過濾資料,就不會污染 Player 類別了。

而處理 HashSet.Add() 的動作,又可以封裝到 ScoreFilter 類別內,建立 ScoreFilter 物件時,將 List<Player> 資料送進 ctor 建構式,再透過 GetWinner() 方法取得資料分組和取最高分的結果即可。

接著又想到因為外面用不到 WinnerPlayer 類別,可將 WinnerPlayer 宣告為私有類別,並封裝進 ScoreFilter 類別內。

綜合以上想法,得到的完成品如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
public static void Main()
{
var result1 = new ScoreFilter1(GetSampleData()).GetWinner(); // 取得各組最高分資料
}

// Player 類別維持原狀
public class Player
{
public string PlayerId { get; set; }
public string PlayerName { get; set; }
public string Team { get; set; }
public string Game { get; set; }
public int Score { get; set; }
}

public class ScoreFilter1
{
private List<Player> _list;

public ScoreFilter1(List<Player> list)
{
_list = list;
}

public List<Player> GetWinner()
{
if (_list == null) return null;

// Step 1: 將 Player 映射成 WinnerPlayer,並對 Score 降冪排序
var orderedlist = _list
.Select(s => new WinnerPlayer
{
PlayerId = s.PlayerId,
PlayerName = s.PlayerName,
Team = s.Team,
Game = s.Game,
Score = s.Score,
}) // 映射成 WinnerPlayer,此類別內有覆寫判斷重複的方法
.OrderByDescending(o => o.Score); // 高分的排上面 (以取得每個分組的最高分資料)

// Step 2: 利用 HashSet<T> Add() 方法的特性,過濾資料
var hashSet = new HashSet<WinnerPlayer>();
foreach (var data in orderedlist)
{
// 利用 HashSet.Add() 遇到重複的內容會略過的特性,過濾掉不要的資料
hashSet.Add(data); // 用 WinnerPlayer 類別內覆寫的方法過濾資料
}

// Step 3: 將 WinnerPlayer 轉型回 Player,並排序最終結果
return hashSet
.Cast<Player>() // 將 WinnerPlayer 轉型回 Player
.OrderBy(o => o.Game) // 排序最終結果 Gamer ASC, Score DESC
.ThenByDescending(t => t.Score)
.ToList();
}

// 封裝在 ScoreFilter 內的 WinnerPlayer 私有類別,且繼承 Player 類別的所有欄位
private class WinnerPlayer : Player
{
public override bool Equals(object obj)
{
bool result = false;

if (obj is Player)
{
var input = (Player)obj;
// 加入至 HashSet<WinnerPlayer> 時,相同 Team + Game 的組合會被視為重複而略過
result = (Team == input.Team && Game == input.Game);
}
return result;
}

public override int GetHashCode()
{
return new { Team, Game }.GetHashCode();
}
}
}

執行結果:
執行結果


方法二:使用 LINQ - ToHashSet() 處理

這是方法一的改進版,在重構方法一的過程,想到 LINQ 有 ToHashSet() 方法,這個方法可傳入 IEqualityComparer<T>,就不需要 WinnerPlayer 類別了,程式碼簡化成這樣:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
public static void Main()
{
var result2 = new ScoreFilter2(GetSampleData()).GetWinner(); // 取得各組最高分資料
}

public class ScoreFilter2
{
private List<Player> _list;

public ScoreFilter2(List<Player> list)
{
_list = list;
}

public List<Player> GetWinner()
{
if (_list == null) return null;

return _list
.OrderByDescending(o => o.Score) // 高分的排上面 (以取得每個分組的最高分資料)
.ToHashSet(new PlayerComparer()) // 利用 ToHashSet() 與自訂的 IEqualityComparer 完成資料分組並只留下各組的最高分
.OrderBy(o => o.Game) // 排序最終結果 Gamer ASC, Score DESC
.ThenByDescending(t => t.Score)
.ToList();
}

// 給 ToHashSet() 用的 IEqualityComparer
private class PlayerComparer : IEqualityComparer<Player>
{
public bool Equals(Player a, Player b)
{
return
b != null && a != null
&& a.Team == b.Team
&& a.Game == b.Game;
}

public int GetHashCode(Player obj)
{
return new { obj.Team, obj.Game }.GetHashCode();
}
}
}

相同的執行結果:
執行結果


方法三:使用 LINQ - GroupBy() 與 SelectMany() 處理

最後找到以前寫的很雜亂的筆記,重新拼湊出 LINQ 的寫法了,這次連 IEqualityComparer<T> 都省了,並增加可指定要取得各組前 N 筆的參數。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
public static void Main()
{
var result3_1 = new ScoreFilter3(GetSampleData()).GetTopN(); // 取得各組第一筆資料
var result3_2 = new ScoreFilter3(GetSampleData()).GetTopN(2); // 取得各組前 2 筆資料
var result3_3 = new ScoreFilter3(GetSampleData()).GetTopN(3); // 取得各組前 3 筆資料
}

public class ScoreFilter3
{
private List<Player> _list;

public ScoreFilter3(List<Player> list)
{
_list = list;
}

public List<Player> GetTopN(int n = 1)
{
if (_list == null) return null;

return _list
.GroupBy(g => new { g.Team, g.Game }) // 相同 Team 與 GameId 分在同組
.SelectMany(group => // 將 GroupBy 的分組資料合併
group.OrderByDescending(o => o.Score) // 每個分組做 Score 降冪排序
.Take(n) // 每組取出前 N 筆
.Select(s => new Player
{
PlayerId = s.PlayerId,
PlayerName = s.PlayerName,
Team = s.Team,
Game = s.Game,
Score = s.Score,
})
)
.OrderBy(o => o.Game) // 排序最終結果 Gamer ASC, Team ASC, Score DESC
.ThenBy(t1 => t1.Team)
.ThenByDescending(t2 => t2.Score)
.ToList();
}
}

GroupBy 與 SelectMany 對應的操作
GroupBy 與 SelectMany 對應的操作

執行結果:
執行結果

將 .Take(1) 改成 .Take(N) 就可以取出每組的前 N 筆資料,例如 .Take(2) 的結果如下:

Take(2) 執行結果

改用 LINQ 來寫,程式碼簡短、要指定每個資料分組要取出幾筆資料,也很容易。