Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 103 additions & 0 deletions 981_time_based_key-value_store/step1.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
# 実装前ノート
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

この問題を見たとき、同じ key に対する value は、 timestamp でソートした二分探索木に入れるのがよいと思いました。また、 get() では二分探索木の中で、 timestamp_prev <= timestamp となる最大の timestamp_prev を探すとき、 O(log n) で探索できると思います。

Python で二分探索木の実装、挑戦してみます…?

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

二分探索木を実装したことがなかったので step4 として挑戦してみます!

質問なのですが、 ソートした配列を用いるか、二分探索木を用いるかどちらか選べる状況ではどのような点を考慮すべきでしょうか。
いまのところ、二分探索木を用いると、計算量において挿入・削除が平均 O(log n) で実行できる点で有利で、実装において (標準ライブラリで提供されていないこともあり) 複雑度が増すという理解です。

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

あくまで自分の場合にになりますが、こんな感じで考えています。
最初にどれくらいの時間で処理を終わらせるべきか、どれくらいのメモリ使用量で処理すべきかを求めます。これらはプログラムに対する要求で決まってくると思います。続いて、いくつかの実装方針について、時間計算量・空間計算量を求めます。次に、時間計算量・空間計算量の式にデータサイズを代入し、計算ステップ数を概算します。そのあと、計算ステップ数から実行時間・仕様メモリ量を概算します。これらのうち、要求を満たすものの中で、最も実装が簡単なものを選びます。
なお、処理時間が重要な場合には、自分なら C++ で書くことを選びます。また、 C++ には標準ライブラリに std::map があり、今回のような処理が比較的楽に書けます。
理想的には、実装したい処理に応じて、言語を書き分けられるようになるとよいと思います。

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

くわしい説明をありがとうございます。計算ステップに換算して考えると n が大きくないときや定数倍の評価がしやすくなると思いました。時間計算量とその係数からなんとなくで実時間を見積もっていたので、ステップ数に換算して考えるようにしてみます。
ステップ数と実時間の対応についてのログも discord 上を漁ってみます。

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

言語の選択は頭に入っていませんでした。 python が提供しているデータ構造も把握しきれていないので覚えていこうと思います。
(ログや他の方の解答から、max_heap、平衡木がないことを把握しました。)

"""
アルゴリズムの選択肢
- 選択にあたっては、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]) は冗長
"""
44 changes: 44 additions & 0 deletions 981_time_based_key-value_store/step2.py
Original file line number Diff line number Diff line change
@@ -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 で見つけた一つ左の要素が対象要素のインデックス
"""
29 changes: 29 additions & 0 deletions 981_time_based_key-value_store/step3.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
from collections import defaultdict
class TimeMap:

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ここ開けないほうが普通かと思ったんですが、開ける流儀もあるんですかね。
https://peps.python.org/pep-0008/#blank-lines

Copy link
Owner Author

@sakzk sakzk Jun 15, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

class 宣言直後の空行について、cpython/Lib/ 内でも空行を入れる、入れないが混在していて、それぞれの基準はよくわかりませんでした。。。小さいクラスであれば間延びして見えるのでわざわざ空行を入れることもないですね。

以下、cpython/Lib で見つけた空行があるクラス定義の例です 。
空行があるもの:
https://github.com/python/cpython/blob/5c58e728b1391c258b224fc6d88f62f42c725026/Lib/__future__.py#L81
https://github.com/python/cpython/blob/5c58e728b1391c258b224fc6d88f62f42c725026/Lib/doctest.py#L2457
ファイル内で空行の有無が混在しているもの:
https://github.com/python/cpython/blob/5c58e728b1391c258b224fc6d88f62f42c725026/Lib/calendar.py#L94
上の例から、クラスが大きいことのシグナルとして空行を入れるかとも思ったのですが、あてはまらないものもありました。
https://github.com/python/cpython/blob/5c58e728b1391c258b224fc6d88f62f42c725026/Lib/plistlib.py#L464

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ありがとうございます。いや、私も CPython をさらっと見て両方あるなあと思ったんですよ。

Google style guide も開けろと明示的には言っていないですね。例は空いてないです。docstring がある場合には空けるようですが。
https://google.github.io/styleguide/pyguide.html#35-blank-lines

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

リンクありがとうございます。Google style guid の例を確認しました。

cpython のコードを見てみて、表現の違いに意味を見出だそうとして結局よくわからず負荷がかかったので、一貫していることの大切さを理解できました。

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` でもよいかも。
"""
92 changes: 92 additions & 0 deletions 981_time_based_key-value_store/step4_binary_search_tree.py
Original file line number Diff line number Diff line change
@@ -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 の値は増加するいっぽうなので 「要素数 = 深さ」となる。空間計算量として最悪の状況で配列はスカスカ)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

10^14 はどこから来ましたか?

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

At most 2 * 10^5 calls will be made to set and get.

とありますね。

配列での表現はcomplete binary treeの時に使うと思います。

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 <= timestamp <= 10^7 から最大でそれだけの深さになり得ると早合点していました。
要素数の上限は set が呼ばれる回数ですね。。。

- 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)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Balanced BSTで実装できますか?

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

リンクありがとうございます。AVL-tree と red-black tree の名前を聞いたことがあるぐらいで、バランスの操作が大変そうで実装したことはありませんでした。(他に関連知識といえば、ファイルシステムとかデータベースは B-tree というのを使っているらしい、ぐらいの知識です。) 平衡木はほかにもいろいろあるんですね。
ひとまず AVL-tree で insert と search を実装してみようと思います。

- 今回は問題の制約より、キーが単調増加するので右の木が伸び続ける。追加位置を見つけるのも、対象をサーチするのも線形リストを辿っているのとおなじになる。

二分探索の 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
"""
71 changes: 71 additions & 0 deletions 981_time_based_key-value_store/znote.md
Original file line number Diff line number Diff line change
@@ -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で開いていたファイル名>` でペーストを継続しているプロセスを探してみたが見つけられなかった。
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

vim だと paste mode にしてから貼るとかいるんじゃないでしょうか。他、cat でどうでしょう。
% cat > /tmp/a.txt
test
test
^D
% cat /tmp/a.txt
test
test

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

paste mode の存在を初めて知りました。コマンド例までありがとうございます。

試してみたところ、paste mode、cat コマンドのいずれも、システムのクリップボードからのペーストで十秒単位の時間がかかるのは変わらずでした。どうやらターミナルへの描画がボトルネックになっていそうでした。

ターミナルに大量にペーストすると遅くなるしくみは思ったより複雑そうなテーマだったので追加で色々調べてみようと思います。
現時点での理解としては、ターミナル上での入力は1文字ごとに画面に反映されてほしいので出力をバッファリングしておらず、そのため、大量の文字一度にペーストするとペーストが終わるまで1文字ずつ画面に出力し続けてしまうので遅い、という理解です。
https://stackoverflow.com/questions/3857052/why-is-printing-to-stdout-so-slow-can-it-be-sped-up

leetcode だとあまり意識することはありませんが、入出力がボトルネックになることもあるという実例ですね。


→ vim を kill してもターミナルエミュレータ上でのペーストが継続する現象には、ターミナルエミュレータのペーストバッファというのが関係しているらしい。
メカニズムはよくわかっていないが、ターミナルエミュレータから vim への出力が、どうにかしてプロンプトに切り替わっている。