以字节为单位进行大小写转换

Posted by 吴俊贤 on October 15, 2018

以小写转换大写为例子

字节操作

这个小写转换,很容易就知道,给每一位都减去'a'-'A'的值,也就是32即可。换成字节的话,直接减去0x20202020即可。

话说当初设计ASCII表的时候,故意把大小写字母的间隔调整为32的吗,这样子这个差值就只有一个bit是1了,做很多运算都非常方便,比如接下来的。

难就难在,我们不能把非小写的字母改掉,这就带来了问题,我们需要判断:每一个字节都在[97,122]这个区间内。

判断字节是否大于等于97

我们不可能一个一个字节的判断,这样就回到了一个一个字节的处理方法上了,我们要用32比特的整数。

那么跟刚才减去0x20202020这个做法类似,我减去每个字节都是97的数,也就是0x61616161。但是,我们不能直接减0x61616161 。减法的实现就是加上负的减数,而我们知道,负数在计算机里面是以补码形式存储的。补码是取反然后加1,我把0x61616161取反加一之后,得到的数是0x9E9E9E9F,就不是本来那个每个字节字节减去97的效果了。我们应该用-97的补码表示,取得这个加的数,也就是0x9F9F9F9F,这样就是每一个位都减了97。

比如,0x61626364的输入,得到的是0x01020303,高位都是0,说明这些数,都大于等于97。

但是,还有一个问题,不知道大家发现了没有。

两个二进制数相减,变成一个数加上一个负的另一个数,如果得到一个正的结果,将会有一个进位1产生。这在字节操作是没有这个问题的,进位1因为超出了字节,被忽略了。

但是,在这里就有问题了。

这个进位1,加到了下一个字节里面去,把本来加的结果给继续加了1。如果我输入,0x60616161的话,我得到的,就是0x00010100,最高的字节居然是0了!0x60是小于97的,产生了错误的结果。

因此,我们必须减去进位。

进位的获取也很简单,只需要加上0x9F9F9F9F之后,一样与上0x80808080取字节最高位,然后再取反,因为最高位为0的是有进位的,我们取反让进位1出现了。再向左移1位,对齐下一个字节的数。然后,用原本加完了0x9F9F9F9F的数,减去这个得到的进位,就是本来应该是的结果了。

比如,还是0x60616161这个例子,按照下列步骤处理。

  1. 加上0x9F9F9F9F得到0x00010100
  2. 取得最高位,与上0x80808080,得到0
  3. 取反,异或0x80808080,得到0x80808080
  4. 左移1位,得到0x01010100
  5. 0x00010100减去0x01010100,就得到应该得到的结果,0xFF000000
  6. 0xFF000000与上0x80808080,得到结果,0x80000000
  7. 0x80000000右移2位,得到0x20000000
  8. 0x20000000异或0x20202020,得到最终结果0x00202020

经过这几个步骤之后,我们去掉了第一个字节的32,第一个字节就不需要减去32了。

判断字节是否小于等于122

做法与上面是类似的,只需要每一位加上-123,也就是0x85

减去处理完的0x20

在处理完0x20202020得到最终的减数之后,我们终于可以使用本来的数字,减去这个的出来的减数,以变成大写了。

时间性能对比

用了下面两个用例函数来测试了下性能

func BenchmarkToUpperCaseUnsafe(b *testing.B) {
	str := "@ABCDEFGHIJKLMNOPQRSTUVWXYZ_`abcdefghijklmnopqrstuvwxyz{|}"
	for i := 0; i < 1000; i++ {
		str += "@ABCDEFGHIJKLMNOPQRSTUVWXYZ_`abcdefghijklmnopqrstuvwxyz{|}"
	}
	b.ResetTimer()

	for i := 0; i < b.N; i++ {
		toUpperCaseUnsafe(str)
	}
}

func BenchmarkToUpperCaseByte(b *testing.B) {
	str := "@ABCDEFGHIJKLMNOPQRSTUVWXYZ_`abcdefghijklmnopqrstuvwxyz{|}"
	for i := 0; i < 1000; i++ {
		str += "@ABCDEFGHIJKLMNOPQRSTUVWXYZ_`abcdefghijklmnopqrstuvwxyz{|}"
	}
	b.ResetTimer()

	for i := 0; i < b.N; i++ {
		toUpperCaseByte(str)
	}
}

结果是按32位处理的每次59550ns,字节处理每次52542ns,还要慢。其中为了提高性能使用了unsafe包将byte数组强制转换成了int数组,也只能是这样的结果。

应该还可以优化,或者如果是64位处理呢?

极致优化方法

之前还是太蠢没有想到一个不用判断进位的方法。

本来ascii就设计成7位的二进制数,那么我完全可以在第八位设置1。如果低7位的相减结果是正数,那么不需要借第8位的1来减。这样,上文所说的进位,就不会传递给下一个字节了。

另外,0x20202020这个数完全可以不需要使用,只需要拿两个小于97和小于等于122的结果做一次位运算就行。

具体是,首先我们构造一个数,在97的第8个二进制位设置成1,也就是0xE0。我们新的方法,用0xE0E0E0E0减去输入值。如果对应字节上低7位的数小于97的话,那么减出来是个正数,前面第8位的1保持不动。如果是个负数,则需要向第8位借位,此时结果第一位就是0。

小于等于122的判断类似。

然后,我们得到两个结果,一个是判断小于97的数l97,一个是判断小于等于122的数le122,我们需要取中间的数,那么给l97取反(其他7位不变),再与上le122,高8位为1的,就是在区间[97,122]之内的数了。

然后右移两位,与输入结果进行一个异或运算,这相当于减去了32。

下面是go语言实现

// 极致性能
func uint32toUpperCaseV3(char uint32) uint32 {

	// 0xE1是96前面一位为1
	num1 := uint32(0xE0E0E0E0)
	// 0xFA是122前面一位为1
	num2 := uint32(0xFAFAFAFA)

	// 字节最高位为1的就是小于97的
	lessThan97 := (num1 - char) & 0x80808080
	// 字节高位为1的就是小于122的
	lessThanEqualTo122 := (num2 - char) & 0x80808080

	// smallerThan97最高位为0的和smallerThan123最高位为1的,就是在区间内的,与其来
	between97And122 := (lessThan97 ^ 0x80808080) & lessThanEqualTo122

	return char ^ (between97And122 >> 2)
}