Featured image of post 刷题法入门 OCaml 函数式编程(一)

刷题法入门 OCaml 函数式编程(一)

函数式编程小白的一次自学尝试

这不是严谨的教程

我曾经多次尝试入门 OCaml,几乎都以失败告终😹️。表现为看不懂别人的代码,同时完全无法上手实践。对我个人而言,除了缺少中文资料,我尝试过的入门方法有以下弊端:

  • 看CS3110:看得太累,记住得太少
  • 看流行的OCaml教材:难以坚持
  • 看项目:看不懂,太多specific的东西干扰理解

我觉得这些问题很大程度上是因为思维上和习惯上都无法适应函数式编程

所以在多次尝试后,我终于找到了适合自己的学习方式:刷I/O基础题 + LLM辅助理解 + 参考教材或文字资料。(实在是太中国学生了😹️)

所以可以把这篇博客理解成入门OCaml的拐杖不要当成严谨的教程! 因为我也是在边学边写,所以有些地方会不简洁、不准确。我也是个小白,我不是竞赛大神,我也会犯错。细节和理论还是请大家看主流的教材或官方文档。我只是希望这篇文章可以帮助到和我脑电波同频的学习者,让我们都能成功上手函数式编程😸️。

想要了解一些概念性东西的读者可以看下我之前的文章函数式编程大辨析。当然函数式编程的灵魂还是得积累项目和理论经验。

入门博客的入门要求

  • 需要你自己完成最基础的环境配置
  • 需要懂一种高级编程语言(C++最好)且有数据结构和算法基础
  • 最好浏览过OCaml官网的入门教材,对一些概念有一个大体的认知
  • 注册一个洛谷账号

我后面都会以C++作为对比案例,同时部分内容和大体的顺序参考 CS3110教材,大家可以打开放在边上参考!

思维转换:P1001 A+B 问题

我是一个C++选手,既然OCaml可以支持命令式,那我决定先写一个超级命令式的程序!

1
2
3
4
5
let (a, b) = Scanf.scanf "%d %d" (fun x y -> (x, y))

let ans = a + b

print_int "%d" ans

很好。但其实这个程序会报错,因为OCaml的编译器不会通过换行或者分号来识别一个语句的结束,所以上面这段程序大意是:第一句是一个函数,第二行是个函数,第三行是和第二行连在一起的。还有一个问题,就是对于print_int这一系列带个下划线的print函数,它们的参数只有一个。所以来看下我的修改:

1
2
3
4
5
let (a, b) = Scanf.scanf "%d %d" (fun x y -> (x, y)) in

let ans = a + b in

print_int ans

好的,加了in来圈定作用域之后,就解决了编译器无法识别语句结束的问题,但实际上这三行是一个完整的大句子,而不是命令式那样顺序执行,但编译器视角暂时放一放,我们先矫正一下思路。

1
2
3
4
let () =
  Scanf.scanf "%d %d" (fun a b ->
      print_int (a + b)
    )

这样算是一个比较函数式的程序了。思路上,我们不需要加法的结果来做任何事情,所以let ()可以暂时理解为void,或者理解成C++里的main函数。在得到两个输入后,通过一个匿名函数(fun引导)将两个输入数字的和打印出来。

递归、链表和匹配:P1046 陶陶摘苹果

关于数据类型,我们一上来先学习一下 List,OCaml中的“数组”,但是称之为单向链表可能更合适。首先先来看点需要用到的符号:

  • [] 空表
  • 元素::链表 Cons符,可以用于取出头结点,也可以用于新增一个头结点

The cons operator :: is right associative, meaning that e1 :: e2 :: e3 is understood as e1 :: (e2 :: e3), not as (e1 :: e2) :: e3.

下面开始看这道题,最直观的思路就是用数组存储10个苹果,然后用循环、分支比较和计数器解决。

首先是用数组存储,其实就是一段递归程序,值得注意的是每次递归的最后一行都是新读取的元素a -> 上次一递归中的list这样的结构。

1
2
3
4
5
let rec read_apple n =
  if n = 0 then []
  else
    let a = Scanf.scanf " %d" (fun x -> x) in
    a :: read_apple (n - 1)

