複数のsortされたリストをマージする

複数のsortされたリストをマージする

Intro

業務の中で、複数のDataFrameの相違を得る必要があった。

TL; DR

  • 複数のdfの相違を見ることが目的
  • そのために、複数のsortされたリストをマージするアルゴリズムを作成した

Question

pythonのpandasにおいて、以下のようなDataFrameを差分比較して、その次に示す結果を得たい。

Input

Input

df0

content_keycontent_idcontent_name
S010P001SORT
S020P002
AAAAQ001
S025P002JOIN
AAAAQ001
S030P003AGGREGATE

df1

content_keycontent_idcontent_name
S010P001SORT
S020P002
AAAAQ001
AAAAQ001
S030P003AGGREGATE
S035P004JOIN

df2

content_keycontent_idcontent_name
AABBQ002
S010P001SORT
S015P012
S020P032
AAAAQ001
S026P022JOIN
AAAAQ001
S030P003AGGREGATE

Results

Results

df_merge0

content_keycontent_idcontent_name
AABB-
S010P001SORT
S015-
S020P002
AAAAQ001
S025P002JOIN
S026--
AAAAQ001
S030P003AGGREGATE
S035--

df_merge1

content_keycontent_idcontent_name
AABB-
S010P001SORT
S015-
S020P002
AAAAQ001
S025--
S026--
AAAAQ001
S030P003AGGREGATE
S035P004JOIN

df_merge2

content_keycontent_idcontent_name
AABBQ002
S010P001SORT
S015P012
S020P032
AAAAQ001
S025--
S026P022JOIN
AAAAQ001
S030P003AGGREGATE
S035--

merge_sorted_listsとは

上記のQuestionの結果を得るためには、df0からdf2の微妙に異なるcontent_keyから得られるリストは以下の条件を満たす必要がある。
(後述するが、単純なOuter Joinでは理想の結果を得られない)

条件1: 元の任意のlistはmerged_listから部分列を取り除いて得られる

部分列を取り除いて得られる

「AがBから部分列を取り除いて得られる」とは、かみ砕いていえば
「Aの要素が順序を保ちながらBに含まれている」ということだ。

例えば、A=[1, 2, 5]B=[1, 2, 3, 4, 5]の場合、
[3, 4]を取り除いた[1, 2, 5]はAと一致する。
同様に[2, 3, 5][5]もBから部分列を取り除いて得られる。

これだけが条件なら、そのまま連結してしまうのが早い。

1
2
3
4
5
6
7
list_df0 = ['S010', 'S020', 'AAAA', 'S025', 'AAAA', 'S030']
list_df1 = ['S010', 'S020', 'AAAA', 'AAAA', 'S030', 'S035']
list_df2 = ['AABB', 'S010', 'S015', 'S020', 'AAAA', 'S026', 'AAAA', 'S030']

list_merged = list_df0 + list_df1 + list_df2
print(list_merged)
# ['S010', 'S020', 'AAAA', 'S025', 'AAAA', 'S030', 'S010', 'S020', 'AAAA', 'AAAA', 'S030', 'S035', 'AABB', 'S010', 'S015', 'S020', 'AAAA', 'S026', 'AAAA', 'S030']

ただ、これは各dfの相違を見るという目的を満たさない。
df0とdf1はcontent_keyで見ると、S025とS035が異なるだけだからだ。
つまり、これらのcontent_keyをmergeしたlist_df01は下記のようになってほしい。

1
2
list_df01 = ['S010', 'S020', 'AAAA', 'S025', 'AAAA', 'S030', 'S035']
# 01 , 01 , 01 , 0 , 01 , 01 , 1

これをdf0とdf1から具体的に人間が作成するのは簡単だが、
抽象的なアルゴリズムを作成するとなると、難しい。
(というか、調べても出てこなかった。そんなに需要ない話か?)

少しあいまいだが、条件1に加えて、以下を条件としよう。

条件2: 既存のmerged_listに新たにlistをmergeする場合、一致する部分はできる限り共有する (ただし、順序関係は保つ)

一般的なmergeとの比較

一般にもlistをmergeする方法はある。それらとの比較をしてみよう。

Extend

単純にlistをつなげていく方法。
これは明らかに条件1を満たすが、一切部分を共有しておらず、条件2を満たすとはいえない。

1
2
3
list_df01_extend = list_df0 + list_df1
print(list_df01_extend)
# ['S010', 'S020', 'AAAA', 'S025', 'AAAA', 'S030', 'S010', 'S020', 'AAAA', 'AAAA', 'S030', 'S035']

outer join

dbでよく使われるouter joinを使う方法。
そもそもOuter Joinは、df全体をmergeしつつ、その相違を見るという観点では最も想定される方法だ。
実際にやってみよう。

1
2
df_tmp = pd.merge(df0, df1, on='content_key', how='outer', suffixes=('_0', '_1'))
pyperclip.copy(df_tmp.to_markdown())
content_keycontent_id_0content_name_0content_id_1content_name_1
0AAAAQ001Q001
1AAAAQ001Q001
2AAAAQ001Q001
3AAAAQ001Q001
4S010P001SORTP001SORT
5S020P002P002
6S025P002JOINnannan
7S030P003AGGREGATEP003AGGREGATE
8S035nannanP004JOIN

順序がめちゃくちゃだ!!

