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

Number of 1 Bits #46

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open

Number of 1 Bits #46

wants to merge 1 commit into from

Conversation

rihib
Copy link
Owner

@rihib rihib commented Oct 19, 2024

Number of 1 Bitsを解きました。レビューをお願い致します。

問題:https://leetcode.com/problems/number-of-1-bits/
言語:Go

すでに解いている方々:
Kitaken0107/GrindEasy#24

関連→ #44

ハミング重み、ハミング距離

ハミング重み(ハミングおもみ、英: Hamming weight)とは、シンボル列中の 0 以外のシンボルの個数である。典型的には、ビット列中の1の個数として使われる。ハミング重みは、情報理論や符号理論、暗号理論を含めた複数の分野で使用されている。ハミング重みは、0 だけからなるシンボルとのハミング距離と等しい。

https://ja.wikipedia.org/wiki/ハミング重み

バイナリ表現においては、ハミング重みは、population count, popcount, sideways sum, bit summitationなどと呼ばれたりもする。x86などにpopcntという命令があるなど、機械語命令としても存在することがある(Hamming weight > Efficient implementation - Wikipedia参照)。

情報理論において、ハミング距離(ハミングきょり、英: Hamming distance)とは、等しい文字数を持つ二つの文字列の中で、対応する位置(同じインデックス)にある異なった文字の個数である。

https://ja.wikipedia.org/wiki/ハミング距離

AとBのハミング距離は、 A XOR Bのハミング重みと等しい。

2の補数

位取り基数法

位取り基数法とはいくつかの数字を並べて数を表す方法である(1, 2, 3を並べて123を表すなど)。1つの桁を $N$ 種の数字列の組み合わせで表す位取り基数法を $N$ 進位取り基数法または $N$ 進法と呼ぶ。

2の補数とは

2の補数とは、2を位取り基数法の基数とした場合の基数の補数(つまり2進数の補数)である。補数とは、ある数 $x$ との和が基準となる数 $C$ に等しくなるような数である。補数を $xc$ とするとき、 $C = x + xc$ となる。よって $x = C - xc$ である(同時に $xc = C - x$ にもなることから、 $x$$xc$ の補数でもある)。つまり、 $x$ の2の補数は $2^n - x$ である。たとえば1011(11)の2の補数は $2^4 - 11 = 5$ なので101になる。

ビット操作で2の補数を得る

または $x$ のビットを反転させて1を足すことで $x$ の2の補数を得ることもできる。たとえば1011の2の補数を求めたい場合はまずビットを反転させて100を得て、それに1を足すことで101が2の補数だとわかる。

1011のビットを反転させるということはつまり $1111 - 1011$ をしているのと同じである。また1111$2^4 - 1$ (15)である。つまり、 $n$ 桁のビットを持つ $x$ のビットを反転させると $(2^n - 1) - x$ になる。そのため、 $x$ の2の補数は $2^n - x$ であることから、ビットを反転させた後に1を足すことで2の補数を得ることができるのである。

2の補数を使った減算

$x$ の2の補数を $xc$ とするとき、 $x + xc = 2^n$ という関係が成り立つ。 $n$ ビットの世界では $2^n$ は桁溢れを起こして0と等しくなる(たとえば5ビットの符号なし整数が表現できる最大値は31なので $2^5$ (32)は0になる)ので、つまり $x + xc = 0$ と言える。よって $xc = -x$ と言えることから、 $y - x$ という減算を行いたいとき、 $y - x = y + xc$ となるので、2の補数を使って負の整数を表すことで、引き算を使わずに足し算のみで減算を行える。

たとえば $11 - 5$ という減算を行う場合、これは5ビットの符号あり整数を使うと $01011 + 11011 = 100110$ となり(5は $00101$ なので-5は $11011$ になる)、100110はオーバーフローしていることから最終的な減算結果は00110(6)となる。

引き算用の回路を作らなくて済むので、コンピュータの世界では負の整数は2の補数を使って表すことが一般的である。なのでたとえば4の2の補数が-4を表すことになる。4の2の補数は5ビットの整数なら、この場合は28になる( $2^5 - 4$ )ので、11100が-4を表すことになる。反対に-28を表したい場合は28の補数は4になるので、00100となる。

ここで、5ビットを使って、負の整数だけでなく正の整数も扱いたいとなった場合を考える。1110000100というビットの並びがあったときに、これが正の整数を表しているのか負の整数を表しているのかがこの並びを見るだけではわからない。11100は-4を表しているかもしれないし、28を表しているかもしれない。同様に00100も-28を表しているかもしれないし、4を表しているかもしれない。

これだと不便なので、MSB(一番位の高いビット)が0の場合に正の整数、1の場合に負の整数を表しているものと決めてしまう。そうすると、00000(0) ~ 01111(15)が正の整数を、11111(-1) ~ 10000(-16)が負の整数を表すようになる。MSBが1の場合に正の整数、0の場合に負の整数を表すものとしてしまうことも可能だが、その場合は10000(16) ~ 11111(31)が正の整数を、01111(-17) ~ 00000(-32)が負の整数を表すようになり、表せる範囲が-32~-17, 16~31となってしまうので、使い勝手が良くない。

(補足)1の補数について

ちなみに $b$ 進数を基数とする場合の $x$ に対する減基数の補数は $(b^n - 1) - x$ と定義される。基数が文脈上明らかなら単に $(b-1)$ の補数と呼ばれる。そのため、2進法における減基数の補数は、1の補数と呼ばれる。

