diff --git a/docs/programming/exam/index.md b/docs/programming/exam/index.md index 712b91d..4e78ed0 100644 --- a/docs/programming/exam/index.md +++ b/docs/programming/exam/index.md @@ -1,532 +1,37 @@ # 历年卷整理 -## 历年卷列表 +## 程序设计与算法基础 - -??? note "程序设计基础历年卷(2013-2018+2020)" +暂无 - | 年份 | 试卷 | 答案 | - | :---: | :---: | :---: | - | 2013 | [试卷](fundamentals_of_programming/fp13test.pdf) | [答案](fundamentals_of_programming/fp13answer.pdf) | - | 2014 | [试卷](fundamentals_of_programming/fp14test.pdf) | [答案](fundamentals_of_programming/fp14answer.pdf) | - | 2015 | [试卷](fundamentals_of_programming/fp15test.pdf) | [答案](fundamentals_of_programming/fp15answer.pdf) | - | 2016 | [试卷](fundamentals_of_programming/fp16test.pdf) | [答案](fundamentals_of_programming/fp16answer.pdf) | - | 2017 | [试卷](fundamentals_of_programming/fp17test.pdf) | [答案](fundamentals_of_programming/fp17answer.pdf) | - | 2018 | [试卷](fundamentals_of_programming/fp18test.pdf) | [答案](fundamentals_of_programming/fp18answer.pdf) | - | 2020 | [模拟卷 1 及答案](fundamentals_of_programming/fp20simulation1.pdf) [模拟卷 2 及答案](fundamentals_of_programming/fp20simulation2.pdf) [模拟卷 3 及答案](fundamentals_of_programming/fp20simulation3.pdf) | | +## 程序设计基础与实验 - +暂无 -### C 程序设计专题 +## 【已停开】程序设计基础(2013-2018+2020) - -??? note "C 程序设计专题历年卷(2013-2019)" +该课程已于 2022-2023 学年停开,经典题目解析见[C 大经典题目解析](fundamentals_of_programming/index.md) - | 年份 | 试卷 | 答案 | - | :---: | :---: | :---: | - | 2013 | [试卷](lectures_on_c_programming/lcp13test.pdf) | [答案](lectures_on_c_programming/lcp13answer.pdf) | - | 2014 | [试卷](lectures_on_c_programming/lcp14test.pdf) | [答案](lectures_on_c_programming/lcp14answer.pdf) | - | 2015 | [试卷](lectures_on_c_programming/lcp15test.pdf) | [答案](lectures_on_c_programming/lcp15answer.pdf) | - | 2016 | [试卷](lectures_on_c_programming/lcp16test.pdf) | [答案](lectures_on_c_programming/lcp16answer.pdf) | - | 2017 | [试卷](lectures_on_c_programming/lcp17test.pdf) | [答案](lectures_on_c_programming/lcp17answer.pdf) | - | 2018 | [试卷](lectures_on_c_programming/lcp18test.pdf) | [答案](lectures_on_c_programming/lcp18answer.pdf) | - | 2019 | [试卷](lectures_on_c_programming/lcp19test.pdf) | [答案](lectures_on_c_programming/lcp19answer.pdf) | - +| 年份 | 试卷 | 答案 | +| :---: | :---: | :---: | +| 2013 | [试卷](fundamentals_of_programming/fp13test.pdf) | [答案](fundamentals_of_programming/fp13answer.pdf) | +| 2014 | [试卷](fundamentals_of_programming/fp14test.pdf) | [答案](fundamentals_of_programming/fp14answer.pdf) | +| 2015 | [试卷](fundamentals_of_programming/fp15test.pdf) | [答案](fundamentals_of_programming/fp15answer.pdf) | +| 2016 | [试卷](fundamentals_of_programming/fp16test.pdf) | [答案](fundamentals_of_programming/fp16answer.pdf) | +| 2017 | [试卷](fundamentals_of_programming/fp17test.pdf) | [答案](fundamentals_of_programming/fp17answer.pdf) | +| 2018 | [试卷](fundamentals_of_programming/fp18test.pdf) | [答案](fundamentals_of_programming/fp18answer.pdf) | +| 2020 | [模拟卷 1 及答案](fundamentals_of_programming/fp20simulation1.pdf) [模拟卷 2 及答案](fundamentals_of_programming/fp20simulation2.pdf) [模拟卷 3 及答案](fundamentals_of_programming/fp20simulation3.pdf) | | +## 【已停开】C 程序设计专题(2013-2019) +该课程已于 2022-2023 学年停开,经典题目解析见[C 小经典题目解析](lectures_on_c_programming/index.md) -## 2020-2021 年卷 - -- 选择题 1 `typedef` - -> 注意这里的 `typedef struct{char * name;} *T;` 把 `T` 定义为 `struct{char * name;}*;` 的别名。下一个语句即声明变量 `t` 为该类型。 -> -> - A:`char *` -> - B:该表达式实际为 `&(t->name[0])`,即 `char *` 类型。 -> - C:`char *` -> - D:`*(t.name)` 是一个错误的用法,不能直接对结构的指针使用 `.` 运算符访问其成员。 - -- 选择题 3 指针类型 - -> 函数名、数组名都不是指针哦,虽然它们常常退化成指针。 -> -> - A:`p` 是函数名 -> - B:`p` 是函数名 -> - C:`p` 是指向 `int[5]` 的指针 -> - D:`p` 是一个数组 - -- 选择题 4 存储类别限定符 - -> C 语言中一共有 5 个存储类别限定符,请回忆它们的作用: -> -> - `auto`:默认的存储类别限定符,用于局部变量,表示变量的生命周期与函数调用相同。 -> - `register`:用于局部变量,表示变量可能存储在 CPU 寄存器中,以加快访问速度。 -> - `extern`:用于全局变量,表示变量在其他文件中定义。 -> - `static`: -> -> - 用于局部变量,表示变量的生命周期与程序运行相同。 -> - 用于全局变量,表示变量的作用域仅限于当前文件。 -> -> - `_Thread_local`:不作要求。 -> -> 其实 `typedef` 按语法功能也被分在这一类,我们不管它。 -> -> 在任何声明中,只能同时存在最多一个**存储类别限定符**。这与 `const`、`volatile`、`restrict` 等**类型限定符**不同,它们可以同时存在。 - -- 选择题 5 函数指针 - -> 见 2019 年选择题 6 - -- 选择题 8 数据结构的使用 - -> 这种题通用的方法就是每个选项尝试一遍。只要对栈、队列操作熟悉的话,一个个试很快就出来了。 - -- 简答题 1.1 后缀表达式 - -> 如果你的答案和参考答案不一样,也是有对的可能的,就像中缀表达式那样,二元运算符的操作数是可以换序的( -> -> 注意这道题中的 `^` 运算符的右结合问题,加上括号后变成 `d^(e^f)` 而不是 `(d^e)^f`,转换成后缀表达式只能是 `def^^`,而 `fed^^` 之类的全是错的。 - -- 简答题 5 排序算法最优情况 - -> 见常见问题-算法复杂度 -> -> 引用一下 ztgg 的解释: -> -> - 平均情况下,插入元素导致的移动依旧是 $O(n)$ 的,并没有优化,所以总复杂度还是 $O(n^2)$。(批注:交换的复杂度为 $O(n^2)$,比较的复杂度为 $O(n\log n)$,前者较大,占据主导地位)。 -> - 最佳情况应该是插入元素时,不需要移动原来的元素,也就是数组已经排好序了。这个情况下,每次插入只有二分查找的代价,即为 $O(n\log n)$。 - -- 简答题 6 队列操作 - -> 如果第一个 `while` 循环读不懂一定要找同学/老师问清楚捏,队列的使用是很基本的。请思考这个 `while` 循环为什么需要逐个 `malloc()` 呢? -> -> 中间那个 `while` 循环有点迷惑。它其实只是在将 `eQueue` 中的每个元素放到 `dQueue` 时同时把后面一个元素换到队尾去。理解到这里就能做对啦。 - -- 程序填空 3 - -> 这几个空都需要花一会儿时间来推断的: -> -> - `InitGraphics()` 一定要记得 -> - 记住几种回调函数的使用方法,`void registerTimerEvent(TimerEventCallback callback)` 与 `void startTimer(int id,int timeinterval)` 配对使用,它们各自的参数意义。 -> - **计时器回调函数怎么写?**参数 `timerID` 是用来做什么的? -> - 第 14 空可能不容易想到,反正记住在每次画点什么东西之前,都要检查**画笔位置**是否正确,这在绘制分形图形时也很重要。 - -- 算法设计 1 链表循环检测 - -> 想象一个解谜场景:如果你和你的朋友走在一条路上,你们要怎么做才能判断有没有遇到鬼打墙(即在原路绕圈) ? -> -> 答案很简单:一个人走快点,一个人走慢点。如果这条路有尽头,先走的人一定会先到达尽头;如果没有到达尽头,那么他一定会重新看见你。 -> -> `LoopDetect()` 函数的思路也类似:使用两个指针 `fast` 和 `late`,`fast` 每次步进两个节点,`late` 每次步进一个节点。最后终止时只有两种条件:`fast` 无法继续步进或 `fast` 在前进的路上看见 `late`。前者表明没有循环,后者表明有循环。 - -- 算法设计 2 有序数组原地去重 - -> 看到这道题你有没有想起字符串去空格的一个例程? -> -> ```c -> char *a; -> for(int i = 0, j = 0; i < length; i++){ -> if(a[i] != ' ') -> a[j++] = a[i]; -> } -> ``` -> -> 有序数组去重与这个算法也有些类似,只是比较条件换成 `a[i]!=a[j]` 了而已。 - -## 2019-2020 年卷 - -- 选择题 1 递归函数 - -> - D 项 调用栈位于**堆栈段(Stack Segment)**,在运行时创建,也有自己的大小,不能越界访问。越界造成**段错误(Segmentation Fault)**。每次递归调用添加栈帧,造成的越界称为**栈溢出(Stack Overflow)**。堆栈段中保存着**函数调用关系和局部变量**。局部变量过大也可能造成栈溢出。 - -- 选择题 2 时间复杂度分析 -- 选择题 6 函数指针 - -> 一句话总结:作为函数作为形参,会自动退化成函数指针,就像数组名作为形参自动退化成指针那样。这句话在下面的英文部分提到了。 -> -> > The type of a function is determined using the following rules. [...] After determining the type of each parameter, **any parameter** of type “array of T” or **of function type T is adjusted to be “pointer to T”**. [...] - -> - 函数指针:指向函数的指针中储存着函数代码的起始处地址,要指明函数的类型,要指明函数的返回类型和形参类型。把函数名替换成 `(*pf)` 的形式是最简单的方法,如 `void ToUpper(char *)` 改为函数指针 `void (*pf)(char *)`。 -> - 声明函数指针后,可以将函数的地址赋给它,**这种语境下函数名可以表示函数的地址**。因此我们可以写:`pf = ToUpper`,注意不是 `pf = ToUpper()`。 -> -> - 使用函数指针调用函数有两种方法:`(*pf)(mis)` 和 `pf(mis)`,它们看起来矛盾。事实上,K&R C 不允许第二种形式,我也推荐大家始终将函数调用理解为第一种形式。 -> - 第一种形式,先解引用函数指针再调用该函数,这个思路很直接。 -> - 第二种形式,来源是上面的赋值语句,在上面的赋值语境下,指针和函数名可以互换使用。 -> - 取函数的地址也有两种方法:`f` 和 `&f`。 - -> - C 项 或许通过上面的讲解,你能理解 `(*cmd)` 与 `cmd` 的等价之处。下面是 StackOverflow 中的讨论:[c++ - What does `void f(void())` mean? - Stack Overflow](https://stackoverflow.com/questions/39440970/what-does-void-fvoid-mean)。 -> -> > As mentioned in [_dcl.fct_](http://eel.is/c++draft/dcl.fct#5) of the working draft (emphasis mine): -> > -> > > The type of a function is determined using the following rules. [...] After determining the type of each parameter, **any parameter** of type “array of T” or **of function type T is adjusted to be “pointer to T”**. [...] -> > -> > Because of that, the following function: -> > -> > ```cpp -> > void f(void()); -> > ``` -> > -> > Has the same type of: -> > -> > ```cpp -> > void f(void(*)()); -> > ``` -> > -> > Thus the definitions below are identical: -> > -> > ```cpp -> > void f(void(g)()); -> > void f(void(*g)()); -> > ``` -> -> > Correct me if I'm wrong, AFAIK function names are pointers just like array names so **in the first example you are passing function object and compiler does implicit conversion**, in the second example you are directly passing function pointer which is explicit conversion. - -- 选择题 7 函数指针 - -> 同样依据上面的讲解能够选出正确答案 - -- 选择题 8 函数参数 - -> 我觉得 D 项的表述本来就很混乱。函数参数如果为 `void` 就表示函数不接收参数,这就是 `void` 关键字的作用,而不是选项中说的什么“函数有一个 `void` 类型的参数”。 - -- 选择题 9 图形库 - -> 请 WK 班同学一定要去看辅学群里其他老师的图形库课件 - -- 选择题 10 还是函数指针 - -> - 第一行:`F` 定义为 `int (int)` 类型的函数。 -> - 第二行:声明两个类型为 `F` 的函数 `g` 和 `h`。其实就是 `int g(int a)` 和 `int h(int a)`。 -> - 第三行:声明一个数组 `p`,其中每个元素都是 `int (*)(int)` 类型(与 `F` 等价)。并用 `g` 和 `h` 来初始化这个数组。 -> - 让我们从内往外读这个声明:`p` 是标识符的名称,向右 `[]` 表明这是一个数组,向左 `*` 表示其元素是指针,再向右 `(int)` 表示其所指类型是函数,这种函数接受 `int` 类型参数,再向左 `int` 说明这种函数返回 `int` 类型。 -> - 用 `g` 和 `h` 初始化这个数组时,`g` 和 `h` 被转换为函数指针(回顾上面的讨论)。 -> - 第四行:声明一个函数 `q` 这个函数返回 `int`,接受一种数组,这种数组的每个元素都是 `F*` 类型,即 `int (*)(int)`。故函数 `q` 的参数类型为 `int (**)(int)`。 - -> - A 项:数组名就是首元素指针,为 `int (**)(int)`,匹配。 -> - C 项:显然类型匹配。 -> - D 项:对函数取地址,得到 `int (*)(int)`,与 `int (**)(int)` 类型不匹配。 - -- 简答题 1 - -> 如果程序代码有错,就勇敢地写“该段程序可能运行失败”。 - -- 简答题 3 - -> 注意,合并数组的时候部分去重了。如果离开了第一个 `while` 循环,则不会去重。 - -- 简答题 4 - -> 让我们看 `main()` 函数的第一行的表达式: -> -> - 外层:`((H)内层表达式 )(100);`,它会将内层表达式强制类型转换为 `H` 类型的函数,然后对该函数执行函数调用。 -> - 内层:`h(0)`。调用后返回了 `h`,即函数自己,也就是函数自己的指针。作为 `void *` 类型返回,表明它是一个指针,但不知道所指向的类型。 -> - 内层调用后,外层就相当于 `h(100)`了,因为 `h` 本来就是 `H` 类型的函数。 - -- 程序填空 3 `geblib.h` - -> WK 班同学应当补充阅读 `libgraphics` 库中的一些内容,我们直接读源码吧: -> -> - `New()` 宏函数: -> -> - Usage: `p = New(pointer-type);` -> -> - The New pseudofunction allocates enough space to hold an object of the type to which pointer-type points and returns a pointer to the newly allocated pointer. Note that "New" is different from the "new" operator used in C++; the former takes a **pointer type** and the latter takes the target type. -> -> - 源码: -> -> ```C -> void *GetBlock(size_t nbytes); -> #define New(type) ((type) GetBlock(sizeof *((type) NULL))) -> ``` -> -> - 举个例子:调用 `New(char*)` -> -> - 宏展开为 `((char*) GetBlock(sizeof *((char*) NULL)))` -> - `GetBlock()` 函数接收需要分配的字节数,返回分配成功的指针。对于上面的宏展开后的调用参数,`NULL` 被转换为 `char*` 随后解引用仍为 `char` 类型的大小。 -> - 这个调用就返回了一个 `char*` 的指针。 -> -> - `FreeBlock()` 函数: -> -> - 原型:`void FreeBlock(void *ptr)` -> - 与 `free()` 功能类似,不加解释。 - -> 第 15 空有意思,`FreeBlock(PopStack(stack))` 的嵌套写法。 - -- 算法设计 1 分形 - -> 这类算法设计题目,怎么简洁怎么来,以 OI 码风去写是最合适的。不要试图弄完善的交互,那是浪费时间。 - -> 以下是我自己做的时候写的,作为一个不好的参考( -> -> 理解错题意了,原来 `order` 不是方向而是分形次序。 -> -> - 基准情形:长度缩小到某值。 -> - 递归情形:画一根,随后两次递归调用,绘制下一支的 `length` 和 `order`。每次递归调用后,都应当**返回原位**。 -> -> ```c -> #include -> #include -> #include -> #include "graphics.h" -> #define MIN_LEN .1 -> -> double toRadius(double deg) -> { -> return deg * 3.1415926 / 180; -> } -> -> void DrawBranch(double len, double deg) -> { -> DrawLine(len * cos(toRadius(deg)), len * sin(toRadius(deg))); -> if (len * 0.75 < MIN_LEN) -> return; -> DrawBranch(len * 0.75, deg - 15); -> MovePen(GetCurrentX() - len * 0.75 * cos(toRadius(deg - 15)), -> GetCurrentY() - len * 0.75 * sin(toRadius(deg - 15))); -> //也可以用 DrawLine 实现 -> DrawBranch(len * 0.75, deg + 15); -> MovePen(GetCurrentX() - len * 0.75 * cos(toRadius(deg + 15)), -> GetCurrentY() - len * 0.75 * sin(toRadius(deg + 15))); -> } -> -> int main(void) -> { -> double length; -> char order; -> printf("Please enter initial length: "); -> scanf("%lf", &length); -> getchar(); -> printf("Please enter order (u)pper, (d)own, (r)ight, (l)eft: "); -> scanf("%c", &order); -> double deg; -> switch(order) -> { -> case 'u': deg = 90; break; -> case 'd': deg = -90; break; -> case 'r': deg = 0; break; -> case 'l': deg = 180; break; -> default: printf("error.\n"); return 1; -> } -> InitGraphics(); -> MovePen(GetWindowWidth()/2, GetWindowHeight()/2); -> DrawBranch(length, deg); -> return 0; -> } -> ``` - -- 算法设计 2 列表变序 - -> 就用标答的方法,将偶数节点移动到另一个链表,再合并两个链表。 - -## 2018-2019 年卷 - -- 选择题 4 递归计算 - -> 像这种递归计算,就老老实实把函数递归展开吧。展开过程中记得依次记下已经计算完的 `f(0)`、`f(1)` 等值,方便后续计算。 - -- 选择题 6 算法复杂度分析 - -> 这道题我的想法挺奇葩的,我是想只要全部排序一遍 $O(N\log N)$,然后用 $O(1)$ 的时间检查一下头、中间、尾部的元素不就好了吗(doge - -- 选择题 9 递增运算符 - -> 前缀递增运算符先递增再使用。 - -- 简答题 1.2 - -> 本题英文有点烫嘴,我翻译一下: -> -> > 为了用类似 `T p` 的方式声明一个指针 `p`,请写出复合类型 `T` 的定义。`p` 是一个函数的指针,该函数接收 `(char *, double)` 参数,并返回一个 `int *`。 -> -> 读懂题目剩下的就不用说啦。 - -- 简答题 2 数据与字节 - -> 注意:**xx-bit system(n 位系统)**指的是这个系统的指针长度有 $n$ 比特,$8$ 比特为一个字节。故本题的所有指针都是 $4$ 字节。 -> -> 以下是各类型的大小: -> -> - `StudentInfo`:两个 `char` 数组 + 一个指针 = $12 + 20 + 4 = 36$ -> - `PtrStudentInfo`:$4$ -> - `pStudent->name`:一个 `char` 数组 $=20$ -> - `pStudent->photo`:一个 `void*` 指针 $=4$ -> -> 从上面再次看到,数组名并不能简单被看作指针,它还包含数组的类型信息。 - -- 简答题 3 链表操作 - -> 这道题答案感觉有点问题啊。反正只要知道返回的时候 `p` 指向 $2$ 这个节点就算对了,题目说 `node` 那应该不用吧后面的节点都写出来吧。 - -- 简答题 4 链表操作 - -> 这个函数合并了两个链表,按升序合并。 -> -> `HEAD` 是一个临时使用的哑节点。 -> -> 调用后,原来的两个指针指的位置不变,`l1` 仍然指向 $1$ 这个节点。但节点之间的连接变了,这时 `l1` 后面链上了从 `l2` 合并进来的其他节点。所以可以看作“链表” `l1` 发生了改变。 - -- 简答题 5 双向栈 - -> 这是一个双向的栈。每次入/出栈时,需要用 `Tag` 参数指定是哪一头。从数组的角度来看,`Top1` 是左边(头部)那头,`Top2` 是右边(尾部)那头。 -> -> 读这种题时,我推荐先读 `main()` 中的内容,即观察题目给的数据结构是**怎么被使用的**。然后不明白的地方再去看具体实现的代码,其他部分就一点都不用看。比如这道题: -> -> - 先看 `main()` 中的 `Push()` ,这怎么比平常的 `Push()` 多一个参数呢? -> - 再看类型定义,怎么有两个 `Top`?回想 `main()` 中一个令为 `-1` 一个令为 `MaxSize` 便知道这是一个双向栈了。 -> - `Push()` 和 `Pop()` 的代码就不用看了,想象得到是怎么操作的。最多再多看一眼 `if(Tag == 1)` 知道哪个值对应哪一头,就可以完成这道题了。 - -- 简答题 6 不知道是什么 - -> 这个东西,保险起见推荐手工模拟,而且手工模拟几次后你就知道这个函数在干嘛了。 -> -> 其实这个函数的作用是:调用后保证数组 `a[k]` 左侧的元素都比 `a[k]` 小,右侧都比 `a[k]` 大。最后返回 `a[k]` 上的元素。**但不会保证其他元素之间的相对顺序**。 -> -> 具体的操作就是:每轮循环把第 `a[k]` 位置上的元素提出来作为 `x`,然后用 `i` 和 `j` 分别从左右遍历并交换两侧不符合要求的数。交换完成后,数组中比 `x` 小的数都在相对左边的位置,比 `x` 大的数都在相对右边的位置。 -> -> 如果你对快排比较熟悉,那么这就是“如果目标位置不在的一边直接舍弃”的快排。相当于本来快排区间形成一颗树,但是现在就只走一条路,只排 `a[k]` 所在的那些区间套。 - -- 程序填空 1 分型 - -> 画个坐标轴,一切都清晰起来了。然后 `Main()` 里最开始对大三角形三个顶点的求值可能会引起困惑,其实可以不用管它,它不影响你分析递归调用过程。它只是在用三角函数计算位置使这个大三角形的中心在屏幕中央罢了。给个很草的草稿示意图: -> -> 画这个分形的步骤就是:先画大三角,然后各边取中点。大三角的每个顶点和相邻两边中点构成两个小三角。 -> -> ![](https://cdn.bowling233.top/images/2023/06/202306241632120.png) - -- 程序填空 2 循环队列实现 - -> 本质上还是用数组实现循环队列,只不过本题进行了比较完善的封装。 -> -> 如果还不知道循环队列是什么东西,去网上搜一搜。循环队列的要点就是:所有加法操作全部要套上一次取模操作。本题注意一下间接成员运算符 `->` 的使用。 -> -> 此外循环队列的 `rear` 也有不同实现方法,在本题中,它标志队列尾部的后一个元素,也就是下一个元素应该插入的地方;在另一些实现中,它直接标志队列尾部的元素。比如如果本题在创建数组的时候 `Q->rear = maxsize - 1`,这些空应该作怎样的改变呢? - -- 程序填空 3 图形库 - -> 参见常见问题-图形库-计时器 - -- 算法设计 1 寻找第一个公共节点 - -> 想象这样一个情境:还是想象你和你的朋友站在题目所示的两个链表的起始处。这两个链表有可能相交,你们想要尽快找到会合点,怎么办呢?而且一个有用的信息是,你们都知道自己离终点还有多远。 -> -> 如果相交,你们肯定有公共子链表。剩下不同的部分就是你们各自子链表的长度。因此,你们应当先相对终点对齐彼此的位置,使自己剩余的子链表的的长度相等。接下来以相同的速度前进,如果你们在某处会合了,那么这一定是公共子链表的起始处。 -> -> 参答中,`lPtr` 指向较长的链表,`sPtr` 指向较短的链表,`numLeftNodes` 就是两链表节点数的差值,`lPtr=lPtr->next` 的 `for` 循环就是在对齐两人的位置。 -> -> 循环终止的条件是:其中某人走到了尽头 `NULL`,或两人相遇 `lPtr==sPtr`。返回最终位置即可。 - -- 算法设计 2 二分插入排序 - -> 这题简单,不作解析。参答中漏了检查 `minPos == rh` 的情况,想想这样会造成什么后果? - -## 2017-2018 年卷 - -- 选择题 1 数据类型与指针操作 - -> - C 项:`strcpy()` 只能用于字符串。进一步说,它依据字符串末尾的 `\0` 来决定是否停止复制,因此不宜用于此情境。 -> - D 项:每次 `*pc2++ = *pc1++`,都会将 `pc1` 的一个字节拷贝到 `pc2` 指向的位置,并让这两个指针向后移动一个 `char` 的位置。由于 `p1` 和 `p2` 两个结构变量大小都是 $8+8=16$ 字节,因此该选项正确地执行了拷贝。 - -- 选择题 7 冒泡排序 - -> 这道题时,我选的是 D 项。因为我记忆中的冒泡排序是这样的: -> -> ```c -> void bubble_sort(int *arr, int len) { -> int i, j, tmp; -> for (i = 0; i < len - 1; i++) { -> for (j = len - 1; j > i; j--) {//Bubble -> if (arr[j] < arr[j - 1]) { -> tmp = arr[j]; -> arr[j] = arr[j - 1]; -> arr[j - 1] = tmp; -> } -> } -> } -> } -> ``` -> -> 上面这样的写法对于任何情况的复杂度都是 $O(n^2)$。但是冒泡排序普遍会作这样的优化: -> -> - 如果在上一轮的 Bubble 中,没有发生任何交换,则说明这个序列也是有序的,不再需要后续操作了。 -> -> 因此可以在外循环开头添加 `bool flag = 0;`,结尾添加 `if(!flag)break;`,交换操作中添加 `flag=true` 即可将最优情况优化至 $O(n)$。 - -- 选择题 9 存储类别限定符 - -> - D 项:`static` 此处修饰的是指针 `p`。事实上,也不存在指针指向 `static int` 这种说法。`static` 作为存储类别限定符,在变量的声明中表示该变量具有静态存储期。一个指针,只需要管它指向的是什么类型,不需要知道这个对象的存储期。 - -- 简答题 1.(2) 写函数声明 - -> 先想想自己会怎么调用这个 `fun` 函数才能得到 `void` 类型: -> -> - 第一步,调用函数获取返回值:`fun(int)`。 -> - 第二步,解引用返回值,获得函数,调用该函数:`(*fun(int))(int)`。注意,函数指针应当使用 `(*fp)()` 形式调用。 -> -> 得到了答案:`void (*fun(int))(int)`。 - -- 简答题 5 最大子列和 - -> 其实这是一个经典的算法:**所有连续子列元素的和中最大者**。在网上可以搜到很多该算法的原理介绍,请去看一看,看完就能立刻明白这段代码了。 -> -> `thisp` 被放置在最大子列的开头,`maxp` 被放置在最大子列的末尾。 - -- 简答题 6 侏儒排序 - -> 基于比较的排序算法,大概都能优化到**最优情况**复杂度为 $O(n)$ 吧? - -- 程序填空 2 多项式计算 - -> 这题蛮坑的,我看了好一会儿才明白第 (7)(8) 空在干嘛。它其实就是先把前面的高次项提公因式,然后在逐步向后求和的过程给它整体乘上 $x$。比如 $3x^4+2x^2+1$ 可以这样计算: -> -> - $(((3x)x^2)+2x)x + 1$ -> -> 这样做一定程度上减少了计算乘积的次数。 -> -> 同样注意 `->` 运算符的使用。 - -## 2016-2017 年卷 - -- 选择题 1 倒序栈 - -> 注意题目中说明了**栈顶指针位于 `N`**,这个栈是从数组的尾部开始累积的。 - -- 简答题 1.(2) 写函数指针 - -> 简单函数指针,直接这样记:`typedef 返回类型 (*新名字)(参数列表)` - -- 简答题 3 奇偶排序 - -> 和前一年的侏儒排序有点像,有序情况也是一遍过,最优时间复杂度也是 $O(n)$。 - -- 简答题 4 链表操作 - -> 过程中,该链表顺序被重新排列。比 `x` 小的节点依次移动到 `root` 为首的带哨兵链表中,大的依次移动到 `pivot` 为首的哨兵链表中,最后将两个链表合并,返回合并后的链表。 - -- 简答题 5 因数分解 - -> 本题其实就是在从小到达求因数、约去这个因数、求下一个更大的因数...。模拟一遍即可。 - -- 程序填空 1 计数排序 - -> 排序原理题目已经讲清楚了,这里讲一下循环中几个变量的作用: -> -> - `count` 数组:先用于统计出现次数,后用来标记开始位置。 -> - `output_array[count[input_array[i]]] = input_array[i]` 我们来拆解一下: -> - `intput_array[i]` 就是第 `i` 个元素 -> - 把它放到 `count[]` 就能查到这个数应该放置的起始位置。 -> - 所以第 4 空当然要递增啦。 - -- 程序填空 3 差集 - -> 值得一提的是,这两个链表都是集合,这意味着其中的元素都是唯一的,所以不需要考虑重复元素的情况,不需要完整遍历 $A$。 - -## 2015-2016 年卷 - -- 选择题 5 宏的展开 - -> 宏展开只是简单的文本替换。 -> -> - 先展开 `DD` 得到 `SQ(2*3) - SQ(2+3)`,得到 `2 * 3 * 2 * 3 - 2 + 3 * 2 + 3` -> - 先展开 `SQ` 得到 `DD(x, y) = x * x - y * y`,得到 `2 * 3 * 2 * 3 - 2 + 3 * 2 + 3` -> -> 从上面的展开中我们看到,宏函数的展开顺序并不重要。最终结果应当一致。 +| 年份 | 试卷 | 答案 | +| :---: | :---: | :---: | +| 2013 | [试卷](lectures_on_c_programming/lcp13test.pdf) | [答案](lectures_on_c_programming/lcp13answer.pdf) | +| 2014 | [试卷](lectures_on_c_programming/lcp14test.pdf) | [答案](lectures_on_c_programming/lcp14answer.pdf) | +| 2015 | [试卷](lectures_on_c_programming/lcp15test.pdf) | [答案](lectures_on_c_programming/lcp15answer.pdf) | +| 2016 | [试卷](lectures_on_c_programming/lcp16test.pdf) | [答案](lectures_on_c_programming/lcp16answer.pdf) | +| 2017 | [试卷](lectures_on_c_programming/lcp17test.pdf) | [答案](lectures_on_c_programming/lcp17answer.pdf) | +| 2018 | [试卷](lectures_on_c_programming/lcp18test.pdf) | [答案](lectures_on_c_programming/lcp18answer.pdf) | +| 2019 | [试卷](lectures_on_c_programming/lcp19test.pdf) | [答案](lectures_on_c_programming/lcp19answer.pdf) | diff --git a/docs/programming/exam/lectures_on_c_programming/index.md b/docs/programming/exam/lectures_on_c_programming/index.md new file mode 100644 index 0000000..5cb81af --- /dev/null +++ b/docs/programming/exam/lectures_on_c_programming/index.md @@ -0,0 +1,501 @@ +# C 程序设计专题 历年卷经典题目解析 + + +!!! danger "页面正在施工中" + + +## 2020-2021 年卷 + +- 选择题 1 `typedef` + +> 注意这里的 `typedef struct{char * name;} *T;` 把 `T` 定义为 `struct{char * name;}*;` 的别名。下一个语句即声明变量 `t` 为该类型。 +> +> - A:`char *` +> - B:该表达式实际为 `&(t->name[0])`,即 `char *` 类型。 +> - C:`char *` +> - D:`*(t.name)` 是一个错误的用法,不能直接对结构的指针使用 `.` 运算符访问其成员。 + +- 选择题 3 指针类型 + +> 函数名、数组名都不是指针哦,虽然它们常常退化成指针。 +> +> - A:`p` 是函数名 +> - B:`p` 是函数名 +> - C:`p` 是指向 `int[5]` 的指针 +> - D:`p` 是一个数组 + +- 选择题 4 存储类别限定符 + +> C 语言中一共有 5 个存储类别限定符,请回忆它们的作用: +> +> - `auto`:默认的存储类别限定符,用于局部变量,表示变量的生命周期与函数调用相同。 +> - `register`:用于局部变量,表示变量可能存储在 CPU 寄存器中,以加快访问速度。 +> - `extern`:用于全局变量,表示变量在其他文件中定义。 +> - `static`: +> +> - 用于局部变量,表示变量的生命周期与程序运行相同。 +> - 用于全局变量,表示变量的作用域仅限于当前文件。 +> +> - `_Thread_local`:不作要求。 +> +> 其实 `typedef` 按语法功能也被分在这一类,我们不管它。 +> +> 在任何声明中,只能同时存在最多一个**存储类别限定符**。这与 `const`、`volatile`、`restrict` 等**类型限定符**不同,它们可以同时存在。 + +- 选择题 5 函数指针 + +> 见 2019 年选择题 6 + +- 选择题 8 数据结构的使用 + +> 这种题通用的方法就是每个选项尝试一遍。只要对栈、队列操作熟悉的话,一个个试很快就出来了。 + +- 简答题 1.1 后缀表达式 + +> 如果你的答案和参考答案不一样,也是有对的可能的,就像中缀表达式那样,二元运算符的操作数是可以换序的( +> +> 注意这道题中的 `^` 运算符的右结合问题,加上括号后变成 `d^(e^f)` 而不是 `(d^e)^f`,转换成后缀表达式只能是 `def^^`,而 `fed^^` 之类的全是错的。 + +- 简答题 5 排序算法最优情况 + +> 见常见问题-算法复杂度 +> +> 引用一下 ztgg 的解释: +> +> - 平均情况下,插入元素导致的移动依旧是 $O(n)$ 的,并没有优化,所以总复杂度还是 $O(n^2)$。(批注:交换的复杂度为 $O(n^2)$,比较的复杂度为 $O(n\log n)$,前者较大,占据主导地位)。 +> - 最佳情况应该是插入元素时,不需要移动原来的元素,也就是数组已经排好序了。这个情况下,每次插入只有二分查找的代价,即为 $O(n\log n)$。 + +- 简答题 6 队列操作 + +> 如果第一个 `while` 循环读不懂一定要找同学/老师问清楚捏,队列的使用是很基本的。请思考这个 `while` 循环为什么需要逐个 `malloc()` 呢? +> +> 中间那个 `while` 循环有点迷惑。它其实只是在将 `eQueue` 中的每个元素放到 `dQueue` 时同时把后面一个元素换到队尾去。理解到这里就能做对啦。 + +- 程序填空 3 + +> 这几个空都需要花一会儿时间来推断的: +> +> - `InitGraphics()` 一定要记得 +> - 记住几种回调函数的使用方法,`void registerTimerEvent(TimerEventCallback callback)` 与 `void startTimer(int id,int timeinterval)` 配对使用,它们各自的参数意义。 +> - **计时器回调函数怎么写?**参数 `timerID` 是用来做什么的? +> - 第 14 空可能不容易想到,反正记住在每次画点什么东西之前,都要检查**画笔位置**是否正确,这在绘制分形图形时也很重要。 + +- 算法设计 1 链表循环检测 + +> 想象一个解谜场景:如果你和你的朋友走在一条路上,你们要怎么做才能判断有没有遇到鬼打墙(即在原路绕圈) ? +> +> 答案很简单:一个人走快点,一个人走慢点。如果这条路有尽头,先走的人一定会先到达尽头;如果没有到达尽头,那么他一定会重新看见你。 +> +> `LoopDetect()` 函数的思路也类似:使用两个指针 `fast` 和 `late`,`fast` 每次步进两个节点,`late` 每次步进一个节点。最后终止时只有两种条件:`fast` 无法继续步进或 `fast` 在前进的路上看见 `late`。前者表明没有循环,后者表明有循环。 + +- 算法设计 2 有序数组原地去重 + +> 看到这道题你有没有想起字符串去空格的一个例程? +> +> ```c +> char *a; +> for(int i = 0, j = 0; i < length; i++){ +> if(a[i] != ' ') +> a[j++] = a[i]; +> } +> ``` +> +> 有序数组去重与这个算法也有些类似,只是比较条件换成 `a[i]!=a[j]` 了而已。 + +## 2019-2020 年卷 + +- 选择题 1 递归函数 + +> - D 项 调用栈位于**堆栈段(Stack Segment)**,在运行时创建,也有自己的大小,不能越界访问。越界造成**段错误(Segmentation Fault)**。每次递归调用添加栈帧,造成的越界称为**栈溢出(Stack Overflow)**。堆栈段中保存着**函数调用关系和局部变量**。局部变量过大也可能造成栈溢出。 + +- 选择题 2 时间复杂度分析 +- 选择题 6 函数指针 + +> 一句话总结:作为函数作为形参,会自动退化成函数指针,就像数组名作为形参自动退化成指针那样。这句话在下面的英文部分提到了。 +> +> > The type of a function is determined using the following rules. [...] After determining the type of each parameter, **any parameter** of type “array of T” or **of function type T is adjusted to be “pointer to T”**. [...] + +> - 函数指针:指向函数的指针中储存着函数代码的起始处地址,要指明函数的类型,要指明函数的返回类型和形参类型。把函数名替换成 `(*pf)` 的形式是最简单的方法,如 `void ToUpper(char *)` 改为函数指针 `void (*pf)(char *)`。 +> - 声明函数指针后,可以将函数的地址赋给它,**这种语境下函数名可以表示函数的地址**。因此我们可以写:`pf = ToUpper`,注意不是 `pf = ToUpper()`。 +> +> - 使用函数指针调用函数有两种方法:`(*pf)(mis)` 和 `pf(mis)`,它们看起来矛盾。事实上,K&R C 不允许第二种形式,我也推荐大家始终将函数调用理解为第一种形式。 +> - 第一种形式,先解引用函数指针再调用该函数,这个思路很直接。 +> - 第二种形式,来源是上面的赋值语句,在上面的赋值语境下,指针和函数名可以互换使用。 +> - 取函数的地址也有两种方法:`f` 和 `&f`。 + +> - C 项 或许通过上面的讲解,你能理解 `(*cmd)` 与 `cmd` 的等价之处。下面是 StackOverflow 中的讨论:[c++ - What does `void f(void())` mean? - Stack Overflow](https://stackoverflow.com/questions/39440970/what-does-void-fvoid-mean)。 +> +> > As mentioned in [_dcl.fct_](http://eel.is/c++draft/dcl.fct#5) of the working draft (emphasis mine): +> > +> > > The type of a function is determined using the following rules. [...] After determining the type of each parameter, **any parameter** of type “array of T” or **of function type T is adjusted to be “pointer to T”**. [...] +> > +> > Because of that, the following function: +> > +> > ```cpp +> > void f(void()); +> > ``` +> > +> > Has the same type of: +> > +> > ```cpp +> > void f(void(*)()); +> > ``` +> > +> > Thus the definitions below are identical: +> > +> > ```cpp +> > void f(void(g)()); +> > void f(void(*g)()); +> > ``` +> +> > Correct me if I'm wrong, AFAIK function names are pointers just like array names so **in the first example you are passing function object and compiler does implicit conversion**, in the second example you are directly passing function pointer which is explicit conversion. + +- 选择题 7 函数指针 + +> 同样依据上面的讲解能够选出正确答案 + +- 选择题 8 函数参数 + +> 我觉得 D 项的表述本来就很混乱。函数参数如果为 `void` 就表示函数不接收参数,这就是 `void` 关键字的作用,而不是选项中说的什么“函数有一个 `void` 类型的参数”。 + +- 选择题 9 图形库 + +> 请 WK 班同学一定要去看辅学群里其他老师的图形库课件 + +- 选择题 10 还是函数指针 + +> - 第一行:`F` 定义为 `int (int)` 类型的函数。 +> - 第二行:声明两个类型为 `F` 的函数 `g` 和 `h`。其实就是 `int g(int a)` 和 `int h(int a)`。 +> - 第三行:声明一个数组 `p`,其中每个元素都是 `int (*)(int)` 类型(与 `F` 等价)。并用 `g` 和 `h` 来初始化这个数组。 +> - 让我们从内往外读这个声明:`p` 是标识符的名称,向右 `[]` 表明这是一个数组,向左 `*` 表示其元素是指针,再向右 `(int)` 表示其所指类型是函数,这种函数接受 `int` 类型参数,再向左 `int` 说明这种函数返回 `int` 类型。 +> - 用 `g` 和 `h` 初始化这个数组时,`g` 和 `h` 被转换为函数指针(回顾上面的讨论)。 +> - 第四行:声明一个函数 `q` 这个函数返回 `int`,接受一种数组,这种数组的每个元素都是 `F*` 类型,即 `int (*)(int)`。故函数 `q` 的参数类型为 `int (**)(int)`。 + +> - A 项:数组名就是首元素指针,为 `int (**)(int)`,匹配。 +> - C 项:显然类型匹配。 +> - D 项:对函数取地址,得到 `int (*)(int)`,与 `int (**)(int)` 类型不匹配。 + +- 简答题 1 + +> 如果程序代码有错,就勇敢地写“该段程序可能运行失败”。 + +- 简答题 3 + +> 注意,合并数组的时候部分去重了。如果离开了第一个 `while` 循环,则不会去重。 + +- 简答题 4 + +> 让我们看 `main()` 函数的第一行的表达式: +> +> - 外层:`((H)内层表达式 )(100);`,它会将内层表达式强制类型转换为 `H` 类型的函数,然后对该函数执行函数调用。 +> - 内层:`h(0)`。调用后返回了 `h`,即函数自己,也就是函数自己的指针。作为 `void *` 类型返回,表明它是一个指针,但不知道所指向的类型。 +> - 内层调用后,外层就相当于 `h(100)`了,因为 `h` 本来就是 `H` 类型的函数。 + +- 程序填空 3 `geblib.h` + +> WK 班同学应当补充阅读 `libgraphics` 库中的一些内容,我们直接读源码吧: +> +> - `New()` 宏函数: +> +> - Usage: `p = New(pointer-type);` +> +> - The New pseudofunction allocates enough space to hold an object of the type to which pointer-type points and returns a pointer to the newly allocated pointer. Note that "New" is different from the "new" operator used in C++; the former takes a **pointer type** and the latter takes the target type. +> +> - 源码: +> +> ```C +> void *GetBlock(size_t nbytes); +> #define New(type) ((type) GetBlock(sizeof *((type) NULL))) +> ``` +> +> - 举个例子:调用 `New(char*)` +> +> - 宏展开为 `((char*) GetBlock(sizeof *((char*) NULL)))` +> - `GetBlock()` 函数接收需要分配的字节数,返回分配成功的指针。对于上面的宏展开后的调用参数,`NULL` 被转换为 `char*` 随后解引用仍为 `char` 类型的大小。 +> - 这个调用就返回了一个 `char*` 的指针。 +> +> - `FreeBlock()` 函数: +> +> - 原型:`void FreeBlock(void *ptr)` +> - 与 `free()` 功能类似,不加解释。 + +> 第 15 空有意思,`FreeBlock(PopStack(stack))` 的嵌套写法。 + +- 算法设计 1 分形 + +> 这类算法设计题目,怎么简洁怎么来,以 OI 码风去写是最合适的。不要试图弄完善的交互,那是浪费时间。 + +> 以下是我自己做的时候写的,作为一个不好的参考( +> +> 理解错题意了,原来 `order` 不是方向而是分形次序。 +> +> - 基准情形:长度缩小到某值。 +> - 递归情形:画一根,随后两次递归调用,绘制下一支的 `length` 和 `order`。每次递归调用后,都应当**返回原位**。 +> +> ```c +> #include +> #include +> #include +> #include "graphics.h" +> #define MIN_LEN .1 +> +> double toRadius(double deg) +> { +> return deg * 3.1415926 / 180; +> } +> +> void DrawBranch(double len, double deg) +> { +> DrawLine(len * cos(toRadius(deg)), len * sin(toRadius(deg))); +> if (len * 0.75 < MIN_LEN) +> return; +> DrawBranch(len * 0.75, deg - 15); +> MovePen(GetCurrentX() - len * 0.75 * cos(toRadius(deg - 15)), +> GetCurrentY() - len * 0.75 * sin(toRadius(deg - 15))); +> //也可以用 DrawLine 实现 +> DrawBranch(len * 0.75, deg + 15); +> MovePen(GetCurrentX() - len * 0.75 * cos(toRadius(deg + 15)), +> GetCurrentY() - len * 0.75 * sin(toRadius(deg + 15))); +> } +> +> int main(void) +> { +> double length; +> char order; +> printf("Please enter initial length: "); +> scanf("%lf", &length); +> getchar(); +> printf("Please enter order (u)pper, (d)own, (r)ight, (l)eft: "); +> scanf("%c", &order); +> double deg; +> switch(order) +> { +> case 'u': deg = 90; break; +> case 'd': deg = -90; break; +> case 'r': deg = 0; break; +> case 'l': deg = 180; break; +> default: printf("error.\n"); return 1; +> } +> InitGraphics(); +> MovePen(GetWindowWidth()/2, GetWindowHeight()/2); +> DrawBranch(length, deg); +> return 0; +> } +> ``` + +- 算法设计 2 列表变序 + +> 就用标答的方法,将偶数节点移动到另一个链表,再合并两个链表。 + +## 2018-2019 年卷 + +- 选择题 4 递归计算 + +> 像这种递归计算,就老老实实把函数递归展开吧。展开过程中记得依次记下已经计算完的 `f(0)`、`f(1)` 等值,方便后续计算。 + +- 选择题 6 算法复杂度分析 + +> 这道题我的想法挺奇葩的,我是想只要全部排序一遍 $O(N\log N)$,然后用 $O(1)$ 的时间检查一下头、中间、尾部的元素不就好了吗(doge + +- 选择题 9 递增运算符 + +> 前缀递增运算符先递增再使用。 + +- 简答题 1.2 + +> 本题英文有点烫嘴,我翻译一下: +> +> > 为了用类似 `T p` 的方式声明一个指针 `p`,请写出复合类型 `T` 的定义。`p` 是一个函数的指针,该函数接收 `(char *, double)` 参数,并返回一个 `int *`。 +> +> 读懂题目剩下的就不用说啦。 + +- 简答题 2 数据与字节 + +> 注意:**xx-bit system(n 位系统)**指的是这个系统的指针长度有 $n$ 比特,$8$ 比特为一个字节。故本题的所有指针都是 $4$ 字节。 +> +> 以下是各类型的大小: +> +> - `StudentInfo`:两个 `char` 数组 + 一个指针 = $12 + 20 + 4 = 36$ +> - `PtrStudentInfo`:$4$ +> - `pStudent->name`:一个 `char` 数组 $=20$ +> - `pStudent->photo`:一个 `void*` 指针 $=4$ +> +> 从上面再次看到,数组名并不能简单被看作指针,它还包含数组的类型信息。 + +- 简答题 3 链表操作 + +> 这道题答案感觉有点问题啊。反正只要知道返回的时候 `p` 指向 $2$ 这个节点就算对了,题目说 `node` 那应该不用吧后面的节点都写出来吧。 + +- 简答题 4 链表操作 + +> 这个函数合并了两个链表,按升序合并。 +> +> `HEAD` 是一个临时使用的哑节点。 +> +> 调用后,原来的两个指针指的位置不变,`l1` 仍然指向 $1$ 这个节点。但节点之间的连接变了,这时 `l1` 后面链上了从 `l2` 合并进来的其他节点。所以可以看作“链表” `l1` 发生了改变。 + +- 简答题 5 双向栈 + +> 这是一个双向的栈。每次入/出栈时,需要用 `Tag` 参数指定是哪一头。从数组的角度来看,`Top1` 是左边(头部)那头,`Top2` 是右边(尾部)那头。 +> +> 读这种题时,我推荐先读 `main()` 中的内容,即观察题目给的数据结构是**怎么被使用的**。然后不明白的地方再去看具体实现的代码,其他部分就一点都不用看。比如这道题: +> +> - 先看 `main()` 中的 `Push()` ,这怎么比平常的 `Push()` 多一个参数呢? +> - 再看类型定义,怎么有两个 `Top`?回想 `main()` 中一个令为 `-1` 一个令为 `MaxSize` 便知道这是一个双向栈了。 +> - `Push()` 和 `Pop()` 的代码就不用看了,想象得到是怎么操作的。最多再多看一眼 `if(Tag == 1)` 知道哪个值对应哪一头,就可以完成这道题了。 + +- 简答题 6 不知道是什么 + +> 这个东西,保险起见推荐手工模拟,而且手工模拟几次后你就知道这个函数在干嘛了。 +> +> 其实这个函数的作用是:调用后保证数组 `a[k]` 左侧的元素都比 `a[k]` 小,右侧都比 `a[k]` 大。最后返回 `a[k]` 上的元素。**但不会保证其他元素之间的相对顺序**。 +> +> 具体的操作就是:每轮循环把第 `a[k]` 位置上的元素提出来作为 `x`,然后用 `i` 和 `j` 分别从左右遍历并交换两侧不符合要求的数。交换完成后,数组中比 `x` 小的数都在相对左边的位置,比 `x` 大的数都在相对右边的位置。 +> +> 如果你对快排比较熟悉,那么这就是“如果目标位置不在的一边直接舍弃”的快排。相当于本来快排区间形成一颗树,但是现在就只走一条路,只排 `a[k]` 所在的那些区间套。 + +- 程序填空 1 分型 + +> 画个坐标轴,一切都清晰起来了。然后 `Main()` 里最开始对大三角形三个顶点的求值可能会引起困惑,其实可以不用管它,它不影响你分析递归调用过程。它只是在用三角函数计算位置使这个大三角形的中心在屏幕中央罢了。给个很草的草稿示意图: +> +> 画这个分形的步骤就是:先画大三角,然后各边取中点。大三角的每个顶点和相邻两边中点构成两个小三角。 +> +> ![](https://cdn.bowling233.top/images/2023/06/202306241632120.png) + +- 程序填空 2 循环队列实现 + +> 本质上还是用数组实现循环队列,只不过本题进行了比较完善的封装。 +> +> 如果还不知道循环队列是什么东西,去网上搜一搜。循环队列的要点就是:所有加法操作全部要套上一次取模操作。本题注意一下间接成员运算符 `->` 的使用。 +> +> 此外循环队列的 `rear` 也有不同实现方法,在本题中,它标志队列尾部的后一个元素,也就是下一个元素应该插入的地方;在另一些实现中,它直接标志队列尾部的元素。比如如果本题在创建数组的时候 `Q->rear = maxsize - 1`,这些空应该作怎样的改变呢? + +- 程序填空 3 图形库 + +> 参见常见问题-图形库-计时器 + +- 算法设计 1 寻找第一个公共节点 + +> 想象这样一个情境:还是想象你和你的朋友站在题目所示的两个链表的起始处。这两个链表有可能相交,你们想要尽快找到会合点,怎么办呢?而且一个有用的信息是,你们都知道自己离终点还有多远。 +> +> 如果相交,你们肯定有公共子链表。剩下不同的部分就是你们各自子链表的长度。因此,你们应当先相对终点对齐彼此的位置,使自己剩余的子链表的的长度相等。接下来以相同的速度前进,如果你们在某处会合了,那么这一定是公共子链表的起始处。 +> +> 参答中,`lPtr` 指向较长的链表,`sPtr` 指向较短的链表,`numLeftNodes` 就是两链表节点数的差值,`lPtr=lPtr->next` 的 `for` 循环就是在对齐两人的位置。 +> +> 循环终止的条件是:其中某人走到了尽头 `NULL`,或两人相遇 `lPtr==sPtr`。返回最终位置即可。 + +- 算法设计 2 二分插入排序 + +> 这题简单,不作解析。参答中漏了检查 `minPos == rh` 的情况,想想这样会造成什么后果? + +## 2017-2018 年卷 + +- 选择题 1 数据类型与指针操作 + +> - C 项:`strcpy()` 只能用于字符串。进一步说,它依据字符串末尾的 `\0` 来决定是否停止复制,因此不宜用于此情境。 +> - D 项:每次 `*pc2++ = *pc1++`,都会将 `pc1` 的一个字节拷贝到 `pc2` 指向的位置,并让这两个指针向后移动一个 `char` 的位置。由于 `p1` 和 `p2` 两个结构变量大小都是 $8+8=16$ 字节,因此该选项正确地执行了拷贝。 + +- 选择题 7 冒泡排序 + +> 这道题时,我选的是 D 项。因为我记忆中的冒泡排序是这样的: +> +> ```c +> void bubble_sort(int *arr, int len) { +> int i, j, tmp; +> for (i = 0; i < len - 1; i++) { +> for (j = len - 1; j > i; j--) {//Bubble +> if (arr[j] < arr[j - 1]) { +> tmp = arr[j]; +> arr[j] = arr[j - 1]; +> arr[j - 1] = tmp; +> } +> } +> } +> } +> ``` +> +> 上面这样的写法对于任何情况的复杂度都是 $O(n^2)$。但是冒泡排序普遍会作这样的优化: +> +> - 如果在上一轮的 Bubble 中,没有发生任何交换,则说明这个序列也是有序的,不再需要后续操作了。 +> +> 因此可以在外循环开头添加 `bool flag = 0;`,结尾添加 `if(!flag)break;`,交换操作中添加 `flag=true` 即可将最优情况优化至 $O(n)$。 + +- 选择题 9 存储类别限定符 + +> - D 项:`static` 此处修饰的是指针 `p`。事实上,也不存在指针指向 `static int` 这种说法。`static` 作为存储类别限定符,在变量的声明中表示该变量具有静态存储期。一个指针,只需要管它指向的是什么类型,不需要知道这个对象的存储期。 + +- 简答题 1.(2) 写函数声明 + +> 先想想自己会怎么调用这个 `fun` 函数才能得到 `void` 类型: +> +> - 第一步,调用函数获取返回值:`fun(int)`。 +> - 第二步,解引用返回值,获得函数,调用该函数:`(*fun(int))(int)`。注意,函数指针应当使用 `(*fp)()` 形式调用。 +> +> 得到了答案:`void (*fun(int))(int)`。 + +- 简答题 5 最大子列和 + +> 其实这是一个经典的算法:**所有连续子列元素的和中最大者**。在网上可以搜到很多该算法的原理介绍,请去看一看,看完就能立刻明白这段代码了。 +> +> `thisp` 被放置在最大子列的开头,`maxp` 被放置在最大子列的末尾。 + +- 简答题 6 侏儒排序 + +> 基于比较的排序算法,大概都能优化到**最优情况**复杂度为 $O(n)$ 吧? + +- 程序填空 2 多项式计算 + +> 这题蛮坑的,我看了好一会儿才明白第 (7)(8) 空在干嘛。它其实就是先把前面的高次项提公因式,然后在逐步向后求和的过程给它整体乘上 $x$。比如 $3x^4+2x^2+1$ 可以这样计算: +> +> - $(((3x)x^2)+2x)x + 1$ +> +> 这样做一定程度上减少了计算乘积的次数。 +> +> 同样注意 `->` 运算符的使用。 + +## 2016-2017 年卷 + +- 选择题 1 倒序栈 + +> 注意题目中说明了**栈顶指针位于 `N`**,这个栈是从数组的尾部开始累积的。 + +- 简答题 1.(2) 写函数指针 + +> 简单函数指针,直接这样记:`typedef 返回类型 (*新名字)(参数列表)` + +- 简答题 3 奇偶排序 + +> 和前一年的侏儒排序有点像,有序情况也是一遍过,最优时间复杂度也是 $O(n)$。 + +- 简答题 4 链表操作 + +> 过程中,该链表顺序被重新排列。比 `x` 小的节点依次移动到 `root` 为首的带哨兵链表中,大的依次移动到 `pivot` 为首的哨兵链表中,最后将两个链表合并,返回合并后的链表。 + +- 简答题 5 因数分解 + +> 本题其实就是在从小到达求因数、约去这个因数、求下一个更大的因数...。模拟一遍即可。 + +- 程序填空 1 计数排序 + +> 排序原理题目已经讲清楚了,这里讲一下循环中几个变量的作用: +> +> - `count` 数组:先用于统计出现次数,后用来标记开始位置。 +> - `output_array[count[input_array[i]]] = input_array[i]` 我们来拆解一下: +> - `intput_array[i]` 就是第 `i` 个元素 +> - 把它放到 `count[]` 就能查到这个数应该放置的起始位置。 +> - 所以第 4 空当然要递增啦。 + +- 程序填空 3 差集 + +> 值得一提的是,这两个链表都是集合,这意味着其中的元素都是唯一的,所以不需要考虑重复元素的情况,不需要完整遍历 $A$。 + +## 2015-2016 年卷 + +- 选择题 5 宏的展开 + +> 宏展开只是简单的文本替换。 +> +> - 先展开 `DD` 得到 `SQ(2*3) - SQ(2+3)`,得到 `2 * 3 * 2 * 3 - 2 + 3 * 2 + 3` +> - 先展开 `SQ` 得到 `DD(x, y) = x * x - y * y`,得到 `2 * 3 * 2 * 3 - 2 + 3 * 2 + 3` +> +> 从上面的展开中我们看到,宏函数的展开顺序并不重要。最终结果应当一致。 diff --git a/docs/programming/index.md b/docs/programming/index.md index 029d9c5..a1787ef 100644 --- a/docs/programming/index.md +++ b/docs/programming/index.md @@ -2,6 +2,18 @@ 欢迎来到竺院辅学程设版块🤗。你可以在左侧导航栏中详细浏览本模块的内容。 +## FAQ + +我们为各个模块同学们经常遇到的问题整理了一些 FAQ: + +| 知识模块 | FAQ | +| :---: | :---: | +| 暂无 | 暂无 | + +## 历年卷 + +请跳转到 [历年卷](exam/index.md) 页面。历年卷均已上传完成,经典题目解析还在整理中。 + ## 其他资源 ### 常用网站 diff --git a/docs/programming_lecture/lecture1/lecture1.md b/docs/programming_lecture/lecture1/lecture1.md index e801085..01e6756 100644 --- a/docs/programming_lecture/lecture1/lecture1.md +++ b/docs/programming_lecture/lecture1/lecture1.md @@ -7,13 +7,20 @@ -!!! danger "本页面正在施工中" +???+ danger "本页面正在施工中" -!!! abstract "内容提要" +???+ abstract "内容提要" + - C 语言程序基本结构 - 编译过程:从源代码到可执行文件 - 编译器和开发套件:`gcc`、`clang` 和 `llvm` 究竟是什么? - 调试器:如何使用 `gdb` 或 `lldb` 设置断点、找到段错误的根源? + +???+ tip "如何食用本讲义" + + 作为在线讲义,我会尽量写得详细一些,为同学们提供复习和进一步扩展的指引。因为时间有限,在课上我无法覆盖讲义中所有的内容。同学们可以根据自己的习惯选择在课前课后浏览本讲义~ + + 本次课的核心内容从「程序的编译过程」开始。前面的内容作为预备知识,在课上将会快速带过。 ## 课程导言 @@ -71,7 +78,7 @@ CPU 的工作非常简单:从内存中读取并执行一条指令,再从内 CPU 还有自己的小工作区——由若干寄存器(Register)组成的寄存器组。每个寄存器能存储一个数字。 -### 高级计算机语言和编译器 +### 从机器语言、汇编语言到高级语言 现代计算机的结构与 70 年前并没有本质上的不同,但是程序设计语言取得了很大的发展,产生了汇编语言和高级语言。我们仍然不能直接对 CPU 说:为我计算 $1 + 1$,但我们可以用高级语言简洁的表达它,让**编译器(compiler)和汇编器(assembler)**将其翻译成 `0101` 的机器语言。下图展示了程序设计语言的发展历史,编译过程其实就是这一历史的反向。 @@ -141,40 +148,388 @@ CPU 还有自己的小工作区——由若干寄存器(Register)组成的 -!!! question "为什么需要高级语言?" +???+ question "为什么需要高级语言?" 1. 机器语言和汇编语言都是非常底层的语言,程序员需要关注计算机的细节,这使得程序的**可读性很差**。使用高级语言,程序员能**将注意力转移到要解决的问题上来**。 2. 机器语言和汇编语言都是与具体 CPU 相关的,程序员需要为不同的 CPU 编写不同的程序,**可移植性差**。使用高级语言,程序员只需要写一次程序,再使用编译器就能将其翻译成能在特定 CPU 的机器语言。 -!!! info "前瞻:第四代和第五代编程语言" +???+ info "前瞻:第四代和第五代编程语言" 编程语言仍在发展演化。目前已经有了第四代和第五代编程语言的概念。第三代的编程语言虽然是用语句编程而不直接用指令编程,但语句也分为输入、输出、基本运算、测试分支和循环等几种,和指令有直接的对应关系。而第四代以后的编程语言更多是描述要做什么(Declarative)而不描述具体一步一步怎么做(Imperative),具体一步一步怎么做完全由编译器或解释器决定,例如SQL语言(SQL,Structured Query Language,结构化查询语言)就是这样的例子。 -??? info "对机器语言有兴趣?" +???+ info "对机器语言有兴趣?" [这里](https://eng.libretexts.org/Bookshelves/Computer_Science/Programming_Languages/Introduction_To_MIPS_Assembly_Language_Programming_(Kann)/04%3A_Translating_Assembly_Language_into_Machine_Code)提供了一些将汇编语言转换为 MIPS 指令集机器语言的基础例子,有兴趣可以了解一下。 -### 扩展:编译和解释 +## 程序的基本语法结构 + +经过了 2-3 周课程的学习,相信同学们多少都写过了一些代码,对语言有了一些基本认识。本节将系统地梳理程序的语法和结构知识,帮助大家理清思路,为后续理解程序编译过程和调试技术作铺垫。 + +### 写程序的目标是什么? + +从根本上说,计算机是由数字电路组成的运算机器,只能对数字做运算,程序之所以能做符号运算,是因为符号在计算机内部也是用数字表示的。此外,程序还可以处理声音和图像,声音和图像在计算机内部必然也是用数字表示的,这些数字经过专门的硬件设备转换成人可以听到、看到的声音和图像。 + +程序由一系列指令(Instruction)组成,指令是指示计算机做某种运算的命令,通常包括以下几类: + +- 输入(Input):从键盘、文件或者其它设备获取数据。 +- 输出(Output):把数据显示到屏幕,或者存入一个文件,或者发送到其它设备。 +- 基本运算:执行最基本的数学运算(加减乘除)和数据存取。 +- 测试和分支:测试某个条件,然后根据不同的测试结果执行不同的后续指令。 +- 循环:重复执行一系列操作。 + +对于程序来说,有上面这几类指令就足够了。你曾用过的任何一个程序,不管它有多么复杂,都是由这几类指令组成的。**程序是那么的复杂,而编写程序可以用的指令却只有这么简单的几种,这中间巨大的落差就要由程序员去填了,所以编写程序理应是一件相当复杂的工作。编写程序可以说就是这样一个过程:把复杂的任务分解成子任务,把子任务再分解成更简单的任务,层层分解,直到最后简单得可以用以上指令来完成。** + +### 词法和语法规则 + +词法(Lexical)和语法(Syntax)是编程语言的两个基本概念。词法规则定义了编程语言中的基本符号,语法规则定义了这些符号如何组成合法的表达式、语句和程序。 + + +???+ note "C 的词法规则:贪心法" + + 术语 **token** (符号)是语言的基本表意单元。字符组成符号。例子: `->`、`file` 都是符号。同一组字符序列在**不同上下文**中可能属于不同符号。 + + 如果该字符可能组成符号,那么再读入下一个字符,直到读入的字符串已经不可能再组成一个有意义的符号。 + +???+ example "词法练习:请思考下面这些表达式的行为" + + 点击「+」号展开答案。 + + ```c linenums="1" + a---b /*(1)!*/ + a -- - b /*(2)!*/ + a - -- b /*(3)!*/ + + //下面的 p 指向除数。 + y = x/*p + /*(4)!*/ + y = x / *p /*(5)!*/ + + n-->0 /*(6)!*/ + n-- >0 /*(7)!*/ + n- -> 0 /*(8)!*/ + + a+++++b /*(9)!*/ + ``` + + 1. 等价于 `(a--) - b`。按照贪心法,编译器读入两个连续的 `-` 号后,已经不可能再组成一个有意义的符号,因此这个符号被确定为后缀递减运算符。接着读取下一个符号。 + 2. 等价于 `(a--) - b` + 3. 等价于 `a - (--b)` + 4. 等价于 `y = x`。`/*` 被贪心法解释为注释的开头。 + 5. 等价于 `y = x / (*p)` + 6. 等价于 `(n--) > 0` + 7. 等价于 `(n--) > 0` + 8. 等价于 `(n-) -> 0`。这是一个无效的语句,`n-` 本身不是一个合法的表达式,也无法用作 `->` 的操作数。 + 9. 等价于 `((a++)++) + b`。请思考一下,这个语句有效吗? + + + + +???+ note "语法:标识符、表达式和语句" + + 为了便于初学者理解,我们采用一套简化的语法规则: + + - 标识符(Identifier):就是「名字」,用于指代各种实体,比如:对象、类型、函数、标签、宏等等。 + + ???+ warning "对象的不同含义" + + 在这里,对象(Object)的含义与 C++ 中不同。这里的对象指数据存储的一个区域,其内容可以表示*值*。比如下面的语句创建了一个对象: + + ``` + int a; + ``` + + 创建一个对象的意思就是分配了一块存储空间。标识符 `a` 用于指代这个对象。每个对象都有大小、生存期、存储器、值等属性,我们将在下节课详细展开。 + + - 表达式(Expression):由运算符(`+-*/%` 等等)和操作数(常量、变量、函数返回值......)组成的式子,可以计算出一个值。 + - 语句(Statement):C 标准确定了五种语句类型。除了复合语句,其它语句都以分号 `;` 结尾。下面是语句的定义,你可以发现**语句是递归定义的**。 + + - 复合语句:由花括号包围的一组语句。 + + ```c + { 语句或声明 } + ``` + + - 表达式语句:表达式加上分号就是表达式语句。C 程序中大部分语句都是表达式语句。空语句也算作表达式语句。 + + ```c + 表达式; + ``` + + 例子: + + ```c + puts("hello"); // 表达式语句 + char *s; + while (*s++ != '\0') + ; // 空语句 + ``` + - 选择语句: + ```c + if(表达式) 语句 + if(表达式) 语句 else 语句 + switch(表达式) 语句 + ``` -## 计算机程序的基本结构 + - 循环语句: -本节再带大家对自己写的程序的基本结构做一些认识,帮助大家理清思路。 + ```c + while(表达式) 语句 + do 语句 while(表达式); + for(初始化子句;表达式;表达式) 语句 + ``` -## Lap:关于题目 + - 跳转语句: + + ```c + goto 标识符; + continue; + break; + return 表达式; + ``` + + 完整的C语法规则请参考: + + - [CPPReference:标识符](https://zh.cppreference.com/w/c/language/identifier) + - [CPPReference:表达式](https://zh.cppreference.com/w/c/language/expressions) + - [CPPReference: 语句](https://zh.cppreference.com/w/c/language/statements) + + + +语法将在后续课程中作深入讲解(第 2 讲涉及类型相关的语法,第 4 讲涉及函数指针)。 + +### 函数:C 程序的基本模块 + +「指令」是第一、二代编程语言的基本结构。C 语言是面向过程的高级语言,它的基本模块是**函数(Function)**。 + + +???+ note "关键概念:函数" + + **从外面看**,函数就像一个黑盒子,只能看到函数的三大要素: + + - 函数名:函数的名字,用来调用函数。 + - 函数名与变量名的命名规则相同。 + - 参数:函数的输入,可以有**多个**。 + - 返回值:函数的输出,只能有**一个**。 + + 下面这行语句被称为函数签名(Function Signature)或函数原型(Function Prototype)。它给出了函数对外的一切信息: + + ```c + int MyFunc(int a, int b); + ``` + + **从里面看**,函数是一组指令的集合,它们按照一定的顺序执行,完成某个特定的功能。 + + +当我们调用一个函数时,我们应当按照函数签名中的要求传入参数,并可以获得它的返回值。在函数签名的语境下,`void` 表示空,即不存在。下面的这个函数没有参数,也没有返回值。 + +```c +void MyFunc(void); +``` + +因为我们比较关心函数返回值的类型,有时会把函数的返回值类型称为这个函数的“类型”,比如会说 `MyFunc` 这个函数是一个 `void` 函数。 + + +???+ note "关于 `void`" + + Q:`void` 到底有哪些含义? + + A: 下面是 `void` 的一般用法 + + - `void` 作为函数参数,表示函数不接受参数。 + - `void` 作为函数返回值,表示函数不返回值。 + - `void*` 是一种指针类型,表示不知道指向的类型是什么。 + + 从这些用法来看,似乎 `void` 是一种类型,但这会引起下面问题中的矛盾。另一种看待方式是:上面的用法都是特殊的语法,不过是恰好用了同一个关键字 `void` 罢了。 + + Q:所以 `void` 是一种类型吗? + + A:C 标准从概念上将 `void` 作为一种类型。但是 `void` 类型的变量是不存在的,因为它没有大小,编译器也不允许你写下 `void a;`。这会引起一些困惑。 + + +函数有两个功能: + +- 返回值:函数调用本身就是一个表达式,它的值就是函数的返回值。 +- 副作用:除了返回值以外的功能统称为副作用。 + +一些例子: + +| 函数签名 | 返回值的含义 | 副作用 | +| -------------------------------------- | ------------ | -------------------- | +| `int printf(const char *format, ...);` | 打印的字符数 | 打印字符串到标准输出 | +| `int scanf(const char *format, ...);` | 读取的字符数 | 从标准输入读取字符串 | +| `int rand(void);` | 生成的随机数 | 无 | +| `void exit(int status);` | 无 | 退出程序 | + +每个 C 语言程序都必须包含一个 `main` 函数,它是程序的入口。`main` 的函数签名一般是这样的: + +```c +int main(void); +int main(int argc, char *argv[]); //以后学命令行参数就会用到这种形式 +``` + +`main` 的调用者是操作系统。操作系统看到 `main` 的签名中说返回值为 `int`,因此系统会等待 `main` 返回一个整数。这个整数一般被用于告知操作系统程序的执行状态,`0` 表示正常结束,其他值可以传递其他信息。这就是为什么 `main` 的末尾应当写 `return 0`。如果 `main` 函数中没有 `return` 语句,有些编译器会为你补全(其他函数不会)。**但请记得写上,这是你的责任。** + +没有返回值的函数也可以使用 `return` 语句,此时它没有返回值的作用,而是结束当前函数的执行并返回。例子: + +```c +void print_logarithm(double x) +{ + if (x <= 0.0) { + printf("Positive numbers only, please.\n"); + return; + } + printf("The log of x is %f", log(x)); +} +``` + + +??? info "其他建议" + + 每个函数都应该设计得尽可能简单,简单的函数才容易维护。应遵循以下原则: + + 1. 实现一个函数只是为了做好一件事情,不要把函数设计成用途广泛、面面俱到的,这样的函数肯定会超长,而且往往不可重用,维护困难。 + + 2. 函数内部的缩进层次不宜过多,一般以少于 4 层为宜。如果缩进层次太多就说明设计得太复杂了,应考虑分割成更小的函数(Helper Function)来调用。 + + 3. 函数不要写得太长,建议在24行的标准终端上不超过两屏,太长会造成阅读困难,如果一个函数超过两屏就应该考虑分割函数了。[CodingStyle]中特别说明,如果一个函数在概念上是简单的,只是长度很长,这倒没关系。例如函数由一个大的 `switch` 组成,其中有非常多的 `case`,这是可以的,因为各 `case` 分支互不影响,整个函数的复杂度只等于其中一个 `case` 的复杂度,这种情况很常见,例如 TCP 协议的状态机实现。 + + 4. 执行函数就是执行一个动作,函数名通常应包含动词,例如 `get_current`、`radix_tree_insert`。 + + 5. 比较重要的函数定义上侧必须加注释,说明此函数的功能、参数、返回值、错误码等。 + + 6. 另一种度量函数复杂度的办法是看有多少个局部变量,5 到 10 个局部变量已经很多了,再多就很难维护了,应该考虑分割成多个函数。 + +??? note "扩展:编译和解释" + + 简单了解一下就好。 + + 将高级语言编写的**源代码**转化成机器语言的**目标程序**的过程统称为翻译(Translation)。翻译的方式有两种:编译(Compile)和解释(Interpret)。编译将整个程序翻译成机器语言,解释则是边翻译边执行。 + + C 语言是典型的编译型语言,源代码需要经过编译后才能运行,而编译阶段并不会执行程序。Python 则是典型的解释型语言,逐句执行源代码,不需要产生可执行文件。 + + 这些描述的都是一种语言的典型用法。事实上 C 语言也开发出了相应的解释器,Python 也开发出了相应的编译器。两种翻译方式各有优劣。 + + +### 现在你是编译器 + +接下来我们将化身 C 语言编译器,解读一些代码。相信经过下面的训练,你对代码和程序执行的理解会更加深入。 + + +???+ example "例子:语法树" + + ```c + printf("%d:%d is %d minutes after 00:00\n", hour, minute, hour * 60 + minute); + ``` + + 编译器在翻译这条语句时,首先根据上述语法规则把这个语句解析成下图所示的语法树,然后再根据语法树生成相应的指令。语法树的末端的是一个个Token,每一步展开利用一条语法规则。 + + ![](https://box.kancloud.cn/2016-04-02_56ff80d067d7a.png) + + 理解组合(Composition)规则是理解语法规则的关键所在,正因为可以根据语法规则任意组合,我们才可以用简单的常量、变量、表达式、语句搭建出任意复杂的程序,以后我们学习新的语法规则时会进一步体会到这一点。从上面的例子可以看出,表达式不宜过度组合,否则会给阅读和调试带来困难。 + +???+ note "表达式不宜过度组合" + + 这涉及代码可读性问题。看看下面这段代码: + + ```c + double distance(double x1, double y1, double x2, double y2) + { + return sqrt((x2-x1) * (x2-x1) + (y2-y1) * (y2-y1)); + } + ``` + + 这样写很简洁,但如果写错了呢?只知道是这一长串表达式有错,根本不知道错在哪,而且整个函数就一个语句,插printf都没地方插。所以用临时变量有它的好处,使程序更清晰,调试更方便,而且有时候可以避免不必要的计算,例如上面这一行表达式要把(x2-x1)计算两遍,如果算完(x2-x1)把结果存在一个临时变量dx里,就不需要再算第二遍了(虽然这些优化现代编译器都会替你自动完成)。下面这个版本是可读性高的代码: + + ```c + double distance(double x1, double y1, double x2, double y2) + { + double dx = x2 - x1; + double dy = y2 - y1; + double dsquared = dx * dx + dy * dy; + double result = sqrt(dsquared); + + return result; + } + ``` + + 码风不是死的,请同学们视情况切换码风。 + + +## Lap:关于做题目 + +如果想做题的话,有基础的同学可以早点开始做[历年卷](../../programming/exam/index.md),了解一下程设考试都考些啥。 + +简单提一些 Tips: + +- 考试是全英文。如果平常不太看英文文档/没怎么学/英语很差的同学,至少你考前做历年卷的时候要把生词全部抄下来记一遍。 +- 一定一定一定要做历年卷。**不要以为自己会写点代码就能应付考试了**,其实考试和你写代码水平并不呈正相关。考试的题目都是很细节的,很多都是你平常不会注意到的,所以一定要做历年卷,做完了还要看看答案,看看自己哪里做错了,哪里没注意到。这里展示一个去年 C 小考试干碎一片人的选择题: + + +???+ example "干碎一片人的选择题" + + Suppose `T` is a type name and `t` is a variable of type `T`. Which of the following is **NOT** a valid expression? + +
+
A. `sizeof(T)`
+
B. `sizeof(t)`
+
C. `sizeof T`
+
D. `sizeof t`
+
+ + ??? note "答案" + + 答案是 C,不知道你猜对了吗? + + 大家一般写代码 `sizeof` 后面都会加括号的对吧,但你知道为什么吗?想要知道为什么,需要回顾前面学习的语法知识。 + + `sizeof` 是一个运算符,它有两种使用方式: + + ```c + sizeof(类型) + sizeof 表达式 + ``` + + 我们知道,`t` 是表达式,`(t)` 也是表达式。`sizeof(T)` 的使用符合规范。因此 A、B、D 都是正确的。为什么不规定 `sizeof 类型` 的用法呢?因为这可能引起歧义,有些类型名就携带空格,比如:`short int`、`struct node` 等等。了解了这些知识,你能说说下面的语句是否合法吗?如果合法,你能解释它的含义吗?如果不规定类型必须带括号,可能会产生哪些二义性? + + ```c + sizeof int***p + sizeof(int)*p + sizeof int * + 1 + ``` + + +如果你想的话,可以再来两道: + + +??? example "C 大 16 年选择题" + + In the following notations, _____ can express a character constant( 字符常量 ) correctly. + +
+
A. `'\x100'`
+
B. `125`
+
C. `'\08'`
+
D. `'\'`
+
+ + ??? note "答案" + + B + ## 程序的编译过程 接下来,我们将了解编译器和汇编器是如何一步步把你的程序编译成机器码的。我们以最经典的 C 语言编译系统 GCC 为例。 -??? note "什么是 GCC?" +???+ note "什么是 GCC?" 简单地说,GNU 项目旨在开发一个完全自由的操作系统以及配套的软件。GCC 最早是 GNU C Compiler 的简称,现在**代表 GNU Compiler Collection**。这表明它不是单个程序,而是一系列编译工具的集合,包括了 C、C++、Objective-C、Fortran、Ada、Go、D 等语言的前端,以及汇编器、链接器等后端,和这些语言的库文件。 @@ -194,7 +549,7 @@ CPU 还有自己的小工作区——由若干寄存器(Register)组成的 -??? info "gcc 的输出信息" +???+ info "gcc 的输出信息" 点击文本中带圆圈的 `+` 号可以展开详细信息,高亮的行是运行某个编译工具的具体命令。 @@ -273,7 +628,7 @@ CPU 还有自己的小工作区——由若干寄存器(Register)组成的 -??? question "为什么没有看见预处理器 `cpp` 的执行呢?" +???+ question "为什么没有看见预处理器 `cpp` 的执行呢?" 某些版本的 `gcc` 会将预处理器 `cpp` 和编译器 `gcc` 合并成一个指令,比如上面的 `cc1`,这样就不用单独调用 `cpp` 了。 @@ -381,7 +736,7 @@ as -o hello.o hello.s ``` -!!! note "目标文件" +???+ note "目标文件" 目标文件中包含计算机能读懂的机器代码和数据,有三种形式: @@ -407,7 +762,7 @@ exec: Failed to execute process: './hello.o' the file could not be run by the op - 启动代码:启动程序时,操作系统会将控制权交给程序的入口点,但这个入口点不是 `main` 函数,而是一些启动代码。启动代码在执行 `main` 前进行一些初始化工作,并在退出 `main` 后做一些扫尾工作。 -!!! note "一个不带启动代码的例子" +???+ note "一个不带启动代码的例子" Linux 程序的入口点一般是 `_start`,它完成一些内存初始化的工作,然后跳转到 `main` 函数。我们在链接阶段不带上含有启动代码的目标文件,看看在缺少 `_start` 的情况下会发生什么。 @@ -456,13 +811,13 @@ exec: Failed to execute process: './hello.o' the file could not be run by the op 链接有两种类型:静态链接和动态链接。 -!!! note "静态链接" +???+ note "静态链接" 如果你的程序与静态库链接,那么链接器会将静态库中的代码复制到你的程序中。这样,你的程序就不再依赖静态库了,可以在任何地方运行。但是,如果静态库中的代码发生了变化,你的程序并不会自动更新,你需要重新编译你的程序。 在 Linux 系统上,静态库的文件名以 `.a` 结尾,比如 `libm.a`。在 Window 上,静态库的文件名以 `.lib` 结尾,比如 `libm.lib`。静态库可以使用 `ar` (archive program)工具创建。 -!!! note "动态链接" +???+ note "动态链接" 当你的程序与动态库链接时,程序中创建了一个表。在程序运行前,操作系统将需要的外部函数的机器码加载到内存中,这就是**动态链接过程**。 @@ -566,14 +921,14 @@ main2.c:(.text+0x1a): undefined reference to `vector_add' ## 程序调试技术 -!!! info "杂谈:Bug 的典故" +???+ info "杂谈:Bug 的典故" 编程是一件复杂的工作,因为是人做的事情,所以难免经常出错。据说有这样一个典故:早期的计算机体积都很大,有一次一台计算机不能正常工作,工程师们找了半天原因最后发现是一只臭虫钻进计算机中造成的。从此以后,程序中的错误被叫做臭虫(Bug),而找到这些Bug并加以纠正的过程就叫做调试(Debug)。有时候调试是一件非常复杂的工作,要求程序员概念明确、逻辑清晰、性格沉稳,还需要一点运气。 ### Bug 的类型 -调试的技能我们在后续的学习中慢慢培养,但首先我们要区分清楚程序中的Bug分为哪几类。 +调试的技能我们在后续的学习中慢慢培养,但首先我们要区分清楚程序中的 Bug 分为哪几类。 #### 编译时错误 @@ -581,7 +936,7 @@ main2.c:(.text+0x1a): undefined reference to `vector_add' #### 运行时错误 -编译器检查不出这类错误,仍然可以生成可执行文件,但在运行时会出错而导致程序崩溃。对于我们接下来的几章将编写的简单程序来说,运行时错误很少见,到了后面的章节你会遇到越来越多的运行时错误。读者在以后的学习中要时刻_注意区分编译时和运行时(Run-time)这两个概念_,不仅在调试时需要区分这两个概念,在学习C语言的很多语法时都需要区分这两个概念,有些事情在编译时做,有些事情则在运行时做。 +编译器检查不出这类错误,仍然可以生成可执行文件,但在运行时会出错而导致程序崩溃。对于我们接下来的几章将编写的简单程序来说,运行时错误很少见,到了后面的章节你会遇到越来越多的运行时错误。读者在以后的学习中要时刻*注意区分编译时和运行时(Run-time)这两个概念*,不仅在调试时需要区分这两个概念,在学习 C 语言的很多语法时都需要区分这两个概念,有些事情在编译时做,有些事情则在运行时做。 #### 逻辑错误和语义错误 @@ -589,10 +944,10 @@ main2.c:(.text+0x1a): undefined reference to `vector_add' 通过本节你将掌握的最重要的技巧就是调试。调试的过程可能会让你感到一些沮丧,但**调试也是编程中最需要动脑的、最有挑战和乐趣的部分**。从某种角度看调试就像侦探工作,根据掌握的线索来推断是什么原因和过程导致了你所看到的结果。调试也像是一门实验科学,每次想到哪里可能有错,就修改程序然后再试一次。如果假设是对的,就能得到预期的正确结果,就可以接着调试下一个 Bug,一步一步逼近正确的程序;如果假设错误,只好另外再找思路再做假设。“当你把不可能的全部剔除,剩下的——即使看起来再怎么不可能——就一定是事实。”(即使你没看过福尔摩斯也该看过柯南吧)。 -也有一种观点认为,编程和调试是一回事,编程的过程就是逐步调试直到获得期望的结果为止。你应该总是从一个能正确运行的小规模程序开始,每做一步小的改动就立刻进行调试,这样的好处是总有一个正确的程序做参考:如果正确就继续编程,如果不正确,那么一定是刚才的小改动出了问题。例如,Linux 操作系统包含了成千上万行代码,但它也不是一开始就规划好了内存管理、设备管理、文件系统、网络等等大的模块,一开始它仅仅是 Linus Torvalds 用来琢磨 Intel 80386 芯片而写的小程序。据 Larry Greenfield 说,“Linus 的早期工程之一是编写一个交替打印 AAAA 和 BBBB 的程序,这玩意儿后来进化成了 Linux。”(引自 $The Linux User's Guide Beta1$ 版)在后续的课程中会给出更多关于调试和编程实践的建议。 +也有一种观点认为,编程和调试是一回事,编程的过程就是逐步调试直到获得期望的结果为止。你应该总是从一个能正确运行的小规模程序开始,每做一步小的改动就立刻进行调试,这样的好处是总有一个正确的程序做参考:如果正确就继续编程,如果不正确,那么一定是刚才的小改动出了问题。例如,Linux 操作系统包含了成千上万行代码,但它也不是一开始就规划好了内存管理、设备管理、文件系统、网络等等大的模块,一开始它仅仅是 Linus Torvalds 用来琢磨 Intel 80386 芯片而写的小程序。据 Larry Greenfield 说,“Linus 的早期工程之一是编写一个交替打印 AAAA 和 BBBB 的程序,这玩意儿后来进化成了 Linux。”(引自 _The Linux User's Guide Beta1_ 版)在后续的课程中会给出更多关于调试和编程实践的建议。 -!!! note "杂谈:为什么很多同学感觉调试的过程十分煎熬?" +???+ note "杂谈:为什么很多同学感觉调试的过程十分煎熬?" 或许你也会在后续的学习中亲身体会或看到,同学们被程序的 Bug(最典型的是段错误)折磨得焦头烂额。这可能有以下原因: @@ -663,7 +1018,7 @@ void echo() #### 编译时开启调试信息 -在编译时,使用 `-g` 选项开启调试信息。这样,编译器会在目标文件中插入调试信息,包括源代码文件名、行号、变量名、函数名等。这些信息可以帮助调试器定位到源代码的位置。 +在编译时,使用 `-g` 选项在可执行文件中加入源代码的信息,比如可执行文件中第几条机器指令对应源代码的第几行,但并不是把整个源文件嵌入到可执行文件中,所以在调试时必须保证gdb能找到源文件。这些信息可以帮助调试器定位到源代码的位置。 ```bash gcc -g -o gets gets.c @@ -768,18 +1123,18 @@ $1 = 0 `next` 命令将会单行执行程序,且不会进入函数内部(即把整个函数当作一行)。`step` 会进入函数内部一行一行执行。`continue` 命令将继续执行程序,直到下一个断点。 ```text -(gdb) next -7 while((c = getchar()) != '\n' && c != EOF) -(gdb) next -Breakpoint 1,gets (s=0x7fffffffd760 "") at gets.c:8 -8 *dest++ = c; -(gdb) print dest - s -$2 =1 -(gdb) continue -Continuing. -Breakpoint 1, gets (s=0x7fffffffd760 "") at gets.c:8 -8 *dest++ = c; -(gdb) print dest - s +(gdb) next +7 while((c = getchar()) != '\n' && c != EOF) +(gdb) next +Breakpoint 1,gets (s=0x7fffffffd760 "") at gets.c:8 +8 *dest++ = c; +(gdb) print dest - s +$2 =1 +(gdb) continue +Continuing. +Breakpoint 1, gets (s=0x7fffffffd760 "") at gets.c:8 +8 *dest++ = c; +(gdb) print dest - s $3 = 2 ``` @@ -802,17 +1157,23 @@ $5 = 23 ``` -!!! tip "调试技巧" +???+ tip "调试技巧" 直接按 ++enter++ 键,`gdb` 会重复上一条命令。这样就不用一直输入 `next` 或 `step` 了。 在一些更为复杂的程序中,使用 `gdb` 调试的优越性就逐渐显现出来了。你不用频繁更改源代码插入 `printf` 语句,只需要在 `gdb` 中设置断点,然后逐步执行程序,查看变量的值,就能找到错误所在。 + +!!! note "进一步学习" + + 由于个人水平不足以及时间有限,没能写出一个体验较好的 GDB 调试实验。十分希望同学们在课后去看看这篇文章[Linux C 一站式编程:第 10 章 gdb](https://www.kancloud.cn/wizardforcel/linux-c-book/134935),把这边的调试实例都过一遍~ + + ## 参考资料 -!!! info "参考资料" +???+ info "参考资料" - [GCC and Make - A Tutorial on how to compile, link and build C/C++ applications](https://www3.ntu.edu.sg/home/ehchua/programming/cpp/gcc_make.html) - [Understanding GCC warnings](https://developers.redhat.com/blog/2019/03/13/understanding-gcc-warnings) diff --git a/mkdocs.yml b/mkdocs.yml index edf8972..d21ce06 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -1,9 +1,9 @@ -site_name: 竺院辅学计划站点 +site_name: 浙江大学竺可桢学院辅学计划站点 site_url: https://ckc-agc.pages.zjusct.io/study-assist/ repo_url: https://github.com/ckc-agc/study-assist -repo_name: 竺院辅学计划站点 +repo_name: 浙江大学竺可桢学院辅学计划站点 edit_uri: tree/master/docs -site_description: 竺院辅学计划站点 +site_description: 浙江大学竺可桢学院辅学计划站点 site_author: 竺可桢学院学业指导中心 theme: