Skip to content
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

Top K Frequent Elements #20

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open

Top K Frequent Elements #20

wants to merge 2 commits into from

Conversation

rihib
Copy link
Owner

@rihib rihib commented Aug 17, 2024

Top K Frequent Elementsを解きました。レビューをお願い致します。

問題:https://leetcode.com/problems/top-k-frequent-elements/
言語:Go

すでに解いている方々:
hayashi-ay/leetcode#3
hayashi-ay/leetcode#60
cheeseNA/leetcode#13
Mike0121/LeetCode#3
TORUS0818/leetcode#11
fhiyo/leetcode#12
Ryotaro25/leetcode_first60#10
kazukiii/leetcode#10
wf9a5m75/leetcode3#3
Yoshiki-Iwasa/Arai60#8
kagetora0924/leetcode-grind#11
seal-azarashi/leetcode#9
ryoooooory/LeetCode#16
hroc135/leetcode#10

パフォーマンス比較

  • バケットソートを使った解法:時間計算量は $O(n)$ 、空間計算量は $O(n)$
  • クイックセレクトを使った解法:時間計算量は平均 $O(n)$ 、最悪 $O(n^{2})$ 、空間計算量は入力値を除いた追加の計算量は $O(m)$$m$ はユニークな要素の数)
  • PDQソートを使った解法:時間計算量は平均 $O({n} log {n})$ 、最悪 $O(nm)$ 、空間計算量は $O(n)$
  • 最小ヒープを使った解法:時間計算量は $O({n} log {k})$ 、空間計算量は $O(n)$
  • 最大ヒープを使った解法:時間計算量は $O({k} log {n})$ 、空間計算量は $O(n)$

最小ヒープを使う場合の時間計算量は $O({n} log {k})$$O(n + {n} log {k})$ )になる。まず、マップを作成するのに全ての要素をカウントする必要があるので $O(n)$ かかるのと、最小ヒープは最大で $k$ 個の要素のみを保持しておけば良いため、挿入と最小要素の削除にはどちらも $O(log {k})$かかる。それを全ての要素に対して行うので全体では $O({n} log {k})$ になる。

最大ヒープの場合は、 $n$ 個の要素を持つヒープから最大値を取り出すのに再構築を含めて $O(\log {n})$ かかり、 $k$ 個取り出すので $O({k} \log {n})$ になる。

バケットソート

時間計算量は $O(n)$ だが、標準ライブラリのソートは最適化されている分、速い可能性がある(参照)。

クイックセレクト

ホーアの選択アルゴリズムとも呼ばれる。クイックソートと同様に、平均的なパフォーマンスは良好だが、最悪の場合のパフォーマンスは悪くなる。クイックセレクトとその派生アルゴリズムは、効率的な実装として最もよく使われる選択アルゴリズムである。

空間計算量

クイックセレクトはインプレースアルゴリズムでもあり、末尾呼び出し最適化がかかる場合や、以下のようにループで末尾再帰を排除した実装を行う場合は、定数のメモリ空間で実行できる。

ただしGoでは末尾呼び出し最適化が行われない。

時間計算量

クイックセレクトは、クイックソートと同じように1つの要素をピボットとして選択し、ピボットに基づいてデータをピボットよりも小さいかに大きいか二分割するのを繰り返す。ただし、クイックソートのように分割した両方の区間に対して再帰するのではなく、クイックセレクトは一方の側、つまり検索している要素のある側にのみ再帰する。これにより、平均計算量が軽減される。平均計算量はクイックソートが $Θ({n} log {n})$ なのに対してクイックセレクトは $Θ(n)$ 。最悪計算量はクイックソートと同じく $O(n^2)$

クイックソートと同様に、クイックセレクトは平均パフォーマンスは良好だが、これは選択されたピボットに左右される。適切なピボットが選択された場合、検索セットのサイズは指数関数的に減少し、線形時間で実行できる。ただし、毎回1つの要素だけ減少するなど、不適切なピボットが一貫して選択されている場合、 $O(n^2)$ となる。

ランダムにピボットを選ぶ

ランダムにピボットを選ぶ場合、最悪のケースが発生する確率は指数関数的に減少するため時間計算量はほとんど確実に
$O(n)$ になる。

