背景
现在已经连续刷了三个月的 leetcode 题目,同时也看了不少算法书籍文章,明显感觉做算法题的速度和准确率提升了不少。毕竟前段时间的面试上在算法题上面栽过跟头,想着这块硬骨头还是得啃一下, 就开始去力扣刷每日一题和周赛,没想到居然已经坚持了三个月。
计划做一下 Go 语言版的数据结构与算法专项整理,让我们看到问题都迎刃而解。现在就从“整数”专项开始吧。
整数的基础知识
整数是一种基本的数据类型。我们在做整数相关的运算时,需要考虑计算结果是否超过范围,产生溢出会让我们得到错误的结果。
Go 中的整数类型取值范围:
uint8 : 0 to 255
uint16 : 0 to 65535
uint32 : 0 to 4294967295
uint64 : 0 to 18446744073709551615
int8 : -128 to 127
int16 : -32768 to 32767
int32 : -2147483648 to 2147483647
int64 : -9223372036854775808 to 9223372036854775807
可以在 math 包看到内置的整数范围常量:
// Integer limit values.
const (
intSize = 32 << (^uint(0) >> 63) // 32 or 64
MaxInt = 1<<(intSize-1) - 1
MinInt = -1 << (intSize - 1)
MaxInt8 = 1<<7 - 1
MinInt8 = -1 << 7
MaxInt16 = 1<<15 - 1
MinInt16 = -1 << 15
MaxInt32 = 1<<31 - 1
MinInt32 = -1 << 31
MaxInt64 = 1<<63 - 1
MinInt64 = -1 << 63
MaxUint = 1<<intSize - 1
MaxUint8 = 1<<8 - 1
MaxUint16 = 1<<16 - 1
MaxUint32 = 1<<32 - 1
MaxUint64 = 1<<64 - 1
)
我们经常会用到比较整数大小的函数,但是 Go 没有内置该方法:
func min(a, b int) int {
if a < b {
return a
}
return b
}
func max(a, b int) int {
if a > b {
return a
}
return b
}
二进制
整数在计算机中是以二进制的形式表示的。二进制是指数字的每位都是0或1。
位运算是把数字用二进制形式表示之后,对每位上0或1的运算。
Go 语言支持的位运算有:与(&)、或(|)、异或(^)、左移(<<)和右移(>>)。
左移运算符 m << n 表示把 m 左移 n 位,即 m 乘以 2 的 n 次方。如果左移 n 位,那么最左边的 n 位将被丢弃,同时在最右边补上 n 个 0。
右移运算符 m >> n 表示把 m 右移 n 位,即 m 除以 2 的 n 次方。如果右移 n 位,则最右边的 n 位将被丢弃。但右移时处理最左边位的情形比较复杂。如果数字是一个无符号数值,则用 0 填补最左边的 n 位。如果数字是一个有符号数值,则用数字的符号位填补最左边的 n 位。也就是说,如果数字原先是一个正数,则右移之后在最左边补 n 个 0;如果数字原先是一个负数,则右移之后在最左边补 n 个 1。
// 二进制位运算示例
func binaryExample() {
a := 60 // 60 = 00111100
fmt.Printf("a = %d, %08b\n", a, a)
b := 13 // 13 = 00001101
fmt.Printf("b = %d, %08b\n", b, b)
c := a & b // 12 = 00001100
fmt.Printf("a & b = %d, %08b\n", c, c)
c = a | b // 61 = 00111101
fmt.Printf("a | b = %d, %08b\n", c, c)
c = a ^ b // 49 = 00110001
fmt.Printf("a ^ b = %d, %08b\n", c, c)
c = a << 2 // 240 = 11110000
fmt.Printf("a << 2 = %d, %08b\n", c, c)
c = a >> 2 // 15 = 00001111
fmt.Printf("a >> 2 = %d, %08b\n", c, c)
}
/* 打印结果
a = 60, 00111100
b = 13, 00001101
a & b = 12, 00001100
a | b = 61, 00111101
a ^ b = 49, 00110001
a << 2 = 240, 11110000
a >> 2 = 15, 00001111
*/
例题
29. 两数相除
题目:输入2个 int 型整数,它们进行除法计算并返回商,要求不得使用乘号'*'、除号'/'及求余符号'%'。当发生溢出时,返回最大的整数值。假设除数不为0。
例如,输入15和2,输出15/2的结果,即7。
分析: 当被除数大于除数时,继续比较判断被除数是否大于除数的2倍,如果是,则继续判断被除数是否大于除数的4倍、8倍等。如果被除数最多大于除数的 2k 倍,那么将被除数减去除数的 2k 倍,然后将剩余的被除数重复前面的步骤。由于每次将除数翻倍,因此时间复杂度是 O(logn)。
32 位有符号整数的取值范围是 [−231, 231 − 1]。将 −231 转换为正数会导致溢出,将任意正数转换为负数都不会溢出,所以可以先将正数转换为负数,计算完后,再调整结果的正负号。这里唯一的溢出情况是 −231 / -1,231 超过了正数范围。
可以用左移运算符替代乘以2,右移运算符替代除以2。
参考代码:
func divide(dividend int, divisor int) int {
if dividend == math.MinInt32 && divisor == -1 {
return math.MaxInt32
}
negative := 2
if dividend > 0 {
negative--
dividend = -dividend
}
if divisor > 0 {
negative--
divisor = -divisor
}
result := 0
halfMinInt32 := math.MinInt32 >> 1
for dividend <= divisor {
value := divisor
quotient := 1
for value >= halfMinInt32 && dividend <= value << 1 {
quotient = quotient << 1
value = value << 1
}
result += quotient
dividend -= value
}
if negative == 1 {
return -result
}
return result
}
67. 二进制求和
题目:输入两个表示二进制的字符串,请计算它们的和,并以二进制字符串的形式输出。
例如,输入的二进制字符串分别是"11"和"10",则输出"101"。
分析: 如果直接转为整数去运算,可能会溢出。可以从两个字符串的右端开始相加,逢二进一,把每一位的运算结果依次添加到数组中,最后再反转数组。
func addBinary(a string, b string) string {
arr := make([]byte, 0)
k := 0
var carry uint8
for k < len(a) || k < len(b) || carry > 0 {
var r = carry
if k < len(a) {
r = r + a[len(a)-k-1] - '0'
}
if k < len(b) {
r = r + b[len(b)-k-1] - '0'
}
k++
if r == 2 {
r = 0
carry = 1
} else if r == 3 {
r = 1
carry = 1
} else {
carry = 0
}
arr = append(arr, r+'0')
}
// 反转数组
for i, j := 0, len(arr)-1; i < j; i, j = i+1, j-1 {
arr[i], arr[j] = arr[j], arr[i]
}
return string(arr)
}
338. 比特位计数
题目:输入一个非负数n,请计算0到n之间每个数字的二进制形式中1的个数,并输出一个数组。
例如,输入的n为4,由于0、1、2、3、4的二进制形式中1的个数分别为0、1、1、2、1,因此输出数组[0,1,1,2,1]。
分析: 最简单的方式是把整数转为二进制字符串,然后数该字符串中 1 的个数。 Golang 把整数转为二进制字符串的方法是 strconv.FormatInt(int64(i), 2),但是这么做的效率较低,
我们发现 i & (i-1) 的结果会把 i 的二进制形式最右边的 1 置为 0,比如 111 & 110 = 110, 110 & 101 = 100, 100 & 011 = 0。
由此可以得到状态转移方程:f(i) = f(i & (i-1)) + 1,参考代码如下:
func countBits(n int) []int {
arr := make([]int, n+1)
for i := 1; i <= n; i++ {
arr[i] = arr[i&(i-1)] + 1
}
return arr
}
思路二: 偶数右移一位时,1 的个数不变,奇数右移一位时,1 的个数减 1。比如 110 >> 1 = 11, 111 >> 1 = 11。
同时用 i & 1 替代 i % 2 运行效率会更高。
可以得到状态转移方程:f(i) = f(i >> 1) + i & 1,参考代码如下:
func countBits(n int) []int {
arr := make([]int, n+1)
for i := 1; i <= n; i++ {
arr[i] = arr[i>>1] + (i & 1)
}
return arr
}
318. 最大单词长度乘积
题目:输入一个字符串数组words,请计算不包含相同字符的两个字符串words[i]和words[j]的长度乘积的最大值。如果所有字符串都包含至少一个相同字符,那么返回0。假设字符串中只包含英文小写字母。
例如,输入的字符串数组words为["abcw","foo","bar","fxyz","abcdef"],数组中的字符串"bar"与"foo"没有相同的字符,它们长度的乘积为9。
"abcw"与"fxyz"也没有相同的字符,它们长度的乘积为16,这是该数组不包含相同字符的一对字符串的长度乘积的最大值。
分析: 首先,遍历比较任意两个字符串是否存在相同字符,不存在相同字符则计算乘积。这个题目假设所有字符都是英文小写字母,只有26个可能的字符,因此最多只需要在每个字符串对应的哈希表中查询26次就能判断两个字符串是否包含相同的字符。
由于这个题目只需要考虑26个英文小写字母,因此可以用一个长度为26的布尔型数组来模拟哈希表。数组下标为0的值表示字符'a'是否出现,下标为1的值表示字符'b'是否出现,其余以此类推。代码参考如下:
func maxProduct(words []string) int {
wordCnt := make([][26]bool, len(words))
for i, word := range words {
for _, ch := range word {
wordCnt[i][ch - 'a'] = true
}
}
var res int
for i := 0; i < len(words)-1; i++ {
for j := i+1; j < len(words); j++ {
k := 0
for ; k < 26; k++ {
if wordCnt[i][k] && wordCnt[j][k] {
break
}
}
if k == 26 {
res = max(len(words[i]) * len(words[j]), res)
}
}
}
return res
}
思路二: 用整数的二进制数位记录字符串中出现的字符。
布尔值只有两种可能,即true或false,这与二进制有些类似,要么是0要么是1。因此,可以将长度为26的布尔型数组用26个二进制的数位代替,二进制的0对应布尔值false,而1对应true。同时 int32 的二进制有 32 位,可以满足我们的需求。代码参考如下:
func maxProduct2(words []string) int {
wordCnt := make([]int, len(words))
for i, word := range words {
for _, ch := range word {
wordCnt[i] |= 1 << (ch - 'a')
}
}
var res int
for i := 0; i < len(words)-1; i++ {
for j := i+1; j < len(words); j++ {
if wordCnt[i] & wordCnt[j] == 0 {
res = max(len(words[i]) * len(words[j]), res)
}
}
}
return res
}
参考
- Go 数据结构与算法之整数示例源码
- 《剑指Offer(专项突破版):数据结构与算法名企面试题精讲》
- 二进制与 Go 的原子操作