Here remains my dream.

程序设计#2 - 序列入门和缓冲输入输出

39 min

这一篇是关于 数组字符串。因为这些东西的内容极其宏大,所以本篇仅仅是一个入门,连初步都算不上。

此外,本篇还涉及了计算机底层逻辑的输入输出的教程。缓冲输入输出的意思是,将键盘从命令行输入、输出到命令行的内容优先存到缓冲区,这样程序就不需要频繁地读写命令行了。

数组和字符串都是由一系列元素组成的一个序列。它们的性质十分相似,因此放在同一篇教程里详细讨论。

序列的引入

还记得在 前期准备 中我们谈到的 数组和字符串 吗?它们有一个共同的特点:由几个元素以特定的顺序排列而成。它们的这种性质与数学中的数列(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 中,13 前面,35 前面,以此类推。即使它们在原数组中并不连续。

  • [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 的个数 为例。

lc 485
lc 485

我们可以通过遍历一次数组,并且使用一个变量 now 来记录当前已经有连续 1 的数量。

我们用 i 表示数组的索引,那么

  • 如果 nums[i] == 1now++
  • 如果 nums[i] == 0,将 now 归零。

在每一次判断完 now 执行的操作后,将答案 ansnow 相比后取最大值,就是结果了。

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
}

除此之外,还有挖坑 / 种树问题。因为不能在相同的地方两次挖坑或者种树,所以我们通过 10 来表示有 / 没有执行操作。

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】工艺品制作 - 洛谷 来练练手。注意切割会重复。

🔗跳转至 P5729 解法

字符串

字符串作为一种序列,它和数组/切片的最大区别是,字符串的每一个元素都是不可变的(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-8UTF-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.BuilderWriteString() 方法拼接字符串。

遍历字符串时需要判断是否遍历到空格,并截断字符串,存到 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 的标准库 bufioos

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,并且把标准输入流都导向这个缓冲空间。

默认缓冲空间为 4096 字节
默认缓冲空间为 4096 字节

我们在下面使用的 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.Builderstrings.Join() 构建新的字符串。

高速读写包括两个部分:缓冲输入输出自定义解析

关于高速读写,这里有一个思想:倒序输出。这里先不说倒序输出有什么用。我们通过自定义缓冲区的输入内容应该怎么分割,大大提升了程序的输入输出性能。

其实你应该看出来了,高速读写完全可以用下面两张图概括。

超级拆解
超级拆解
超级拼装
超级拼装

(图片仅供展示)

那么,这期教程就到这里,我们将会在下一期说明结构体,并且会用到 func 关键字的更多使用方法。

🎉撒花🎉

Footnotes

  1. 函数的英文是 “function”,而 function 这个词又恰好有“功能”的意思。

  2. 二进制数组的意思是只含有 01 的数组。由于可以用布尔值来表示 01,因此二进制数组往往可以转换成布尔数组,并表现成 []bool 的形式。用这种数组来记录存在的状态非常方便。

  3. 三维及以上数组的应用场景之一是动态规划。这里用不到所以暂时略过。

  4. 字符串事实上是一个只读的字节序列 []byte

  5. 1 个字节(byte)等于 8 个比特(bit)。例如 9 的二进制形式是 1001,每个 10 都用一个比特的空间存储,而 9 在计算机中通常要补全成 00001001,这时整数 9 的空间占用就是 1 个字节。

  6. ASCII(American Standard Code for Information Interchange,美国信息交换标准代码)是基于拉丁字母的一套电脑编码系统。它最主要的功能是显示现代英语文本。一个 ASCII 字符占用 1 个字节。

  7. 方法(method)和函数(function)的区别在于,函数是一个完全独立的单元,而方法需要依赖特定的类存在。例如,strings.Builder 是一个专门构造字符串的类型,在构造完之后需要输出,就需要使用 String() 方法。String() 方法必须依赖 strings.Builder 才能使用,所以它是方法而不是函数。

  8. 学习过 C/C++ 的人可能比较熟悉下面提到的重要概念。但对于感到生疏的人来说,别慌,我相信我的说法能让你分明白这些概念。

  9. 速度大概是 fmt.Scan() 的一百倍,a, err := in.ReadString('\n') 的十倍。

  10. 32 位整数指的是这个数在计算机中占用 32 个比特,也就是 4 个字节。一个 32 位整数的区间是 [-2147483648, 2147483647]。64 位整数同理。