ok,停一下,假如你是手敲的代码,是否疑惑" %d"前面为什么有个空格?或者像我一样直接忽略了,然后发现输入有问题😹️

其实这个空格代表了吃掉前方所有连续的空白字符(包括空格、换行符、制表符等),直到遇到非空白字符为止,所以一定要加上。

解决了输入,来看一下这个题的核心。这里我们用到了match...with...,和C++中的switch不同,这里的模式匹配(Pattern Matching)不只是if语法糖,具体有点难解释,有点类似可以同时执行判断和变量操作,而且很好的一点是,如果漏掉了某类情况,编译器会直接报错。总之看下我们的例子:

1
2
3
4
5
6
let rec count_apple lst max_h =
  match lst with
  | [] -> 0
  | h :: t ->
      let cur_can_pick = if h <= max_h then 1 else 0 in
      cur_can_pick + count_apple t max_h

参数1 lst 是一个 List,情况1代表空,情况2代表它能够取出来一个头元素 h,即非空(因为 Cons 符决定了,右边的那个 t 肯定是个 List,所以即使 t 是空的,h :: t这个 List 也至少有 h 这个元素在)。搞明白了match...with...,其余的就好办多了,我们这个函数最终返回的结果就是每次递归cur_can_pick的累加。

好的,综上所述,这道题的完整代码就是:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
let rec read_apple n =
  if n = 0 then []
  else
    let a = Scanf.scanf " %d" (fun x -> x) in
    a :: read_apple (n - 1)

let rec count_apple lst max_h =
  match lst with
  | [] -> 0
  | h :: t ->
      let cur_can_pick = if h <= max_h then 1 else 0 in
      cur_can_pick + count_apple t max_h

let () =
  let apples = read_apple 10 in
  let reach = Scanf.scanf " %d" (fun x -> x) in
  let max_h = reach + 30 in
  let ans =  count_apple apples max_h in
  print_int ans

结构:P5740 最厉害的学生

接下来我们通过这道题实践一下 Records,其实把它理解成 C++ 中的结构体就可以了,没有太难。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
type student = {
  name : string;
  chinese : int;
  math : int;
  english : int;
  total : int;
}

let rec find_best remaining best_stu =
  if remaining = 0 then
    best_stu
  else
    let (n, c, m, e) = Scanf.scanf " %s %d %d %d" (fun n c m e -> (n, c, m, e)) in
      let stu = {name = n; chinese = c; math = m; english = e; total = c + m + e} in
        if stu.total > best_stu.total then
          find_best (remaining-1) stu
        else
          find_best (remaining-1) best_stu

let () =
  let n = Scanf.scanf " %d" (fun x -> x) in
  if n > 0 then
    let (name, c, m, e) = Scanf.scanf " %s %d %d %d" (fun n c m e -> (n, c, m, e)) in
    let first_stu = { name = name; chinese = c; math = m; english = e; total = c + m + e } in
    let ans = find_best (n - 1) first_stu in
    Printf.printf "%s %d %d %d\n" ans.name ans.chinese ans.math ans.english

我觉得这里除了在定义的时候使用 : ,实例化的时候使用 = 需要注意一下,别的没有什么思路上很难的地方。

但是我一开始写的时候犯了个错误,我一开始写的是:

1
find_best remaining-1 stu

这样会报错,为什么?我觉得可以理解为函数调用的优先级是最高的,所以编译器会先执行 find_best remaining,进入死循环。因此参数进行运算时,记得加上括号!

1
find_best (remaining-1) stu

高级匹配:P1042 乒乓球

好的,接下来就要上点难度了。一看这个题目就烦,因为有至少两个问题要解决:

  1. 正确读取 E 之前的输入
  2. 设计分数计算匹配机制

如果是用C++写,大概的思路就是用一个 while 循环内,两个变量来做计分器。怎么这么简单啊我哭了。

那先来想下输入,直接放我的代码:

