絞り込み機能の実装の中で、ReactのStateとして管理しているカテゴリの制御にて発生した課題と、それをどのように解決したのかという事についての記事です。
絞り込み機能の要件と前提
今回制作する絞り込みの機能要件
- 絞り込み条件は3階層の構造となっている
- 1階層目の項目はAND検索
- 2,3階層目の項目はOR検索
- 1階層目には3つの項目が存在する
- 3階層目まである項目は1階層目の1つ目の項目
- 絞り込みに使用する要素は記事のカテゴリIDで、記事本体についているカテゴリIDを元に絞り込む。
- カテゴリIDに重複はない。
- urlのパラメータにカテゴリIDをつけ、その情報を元に絞り込み検索を行いたい。
- urlを元に絞り込む場合、選択した内容も反映される事(3階層目の項目もアクティブになり、その親の2階層目もアクティブになる)
制作における前提
- 絞り込み項目の内容は後から変更になるかもしれない。
- 最大3階層、AND検索、OR検索は変わらないが、2階層目までしかなかった項目が3階層になったり、その逆もありえる。
- 1,2,3階層目、それぞれの項目の数も変化するかもしれない。
絞り込み条件を選択するUIのイメージ
各カテゴリ、各記事が持っているデータと構造
カテゴリ、記事のデータはどちらもjson形式で取得した後、object形式で変数に格納されています。
カテゴリ
カテゴリ情報は以下のような構成で保持されています。
「’」シングルクォーテーションで囲われている文字はその文字列がプロパティ名となり、囲われていない文字は各カテゴリの情報が動的にはいります。
例えば『categoryId』はカテゴリのid、すなわち、その重複のない数字(そのカテゴリのID)が入ります。
{ Type:{ categoryId:{ 'title': 'string', 'id': 'number', 'isActive': 'boolean', 'child': { categoryId: { 'title': 'string', 'id': 'number', 'isActive': 'boolean', }, }, }, }, }
今回は、選択したカテゴリのボタンの色を変更する必要がありますが、そのカテゴリが選択された状態なのか、選択されていない状態なのかという情報はこちらのデータで管理します。
isActiveプロパティがそれを担っており、true/falseで管理しています。
記事
記事情報は以下のような構成で保持されています。
「’」シングルクォーテーションで囲われている文字はその文字列がプロパティ名となり、囲われていない文字は各カテゴリの情報が動的にはいります。
例えば『articleId』は記事のid、すなわち、その重複のない数字(その記事のID)が入ります。
{ articleId:{ 'id': 'number', 'title': 'string', 'link': 'string', 'descForArchive': 'string', 'categorys': { categoryId: { 'title': 'string', }, }, }, }
情報やデータはグラフで考える
この状態でいきなりプログラムを考えてもよいのですが、情報を整理するためにカテゴリデータをグラフに置き換えて考えましょう。
グラフに置き換えて考える事で余計な情報を排除することができ、理解しやすくなります。
また、抽象的になるので他の問題にも転用しやすくなる事もメリットです。
情報をグラフに置き換えるとは
情報をグラフに置き換えるとは情報どおしの関係を辺と点で結んで表す事を言います。
数学のx座標y座標のことではありません。
グラフ理論[wiki]です。
例えば以下のように、全てのスポットを効率よく周りたいので最短の経路を見つけたいといった問題が有名です。
地図で考えると余計な情報が沢山含まれており、複雑になるので、必要な情報のみを抽出し、以下のように辺と点で表してから考えます。
これと同じように、カテゴリデータをobjectのままで考えようとすると理解が難しくなるのでグラフにして考えます。
カテゴリデータのグラフ
カテゴリデータをグラフにしますが、今回必要なものはカテゴリの親子関係になります。そのカテゴリがどういった情報を持っているのかは気にする必要はありません。
ですので、必要なものは以下のになります。
{ Type:{ categoryId:{ 'child': { categoryId: { }, }, }, }, }
これをグラフにすると以下のようになります。
これは型としての構造なので、ここにデータが入ると以下のようなイメージになります。
グラフの点の部分を「ノード」といい、グラフの辺の部分を「エッジ」と呼ぶのでこちらでもそれぞれノード、エッジで話を進めます。
各ノードにはjobTypeやcategoryIdなど、データ型に適した値が入ってきます。
ちなみにこのようなグラフを無向グラフと言います。
今回の場合は情報へのアクセスが必要となってきますので、どこから各ノードにアクセスができるのかという情報も必要になってきます。
グラフに向きを与えましょう。
このようなグラフを有向グラフと言います。
見てわかるようにobjectは親から子を参照する事しかできず、子から親を参照する事ができない構造となっています。
単純な実装方法
まず、初めに単純に選択した条件で記事を絞り込む方法を考えました。
単純な絞り込み方法とは、探したい情報が見つかるまで各ノードをしらみつぶしに巡回する方法です。
objectは「.」ドット繋ぎで自分の子ノードにアクセスできるので再帰処理を繰り返し全てのノードと検索したいidを照らし合わせていきます。
発生する問題点や課題
全てのノードを巡回するような実装方法でも一応検索は可能です。
ですが、その場合、毎回毎回n個のノード分の計算を行うことになります。
もしカテゴリが10000個あれば10000回の計算が必要になるわけです。
さらに、今回の場合はそのカテゴリの親を探す必要があります。
objectのデータ構造ではグラフの向きは全て親から子に対して向いています。
子が親を知る方法はないので、子が見つかったあとに、その親を見つける処理が必要となってきます。
親を見つけるには子を見つけるまでに通った道を覚えておく必要がありますが、その数もn個必要となります。
まとめるとobject形式のカテゴリデータをそのまま使用すると以下のような課題が発生します。
- ノード探すのに時間がかかる
- 親ノードを見つける事が大変
- 後の絞り込み条件の変更に対応できないかもしれない
計算量について
ノードを探すのに時間がかかると記載していますが、具体的にどのくらい時間がかかるのでしょうか。
ここではその時間 = 計算量についてみていきます。
計算量とは処理を終了するまでに実行されるプログラムの量の事です。
この計算量を把握する事で早いのか遅いのか、アバウトですが、時間の感覚を知る事ができます。
計算量は『O』オーダーで表されます。
計算量は主に以下主流となります。
O(2^n)
O(n^2)
O(nlogn)
O(n)
O(logn)
O(1)
計算量が多い順で並んでいます。
O(2^n) が計算量が一番多く、 O(1) が計算量が一番少ない事を表しています。
例えばO(2^n)はn(ノードの数)がたったの15でも32768回の計算を行わなければいけない事を表し、O(1)はnが 32768 でもたった1回の処理で良いという事を表します。
詳しくは[初心者向け] プログラムの計算量を求める方法などを参照してください。
今回の各ノードを全て巡回する方法はO(n)になります。
解決方法
上記3つの課題を解決した方法を紹介します。
解決方法は連結リストとハッシュを組み合わせて今回行いたい事に適したデータ構造を制作しました。
連結リストを使って子から親を参照できるようにする
連結リストとは、ノードと次のノードを持つobjectを数珠つなぎにしてリストを制作して各ノードにアクセスする事ができような仕組みを持った構造です。
- 片方向リスト
- 双方向リスト
- 循環リスト
があります。
連結リストについての詳細はうさぎでもわかる配列と連結リストを参照してください。
これと似たデータ構造に配列(array)があります。配列も うさぎでもわかる配列と連結リスト で一緒に解説されていますので参考にどうぞ。
連結リストと配列はそれぞれメリットデメリット、特性があります。今回は配列よりも連結リストの方が適しているので連結リストを使用します。
連結リストの中でも片方向リストを使用します。
片方向リストをカテゴリobjectに組み合わせることでobjectの課題だった親から子しか参照する事ができない問題を解決します。
以下のようにobjectの各ノードをコピーし、連結リストのノードを制作します。
その連結リストのノードを子→親という形で連結します。
上の画像の3番のような構造をしたものが片方向連結リストになります。
現在は連結リストを制作し、ノード4の親がノード2である事がわかっただけです。
今回やりたい事はobjectの各カテゴリのisActiveにアクセスする事です。(現在は省略していますがobjectのノードにはさらにプロパティが入っています)
ですので、連結リストのノードを拡張してobjectのノードと紐づけます。
これで連結リストのノードを介してobjectの子ノードから親ノードへアクセスする事ができるようになりました。
このような連結を全てのノードで行います。
ですので、連結リストのノードの分、O(n)だけメモリを消費する事に注意です。
連結リストのノードをハッシュに登録する
ハッシュ、ハッシュマップとは配列とハッシュ関数を用いて、key:valueの形でデータを保存し、O(1)でデータを文字列にて参照できるようにしたデータ構造の事です。
配列は通常、数字を添え字としてデータを格納します。
array[3] = 40;
上記は配列の3番目に40を格納しました。そして、array[3]とする事で保存した値を取得することができます。
※アドレスは適当です。
配列はメモリ上に連続してデータが並んでおり、データの位置(何番目)はO(1)で見つける事が可能です。
配列はO(1)でデータにアクセスできるのでとても便利なデータ構造ですが、添え字が数字しか使用する事ができません。
以下のような書き方はできないと言いう事です。
array['string'] = 100
この書き方を可能にし、文字列で検索ができるようにしたものがハッシュやハッシュマップと言われるものです。(ハッシュテーブルと言ったりもします)
これを実現するために、添え字の文字列をハッシュ関数に通してハッシュ化し、ユニークな数字に置き換えて管理します。
数字が衝突した時など、いろいろありますが、使用する上では仕組みについては深く知る必要はないので、説明はここまでにします。
肝心なのはハッシュマップをJava Scriptではどのようにすれば使用できるのか。ですが、とても簡単です。
Java Scriptでのハッシュマップはobjectになります。
言語によって異なりますが、Java Scriptではobjectがハッシュマップの役割を果たします。
ここからは制作したハッシュマップに連結リストを登録するイメージを見ていきます。
以下の画像が連結リストとハッシュマップをつなげた際のデータ構造のイメージです。
ハッシュマップに登録しておくことでO(1)でノードを見つける事ができ、そこからO(1)でobjectの検索したいノードにアクセスする事が可能です。
親を知りたければ、連結リストから親をたどります。
このようなデータ構造を組めば、後の仕様変更で階層がいくら深くなろうが、カテゴリがいくら増えようが、O(1)で対象にアクセスし、全ての親を見つける事が可能です。
コードに落とし込む
上記で説明した構造をコードに落とし込みます。
イメージで理解する事は難しくありませんが、あの構造をコード上で表現するとなると理解が難しくなります。
特に、Java Scriptやプログラムの基礎を理解していないと難しく感じる部分があります。
その場合は最後に記載してある「ここで使用するプログラミングの知識」を参照してから考えるといいかもしれません。
連結リストのnodeを制作する
/* * ノード */ class Node { constructor(value, _prev = null, raw = null) { this.value = value; this.raw = raw; this._prev = _prev; } /** * 前のnode(親)をセットする * @param {object} node - セットするnode */ setPrev(node) { this._prev = node; } }
カテゴリデータを元にハッシュテーブルと連結リストを作る
/** * 検索indexの制作 */ class SearchIndex { constructor() { this.hashMap = {}; } /** * indexを制作する処理 * @param {object} data - 制作元となるデータ */ buildIndex(data, prev = null) { let keys = Object.keys(data); let currentNode = null; keys.map(key => { let item = data[key]; if(typeof(item) == 'object') { // 連結リストを制作してprevをセット currentNode = this.createNode(prev, key, item); if(prev != 'child') { // ハッシュマップに登録 this.hashMap[key] = currentNode; }; this.buildIndex(item, currentNode); } }); return; } /** * nodeを作る処理 * @param {object} prev - 親node * @param {object} node - カレントnode * @param {object} raw - つなぎたい元データ */ createNode(prev, node, raw) { let currentNode = null; if(prev != null) { currentNode = new Node(node); currentNode.setPrev(prev); currentNode.raw = raw; } else { currentNode = new Node(node); currentNode.raw = raw; } return currentNode; } }
コード全体
/* * ノード */ class Node { constructor(value, _prev = null, raw = null) { this.value = value; this.raw = raw; this._prev = _prev; } /** * 前のnodeをセットする * @param {object} node - セットするnode */ setPrev(node) { this._prev = node; } } /** * 検索indexの制作 */ class SearchIndex { constructor() { this.hashMap = {}; } /** * indexを制作する処理 * @param {object} data - 制作元となるデータ */ buildIndex(data, prev = null) { let keys = Object.keys(data); let currentNode = null; keys.map(key => { let item = data[key]; if(typeof(item) == 'object') { // 連結リストを制作してprevをセット currentNode = this.createNode(prev, key, item); if(prev != 'child') { // ハッシュマップに登録 this.hashMap[key] = currentNode; }; this.buildIndex(item, currentNode); } }); return; } /** * nodeを作る処理 * @param {object} prev - 親node * @param {object} node - カレントnode * @param {object} raw - つなぎたい元データ */ createNode(prev, node, raw) { let currentNode = null; if(prev != null) { currentNode = new Node(node); currentNode.setPrev(prev); currentNode.raw = raw; } else { currentNode = new Node(node); currentNode.raw = raw; } return currentNode; } } export default SearchIndex;
ここで使用するプログラミングの知識
名前 | 参考記事 |
Java Scriptのデータ構造とデータ型 | データ構造とデータ型[MDN] |
値渡しと参照渡し | 値渡しと参照渡しの違いを理解する |
class構文 | クラス[MDN] |
Object | Object[MDN] |
配列/連結リスト | うさぎでもわかる配列と連結リスト |
再帰 | 再帰関数を学ぶと、どんな世界が広がるか |
上記で参考になる記事を紹介していますが、私自身はRecursionという教材を通して学習しました。
有料になりますが、スキルは必ず付きますのでもしお金に余裕があればおすすめのサービスです。