Skip to content
Open
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
333 changes: 333 additions & 0 deletions medium/300/answer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,333 @@
# Step1

かかった時間:7min

計算量:
nums.length=Nとして

時間計算量:O(NlogN)

空間計算量:O(N)

```python
import bisect


class Solution:
def lengthOfLIS(self, nums: List[int]) -> int:
lis = []
for num in nums:
if not lis or lis[-1] < num:
lis.append(num)
continue

i = bisect.bisect_left(lis, num)
lis[i] = num

return len(lis)
```
思考ログ:
- 直近で解いて覚えていた
- 最初はこの解法を受け入れるのに時間がかかった気がする
- 例えば、[10, 11, 12, 1, 2]だと、変数lisは
- [10]
- [10, 11]
- [10, 11, 12]
- [1, 11, 12]
- [1, 2, 12]
- 大事なのは大小関係なので、それを崩さないように挿入していけば、2つの候補を同時に管理できる
- 名前がlisじゃないという議論があったと思うが、一度ロムってから考えよう、、

ボトムアップDP
```python
class Solution:
def lengthOfLIS(self, nums: List[int]) -> int:
lis = [1] * len(nums)
for i in range(1, len(nums)):
for j in range(i):
if nums[j] < nums[i]:
lis[i] = max(lis[i], lis[j] + 1)

return max(lis)
```
- 一番分かりやすいし思いつく解法では
- 命名の問題が同様にある

# Step2

講師役目線でのセルフツッコミポイント:
- 命名関連の選択肢は再度考えた方がいい
- 名前を工夫する

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.

step3で書いたつもりでした。

- コメントで補足する

参考にした過去ログなど:
- https://github.com/kazukiii/leetcode/pull/32
- Seg木
- https://github.com/seal-azarashi/leetcode/pull/28
- Javaの標準ライブラリの二分探索メソッドの返り値が悩ましい
- https://github.com/Ryotaro25/leetcode_first60/pull/34
- https://github.com/Yoshiki-Iwasa/Arai60/pull/46
- 命名、lisに関して
- https://github.com/fhiyo/leetcode/pull/32
- BIT、Seg木
- BITはロジックを読んどく、Seg木は実装してみよう
- https://github.com/SuperHotDogCat/coding-interview/pull/28
- https://github.com/sakupan102/arai60-practice/pull/32
- https://github.com/goto-untrapped/Arai60/pull/18
- https://github.com/YukiMichishita/LeetCode/pull/7
- https://github.com/shining-ai/leetcode/pull/31
- BIT、Seg木
- https://github.com/hayashi-ay/leetcode/pull/27


セグ木
```python
IDENTITY_ELEMENT = 0

class SegTree:
def __init__(self, n: int):
leaf_n = 1
while leaf_n < n:
leaf_n *= 2
self.leaf_n = leaf_n
self.tree = [0] * (2 * leaf_n - 1)

def _convert_to_tree_index(self, i: int) -> int:
return i + self.leaf_n - 1

def query(self, l: int, r: int):
'''
note:
右半開区間でquery範囲を指定する(x ∈ [l, r))
'''
l = self._convert_to_tree_index(l)
r = self._convert_to_tree_index(r)

result = IDENTITY_ELEMENT
Copy link

Choose a reason for hiding this comment

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

IDENTITY_ELEMENT, 定数 (本当は変数ですが) にする必要ありますかね?自分はそのまま result = 0 と書いた方が0で初期化する意図が分かりやすい気がしました。

Copy link
Owner Author

Choose a reason for hiding this comment

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

演算によって単位元が変わるので、こんな形式にしたのですが、このSegTreeクラスは汎用的に設計ができてる訳でもないので、なんだか中途半端なことになっていると思います。

while l < r:
# if l is left child
if l % 2 == 0:
result = max(result, self.tree[l])
# if r-1 is right child
if r % 2 == 0:
result = max(result, self.tree[r - 1])
l = (l - 1) // 2
r = (r - 1) // 2
Comment on lines +107 to +115
Copy link

Choose a reason for hiding this comment

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

自分がセグメント木をあまり理解していないからでしょうが、何をやっているのか分かりませんでした...
(if l is left child とありますが、lが偶数ならlは右の子のような?)

Copy link
Owner Author

@TORUS0818 TORUS0818 Oct 27, 2024

Choose a reason for hiding this comment

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

配列の0番目の要素がrootになるようにしているので、奇数が左の子になると思います。
嘘つきました。コメントがおかしいかも。。

Copy link
Owner Author

Choose a reason for hiding this comment

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

lが偶数ならlは右の子のような?
右ですね。。

やっていることは、、説明がややこしいですね。。何か具体例を考えるのがいいかもです。

配列の長さが7のケース(葉が4つ)で考えます。
0[1, 2, 3, 4]
1[1, 2] 2[3, 4]
3[1] 4[2], 5[3] 6[4]
のように配列の要素[圧縮した要素の集合]が対応しています。

numsから4(圧縮後)が出てきたとします。
今まで出てきた1 or 2 or 3で終わる最長の部分列の長さが知りたいです(それ+1を6[4]にセットすれば良いので)

この時、3[1] 4[2], 5[3]の各々について確認するのではなく、1[1, 2] , 5[3]だけ確認すれば効率がいいということで、そのような候補の探索を始めるのですが、例えば左端の3[1]をこの候補に入れるかどうかを判定するのに、これが右の子かどうかを確認しています。左の子の場合は親を確認すれば良いのでスキップします。右の子の場合は親に左の子の情報が含まれてしまっているのでこれを採用する必要があります。

区間の右端から考える場合も考え方は同じです(ただ候補に含めるかどうかの判定の左右が逆になります)

親が同じになったら探索を終了します。

Copy link

Choose a reason for hiding this comment

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

圧縮した要素の集合は1-indexedになってますが0-indexedですかね?(じゃないとコードにある木のindexへの変換が上手くいかない気がします)

これって一般にはqueryの引数としてlが0以外も来ることを想定していると思いますが、lが0より大きい場合って変なことになったりしませんか?上の具体例だとたとえば st.query(1,3) (0-indexedで書いています) みたいなやつです。

たとえば下の図に書いたような [1,3)の区間に対するクエリ (st.query(1,3)) を行うと、rが偶数のときr-1の場所を見ながら親を辿るので[0,3)の区間の最大値を返すことになっちゃう気がしています。 (図の赤い斜線部分がクエリの結果参照される範囲)

Screenshot 2024-10-27 at 23 57 01

Copy link

Choose a reason for hiding this comment

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

このコード、n = 4 で r = 4 が来るとどこまでループが回りますかね。l = 2 とでもしましょうか。
はじめに、(5, 7) になります。その後、(2, 3), (0, 1), (-1, 0) で (-1, -1) で停止であってますか。

Copy link

Choose a reason for hiding this comment

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

あ、これ、まずなんで訳が分からなくなるかというと、l, r という整数に2つの情報、つまり、「どこ」から「どこ」までのセグメントであるか、という情報を載せていて、(segment - 1) // 2 という整数の上での操作と、セグメントの上での意味が遠く見えるからです。

さて、segment(l, width=1) で、セグメントの範囲の値が手に入るとしましょう。
ただ、width は l が2で割り切る回数まで倍に広げた場合は効率的に計算できます。

効率を考えないと

result = 0
for i in range(l, r):
    result = max(result, segment(i, 1))

が求めたいものですね。

Copy link

@oda oda Oct 28, 2024

Choose a reason for hiding this comment

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

ところで、これだと効率が悪いので速くしたいのですよね。
大まかには次のような構造です。

result = 0
width = 1
while l < r:
    # segment(f(l), width) と segment(g(r), width) で result 更新
    # l, r を微修正
    width *= 2

次、l と r の不変条件は、なんでしょうか。[l, r) の範囲は未計算ということですね。
つまり、このプログラムは width *= 2 をしなくても大まかには動くものです。

result = 0
while l < r:
    result = max(result, segment(l, 1))
    l += 1
    result = max(result, segment(r - 1, 1))
    r -= 1
# 最後一回は2回行われることがあるので本当は判定必要だけれども今回は max だからまあいいでしょう。

Copy link

Choose a reason for hiding this comment

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

さて、l が width * 2 で割れるときには、segment の計算を width *= 2 してからしたいわけです。しかし、割れない場合には、その場で計算するしかありませんね。r も同じで r が奇数ならば width を広げたら計算ができなくなるので、計算してから次にいくしかありませんね。

これくらいの情報が陽にあれば、読めるコードになると思います。

Copy link
Owner Author

Choose a reason for hiding this comment

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

@oda
補足説明もありがとうございます!
少し考えてみたいと思います。

以前にも”意図と操作の距離”についてコメントを頂いていました。
面倒くさがって、結果として遠回りになってしまっているのですね。

Copy link
Owner Author

Choose a reason for hiding this comment

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

@fhiyo
以下のコミットで修正しました。
e70ab6a


return result

def update(self, i: int, val: int) -> None:
i = self._convert_to_tree_index(i)
self.tree[i] = val

# get parent_index
i = (i - 1) // 2
while i >= 0:
self.tree[i] = max(self.tree[2 * i + 1], self.tree[2 * i + 2])
i = (i - 1) // 2
Comment on lines +123 to +127
Copy link

Choose a reason for hiding this comment

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

while (i := (i - 1) // 2) >= 0:
    self.tree[i] = max(self.tree[2 * i + 1], self.tree[2 * i + 2])

一応、こうも書けそうですね。見づらくなるギリギリのラインな気もしますが


class Solution:
def lengthOfLIS(self, nums: List[int]) -> int:
def compress(nums: list[int]) -> list[int]:
num_to_compressed_num = {}
for i, num in enumerate(sorted(set(nums))):
num_to_compressed_num[num] = i

compressed_num = [num_to_compressed_num[num] for num in nums]
return compressed_num

compressed_num = compress(nums)

Choose a reason for hiding this comment

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

compressed_numsとかの方が良いかなと思います

n = len(set(compressed_num))
st = SegTree(n)
for i, num in enumerate(compressed_num):
res = st.query(0, num)

Choose a reason for hiding this comment

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

resより良い命名があると思います。ものとしてはnumより小さい値で終わるLISのうち最長のものですね。

st.update(num, res + 1)

return st.tree[0]
```
思考ログ:
- この問題に対してoverkillなのは分かっているが、皆さんが結構実装されているのでこの機会にお勉強してみた
- シンプルなセグ木の理解自体はそんなにかからなかったが、寧ろこの問題での使い方の部分で躓いた(それはセグ木を理解できていな(ry)
Copy link

Choose a reason for hiding this comment

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

うーん、この overkill という表現にとても違和感があります。

「必要よりも高度な道具を使って解決する」くらいの意図だと思うのですが、ここでいう高度とはどういうことでしょうか。
実装の複雑さだったら、C++ の std::map 典型的には赤黒木のほうがはるかに複雑ですよ。Priority Queue さえも複雑じゃないでしょうか。

これは、アルゴリズムやデータ構造が、なんらかの高度さの順に並んでいるという感覚がなければ出てこない表現です。

セグメントツリーは、確かに「使えることに気が付きにくいが、計算量を落とせる場合が極めて稀にあり、短時間で書けるくらい単純である」という意味で、プログラミングコンテストに向いているため、競技プログラミング同好会時代にも出てきていました。

Copy link
Owner Author

Choose a reason for hiding this comment

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

ありがとうございます。
overkillという表現は、ロジックを理解しようとして色々調べていた際に見かけたものを深く考えず引用したものなのですが、少し思考を整理してみました。

今回私が「overkill」と感じたのは、セグ木のような方法を用いずとも、もっと素直で万人が思いつくであろう方法が他にあるだろう、さらにそれを使うことで劇的に計算量が改善されるならまだしも、そうでないならoverkill(やり過ぎ)だろう、という感覚だったのだと思います。

ご指摘のアルゴリズムやデータ構造に順序が入っているのでは、という点に関してはなんとも言えないところがあり、こんなの誰かから聞かないと一生思い付かないな、みたいなアイデアに関しては高度なもの、という感覚は少なからずあります。ただそのようなものを知っていることが偉い、という感覚はない(はず)と思っています。

- 考え方を簡単にまとめておく
- 座標圧縮
- 大小の情報だけあればいいので大きさ順に番号を振り直す
- ```query``` で自分より小さい数字で終わる最長増加部分列(LIS)を探す
- ```update```で自分の担当部分のLISの情報を更新する
- ```query```で返ってきた結果を一つ大きくすればいい
- 自分の先祖の情報も全部更新する
- 最後にrootの値を取ってくればいい
- TIPS
- 木は配列で管理すると扱いやすい
- (0-indexで考えて)親から子供のインデックスを知りたい時は、親のインデックス * 2 + 1, 親のインデックス * 2 + 2
- 子供から親のインデックスを知りたい時は、(子のインデックス - 1) // 2
- ビット演算でもできるが、今回は(自分にとっての)分かりやすさを優先した
- 更新と区間の情報取得が入り乱れている時に有用
- 更新が一回だけの場合などは累積和のロジックなど、前処理をしておけば良いが、道中更新があると困る
- 任意の区間を二分木のノードに分割できるのがミソ
- 端の扱いなどをちゃんと意識して書いていかないと容易にバグる

# Step3

かかった時間:1min

```python
import bisect


class Solution:
def lengthOfLIS(self, nums: List[int]) -> int:
# 長さがindex+1になるような任意の増加部分列を考えた時
# それらの最後の要素のうち最小のものを記録するための配列
# ex) [4,6], [3,5], [1,2]ならmin(6, 5, 2) = 2が入る
minimum_last_val_subseq = []
Copy link

Choose a reason for hiding this comment

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

個人的には length_to_min_last_vals という名前にすると思います。配列の添え字に何を指定すると、どのような値が返ってくるかを表そうとしています。ただ、 length と添え字が 1 ずれている点に注意が必要です。

for num in nums:
if not minimum_last_val_subseq \
or minimum_last_val_subseq[-1] < num:
minimum_last_val_subseq.append(num)

i = bisect.bisect_left(minimum_last_val_subseq, num)
minimum_last_val_subseq[i] = num
Comment on lines +184 to +189
Copy link

Choose a reason for hiding this comment

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

continue忘れですかね?numをappendしてからnumを二分探索してその位置にnumを挿入していて無駄な気がします。

個人的には常に二分探索する方が好みです。

i = bisect_left(minimum_last_val_subseq, num)
if i >= len(minimum_last_val_subseq):
    minimum_last_val_subseq.append(num)
else:
    minimum_last_val_subseq[i] = num

Copy link
Owner Author

Choose a reason for hiding this comment

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

ありがとうございます。
スキップし忘れました。


return len(minimum_last_val_subseq)
```
思考ログ:
- コメントで補足する癖をつけよう
- あんまり冗長にやってもアレなので匙加減と、逆に無駄に不要なコメントを残さないようにも気を付ける
- GPTに聞いてみた
> 候補となる変数名
> min_last_elements_of_increasing_subsequences
> 変数が持つ情報をそのまま説明する形で、冗長ですが内容が明確です。長さを意識しつつ正確さを優先したい場合に適しています。
> min_ends_of_increasing_subsequences
> 1の名前を少し短くした形です。「各長さごとの増加部分列の最小の終点(最後の要素)」という意味が伝わりやすいでしょう。
> increasing_subseq_min_ends
> 読みやすさを保ちながら短縮した名前で、PEP 8の命名規則に合致しています。変数の用途や内容が概ね分かりやすくなります。
> min_last_of_increasing_sublists
> subsequencesではなくsublist(部分リスト)という名前で少し簡略化していますが、役割は理解しやすいでしょう。
> おすすめ
> min_ends_of_increasing_subsequencesが適切です。この名前は要素が増加部分列の最終要素の最小値であることを明確に示し、長さも扱いやすいです。
> もし簡潔さを重視する場合は、increasing_subseq_min_endsも良い選択です。
- なお、質問は以下
> 今pythonでコードを書いているのですが、変数名の命名で困っています。
Copy link

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.

プロンプトエンジニアリングですね。
ここら辺の事情もなかなか追えてませんが、面白そうな領域ですよね。

> この変数Xは配列で、以下のような特徴を持ちます。
> - X[i]は、ある数列Yの長さi+1の増加部分列が対応します。
> - X[i]にはそのような部分列の最終要素の中で最小のものが入ります。
> 例えばY = [1,3,2,4]を考えるとX[1]に対応する増加部分列は[1,3], [1,2], [1,4], [3,4], [2,4]となり、min(3, 2, 4, 4, 4) = 2なのでX[1]=2となります。
> Xとして適切な名前を提案してください。なお、pep8の命名規則を守ってください。

# Step4

セグ木を再度実装
```python
class SegTree:
def __init__(self, n: int):
leaf_n = 1
while leaf_n < n:
leaf_n *= 2
self.leaf_n = leaf_n
# 1-indexed tree
self.tree = [0] * (2 * leaf_n)

def update(self, seg_i: int, val: int) -> None:
assert 0 <= seg_i < self.leaf_n, f'seg_i is out of index. seg_i: {seg_i}'

tree_i = seg_i + self.leaf_n
Comment on lines +231 to +233
Copy link

Choose a reason for hiding this comment

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

下の query にもあるし、別のメソッドに切り出した方が見通しが良い気がしました。

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行で済むような処理なので関数に切り出さない選択をしましたが、このインデックス変換の計算は、何をしているかぱっと見では分かりにくいので、関数として切り出すのも良いなと思いました。

self.tree[tree_i] = val
while tree_i > 1:
tree_i //= 2
self.tree[tree_i] = max(self.tree[2 * tree_i], self.tree[2 * tree_i + 1])

def query(self, start_seg_i: int, end_seg_i: int) -> int:
assert 0 <= start_seg_i < self.leaf_n, f'start_seg_i is out of index. start_seg_i: {start_seg_i}'
assert start_seg_i <= end_seg_i <= self.leaf_n, f'end_seg_i is out of index. end_seg_i: {end_seg_i}'

tree_l = start_seg_i + self.leaf_n
tree_r = end_seg_i + self.leaf_n
Comment on lines +243 to +244
Copy link

Choose a reason for hiding this comment

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

tree_lやtree_rという変数名を見てindexだとは思えない気がします。

Copy link
Owner Author

Choose a reason for hiding this comment

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

いい名前が思いつきませんでした。。
seg_iに合わせて、start_tree_iとかにするか、tree_i, tree_jとかにしてしまうとかですかね。。

result = 0
while tree_l < tree_r:
if tree_l % 2:
result = max(result, self.tree[tree_l])
tree_l += 1
if tree_r % 2:
tree_r -= 1
result = max(result, self.tree[tree_r])
tree_l //= 2
tree_r //= 2

return result

def get_maximum_val_of_all_segments(self):
return self.tree[1]

class Solution:
def lengthOfLIS(self, nums: List[int]) -> int:
def compress(nums: list[int]) -> list[int]:
num_to_compressed_num = {}
for i, num in enumerate(sorted(set(nums))):
num_to_compressed_num[num] = i

compressed_num = [num_to_compressed_num[num] for num in nums]
return compressed_num

compressed_num = compress(nums)
n = len(set(compressed_num))
st = SegTree(n)
for i, num in enumerate(compressed_num):
res = st.query(0, num)
Copy link

Choose a reason for hiding this comment

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

resとは何でしょうか?

Copy link
Owner Author

Choose a reason for hiding this comment

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

resultの略で横着しました。。

st.update(num, res + 1)

return st.get_maximum_val_of_all_segments()
Copy link

Choose a reason for hiding this comment

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

st.query(0, n) で十分な気がしています。

Copy link
Owner Author

Choose a reason for hiding this comment

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

st.query(0, n)でもう一度計算するのも無駄かなと思い、この処理を加えました。

```
思考ログ:
- 以下アップデートしました。
- 単位元は直接0で初期化するように変更(汎用的な実装でもないので)
- 計算をシンプルにするために内部のtreeの配列は1-indexedにした
- セグメントの値や区間指定は0-indexedのままにしている
- 使う側は内部の1-indexedを意識しないでいいように(treeにはアクセスしないでいいように)敢えて```get_maximum_val_of_all_segments```を実装した
- ```query```のバグを修正
- 道中計算が必要になったlとrは微調整をしないといけないことに気づいていなかった
- セグメント[0], [1]で[1]を採用した場合、次は[2]以降を考えないといけないのにその親[0, 1]に遷移してしまっていた
- ビット演算ver
- ビット演算と(完全)二分木のノード移動をまとめておく(1-indexed配列)
- 自分のインデックスを```i```として
- 親:```i >> 1```
- 兄弟:```i ^ 1```
- 左の子:```i << 1 | 0```
- 右の子:```i << 1 | 1```
- なぜ1との排他的論理和で兄弟?
- 自分が偶数のインデックスの時は相手は一つ大きい奇数、自分が奇数のインデックスの時は相手は一つ小さい偶数
- 自分が偶数の時最下位ビットは0、^1を取ると最下位ビットは1になる(一つ大きい奇数)
- 自分が奇数の時最下位ビットは1、^1を取ると最下位ビットは0になる(一つ小さい偶数)
```python
def update(self, seg_i: int, val: int) -> None:
assert 0 <= seg_i < self.leaf_n, f'seg_i is out of index. seg_i: {seg_i}'

tree_i = seg_i + self.leaf_n
self.tree[tree_i] = val
while tree_i > 1:
tree_i >>= 1
self.tree[tree_i] = max(self.tree[tree_i << 1 | 0], self.tree[tree_i << 1 | 1])

def query(self, start_seg_i: int, end_seg_i: int) -> int:
assert 0 <= start_seg_i < self.leaf_n, f'start_seg_i is out of index. start_seg_i: {start_seg_i}'
assert start_seg_i <= end_seg_i <= self.leaf_n, f'end_seg_i is out of index. end_seg_i: {end_seg_i}'

tree_l = start_seg_i + self.leaf_n
tree_r = end_seg_i + self.leaf_n
result = 0
while tree_l < tree_r:
if tree_l & 1:
result = max(result, self.tree[tree_l])
tree_l += 1
if tree_r & 1:
tree_r -= 1
result = max(result, self.tree[tree_r])
tree_l >>= 1
tree_r >>= 1

return result
```

```python
```

思考ログ: