程序设计#2 - 序列入门和缓冲输入输出
这一篇是关于 数组、字符串。因为这些东西的内容极其宏大,所以本篇仅仅是一个入门,连初步都算不上。
此外,本篇还涉及了计算机底层逻辑的输入输出的教程。缓冲输入输出的意思是,将键盘从命令行输入、输出到命令行的内容优先存到缓冲区,这样程序就不需要频繁地读写命令行了。
数组和字符串都是由一系列元素组成的一个序列。它们的性质十分相似,因此放在同一篇教程里详细讨论。
序列的引入
还记得在 前期准备 中我们谈到的 数组和字符串 吗?它们有一个共同的特点:由几个元素以特定的顺序排列而成。它们的这种性质与数学中的数列(sequence,→ 数列 - 求闻百科)非常相似,因此在程序设计中,我们把具有这种特点的数据类型或数据结构称作 序列。
💡数组和字符串都是序列的代表。
package main
import (
"fmt"
)
func main() {
s := "A string is a sequence."
a := [7]int{6, 5, 1, 2, 3, 4, 5}
fmt.Printf("这是一个字符串:%s\n", s)
fmt.Printf("这是一个数组:%v\n", a)
}
输出
这是一个字符串:A string is a sequence.
这是一个数组:[6 5 1 2 3 4 5]
我们可以从序列中引出一个新概念:子序列。因为序列只关注元素的排列顺序,所以子序列也是只关注排列顺序的。我们通过一个具体的例子来看。
例如一个数组 a
: [1, 2, 3, 4, 5, 6, 7]
。
[1, 3, 5, 7]
是a
的子序列。因为在a
中,1
在3
前面,3
在5
前面,以此类推。即使它们在原数组中并不连续。[1, 2, 3, 4]
是a
的子序列。[1, 2, 3, 4, 5, 6, 7]
是a
的子序列。
这里有一个与子序列相近的概念:子数组。与子序列唯一不同的是,子数组在原数组中必定是连续的。一定要区分开。
因此,切片操作相当于在原数组中截取子数组。
package main
import (
"fmt"
)
func main() {
a := [7]int{1, 2, 3, 4, 5, 6, 7}
sub_a := a[1:5]
fmt.Printf("%v 的子数组之一是:%v\n", a, sub_a)
}
输出
[1 2 3 4 5 6 7] 的子数组之一是:[2 3 4 5]
字符串因为也是序列,所以同样有 子序列 和 子串 的概念。子串是原字符串中连续的一段。
序列操作
数组和切片
上一期我们提到了,切片是数组的引用类型,切片比数组多了一个性质:切片的长度是可变的。如果我们需要在一系列数据中添加或删除元素,那么切片是更符合实际需求的数据类型。
为了行文方便,以后不一定会把数组和切片分得特别清晰。
利用数组可以有序地组织元素的特性。如果在题目中要求反向输出所给的一系列数据,那么数组是最合适的选择。
例如 洛谷 P5727 【深基 5.例 3】冰雹猜想 中,我们就可以通过把当前的数增加到切片 a
中,这样在得到 1 后,开始反向输出 a
即可。
package main
import (
"fmt"
)
func main() {
var n int
fmt.Scan(&n)
a := make([]int, 0)
a = append(a, n) // 给出的 n 也要记录
for n != 1 {
if n % 2 == 1 {
n = n * 3 + 1
} else {
n /= 2 // 等价于 n = n / 2
}
a = append(a, n)
}
for i := len(a)-1; i >= 0; i-- {
fmt.Printf("%d ", a[i])
}
}
反向遍历切片可以使用如下格式。
for i := len(a)-1; i >= 0; i-- {
/* 代码 */
}
如果我们把一个数组套上另一个数组,那么这个数组就变成了 二维数组。二维数组也叫做 矩阵。
在 洛谷 P5728 中,题目给的就是一个行数为 N
,列数为 3 的矩阵。
由于操作涉及到总分,我们可以对每一行成绩求和,并且将成绩的和也合并到矩阵中,这样就更方便运算了。
绝对值可使用 Go 的标准库 math
中的函数 math.Abs()
,也可以自己写一个新函数。
💡如何写一个函数
数学中的函数描述了两个集合的对应关系。但在程序设计中的函数是用来实现特定功能的1。使用函数的原因之一是,我们希望反复地使用一个功能,又不希望每一次使用这功能都要敲一遍代码。
我们在此前已经接触到了一个函数 len()
,它能够接收输入,并返回一个数组、切片、字符串或是映射等的长度。
你应该能注意到,如果我们导入了包(例如 fmt
),我们调用包中的函数时,函数的首字母必然大写(例如 fmt.Println()
)原因在下一节结构体中解释。为了风格统一,建议自定义的函数首字母也大写。
与 len()
类似,我们希望绝对值函数 Abs()
能实现这样的功能。
- 如果
n < 0
,输出n
的相反数-n
。 - 如果
n >= 0
,输出n
。
函数的定义方式是这样。
func function(参数1 type, 参数2 type) 返回值1 type, {
/* 代码,这一块也叫函数体 */
}
⚠注意:function
表示函数名,in
表示输入,out
表示输出。三个 type
都填数据类型,如果数据类型填的是 any
,就表示可使用任意类型的数据。
以上的定义方式仅作为示例。函数的输入和输出都不需要限制数量。可以没有输入,也可以有很多输入。输出也一样。
因此,考虑到输入和输出都是 int
类型,我们自己实现的绝对值函数如下所示。
func Abs(n int) int {
if n < 0 {
n = -n // 这一行的意思是计算 n 的相反数,并赋值给 n
}
return n
}
关于函数,这只是一个非常粗浅的介绍,我们在此处的目的是够用就行。我们会在下一部分结构体中说明函数的更多用法以及关键字 func
的应用场景。
为了避免在组合时出现重复组合或与自己组合,我们只让每一位同学与编号比自己小的同学组合。
跟主函数 func main()
结合起来,就得到了整道题的解法。
package main
import (
"fmt"
)
func Abs(n int) int { // 计算绝对值
if n < 0 {
n = -n
}
return n
}
func main() {
var n int
fmt.Scan(&n)
a := make([][]int, n) // n 就是学生的数量
// 我们可以把每一个学生都看作一个数组,数组中的数据就是我们需要计算的东西
for i := 0, i < n; i++ { // 循环输入以便生成矩阵
b := make([]int, 4) // b[0] 表示语文成绩、b[1] 表示数学成绩、b[2] 表示英语成绩、b[3] 表示总分
fmt.Scan(&b[0], &b[1], &b[2])
b[3] = b[0] + b[1] + b[2]
a[i] = b
}
// a[i] 表示第 i+1 位学生(索引从 0 开始)
// a[i][0] 表示第 i+1 位学生的语文成绩,以此类推
var pairs int
for i := range a { // 遍历每一位学生
for j := 0; j < i; j++ { // 防止跟自己组合或是重复组合
diff_a := Abs(a[i][0] - a[j][0])
diff_b := Abs(a[i][1] - a[j][1])
diff_c := Abs(a[i][2] - a[j][2])
diff_all := Abs(a[i][3] - a[j][3])
if diff_a <= 5 && diff_b <= 5 && diff_c <= 5 && diff_all <= 10 {
pairs++
}
}
}
fmt.Println(pairs)
}
上面两道题是遍历一维数组与二维数组的模板题。它们的中心思想是这样的:遍历的同时记录数据。否则,遍历就失去了价值。
这一思想的平凡应用场景很多,其中之一是求最大连续值。以 LeetCode 485. 最大连续 1 的个数 为例。

