diff --git a/981_time_based_key-value_store/step1.py b/981_time_based_key-value_store/step1.py new file mode 100644 index 0000000..90c4ae4 --- /dev/null +++ b/981_time_based_key-value_store/step1.py @@ -0,0 +1,103 @@ +# 実装前ノート +""" +アルゴリズムの選択肢 +- 選択にあたっては、set, get どちらの比重が高いか考える必要がある。要素がたくさんあって、get が多いなら、values はソートしてあるほうがよい。 +- setでappend, get で線形探索 + - set O(1), get O(n) +- setでbisect.insort, get で binary_search + - set O(n), get O(long_n) + - insort は、挿入位置の探索に O(lon_n)、挿入に O(n) かかる + +変数名の選択肢 +- key_to_values, key_to_value_pairs, key_to_timestamped_values + - 問題文から、key_to_values を使った。具体的な状況が与えらればもっとよいのを考える必要がある。 +- `for v, timestamp_prev in self.key_to_values[key]:` の `v` はループ内なので1文字でよいかと判断した。 + - value にすると、ループの外側の value とかぶる。 + +データ構造 +- key: string, value: List となる dictionary で定義する。 import が必要だが defaultdict() でも書ける。 +- set, get の他に、 update がある場合は tuple の他に list も選択肢に入る + - 要素を削除して再度挿入するよりも、上書きして更新するほうが早いため + - 今回は set, get のみ実装すればよいので tuple をえらんだ。 + +注意が必要な仕様 +- すべての timestamp_prev について、timestamp < time_stamp_prev だった場合は、該当なしとなる。 + - value を "" で初期化することで対応。 +""" + +# timestamp によるソートを行わない実装。leetcode の環境では TLE。 +class TimeMap: + + def __init__(self): + self.key_to_values = dict() + + def set(self, key: str, value: str, timestamp: int) -> None: + if key not in self.key_to_values: + self.key_to_values[key] = list() + self.key_to_values[key].append((value, timestamp)) + + def get(self, key: str, timestamp: int) -> str: + if key not in self.key_to_values: + return "" + value = "" + max_time_stamp_so_far = 0 # + for v, timestamp_prev in self.key_to_values[key]: + if timestamp_prev <= timestamp and max_time_stamp_so_far < timestamp_prev: + value = v + max_time_stamp_so_far = timestamp_prev + return value + +# 考察+実装20分、テストの脳内実行に6分。 + +# Your TimeMap object will be instantiated and called as such: +# obj = TimeMap() +# obj.set(key,value,timestamp) +# param_2 = obj.get(key,timestamp) + +# 改善点 +""" +線形探索内部の条件判定の `if timestamp_prev <= timestamp and max_time_stamp_so_far < timestamp_prev:` は次のようにもかける +- `if max_time_stamp_so_far < timestamp_prev <= timestamp_prev:` 区間がわかりやすい + +`max_time_stamp_so_far = 0` の初期値はコメントを付けたほうがいいい気がするが、よいのが思いつかない +- `max_time_stamp_so_far = 0 # Since there is no valid timestamp_prev, max_time_stamp_so_far remains zero.` これだと、挙動の説明をしているが、なぜ 0 が入っているかの理由にはなっていない。 +- 値は、timestamp の取りうる値よりも小さければなんでもよい。0 のほかに -1 も選択肢。比較に用いるので None はだめ。 + - `max_time_stamp_so_far = 0 # It can be any value as long as it is smaller than the minimum possible value of timestamp` 長いけどこれはよさそう。 +""" + +# timestamp で values をソートする実装。 +from collections import defaultdict +class TimeMap: + + def __init__(self): + self.key_to_values = defaultdict(list) + + def set(self, key: str, value: str, timestamp: int) -> None: + bisect.insort(self.key_to_values[key], (timestamp, value), key=lambda x : x[0]) + return None + + def get(self, key: str, timestamp: int) -> str: + if key not in self.key_to_values: + return "" + insert_place = bisect.bisect_right(self.key_to_values[key], timestamp, key=lambda x : x[0]) + if insert_place == 0: + return "" + return self.key_to_values[key][insert_place - 1][1] + +# 実装20分、脳内ラン9分 + +# 実装中ノート +""" +コーナーケースの扱い +- timestamp_prev == timestamp となるときの挙動 + - timestamp_prev <= timestamp となる最大の timestamp_prev を見つける問題なので、bisect_right を使うと簡単になる。 + - テストケースを脳内実行して、insert_place == 0 のケアが必要なことに気づく。 +""" + +# 改善点 +""" +set(): +- 関数の型ヒントで None を返すと明示してあるので、 `return None` は書かない方に統一するのが良さそう。 +- tuple の初期化 + - (a, b) でやる。tuple([a, b]) は冗長 +""" diff --git a/981_time_based_key-value_store/step2.py b/981_time_based_key-value_store/step2.py new file mode 100644 index 0000000..607a4d3 --- /dev/null +++ b/981_time_based_key-value_store/step2.py @@ -0,0 +1,44 @@ +# bisect_left, bisect_right でコーナーケースの扱いに違いがでるため、両方で実装してみた。 +class TimeMaOp: + + def __init__(self): + self.key_to_values = dict() + + def set(self, key: str, value: str, timestamp: int) -> None: + if key not in self.key_to_values: + self.key_to_values[key] = list() + bisect.insort(self.key_to_values[key], (value, timestamp), key=lambda x: x[1]) + + # bisect_right を使った get() の実装 + def get_right(self, key: str, timestamp: int) -> str: + if key not in self.key_to_values: + return "" + target_index = (-1) + bisect_right(self.key_to_values[key], timestamp, key=lambda x: x[1]) + if target_index < 0: + return "" + return self.key_to_values[key][target_index][0] + + # bisect_left を使った get() の実装。 + def get_left(self, key: str, timestamp: int) -> str: + insert_place = bisect_left(self.key_to_values[key], timestamp, key=lambda x: x[1]) + if insert_place == len(self.key_to_values[key]): + return self.key_to_values[key][-1][0] + if self.key_to_values[key][insert_place][1] == timestamp: + return self.key_to_values[key][insert_place][0] + if insert_place == 0: + return "" + return self.key_to_values[key][insert_place - 1][0] + +# step1 からの変更点 +""" +get_right: +- インデックスの調整 + - `insert_place - 1` に `target_index` という名前をつけた。`-1`によるインデックスの調整の意図が伝わりやすくなるかと思ったため。 + - bisect_right の項が長いので、 `(-1)` を式の最初に持ってきた。 + +get_left: +- bisect_left を使った実装はこれ以上きれいな記述は思いつかなかった。 + - return 文中のインデックスの指定が3パターンあり、条件文も3つあるのでのでいかにもミスしやすそう + - 3つの条件文の並びは順序を崩してはいけない。 + - IndexError のケア → timestamp_prev == timestamp のケア → timestamp == 0 のケア→ これらがすべてに当てはまらないなら、bisect_left で見つけた一つ左の要素が対象要素のインデックス +""" diff --git a/981_time_based_key-value_store/step3.py b/981_time_based_key-value_store/step3.py new file mode 100644 index 0000000..96366e5 --- /dev/null +++ b/981_time_based_key-value_store/step3.py @@ -0,0 +1,29 @@ +from collections import defaultdict +class TimeMap: + + def __init__(self): + self.key_to_timestamped_value = defaultdict(list) + + def set(self, key: str, value: str, timestamp: int) -> None: + bisect.insort(self.key_to_timestamped_value[key], (timestamp, value), key=lambda x: x[0]) + + def get(self, key: str, timestamp: int) -> str: + if key not in self.key_to_timestamped_value: + return "" + target_index = -1 + bisect_right(self.key_to_timestamped_value[key], timestamp, key=lambda x: x[0]) + if target_index < 0: + return "" + return self.key_to_timestamped_value[key][target_index][1] + +# Your TimeMap object will be instantiated and called as such: +# obj = TimeMap() +# obj.set(key,value,timestamp) +# param_2 = obj.get(key,timestamp) + +# 4'34 -> 3'48 -> 3'05 + +""" 備考 +timestamped_value の語順に合わせて、 (timestamp, value) の順序でタプルを構築している。関数の定義も、`set(self, timestamp: int, valu: str):` とするのが自然かも。 + +target_index の取りうる値の範囲は `-1 <= target_index < len(self.key_to_timestamped_value[key])` なので、 `if target_index < 0` は、`if target_index == -1` でもよいかも。 +""" diff --git a/981_time_based_key-value_store/step4_binary_search_tree.py b/981_time_based_key-value_store/step4_binary_search_tree.py new file mode 100644 index 0000000..3aa64ea --- /dev/null +++ b/981_time_based_key-value_store/step4_binary_search_tree.py @@ -0,0 +1,92 @@ +# timestamp をキーにした二分探索木で、timestamped_values を実装する。(時間計算量の最悪状態に近いため leetcode 環境ではTLE) +#+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ +# Constraints: +# 1. 1 <= key.length, value.length <= 100 +# 2. key and value consist of lowercase English letters and digits. +# 3. 1 <= timestamp <= 10^7 +# 4. All the timestamps timestamp of set are strictly increasing. +# 5. At most 2 * 10^5 calls will be made to set and get. ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + +class Node: + def __init__(self, key: int=None, value: str=None, left=None, right=None): # 型のtypo + self.key = key + self.value = value + self.left = left + self.right = right + + def __str__(self): # デバッグ用 + return f"{self.key}, {self.value}, (l: {self.left}), (r:{self.right})" + +class TimeMap: + def __init__(self): + self.key_to_timestamped_value = dict() + + def set(self, key: str, value: str, timestamp: int) -> None: + if key not in self.key_to_timestamped_value: + self.key_to_timestamped_value[key] = Node(timestamp, value) + return + root = self.key_to_timestamped_value[key] + while root: + if root.key < timestamp and not root.right: + root.right = Node(timestamp, value) + return + elif root.key >= timestamp and not root.left: # key が重複する Node は 左のサブツリーに追加する。 + root.left = Node(timestamp, value) + return + if root.key < timestamp: + root = root.right + elif root.key >= timestamp: + root = root.left + raise Exception("Something wrong with set() method: there may be duplicate timestamps.") + + def get(self, key: str, timestamp: int) -> str: + if key not in self.key_to_timestamped_value: + return "" + value = "" + root = self.key_to_timestamped_value[key] + while root: + if root.key <= timestamp: + value = root.value + root = root.right + else: + root = root.left + return value + +""" +設計の選択:binary search tree を tree として実装するか、array で実装するか +- array による binary search tree の実装:parent のインデックスを `i` として、左の子を インデックス `2i + 1`, 右の子を インデックス `2i + 2` の要素とすればいい。 +array で実装する場合の計算コストの見積もり: +今回は、配列に 10^14 要素分の領域が必要になる。(1 <= timestamp <= 10^7 であり、かつ、key となる timestamp の値は増加するいっぽうなので 「要素数 = 深さ」となる。空間計算量として最悪の状況で配列はスカスカ) + - python の int 型のオブジェクトの大きさは、64ビット環境で 28byte~ : https://stackoverflow.com/questions/10365624/sys-getsizeofint-returns-an-unreasonably-large-value + - `sys.getsizeof(Node())` は 48 byte なので実際に必要するメモリは int 型を格納するときの2倍ぐらい必要になる。 + - 実消費メモリの概算 + - 概算 (int型だけを入れる想定):28byte * 10^14 = 2.8B * (1000)^5 ≒ 3B * (2^10)^5 + - (2^32bit = 4GiB の数倍を遥かに超えているので手元のマシンのメモリには収まらない大きさ。) + - 実際に配列を作ってみる:`[0 for _ in range(10**9)]` で、`8806512088 byte` ≒ 9 GB + - これに 10^5 をかけて、9GB * 10^5 = 9 * 10^9 * 10^5 = 9 * 10^14。概算の1/3倍ぐらい + - 900TB + - 参考 `>>> sys.getsizeof([0 for _ in range(10**10)]) # 82547110552 byte` + - いずれにしてもメモリに収まりきらない + - 実時間 + - 配列の初期化`cProfile.run('[0 for _ in range(10**8)]')` で約3秒かかり線形増加するので初期化だけで 3 * 10^6 秒かかる。 +以上より、今回は array で binary search tree を構築するのは厳しそう。 + +計算量の見積もり: +- set(), get() + - いずれの操作も木がバランスされているとき O(log_n)、木が偏っているとき O(n) + - 今回は問題の制約より、キーが単調増加するので右の木が伸び続ける。追加位置を見つけるのも、対象をサーチするのも線形リストを辿っているのとおなじになる。 + +二分探索の delete (未実装): +削除する対象を見つけたら、左のサブツリーの最大値 (or 右のサブツリーの最小値) で上書きして、上書きに使った葉のノードを削除する。 +参照:https://www.geeksforgeeks.org/deletion-in-binary-search-tree/ + +時刻の制約と binary search tree の設計について: +timestamp に重複がなく、値が単調増加するというのは時刻のデータであれば妥当な仮定思われる。他方、時間の粒度によっては同じタイムスタンプがついてもおかしくない。 +今回の問題は左のサブtree に入れると探索範囲がきれいに二分できるのでそうした。(timestamp_prev <= timestamp) + +時刻関連のリンク集: +Pythonの時刻を扱うモジュール:https://docs.python.org/ja/3/library/time.html +Unix Time 1970年1月1日からの経過時間を固定長2進数で表現する:https://en.wikipedia.org/wiki/Unix_time +2038年には32ビットがオーバーフローする:https://en.wikipedia.org/wiki/Year_2038_problem +""" diff --git a/981_time_based_key-value_store/znote.md b/981_time_based_key-value_store/znote.md new file mode 100644 index 0000000..c596613 --- /dev/null +++ b/981_time_based_key-value_store/znote.md @@ -0,0 +1,71 @@ +# bisect_left, bisect_right の実装 (cpython) +- 二分探索関連 https://discord.com/channels/1084280443945353267/1235971495696662578/1237425063242891315 +- bisect_left/right: 区間の取り方はどちらも[0, len(s)), while (lo < hi), 脱出時の lo を返す + - == のときに、左区間を選択するのが bisect_left, 右区間を選択するのが bisect_right。 + - left/right の振る舞いの違いを見るには、重複を含むリストを用意しないと違いがわからない (当たり前) + +```python +[1, 5, 5, 10] + 0, 1, 2 + l _ m _ h +``` + +```python +bisect_right の探索ループ: + while lo < hi: + mid = (lo + hi) // 2 + # 本家。== を下に任せているのがわかりやすい。 + if x < key(a[mid]): + hi = mid + else: + lo = mid + 1 + # == を含む形に書き換えてみた。right と left の違いは、 == の扱いの違いにある。 + # if key(a[mid]) <= x: + # lo = mid + 1 + # else: + # hi = mid + return lo + +bisect_left の探索ループ: + while lo < hi: + mid = (lo + hi) // 2 + if key(a[mid]) < x: + lo = mid + 1 + else: + hi = mid + return lo +``` + +# データ構造設計問題の解き方 +データ構造・アルゴリズムの選択肢の洗い出し -> set -> get の順にやるのがよさそう +- set がちゃんと動くことを確認してから get の実装にとりかかるとよさそう。(get がうまくいかないときに set にバグがないか遡るのは大変なので) +- やってみて TLE するかどうかを見るのでは行き当たりばったりになる。TLE するにしても実装する前にどれぐらいかかりそうか見積もる。 + - 参考リンク + - クロック数 https://discord.com/channels/1084280443945353267/1183683738635346001/1204276545577943051 + - ビッグオー記法の言葉遣い https://discord.com/channels/1084280443945353267/1192728121644945439/1233723795794165781 + - 計算理論関係の用語 https://discord.com/channels/1084280443945353267/1198621745565937764/1236328960753795112 + - 実時間で間に合うかどうかを気にする https://discord.com/channels/1084280443945353267/1227073733844406343/1235994903595712593 + - 計算量の評価が難しい例 https://discord.com/channels/1084280443945353267/1233295449985650688/1242118169968115845 + - 今回の問題は、探索対象となるリストの長さが最大で100、getメソッドの呼び出し回数が最大 2*10^5 だった。 + - 線形探索でも1秒以内に終わりそう。 +- サポートする操作 set, get, update, delete + - 一般的に気にすること + - 重複は含まれるか、順序は気にするか、計算量の要件はあるか + - コーナーケース:空に注意、==に注意 + - set で気にすること immutable/immutable, 参照透過性 + - update でも同様。 + - 順序付きのデータ構造に対する get: どれぐらい要素数が大きくなれば、線形探索→二分探索に変えたほうがいいのか。 + - だいたい要素数20ぐらいで、二分探索のほうが早くなる https://discord.com/channels/1084280443945353267/1192736784354918470/1199348106949558334, https://discord.com/channels/1084280443945353267/1200089668901937312/1203010805084332062 + - ソートを保つのは set が O(n) になるのとトレードオフ + - 線形リストに対しては二分探索を O(lon_n) でできない。dict[index] = Node となるような連想配列を用意しても、delete, insert にともなうインデックスの更新に結局 O(n) かかるので。 + - dict(key, Node) はあり得る。 参照 LRU-cache https://leetcode.com/problems/lru-cache/description/ +- set, get を同時に実行するとどうなるか、も気にする必要があるかも + + +# とても大きなテストケース(10^5ぐらい)をコピペすると、コピペに時間がかかる。 +1. vim で開いたテスト用のファイルにテストケースをペーストしたところ、ペーストがしばらく終わらなかった。 +2. ファイルを開いている vim のプロセスを、`kill -9 PID` で kill したところ、プロンプトに切り替わり、プロンプトへのペーストが継続した。 +3. `ps -x | grep ` でペーストを継続しているプロセスを探してみたが見つけられなかった。 + +→ vim を kill してもターミナルエミュレータ上でのペーストが継続する現象には、ターミナルエミュレータのペーストバッファというのが関係しているらしい。 +メカニズムはよくわかっていないが、ターミナルエミュレータから vim への出力が、どうにかしてプロンプトに切り替わっている。