たとえば1011(11)の2の補数は $(2^4 - 1) - 11 = 4$ なので0100になる。つまりビットを単に反転させることで1の補数を得ることができる(2の補数を求めたい場合はビットを反転させた後に1を足す必要がある)。そのため1の補数は同じゼロを表現する際に、正のゼロ(0000)と負のゼロ(1111)の2つのゼロが存在することになる。

Rightmost set bit

Rightmost set bitをunsetする方法

Rightmost set bitをunsetするには、単純に実装すると右シフトを繰り返していくことで実現できるが、この場合はRightmost set bitが $n$ 桁目に存在する場合に、 $O(n)$ の時間計算量になる。

func hammingWeightNaive1(n int) int {
	count := 0
	for n > 0 {
		count += n % 2
		n /= 2
	}
	return count
}

func hammingWeightNaive2(n int) int {
	count := 0
	for n != 0 {
		count += n & 1
		n >>= 1
	}
	return count
}

効率的にRightmost set bitをunsetするには、n & (n-1)とするか2の補数を使うかの2つの方法がある。この場合、Rightmost set bitをunsetする時間計算量は $O(1)$ になる。

n & (n-1)

たとえば $n$10100のとき、 $n-1$10011となる。10001のときは10000になる。つまり、 $n-1$ とすると繰り下がるため、Rightmost set bitは0になり、Rightmost set bitの下の位のビットは全て1になる。そのためn & (n-1)とすることで、100...011...のANDがとられるので000...になり、Rightmost set bitがunsetされる。

Turn off the rightmost set bit

2の補数を使う

前述したように $n$ ビットの世界では、 $xc = -x$ なので、単に $-x$ とすることで、 $x$ の2の補数を得ることができる。またこれも前述したように、2の補数は $x$ のビットを反転させて1を足したものである。たとえば10110の2の補数は01010であり、10001の2の補数は01111になる。ここで注目したいのは、 $x$ とその2の補数はお互いにRightmost set bit以外が反転しているということである。つまり、x & -xとすると、Rightmost set bitのみが1になったビット列を得ることができる。よって、x - (x & -x)としてあげれば、 $x$ のRightmost set bitをunsetすることができるのである。

Unset he rightmost set bit using 2s complement

Rightmost unset bitをsetする方法

Rightmost unset bitとは最下位の0になっているbitのことである。たとえば1011なら3番目のビットが、1100ならLSBがRightmost unset bitである。このRightmost unset bitをsetするには、 $x$ のビットを反転させた^xを得て、そのRightmost set bitをunsetし、最後にそれを再度反転させれば、Rightmost unset bitをsetすることができる。Rightmost set bitをunsetする方法は前述のとおりである。

func setRightmostUnsetBit(n int) int {
	flipped := ^n
	flipped &= flipped - 1
	return ^flipped
}

Set the rightmost unset bit


rightmost set bitは n & (n - 1) で求めることができるので、効率的にcountしていくことができる。
*/
func hammingWeightStep1(n int) int {

Choose a reason for hiding this comment

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

複数回この関数が呼ばれるならどう最適化しますか?

Copy link
Owner Author

@rihib rihib Oct 19, 2024

Choose a reason for hiding this comment

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

たとえばあらかじめnビット列の全てのパターンについてハミングウェイトを計算しておき、それを使って入力されたビット列のハミングウェイトを計算するというのはいかがでしょうか。

30ビットぐらいであれば、4GBぐらいに収まると思うので現実的な気がします。

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.

ありがとうございます、承知しました🙏

Copy link

@liquo-rice liquo-rice Oct 19, 2024

Choose a reason for hiding this comment

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

全てのパターンに対して事前計算した結果を利用するのとその都度計算するのはどちらが速そうですか?
この問題なら32ビット列ですが、64ビットまたはそれ以上ならどうしましょうか?

Copy link
Owner Author

@rihib rihib Oct 19, 2024

Choose a reason for hiding this comment

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

この関数の場合、一度のループで3nsぐらいかかるかなと思ってまして、それをsetされているビット数分繰り返しただけの時間がかかると思います。パターンを保持しておく方法だと、10ビットぐらいであれば1次キャッシュに載りますし、20ビット弱ぐらいなら2次キャッシュに載るかと思いますので、1~10nsぐらいで求まるかと思います。ただし30ビットぐらいになるとメモリにアクセスする必要が出てくると思うので、メモリアクセスに100nsぐらいかかるとすると、その都度計算する方法の方が速くなってくるかもしれません。

保持している以上のビット数が来た場合ですが、その場合は都度nビット分だけ取り出すということを繰り返して、合計を求めるということができるかと思います。

Copy link
Owner Author

@rihib rihib Oct 19, 2024

Choose a reason for hiding this comment

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

なので、10bitぐらいのパターンを保持しておいて、都度10ビット分だけ取り出すということを繰り返して、合計を求めるというのが一番良いのではないかと思いました。

Choose a reason for hiding this comment

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

その方針で良さそうですね。

/*
時間:3分

rightmost set bitは n & (n - 1) で求めることができるので、効率的にcountしていくことができる。
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.

まだ見てないです。頭から抜けてました。これも参考にして書き直してみます。

@nodchip
Copy link

nodchip commented Oct 22, 2024

この辺りも読むと良いかもしれません。
https://pkg.go.dev/math/bits#OnesCount

分割統治法を用いた効率的なアルゴリズムは、ソフトウェアエンジニアの常識には含まれてはいないと思うのですが、「ハッカーのたのしみ: 本物のプログラマはいかにして問題を解くか」に載っているため、知ってる人は知っていると思います。
https://en.wikipedia.org/wiki/Hamming_weight#Efficient_implementation

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.

4 participants