我们可以通过遍历一次数组,并且使用一个变量 now
来记录当前已经有连续 1
的数量。
我们用 i
表示数组的索引,那么
- 如果
nums[i] == 1
,now++
。 - 如果
nums[i] == 0
,将now
归零。
在每一次判断完 now
执行的操作后,将答案 ans
与 now
相比后取最大值,就是结果了。
func findMaxConsecutiveOnes(nums []int) int {
var now, ans int
for _, num := range nums {
if num == 1 {
now++
} else {
now = 0
}
ans = max(now, ans)
}
return ans
}
除此之外,还有挖坑 / 种树问题。因为不能在相同的地方两次挖坑或者种树,所以我们通过 1
和 0
来表示有 / 没有执行操作。
P1047 NOIP 2005 普及组 校门外的树 - 洛谷 中,因为给出的区间中的树可能已经被挖走,而已经挖掉的树不能第二次挖掉,所以我们可以通过记录一个二进制数组2来表示某个点的树是否存在。
因为题目中在原点处也种了树,注意在生成记录挖树状态的数组时将长度设置为 l+1
。
package main
import "fmt"
func main() {
var l, m int
fmt.Scan(&l, &m)
trees := make([]bool, l+1) // 布尔数组,记录树是否健在
moved := make([][2]int, m) // 记录所有将被挖走的树的区间
for i := 0; i < m; i++ {
fmt.Scan(&moved[i][0], &moved[i][1])
}
// 接下来只要遍历 moved 中每一个数组即可
// 1 表示已被挖走,0 表示未被挖走
// 例如,moved[0] == [150 300],那么我们把 trees 中索引从 150 到 300 的数据全部设为 true
for _, moved_i := range moved {
for i := moved_i[0]; i <= moved_i[1]; i++ {
trees[i] = true
}
}
var ans int
for _, tree := range trees {
if !tree { // false 的个数就是剩余的树的数量
ans += 1
}
}
fmt.Println(ans)
}
数组还可以继续套娃,此后的数组称作三维数组、四维数组……但这样的套娃在数据增长后的操作会很麻烦,而且完全可以转化成多个数组的运算,这里不再继续推广3。如果感兴趣可以试试看 P5729 【深基5.例7】工艺品制作 - 洛谷 来练练手。注意切割会重复。
字符串
字符串作为一种序列,它和数组/切片的最大区别是,字符串的每一个元素都是不可变的(immutable)。这意味着一个字符串一旦生成就不能再改变内容4。
这里有一个易错点:在 Go 中,使用双引号 ""
表示初始化一个字符串,它的类型是 string
;单引号 ''
表示初始化一个 ASCII 字符,它的类型是 byte
。他们是完全不同的。
我们已经知道,一个 UTF-8 字符可以使用 rune
来存储。一个 rune
占用的字节5数是 4。
Go 的字符串都以 UTF-8 编码。但是 Go 很聪明,它把字符串里的每一个字符都用 UTF-8 编码,而不是采取空间占用更大的 rune
,这样就能节省空间。
这里需要引入一个关于字符的知识。
UTF-8 和 Unicode 的关系
Unicode(万国码)是一个字符集,这个集合中包含了世界上所有文字和符号的编码。但是字符集只能给字符分配编号,不能有序地组织起海量的字符。在这种背景下,就需要一个标准来组织所有的字符。
早期使用的标准叫做 UTF-32,它的每个字符都占用 4 个字节。这样的标准确实简单,但有一个严重的弊端:分别使用 UTF-32 和 ASCII6 存储一篇纯现代英文文本,前者消耗的空间是后者的四倍。
目前最通用的规则是 UTF-8。UTF-8 通过变长地存储字符来优化空间。比如,对于英文字母“a”和汉字“字”。
a U+0061 二进制表示:01100001 UTF-8表示:0x61
字 U+5b57 二进制表示:01011011 01010111 UTF-8表示:0xE5 0xAD 0x97
如果我们将这些字符按照二进制最高位来划分空间的话,就能极大地节省空间。
当我们在 Go 中计算一个字符串的长度时,实际上是在计算它占用的字节数。
package main
import "fmt"
func main() {
s := "abcdefg"
t := "这是一行中文"
fmt.Printf("%s 占用的字节数是:%d\n", s, len(s))
fmt.Printf("%s 占用的字节数是:%d\n", t, len(t))
}
输出
abcdefg 占用的字节数是:7
这是一行中文 占用的字节数是:18
输出内容告诉我们一个英文字母占用 1 字节,一个汉字占用 3 字节,符合我们对 UTF-8 编码的印象。
要返回字符串的字符数量我们通常使用迭代循环,也可以使用 Go 内置的 strings
包。
package main
import (
"fmt"
"strings"
)
func main() {
t := "这是一行中文"
var lengthT int
for range t {
lengthT++
}
fmt.Printf("%s 的长度是:%d\n", t, lengthT)
fmt.Println(strings.Count(t, "") - 1)
}
输出
这是一行中文 的长度是:6
6
这里使用 strings.Count()
函数时我们统计的是空字符 ""
,也就是每两个字符之间、头部以及末尾的空字符,因此在得到结果后需要减去 1。
因为字符串是无法改变的,所以当我们需要反转字符串时,就需要开一个新的字符串或是使用 []byte
。
如果是洛谷等主要面向程序设计竞赛的题库,那么完全可以处理一部分字符串就输出一部分,没有必要为了构建字符串专门写一个函数。但是在力扣等平台上,只需要写核心代码,返回值通常限制在一个字符串,这就需要我们为字符串做预处理。
重开一个字符串可以使用 strings.Builder
来初始化空字符串。然后使用 Builder
的方法 String()
输出即可7。
字符串拼接
151. 反转字符串中的单词 - 力扣(LeetCode)中,因为我们需要频繁地写入一个字符串,且 s
的长度达到了 10,000,所以使用 strings.Builder
是相对合适的选择。下文还会提到如何使用 strings.Join()
函数来拼接字符串。
在这一题中,我们应该先遍历一次字符串,去除所有的空格后,使用 strings.Builder
的 WriteString()
方法拼接字符串。
遍历字符串时需要判断是否遍历到空格,并截断字符串,存到 builder
中。
为了尽量优化性能,我们反向遍历字符串,用一个索引 j
记录当前单词的右边界,当循环变量 i
遍历到空格时,就说明找到了当前单词的左边界。在 i
找到下一个非空格的字符时,就让 j = i
。不断循环这个过程即可。
func reverseWords(s string) string {
var builder strings.Builder // 不可以直接使用 strings.Builder,必须先赋值
s = strings.TrimSpace(s) // 清除左右空格
i := len(s) - 1
for i >= 0 {
j := i
for i >= 0 && s[i] != ' ' {
i--
}
if builder.Len() > 0 {
builder.WriteByte(' ') // 防止拼接最后一个单词时加上一个空格
}
builder.WriteString(s[i+1 : j+1]) // 无论是什么切片操作都是“留头去尾”的,所以这样不会超出范围
for i >= 0 && s[i] == ' ' {
i--
}
j = i
}
return builder.String()
}
我们还可以用 strings.Join()
来拼接。strings.Join()
可以把一个切片中的所有字符串拼起来并决定连接符号。
func reverseWords(s string) string {
s = strings.TrimSpace(s)
i := len(s) - 1
var words = make([]string, 0)
for i >= 0 {
j := i
for i >= 0 && s[i] != ' ' {
i--
}
words = append(words, s[i+1 : j+1])
for i >= 0 && s[i] == ' ' {
i--
}
j = i
}
return strings.Join(words, " ") // 意思是,把切片 words 的全部元素用空格连接起来
}
这样操作相比 string.Builder
来说,可读性强了很多,因此更推荐用后者。
有些时候题目会限制空间,例如原地修改数组。字符串本身不能这样做,但是将字符串转为存储 byte
类型数据的数组后,就可以原地修改字符了。
例如 344. 反转字符串 - 力扣(LeetCode),这道题目要求我们反转给出的 s
,我们发现 s
的数据类型不是 string
,而是 []byte
。
这实际上是把不可变的字符串转换成了可变的数组,所以我们只需要设置两个坐标,l
在头,r
在尾。
不断交换 s[l]
和 s[r]
,l
循环一次就增加 1,r
循环一次就减少 1。直到 l >= r
。
func reverseString(s []byte) {
l := 0
r := len(s) - 1
for l < r {
s[l], s[r] = s[r], s[l] // a, b = b, a 表示交换 a 和 b 的值
l++
r--
}
}
这里涉及序列中非常重要的双指针算法。双指针算法的中心思想是,在给定的序列中选择两个下标(或者索引),然后让两个下标遍历序列。这不一定要遍历完整的序列。
双指针算法是非常高效的,因为它通常只需要遍历两次完整的序列。关于双指针,在上面的两个示例中分别使用了快慢指针和左右指针。
关于双指针,更多内容会在之后的经典算法部分说明。
带缓冲的输入输出
有空格的输入
在之前我们使用的输入中,我们用的全部都是 fmt
包提供的 fmt.Scan()
这个函数,但是这有一个问题:如果输入是一行带空格的英文句子,那么 fmt.Scan()
最多只能获取第一个单词。这是因为 fmt.Scan()
默认使用空格 " "
作为分隔符。就在上文的 LeetCode 151 中,如果题目使用的是标准输入输出,那么以目前的方式是过不了这一题的。
为了避免这种情况,我们需要使用 Go 的标准库 bufio
和 os
。
package main
import (
"bufio"
"fmt"
"os"
)
func main() {
in := bufio.NewReader(os.Stdin) // 创建读取器
out := bufio.NewWriter(os.Stdout) // 创建写入器
defer out.Flush() // 推迟刷新,确保全部输出
a, err := in.ReadString('\n') // 到换行符才截断
if err != nil { // 错误处理
return
}
fmt.Fprintln(out, a)
}
它的结果是
IN | OUT
-------------------+----------------
I am Karlbaey. | I am Karlbaey.
下面详细解释这段程序输入输出的原理8。(以下涉及大量说明和概念,请一定要细心阅读)
os
包能够直接和操作系统互动。我们如果希望执行一个自己写好的程序,最直接的方式就是在命令行输入程序名来运行程序。这个程序读取输入时,实际上是接受我们在键盘上的输入,也就是标准输入流 os.Stdin
。标准输入流默认关联键盘(也就是常说的键盘打字),但在某些场景中会关联文件。
标准输出流 os.Stdout
同理,程序把处理好的文本打印在命令行上,就称作标准输出流。
因为我们希望直接看到输入和输出的文本,所以在这里不对这两个流做任何处理。
现在有了输入输出流,但还不够。如果每次需要输入都要在命令行和程序之间来回跑,那就太慢了。所以我们希望能有一个空间,用来存储我们输入到命令行的内容,这样程序直接读取这片空间,速度就快得多了。这片空间的学名叫做内存,上一篇提到的指针就用于记录内存地址。内存的访问速度是极快的,比直接从命令行读取数据快 1,000 倍左右。
想象一下你在厨房做了一锅汤。现在你在餐桌上吃饭,想喝汤的话,最好的做法是拿一个足够大的碗,将汤装进大碗里面带回餐桌喝。如果你每次想喝汤都要跑去厨房里面喝,这种做法的效率可想而知。将“用大碗装汤”的比喻反映在程序设计里,就是将输入优先存到内存里,这样的操作效率就高多了。
为了实现将数据存到内存里然后让程序访问,Go 提供了 bufio
包。buf
的意思是缓冲(buffer),io
的意思是输入输出(input and output)。
in := bufio.NewReader(os.Stdin)
实际上是在内存中创建一个带有缓冲空间的读取器 in
,并且把标准输入流都导向这个缓冲空间。

