如果想在Go语言中生成只有随机字符(大写或小写)、没有数字的定长字符串。什么方法最快最简单?
这个问题要求“最快最简单的方法”。我们将由浅入深,以循序渐进的方式给出最终最快的代码。(可以在答案的最后找到每次迭代的基准测试。)
所有解决方案和基准测试代码都可以在Go Playground上找到。 Playground上的代码是测试文件,而不是可执行文件。将其保存到名为XX_test.go 的文件中,并使用go test -bench . 可以运行它。
一,逐步改进的方法
1.起步(字符)
我们需要改进的原始通用解决方案是:
func init() {
rand.Seed(time.Now().UnixNano())
}
var letterRunes = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
func RandStringRunes(n int) string {
b := make([]rune, n)
for i := range b {
b[i] = letterRunes[rand.Intn(len(letterRunes))]
}
return string(b)
}
2.字节
如果要选择和汇总的字符来自随机字符串(只包含英文字母的大写和小写字母),我们可以使用字节,因为英文字母映射到UTF-8编码中的字节是1对1(是如何存储字符串)。
所以代替:
var letters = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
我们可以用:
var letters = []bytes("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
甚至更好的:
const letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
现在这已经是一个很大的改进:我们可以将它变为const (有string常量 ,但是没有slice常量)。作为额外的收益,表达式len(letters) 也将是const ! (如果s 是一个字符串常量,则表达式len(s) 是常量。)
这样的性能开销是多少?几乎没有开销! string 可以编入索引,索引其字节。完美,正是我们想要的。
所以我们的下一个方法如下:
const letterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
func RandStringBytes(n int) string {
b := make([]byte, n)
for i := range b {
b[i] = letterBytes[rand.Intn(len(letterBytes))]
}
return string(b)
}
3.求余数
之前的解决方案通过调用Rand.Intn() 获得一个随机数来指定随机字母,rand.Intn() 委托给Rand.Int31n() 。
与rand.Int63() 相比,这要慢得多,后者产生一个63随机bits的随机数。
所以我们可以简单地调用rand.Int63() 并在除以len(letterBytes) 后使用余数:
func RandStringBytesRmndr(n int) string {
b := make([]byte, n)
for i := range b {
b[i] = letterBytes[rand.Int63() % int64(len(letterBytes))]
}
return string(b)
}
这种方法效果明显更快,缺点是所有字母的概率都不完全相同(假设rand.Int63() 以相同的概率产生所有63位数字)。尽管由于字母52 的数量比1<<63 - 1 小很多,但是失真非常小,所以在实践中这非常精细。
为了使这个理解更容易:假设你想要一个0..5 范围内的随机数。使用3个随机位,这将产生具有双倍概率的数字0..1 ,而不是来自范围2..5 。使用5个随机位,0..1 范围内的数字将与6/32 概率和2..5 范围内的数字一起出现,其中5/32 概率现在更接近期望值。增加位数会使这一点变得不那么重要,当达到63位时,它可以忽略不计。
4.掩码
在前面的解决方案的基础上,我们可以通过使用随机数的最低位来保证字母的平等分配。因此,例如,如果我们有52个字母,则需要6位来表示它:52 = 110100b 。所以我们只使用rand.Int63() 返回的最低6位数。并且为了保持字母的平均分配,我们只有”接受”落在0..len(letterBytes)-1 的范围内的数字。如果最低位更大,我们将其丢弃并查询新的随机数。
请注意,最低位大于或等于len(letterBytes) 的可能性一般小于0.5 (平均为0.25 ),这意味着即使出现这种情况,重复此”rare”案例也会降低找不到的好的数字的可能性。在n次 重复之后,我们没有获得好数字的机会远小于pow(0.5, n) ,这只是一个较高的估计。在52个字母的情况下,6个最低位不好的可能性仅为(64-52)/64 = 0.19 ;这意味着例如在10次重复之后没有良好数字的机会是1e-8 。
所以新的解决方案如下:
const letterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
const (
letterIdxBits = 6 // 6 bits to represent a letter index
letterIdxMask = 1<<letterIdxBits - 1 // All 1-bits, as many as letterIdxBits
)
func RandStringBytesMask(n int) string {
b := make([]byte, n)
for i := 0; i < n; {
if idx := int(rand.Int63() & letterIdxMask); idx < len(letterBytes) {
b[i] = letterBytes[idx]
i++
}
}
return string(b)
}
5.掩码改进
前面的解决方案仅使用rand.Int63() 返回的63个随机位中的最低6位。这是一种浪费,因为获取随机位是我们算法中最慢的部分。
如果我们有52个字母,那意味着6bits编码字母索引。所以63个随机位可以指定63/6 = 10 不同的字母索引。让我们使用所有这10个:
const letterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
const (
letterIdxBits = 6 // 6 bits to represent a letter index
letterIdxMask = 1<<letterIdxBits - 1 // All 1-bits, as many as letterIdxBits
letterIdxMax = 63 / letterIdxBits // # of letter indices fitting in 63 bits
)
func RandStringBytesMaskImpr(n int) string {
b := make([]byte, n)
// A rand.Int63() generates 63 random bits, enough for letterIdxMax letters!
for i, cache, remain := n-1, rand.Int63(), letterIdxMax; i >= 0; {
if remain == 0 {
cache, remain = rand.Int63(), letterIdxMax
}
if idx := int(cache & letterIdxMask); idx < len(letterBytes) {
b[i] = letterBytes[idx]
i--
}
cache >>= letterIdxBits
remain--
}
return string(b)
}
6.来源
掩码改进非常好,我们还可以改进它,但过于复杂以至于不值得这么做了。
现在让我们找到其他改进的点——看看随机数的来源。
有一个crypto/rand 包提供了一个Read(b []byte) 函数,所以我们可以用它来获得我们需要的单个调用所需的字节数。这在性能方面没有帮助,因为crypto/rand 实现了加密安全的伪随机数生成器,因此速度要慢得多。
让我们坚持使用math/rand 软件包。 rand.Rand 使用rand.Source 作为随机位的来源。 rand.Source 是一个指定Int63() int64 方法的接口:我们最新解决方案中唯一需要和使用的方法。
所以我们真的不需要rand.Rand (显式或全局,共享的rand 包),rand.Source 对我们来说已经足够了:
var src = rand.NewSource(time.Now().UnixNano())
func RandStringBytesMaskImprSrc(n int) string {
b := make([]byte, n)
// A src.Int63() generates 63 random bits, enough for letterIdxMax characters!
for i, cache, remain := n-1, src.Int63(), letterIdxMax; i >= 0; {
if remain == 0 {
cache, remain = src.Int63(), letterIdxMax
}
if idx := int(cache & letterIdxMask); idx < len(letterBytes) {
b[i] = letterBytes[idx]
i--
}
cache >>= letterIdxBits
remain--
}
return string(b)
}
另请注意,最后一个解决方案不要求您初始化(播种)math/rand 软件包的全局Rand ,因为它未被使用(并且我们的rand.Source 已正确初始化/设置种子)。
还有一点需要注意:math/rand 的包文档说明:
The default Source is safe for concurrent use by multiple goroutines.
所以默认源比rand.NewSource() 可能获得的Source 慢,因为默认源必须在并发访问/使用时提供安全性,而rand.NewSource() 不提供此功能(因此它返回的Source可以 更快) )。
二、评测
好吧,让我们对不同的解决方案进行基准测试。
BenchmarkRunes 1000000 1703 ns/op
BenchmarkBytes 1000000 1328 ns/op
BenchmarkBytesRmndr 1000000 1012 ns/op
BenchmarkBytesMask 1000000 1214 ns/op
BenchmarkBytesMaskImpr 5000000 395 ns/op
BenchmarkBytesMaskImprSrc 5000000 303 ns/op
只需从字符串切换到字节,我们立即获得22%的性能提升。
摆脱rand.Intn() 并使用rand.Int63() 代替另外24%的提升。
掩码(并且在大索引的情况下重复)减慢一点(由于重复调用):-20%……
但是当我们使用63个随机位中的所有(或大多数)(来自一个rand.Int63() 调用的10个索引)时:它加速了3.4倍。
最后,如果我们解决了(non-default,新)rand.Source 代替rand.Rand ,我们再次获得23%。
比较最终解决方案:RandStringBytesMaskImprSrc() 比RandStringRunes() 快5.6倍。
参考资料
- How to generate a random string of a fixed length in Go?
|