程序设计#1 - 流程控制
⚠ 注意:从本篇教程开始,就会涉及真正的程序设计,但不会太难,更多的是作为讲解用的例题。
书接上回。我们在 第零篇教程 中简要地说明了如何在 Go 中操作内置的数据类型。如果我们只是一条一条地执行代码,那这个程序就是 顺序结构。顺序结构是最基本的流程控制。不过在这之前,我们需要先接触程序的输入输出,以及指针和指针运算。
I/O For Golang
Go 有内置的 fmt
包用于格式化输入输出,我们以 P1001 A+B Problem - 洛谷 为例。
package main
import "fmt"
func main() {
var a, b int // 初始化原题中的 A、B
fmt.Scan(&a, &b) // 输入
fmt.Println(a+b) // 输出结果
}
其中,fmt.Scan
负责程序的输入,fmt.Println
负责程序的输出。输入时涉及到了指针运算 &
。&
的学名是 取址运算符,它能够获取变量的内存地址,以便在程序读取输入时,将输入内容存在正确的地方,在下文会详细地说明。这样的代码可以在原题拿满分。
Go 的输入除了这里的 fmt.Scan
,还可以使用 fmt.Scanf()
(格式化输入)和 fmt.Scanln()
(只会检查一行输入,直到这行结束)。
package main
import "fmt"
func main() {
var a, b, c, d, e int=
fmt.Scan(&a, &b)
fmt.Scanf("%d", &c)
fmt.Scanln(&d)
fmt.Scanln(&e)
fmt.Printf("a 的值是:%d\n", a)
fmt.Printf("b 的值是:%d\n", b)
fmt.Printf("c 的值是:%d\n", c)
fmt.Printf("d 的值是:%d\n", d)
fmt.Printf("e 的值是:%d\n", e)
}
这段代码,会因为输入方式不同而输出不同的结果。(同一行为对应的输入输出)
IN | OUT
--------------------+-------------
6 5 1 2 3 | a 的值是:6
| b 的值是:5
| c 的值是:1
| d 的值是:2
| e 的值是:0
--------------------+-------------
6 5 1 2 | a 的值是:6
3 | b 的值是:5
| c 的值是:1
| d 的值是:2
| e 的值是:3
--------------------+-------------
这是因为 fmt.Scanln()
检查到换行符时就不再继续读取输入,并且当前程序输入会换行。在第一组测试中没有第二行,所以无法输入 e
的值。
格式化输出 在 上一篇 中已经给出了详细的方法,对照题目的输出样例写输出语句即可。
练习题:K-0 超级 65!
如下图,写一个程序输出这幅 ASCII 字符画。你需要在这张字符画的每一行开头加一个 特定的数字 n
以及一个空格。
.ooo oooooooo .o .oooo. .oooo. .o oooooooo
.88' dP""""""" o888 .dP""Y88b .dP""Y88b .d88 dP"""""""
d88' d88888b. 888 ]8P' ]8P' .d'888 d88888b.
d888P"Ybo. `Y88b 888 .d8P' <88b. .d' 888 `Y88b
Y88[ ]88 ]88 888 .dP' `88b. 88ooo888oo ]88
`Y88 88P o. .88P 888 .oP .o o. .88P 888 o. .88P
`88bod8' `8bd88P' o888o 8888888888 `8bd88P' o888o `8bd88P'
例
输入
一个整数 n,在区间 [1, 9] 内。
5
输出
5 .ooo oooooooo .o .oooo. .oooo. .o oooooooo
5 .88' dP""""""" o888 .dP""Y88b .dP""Y88b .d88 dP"""""""
5 d88' d88888b. 888 ]8P' ]8P' .d'888 d88888b.
5 d888P"Ybo. `Y88b 888 .d8P' <88b. .d' 888 `Y88b
5 Y88[ ]88 ]88 888 .dP' `88b. 88ooo888oo ]88
5 `Y88 88P o. .88P 888 .oP .o o. .88P 888 o. .88P
5 `88bod8' `8bd88P' o888o 8888888888 `8bd88P' o888o `8bd88P'
指针与指针运算
指针(pointer)是 记录值的内存地址的数据类型。例如,定义一个变量 a
,如果变量 b
存储了 a
的内存地址,那么 b
是 a
的指针。用代码表示就是这样。
b := &a
关于 &
取址运算符,我们通过下面的代码来测试。
package main
import "fmt"
func main() {
var a, b int
fmt.Scan(&a, &b)
fmt.Printf("a 的值是:%d\nb 的值是:%d\n", a, b)
fmt.Printf("a 的内存地址在 %v\nb 的内存地址在 %v", &a, &b)
}
输出