3要素の中央値をピボットに選ぶ

クイックソートのように3要素(例えば、先頭・中央・末尾)の中央値をピボットに選ぶ戦略が有効である。これにより、現実でよくある部分的にソートされたデータに対して線形のパフォーマンスが得られる。ただし、不自然な並びでは依然として最悪計算量になる可能性がある。 David Musserは、その戦略に対する攻撃を可能にする「3要素の中央値キラー」シーケンスについて説明している。彼はこれを動機としてイントロセレクトを開発した。

中央値の中央値をピボットに選ぶ

より洗練されたピボット戦略を使用することで、最悪の場合でも線形パフォーマンスを保証できる。これを実現できるのが中央値の中央値だが、ピボットの計算のオーバーヘッドは高いため、実際にはあまり使用されない。始めはクイックセレクトを使って、ある程度の回数で終了しなかった場合に中央値の中央値を使うことで、高速の平均ケースパフォーマンスと線形の最悪ケースパフォーマンスの両方を達成できる。この手法を使うのがイントロセレクトである。

全体の配列を小さな部分に分け、それぞれの部分から中央値を計算し、その中央値を再び集めたものから更に中央値を求めることで求められる。

PDQソート

PDQソートは、クイックソートとヒープソートを組み合わせたイントロソートを拡張・改良したPattern-Defeating Quicksort (PDQsort)の論文をベースに Go で実装したもので、クイックソートが苦手な同じ値を持つシーケンスに対してより効率よくソートできる。

Goのsort.Stringsのコードを見るとGo1.22以降は内部では単にslices.Sortを呼び出すと書いてあり、slices.Sortの内部ではPDQソートというものを使っているようで、PDQソートは $k$ 個の異なる要素を持つ入力に対して $O(nk)$ の最悪ケースに収まるとのこと。基本的にはクイックソートなので、平均的には $O({n} log {n})$ で、最悪計算量は $O(n^2)$ にならずに $O(nk)$ になる。

参考:https://www.m3tech.blog/entry/pdqsort

プライオリティキュー

最小ヒープを用いて、サイズがkを超えたら一番頻度の低い要素をPopすることを繰り返すことで最終的にk個の最も頻度の高い要素を得ることができる。最大ヒープを用いて全ての要素を頻度の高い順に保持して先頭からk個を取り出すということも可能だが、その分最小ヒープに比べてメモリ消費量が大きくなる。

その他

ryoooooory/LeetCode#16 (comment)

Java を使う場合は、TreeMap も見ておいてください。私は、PriorityQueue よりもこちらのほうが大事な感覚があります。Key が順番に並んでいます。
https://docs.oracle.com/javase/7/docs/api/java/util/TreeMap.html

乱数生成

math/randcrypto/rand

Goにはmath/randcrypto/randという2つの乱数生成用のパッケージがある。crypto/randは暗号学的に安全な乱数生成器を提供し、セキュアなトークンやキーの生成などに使用される。対してmath/randは擬似乱数を生成するため、指定されたシード値に基づいて乱数が生成され、同じシード値を使うと常に同じ乱数を得られる。crypto/randに比べて高速に実行でき、シード値が同じであれば、プログラムを実行するたびに同じ結果が得られるため、再現性が重要なシミュレーションやテストに使える。

Go Playground

math/rand

Go1.20からグローバルなシードを設定するrand.Seedを使用するのではなく、rand.New(rand.NewSource(seed))を使って新しいrand.Randインスタンスを作成し、シードを設定する方法が推奨されるようになった。これによって複数の乱数生成器を独立して動かすことができるようになり、意図しないグローバルな状態の共有を避けられる。

// Bad
func main() {
  mathRand.Seed(123)
  fmt.Println(mathRand.Intn(100))
}
// Good
func main() {
  var r *rand.Rand
  r = rand.New(rand.NewSource(time.Now().UnixNano()))  // 再現性のある乱数生成をしたい場合はUnix時間の代わりに任意の値をSeedとして設定することもできる
  fmt.Println(r.Intn(100))  // 0から99までのランダムな`int`の擬似乱数を生成
  fmt.Println(r.Float64())  // 0.0から1.0未満までのランダムな`float64`の擬似乱数を生成
}