条件1を満たしていなかった。
当初の試行錯誤では、この外部連結で元の順序関係をどうにか保てないかと考えた。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# ええ、ChatGPTを使いましたとも

df0['index'] = df0.index

# outer joinを実行
merged_df = pd.merge(df0, df1, on='content_key', how='outer', suffixes=('_df0', '_df1'))

# 元のインデックス列で並べ替え
merged_df = merged_df.sort_values(by='index').reset_index(drop=True)

# 不要な列を削除
merged_df = merged_df.drop(columns=['index'])

pyperclip.copy(merged_df.to_markdown())
content_keycontent_id_df0content_name_df0content_id_df1content_name_df1
0S010P001SORTP001SORT
1S020P002P002
2AAAAQ001Q001
3AAAAQ001Q001
4S025P002JOINnannan
5AAAAQ001Q001
6AAAAQ001Q001
7S030P003AGGREGATEP003AGGREGATE
8S035nannanP004JOIN

AAAAが重複しているが、まあまあ悪くない。

しかし、ここでdf0とdf2で外部連結すると下記の通りになる。

content_keycontent_id_df0content_name_df0content_id_df1content_name_df1
0S010P001SORTP001SORT
1S020P002P032
2AAAAQ001Q001
3AAAAQ001Q001
4S025P002JOINnannan
5AAAAQ001Q001
6AAAAQ001Q001
7S030P003AGGREGATEP003AGGREGATE
8AABBnannanQ002
9S015nannanP012
10S026nannanP022JOIN

先頭に来るべきAABBや、途中で挿入されるべきS015やS026が、順序関係を保てない。
これはsortの基準としてdf0のindexを使っているためだ。
df0に含まれないcontent_keyが挿入されると、それらのindexはnanになり、後ろに回される。
df2のindexをsortのkeyとして併用しようとしても、df0からのindexではnanになるため、結局後ろに回される。
df2のindexをsortのkeyとしても、df2に含まれないcontent_keyに対応するindexはnanになる。状況は変わらない。

1
2
3
4
5
6
7
8
9
10
11
12
13
df0['index'] = df0.index
df2['index'] = df2.index

# outer joinを実行
merged_df = pd.merge(df0, df2, on='content_key', how='outer', suffixes=('_df0', '_df2'))

# 元のインデックス列で並べ替え
merged_df = merged_df.sort_values(by=['index_df0', 'index_df2']).reset_index(drop=True)

# 不要な列を削除
# merged_df = merged_df.drop(columns=['index_df0', 'index_df2'])

pyperclip.copy(merged_df.to_markdown())
content_keycontent_id_df0content_name_df0index_df0content_id_df2content_name_df2index_df2
0S010P001SORT0P001SORT1
1S020P0021P0323
2AAAAQ0012Q0014
3AAAAQ0012Q0016
4S025P002JOIN3nannannan
5AAAAQ0014Q0014
6AAAAQ0014Q0016
7S030P003AGGREGATE5P003AGGREGATE7
8AABBnannannanQ0020
9S015nannannanP0122
10S026nannannanP022JOIN5

すべてのcontent_keyを順序関係を保ちながら含むlistがあれば問題は解決する。
それこそまさに、merge_sorted_listsである。

Tweet

df0のindexを基準にしつつ、その値がnanの場合はdf2基準として期待されるsortを行う、という方法も考えられる。
ただ、それはPandasのDataFrameの既存の機能の範疇を超えている。

また、AAAAの重複も実は根深い問題なのだ。

merge_sorted_listsの実装

ChatGPTにプロンプトを投げたのち、手動で修正することで以下を得た。

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
def _merge_sorted_two_lists(list1: list, list2: list) -> list:
# マスターリストを格納するためのリスト
master_list = []
i, j = 0, 0

# 両方のリストの要素を比較しながらマスターリストを作成
while i < len(list1) and j < len(list2):
if list1[i] == list2[j]:
if len(master_list) == 0 or list1[i] != master_list[-1]:
master_list.append(list1[i])
i += 1
j += 1
elif list1[i] in list2[j:]:
if len(master_list) == 0 or list2[j] != master_list[-1]:
master_list.append(list2[j])
j += 1
else:
if len(master_list) == 0 or list1[i] != master_list[-1]:
master_list.append(list1[i])
i += 1

# 残りの要素を追加
while i < len(list1):
if len(master_list) == 0 or list1[i] != master_list[-1]:
master_list.append(list1[i])
i += 1

while j < len(list2):
if len(master_list) == 0 or list2[j] != master_list[-1]:
master_list.append(list2[j])
j += 1

return master_list


def merge_sorted_lists(input_lists: list) -> list:
master_list = input_lists[0]
for list_now in input_lists[1:]:
master_list = _merge_sorted_two_lists(master_list, list_now)
return master_list

Prompt for ChatGPT

[1,2,3,4,7,10,9,11,5]と
[1,2,3,4,6,10,11,20]から
[1,2,3,4,6,7,10,9,11,5,20]を得るようにlistを並び順を考慮してmergeするpythonのscriptが欲しい

list1とlist2の比較は数値の大小では比較しない
list1とlist2を混ぜたmaster listがあると仮定して、行う

master listがあると仮定して、master listを見つけたい

まとめ

merge_sorted_listsは、複数のsortされたリストをマージする際に、それらの相違を見つけるためのアルゴリズムである。

コメント