注意到每次执行代码时,内存地址都可能会变。这是内存的特点之一:随机访问。
当使用 fmt.Scan()
输入时,我们实际上是希望 把输入到程序的值存到变量对应的内存地址中,这就要求我们必须使用 &
获取内存地址。打个比方,把输入比作快递包裹,fmt.Scan()
能告诉快递员把包裹送到哪里,如果我们只是告诉快递员“把包裹送到我家(变量名)”显然是不对的,应该告诉快递员家的具体地址(内存地址),才能收到包裹(输入成功)。
关于 *
解引用运算符,可看下面的代码。
package main
import "fmt"
func main() {
var a int
fmt.Scan(&a)
a_P := &a // 定义一个指向 a 的指针 a_P
fmt.Printf("a 的类型是:%T\na 的值是:%v\n", a, a)
fmt.Printf("a_P 的类型是:%T\na_P 的值是:%v\n", a_P, a_P) // 内存地址会变
fmt.Printf("a_P 的类型:%T\na_P 解引用后的值:%v", *a_P, *a_P) // 解引用 a_P
}
输入
6
输出
a 的类型是:int
a 的值是:6
a_P 的类型是:*int
a_P 的值是:0xc00000a0c8
a_P 的类型:int
a_P 解引用后的值:6
结合着上面的两块代码,我们就知道了指针的两个运算的特点: &
取址表示由值到指针,*
解引用表示从指针到值。当后面学习到结构体的时候,使用指针往往可以节省大量内存,提高性能。因为 指针只记录了值的地址,所有的操作仅仅是通过解引用指针来作用到值本身上的。
Go 中同样有 空指针 的概念,意思是,指针指向的地方什么都没有。如果尝试给空指针的值赋值,程序会报错。
package main
import "fmt"
func main() {
var a *int // a 是一个空指针
*a = 10 // 将空指针的值改为 10(panic)
fmt.Println(a, &a)
}
输出
<nil> 0xc000088058
panic: runtime error: invalid memory address or nil pointer dereference
[signal 0xc0000005 code=0x1 addr=0x0 pc=0x20a97c]
goroutine 1 [running]:
main.main()
E:/DRAFTBOX/Go/main.go:9 +0x7c
panic
(恐慌)表示程序遇到了无法恢复的错误。除此之外,查询数组或切片范围以外的索引,除法运算中除数为零导致 panic
也很常见。
流程控制语句
💡 顺序结构
顺序结构是按照从上往下的顺序,依次执行代码的结构。 最经典的就是上文提到的 P1001,以及这一题 P5703。前者是将两个输入值相加,后者是将两个输入值相乘。
P5703
package main
import "fmt"
func main() {
var x, n int
fmt.Scan(&x, &n)
fmt.Println(x*n)
}
💡 选择结构
选择结构是程序根据不同条件,选择性地执行不同代码的结构。这种结构通过条件语句实现。Go 中有两种条件语句。
条件语句都是可以嵌套的。嵌套就是在条件语句中再写一个条件语句。
if ... else ...
if ... else ...
的用法是,在 if
后写一个 布尔表达式 1,如果结果为 true
,就执行 if
后的语句,否则就执行 else
后的语句。
⚠ 注意:else
并不是必须的。如果没有 else
,那么 if
在布尔表达式为 true
时,其后紧跟的语句块执行,如果为 false
则不执行。
两段 if ... else ...
语句可以连用,形成的 else if ...
结构与 Python 中的 elif
类似。
这是最常用的条件语句。
我们以洛谷 P5714 为例题,看看 if ... else ...
怎么实际应用。
解法
package main
import "fmt"
func main() {
var m, h float64 // 方便保留有效数字的运算
fmt.Scan(&m, &h)
bmi := m / (h * h)
if bmi < 18.5 {
fmt.Println("Underweight")
} else if bmi >= 18.5 && bmi < 24 {
fmt.Println("Normal")
} else {
fmt.Printf("%.6g\n", bmi) // %.6g 表示保留小数的 6 位有效数字,%.5g 就是保留 5 位有效数字,以此类推
fmt.Println("Overweight")
}
}
结果
IN | OUT
--------------------+-----------------------------
70 1.72 | Normal
--------------------+-----------------------------
100 1.68 | 35.4308
| Overweight
if
可以用 关系运算符 和 逻辑运算符 2 来写布尔表达式。例如,else if bmi >= 18.5 && bmi < 24
转换成自然语言就是“bmi
大于等于 18.5 且 bmi
小于 24”。>=
和 <
是关系运算符,&&
是逻辑运算符
其余的关系运算符和逻辑运算符如下所示。
package main
import "fmt"
func main() {
a, b := 6, 5
fmt.Printf("a: %d\n", a)
fmt.Printf("b: %d\n", b)
// 关系运算符
fmt.Printf("a 是否等于 b?%t\n", a == b) // 与 := 和 = 区分开
fmt.Printf("a 是否大于 b?%t\n", a > b)
fmt.Printf("a 是否小于 b?%t\n", a < b)
fmt.Printf("a 是否不等于 b?%t\n", a != b)
fmt.Printf("a 是否大于等于 b?%t\n", a >= b)
fmt.Printf("a 是否小于等于 b?%t\n", a <= b)
// 逻辑运算符
fmt.Printf("a 大于 b 而且 a 小于 10?%t\n", a > b && a < 10) // && 表示和(AND)运算
fmt.Printf("a 小于 b 或者 b 不等于 0?%t\n", a < b || b != 0) // || 表示或(OR)运算
fmt.Printf("a 小于等于 b 而且 a 大于 10?%t\n", !(a > b && a < 10)) // ! 表示非(NOT)运算,它后面的布尔值都会被反转
}
输出
a: 6
b: 5
a 是否等于 b?false
a 是否大于 b?true
a 是否小于 b?false
a 是否不等于 b?true
a 是否大于等于 b?true
a 是否小于等于 b?false
a 大于 b 而且 a 小于 10?true
a 小于 b 或者 b 不等于 0?true
a 小于等于 b 而且 a 大于 10?false
这里涉及一个逻辑的小知识:设有两个布尔值 A
和 B
,根据德摩根定律(De Morgan’s laws,→ 德摩根定律 - 求闻百科),NOT(A OR B)
等价于 NOT A AND NOT B
,NOT(A AND B)
等价于 NOT A OR NOT B
。表现在代码上,如下所示。
!(A || B) == !A && !B
!(A && B) == !A || !B
switch ... case ...
switch ... case ...
的用法是,在 switch
后接上一个变量,这个变量的 数据类型 可以是 int
、string
或是 bool
等,表示待 匹配(match)的数据。它尝试匹配每一个 case
,匹配成功即执行该 case
的语句。
每一个 case
语句后接的表达式的数据类型 必须 和 switch
一致,而且 case
匹配的数据不能够重复。
每一个 case
会在最后默认加上一个 break
3。
package main
import "fmt"
func main() {
var a int
fmt.Scan(&a)
switch a {
case 1, 0: // case 可以匹配多个数据
fmt.Println("Monday")
case 2:
fmt.Println("Tuesday")
case 3:
fmt.Println("Wednesday")
if a > 2 { // 此时做判断,如果 a 大于 2 立刻跳出匹配(必定为 true)
break
}
fallthrough // fallthrough 是多余的,因为不可能执行到这一条语句
case 4:
fmt.Println("Thursday")
case 5:
fmt.Println("Friday")
fallthrough // 如果 a 等于 5,就紧接着执行下一条 case 6
case 6:
fmt.Println("Saturday")
case 7:
fmt.Println("Sunday")
default: // a 不在 0 到 7 之间
fmt.Println("Beyond the range of a week.")
}
}
不同的输入会导致不同的输出。
IN | OUT
--------------------+-----------------------------
0 | Monday
--------------------+-----------------------------
1 | Monday
--------------------+-----------------------------
3 | Wednesday
--------------------+-----------------------------
5 | Friday
| Saturday
--------------------+-----------------------------
10 | Beyond the range of a week.
--------------------+-----------------------------
在 switch ... case ...
中有两个关键字:fallthrough
和 default
。
fallthrough
表示在当前的case
执行完毕后 强制执行下一个case
,无论下一个case
的表达式是否为true
。fallthrough
必须紧紧贴着下一个case
写,而且不能放在最后一个case
中。default
的意思是,所有的case
都匹配失败时,默认执行的语句。 例如上面的输入中,输入10
,就执行了default
中的语句,输出Beyond the range of a week.
。无论default
放在哪里,它一定最后执行。
switch ... case ...
在处理情况较为复杂的分支时具有优越性,因为 case
可以一次匹配多个条件,有效地增强了代码可读性。但在 case
数量较小时,它们的性能差异并不明显。
例如 力扣 1456. 定长子串中元音的最大数目,它的解法如下。
func maxVowels(s string, k int) int {
ans := 0
left := 0
now := 0
// 这里实际上是在维护一个长为 k 的队列(queue),关于队列的知识会在数据结构中说明
for right := 0; right < len(s); right++ {
switch s[right] {
case 'a', 'e', 'i', 'o', 'u': // 匹配元音
now++
}
if right-left+1 < k {
continue
}
if now > ans {
ans = now
}
switch s[left] {
case 'a', 'e', 'i', 'o', 'u': // 匹配元音
now--
}
left++
}
return ans
}
这里运用了 for
循环,它是下一小节 循环结构 的主角。
💡 循环结构
Go 中只有一种循环:for
循环。for
循环有四种形式。
三表达式循环
此时 for
循环有三个部分,分别是 初始化语句、循环条件 和 后置语句 4,它们使用分号 ;
分隔。
例如我们希望从 1 输出到 10,就可以使用这种形式的 for
循环。
package main
import "fmt"
func main() {
for i := 1; i <= 10; i++ {
fmt.Printf("%d ", i)
}
}
输出
1 2 3 4 5 6 7 8 9 10
后置语句通常使用 循环变量 搭配 自增自减运算符。在上面的循环中,i
是循环变量;++
是自增运算符,表示在这一次循环结束后将 i
+1。自减运算符是 --
,表示将当前变量 -1。
以 洛谷 P5705 【深基 2.例 7】数字反转 为例。这一题的目标是将带小数点的数字反转,因此选用字符串方便我们处理。
解法
package main
import "fmt"
func main() {
var s string
fmt.Scan(&s)
for i := len(s) - 1; i >= 0; i-- { // 使用自减运算符反向处理输入
fmt.Printf("%s", string(s[i]))
}
fmt.Print("\n") // 换行准备下一轮输出
}
另一种应用三表达式循环的题目是 洛谷 P1424 小鱼的航程(改进版)。
在这一题,我们需要从 题目给出的开始天数开始遍历,因为在开始天数之前小鱼都没有游泳。
然后,判断这一天是不是星期六或星期天。如果是的话,不做任何操作;如果不是,游泳里程数加 250(单位:km)。
判断天数使用取余运算 %
5,如果当前天数除以 7 的余数是 6 或 0,说明这一天是周末。
P1424 解法
package main
import "fmt"
func main() {
var x, n, swim int
fmt.Scan(&x, &n)
for i := x; i < x+n; i++ { // 从星期 x 开始,往后数 n 天
switch i % 7 {
case 1, 2, 3, 4, 5: // 星期一到星期五
swim += 250
}
}
fmt.Println(swim)
}
条件循环
此时 for
循环只有一个部分:循环条件。当循环条件是 true
时,循环继续;循环条件是 false
时,不再循环。
package main
import "fmt"
func main() {
num := 10
for num > 0 { // 这里的 num > 0 就是循环条件
fmt.Printf("%d ", num)
num--
}
}
输出
10 9 8 7 6 5 4 3 2 1
无限循环
无限循环只使用一个 for
,类似 C++ 的 while(ture)
和 Python 的 while True
。
package main
import "fmt"
func main() {
num := 10
for {
fmt.Printf("%d ", num)
num--
}
}
输出
10 9 8 7 6 5 4 3 2 1 0 -1 -2 -3 -4 -5 -6 -7 -8 -9...
⚠ 注意!无限循环一定要包含跳出循环的条件!否则程序就失去了原来的作用。
迭代循环
因为这种循环依赖于关键字 range
,因此也叫 for-range 循环。这种循环专门用于遍历 6 数组、切片或字符串等。
关键字 range
可以生成一系列整数。例如,for i := range n
表示从 0 到 n-1
.
package main
import "fmt"
func main() {
b := [3]int{65, 12, 345} // 数组
c := make([]int, 10) // 空切片
d := map[string]int{"Karlbaey": 255, "65": 6512345}
for a := range 10 { // 从 0 到 9
fmt.Printf("%d ", a)
}
fmt.Print("\n")
for idx, element := range b {
fmt.Printf("数组的第 %d 位元素是 %d", idx+1, element)
fmt.Print("\n")
}
for _, element := range c { // 用销毁变量 _ 销毁索引
fmt.Printf("%d ", element)
fmt.Print("\n")
}
for key := range d { // 如果只有一个循环变量,那么优先遍历键
fmt.Printf("%s ", key)
}
fmt.Print("\n")
}
输出
数组的第 1 位元素是 65
数组的第 2 位元素是 12
数组的第 3 位元素是 345
0
0
0
0
0
0
0
0
0
0
Karlbaey 65
range
关键字也可以 只输出一个值。如果是数组或切片,这个值就是 索引;如果是映射,这个值就是 键。
无论是哪种形式的循环,它们都能用两个语句打断循环:break
、continue
。
package main
import "fmt"
func main() {
// break 就是跳出循环。一旦执行到 break 这个循环就此结束,继续执行循环下方的代码。
for {
fmt.Println("loop")
break // 事实上这个循环只循环了一次
}
// continue 就是开始下一次循环
for n := 0; n <= 5; n++ {
if n%2 == 0 {
continue // n 是偶数时就执行下一次循环,下方的输出不会执行
}
fmt.Println(n)
}
}
输出
loop
1
3
5
循环可以嵌套,以 洛谷 P5721 【深基 4.例 6】数字直角三角形 为例。
题目中要求输出的三角形从第二行开始,每一行都比上一行长度少 2,也就是一个数字的长度。
所以在第一行的输出长度为 2*n
,每一行递减。我们可以通过记录一个变量 num
来确定当前应该输出的值,再用一个双层循环确定这一行输出数字的数量。
package main
import "fmt"
func main() {
var n int
fmt.Scan(&n)
num := 1
for i := range n {
for j := 0; j < n-i; j++ { // i 就是当前行减少输出的数量
if num < 10 {
fmt.Printf("0%d", num)
} else {
fmt.Printf("%d", num)
}
num++
}
fmt.Print("\n")
}
}
这样输出的三角形的顶角在左上角,如果要输出顶角在右上角、左下角以及右下角的三角形,使用下列代码即可。
无论是顶角在哪个方向,一定是 外层循环控制行数,内层循环控制列数。
右上角
package main
import "fmt"
func main() {
var n int
fmt.Scan(&n)
num := 1
for i := range n {
for range i {
fmt.Print(" ") // 随着 i 增加,在每一行前面补上 i*2 个空格
}
for j := 0; j < n-i; j++ { // n-i 是当前输出的数字个数
if num < 10 {
fmt.Printf("0%d", num)
} else {
fmt.Printf("%d", num)
}
num++
}
fmt.Print("\n")
}
}
左下角
package main
import "fmt"
func main() {
var n int
fmt.Scan(&n)
num := 1
for i := 1; i <= n; i++ { // 从 1 开始循环是为了防空行
for range i { // 如果 i == 0,那么这个循环内的语句都不会执行
if num < 10 {
fmt.Printf("0%d", num)
} else {
fmt.Printf("%d", num)
}
num++
}
fmt.Print("\n")
}
}
右下角
package main
import "fmt"
func main() {
var n int
fmt.Scan(&n)
num := 1
for i := 1; i <= n; i++ { // 防空行
for range n - i {
fmt.Print(" ")
}
for range i {
if num < 10 {
fmt.Printf("0%d", num)
} else {
fmt.Printf("%d", num)
}
num++
}
fmt.Print("\n")
}
}
金字塔
package main
import "fmt"
func main() {
var n int
fmt.Scan(&n)
num := 1
for i := 1; i <= n; i++ { // 防空行
for range n - i {
fmt.Print(" ") // 只要改为一个空格即可
}
for range i {
if num < 10 {
fmt.Printf("0%d", num)
} else {
fmt.Printf("%d", num)
}
num++
}
fmt.Print("\n")
}
}
输入
13
输出
01
0203
040506
07080910
1112131415
161718192021
22232425262728
2930313233343536
373839404142434445
46474849505152535455
5657585960616263646566
676869707172737475767778
79808182838485868788899091
到了这里,你就可以开始刷程序设计题了,全部可能需要用的语句都在这里做了教程。下一期教程会开始谈数组以及字符串,随后就可以开始学习栈、队列、链表还有堆之类的数据结构了。这里推荐洛谷的入门题单 100 - 顺序结构、101 分支结构 以及 102 - 循环结构。
一定要自己打一次代码,理解程序为什么这样写。学习程序设计与学习数学非常像,一定要亲手写一次知识才是自己的。
🎉 撒花 🎉
Footnotes
布尔表达式(Boolean expression)是一段代码声明,它只有
true
(真)和false
(假)两个取值。最简单的布尔表达式是等式(equality),这种布尔表达式用来测试一个值是否与另一个值相同。 ↩关系运算符是用于比较两个值关系的运算符,关系运算的结果是布尔值;逻辑运算符是合并布尔表达式的运算符,逻辑运算的结果仍然是布尔值。 ↩
break
表示强制跳出循环或匹配,此后的程序语句不再执行。在下文的for
循环也会使用到这个语句。 ↩后置语句也叫迭代语句。迭代的意思是,不断重复某个过程,每个过程都会有修改或优化。 ↩
取余也可以用 表示,与程序设计中的
%
是一样的。 ↩遍历(traversal)的意思是,按照一定的顺序依次访问一系列数据中的每个元素,确保每个元素都被访问一次且仅一次。 ↩