math/rand/v2

Go1.22で追加される新しい乱数パッケージmath/rand/v2の紹介

Goは2009年にリリースされて以来、これまで数多くの標準パッケージが実装されてきましたが、リリースから時間が経つにつれて様々な問題が発見されてきました。それらの問題はプログラムの外部から見える振る舞いを変えないようであれば修正が行われてきましたが、振る舞いを変える場合、後方互換性を維持するために非推奨にして使用しないように勧告したり、既存の実装を残したまま拡張したりしながら問題を回避してきました。

最近では、既存のパッケージを残しつつ、新しくv2パッケージとして提供しようという動きが出てきており、例えば既存の標準パッケージであるencoding/jsonの様々な問題点を解消するために、そのv2パッケージencoding/json/v2が提案されていることをご存じの方も多いと思います。実はGo1.22ではencoding/json/v2よりも一足先に、パッケージmath/randにv2パッケージが導入されようとしており、v2パッケージの先駆けとなる見込みです。

func main() {
	fmt.Println(rand.IntN(100))  // 0から99までの`int`の擬似乱数を生成
	fmt.Println(rand.Float64())  // 0.0から1.0未満までのランダムな`float64`の擬似乱数を生成
}

Go 1.22 の math/rand/v2 を使ってみる

Copy link

@sasanquaneuf sasanquaneuf left a comment

Choose a reason for hiding this comment

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

コードを見てコメントしました。GoでHeapを使う場合はInterfaceの実装が必要なんですね。

}
topK := make([]int, 0, k)
for i := len(countToNum) - 1; i >= 0 && len(topK) < k; i-- {
topK = append(topK, countToNum[i]...)

Choose a reason for hiding this comment

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

特に問題はないですが、この解法だけ一般にはkを超える配列を返却する可能性があるな、と思いました。
(Leetcodeの制約では It is guaranteed that the answer is unique. なので、解として間違っているという事ではありません)

※Goのmakeの3つ目の引数のキャパシティは、要素数が増えたら勝手にスケールするんですね。

Copy link
Owner Author

Choose a reason for hiding this comment

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

GoでHeapを使う場合はInterfaceの実装が必要なんですね。

はい、おっしゃる通りです。なので、特に使わないメソッドについてもインターフェースを満たすように実装しています。

※Goのmakeの3つ目の引数のキャパシティは、要素数が増えたら勝手にスケールするんですね。

その分、新しい領域の確保と要素のコピーのオーバーヘッドが発生するので、kを超える配列が発生する場合にそれを避けたい場合は、ユニークな要素数(len(countToNum))分のキャパシティを確保する必要があるのかと思いますが、topKの要素数よりもユニークな要素数がはるかに大きいことが予想できる場合はメモリが無駄になるので考えものですね、、

@hroc135
Copy link

hroc135 commented Aug 17, 2024

https://github.com/orlp/pdqsort?tab=readme-ov-file#the-best-case
ここにはpdqsortの最適ケースについても記載されています。私が好きな特長は、

Linear time is achieved for inputs that are (...) strictly in ascending order followed by one out-of-place element.

の部分で、

slices.Sort(nums)
nums = append(nums, 1)
slices.Sort(nums)

のようなコードだと、1回目のソートはO(nlogn)で、2回目のソートはO(n)でできるのが時々役に立つと思います

Comment on lines +14 to +17
type Element struct {
num int
count int
}

Choose a reason for hiding this comment

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

実際に開発をしていると、独自定義の構造体には適切な Go Doc Comment が欲しいなと思うことが多いです。面接時にコードに反映させるかどうかは状況次第だと思いますが、次のように書いておくとより親切になるだろうな、ぐらいのことが考えられているとより良いように思います: https://tip.golang.org/doc/comment#type

ちなみにちゃんと書いておくとこのような見た目で出力することが出来、便利です: https://blog.lufia.org/entry/2018/05/14/150400

@ryoooooory
Copy link

確認おくれました!いいとおもいます!

@rihib rihib mentioned this pull request Nov 18, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants