Skip to content

703. Kth Largest Element in a Stream #23

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 8 commits into
base: main
Choose a base branch
from
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
250 changes: 250 additions & 0 deletions 0703_Kth_Largest_Element_in_a_Stream/solution.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,250 @@
## Problem

https://leetcode.com/problems/kth-largest-element-in-a-stream/

## Step 1

5 分程度答えを見ずに考えて、手が止まるまでやってみる。
何も思いつかなければ、答えを見て解く。ただし、コードを書くときは答えを見ないこと。
動かないコードも記録する。
正解したら一旦 OK。思考過程もメモする。

### Approach 1. Heap を使った方法

- わからなかったので回答を見て作成。ヒープはこれまで慣れてなかったのでこの問題でしっかりめにインプットした。時間があれば自分で実装したいところだが一旦後回し
- PriorityQueue
- https://docs.oracle.com/javase/8/docs/api/java/util/PriorityQueue.html
- k 番目に大きい値だけを求めるために、サイズ k の最小ヒープを作り「常に上位 k 個の中で最小の値(= k 番目に大きい値)を根に持つ」よう管理するという方法。

時間計算量: 各 add が O(log k) → 合計 O(n log k)
空間計算量: O(k)

```java
class KthLargest {
private final PriorityQueue<Integer> minHeap;
private final int k;

public KthLargest(int k, int[] nums) {
this.k = k;
minHeap = new PriorityQueue<>(k);

for (int num : nums) {
add(num);
}
}

public int add(int val) {
if (minHeap.size() < k) {
minHeap.offer(val); // add
} else if (minHeap.peek() < val) {
minHeap.poll(); // remove root
minHeap.offer(val);
}

return minHeap.peek();
}
}
```

上記に対していただいたコメント

- https://github.com/katsukii/leetcode/pull/23/files#r2068473153
- > この if-else if 文を読んでいて、else のケースが大丈夫なのかなというのを考えるのに少し時間が取られたのでもう少し素直に書ける余地があるかなと思います。
- > とりあえず queu に突っ込んでしまって、要素がサイズを超えていれば、減らしてあげるみたいな感じのほうがシンプルかなと個人的には思います。
- ```java
public int add(int val) {
scores.offer(val);
if (scores.size() > k) {
scores.poll();
}
return scores.peek();
}
```
- たしかにこちらの方がわかりやすい

## Step 2

他の方が描いたコードを見て、参考にしてコードを書き直してみる。
参考にしたコードのリンクは貼っておく。
読みやすいことを意識する。
他の解法も考えみる。

### Approach 2. TreeMap を使った方法

- https://github.com/Ryotaro25/leetcode_first60/pull/9/files#r1619710596
- > この問題で priority_queue にいきなりいくのは、私は実は違和感があります。
- > 平衡二分木が C++ だったら map があり、これは順番に並んでいます。
- 平衡二分探索木
- https://ja.wikipedia.org/wiki/%E5%B9%B3%E8%A1%A1%E4%BA%8C%E5%88%86%E6%8E%A2%E7%B4%A2%E6%9C%A8
- Java だと TreeMap がそれに当たるっぽい(赤黒木)

Choose a reason for hiding this comment

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

この問題を解いたことないところからの質問失礼します。
上記priority_queueとmapの議論も読ませてもらったのですが、問題の要求的に
1.どうやってk番目の順位のスコアを知るか
2.新しいスコアが入ってきたときの適切な更新
の2つの要求があって、1と2どちらも解決できるデータ構造としてpriority_queueとmapどっちがいいか(まずよく使われるMapを思い出してもいいのでは)という議論だと認識してます。

しかしもっと単純にもしそれらのライブラリを知らなかったら、1を求めるために最初はソートでk番目のスコアを求める、それを保持しつつ新しいスコアと比べて更新するとかでもこの問題は問題ないのでしょうか

Copy link
Owner Author

Choose a reason for hiding this comment

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

コメントありがとうございます。仰るとおりコメントいただいた解法でも問題ないと思います。

以下に追加実装してみました。
https://github.com/katsukii/leetcode/pull/23/files#diff-2cbd596e05763f14077e25b7a93421a6edb70d86913362e022332db1f0073c34R179


- TreeMap にスコアを Key、当該スコア個数を Value として保存

```java
class KthLargest {
private final int k;
private TreeMap<Integer, Integer> scores;

public KthLargest(int k, int[] nums) {
this.k = k;
this.scores = new TreeMap<>();

for (int num : nums) {
scores.put(num, scores.getOrDefault(num, 0) + 1);
}
}

public int add(int val) {

scores.put(val, scores.getOrDefault(val, 0) + 1);
int rank = 0;
for (int key : scores.descendingKeySet()) {
rank += scores.get(key);
if (rank >= k) {
return key;
}
}
return -1; // dummy
}
}
```

- 上記の方法で試したところ、大量の add が走るテストケースで Time Limit Exceeded エラーとなった。要素削除がないため add のたびに scors が増え続けること原因
- 以下は 常に Top k に相当する要素だけを残すようにサイズを k に保つ
- k 番目に大きい要素は TreeMap の最小 Key に該当する

時間計算量: コンストラクタが O(n log k)、add 単発が O(log k)なので O(n log k)
空間計算量: O(k)

```java
class KthLargest {
private final int k;
private TreeMap<Integer, Integer> scores;
private int scoreCount;
Copy link

Choose a reason for hiding this comment

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

自分なら numScores と名付けると思います。チームの平均的な書き方に合わせることをお勧めいたします。

Copy link
Owner Author

Choose a reason for hiding this comment

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

ありがとうございます。たしかにそうですね。意識してみます。


public KthLargest(int k, int[] nums) {
this.k = k;
this.scores = new TreeMap<>();
this.scoreCount = 0;
for (int num : nums) {
add(num);
}
}

public int add(int val) {
if (scoreCount < k) {
scores.put(val, scores.getOrDefault(val, 0) + 1);
scoreCount++;
} else {
int kthScore = scores.firstKey();
if (val > kthScore) {
scores.put(val, scores.getOrDefault(val, 0) + 1);
Copy link

Choose a reason for hiding this comment

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

私はこの put と getOrDefault を一行に書くのは好みではないです。
2回 val が出てきて、あと目が左右に振られますね。
https://docs.google.com/document/d/11HV35ADPo9QxJOpJQ24FcZvtvioli770WWdZZDaLOfg/edit?tab=t.0#heading=h.kqo7dp4vlnzh

val を2回書かないならば compute を使うようなのもありますが、素直に2行にするのも一つです。
https://discord.com/channels/1084280443945353267/1300342682769686600/1357629082069893221

Copy link
Owner Author

Choose a reason for hiding this comment

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

たしかに素直に2行にした方が断然わかりやすいですね。今後そうします。

updateScoreCount(kthScore);
}
}
return scores.firstKey();
}

private void updateScoreCount(int key) {
int count = scores.get(key);
if (count == 1) {
scores.remove(key);
} else {
scores.put(key, count - 1);
}
}
}
```

上記に対しいただいたコメント

- https://github.com/katsukii/leetcode/pull/23/files#r2065049233

- > 自分なら numScores と名付けると思います。チームの平均的な書き方に合わせることをお勧めいたします。
- たしかに個数を表すなら num◯◯ の方が共通認識としてわかりやすいのはあるかもしれない

- https://github.com/katsukii/leetcode/pull/23/files#r2067722954
- > `scores.put(val, scores.getOrDefault(val, 0) + 1);`
- > 私はこの put と getOrDefault を一行に書くのは好みではないです。
- > val を 2 回書かないならば compute を使うようなのもありますが、素直に 2 行にするのも一つです。
- たしかに 2 行にした方が見やすい。今後気をつける
- ```java
int count = scores.getOrDefault(val, 0) + 1;
scores.put(val, count);
```

## Step 3

今度は、時間を測りながら、もう一回書く。
アクセプトされたら消すを 3 回連続できたら問題は OK。

Choose a reason for hiding this comment

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

step3 が一番読みやすかったです。

Copy link
Owner Author

Choose a reason for hiding this comment

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

ありがとうございます。


```java
class KthLargest {
private PriorityQueue<Integer> scores;
private final int k;

public KthLargest(int k, int[] nums) {
this.k = k;
this.scores = new PriorityQueue<>();
for (int num : nums) {
this.add(num);
}
}

public int add(int val) {
if (scores.size() < k) {

Choose a reason for hiding this comment

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

このif-else if文を読んでいて、elseのケースが大丈夫なのかなというのを考えるのに少し時間が取られたのでもう少し素直に書ける余地があるかなと思います。

とりあえずqueuに突っ込んでしまって、要素がサイズを超えていれば、減らしてあげるみたいな感じのほうがシンプルかなと個人的には思います。

   public int add(int val) {
      scores.offer(val);
      if (scores.size() > k) {
          scores.poll();
      }
       return scores.peek();
   }

Copy link
Owner Author

Choose a reason for hiding this comment

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

たしかにこちらの方が分かりやすいです。ありがとうございます。

scores.offer(val);
} else if (scores.peek() < val) {
scores.poll();
scores.offer(val);
}
return scores.peek();
}
}
```

## Step 4

コメントいただいて実装した

### Approach 3.ソート配列

時間計算量: O(n log n)
空間計算量: O(k)

- https://github.com/katsukii/leetcode/pull/23/files#r2065369258
- > 最初はソートで k 番目のスコアを求める、それを保持しつつ新しいスコアと比べて更新するとかでもこの問題は問題ないのでしょうか
- たしかこれでも解法としてありえそう

- コンストラクタ: 空の配列をメンバ変数として用意し、for 文で nums の要素数分 add を呼び出す
- add: 引数の val を配列の適切な位置に挿入したあと要素数が k 個になるように調整し index 0 を返す
  - `Collections.binarySearch()` でソート済配列のどの位置に挿入されるか特定可能

```java
class KthLargest {
private final int k;
private List<Integer> sortedList;
public KthLargest(int k, int[] nums) {
this.k = k;
this.sortedList = new ArrayList<>();

for (int num : nums) {
add(num);
}
}

public int add(int val) {
int insertPosition = Collections.binarySearch(sortedList, val);
if (insertPosition < 0) { // if val isn't in sortedList
insertPosition = -(insertPosition + 1);
}

sortedList.add(insertPosition, val);

if (sortedList.size() > k) {
sortedList.remove(0);

Choose a reason for hiding this comment

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

L237でbinarySearchを使っているのは、この配列がソート済みなことを利用してO(log(k))で要素を追加したいということなのかなと思うのですが、結局ここで配列の先頭要素を削除しているので、O(k)になっていると思います(削除後に左詰めされるのでそこで線形に時間が掛かる)。

https://docs.oracle.com/javase/8/docs/api/java/util/ArrayList.html

All of the other operations run in linear time (roughly speaking).

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.

ご指摘ありがとうございます。おっしゃるとおりO(k)ですね。

降順にソートしてあげて削除は末尾要素にすれば解決するかなと思いました。

たしかに、削除の部分の時間計算量は削除がO(1)になるため平均時間計算量はマシになりますね。sortedList.add(insertPosition, val); が相変わらずシフトするので最悪時間計算量はO(k)のままですが。

}
return sortedList.get(0);
}
}
```