我们在下面使用的 in.ReadString('\n')
意思就是在这个读取器的缓冲空间中一直读,直到读到换行符 "\n"
才停下,这样就避免了 fmt.Scan()
默认的读到空格就停止的问题。
如果使用另一个读取缓冲空间的函数 fmt.Fscan()
,也仍然有这个问题。所以一定要记牢这个用法。
同理可得,out := bufio.NewWriter(os.Stdout)
就是在内存中创建一个带有缓冲空间的写入器,并把标准输出流导向写入器的缓冲空间
defer out.Flush()
是一道保险。这道保险确保在函数结束前(遇到 return
或者执行到最后一行),把所有缓冲数据都实际地写入输出设备。本文的语境中,输出设备指的是命令行。如果没有这一行,那么我们输入的内容只是被写入了内存缓冲区,而没有被实际刷新到标准输出。
defer
关键字的意思是延迟函数执行,它通常用在函数结束前的清扫工作,例如关闭文件、释放内存等。
if err != nil
用来处理可能的错误。如果发生了什么奇怪的问题就让函数提前结束,不执行输出。
fmt.Fprintln(out, a)
的意思是从写入器 out
中读取 a
的值,并且打印到命令行上。带上了一个 F
表示指定输出目标。
高速读写
当输入数量来到 105 乃至 106 时,上面的输入方法都不够用了。因为哪怕是分割输入、分割输出这种操作,在次数来到百万级别时,时间占用都会非常大。考虑到下面还有程序的主要内容需要执行,让输入输出拖慢我们的脚步显然是不值当的,所以我们引入一个新的概念:高速读写9。
使用缓冲来存储输入输出还是第一步。我们运用的 in.ReadString('\n')
还要切割字符串,这在一定程度上也拖慢了输入的时间。我们希望用一种高速的方法直接读内存,如何读由我们自己决定。这个“如何读”的过程,我们称作自定义解析。自定义解析需要我们使用上面提到的如何定义函数。
高速读写通常不考虑浮点数(float,也就是小数),因为 fmt
提供的解析浮点数的工具已经足够高效,自定义解析并不能带来很大的性能提升。
在这里需要先导入必要包,然后定义两个全局变量 reader
(输入)和 writer
(输出)。全局变量的意思是在整个程序都能用的变量。
package main
import (
"bufio"
"os"
) // 看到了吗?我们连最早用到的 fmt 都不需要了
var reader = bufio.NewReader(os.Stdin) // 上面提过,这里不再说这两者的含义
var writer = bufio.NewWriter(os.Stdout)
我们以输入一系列整数为例。
输入
65 12 345
我们实际上是从这一行的第一位开始读,中间遇到空格时就把当前读到的内容截断,作为一个数字给程序处理。只要当每一个空格都被跳过,而且读到这一行末尾时,就算读完了这一行的全部数字。这就称作整数的解析逻辑。
那么我们就写一个针对 32 位整数10的解析逻辑。
func nextInt() int {
var n int
var sign int = 1 // 决定是否是负数
var b byte
// 跳过非数字字符
for {
b, _ = reader.ReadByte() // 往后读一位。b 就是当前读取的内容,占一个字节
if b == '-' { // b 是 -,说明这个整数是负数
sign = -1
break
} else if b >= '0' && b <= '9' {
n = int(b - '0') // ASCII 运算,说明 b 对应的 ASCII 码点在 0 到 9 之间,写入即可
break
}
}
// 读取数字
for {
b, _ = reader.ReadByte()
if b < '0' || b > '9' { // 读到空格或者换行符了,也有可能是读到末尾了
break
}
n = n*10 + int(b-'0') // n 就是上面解析好的数字
// 例如 n == 1, b == 6,说明当前的数字就是 16
// n*10 + b 即可,其余的数字同样处理
}
return n * sign // 处理正负
}
解析 64 位整数只要把上面代码中的 int
改成 int64
就行了。
解析字符串的逻辑跟解析整数是一致的,不过需要去掉回车 \r
、换行 \n
和制表符 \t
。而且在上文的字符串部分我们也提到过,字符串本身不可变,我们应该用 []byte
。
func nextString() string {
var bytes []byte
var b byte
// 跳过空白字符
for {
b, _ = reader.ReadByte()
if b != ' ' && b != '\n' && b != '\t' && b != '\r' { // 不是空白字符
bytes = append(bytes, b)
break
}
}
// 读取直到空白字符
for {
b, _ = reader.ReadByte()
if b == ' ' || b == '\n' || b == '\t' || b == '\r' {
break
}
bytes = append(bytes, b)
}
return string(bytes)
}
这个读字符串的函数并不能读包含空格的字符串,因为它读到空格就停止读取了。
如果需要读一整行,我们应该写一个专门读一行的函数。
func nextLine() string {
line, _ := reader.ReadString('\n')
// 去除可能的换行符
if len(line) > 0 && line[len(line)-1] == '\n' { // 清除换行
line = line[:len(line)-1] // 切片,切掉最后一位的换行符
}
return line
}
这里还是用内置函数读取一整行。
解决了读取的问题,现在我们需要处理写入的问题。
先从写入整数开始。我们写入整数时,优先处理的问题应该是整数是否为负以及整数是否为 0。
然后,因为不知道这个整数具体有多少位,我们应该把这个整数反转。这个操作的同时我们就知道了整数的位数,然后反向输出即可。
func writeInt(n int) {
if n < 0 { // 判定负数
writer.WriteByte('-')
n = -n
}
if n == 0 { // 判定 0
writer.WriteByte('0')
return // 提前结束
}
// 反转数字
var digits []byte
for n > 0 {
digits = append(digits, byte('0'+n%10))
n /= 10 // 自动截断整数
}
// 逆序输出
for i := len(digits) - 1; i >= 0; i-- {
writer.WriteByte(digits[i])
}
}
在写入操作中,只有整数需要这样的特殊处理。因为 bufio
没有提供直接写入整数的方法,我们需要自己实现。
其余的写入字符串等操作,直接调用方法 writer.WriteString(s)
和 writer.WriteByte('\n')
(实现换行)即可。
我们放在主函数里测试。
func main() {
defer writer.Flush() // 仍然要有一层保险
c := nextLine()
a := nextInt()
b := nextString()
writeInt(a)
writer.WriteByte('\n') // 都是换行,如果嫌麻烦可以再自定义函数
writer.WriteString(b)
writer.WriteByte('\n')
writer.WriteString(c)
writer.WriteByte('\n')
}
输入输出
IN | OUT
---------------------+-----------------------
I am Karlbaey | 65656565
65656565 string | string
| I am Karlbaey
该给这篇教程结个尾了。我们在这篇教程里说了数组和切片、字符串,以及高速读写。
数组、切片和字符串最重要的操作是遍历,然后在遍历的同时记录数据。
遍历次数能够直接决定这个程序的性能如何,所以我们引入了双指针的概念。用来减少遍历次数。
切片是可变的,修改切片某个元素可以引用下标 a[i] = ...
,在切片后加入新元素使用 a = append(a, ...)
。
字符串是不可变的,改变字符串需要使用 []byte
,或者使用 strings.Builder
或 strings.Join()
构建新的字符串。
高速读写包括两个部分:缓冲输入输出和自定义解析。
关于高速读写,这里有一个思想:倒序输出。这里先不说倒序输出有什么用。我们通过自定义缓冲区的输入内容应该怎么分割,大大提升了程序的输入输出性能。
其实你应该看出来了,高速读写完全可以用下面两张图概括。