1
2
3
4
5
6
7
8
let rec read_chars () =
  try
    let c = Scanf.scanf "%c" (fun x -> x) in
    match c with
    | 'E' -> ['E']
    | 'W' | 'L' -> c :: read_chars ()
    | _ -> read_chars ()
  with End_of_file -> []

这里涉及了很多要点:

  • 在OCaml的模式匹配中,下划线 _ 可以作为一个占位符,代表我不关心的其他所有情况。
  • try ... with End_of_file -> [] 可以理解为C++中的 try ... catch,而这里的 End_of_file 是一个关键字,代表 EOF (End Of File)。
  • 'W' | 'L' 是这两种情况走向同一个结果,匹配顺序是从左到右。
  • read_chars () 里这个括号,实际上是一个值 unit,可以代表空参数列表,在这里用于告诉编译器这是个函数,不是个常量。

这样在常规链表输入的基础上,判断了链表的终点E。

接着是核心递归逻辑,有几种情况(模式):

  • 华华或对手得分
  • 累计可以计算一次大比分
  • 识别到E,比赛结束

模式理顺了之后就可以写代码了。这个递归函数肯定需要两个大比分和一个链表作为参数,但是累计大比分好像有点难办,因为这涉及到数值和逻辑运算,不是单纯的匹配。这时候可以用 when 关键字来限制匹配条件,在匹配的同时,又要满足后面的逻辑判断,才可以匹配成功。下面是代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
let rec play_11 lst w l =
  match lst, w, l with
  | lst, w, l when (w >= 11 && w - l >= 2) || (l >= 11 && l - w >= 2) ->
      Printf.printf "%d:%d\n" w l;
      play_11 lst 0 0
  | 'E' :: _, w, l -> Printf.printf "%d:%d\n" w l
  | 'W' :: tail, w, l ->
      play_11 tail ( w + 1 ) l
  | 'L' :: tail, w, l ->
      play_11 tail w ( l + 1 )
  | [], _, _ -> ()
  | _ :: tail, w, l ->
      play_11 tail w l

完整代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
let rec play_11 lst w l =
  match lst, w, l with
  | lst, w, l when (w >= 11 && w - l >= 2) || (l >= 11 && l - w >= 2) ->
      Printf.printf "%d:%d\n" w l;
      play_11 lst 0 0
  | 'E' :: _, w, l -> Printf.printf "%d:%d\n" w l
  | 'W' :: tail, w, l ->
      play_11 tail ( w + 1 ) l
  | 'L' :: tail, w, l ->
      play_11 tail w ( l + 1 )
  | [], _, _ -> ()
  | _ :: tail, w, l ->
      play_11 tail w l
  
let rec play_21 lst w l =
  match lst, w, l with
  | lst, w, l when (w >= 21 && w - l >= 2) || (l >= 21 && l - w >= 2) ->
      Printf.printf "%d:%d\n" w l;
      play_21 lst 0 0
  | 'E' :: _, w, l -> Printf.printf "%d:%d\n" w l
  | 'W' :: tail, w, l ->
      play_21 tail ( w + 1 ) l
  | 'L' :: tail, w, l ->
      play_21 tail w ( l + 1 )
  | [], _, _ -> ()
  | _ :: tail, w, l ->
      play_11 tail w l

let rec read_chars () =
  try
    let c = Scanf.scanf "%c" (fun x -> x) in
    match c with
    | 'E' -> ['E']
    | 'W' | 'L' -> c :: read_chars ()
    | _ -> read_chars ()
  with End_of_file -> []

let () =
  let data = read_chars () in
  play_11 data 0 0;
  Printf.printf "\n";
  play_21 data 0 0

这个案例算是初步看出点灵魂了,我个人觉得,C++是有点走一步看一步的,而OCaml更贴近状态机的本质,需要考虑整个数据空间

结语

好了,第一期就到这里了!其实我狂写了这么多,也还没捋完cs3110的前3部分😹️。总之希望可以帮助到你,有任何想法请和我交流!后面我会持续更新的(大概。

使用 Hugo 构建
主题 StackJimmy 设计
发表了13篇文章 · 共嘟嘟了34.70k字
·