Coursera C 程序设计进阶,北京大学

专题一的 计算导论与 C 语言基础 还是比较简单的,但弥补了我一些概念与细节上的缺陷。
但专题二开始出现了 指针 与 初步的 面向对象 内容,这是搞竞赛时接触比较少的。


函数 - C 程序的基本构成单位

  • 函数的定义与声明:
    关于 实参形参 的概念:实参是实际向函数中传入的参数;形参是定义函数时声明的,使函数形式完整
int max(int x, int y) { ... } // x, y -> 形参
max(a, b);                    // a, b -> 实参
  • 函数的原型(对函数的提前声明):返回值类型 + 函数名 + 参数类型
    译为 signature: 和 SML 中的 signature 作用很相似!
int max(int, int);
  • 函数的运行
    调用返回结束函数内存立刻释放
    参数的传递:是 copy 复制 (强调 值传递,passed by value
  • 变量的作用域:局部变量 与 全局变量 -> 同样满足 lexical scope 与 closure。复习:closure 是函数及定义该函数时的环境组成的
    全局与局部变量名称发生冲突时,局部变量屏蔽 (shadow)全局变量
  • 数组作函数参数
    这里又和 python 中的 list 传递有点相似:数组的值会被改变
void change(int a[]) {    // 数组作参的形式
  a[0] = 30, a[1] = 50;
}
int a[2] = {3, 5};
change(a);                // 传入数组:传入数组名称 a
cout << a[0] << " " << a[1] << endl;

与直觉不同,在执行完 change 函数后,a[0], a[1]的值的的确确发生了改变!传参难道不是遵循 passed by value 原则吗?
这是由于传入 数组名称 a 与传入某个变量不同:数组名称 a 并非变量,而是一个常量,代表数组的地址
也就是 change 函数是对数组所在内存地址进行了修改:这就是与 传值(pass by value) 相对应的 传引用/地址(pass by reference)
在传引用条件下,函数任何操作都是对数组实际存储空间的操作,当然会改变其值


递归

  • 函数不能嵌套定义 -> 所有函数一律平等
    这和 SML 语言中不同,SML 中可以用 let 表达创造函数的子函数
  • 函数支持嵌套调用 (nested call)
    递归:函数在定义中直接或间接调用自身的一种方法
  • 递归的作用
  1. 用递归完成递推: 找到基状态与转移条件
  2. 模拟连续过程: 找到动作与不同动作之间的关系,边界条件
    经典的汉诺塔问题:当年一头雾水,现在已经可以直接打出来了
#include <iostream>

using namespace std;

int n;

void hanoi(int n, int st, int mid, int ed) {
    if (n == 1) {
        cout << st << "->" << ed << endl;
        return;
    }
    hanoi(n - 1, st, ed, mid);
    cout << st << "->" << ed << endl;
    hanoi(n - 1, mid, st, ed);
}

int main() {
    cin >> n;
    hanoi(n, 1, 2, 3);
    return 0;
}
  • 一些练习题:
    POJ 1664 放苹果 -> DP,分允许有空盘与无空盘来转移
    逆波兰表达式:模拟

指针

终于到指针了!

  • 变量的三要素:值,地址,名称:其中,地址就被称为 指向该变量的指针
  • 取地址运算符 & 可以取得一个变量的地址
int x;
cout << &x << endl;
cout << sizeof (&x) << endl;
  • 我们可以通过指针运算符 * 访问地址中存储的值
int x = 2;
cout << *&x << endl; // == cout << x << endl;
  • 指针变量:专门存放指针 (某个变量的地址) 的变量
int c = 2;
int *pointer = &c; // pointer 是指向变量 c 的指针变量
// int:基类型,所指向变量的类型; * : 指针变量类型

使用 * 运算符对指针变量进行操作即可取得指针变量指向的存储空间的内容
*pointer 即可得到 变量c

  • 指针变量的自加自减操作
    指针变量可以用 ++, -- 操作进行自加自减的操作
int n = 0;
int *pointer = &n;
cout << pointer << endl;   // 假设输出的地址是 0x00000100 
pointer++;
cout << pointer << endl;   // 自加操作的意义?

直觉上来讲,pointer++ 后应该会指向紧接着它的下一个地址,即 0x00000101
然而不要忘记了 pointer 的基类型是 int 型,占四个字节:也就是说,pointer 会指向的下一个地址应是 0x00000104

  • 指针变量的初始化
    在指针变量未赋值时,我们一般将其初始化为 NULL
int *pointer = NULL;
  • 指向一维数组的指针
    之前在介绍 传引用 的概念时,我们提到将数组名称传入函数时,实际上传入的是这个数组的地址
    然而这个描述不太准确,实际上,数组名称相当于数组首元素的地址
int a[10] = {0, 0, 0, 0, 0, 0, 0, 0, 0, 0};
// a == &a[0]

注意:\(a\) 是一个地址常量,不能被更改

  • 用指针访问一维数组
int a[10], *point = a;  // 等价于 int *point = &a[0],指向数组的首元素
point + i <=> a + i <=> &a[i]  // 第 i 个元素的指针。是不是很熟悉?这是 sort 里的参数呀!原来向 sort 等类似函数里传入的参数是指针
*(point + i) <=> *(a + i) <=> a[i] <=> point[i]  // 注意:point[i] 也可以访问第 i 个元素!

虽然用指针访问速度较快,但一定要时刻注意有效的范围
我们知道 \(a\) - 数组名称相当于指向数组第 \(0\) 个元素的指针,那 &a 有什么意义呢?
这里我们要知道:一维数组的名字,在定义时或者和 sizeof& 一起使用时才表示整个数组,出现在表达式中就会被转换为指向数组第 0 个元素的指针
因此 &a 中的 \(a\) 指的是整个数组 \(a\)&a 取得的地址虽然与 \(a\) 相同,但 cout << &a + 1 << endl; 输出的结果将会跨过整个数组而不是仅仅跨过一个元素
*&a 可以理解为与 a 等价,也可以理解为一整行数据。而一整行数据是无法输出,没有意义的,因此编译器会将其自动转化为指向第 \(0\) 个元素的指针即 \(a\)

  • 指针与多维数组
    考虑一个二维数组 a[3][4]
int a[3][4] = {{1, 2, 3, 4}, {5, 6, 7, 8}, {9, 10, 11, 12}};

我们知道,\(a\) 相当于数组首元素的地址,而该二维数组的首元素是一个包含着四个 int 元素的一维数组:\(\{1, 2, 3, 4\}\)
也就是说,\(a\) 的基类型是 "包含四个int元素的一维数组"
所以按如下方式定义

int (*pointer)[4] = a;    // 此时的 pointer 指向一维数组 {1, 2, 3, 4}。定义时的 () 不能省略:*pointer[4] 定义的是一个指针数组
cin >> i >> j;

现在考虑 *pointer 的意义:
按照之前的规律,* 运算符可以取得 \(pointer\) 指向的对象的值,在这里即是数组 \(\{1, 2, 3, 4\}\)
整行数据是没有意义的:即使输出,也会得到一个完全无关的值
编译器在遇到这种情况时,会将其转化为指向该行第 0 个元素的指针,也就是 *pointer = &a[0]
这个原理其实早已体现:就像一维数组的名字,在定义时或者和 sizeof& 一起使用时才表示整个数组,出现在表达式中就会被转换为指向数组第 0 个元素的指针

int x = *(*(pointer + i) + j)

在上面这段语句中,\(x\) 的值与 \(a[i][j]\) 一致。怎么分析呢?
*(point + i)指向第 i 行数据,被编译器转化为指向第 \(i\) 行数据第 \(0\) 个元素的指针;
(*(point + i) + j) 则是指向第 \(i\) 行第 \(j\) 个元素的指针
*(*(point + i) + j) 最后在用指针运算符操作,我们就得到了第 \(i\) 行第 \(j\) 个元素的值 \(p[i][j]\)\(a[i][j]\)

  • 另一种操作就是将二维数组视作展开的一维数组
int a[3][4] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12};
for (int *p = &a[0][0]; p < &a[0][0] + 12; ++p) {
  ......
  // *p -> 数组中的每一个元素
}
  • 指针练习题中的错题:
int fun() {
    char a[10] = {'1', '2', '3', '4', '5', '6', '7', '8', '9', 0}, *p;
    int i = 8;
    p = a + i;
    cout << p - 3 << endl;
    return 0;
}
// fun 函数的输出结果是?

这题我答的是 &a[5],即第五个元素的地址
但实际答案却是 \(6789\)
这是因为char数组即字符串的特殊性:字符串是可以通过 cout << 字符串名 对字符串进行输出的
输出 p - 3 实际上就是输出 a + 5 也就是 \(a\) 数组第五个字符及以后的所有内容
如果这不是一个 char 数组,而是一个 int 数组,那么我的答案就是正确的:程序将会输出第五个元素所在的地址

  • 指针与字符串
    与数组相同,字符串(char数组)的名称同样也是第 \(0\) 个元素所在的地址
    但是与数组不同的是,cout 后接上字符串名称并不会输出地址,而会输出整个字符串
char buf[] = "ABCDE";
cout << buf << endl;      // 输出 ABCDE
cout << buf + 3 << endl;  // 输出 DE

如果想输出所在地址而不是字符串本身,用以下方式:

cout << stactic_cast<void*>(buf) << endl;

另外,我们还可以用指针实现对字符串的定义。

char *str = "Hello";
cout << str << endl;  // Hello
str++; 
cout << str << endl;  // ello
str[2] = 'k';         // 这是不允许的,程序将报错
str = buf;
cout << str << endl;  // ABCDE

用指针这样定义的字符串被称为字符串常量,也就是说,其只能被访问而不能被修改

  • 指针与多维数组 (*Important)
    从上面对一维与二维数组的分析,我们可以做出如下总结
  1. 数组名相当于指向数组第一个元素的指针
  2. *p 相当于把 $p 的管辖范围下降了一个级别
  3. &p 相当于把 \(p\) 的管辖范围上升了一个级别
    例,对于二维数组 \(a[][]\)
    &a 指的是指向整个二维数组的指针 -> 此为最高级
    a 指的是指向二维数组的第一个元素即 \(a[0][]\) 的指针
    *a / a[0] 指的是指向 \(a[0][]\) 的第一个元素即 \(a[0][0]\) 的指针
    **a 指的是 \(a[0][0]\) 元素的值 -> 此时达到最低级
    a*a 又可以通过加法/自增操作遍历其级别中的所有元素
  • 指向符号常量的指针
    定义方式
const int *p = &a;
*p = 3;           // this is wrong!

这样定义的指针是一个指向常量的指针,也就是说,不支持对其指向对象的修改
当我们将数组作为参数传入函数时,为了避免对原数组的修改,我们可以通过添加 const 关键字使其变为一个常量指针

  • 指针作函数参数
    当指针作为函数参数时,由于函数得到了地址,可以对其内容进行修改
    对于一些我们不希望改变的内容,我们可以使用 const 来限制指针的功能
int fun(const int *p, int *a) {  // 或 const int p[], int a[]
  ...
  // 利用 p 中信息对 a 进行相应更改:将不变的 p 加上 const 关键字
}
int fun2(const int (*p)[40], int (*a)[40]) {   // 或 const int p[][40], int a[][40]
  ...
}
  • 指针作函数返回值
int *fun() {
  int val = 20;     // 动态局部变量
  return &val;
}
int *p = fun();
cout << *p << endl; // meaningless

指针作函数返回值很简单:只需要在定义函数时加上对应的指针运算符即可
但需要注意的是,我们需要确保函数所返回的地址意义:即 返回一个处于生命周期中的变量的地址
以上程序是无意义的,因为当我们访问 \(p\) 中的地址时,变量 \(val\) 已经被销毁
因此我们一般把全局变量的地址或静态局部变量的地址作为返回值

  • 静态局部变量
    我们通过添加 static 关键字来创建一个静态局部变量
int *fun() {
  static int val = 20;  // 静态局部变量
  int val2 = 20;        // 动态局部变量
  return &val;
}
int *p = fun();
cout << *p << endl;    // 有意义!

与动态局部变量不同,函数结束时,静态局部变量不会消失,重复调用函数时也不会为其重新分配空间
因此,静态局部变量仅在第一次调用该函数时进行初始化,以后每次调用函数时不再重新赋初值而只是保留上次函数调用结束时的值
虽然静态局部变量在函数调用结束后仍然存在,但其他函数是不能引用它的,也就是说,在其他函数中它是“不可见”的。

void fun() {
  static int val = 20;
  cout << val << endl;
  val++;
}
fun();   // print 20
fun();   // print 21

以上例子可以看出动态与静态局部变量的区别:如果 \(val\) 是动态局部变量,那么两次调用函数的结果应该都是 \(20\)


结构体

结构体之前竞赛里用的非常多,这里就简单记录一下一些不了解的东西吧

  • 结构体与指针
struct Student {
  int id;
  char name[20];
} a[100];
Student *p = a;  // p 是指向 a 数组第一个元素的指针

通过指针访问结构体内数据有两种方式

int ID = (*p).id;
char *ch = (*p).name;  // ch 指向 name 的第 0 个元素
cout << ch << endl;    // 输出整个字符串

另一种访问的方式是 -> 运算符

int ID = p->id;
char *ch = p->name;
cout << ch << endl;    // 同上

->指向运算符用来访问指针所指向的结构体变量中的成员


两种传引用的方式

void change(int *s) {
  *s = 4;
}
int a = 2;
change(&a);
cout << a << endl; // print 4 输出改变

用引用变量实现

void change(int &s) {
  s = 4;
}
int a = 2;
change(a);
cout << a << endl;  // print 4 输出改变

结构体,指针模拟链表与双向链表

  • 用结构体作为链表节点
struct node {
  int val;
  node *pre, *nxt;   // 指向前一个元素 / 后一个元素的指针
};

创建一个新节点:以头,尾节点为例

node *head = new node;
int *pint = new int(4);      // 申请一个存放值为 4 的内存并使 pint 指向它
int *parr = new int[100];    // 申请一个存放 100 个元素的数组的内存并使 parr 指向它
head->pre = NULL, head->nxt = tail;
tail->pre = head, tail->nxt = NULL;

释放某个节点的内存:在删除之前记得将其 pre,nxt 指针处理好

delete head;
delete pint;
delete [] parr;
posted @ 2022-04-13 11:35  四季夏目天下第一  阅读(50)  评论(0编辑  收藏  举报