(图片仅供展示)
那么,这期教程就到这里,我们将会在下一期说明结构体,并且会用到 func
关键字的更多使用方法。
🎉撒花🎉
Footnotes
函数的英文是 “function”,而 function 这个词又恰好有“功能”的意思。 ↩
二进制数组的意思是只含有
0
和1
的数组。由于可以用布尔值来表示0
和1
,因此二进制数组往往可以转换成布尔数组,并表现成[]bool
的形式。用这种数组来记录存在的状态非常方便。 ↩三维及以上数组的应用场景之一是动态规划。这里用不到所以暂时略过。 ↩
字符串事实上是一个只读的字节序列
[]byte
。 ↩1 个字节(byte)等于 8 个比特(bit)。例如 9 的二进制形式是
1001
,每个1
和0
都用一个比特的空间存储,而 9 在计算机中通常要补全成00001001
,这时整数 9 的空间占用就是 1 个字节。 ↩ASCII(American Standard Code for Information Interchange,美国信息交换标准代码)是基于拉丁字母的一套电脑编码系统。它最主要的功能是显示现代英语文本。一个 ASCII 字符占用 1 个字节。 ↩
方法(method)和函数(function)的区别在于,函数是一个完全独立的单元,而方法需要依赖特定的类存在。例如,
strings.Builder
是一个专门构造字符串的类型,在构造完之后需要输出,就需要使用String()
方法。String()
方法必须依赖strings.Builder
才能使用,所以它是方法而不是函数。 ↩学习过 C/C++ 的人可能比较熟悉下面提到的重要概念。但对于感到生疏的人来说,别慌,我相信我的说法能让你分明白这些概念。 ↩
速度大概是
fmt.Scan()
的一百倍,a, err := in.ReadString('\n')
的十倍。 ↩32 位整数指的是这个数在计算机中占用 32 个比特,也就是 4 个字节。一个 32 位整数的区间是
[-2147483648, 2147483647]
。64 位整数同理。 ↩