信息学竞赛 CPP 基础篇

c++语言概述

聊聊cpp的历史,背景,特点。。。

语言简史—里程碑

  • 1972 c语言的最早版本,目标是开发unix系统
  • 1989 c语言的第一个标准 ANSI C 著名的c89/c90
  • 1980 贝尔实验室,==c语言 + 面向对象== ==>> 第一个c++版本

image-20220201080842746

  • 1994 第一个C++标准 ANSI C++

  • 1998/1999 STL等提案的确认,ANSI/ISO标准,被认为==标准c++==

    所有编译器都默认支持 著名的c++99标准, 并非是每个编译器100%实现了该标准

  • 2011 大幅度引入新特性,修正了很多缺陷或改进语法 ==>> c++11,

    一般的编译环境都需要加编译选项

  • 2014,2017,2020 。。。进行了一些修正,引入很多特性,有人反对

c/c++是成功的

40年对计算机语言是很长的,这期间大浪淘沙,不断有新秀登场

delphi, java, c#,go, rust 现代的设计理念

章节一:信息学竞赛 CPP 基础篇 - 图2

python ruby php js … 脚本语言更灵活、敏捷

c/c++一直保持大量的用户群,在操作系统,嵌入式,实时等关键领域垄断地位

c/c++ 历久弥新的原因?

  1. 平衡易用性(对人友好)和直达性(对机器高效)

    简洁高效,非必要不过度抽象 ==>> 执行效率

章节一:信息学竞赛 CPP 基础篇 - 图3

  1. 兼容性很重要!!!

c++ 完全兼容 c,现代的编译器兼容上个世纪80年代的库

高质量的库,种类多,数量大,是资产,也是包袱。。。

编译速度? 通过预编译头等策略

c++语言的特性

  • 编译型 编译 vs 解释,半编译,JIT

  • 静态类型 编译器可以更多地介入和监督。 有利于:编译期间发现错误 有利于:编译器对目标代码进行深度优化

章节一:信息学竞赛 CPP 基础篇 - 图4

  • 弱类型 编译器非必要不强迫。

  • 源码保护 从产品很难复原出源码。维护著作权。

勿于浮沙筑高塔

c/c++ 是威力巨大的利器!!!

前提是:使用者需要==真正充分理解==其运作原理,特性,才能驾驭它。

不能似是而非,一知半解

此武器,用好了,一招制敌!

实时效果反馈

1.c++对c语言主要添加了什么特性?

  • A 面向切面

  • B 面向对象

  • C 面向函数

  • D 面向并发

2.c++是哪种类型的高级语言?

  • A 编译型

  • B 解释型

  • C JIT(即时编译)

  • D 虚拟机

答案 1=>B 2=>A

开发环境介绍

子曰: 工欲善其事,必先。。。

原始方案

纯文本编辑器 + 命令行

  • 纯文本编辑器

linux/Unix: vi, vim, emacs

windows: 记事本,notepad++, ultraEdit

image-20211205180712298

  • 命令行窗口

windows: cmd窗口,powershell

章节一:信息学竞赛 CPP 基础篇 - 图6

linux: bash, csh, ksh

  • 编译环境 编译器是任务是:把源代码翻译成机器能执行的二进制指令

    • 微软的: vs默认 cl.exe
    • GNU的: gcc/g++
    • 苹果的: clang 支持 c, c++, objective-c

章节一:信息学竞赛 CPP 基础篇 - 图7

  • 第一个程序 helloworld
  1. 编辑器编写代码,并保存为 a.cpp

    1. #include <stdio.h>
    2. int main()
    3. {
    4. printf("hello!\n");
    5. return 0;
    6. }
  2. 编译为可执行文件: g++ a.cpp
  3. 执行exe文件: a

轻量 IDE

代码着色,自动编译,代码补全??

vscode: 现代,网页风格,主题丰富,插件丰富

image-20211205181542214

vscode可以支持很多语言的开发,需要安装相应的插件。

vscode可以根据扩展名,自动识别语言的类型,给出插件建议。

一般按照建议,安装语言默认插件即可。

image-20211205181725118

sublimeText:反应快,界面主题丰富,扩展强,安装容易,有商业支持

章节一:信息学竞赛 CPP 基础篇 - 图10

devCpp, codeBlocks 开源,简洁

image-20211205181755031

devcpp 是开源产品,文件很小,无需安装。不用建立工程,直接对.cpp, .c 文件操作

devcpp 是很多竞赛,考试的指定编译环境

重型 IDE

重型的优势:

代码着色,代码补全,语法检查,提示等更准确

对软件的==工程化==开发支持好,比如调试,测试,重构,优化等

vc++6.0 国内(尤其高校)曾经十分流行

章节一:信息学竞赛 CPP 基础篇 - 图12

很古老了,win98时代的产品。微软出品,品质还是能保底的。尤其调试功能很容易上手。

vs系列

当然强大!一般是收费的。

visual studio 2010 社区版,学习,调试

QtCreator

Qt开发套件的标配工具,有开源版本。

章节一:信息学竞赛 CPP 基础篇 - 图13

image-20211205182014537

对初学者,重量级武器唯一缺点:再小的任务也要先建立个项目,然后。。。

安装体积大,耗时。。。不算缺点

实时效果反馈

1.下列哪个不是纯文本编辑器?

  • A notepad++

  • B sublimeText

  • C vim

  • D WPS

2.下列说法正确的是?

  • A c++语言开发必须使用 IDE

  • B c++ 开发环境下无法编译 c 语言风格的程序

  • C g++ 与 gcc 编译器类似,只是默认就链接了 c++的标准库

  • D 微软的visual studio套件默认使用clang编译器

答案 1=>D 2=>C

基本数据类型

概览

章节一:信息学竞赛 CPP 基础篇 - 图15

c++的数据类型分为:

基本数据类型:布尔,整数,浮点

派生类型:修饰类型,指针类型,用户定义的复合类型等

基本类型表

关键字 含义 说明
bool 布尔 表示是否的概念 false true 0 1
int 整数 表示计数或数量
float 单精度 近似表达实数的概念
double 双精度 近似表达实数
char 字符 代表一个ASCII码符号 -128 - 127
void 表示没有,或者空的概念

基本类型占用空间

image-20211205182731548

现在就开始实验,不要依赖于文档

ps. 一般IDE注释和取消注释的方案:行选中,ctrl + /

sizeof(变量) 或者 sizeof(类型) 来探测

sizeof 是关键字,不是函数

不同的平台,不同的硬件,不同的编译环境可以有差异,不要武断!

类型修饰符

==short== 更短

==long== 更长

==signed== 有符号

==unsigned== 无符号

如果省略了基本类型,默认为 int

short = short int

unsigned = unsigned int

类型能多次修饰

long long = long long int

类型的可能取值范围

#include <limits>

std::numeric_limits<类型>::max() 或 min() 求最大值,最小值

unsigned 能表示更大的数值范围

unsigned 在移位操作时很重要

类型的别名

typedef 可以给类型赋予别名,与原名等价

书写更简便,尤其时复合类型时

例如:typedef long long int LL;

实时效果反馈

1.使用 sizeof 关键字会获得何种信息?

  • A 变量或类型占用内存的字节数

  • B 变量包含元素的个数

  • C 变量的级别

  • D 变量生存时间

2.unsigned int 与 signed int 占用内存大小关系是?

  • A 前者更多

  • B 后者更多

  • C 占用内存相同

  • D 与平台和编译器版本有关

答案

1=>A 2=>C

变量及其作用域

变量好比一个小盒子,能容纳数据,有名字,有地址,有大小,类型。。。

image-20211205183924165

两个重要概念:

  • 生存期:什么时候分配内存,什么时候释放内存
  • 作用域:在什么位置可见,什么位置不可见

全局变量

定义在函数外的变量,静态的生存期

  • 全局变量可以初始化,可以用表达式
  • 全局变量可以在多个函数间,多个文件间共享
  • 全局位置不能执行非定义语句
  1. int a = 100;
  2. int b = 200;
  3. int c;
  4. c = a + b; // 编译错误!
  5. int main()
  6. {
  7. cout << c << endl;
  8. return 0;
  9. }

此是反面教材, 不能在全局位置执行其它语句

变量可以在声明时初始化,也可以只声明。 当未初始化时,变量的值是随机的,此是很多bug发源地

局部变量:

函数内部定义,函数执行时存在

局部变量可以和全局同名,会产生覆盖效果

变量赋值

赋值是最常见的动作,但其意义非凡!!

image-20211205184151050

理解这个很重要,澄清将来的指针动作全靠它!

变量是一个小盒子,装着数据。 a = b; 的意思是:把 b 中的数据复制一份,放到 a 中。 a 中原来的数据被“踩死”了。

试试看,a b 现在值多少?

  1. int a = 5, b = 8;
  2. a = a + b;
  3. b = a - b;
  4. a = a - b;
  5. cout << a << "," << b << endl;

……?????#$@

如何交换两个变量?

如何交换:酱油和醋,找个空瓶子 空瓶 = 酱油;酱油 = 醋;醋 = 空瓶;

【示例】交换 a, b 变量的值

  1. int a = 5, b = 8;
  2. int t = a; a = b; b = t;
  3. cout << a << "," << b << endl;

这样做,还有什么缺点吗?

大括号作用域

大括号开辟了,新的一层,嵌套作用域

  • 大括号作用域可以嵌套,实际上局部作用域就是==最外一层==大括号。
  • 大括号作用域可以嵌套覆盖
  • 编程习惯:尽量缩小变量的作用域。

更好的交换变量方法???

  1. int a = 5, b = 8;
  2. { int t = a; a = b; b = t; }
  3. cout << a << "," << b << endl;

实时效果反馈

1.在全局作用域==不==可以对变量做哪些事情?

  • A 声明变量

  • B 给变量赋一个初始值

  • C 在变量初始值中使用表达式

  • D 执行声明,初始化以外的其它语句

2.局部变量的作用域是?

  • A 本程序范围内

  • B 本函数范围内

  • C 最近一层的左大括号到右大括号

  • D 可以被全局变量覆盖掉

答案

1=>D 2=>C

常数与常量

先看看最粗略的内存结构。

image-20211205212212657

常数

也叫立即数,或者“字面量”,就是硬编码到程序中的那些“死”数据。

  • 字符常数 单引号:’A’, ‘x’ 有些字符无法表示,可以直接整数,或:转义字符

    1. char a = 'A';
    2. char b = 9;
    3. char c = 66;

    ‘\t’ 与 9 是同等效果的 ‘\n’ 是什么?它的 ASCII 码是多少? 可以实验确定

  1. char a = '\n';
  2. int x = a;
  3. cout << x << endl;
  • 整型常数 默认是 int,需要长整数需要加 LL 考考你,一年有多少秒? int a = 365 * 24 * 60 * 60 * 1000; ???

    十六进制表示法: 0xff, 0xBA3C

    二进制与十六进制有天然对应关系:

    cout << hex << x<< endl;

  • 浮点型常数 可以使用 f 或 d 尾标表示类型 可以使用科学计数法表示更大的数字 0.5e12, 3.18e-20

常量

用 const 修饰的变量,它占用数据区内存,但编译器保证你不能修改它的值。

常量必须在定义时初始化

编译器的保证只是形式上的,你可以绕开编译器,强行改变其值! 常量的用处:

  • 多个地方用到同一个常量,内存只有一份拷贝。
  • 程序中不希望修改的量,避免“笔误”
  • 程序中不希望出现魔法数字,这样不利于将来的变更

枚举类型

把一组常数符号化,比如:星期,职称等有限种类的情形

enum week{Mon, Tue, Wed, Thu, Fri, Sat, Sun} a; 实际上相当于:

  1. enum week{Mon, Tue, Wed, Thu, Fri, Sat, Sun};
  2. enum week a;

这实际上是同时定义了三样东西: 类型: enum week 类型中的常数符号: Wed, Sat 等 该类型的变量: a

enum 的典型错误用法:

  1. enum week{Mon, Tue, Wed, Thu, Fri, Sat, Sun};
  2. int a = Thu;
  3. //a = 100;
  4. cout << a << endl;

可以通多 typedef 来定义enum的别名,避免定义多个变量时的尴尬。

  1. typedef enum {Mon, Tue, Wed, Thu, Fri, Sat, Sun} WEEK;

实时效果反馈

1. 下面关于常量的说法,==不==正确的是

  • A 常量初始化后,用任何方法都不能更改其值

  • B 常量必须在声明时初始化

  • C 多次引用常量,只占用一份内存

  • D 常量的初始化可以使用表达式

2.下列定义 enum 类型的别名,哪个==不正确==?

  • A typedef enum {yes,no} YN

  • B typedef enum {yes=1,no} YN;

  • C typedef enum YN_ {yes,no} YN;

  • D typedef enum {yes,no} YN;

答案

1=>A 2=>A

类型修饰符

  • 对大类型进行细化, short long signed unsigned
  • const 常量,
  • volatile 不要进行自动优化

存储修饰符

  • auto 默认的类型,称为:自动变量、栈变量、局部变量 c++17 已经把 auto 挪为他用,不再是类型修饰符
  • register 直接放寄存器中,速度快。c++17 开始废弃

image-20211205213603632

  • static 修饰,静态空间,与全局变量同在

  • extern 跨文件的伪声明

    并不分配内存,只是告诉编译器,全局变量会在另一个地方声明

内存的类型

程序分配的内存,主要在“栈”和“堆”这两部分。“堆”比“栈”复杂

image-20211205213722768

  • 只读代码: 存代码,也包括立即数,以及定常资源
  • 静态空间: 存放static变量,全局变量,常量
  • 栈区(stack): 自动变量,函数执行时的上下文环境
  • 堆(heap): 程序运行中,动态地申请及归还

image-20211205213758226

  • 堆和栈的比较
比较项目 说明
谁来管理 自动申请,自动释放 程序员代码申请,代码释放
时间特性 后申请的,必然先释放 没有限制
空间特性 所有申请的空间都是连续在一起的 空间上没有规律 可能产生能“碎片”
大小限制 有大小限制,默认2M 大小理论上没有限制
响应速度 很快 较栈慢
常见错误 栈溢出 悬挂指针,内存泄漏 栈的大小可设置

优美的栈

后进先出的结构(LIFO)

对比:heap 是什么结构? 自由进出

  • 栈能实现沿着原路返回的功能 想一想: 很多人爬山,山脚下堆放衣服,回来拿衣服的时,怎样不会纠缠在一起??
  • 特点是:自动分配,自动释放
  • 缺点: 栈溢出 stackoverflow

实时效果反馈

1.下面关于c++栈空间的说法,正确的是?

  • A 常量分配在栈空间

  • B 常数(立即数)分配在栈空间

  • C 全局变量分配在栈空间

  • D 自动变量分配在栈空间

2.可执行程序被加载入内存后,二进制代码被存在哪个内存位置?

  • A 静态空间

  • B 栈空间

  • C 只读空间

  • D 堆空间

答案

1=>D 2=>C

基本运算符

a+b, p = x->f() 12;

本质上说,运算符就是一种函数,只是表现形式更加人性化

==目==的概念

目就是参加运算操作数的个数

章节一:信息学竞赛 CPP 基础篇 - 图23

  • 主要有单目,双目,三目(只有一个运算符)
  • 同一运算符也可以有多目 a-b 和 -a 称为运算符的重载形式
  • 运算符有优先级和结合律
优先级 运算符 结合性
1 ( ) [ ] -> . 左到右
2 ! ~ ++ -- + - * & (类型) sizeof 右到左
3 * / % 左到右
4 + - ..
5 << >> ..
6 < <= > >= ..
7 == != ..
8 & ..
9 ^ ..
10 l (单竖线,位或) ..
11 && ..
12 ll (双竖线,逻辑或) ..
13 ?: (唯一的三目运算符) 右到左
14 = += -= *= /= %= &= ^= <<= >>= 右到左
15 , (逗号) 左到右

算数运算符

  • 整除,求余 试一试: 如何让 a 不断重复 0 1 2 0 1 2 ...

    思考题:a, b 是两个月份值,求 a 月 到 b 月,距离多少个月?

  • 浮点运算有舍入误差

  • 字符型可以参与整数的运算 试一试: 把小写字母转为大写字母

关系运算符

大于,小于,等于,不等于 。。。

不同类型的数比较—-陷阱! 浮点数精确比较—-陷阱!

逻辑运算符

表示:与 或 非 的概念 && || !

  • 注意优先级: !a&&b的意思是:(!a) && b,而不是: !(a&&b) a || b && c 的意思是: a || (b && c), 而不是:(a||b) && c
  • 逻辑运算符有短路求值特性,当心!

其它

  • 赋值=本身是个运算符,结合律:右结合 所以才有:a = b = c; 意思是: a = (b = c)
  • ( ) 是运算符,它的意思是强制转换,有可能丢失精度 如何取:天花板?地板? 3.5 -> 3 3.5 -> 4 如何四舍五入?
  • 还有很多高级的运算符,后续用到再讲

实时效果反馈

1.*p++ = x == y 正确的含义是:

  • A (*(p++) = x ) == y

  • B *(p++) = (x == y)

  • C ((*p)++) = (x == y)

  • D (((*p)++) = x) == y

2. 四则运算符多数都是既==单目==又==双目==,只有一个例外,它是:

  • A +

  • B -

  • C *

  • D /

答案

1=>B 2=>D

选择与分支

根据条件的真假,决定执行哪路代码 if .. else.. switch ?: 都有这个能力

if 的条件中尽量用 bool, 其它类型会强制转为bool

章节一:信息学竞赛 CPP 基础篇 - 图24

  • if 的条件中可能会发生短路求值现象 if(a表达式 && b表达式) ...a表达式为假的时候,b表达式根本不会执行 同理,if(a表达式 || b表达式) ...
  1. int a = 5, b = 8;
  2. int x = 100;
  3. if(a>10 && (x=b)){
  4. cout << "here" << endl;
  5. }
  6. cout << x << endl;

单 if 语句

  1. 尽量使用这个语句,避免 else

试一试: 给定年份,求是不是闰年

  1. bool leap_year = x % 4 == 0 && x % 100 != 0 || x % 400 == 0;

晕不晕? 可以采用渐进的方案,逐渐接近目标:假设修正法

章节一:信息学竞赛 CPP 基础篇 - 图25

if .. else

if 语句是可以嵌套的,嵌套是 bug 的发源地之一(哪个else 对应哪个if)

  • 试一试:给定 a, b, c 三个数,求居中的一个(假设没有相等的情况)
  1. int a = 5, b = 2, c = 8;
  2. if(a>b){
  3. if(b>c)
  4. cout << b << endl;
  5. else
  6. if(c>a) cout << a << endl;
  7. else cout << c << endl;
  8. }
  9. else{
  10. if(b<c)
  11. cout << b << endl;
  12. else
  13. if(c<a) cout << a << endl;
  14. else cout << c << endl;
  15. }

有很多的改进方法:

  • 使用函数,避免 if 嵌套
  • 使用 ?: 表达式
  • 使用”冒泡排序法”
  1. // 伪代码
  2. if(a>b) a <---> b
  3. if(b>c) b <---> c
  4. if(a>b) a <---> b
  5. print(b)

?: 表达式

?: 是唯一的三目运算符

  1. xxx = a ? b : c; //重点在求值,不在分支

与 if else 相比,它的重点是求值,而不是:执行一系列动作 改进的求居中方案:

  1. int a = 5, b = 2, c = 8;
  2. if(a>b && a>c) cout << (b > c? b : c) << endl;
  3. if(b>a && b>c) cout << (a > c? a : c) << endl;
  4. if(c>a && c>b) cout << (a > b? a : b) << endl;
  • 试一试: 给定 a, b, c 三个数,求最大值 不要想复杂了,这可以用”擂台赛”的方式解决 提示: 先让 a 上台,然后挑战者与之比试,胜者留在擂台,继续接受挑战。。。

实时效果反馈

1.下列说法正确的是:

  • A if 条件表达式只能接受bool量

  • B if 必须与 else 配对使用

  • C ?: 表达式可以用 if … else 改写

  • D if else 嵌套越多越高效

2. 当 if 分支只有一个语句时,下列说法错误的是:

  • A 可以省略大括号

  • B 可以与 if 写在同一行上

  • C 可以省略条件语句的圆括号

  • D 可以把条件取反,并且交换 if else 的分支部分

答案

1=>C 2=>C

switch

当分支情况较多时,

if .. else if …. else if …. else …

嵌套过多,维护不方便。

  1. switch(表达式){
  2. case 1:
  3. 代码块1;
  4. break;
  5. case 2:
  6. 代码块2;
  7. break;
  8. default:
  9. 默认处理
  10. }

章节一:信息学竞赛 CPP 基础篇 - 图26

要求:

  1. switch 圆括号中的值必为:整数,字符 或者 枚举类型

如果不满足这个要求,可以用表达式换算为符合要求。 示例: 对学生成绩进行分类,输出:A, B, C, D, E 5个等级

分类标准:

级别 分数范围 备注
A 90-100 相当于:优秀
B 80-89 良好
C 70-79 中等
D 60-69 及格
E 0-59 不及格
  1. int score = 85;
  2. char level = 'E';
  3. switch (score / 10) {
  4. case 9:
  5. case 10:
  6. level = 'A';
  7. break;
  8. case 8:
  9. level = 'B';
  10. break;
  11. case 7:
  12. level = 'C';
  13. break;
  14. case 6:
  15. level = 'D';
  16. }
  17. cout << level << endl;

与 if .. else 的比较

示例:对学生成绩进行分类,输出:A, B, C, D, E 5个等级 可以假设修正法,先假设不及格 满足了某个条件再修正它

常见的bug

  • 忘记了 default
  • 忘记了在每个case中写 break;

实时效果反馈

1.关于switch语句,正确的说法是:

  • A 有些场景,switch无法用 if .. else 来代替

  • B switch中必须包含 default 分支

  • C switch 的条件表达式中可以接受浮点数值

  • D switch 的 case 中不强制要求break; 语句

2. 下列关于switch说法,错误的是:

  • A 当某个case检测成功时,执行该case块的代码,然后跳出 switch语句

  • B 每个case 块可以有多条语句,且不需要大括号

  • C default 块当所有case都不满足必会执行

  • D default 块当有某个case满足时,也可能会执行

答案

1=>D 2=>A

while循环

循环是程序逻辑的关键点,是代码复杂度的源泉,需要谨慎对待

最可靠的循环—死循环

  1. while(true){
  2. 执行一些动作;
  3. }

配合的出口语句:break if (条件) break;

示例: 对学生成绩进行分类,输出:A, B, C, D, E 5个等级

  1. int score = 100;
  2. int a = score - 60;
  3. char level = 'E';
  4. while(true){
  5. if(a<0) break;
  6. level--;
  7. a -= 10; // a = a - 10;
  8. if(level=='A') break; // 封顶
  9. }
  10. cout << level << endl;

示例: 已知正整数x,从低位到高位,输出每个十进制数位 提示:通过不断整除,消掉最后一位,直到变成0

while 和 do..while

do-while 循环并不常用,所有情况,可以用内部的break来解决

章节一:信息学竞赛 CPP 基础篇 - 图27

实时效果反馈

1.关于break,正确的说法是:

  • A break可以跳出循环,或者switch

  • B break 跳出本层大括号

  • C break 跳出本函数

  • D break结束本程序

2. 下列关于while循环的说法,正确的是:

  • A while循环必然会终止

  • B while 循环体中可以没有任何语句

  • C while 循环不能嵌套使用

  • D while循环的条件可以不写

答案

1=>A 2=>B

for循环

当我们已知执行次数的时候,用 for 循环更方便一些。

for 循环的语法:

  1. for(初始化循环变量; 循环保持条件; 循环变量变化){
  2. 循环体
  3. }

章节一:信息学竞赛 CPP 基础篇 - 图28

  • 循环变量的生存期 循环变量仅仅在 for 语句范围内有效,是局部变量,放在栈中 循环变量的生存期,比循环体内的局部变量更长
  1. 相当于:
  2. for语句{
  3. 循环变量 i ...
  4. {
  5. 循环体内,可以有自己的局部变量
  6. }
  7. }
  • continue 直接进入下一轮循环 if(条件) continue; 越过循环的某些轮次不做

示例 打印九九乘法表 可以用双层嵌套

  1. for(int j=1; j<=9; j++){
  2. for(int i=1; i<=j; i++){
  3. int k = i * j;
  4. cout << j << "x" << i << "=" << k << " ";
  5. }
  6. cout << endl;
  7. }

示例 100以内的素数 素数:就是不能再等分的数,比如:2,5,19.... 最小的素数是2 设计思路:

  1. for(n 2 100){
  2. 先标记:n 是素数
  3. for(从 2 开始到 n-1 试着除 n){
  4. 如果能除尽 {
  5. 标记:n 不是素数
  6. 跳出循环;
  7. }
  8. }
  9. if(标记为素数) 输出 n;
  10. }

示例 分解质因数

90 = 2 x 3 x 3 x 5 设计思路:

  1. for(i 2 n-1){
  2. if(n 能除开 i){
  3. 输出 i
  4. n = n / i
  5. 设法阻止 i 的自增行为
  6. }
  7. }
  8. if(剩下的 n > 1) 输出 n;

实时效果反馈

1.关于for的循环变量,正确的说法是:

  • A 循环变量在每轮循环重新分配

  • B 循环变量只能是整数

  • C 循环变量不能是char

  • D 循环变量的生存期大于循环体内的局部变量的生存期

2. 下列关于循环的说法,正确的是:

  • A while 循环至少会执行一次

  • B for 循环会至少执行一次

  • C do-while 循环会至少执行一次

  • D 所有循环都可能一次也不执行

答案

1=>D 2=>C

函数入门

函数,在数学上的定义:对于输入的若干值,返回唯一的值。

章节一:信息学竞赛 CPP 基础篇 - 图29

函数的定义

  1. 返回值类型 函数名(输入参数列表 。。。){
  2. 函数体
  3. return 返回值
  4. }

例子:

  1. int myadd(int a, int b){
  2. int t = a * 10 + b;
  3. return t;
  4. }

调用它的方法:int x = myadd(5, 8);

函数定义时的参数,称为:形式参数,简称:形参 调用函数时,传给它的参数,称为:实际参数,简称:实参

  • 一般的情况下,形参的改变不影响实参。

  • 形参是局部变量,在栈中分配

  • 形参的寿命小于实参

函数的用处

函数是重要的==抽象==手段

  1. 人类解决复杂问题的法宝:大问题分解为小问题 这就是著名的自顶向下的设计
  2. 避免重复做某件事,去掉冗余 (don’t repeat yourself) 重复是恶趣之始 拷贝是万恶之源
  3. 消除 if..else 的嵌套,增加可读性 【三数取中】的例子,再次函数版本的实现: 三个数居中 = 两两最大值的最小值
  4. 消除 循环嵌套,增加可读性 【九九乘法表】的例子,再次函数版实现
  1. // 输出乘法表的某一项
  2. // row: 行号,i: 列号
  3. void t99_item(int row, int i)
  4. {
  5. cout << i << "x" << row << "=" << row * i;
  6. }
  7. // 输出乘法表的一行
  8. // row: 行号
  9. void t99_row(int row)
  10. {
  11. for(int i=1; i<=row; i++){
  12. t99_item(row,i);
  13. cout << " ";
  14. }
  15. }
  16. int main()
  17. {
  18. for(int i=1; i<=9; i++){
  19. t99_row(i);
  20. cout << endl;
  21. }
  22. return 0;
  23. }

返回值

返回值可以没有, 类型写 void 有返回值的时候,调用方也可以当作没有来用。 有返回值的时候,忘记了返回会怎样?

实时效果反馈

1.关于函数的说法,错误的是:

  • A 使用函数可以避免重复代码

  • B 使用函数可以把使程序更容易读懂

  • C 使用函数可以把大问题分解为小问题

  • D 使用函数可以提高执行速度

2. 下列关于返回值的说法,正确的是:

  • A 有返回值的函数,调用方必须接收返回值

  • B 没有返回值的函数,调用方也可以假装接收返回值

  • C 有返回值的函数,如果忘记了返回,会返回默认值

  • D c++只能返回一次值(不能多个)

答案

1=>D 2=>D

指针入门

指针就是存放其它变量地址的小盒子 所说的地址,是虚拟内存的地址,本质上就是整数,默认以十六进制表示

image-20211206093537353

指针的定义

类型 * 指针名 初始化: 指针 = &某变量

通过指针,访问被指向的变量

可以读取 myxx = *p; 也可以写入 *p = ... 要十分谨慎 通过指针写入是危险的,这是c++强大的原因,也是bug发源地

可以在参数中使用指针

形参是指针类型时,可以改变实参的值 我们可以利用这个特性,间接地实现多个返回值。

【示例】 交换连个变量

  1. void swap(int* a, int* b)
  2. {
  3. int t = *a;
  4. *a = *b;
  5. *b = t;
  6. }

【示例】 改写三数居中的例子,使用冒泡排序法 冒泡排序: 如果相邻的连个元素逆序,则交换它们 多次重复这个动作 伪代码如下:

  1. int mid(int a, int b, int c)
  2. {
  3. // 我们心中的目标是: a <= b <= c
  4. if(a > b) 交换a,b
  5. if(b > c) 交换b,c
  6. ...
  7. }

可以返回指针类型

避免返回局部变量的地址

  1. int* f(){
  2. int a = 100;
  3. //....
  4. return &a;
  5. }
  6. int main(){
  7. int* p = f();
  8. ....
  9. }

当我们拿到返回值的时候,变量 a 已经被释放了。 我们的指针指向了已经无效的内存,产生了:悬挂指针 这是很常见的bug之源

实时效果反馈

1.对指针类型,说法正确的是? __

  • A 虚拟内存的地址,本质上就是整数

  • B 对 cpu 的一种特殊的指令

  • C 程序的特殊区域

  • D c++语言独有的秘密武器

2. 悬挂指针是怎么产生的?

  • A 一个指针长时间没用,被回收了

  • B 一个指针指向了无效的内存

  • C 一个指针,其值为0

  • D 一个指针,长夜漫漫,黯然神伤

答案

1=>A 2=>B

引用类型

引用在语义上看,是:别名机制 在实现上,被转换为对指针的操作。

  1. int a = 100;
  2. int& b = a;
  3. b++; //实际上就是 a++

变量可以看作是:内存中小盒子, 变量名可以看作是盒子的标签 引用则可以想象为盒子上的另一个标签 就是说,一个盒子有多少个名字

image-20211206094157826

有了指针,为什么还要引入别名?

  1. 为了语义上更清晰,更接近人的思考习惯。 【改写】 swap 函数

    1. void swap(int& a, int& b)
    2. {
    3. int t = a;
    4. a = b;
    5. b = t;
    6. }
    7. int main()
    8. {
    9. int a = 5, b = 8;
    10. swap(a, b);
    11. cout << a << "," << b << endl;
    12. return 0;
    13. }
  2. 引用是不能运算的(比如++,—),只能去引用原来的值 相当于给“指针”这匹野马,套上约束,使用上更安全。

  3. 不存在空引用,但空指针很普遍,是bug之源

  4. 创建时初始化,不存在随机垃圾内存现象

  5. 引用绑定了一个目标,不能中途更换,指针随便

引用可以作为函数参数,也可以是返回值

当作形参时,可以看作实参的别名 当作返回值时,注意不要引用已经释放的变量

返回不存在的对象的引用,与返回不存在的对象的指针,都是典型的错误。

实现多返回

表面上看,c++的函数只能返回一个值,无法实现多值返回。

实际上,我们可以变通。

【示例】 通过引用实现返回多个值的效果 编写一个函数,传入a,b,返回a除以b的商,和余数

提示:可以用参数接收返回值。

实时效果反馈

1.引用和指针的关系:

  • A 引用比指针操作更快

  • B 引用比指针有更多的操作,功能更强大

  • C 引用总是比指针更省内存

  • D 引用比指针更安全,更人性化

2. 哪个==不是==使用引用的优点

  • A 必须初始化,不可能有垃圾值

  • B 无法更换绑定的变量,防止误操作

  • C 不能进行++ —的运算,更安全

  • D 看起来更酷,更唬人

答案

1=>D 2=>D

数组入门

image-20211204094732969

数组的由来

  1. 3个数求最大值

    1. int a=5, b=2, c=8;
    2. int m = a;
    3. if(b>m) m = b;
    4. if(c>m) m = c;
  1. 10个数求最大值

    不可能定义 10 个变量啊!!

    。。。要不 100 个数??

    1. int a[9] = {9,7,2,5,3,6,1,4,8};
    2. int m = a[0];
    3. for(int i=1; i<9; i++){
    4. if(a[i]>m) m = a[i];
    5. }
    6. cout << m << endl;
  1. 一个变量名,能操纵多个变量

    • 通过==下标==(index)来操纵每个元素

    • 下标从==0==开始到==n-1==

    • 可以对元素==读==,也可以==写==

      1. ... = a[5]; // 读取
      2. a[5] = ... // 写入

当心! ​ c++ 不会去保证下标合理(不做运行时检查)

数组是什么?

数组就是存在一起的一组相同类型的变量

int a[3]; //定义含有三个元素的整型数组

image-20211204092314053

  1. 每个变量的地址是==紧挨着==的
  2. 每个变量都是==相同==的类型(意味着占用相同大小的空间)
  3. 只有一个名字,才能==统一==操作

数组存在哪里?

答曰:栈

(更严格地说,还可以是静态空间,比如:全局数组)

  • 一般情况下,数组是局部变量

  • 局部变量在==栈==中分配空间

  • 自动分配,自动释放

怎么知道数组占用多大空间?

两种方案?

  1. 元素个数 x 单个元素的大小

    1. #define N 100
    2. ...
    3. int a[N];
    4. x = sizeof(int) * N;
  1. sizeof(数组变量名)

    1. #define N 100
    2. ...
    3. int a[N];
    4. x = sizeof(a);

元素初始化

  1. 指定元素个数

    1. int a[3] = {10,20,50};
  1. 不指定元素个数(自动推断)

    1. int a[] = {3,6,9};
  1. 指定值的个数少于元素个数

    1. int a[10] = {10,20,30};

    当心!

    没有赋初始值的元素是==随机值==

拓展

image-20211204084522640

  1. ==长定==数组,就是数组创建后,大小就==固定==了,无法改变

    • 通常,大小直接常数,或define一个常数(==定长==数组,编译器知道大小)

      1. // 怎样避免魔法数字??
      2. #define N 10 // 老方案
      3. const int N = 10; // 新方案(提倡)
  • c++99 中允许使用==变量==(运行时求值)

    1. int n = f(2)+@$@#$@!...; //一顿操作猛如虎,最后得到一个数
    2. int a[n]; // 初始化免谈,因为编译器也不知道到底有多大!!
  1. 动态数组是:开始时有个==初始==大小,将来可以==按需==扩大或缩小

    许多高级语言中的数组就是这个。

    通过==重新分配==内存,并==拷贝==旧的内容来实现。

实时效果反馈

1.下列说法正确的是__

  • A 数组的大小一经创建就无法改变了

  • B 数组中可以包含不同类型的数据

  • C 数组在定义时,必须赋给初始值

  • D 如果不对数组赋初始值,其值为0

2.对数组的下标,说法==错误==的是__

  • A 数组的下标总是从 0 开始

  • B 数组的下标最大为:元素个数-1

  • C 当访问a[N]的时候(N是数组a的大小),会产生下标越界

  • D 可以使用负数下标

答案

1=>A 2=>D

函数进阶

章节一:信息学竞赛 CPP 基础篇 - 图35

  • 数学上的函数是:

输入数据 ===> 函数(加工机器) ===>吐出一个值

  • 程序中的函数行为更复杂

    主要表现在函数执行时,可能会影响调用者

    也叫“副作用”

被调方(callee)如何影响主调方(caller)

  1. 返回值

    这是最正经的渠道,调用方自己来决定如何使用返回值

    可以:

    1. x = f(...) // 接收返回值

    也可以:丢弃

  2. 指针参数

    参数类型为指针的话,就复杂了,callee 直接操刀。。。

  3. 形参是:指针的指针

    比如: int** p

    这个就更恐怖了,实质与前述相同

  4. 形参是:引用

    这个看着唬人,实际上最后,还是转换为指针的方案

指针形参

这种方式,好比调用方准备了一个盒子,把盒子地址传过去,被调方往里塞东西

image-20211207131331046

讨饭的例子。。。

其本质是:两个指针指向同一个东西

  1. void f(int* p) { *p = .... }
  2. ....
  3. int x = ...
  4. int* a = &x;
  5. f(a);

image-20211206145815056

【示例】数组排序

冒泡排序法

每一趟排序,把最大的归位

image-20211204181814851

核心代码:

  1. void bubble_sort(int* a, const int N)
  2. {
  3. for(int j=0; j<N-1; j++){
  4. for(int i=0; i<N-j-1; i++){
  5. if(a[i]>a[i+1]) swap(a[i],a[i+1]);
  6. }
  7. }
  8. }
  9. // 调用方:
  10. int a[] = {3,5,9,2,1,4,6};
  11. bubble_sort(a, sizeof(a) / sizeof(int));

指针的指针形参

这是为了修改调用方指针本身(不是指针指向的值),使得它指向所需的内容

【示例】

准备一个整数数组,准备2个指针 p 和 q

调用函数 f 后,使得p指向==第一个==负数,q指向==最后一个==负数

image-20211204213116045

  1. void f(int* a, const int N, int** p, int** q)
  2. {
  3. for(int i=0; i<N; i++){
  4. if(a[i]<0) {
  5. *p = &a[i];
  6. break;
  7. }
  8. }
  9. for(int i=N-1; i>=0; i--){
  10. if(a[i]<0){
  11. *q = &a[i];
  12. break;
  13. }
  14. }
  15. }

来看调用方

  1. int a[] = {3, 5, -3, 9, -5, 2, 1, -1, -2, 4, 6};
  2. const int N = sizeof(a) / sizeof(int);
  3. int *p, *q; // int* p, q;
  4. f(a, N, &p, &q);
  5. cout << *p << ", " << *q << endl;

有的人晕车,有的人晕船,。。。

c/c++程序员==不能==晕指针,那好比美丽的鸟儿有==恐高症==。

秘诀:

只要对照这两个例子,找共性。

  • 要回填的值是什么类型? 比如是 A

    。。。我们要修改A类型的东西。。。那么,要通知别人什么信息?。。。。

  • 传递的参数是什么类型?必然是:A*

  • 当 A = int 时,我们得到了 `int*`

实时效果反馈

1.下列关于冒泡排序,说法正确的是__

  • A 100个元素冒泡排序,共需要比较99次

  • B 100个元素冒泡排序,需要比较99 + 98 + 97 + … + 1 次

  • C 一趟冒泡排序可以找到最大值和最小值

  • D 完全逆序的数据,需要更多的比较次数

2.示例题目中,我们使用了指针的指针,目的是__

  • A 让一个指针,指向另一个指针

  • B 取得指针的地址

  • C 回填一个指针类型的数据

  • D 炫耀一下指针的颜值

答案

1=>B 2=>C

数组与指针进阶

image-20211205074739343

子曰:了解事物本质的方法是:观察事物的行为

数组名字的本质

  1. 准备两个数组 a, b,一个指针 p

  2. 观察下面的实验:

    1. int a[3] = {1, 2, 3};
    2. int b[3] = {1, 2, 3};
    3. int* p = a;
    4. cout << (a == b) << endl; // == 的含义?
    5. cout << a << endl;
    6. cout << b << endl;
    7. cout << p << endl;
    8. a = b; //编译不过去!
    9. cout << sizeof(a) << "," << sizeof(p) << endl;
  1. 观察如下实验:

    1. int a[3] = {1,2,3};
    2. cout << a << endl; // 它的值是多少?类型?等同不?
    3. cout << &a << endl;
    4. cout << &a[0] << endl; // &(a[0])

    结果相同,这三者==等同==不?

  2. 观察如下实验:

    1. int a[3] = {1,2,3};
    2. cout << a << endl;
    3. cout << &a << endl;
    4. cout << a + 1 << endl;
    5. cout << &a + 1 << endl;
    6. cout << (a == &a) << endl;
  1. 思考与结论:

    数组不会作为整体来==比较==,或者==赋值==,输出的时候,与指针无二

    数组名不能被赋值,它只是编译器记录的信息,不是变量。

    数组名取大小,包含了数组长度信息;赋值给指针,则==丢掉==了这个信息

    数组名取地址,得到一个指向整个数组类型的==指针==

章节一:信息学竞赛 CPP 基础篇 - 图41

重点:

  • 指针的==值==,决定了它指向哪块数据

  • 指针的==类型==,决定了它的运算行为(比如 +1 的行为)

数组作为参数传递

子曰: 真知来源于实践

做下面的实验:

实参 形参 现象
int a[3] int * p 可操作,丢失size信息
int a[3] int p[ ] 与前项同
int a[3] int p[3] 同前
int a[3] int p[4] 竟然也可以??

指针的运算

探究:p + 1 的含义

  1. int a;
  2. int* p = &a; cout << p << endl;
  3. p++; cout << p << endl;
  4. char b;
  5. char* q = &b; cout << q << endl;
  6. q++; cout << q << endl;

结论:

p + n 真实的增量是 n * sizeof(单个元素)

指针运算与[ ]运算的等效性

*(p + n) === p[n]

*p === p[0]

请反复验证:

指针可以当数组用;数组也可以当指针用

子曰:c语言中本没有数组,它只是指针的幻影

章节一:信息学竞赛 CPP 基础篇 - 图42

拓展:数组引用(用作参数的时候,有区别)

  1. //作为形式参数时:
  2. int (&p)[3] //携带了长度信息
  3. int p[3], int p[], int* p //是等同的

实时效果反馈

1.a是整型数组,int* p = a+1 的含义是__

  • A 与 int* p = &a[1]; 等同

  • B 把数组 a 的第二个元素赋值给 p

  • C 把表示数组的首地址的整数加 1,给了p

  • D 把数组中的每个元素都加 1, 并把数组地址给 p

2.对于函数 void f(int p[3]), 说法正确的是__

  • A 调用时,必须传入一个含有三个元素的整型数组

  • B 函数内部:sizeof(p) 会返回 12

  • C 函数内部用 *(p+1) = … 是错误的写法,应该: p[1] = …

  • D 调用时,传入任何 int* 类型的实参,都能编译通过

答案

1=>A 2=>D

数组元素搬运

章节一:信息学竞赛 CPP 基础篇 - 图43

数组的拷贝

  1. // 错误的拷贝方案
  2. int a = {1,2,3};
  3. int b = {7,8,9};
  4. a = b;

需要自己定义函数 mycopy

mycopy(…) 如何安排 mycopy 的参数?关键是==长度==信息。

两种设计风格:

  1. // 目标指针,源指针,拷贝个数
  2. void mycopy(int* dst, int* src, const int n);
  1. // 目标指针,源指针,源阻拦索
  2. void mycopy(int* dst, int* src, int* src_end);`

它们都很灵活,可以支持部分拷贝

  1. void mycopy(int* dst, int* src, int* src_end)
  2. {
  3. for(int* p = src; p < src_end; p++) *dst++ = *p;
  4. }
  5. void mycopy(int* dst, int* src, const int n)
  6. {
  7. for(int i=0; i<n; i++) dst[i] = src[i];
  8. }
  9. void show(int* a, const int n)
  10. {
  11. for(int i=0; i<n; i++) cout << a[i] << " ";
  12. cout << endl;
  13. }
  14. int main()
  15. {
  16. int a[] = {1,2,3,4,5};
  17. int b[] = {10,20,30,40,50,60};
  18. //mycopy(b+2, a+2, 3);
  19. mycopy(b+2, a+2, a+2+3);
  20. show(b,6);
  21. return 0;
  22. }

数组拷贝大坑—overlap

image-20211208213238433

当源位置与目标位置==重叠==的时候,我们的代码失效了

秘诀 拷贝的次序很重要!!

  1. void mycopy(int* dst, int* src, int* src_end)
  2. {
  3. if(dst==src) return;
  4. int n = src_end - src;
  5. if(dst>src)
  6. for(int i=n-1; i>=0; i--) dst[i] = src[i];
  7. else
  8. for(int i=0; i<n; i++) dst[i] = src[i];
  9. }

数组元素的删除

  • 数组元素的删除,就是把元素后面的内容,向前平移(本质=overlap拷贝)

image-20211209100201001

数组元素的插入

  • 插入一个元素,就意味着:需要把那个位置开始的后面内容,向后平移
  • 本质是向后平移,空出位置

image-20211209100613678

注意:

数组的结构决定了,它不适用于频繁地插入和删除,尤其数组很大的时候

如果需要,我们可以使用:==链表==,==哈希表==,==块链==等更复杂的结构

实时效果反馈

1.怎样把数组元素,正确地从地址A,搬运到地址B

  • A低地址高地址拷贝每个元素

  • B高地址低地址拷贝每个元素

  • C 先拷贝中间地址重叠的部分,后拷贝两边的

  • D 根据 A, B 的位置关系,决定拷贝的次序

2.关于向数组中插入元素, 说法正确的是__

  • A 调用 a.insert(索引,值);

  • B 插入元素后,数组变大了

  • C 先要把插入位置开始的元素都向后移动,为新来的元素腾出空间

  • D 在数组的任何位置插入新元素都是相同的代价

答案

1=>D 2=>C

插入排序法

章节一:信息学竞赛 CPP 基础篇 - 图47

问题:

给定一个数组,用简单插入法,排序。(模拟,扑克牌抓牌的动作)

要点 把一个元素插入到有序的表中,并让表的长度增长

方案

我们尽量不利用额外的空间。

  1. 可以把数组分开成两个区域,左边是排好序的,右边是待排序的。
  2. 从右边拿一个数,插入左边适当的位置
    • 寻找恰当的位置
    • 把那个位置的数据向后平移
    • 放入插入的数据
  3. 重复2,直到没有剩余数据

场景

章节一:信息学竞赛 CPP 基础篇 - 图48

实现

  1. int* find_pos(int* begin, int* end, int x)
  2. {
  3. for(int* p = begin; p<end; p++){
  4. if(*p >= x) return p;
  5. }
  6. return NULL;
  7. }
  8. void mycopy(int* dst, int* src, int* src_end)
  9. {
  10. if(dst==src) return;
  11. int n = src_end - src;
  12. if(dst>src)
  13. for(int i=n-1; i>=0; i--) dst[i] = src[i];
  14. else
  15. for(int i=0; i<n; i++) dst[i] = src[i];
  16. }
  17. void mysort(int* a, const int N)
  18. {
  19. for(int*p=a; p<a+N; p++){
  20. int t = *p;
  21. int* q = find_pos(a, p, t);
  22. if(q!=NULL){
  23. mycopy(q+1, q, p);
  24. *q = t;
  25. }
  26. }
  27. }

拓展

简单插入排序,比较的次数很多

我们可以采用==折半查找==来提高效率

实时效果反馈

1.关于简单插入排序,说法正确的是__

  • A 排序较慢,且与待排的数据情况无关

  • B 当数据基本有序时,排序会更快

  • C 当数据是==逆序==时,排序更快

  • D 当数据都相同的时候,排序最慢

2.关于空指针,正确的说法是__

  • A 空指针也是一个普通的指针,其值为0

  • B 空指针不指向任何位置

  • C 空指针指向它自己

  • D 空指针指向NULL

答案

1=>B 2=>A

二维数组

章节一:信息学竞赛 CPP 基础篇 - 图49

二维数组的存储规律

列优先,存完第一行,存第二行,。。。

image-20211206191045416

二维数组元素访问方法

a[行号][列号] = ...

【示例】,填写一个数组,如下数据:

章节一:信息学竞赛 CPP 基础篇 - 图51

可以利用存储关系,用一维方式来填充

  1. int a[3][6];
  2. int *p = &a[0][0]; // == &(a[0][0])
  3. for(int i=0; i<sizeof(a)/sizeof(int); i++) p[i] = i+1;

传递给函数的参数类型

弄清这几个类型:

形参 实质含义 说明
int a[3][4] int a[][4] 第一维度的大小是无用的,写多少都行,类似一维情况
int (*a)[4] 指针类型,指向int[4]的东西 行指针,加 1 运算会跳过一整列的数据
int* a[4] int** a [ ]优先级高,a 是[4]类型,也就是 []类型,也就是指针类型

【实战1】

  1. // 已知二维数组中的字母阵列,请输出如下图形:
  2. A B C D
  3. E F G H
  4. I J K L

【实战2】

二维数组边沿初始化

把边数组的”边框“清零

章节一:信息学竞赛 CPP 基础篇 - 图52

  1. void show(int* a, const int W, const int H)
  2. {
  3. for(int j=0; j<H; j++){
  4. for(int i=0; i<W; i++){
  5. cout << a[j*W+i] << " ";
  6. }
  7. cout << endl;
  8. }
  9. }
  10. void clear_border(int* a, const int W, const int H)
  11. {
  12. for(int j=0; j<H; j++){
  13. for(int i=0; i<W; i++){
  14. if(i>0 && i<W-1 && j>0 && j<H-1) continue;
  15. a[j*W+i] = 0;
  16. }
  17. }
  18. }
  19. //..... 调用处:
  20. int a[3][6];
  21. int *p = &a[0][0]; // == &(a[0][0])
  22. for(int i=0; i<sizeof(a)/sizeof(int); i++) p[i] = i+1;
  23. clear_border(&a[0][0],6,3);
  24. show((int*)a,6,3);

实时效果反馈

1.关于二维数组,说法正确的是__

  • A 二维数组就是一个数组的元素本身又是数组

  • B 二维数组中,每行的元素个数可以不同

  • C 二维数组的数据可能分别存储在多个位置

  • D 二维数组作为参数类型传入函数时,保持了宽度和高度信息

2.二维数组的第x个元素,所在的列号算法是:

  • A x / 数组宽度

  • B x % 数组宽度

  • C x / 数组高度

  • D x % 数组高度

答案

1=>A 2=>B

动态内存分配

image-20211209155512286

内存的分配与释放

栈空间(stack)的分配与释放是自动进行的,是隐式

堆空间(heap)的分配与释放是代码显式操作行为

堆像辽阔的海滩,我们可以随意挖坑,也别忘记了填坑。

有的坑特别巨大,有的很小,有的保留一年,有的三秒消失…..

使用场合(目的)

  • 不能事先确定需要多少空间,以及需要占用多久

    • 比较:事先可以确定变量到底占用多少空间,也能确定用多久。

    (后来的必定比先来的短命)

  • 需要的空间太大,栈不够用

内存的管理的一般原则:谁申请,谁释放。但有时无奈。

函数的设计模式,当我需要一些数据(好比需要一些肉),

  1. 直接返回在中,用完即可自动释放
  1. 调用方提供空间,并负责释放:”把肉放在我的碗里“
  1. 函数方提供碗和肉,调用方使用完毕后,别忘记归还碗(动态申请的最普遍用法)

使用方法

image-20211205164620517

malloc用法:

void* malloc(申请字节数);

需要include头,一般可以用 stdlib

  • 需要自己计算到底需要多少字节内存
  • 返回的指针需要强制
  • 不会做初始化,返回的内存中的内容是随机值
  • 配合 free, 千万不要忘记释放
  1. int a[3] = {10,20,30};
  2. int* b = (int*)malloc(sizeof(int) * 3);
  3. for(int i=0; i<3; i++) b[i] = a[i];
  4. cout << b[2] << endl;
  5. free(b);

new/delete 用法

  • new 类型 [ 个数];
  • 返回元素类型的指针,不用强制转换
  • 配合 delete [ ] 使用

==当心!== 释放时,别忘记了方括号

  1. int a[3] = {10,20,30};
  2. //int* b = (int*)malloc(sizeof(int) * 3);
  3. int* b = new int [3];
  4. for(int i=0; i<3; i++) b[i] = a[i];
  5. cout << b[2] << endl;
  6. //free(b);
  7. delete [] b;

实例

设计一个函数,传入整数,返回所有的因子

例如:传入30,则返回 [2,3,5,6,10,15]

设计策略,被调用方动态分配内存,主调方用完释放。

  1. int* get_factor(int x)
  2. {
  3. int n = 1; // 多分配一个用于结束标志
  4. for(int i=2; i<x; i++) if(x%i==0) n++;
  5. int* data = (int*)malloc(n * sizeof(int));
  6. n = 0;
  7. for(int i=2; i<x; i++) if(x%i==0) data[n++] = i;
  8. data[n] = -1;
  9. return data;
  10. }
  11. // 以下是调用方:
  12. int* a = get_factor(60);
  13. for(int* p=a; *p>0; p++) cout << *p << " ";
  14. cout << endl;
  15. free(a);

拓展:

calloc 的用处: 需要初始化时

realloc 的用处: 惯例内存惯例时

实时效果反馈

1.malloc和new的区别,说法正确的是__

  • A new是在静态空间中分配内存,malloc在堆空间分配

  • B new初始化内存,malloc不初始化

  • C new返回正确的类型,malloc总是返回void*

  • D 为数组开辟空间,必须用 new [ ]

2.如果在函数中返回动态分配的内存,怎样告诉主调方内存大小呢?:

  • A 可以用 sizeof(指针)的方式求取

  • B 可以通过在内存结尾放特殊标志通知主调方

  • C 可以返回两个值(指针和大小)

  • D 可以返回指针与大小的和

答案

1=>C 2=>B

筛法求素数

image-20211210091644964

到目前,最古老的筛法,仍然是求素数的最有效解法之一。

普通素数算法

核心是:bool is_prime(int x) 如果 x 是素数,就返回 true

  1. bool is_prime(int x)
  2. {
  3. for(int i=2; i<x; i++) if(x%i==0) return false;
  4. return true;
  5. }

小知识

素数的密度, 当n很大时,在 n 附近为: $$ p(n) = \frac{1}{ln(n)} $$ 也就是说,在10000附近,大约 ln(10000) ~= 9.2 个数中出现一个素数

n 越大,这个数字越准确。

筛法

【示例】求第1000000(百万)个素数

在数组中放入自然数,划掉2的倍数,。。。

从2开始,找到第一个没有划掉的数 3,划掉所有3的倍数

。。。

image-20211206153415895

  1. // 把p指向位置数字的整数倍划去
  2. void put_tag(int* p, int* end)
  3. {
  4. for(int*q = p + *p; q<end; q += *p) *q = -1;
  5. }
  6. // 从当前位置找一个最靠近的没有划去的数字
  7. int* next_pos(int* p, int* end)
  8. {
  9. while(++p<end && *p <0);
  10. return p<end ? p : NULL;
  11. }
  12. // 取得第 x 个素数
  13. int get_nth(int x)
  14. {
  15. const int N = x * 20; //需要筛选 N 个数字
  16. int* a = new int [N]; // 堆上分配
  17. for(int i=0; i<N; i++) a[i] = i+2; //初始化
  18. int* p = a; // 标尺,即将标记它的所有倍数
  19. while(p!=NULL){
  20. put_tag(p, a+N);
  21. p = next_pos(p, a+N); // 指向下一个没有被划掉的数
  22. }
  23. int t = -1;
  24. for(int i=0; i<N; i++){
  25. if(a[i] > 0) x--;
  26. if(x==0) {
  27. t = a[i];
  28. break;
  29. }
  30. }
  31. delete [] a;
  32. return t;
  33. }

实时效果反馈

1.素数的出现规律是__

  • A 素数的出现不确定,没有一定规律

  • B 在某个局部没有规律,但总体上数越大越稀少

  • C 按指数规律减少

  • D 按线性规律减少

2. 求第n个素数时,为什么要动态分配内存?

  • A 因为不确定需要多少内存

  • B 这个内存需要一直保留,函数返回也不释放

  • C 内存的申请者和释放者不在同一函数里

  • D 内存可能很大,栈空间不够

答案

1=>B 2=>D

指针数组与数组指针

image-20211210154222398

指针数组

指针数组是一个数组,它的每个元素都是指针,这个指针可以是任何类型

当然,也可以是指向另一个数组的指针(可以仿造出类似java中的不等长数组)

  1. int* p[9]; // p 是含有9个元素的数组,每个元素类型为 int*
  2. p[0] = new int[1]; // 第一个指针指向:一个元素的数组
  3. p[1] = new int[2]; // 第二个指针指向:二个元素的数组

【示例】用指针数组,保存如下的三角矩阵:

章节一:信息学竞赛 CPP 基础篇 - 图58

这只是用于教学的例子,当只有这么少的数据的时候,没有必要大动干戈

这里假设我们锱铢必较,一定要节省每一个字节的内存

  1. int** get_table()
  2. {
  3. int **p = new int* [9+1]; //多分配一个,末尾存NULL
  4. for(int j=0; j<9; j++){
  5. p[j] = new int[j+1+1]; //多分配一个,开始存个数
  6. p[j][0] = j+1;
  7. for(int i=1; i<j+1+1; i++) p[j][i] = i*(j+1);
  8. }
  9. p[9] = NULL;
  10. return p;
  11. }

注意: 当我们返回给调用者一个动态申请的内存时,需要想办法告诉它内存的大小信息。

因为,返回的指针本身不携带任何额外信息。

调用方,用完如何释放

  1. int** p = get_table();
  2. cout << (p[4])[0] << endl;
  3. for(int** q=p; *q; q++) delete [] *q;
  4. delete [] p;

数组指针

相对而言,数组指针用的比较少。

它的定义形如: int (*p)[5]

它的类型是: int (*)[5]

这里,使用括号,是因为:[ ]的优先级高

用途

可以用于,指向二维数组中的第二维数组;

它本身携带了宽度信息(不是指针携带,是指针类型携带,这个类型是给编译器看的)。

但,鉴于它==怪异==的类型,一般初学者不喜欢,宁可多传递一个宽度信息。

【示例】修改二维数组每行的第一个数

  1. //n 是二维数组的高度信息,宽度信息在指针类型中携带了
  2. void f(int (*p)[3], int n)
  3. {
  4. for(int i=0; i<n; i++){
  5. p[i][0] *= p[i][0];
  6. }
  7. }

调用方:

  1. int a[3][3] = {{1,2,3},{4,5,6},{7,8,9}};
  2. f(a,3);
  3. cout << a[2][0];

其实,int (*p)[5]int p[][5] 是等价的。

int p[100][5]也是等价的,100被编译器忽略了

自己动手替换一下试试看。

实时效果反馈

1.关于指针数组,正确的说法是__

  • A 指针数组是说:一个指针,可以当数组用

  • B 指针数组是说:一个指针,指向了数组

  • C 指针数组是一个装满了指针的数组

  • D 指针数组是一个二维数组

2. 当作为形参时,下列哪个类型定义是数组指针?

  • A int** p

  • B char (p)[5]

  • C char* p[5]

  • D (int*) p[5]

答案

1=>C 2=>B

const 修饰符

image-20211210154546356

修饰的花样

const 可以放在被修饰类型的左边或右边,默认是向左结合的

  • 当修饰普通变量的时候
  1. const int M = 5;
  2. int const N = 6; // 本来这个是标准写法,但看着别扭
  • 当修饰数组的时候:

数组名本身就是read-only的,其含义只能是修饰元素

  1. const int a[] = {5}; //修饰的是 int
  2. int const b[] = {6}; //同上,修饰 int
  3. b[0]++; //会被编译器阻止
  • 当修饰指针的时候,就复杂了
  1. int x = 5;
  2. const int * p = &x; //修饰int, 元素不能变,也就是保护x
  3. int const * q = &x; //同上
  4. int * const r = &x; //修饰的是指针,r将来不能指向别人,但x可以改
  • 甚至可以用多个const

    1. int const * const p = &x; // 指针,被指向的东西都不能改

修饰的目的

修饰,为防止意外的修改。相当于”加锁“,

  • 防止他人修改的企图

  • 防止自己无意修改

来看串拷贝的定义:

  1. void StringCopy(char* strDestination, const char *strSource);
  2. // 加const, 防止拷贝函数把源数据破坏了

修饰符在函数中使用

可以作用于参数,也可以作用于返回值。

  • 当是简单类型的时候,修饰没有意义

    1. void f(const int a){ .... }
    2. ....
    3. int x = 10;
    4. f(x); // 函数的执行过程: 创建形参,实参拷贝到形参
  • 演示:函数调用过程

    1. void f(x, y){ .... }
    2. ...
    3. f(a, b);

image-20211210164140845

  • 当有指针的时候,修饰指针本身没意义,修饰被指向的元素有意义

    1. void f(int* const p); // 这个p是复制过来的,改了又如何?
    2. void g(const int* p); // p指向的内容是东家的数据,我能动吗?
  • 当为引用的时候,只能修饰被指向的元素(与指针同理)

  • 返回值为指针时,很少用

    强制接收方用const,但“事不关己,高高挂起”

修饰原则

当设计函数的时候,能使用const尽量使用,避免意外,增强健壮性

实时效果反馈

1.以const修饰变量的目的,哪个是==错误==的?

  • A 防止自己无意间修改了重要数据

  • B 防止别的函数修改我的数据

  • C 强制常数在定义时必须给定一个值,且将来不变

  • D 表示变量常驻内存,不会销毁

2. 当修饰函数形参时,哪个是有意义的写法?

  • A f(const int a)

  • B f(const int *a)

  • C f(const int const *a)

  • D f(int& const a)

答案

1=>D 2=>B

break,continue,return

image-20211210204956518

它们都是流程控制的语句,还有goto

break, continue, 只能作用于最内层的循环(break还可以跳出switch分支)

return 可以跳出多层循环

恰当使用,可以使代码简洁

【示例】100以内的素数

  1. for(int n=2; n<100; n++){
  2. bool is_prime = true;
  3. for(int i=2; i<n; i++){
  4. if(n % i == 0){
  5. is_prime = false;
  6. break;
  7. }
  8. }
  9. if(!is_prime) continue;
  10. cout << n << " ";
  11. //if(is_prime) cout << n << " ";
  12. }
  13. cout << endl;

试着按下方要求,改变代码的表述方式

  • if( xxx == true) 有问题不?
  • 已经发现了 is_prime为 false, 还有必要再下一个 i 吗?
  • 可以在 打印 n 之前,不用 if 吗?
  • 可以去掉 is_prime 变量不?
  • 可以不用 break 不?

可以通过定义函数来避免复杂度高

  1. bool is_prime(int x)
  2. {
  3. for(int i=2; i<x; i++) if(x % i == 0) return false;
  4. return true;
  5. }
  6. int main()
  7. {
  8. for(int n=2; n<100; n++)
  9. if(is_prime(n)) cout << n << " ";
  10. cout << endl;
  11. return 0;
  12. }

用 goto 也可以简洁:

  1. for(int n=2; n<100; n++){
  2. for(int i=2; i<n; i++) if(n % i == 0) goto noprint;
  3. cout << n << " ";
  4. noprint: ;
  5. }

注意

goto 是流程控制最强语句,但要慎用

goto本身无好坏,关键看使用者

实时效果反馈

1.以下说法,正确的是__

  • A break 只能用于循环语句

  • B c++的break可以跳出多重循环

  • C return语句只能跳出本层循环

  • D return语句可以跳出多层循环

2. 下列关于goto说法正确的是__

  • A c++99标准禁止使用 goto 语句

  • B goto语句是一种结构化设计语句

  • C goto语句可以仿真出循环语句效果

  • D goto语句可以提高程序可读性

答案

1=>D 2=>C

数学运算

image-20211211065813408

头文件

c 的传统是包含 math.h,c++是完全兼容 c 的,这个当然可以

c++的标准写法是包含 cmath(注意,没有.h),这个与math.h基本相同,

但,放在std名字空间下,需要 using namespace std; 才方便

常用函数表

函数 功能 备注
double sin(double 弧度) 正弦值 不是角度
double cos(double 弧度) 余弦
double tan(double 弧度) 正切
asin, acos, atan 反三角函数
double exp(double x) 求e指数:${e^x}$
double log(double x) 自然对数 ${log_ex}$ 底不是10
double log10(double x) ${log_{10}x}$
double power(double a, double b) ${a^b}$ b=0.5时,可以代替sqrt
double sqrt(double x) 平方根 ${\sqrt{x}}$
double ceil(double x) 向上取整 返回是double,注意负数情形
double floor(double x) 向下取整
double min(double x, double y) 取最小值
double max(double x, double y) 取最大值
int abs(int x) 绝对值
double fabs(double x) 浮点数的绝对值
void srand(unsigned seed) 初始化随机数生成器 一般用时间做种子seed
int rand() 产生一个伪随机数 只要种子相同,每次序列是一样的

【示例】求三角面积

  1. 已知了三个边长,用海伦定理
    海伦公式:${S = \sqrt{p(p-a)(p-b)(p-c)}}$ , 其中,${p=\frac{a+b+c}{2}}$

  2. 已知了三个顶点的坐标,用行列式 $$ S = \frac{1}{2} \begin{vmatrix} x_1 & y_1 & 1\ x_2 & y_2 & 1\ x_3 & y_3 & 1 \end{vmatrix} $$ 实际上,可以把第三个点坐标设为(0,0),其它两点取相对它的坐标即可: $$ S = \frac{1}{2} \begin{vmatrix} x_1 & y_1 & 1\ x_2 & y_2 & 1\ 0 & 0 & 1 \end{vmatrix} = \frac{1}{2}(x_1y_2 - x_2y_1) $$

  1. double a[3][2] = {{2,4},{5,2},{1,1}};
  2. for(int i=0; i<3; i++){
  3. a[i][0] -= a[2][0];
  4. a[i][1] -= a[2][1];
  5. }
  6. double s = fabs(a[0][0]*a[1][1] - a[0][1]*a[1][0]) * 0.5;

随机数

rand() 产生 0 ~ RAND_MAX 之间的一个伪随机整数

这个序列每次都一样,这样==方便测试==

当需要不一样的时候,可以用初始化==随机种子==

srand(time(NULL))

【示例】洗牌动作

  1. void shuffle(int* begin, int* end)
  2. {
  3. int n = end - begin;
  4. srand(time(NULL));
  5. for(int i=0; i<100; i++){
  6. int a = rand() % n;
  7. int b = rand() % n;
  8. {int t=begin[a]; begin[a]=begin[b]; begin[b] = t;}
  9. }
  10. }

实时效果反馈

1.floor(x)函数与(int)强制的区别,说法==错误==的是__

  • A floor 返回的是浮点数,(int)强制的结果是整数

  • B floor 是向着数轴左边取整,(int)强制是向着原点取整。对负数不同。

  • C floor是标准库的函数,(int)是运算符

  • D floor是c++独有的,(int)在c,c++中都可以用

2. 关于伪随机数序列,说法正确的是__

  • A 当随机种子相同时,伪随机序列在不同的机器上不同

  • B 伪随机序列是机器模拟掷骰子产生的

  • C 和自然随机数比,伪随机序列产生的随机数不均匀

  • D 使用相同的种子,总是得到相同的伪随机序列

答案

1=>D 2=>D

表达式的特性

image-20211207070254393

==表达式==可以做的事情,函数也能做。

但,为了简洁

a = 2 + 3; vs. a = myadd(2,3);

一个表达式==必须==要返回一个值

一个表达式也可能会==改变==变量的值

  1. int a = 5; // 这是语句
  2. // 因为你不能这么写:
  3. x = (int a = 5);

= 是运算符,因而,a = b是表达式

  1. a = b; // 这是表达式
  2. // 因为你可以这么写:
  3. x = (a = b);
  4. // = 是右结合,所以,括号可以省略
  5. x = a = b;

章节一:信息学竞赛 CPP 基础篇 - 图64

  1. if(a = b) { .... } // 这是什么含义
  2. // 1. 把 b 的值赋给 a,如果这个值不是 0, 就。。。。
  3. // 2. 也很可能是想写 if(a==b) 笔误了

当心!

不小心把 == 误写为 =,就会掉入这个坑。

?:是运算符,而 if … else 是语句

  1. x = a > 3 ? 0 : 1 // 与下边的等价:
  2. if(a > b)
  3. x = 0;
  4. else
  5. x = 1;
  6. // 但是你无法这样写:
  7. x = if(a>3) 0 else 1;

++ — 运算符的特殊性

前加,后加的概念

  1. x = ++y; // 含义是:
  2. y += 1;
  3. x = y;
  1. x = y++ // 含义是:
  2. y0 = y;
  3. y = y + 1;
  4. x = y0;

++是试金石,它直击表达式的两个要害处:副作用和返回值

++ — 的优先级很高,与 * !& 是同级别的,并且右结合

所以,*p++的意思是:*(p++)

惯用法

p ,q 都是指针

找到第一个正数:

  1. while(*p <= 0) ++p;

把 p 位置的内容拷贝到 q 位置,直到碰上了负数

  1. while(*p >= 0) *q++ = *p++;

把 p 位置的内容拷贝到 q 位置,直到 p 指针撞墙

  1. // end 也是指针,放在p的必经之路,阻止p的前行
  2. while(p < end) *q++ = *p++;

把 p 位置的串拷贝到 q 位置

  1. while(*q++ = *p++);

这个很巧妙,最后一个结束符’\0’,恰好就是0,恰好就是 false

实时效果反馈

1. 相比于:*x=*y; x+=1; y+=1;x++ = y++; 的优点是:

  • A 功能更强大

  • B 效率更高,机器算得快

  • C 更简洁,增加可读性

  • D 更酷炫,适合装x

2. 下列代码后,x 的值是__

  1. int x=0; x = x++ + x++;
  • A 0

  • B 1

  • C 2

  • D 3

答案

1=>C 2=>B

按位运算

image-20211207090029207

性别,开关,已完成/未完成,都可以表示为布尔量 true/false

许多布尔量,可以用数组表示,char* ? 太浪费。

'A' 的ASCII码是:65,就是二进制的:01000001,它可以表示8个布尔量,第2,8是true,其余false

c++的位运算表

运算符 含义 举例
& 按位与 3 & 5 = 1 (011 & 101 = 001)
\ 按位或 3 \ 5 = 7 (011 \ 101 = 111)
^ 按位异或 3 ^ 5 = 6 (011 ^ 101 = 110)
~ 逐位取反 ~3 = -4 (~0000000000000011 = 1111111111111100)
<< 向左移位,右补零 3<<1 = 6 (011<<1 = 110) 相当于乘以2
>> 向右移位,补位当心! unsigned 类型补零,有符号数则补符号位
3>>1 = 1 (011>>1 = 001)
-1 >> 1 = -1 (按有符号计算)

有符号和无符号的移位区别

  1. char a = 0xff;
  2. a >>= 1; // 此时 a 内值为: 0xff
  3. unsigned char b = 0xff;
  4. b >>= 1; // 此时 b 内值为: 0x7f

实用技巧

  1. 奇偶性判断

    1. if(n % 2) ...; //如果是奇数
    2. if(n & 1) ...; //如果是奇数
  2. 交换两个数(不借助临时变量)

    1. int a = 5, b = 8;
    2. a = a ^ b;
    3. b = b ^ a;
    4. a = a ^ b;
  3. p, q, r 是三个指针,把其中的两个指针通过x ,y传入,需要计算出第三个指针

    1. int n = p ^ q ^ r;
    2. void* s = (void*) (n ^ x ^ y);

按位存储bool数组

  1. // 把第n位设置为x
  2. void set_nth(unsigned char* data, int n, bool x)
  3. {
  4. int a = n / 8, b = n % 8;
  5. if(x)
  6. data[a] |= 0x80 >> b; //目标位为1,其它为0
  7. else
  8. data[a] &= ~(0x80 >> b); //目标位为0,其它位为1
  9. }
  10. // 取得第n位的值(0或1)
  11. bool get_nth(unsigned char* data, int n)
  12. {
  13. int a = n / 8, b = n % 8;
  14. return data[a] & (0x80 >> b);
  15. }
  16. // 主调方需要准备bool数组的存储空间
  17. int main()
  18. {
  19. unsigned char data[20];
  20. set_nth(data, 100, true);
  21. cout << get_nth(data, 100) << endl;
  22. return 0;
  23. }

实时效果反馈

1. 关于移位运算符,说法正确的是:__

  • A >> 在负数和无符号数上表现有差异,<< 不会

  • B >> << 只能对整数运算,无法用于字符型

  • C 对于 unsigned 类型 >> 运算相当于乘以 2

  • D >> 运算符总是在左边补零

2. x ^ y ^ x ^ y ^ x 的结果是:__

  • A x

  • B y

  • C x ^ y

  • D y ^ x

答案

1=>A 2=>A

数据类型转换

image-20211207101759479

静态类型意味着:

  1. 变量类型自始至终不变

  2. 在程序执行前,编译器就知道变量的类型

类型决定了:能对变量进行哪些操作

弱类型

相对于强类型而言,弱类型不拘束于类型

可以把 A 类型的东西硬当成 B 类型来用

有什么用处?

【示例】 请问 int a = -25, 在内存中怎么存的?

  1. int a = -25;
  2. unsigned char* p = (unsigned char *)&a;
  3. cout << hex << (int)p[0] << endl;
  4. cout << hex << (int)p[1] << endl;
  5. cout << hex << (int)p[2] << endl;
  6. cout << hex << (int)p[3] << endl;

可以观察到:

  • 一个int, 硬是当成 4 个unsigned char 来看待
  • char 的默认输出是打印字符的对应字形,我们想看的是ascii值,强转为整数
  • windows上的字节序:低地址,低字节(Little Endian)
  • 负数的补码表示:符号位为1,其它位取反,再加1

类型自动转换

有些类型间的转换危险较小,为了方便,可以自动转换

  1. char -> short -> int -> long -> long long -> float -> double
  • 小类型向大类型转的时候,可以自动转换
  • 整数常数不加说明,默认为 int

类型强制转换

  1. 浮点数的取整

    • (int)x 会丢弃零头部分,只保留整数部分
    • 对负数要注意,是向着原点方向取整的

    • 更科学系统的方案是math.h中的,ceil floor

    • 四舍五入 +0.5

  2. 指针类型的强制转换

    • 任何类型的指针,本质都是一个整数而已,物理上都能转换
    • void p 类型只是限制了 p 这样的动作,与其它指针无异

    • 把某种类型的指针,转为 void* 会丢失类型信息,但实际上没进行任何其它动作

      1. // 指针的本质就是一个整数,
      2. // 只不过,观念上,编译器认为它是某块数据的地址
      3. void *p = (void *)1234567;
      4. cout << p << endl;
      5. cout << p+1 << endl;

实时效果反馈

1. 有什么简单方法能知道数据在内存的二进制表示:__

  • A 把数据序列化到硬盘文件上

  • B 查语言手册,了解基本类型如何存储

  • C 使用黑客工具,探测其它程序的内存图景

  • D 数据区首地址强制为:unsigned char* 指针,逐字节输出

2. c++中的类型转换,说法正确的是:__

  • A 当表达式计算时,int,double可以相互自动转换,其它类型不可。

  • B char 可以自动转为 int, 但不能自动转为 double类型

  • C char 强制转为 int 不可能出现负数

  • D int 强制转 unsigned char 会舍弃 3 个高字节,只保留低字节

答案

1=>D 2=>D

c串入门

  • char* 是串吗?
  1. char a = 'A';
  2. char* p = &a; // p 与 int* 性质相同,本身并不是串
  3. char b[] = {'A', 0}; // b 是串,因为它所指向的区域,以0为结束符
  4. char c[] = {'A', 0, 'B', 'C'}; // c是串,且与b同

我们看到:串是:char* + 零结尾的约定

  • 串的存储类型?栈?堆?静态?
  1. char a[] = {'A','B','C','\0'}; // 串在栈中
  2. char* b = new char[4];
  3. b[0] = 'A'; b[1] = 'B'; b[2]='C'; b[3] = '\0'; //串在堆中
  4. char* c = "ABC"; //串在只读区
  5. char d[] = "ABC"; // 串有两份,只读区,且拷贝到了栈中
  • 零的 3 种形态

0, 暗示为 int 类型

‘\0’, 暗示为 char 类型

NULL, 暗示为 * 类型

  • 空串可不是空指针
  1. char* a = "";
  2. char* b = NULL;

image-20211213084533477

串的赋值

串的赋值,并没有拷贝串的内容,只是拷贝了==指针==!!!

向函数中传递串,传递的是指针,不是串的拷贝

两根绳子牵着同一宠物

  1. char* p = ....;
  2. char* q = p; // 没有拷贝串的内容,只是复制了指针

image-20211213082627940

参数在函数间传递就是这个效果,实参拷贝给形参

串的拷贝

自己写的拷贝:

  1. while(*dst++ = *src++);

不够严谨,不能支持 overlap情形

使用标准库string.h中的:strcpy,试试,依然有问题!!

  1. char a[6] = "ABCD"; //多分配了一个冗余空间,为了...
  2. strcpy(a+1,a); //重叠拷贝
  3. cout << a << endl;

image-20211213081647677

解决方案:

  • 自己写个mycopy,根据情况确定拷贝次序
  • 调用标准库的 memcpy(目的, 源, 拷多少个字节 )
  • 调用标准库的 memcpy(目的, 源, 拷多少个字节 )
  • 串的长度用:strlen() 求出

注意,需要 include

或者:

实时效果反馈

1. 关于c串的说法,正确的是:__

  • A 任何 char* 类型都是合法的 c 串,都可以安全调用string.h的函数

  • B c串的第一个字符存储了串的长度信息

  • C 空串就是NULL,不占用存储空间

  • D c 串的实质是:char*类型加上’\0’结束的约定

2. 把串传递给函数,又害怕函数改变串的内容,怎么办?:__

  • A 形参类型标记为:char * const

  • B 形参类型标记为:char const *

  • C 告诉写函数的那个家伙小心点,别把传进来的串碰坏了。

  • D 形参标记为:char* const &

答案

1=> D 2=>B

进制问题

image-20211213112218648

进制问题只存在于数字的显示,而不是数字的存储(存储总是2进制)

数值本身总是用二进制的形式存储的

任意进制的转换问题

数 ==> 串

串 ==> 数

正整数的十进制显示

  1. void itodec(char* buf, int x);
  2. int main()
  3. {
  4. char buf[100]; // 主调方负责提供足够的空间
  5. itodec(buf, 5301); //被调方负责往 buf 中写
  6. cout << buf << endl;
  7. return 0;
  8. }

image-20211213110155940

正整数的2进制,16进制

10进制简单,10 => 2

16代码稍加改变即可:

  1. void itobin(char* buf, int x)
  2. {
  3. char* p = buf;
  4. do{
  5. *p++ = (x % 2) + '0';
  6. x /= 2;
  7. }while(x>0);
  8. *p = '\0';
  9. reverse(buf, p);
  10. }
  11. void itohex(char* buf, int x)
  12. {
  13. char* p = buf;
  14. do{
  15. int t = x % 16;
  16. *p++ = t<10? t + '0' : t-10+'A';
  17. x /= 16;
  18. }while(x>0);
  19. *p = '\0';
  20. reverse(buf, p);
  21. }

10进制数字串转为int

  1. int dectoi(char* p)
  2. {
  3. int x = 0;
  4. while(*p){
  5. x = x * 10 + (*p-'0');
  6. p++;
  7. }
  8. return x;
  9. }

实时效果反馈

1. 关于进制,说法正确的是:__

  • A 计算机内部都是用10进制表示,显示的时候,有时需要转为16进制等

  • B 计算机内部都是2进制表示的,显示的时候,可以转化为要求的进制形式

  • C 计算机内部记录了一个数是哪种进制格式

  • D 用2进制表示是因为运算快,16则显示的更短

2. 要把一个3进制表示的串,转换为7进制表示,怎么办?:__

  • A 3 进制串 ==> int ==> 7进制串

  • B 把3进制串的每一位翻译为7进制,然后拼起来

  • C 3进制先转为10进制串,再转为7进制串

  • D 3进制先转为2进制串,再转为7进制串

答案

1=> B 2=>A

加密解密入门

image-20211214112817910

明文 + 密钥 => 密文

密文 + 密钥 => 明文

最简单的加密法

  • 循环移位法

    字母平移,其它符号不动

    1. void encode(char* p, int k)
    2. {
    3. while(*p){
    4. if(*p >= 'a' && *p <= 'z'){
    5. *p = (*p - 'a' + 26 + k) % 26 + 'a';
    6. }
    7. p++;
    8. }
    9. }
    10. int main()
    11. {
    12. char buf[] = "I love beijing tian an men";
    13. encode(buf, 5);
    14. cout << buf << endl;
    15. encode(buf, -5);
    16. cout << buf << endl;
    17. return 0;
    18. }

    只要一个encode就可以了,同时用于加密和解密

  • 异或法

    1. void encode(char* buf, const char* key, int n)
    2. {
    3. for(int i=0; i<n; i++) buf[i] ^= key[i];
    4. }
    5. int main()
    6. {
    7. char buf[30] = "I love beijing tian an men";
    8. const char* key = "@#$#$%##$%#^$%%%3@#$@$@#$#$$#@#@$@$@#$RTERTE";
    9. encode(buf, key, 30);
    10. buf[30-1] = 0; //封口
    11. cout << buf << endl;
    12. encode(buf, key, 30);
    13. buf[30-1] = 0;
    14. cout << buf << endl;
    15. return 0;
    16. }

image-20211214120547923

数字签名

也称为数字指纹。正式名字是:==摘要==

目的是:防止明文数据被篡改

明文 ==> 哈希算法 ==> 固定长度的摘要(比如32字节)

从摘要不能反推出原文(不是有没有钥匙的问题)

保存 用户名+密码 的另一方案:只保存摘要信息

将来用户登录时,用同样的算法计算摘要,与保存值比对。。。。

流行的摘要算法:

MD5, 较老

SHA 系列: sha1,sha2,sha3

算法 摘要长度 最大文件长度
MD5 128(16字节) 无限
SHA-1 160(20字节) ${2^{64}-1}$
SHA-256 256 (32字节) ${2^{64}-1}$
SHA-512 512 (64字节) ${2^{128}-1}$
SHA3 224~512,也可任意长度 无限

【示例】 网上又源文件:sha512.c, sha512.h

把 .h 拷贝到 main.cpp 目录, .c 文件直接复制到 main.cpp替换即可

需要包含头文件 #include "SHA512.h"

注意用双引号,不是尖括号,表示相对路径,尖括号为:系统路径

拓展

章节一:信息学竞赛 CPP 基础篇 - 图74

正式环境,使用 DES, AES 更安全可靠,缺点是:需要传递==公共的密钥==

RSA 不需要传递密钥,==公钥 + 私钥== 体系,缺点是:计算慢

一般的策略是:先RSA传递公共密钥,再 AES

https:// 就是这么干的

实时效果反馈

1. 关于异或加密,说法==不正确==的是:__

  • A 很容易被破解

  • B 原理简单,加密、解密速度快

  • C 如果用密钥对密钥本身加密,会得到二进制全 0 信息

  • D 如果用密钥对密钥本身加密,会得到二进制全 1 信息

2. 害怕密钥在传递时被窃听,该怎么办?:__

  • A 双方见面时约好密钥,100年不变

  • B 把密钥用 MD5 加密传递

  • C 通知对方用某个公共事件做密钥(比如某个股票的收盘价)

  • D 把该密钥用 RSA 加密传递

答案

1=> D 2=>D

c串的库函数

image-20211214151305044

  • 求长度: strlen(s)

  • 查找字符

  1. char buf[] = "abcde";
  2. cout << strchr(buf,'c') << endl;
  3. cout << (char*)memchr(buf, 'c', 6) << endl;

还有 strrchr(s, c) 是从后往前找第一个

  • 查找子串
  1. char buf[] = "abccde";
  2. cout << strstr(buf, "cd") << endl;
  • 拼接
  1. char buf[100];
  2. const char* s1 = "abc";
  3. const char* s2 = "1234";
  4. strcpy(buf, s1);
  5. strcat(buf, s2);
  6. cout << buf << endl;

建议用strncat代替strcat,多传入一个长度限制 n

建议用strncpy代替strcpy,多传入一个长度限制n

  • 比较
  1. cout << strcmp("abc","ABC") << endl;

相同,则返回值为0,第一个大,返回正数,否则负数

建议用 strncmp 代替strcmp,多传入一个长度限制n

  • mem** 类的

memchr 类似 strchr,但忽略’\0’,传入长度 n

memcmp 类似 strcmp,传入n, 当成两个字符数组比较

memcpy 类似 strcpy, 但通过长度 n 控制拷贝个数

memmove 类似 memcpy,但可以正确地处理==重叠区域==的问题

memset 用一个字符填充一块内存区域

  • 大小写转换

    自己写吧,很简单

    1. void to_lower(char* s)
    2. {
    3. while(*s){
    4. if(*s >= 'A' && *s<= 'Z') *s += 32;
    5. s++;
    6. }
    7. }
    8. void to_upper(char* s)
    9. {
    10. while(*s){
    11. if(*s >= 'a' && *s<= 'z') *s -= 32;
    12. s++;
    13. }
    14. }

小试牛刀

【示例】

给定一个绝对路径表示的文件名,把它分为4个部分:盘符,路径,文件基名,扩展名

要仔细考虑各种可能的情况

  1. bool splitpath(const char* s, char* buf, char**path,
  2. char** base, char** ext);

内存惯例职责的设计:

主调方先提供一个足够大的 buf, 并传入待分解的 s

被调方,把分解好的各个部分都存到 buf中,其中盘符就存在buf起始位置,

每个部分都有 ‘\0’结束符

后3部分的首地址通过形参来返回

希望的使用方法:

  1. char* s = "c:\\abc\\my\\path\\zhansan.back.txt";
  2. char buf[300];
  3. char* path;
  4. char* base;
  5. char* ext;
  6. splitpath(s, buf, &path, &base, &ext);
  7. cout << buf << endl;
  8. cout << path << endl;
  9. cout << base << endl;
  10. cout << ext << endl;

image-20211214200343600

  1. bool splitpath(const char* s, char* buf, char**path,
  2. char** base, char** ext)
  3. {
  4. char* p = strchr(s,':');
  5. if(p==NULL) return false;
  6. memcpy(buf, s, p-s);
  7. *path = buf + (p-s);
  8. *(*path)++ = '\0';
  9. char* q = strrchr(s,'\\');
  10. if(q==NULL) return false;
  11. memcpy(*path, p+1, q-p);
  12. *base = *path + (q-p);
  13. *(*base)++ = '\0';
  14. char* r = strrchr(s,'.');
  15. if(r==NULL) r = (char*)s + strlen(s);
  16. memcpy(*base, q+1, r-q-1);
  17. *ext = *base + (r-q-1);
  18. *(*ext)++ = '\0';
  19. if(*r)
  20. strcpy(*ext, r+1);
  21. else
  22. *ext = '\0';
  23. return true;
  24. }

这只是个初步的版本,还有很多细节:

比如,文件名是: abc.

文件名是 .ttt

文件名是 ..

文件名里没有 . 但路径名里有 path.xy

。。。各种极端的情况,尽量往变态了想。。。

实时效果反馈

1. 对string.h中的函数,为什么建议用带“n”的版本,比如strncpy ?__

  • A 带n的是新标准,不带n的是老函数

  • B 带n的执行效率更高

  • C 带n的更节约内存

  • D 带n的版本可以防止内存访问越界,更安全

2. 如果希望函数返回3个指针,怎么办比较恰当 ?:__

  • A 把 3 个指针放在一个数组里,通过 return 返回

  • B 传递这 3 个指针的地址给函数

  • C 传递这 3 个指针给函数

  • D 让函数返回多个指针类型的值

答案

1=>D 2=>B

c风格输入与输出

image-20211215070513745

在c++中,使用了面向对象的 cout, cin 来支持标准输入,输出。

它们是==对象类型==敏感的,会根据类型自动决定用什么样的格式。

与之相反,c风格的输入输出提供了最原始,最直接的控制方式,

需要我们手动指定:==格式==

printf

printf 把 c 语言的弱类型特点体现得淋漓尽致

数据是什么类型不重要,重要的是:你认为它是什么类型,或你把它==当作==什么类型

支持可变长度参数列表,格式串丰富强大,与后边提供的参数==数目要吻合==

  1. int printf(const char * format, ...);

格式串是在==编译==阶段解析的,如果不合理,编译器会警告或阻止你

  • 最基本的控制符号:
  1. int a = 97;
  2. printf("%d %X %c \n", a, a, a); // 97 61 a
  3. const char* p = "abc";
  4. printf("%s, %d, %x, %p\n", p, p, p, p);
  5. //abc, 4231269, 409065, 00409065
符号 含义 补充说明
%d 有符号整数
%u 无符号整数
%f 浮点数 一般用%lf 对应于double,实际上float已经被废弃
%e 科学计数法 也可以 E
%s c格式’\0’结尾的串 如果串没有正确结尾,不提供任何安全保障
%c 单个字符
%p 指针地址形式 十六进制的,4字节,前补零
%x 十六进制 x,X 可以控制大小写
%lld long long 有符号 long long 型数据用 %d会截断
%llu long long 无符号
  • 更丰富、细腻的格式控制

    %[标志] [宽度] [.精度] 类型 方括号中的表示可有可无

    1. double a = 125.34567;
    2. printf("%f, %9.1f, %-9.1f, %09.1f \n", a, a, a, a);

章节一:信息学竞赛 CPP 基础篇 - 图78

scanf

  1. int scanf(const char* format, ...);
  2. // 格式与prinf类似
  3. // 返回成功读取的项数

c/c++语言的惯用方案:主调方提供内存管理,把指针传给被调方

  • 读入两个整数,求和

    1. printf("please input 2 int: ");
    2. int a, b;
    3. scanf("%d%d", &a, &b);
    4. printf("%d + %d = %d\n", a, b, a+b);
  • 读入串

    1. char buf[100];
    2. printf("your name? ");
    3. scanf("%s", buf);
    4. printf("hello, %s\n", buf);

注意,%s 格式遇到空白就会结束,

因为空白字符(空格,回车,换行,tab)是默认分隔符

  • 使用扫描字符集

    %[….] 可以指定需要的字符集,属于这个集,则读入buf,不属于则中止,并写’\0’

    1. scanf("%[a-z]", buf);

    只认小写字母,遇到其它的结束

    1. scanf("%[a-z ]", buf); //注意方括号里有个空格

    只认小写和空格

    1. scanf("%[^\n]", buf);

    除了回车,啥都认(解决了前边不能输入空格的问题)

章节一:信息学竞赛 CPP 基础篇 - 图79

实时效果反馈

1. 关于printf,说法正确的是:__

  • A printf 格式串中格式数量与后边提供的参数个数不同,则编译器不通过

  • B prinf 格式串中使用了错误的格式,到运行时才会发现

  • C 我们提供的格式串中的类型,可以与对应的实际变量的类型不同

  • D 除了格式串,printf 必须至少提供一个要输出的数据项

2. 用scanf输入串时,遇到空格就结束,下列办法,哪个是==错的==:__

  • A 换用%[^\n]做格式串

  • B 用 gets() 一次读取整行

  • C 用 %c 一次读一个字符,套上循环使用

  • D 找编译选项,让编译器忽略空格

答案

1=>C 2=>D

格式控制

image-20211215115538790

sprintf

  1. int sprintf(char *buf, const char *format, ...)

与printf类似,只是把结果写入到一个缓冲区。

缓冲区由主调方提供,并保证其空间足够。

返回的整数表示:成功写入了多少字节(不包括’\0’)

【示例】输出杨辉三角形

章节一:信息学竞赛 CPP 基础篇 - 图81

首先忽略格式,看规律:

1 1 1 1 2 1 1 3 3 1 1 4 6 4 1 1 5 10 10 5 1 下一行的等于上一行当前位置 + 前一个位置,末尾补 1

  1. int a[20];
  2. int k = 0; // 当前有效数据个数
  3. for(int j=0; j<10; j++){
  4. int t0 = 0, t;
  5. for(int i=0; i<k; i++){
  6. t = a[i]; //实现 a[i] = a[i] + a[i-1]
  7. a[i] += t0;
  8. t0 = t;
  9. }
  10. a[k++] = 1;
  11. show(a,k);
  12. }

show最开始版本

  1. void show(int* a, int k)
  2. {
  3. for(int i=0; i<k; i++){
  4. printf("%3d ", a[i]);
  5. }
  6. printf("\n");
  7. }

先到串,然后一次输出

  1. void show(int* a, int k)
  2. {
  3. char buf[200];
  4. char* p = buf;
  5. for(int i=0; i<k; i++){
  6. p += sprintf(p, "%3d ", a[i]);
  7. }
  8. printf("%s\n", buf);
  9. }

最终:

  1. void show(int* a, int k)
  2. {
  3. const int w = 50;
  4. char buf[200];
  5. char* p = buf;
  6. for(int i=0; i<k; i++){
  7. p += sprintf(p, "%3d ", a[i]);
  8. }
  9. int sp = (w - strlen(buf)) / 2;
  10. printf("%*s%s\n", sp, "", buf);
  11. }

sscanf

与scanf类似,只是从字符串中读取

可用于把数字串,转为数字值。

  1. const char* a = "125.67";
  2. double d;
  3. sscanf(a, "%lf", &d);
  4. cout << d << endl;

实际上,简单的数字转换可以调用系统函数:

atoi atof

sscanf 适用于更复杂的局面中提取数值

比如:[min=15.2, max=28.0] 中提取最高,最低温度

  1. char* s = "[min=15.2, max=28.0]";
  2. double a,b;
  3. sscanf(s, "[min=%lf, max=%lf]", &a, &b);
  4. cout << a << " " << b << endl;

实时效果反馈

1. 关于 sprintf 的格式控制,说法正确的是:__

  • A printf中的有些格式,在sprintf中不能用

  • B 如果主调方提供的空间不足,sprintf 输出会在最大空间处停止

  • C sprintf 返回成功或者失败

  • D sprintf 可以支持一个变量作为宽度控制

2. 使用 sscanf 转换数据类型,有什么好处?:__

  • A 可以从更复杂的样式中提取信息

  • B 安全性更好,可以避免内存的非法访问

  • C 更加兼容于 c 风格

  • D 当数据格式错误时,可以更友好地处理

答案

1=>D 2=>A

文件分割与名字空间

image-20211215153242403

把源文件分为两个部分:

==头文件==和==体文件==

头文件中包含了:函数调用格式声明,常量声明,类型声明等

体文件则包含具体的实现细节

章节一:信息学竞赛 CPP 基础篇 - 图83

为什么要分开?

  • 源码保护,将来可以只提供 .o 文件和 .h 文件,不提供源程序
  • 分离功能与实现,隐藏细节(这是面向对象的基本思维),便于工程化管理

编译过程:

  1. xx.cpp(包含了 .h) ==> 生成 xx.o 文件
  2. x1.o + x2.o + 库 ===> 生成最终产品(可以是 exe, 可以是动态库等)
  3. 其中如果有 int main() 函数,只能有一个,这是程序的入口

【基础范例】

my.h : 定义了面积计算规范: get_area(double), get_area(double, double)

myc1.cpp 实现了 get_area(double) 这个可以计算圆的面积

myc2.cpp 实现了 get_area(double, double) 这个可以计算矩形的面积

main.cpp 包含了 my.h,就可以调用这两个函数

【qt操作】

项目名字上,右键 | 添加新文件

章节一:信息学竞赛 CPP 基础篇 - 图84

章节一:信息学竞赛 CPP 基础篇 - 图85

添加: my.h, myc1.cpp, myc2.cpp

拷贝如下代码:

my.h

  1. #ifndef MY_H
  2. #define MY_H
  3. // r: 半径,返回圆的面积
  4. double get_area(double r);
  5. // a, b: 矩形的长和宽,返回面积
  6. double get_area(double a, double b);
  7. #endif // MY_H

myc1.cpp

  1. #include "my.h"
  2. #include <math.h>
  3. double get_area(double r)
  4. {
  5. return M_PI * r * r;
  6. }

myc2.cpp

  1. #include "my.h"
  2. double get_area(double a, double b)
  3. {
  4. return a * b;
  5. }

main.cpp

  1. #include <iostream>
  2. #include <string.h>
  3. #include "my.h"
  4. using namespace std;
  5. int main()
  6. {
  7. cout << get_area(2.5) << endl;
  8. cout << get_area(5,6) << endl;
  9. return 0;
  10. }

使用名字空间

名字空间可以解决名字冲突的问题。

不同来源的源代码被分割在不同的名字空间里,便于管理。

  1. //.h 中
  2. namespace ggg{
  3. void f(int a, int b);
  4. ....
  5. }
  6. // .cpp 实现的时候
  7. void ggg::f(int a, int b) .....
  8. // 调用的时候
  9. ggg::f(2,3);
  10. // 可以省略 ggg::
  11. using namespace ggg;

实时效果反馈

1. 关于c++编译过程,说法正确的是:__

  • A .cpp文件先被编译为 .o 目标文件

  • B .h 文件被编译为 .o 目标文件

  • C .o 文件在链接时,需要相应.h 文件的存在

  • D .o 文件在链接时,需要.cpp 文件的存在

2. 为避免名字冲突,哪个办法不可行?:__

  • A 把名字放入不同的名字空间里,引用时需要 空间名::格式

  • B 把名字加上适当的前缀,如:gyh_getSomeThing

  • C 为各个开发小组规定名字定义的规范

  • D 使用匈牙利命名法 nLength, strDest, pMyBuf, …

答案

1=>A 2=>D

递归入门

image-20211216083428367

  • 递归是解决复杂问题的有效并且优美的手段之一

通俗的说法:函数自己调用自己

函数可以调用任何其它的函数,当然也可以包括自己。

递归 ~= 数学上==递推法==

把一个大问题,分解为==与自己相似==的规模更小的问题

【示例】阶乘

一句话定义:n的阶乘,就是 n-1的阶乘 * n

  1. long long fac(int x)
  2. {
  3. if(x==0) return 1;
  4. return fac(x-1) * x;
  5. }

【示例】斐波那契数列

一句话定义:后一项等于前2项之和

  1. long long fib(int x)
  2. {
  3. if(x<2) return 1;
  4. return fib(x-1) + fib(x-2);
  5. }

递归注意

  1. 递归一定要有出口

    在某种条件下,不再递归

    出口多不怕,怕漏

  2. 递归有可能很慢

    这是因为有重复计算问题

    如何避免重复计算? 动态规划或哈希表缓存

  3. 递归有可能引发 stackoverflower

    递归的深度要有个估计

    【示例】 从 1 到 n 求和

    1. int mysum(int x)
    2. {
    3. if(x<1) return 0;
    4. return mysum(x-1) + x;
    5. }

间接递归

有的时候,递归也很“委婉”

a 调 b, b 再调 a,形成间接递归

【示例】上台阶问题

n 层台阶,每步只能迈上 1 级或 2 级,要求用偶数步上完台阶。

共有多少种不同的方案?

章节一:信息学竞赛 CPP 基础篇 - 图87

可以扩展为两个问题:

n层台阶,奇数步完成 + n层台阶,偶数步完成

  1. int feven(int n); // 需要提前声明一下,与头文件原理类似
  2. // 奇数步完成
  3. int fodd(int n)
  4. {
  5. if(n<=0) return 0;
  6. return feven(n-1) + feven(n-2);
  7. }
  8. // 偶数步完成
  9. int feven(int n)
  10. {
  11. if(n<0) return 0;
  12. if(n==0) return 1;
  13. return fodd(n-1) + fodd(n-2);
  14. }

实时效果反馈

1. 关于递归出口的说法,正确的是:__

  • A 间接递归程序不需要写出口

  • B 出口多了容易死循环

  • C 只能写一个出口

  • D 必须提供至少一个出口(不再递归)

2. 对递归程序的弱点,说法==错误==的是:__

  • A 可读性不好,代码冗长

  • B 容易因为重复调用而耗时

  • C 递归深度太大,可能会栈溢出

  • D 忘记写出口,造成死循环,最终栈溢出

答案

1=>D 2=>A

递归的构建方法

image-20211216095049485

  1. 考虑相似性

    设法把一个问题分解出相似性来,这是硬功夫

  2. 试着增加参数

    如果实在弄不成形似问题,试着增加一些参数

  3. 考虑出口

    开始可以多考虑些出口,有冗余,保险点儿

    熟练后,可以提供精当出口

【示例1】组合数

从 m 个不同物体中,任意取 n 个,有多少种不同的取法?

image-20211216110503822

我们来个“无中生有”法,

假设其中有 1 个球很特殊,结果集被分割为 2 部分。

image-20211216110841392

  1. // m 个球中,取 n 个
  2. int qu(int m, int n)
  3. {
  4. if(n<0) return 0;
  5. if(m==n) return 1;
  6. return qu(m-1, n-1) + qu(m-1, n);
  7. }

【示例2】打印字母金字塔

章节一:信息学竞赛 CPP 基础篇 - 图91

这样设计,可否找到相似性?

void ta(int 层数);

仔细观察,ta(层数-1) 与 ta(层数)有哪个不同的地方?

  1. // sp: 塔底左边距,h:塔高
  2. void ta(int sp, int h)
  3. {
  4. if(h<=0) return;
  5. ta(sp+1, h-1);
  6. for(int i=0; i<sp; i++) printf(" ");
  7. for(int i=0; i<h; i++) printf("%c", 'A'+i);
  8. for(int i=h-1-1; i>=0; i--) printf("%c", 'A'+i);
  9. printf("\n");
  10. }

实时效果反馈

1. 设计递归程序的两个要点是:__

  • A 相似性和出口

  • B 相似性和返回值

  • C 出口和返回值

  • D 问题本身的可递推性,以及问题的规模

2. 设计递归时,如果找不到相似性,一般问题出在哪里?__

  • A 问题分解的不够小,继续分解试试

  • B 可能参数太少,增加参数试试

  • C 问题本身不能递归解法

  • D 脑袋进水,出去晒太阳再继续

答案

1=>A 2=>B

递归与循环的关系

image-20211216114640247

在逻辑层面,递归与循环是等价的。

任何循环形式,必然能用递归来表达。

面向函数的语言 haskell, erlang 等, 语言本身根本就没有循环语句,只能递归

循环改递归的练习

【示例1】打印 1~10 的数字

  1. for(int i=1; i<=10; i++) cout << i << endl;

抽象为:打印 从 begin 到 end 间的所有数字

递归的秘诀:==踢皮球==

不要自己揽活儿,想办法把工作分配出去!

我做一点点 + 大部分 交给别人做

【示例2】 模仿 strlen()

请放弃循环,且代码不要超过 2 行

【示例3】 模仿 strstr()

  1. bool mystrstarts(char* buf, char* s) //buf是否以s为头
  2. {
  3. if(*s=='\0') return true;
  4. if(*buf=='\0') return false;
  5. if(*s != *buf) return false;
  6. return mystrstarts(buf+1,s+1);
  7. }
  8. char* mystrstr(char* buf, char* s)
  9. {
  10. if(*s=='\0') return buf;
  11. if(*buf=='\0') return NULL;
  12. if(mystrstarts(buf, s)) return buf;
  13. return mystrstr(buf+1,s);
  14. }

拓展:尾递归

如果一个函数的末尾一句话是调用另一个函数

  1. void f(...)
  2. {
  3. ....
  4. return g(...); // 不能进行任何进一步的运算
  5. }

有些语言会做尾递归的优化,直接优化为 goto语句,不会进行压栈调用

scheme 语言有严格的尾递归优化

g++ version 11, 编译器在 优化级别 -02 的时候,会进行尾递归优化

实时效果反馈

1. 递归与循环的关系,说法正确的是:__

  • A 单层循环可以改写为递归形式,双层的不可以

  • B goto造成的循环不能改写为递归

  • C while(true) {… } 这样的死循环无法改写为递归

  • D 理论上,所有循环都可以用递归来描述

2. 关于尾递归,说法==不正确==的是:__

  • A 有些语言环境支持尾递归的优化,有些不支持

  • B 尾递归优化的主要目的是:减少对栈空间的依赖

  • C c++ 完全不支持尾递归优化

  • D 尾递归就是在函数最后一句调用递归函数,但不可以放在表达式中

答案

1=>D 2=>C

经典问题-生成全排列

image-20211216181604379

【问题】 “abcd”,生成它的全排列:

“abcd”,”abdc”,”acbd”,”adbc”, ….

想用递归?这个问题如何==踢皮球==

考虑把某个字母放在首位,剩下的,求全排列。。。。

章节一:信息学竞赛 CPP 基础篇 - 图94

  1. void swap(char* p, char* q)
  2. {
  3. char t = *p; *p = *q; *q = t;
  4. }
  5. void pai(char* buf, int k)
  6. {
  7. int n = strlen(buf);
  8. if(k==n){
  9. printf("%s\n", buf);
  10. return;
  11. }
  12. for(int i=k; i<n; i++){
  13. swap(buf+k, buf+i); //放置一个字符(试探)
  14. pai(buf, k+1); //递归
  15. swap(buf+k, buf+i); //恢复(回溯)
  16. }
  17. }

STL标准库的方案

  1. // 需要 <algorithm> 头
  2. char buf[] = "abcd";
  3. do{
  4. cout << buf << endl;
  5. }while(next_permutation(buf,buf+4)); //比当前稍大的字典序排列

拓展

如果字母出现重复如何生成全排列?

比如: ”AABBBCC“

交换时,可否限制一下?

记录已经实验过的字符数组 if … continue

实时效果反馈

1. 5个不同的字母,组成的全排列有__种?

  • A 100

  • B 110

  • C 120

  • D 130

2. 标准库中的排列生成方案有什么优势?__

  • A 不限定数组中的数据类型,算法更通用

  • B 比自己写的算法更快速

  • C 更节省内存

  • D 更方便控制顺序

答案

1=>C 2=>A

经典问题-所有组合

image-20211217092424648

【问题】从 m 个中取 n 个的所有方案

实际上,就是要组合每样东西的不取

  • 如果有 a, b, c 三个字母的情况,列出所有情况的最笨的办法:

三层 for 循环,每层取两个值:

  1. for(int i=0; i<=1; i++)
  2. for(int j=0; j<=1; j++)
  3. for(int k=0; k<=1; k++){
  4. if(i) cout << "a";
  5. if(j) cout << "b";
  6. if(k) cout << "c";
  7. cout << endl;
  8. }
  • 稍微改一下,a,b,c,d 四个字母,取2个:
  1. for(int i=0; i<=1; i++)
  2. for(int j=0; j<=1; j++)
  3. for(int k=0; k<=1; k++)
  4. for(int l=0; l<=1; l++){
  5. if(i+j+k+l != 2) continue;
  6. if(i) cout << "A";
  7. if(j) cout << "B";
  8. if(k) cout << "C";
  9. if(l) cout << "D";
  10. cout << endl;
  11. }

循环方案的弱点:

  1. 层数太多话,。。。。
  2. 如果实现不知道几个字母,是动态的,怎么办?

递归的方案

递归的想法也是考虑取和不取,但,

它只考虑局部,”大头“的任务交给”别人“做

  1. // s: 待取串, n: 还要取几个, pick: 选哪些,k:考虑第k个物品
  2. void zuhe(char* s, int n, int* pick, int k)
  3. {
  4. if(n==0){
  5. for(int i=0; i<k; i++) if(pick[i]) cout << s[i];
  6. cout << endl;
  7. return;
  8. }
  9. if(k==strlen(s)) return;
  10. pick[k] = 0;
  11. zuhe(s, n, pick, k+1);
  12. pick[k] = 1;
  13. zuhe(s, n-1, pick, k+1);
  14. }
  15. void zuhe(char* s, int n)
  16. {
  17. int pick[100];
  18. zuhe(s, n, pick, 0);
  19. }

拓展:有重复和无重复

”AABBBCCDDDD“ 取3个,所有方案?

基本物品还是:”ABCD”,每个物品有个最大数目

只需要把前边的方案再加个参数 max数组,记录着每件物品的可取数目上限

  1. int max[] = {2,3,2,4}; //对应于:"AABBBCCDDDD"

这样,算法中就不是 =0, =1, 而是循环 = i (0~max[k])

实时效果反馈

1. 10个不同物品中取8个,方案数是:__

  • A 40

  • B 42

  • C 45

  • D 50

2. 解决组合方案问题时,循环法的致命弱点是:__

  • A 层数太多时,不方便

  • B 嵌套多,可读性不好

  • C 循环多,计算慢

  • D 当物品数量未知时,无法确定循环层数

答案

1=>C 2=>D

栈溢出

image-20211217112633167

栈 是程序的常务工作间

使用不当,会: stackoverflow

不同环境下表现不同。qtcreator里显示为 内存段错误。

什么情况下会栈溢出

例如:数组开得太大了

例如:递归调用的深度太大了,或者干脆就没有出口

  • 观察栈的调用深度

    以求阶乘为例:

    1. int f(int x)
    2. {
    3. if(x==0) return 1;
    4. return x * f(x-1);
    5. }

qtcreator里可以:

  • 设置调试断点: 鼠标点击代码最左边
  • 单步跟踪执行
  • 进入函数
  • 跳出函数
  • 查看栈图景 views | stack

自己写个函数,玩命递归,看结果:

章节一:信息学竞赛 CPP 基础篇 - 图97

解决办法:

类unix环境(unix,linux,苹果os)

可以通过设置用户的权限来指定 的大小

这个观点是 也像文件,cpu 一样是一种珍贵资源,需要定额配给

windows环境,栈的大小写在 exe 文件中,

可以用工具修改,也可以连接时指定。

可以在编译时 附加指令

不同环境指令不同

较新的 win32-g++ 指令选项是: -Wl,--stack=16777216(后面的数字是栈大小,此处为16M)

  • 在qtcreator中,指定编译选项的办法:

    打开 .pro文件,在尾巴上添加:

    1. QMAKE_LFLAGS += "-Wl,--stack=16777216"

    注意:大写W后是小写的L,可不是数字1

实时效果反馈

1. 关于栈溢出的原因,说法正确的是:__

  • A 可能是因为数组访问越界引起的

  • B 可能是因为使用了空指针去求它指向的值

  • C 可能是因为机器硬件上,内存太小

  • D 可能是因为递归函数忘记了写出口

2. windows上怎样设置栈的大小:__

  • A 配置用户的权限表

  • B 给编译器添加编译选项(前提是该编译器支持)

  • C 在源码中设置

  • D 运行exe时,加命令行参数

答案

1=>D 2=>B

函数指针与回调函数

image-20211217120610203

正如,可以通过名字访问单个变量,也可以通过数组,访问一组变量;

通过函数名,可以调用单个函数,通过函数指针数组,可以调用一组函数

  1. int myadd(int x, int y)
  2. {
  3. return x * 10 + y;
  4. }
  5. ...
  6. int (*p)(int, int); //定义了函数指针类型的变量p
  7. p = myadd; //p指向函数
  8. cout << (*p)(5,8) << endl;

使用函数指针,可以得到动态性

  1. void f0(int a)
  2. {
  3. cout << a << endl;
  4. }
  5. void f1(int a)
  6. {
  7. cout << a + 1 << endl;
  8. }
  9. void f2(int a)
  10. {
  11. cout << a * a << endl;
  12. }
  13. typedef void (*MYF)(int);
  14. int main()
  15. {
  16. MYF f[] = {f0,f1,f2};
  17. int a, b;
  18. scanf("%d%d", &a, &b);
  19. (*f[b])(a);
  20. return 0;
  21. }

回调函数

在一个函数中,如果,执行什么动作是需要传递给我的信息,则可以选择用回调函数。

回调函数就是形参是函数指针,函数中,调用这个函数指针的用法。

【示例】

  1. typedef int (*MYF)(int); // 函数指针类型繁琐,通常定义类型别名
  2. int f1(int a) { return a*2; }
  3. int f2(int a) { return a/2; }
  4. // fp: 希望用户执行 f 的时候,动态传递给我的函数变量
  5. void f(int x, MYF fp)
  6. {
  7. cout << "I will call fp: ";
  8. cout << (*fp)(x) << endl;
  9. }

回调函数有何实际用处?

比如:用户界面,但点击按钮执行什么动作,可以通过函数传递入主框架。

而关于如何相应用户输入的主框架是通用逻辑,一般是系统准备好的,

不需要每个应用程序员都去写框架。

比如:执行sort的程序,可能面对用户自定义的类型,它怎么知道——

两个元素谁大谁小?

正确的姿势是:传入一个参数,它是一个用户定义的函数,用于比较大小

【示例】更通用的冒泡排序

  1. typedef int ITEM; // 将来可以换位任何用户定义的类型
  2. void swap(ITEM& a, ITEM& b){ ITEM t=a; a=b; b=t; }
  3. void bubble_sort(ITEM* buf, int n,
  4. int (*fp)(ITEM& a, ITEM& b) )
  5. {
  6. for(int j=0; j<n-1; j++){
  7. for(int i=0; i<n-j-1; i++){
  8. if((*fp)(buf[i],buf[i+1])>0)
  9. swap(buf[i],buf[i+1]);
  10. }
  11. }
  12. }
  13. /////-------以上是通用算法部分,排序过程与用户的具体问题无关---------
  14. int f(int&a, int&b) // 用户自己提供的比较函数
  15. {
  16. return a%10 - b%10;
  17. }
  18. void show(int* buf, int n)
  19. {
  20. for(int i=0; i<n; i++) cout << buf[i] << " ";
  21. cout << endl;
  22. }
  23. int main()
  24. {
  25. int a[] = {4,17,2,19,9,28,31,22,10};
  26. bubble_sort(a,9,f); //比较方式,动态传入
  27. show(a,9);
  28. return 0;
  29. }

实时效果反馈

1. 关于函数指针,说法正确的是:__

  • A 函数指针是一个函数类型的指针,通过它可以执行所指向的函数

  • B 函数指针是函数的别名

  • C 函数指针指向的函数不能有返回值

  • D 函数指针是c++的特性,c 中不能使用

2. 以下哪种情况,适合用回调函数?:__

  • A 不能确定参数的个数,运行时才知道

  • B 不能确定参数的具体类型,需要运行时才知道

  • C 不能确定需要执行的具体动作,需要用户从参数传入

  • D 不能确定所需内存的大小,是运行时计算出来的

答案

1=>A 2=>C

内存泄漏

章节一:信息学竞赛 CPP 基础篇 - 图99

内存如何泄漏?

堆空间中分配的内存,忘记了回收,或者没有恰当回收,则产生泄漏

堆空间的特点:

  1. 可用空间很大,可以分配大块内存(越用越碎,产生内存碎片)
  2. 手工分配,手工释放,需要自己精心维护
  3. 任意时间分配,任意时间释放,生存期灵活

一般常用的调用:

  • malloc / free

  • new [ ] / delete [ ] (最终还是调用 malloc/free)

    区别在于:malloc/free 是库函数,无法处理对象的更高级需求

    new / delete 是运算符,可以重载

    new 申请的内存可以初始化,自动调用构造函数

    delete 释放时,可以自动调用析构函数

观察内存耗尽的现象

【示例】 设置内存分配失败的函数指针

为了当内存分配失败时,执行特定的行为,我们提供一个回调函数

  1. void f()
  2. {
  3. cout << "there is no space any more" << endl;
  4. }

在主程序中设置这个回调函数

  1. set_new_handler(f);

然后准备一个动态分配内存的函数,在主程序中多次调用它

  1. int* bad()
  2. {
  3. return new int[1000 * 1000];
  4. }
  5. // 以下在 main.cpp 中多次调用它,试着改变循环次数,看最大可以多少次
  6. for(int i=0; i<490; i++){
  7. cout << i << " ";
  8. bad();
  9. }

最常见的失误举例

  • malloc/free, new / delete 没有配对儿使用

    可以静态检查,通过源码查找关键字发现

  • delete 忘记方括号

    1. int* p = new int[15];
    2. // dosomething...
    3. delete p;
  • 双层动态申请,忘记了内层

    1. int** p = new int* [3];
    2. p[0] = new int[20];
    3. p[1] = new int[30];
    4. p[2] = new int[40];
    5. // do something here
    6. delete [] p;

    这个的主要危害,在于面向对象时,忘记了调用析构函数,对简单的 int 倒是无妨

    正确的释放写法是:

    1. for(int i=0; i<3; i++)
    2. delete [] p[i];
    3. delete [] p;
  • 着急return, 忘记了资源

    1. char* p = new char[1000];
    2. char* q = new char[1000];
    3. // do something here
    4. delete [] q;
    5. // do something....
    6. if(...) return -1; // 忘记了还有 p 没放
    7. delete [] p;
  • 指针折腾,不小心把某个给“踩死”了

    1. int* p = malloc(...);
    2. int* q = malloc(...);
    3. //....
    4. if(...) p = q; // 此时, p 指向的内存再也不会释放
    5. //....
    6. free(q);
    7. free(p);

如何检测是否泄漏?

  • 各种第三方的检测方法,比如:微软的 vs 内置了检测手段(比较难用)

  • 自己动手,丰衣足食。。。。

    最原始的版本:

  1. int MEMCT; // 全局变量,静态空间,自动初始化
  2. void* my_alloc(int n)
  3. {
  4. void* p = malloc(n);
  5. if(p==NULL) return NULL;
  6. MEMCT++;
  7. return p;
  8. }
  9. void my_free(void* p)
  10. {
  11. if(p==NULL) return;
  12. free(p);
  13. MEMCT--;
  14. }

实时效果反馈

1. 造成内存泄漏的原因,可能是:__

  • A 动态申请了一次内存,释放了两次

  • B 动态申请了两块内存,只释放了一块

  • C 动态申请太大时,没有足够的内存了

  • D 在一个函数中申请内存,却在另一个函数中释放它

2. new/delete 与 malloc/free 的区别,错误的是:__

  • A malloc/free 是c语言原来就有的机制,new/delete是c++引入的

  • B new会做初始化工作,malloc不会

  • C new/delete 可以重载,malloc/free不可以

  • D new可以为对象分配内存,malloc不可以

答案

1=>B 2=>D

程序调试

image-20211219182650392

观察程序行为的常见方法

  • 单元测试
  • 打印中间步骤的状态信息
  • 单步跟踪调试

输出调试信息

  • printf (或者cout)可以输出,但有以下弱点:

    与逻辑输出混在一起,无法统一管理

    怎样重定向到文件中(log)

    怎样方便地去掉调试信息?

    怎样附加通用信息?文件名,时间,行号等

  • 使用qDebug 代替 printf 或 cout 输出

    需要选择Qt工程,暂时可选 qt console,没有GUI的工程

    章节一:信息学竞赛 CPP 基础篇 - 图101

    包含头文件:

    1. #include <qdebug.h>

    使用qDebug() 来代替 cout

    它会在各个项间自动加空格,结尾自动加 ‘\n’

  • 设置.pro 文件,阻止 debug输出

    1. DEFINES += QT_NO_DEBUG_OUTPUT

    需要切换,则使用注释

    1. # DEFINES += QT_NO_DEBUG_OUTPUT
  • 附加信息

    1. __FILE__ // 文件名
    2. __LINE__ // 行号
    3. __TIME__ // 时间

跟踪调试

image-20211220084908128

  • 设置断点(单击,行号左边)
  • 单步跟踪
  • 观察变量
  • 增加表达式(局部变量和表达式窗,右键 | 选第一项)

  • 进入函数

  • 跳出函数
  • 观察栈信息(可点击,查看调用关系)

实时效果反馈

1. 使用qDebug的好处,说法==错误==的是:__

  • A 便于统一去掉或显示调试信息

  • B 信息输出形式书写更方便、快捷

  • C 可以方便显示 文件名,行号等信息

  • D 只在debug版显示,在release版本中不显示

2. 使用跟踪调试时,如何查看指针 p 指向的值?__

  • A 所有情况下,调试器都会自动显示指针指向的值

  • B 增加输出语句,打印指针指向的值

  • C 把鼠标放在 p 上不动,一会儿就会显示 p 指向的值

  • D 增加表达式,(类型) *p

答案

1=>D 2=>D

结构体

image-20211220110421418

结构体是一种复合类型,也是一种用户定义类型

通俗地说,它捆绑多个变量,使之成为一个整体

比如,平面上点坐标:

  1. struct Point{
  2. int x;
  3. int y;
  4. }; // 新定义了一个类型,名字:Point,地位相当于:int

c++ 的结构体统一了 class 和 struct,不再需要typedef 才能定义结构体的类型

struct 与 class 的唯一区别是:struct 内默认都是public

用sizeof 可以查看结构体大小

struct 初始化

  1. void show(Point& pt)
  2. {
  3. cout << "Point: (" << pt.x << "," << pt.y << ")";
  4. cout << endl;
  5. }
  6. // 调用处 .....
  7. Point a = Point{3,5}; //字面量生成“立即数”
  8. Point b = {10,20}; // 类似数组赋初值
  9. Point c = a; // 拷贝每个成员的意思
  10. show(a);

结构体是值语义

a = b; 的结果是拷贝了b中所有项的数据到a,a,b数据是独立的

结构体作为参数传递时,值语义。

当结构体较大时,有时为了效率,我们可以传递引用

如果不需要修改结构体,最好用const修饰,增加安全性

访问结构体的成员

data.x 或者 p->x

  1. void inc(Point* p) // 与主调方共享同一struct数据
  2. {
  3. p->x++; // 相当于:((*p).x)++
  4. p->y++;
  5. }

结构体中的指针和数组

结构体中使用指针类型数据,一定要当心,尤其是char*,最容易误用!

  1. struct STU2
  2. {
  3. char* name;
  4. int age;
  5. };
  6. // 使用:
  7. char name[] = "zhangsan";
  8. STU2 t1 = {name, 15};
  9. STU2 t2 = t1;
  10. cout << sizeof(t1) << endl;
  11. t1.name[0] = 'X';
  12. cout << t2.name << endl;

注意,出现了名字共享的现象。

image-20211220151740239

结构体可以嵌套

  1. struct Point{int x; int y};
  2. struct Rect{Point p1; Point p2;}; // 左上角,右下角

实时效果反馈

1. 关于结构体struct的说法,正确的是:__

  • A 必须通过 typedef 才能使用

  • B 指针可以指向结构体,结构体中也可以包含指针

  • C 声明结构体时,必须赋给初始值

  • D 结构体只能在栈中分配

2. 在结构体中包含char* 类型,==不会==引发的问题是:__

  • A 结构体还在工作中,而它的 char*所指向的数据却被别人释放了

  • B 结构体赋值时,两个指针公用了同一片数据

  • C 释放结构体内存时,忘记了释放 char* 指针,造成内存泄漏

  • D 这样的结构体无法放在栈中

答案

1=>B 2=>D

联合体

image-20211220111554301

联合体也是一种复合结构,用户定义类型

struct 可以看作成员的,union可以看作成员间的关系

也就是说,union的成员是共享同一块存储的。

各成员所占用的空间可以不等长,整体取最大的。

比如,

  1. union MyIP{
  2. unsigned char ipv4[4]; // 4 字节的IP地址
  3. unsigned char ipv6[6]; // 6 字节的IP地址
  4. };

union 经常和 struct 配合使用。

联合体的作用

  1. 多视角,不同的观点对待同样的数据

    1. struct STU
    2. {
    3. char name[20];
    4. int age;
    5. double score;
    6. };
    7. union STU_U
    8. {
    9. STU stu;
    10. unsigned char data[32];
    11. };

    使用时,

    1. STU_U x;
    2. strcpy(x.stu.name,"zhangsan");
    3. x.stu.age = 15;
    4. // x.data[...] ... 对其进行加密处理

    假如没有 union, 这与直接用 (unsigned char* ) 有什么两样吗?

    • 用union更优美,满屏的强制,看起来很不文明

    • 更重要的,编译器知道我们的用意,能够提供更多的帮助

  2. 表达类型“或”的概念,可以同时容纳多种类型

    • 如果区分?

      一般在头部设置个 char tag 作为标记

      更规范地,会定义为 enum 类型

    • sizeof?

      取最大宽度

      可能存在字对齐问题

示例

表达图形的概念:

图形可能有很多种类:三角形,圆形,矩形,它们的数据结构各不相同

  1. // 平面直角坐标上的点
  2. struct Point{
  3. int x;
  4. int y;
  5. };
  6. // 圆
  7. struct Circle{
  8. Point pt; //圆心
  9. int r; // 半径
  10. };
  11. // 矩形
  12. struct Rect{
  13. Point pt1; // 左上角
  14. Point pt2; // 右下角
  15. };
  16. // 枚举图形的种类
  17. enum ShapeKind{
  18. CIRCLE, RECT, TRIANGLE
  19. };
  20. // 图形
  21. struct Shape{
  22. ShapeKind tag;
  23. union {
  24. Circle circle;
  25. Rect rect;
  26. };
  27. };

如何使用呢?

  1. Shape a;
  2. a.tag = CIRCLE;
  3. a.circle.pt = Point{5,5};
  4. a.circle.r = 10;
  5. // 使用 a:
  6. switch (a.tag) {
  7. case CIRCLE:
  8. cout << a.circle.r << endl;
  9. break;
  10. default:
  11. break;
  12. }

实时效果反馈

1. 关于 struct 和 union,说法正确的是:__

  • A struct 会进行字长对齐,union 不会

  • B sizeof(union类型)肯定不小于其中每个成员的 size

  • C struct 中可以嵌套 union, union 中不能嵌套 struct

  • D struct 和 union 不可以相互嵌套

2. 为了区分union中,哪个成员是当前有效的数据,采用哪个方法更好?__

  • A 在头部加个 char 类型 tag

  • B 在头部 加个 int 类型 tag

  • C 定义枚举类型,并标记一个该类型的 tag

  • D 直接读取并分析成员,猜测是哪种情况

答案

1=>B 2=>C

链表入门

image-20211221131303274

链表是复合类型,可以用struct定义它的每个节点

节点是递归类型,它的数据包含了指向本类型的指针

  1. // 定义一个节点类型,包含了数据和指向下一个节点的指针
  2. struct Node{
  3. int data; // “乘客”
  4. Node* next; // 这里是递归定义
  5. };

注意,只可以包含 Node* 类型的变量, 不可以包含 Node next;

想想看,如果这样,sizeof(Node) = ????

节点的内存一般是动态申请的。

当增加数据的时候,申请内存。

删除数据的时候,释放内存。

image-20211221153212144

对节点的常规操作:

  • 求链表的长度

    这是个耗时的动作,可以想办法优化

    1. // 故意用了递归,从效率考虑,应该用循环
    2. int get_len(Node* head)
    3. {
    4. if(head==NULL) return 0;
    5. return get_len(head->next) + 1;
    6. }
  • 增加

    1. // 在表头插入,返回新的head
    2. Node* add_data(Node* head, int x)
    3. {
    4. return new Node{data, head};
    5. }

    为什么要返回?

    为什么不在尾巴上添加?

  • 查找

    1. // 查找
    2. Node* find_data(Node* head, int x)
    3. {
    4. if(head==NULL) return NULL;
    5. if(head->data == x) return head;
    6. return find_data(head->next,x);
    7. }
  • 删除

    删除需要取得目标的前一个节点,原理:

    image-20211221162045427

    1. // 删除值为x的首次匹配节点
    2. Node* del_data(Node* head, int x)
    3. {
    4. if(head==NULL) return NULL;
    5. // 如果是删除第一个,特殊处理
    6. if(head->data == x){
    7. Node* p = head->next;
    8. delete head;
    9. return p;
    10. }
    11. // 找到目标的前趋节点
    12. Node* p = head;
    13. while(p->next){
    14. if(p->next->data == x){
    15. Node* q = p->next;
    16. p->next = q->next;
    17. delete q;
    18. return head;
    19. }
    20. p = p->next;
    21. }
    22. return head;
    23. }

实时效果反馈

1. 怎样在链表表尾添加新节点?__

  • A 首先让表头指针指向表尾

  • B 首先让表尾指针指向表头

  • C 首先根据表头,找到表尾节点

  • D 首先把表尾next置为NULL

2. 当单向链表较长时,下列哪个操作特别耗时?:__

  • A 求链表的长度

  • B 在表头添加节点

  • C 删除表头

  • D 判断是否为空链表

答案

1=>C 2=>A

带表头的单链表

image-20211221190918276

最简单链表的弱点:

  • 在指定位置 插入、删除,都需要该位置的前趋节点
  • 为了方便查找后进一步处理,查找后返回目标的前趋,可是还要兼顾“找不到”这个信息
  • 每次操作后都要返回新的头节点很不方便。
  • 删除和查找,逻辑重复,坏味道。。。
  • 删除所有节点怎么处理?

增加一个头节点,即可解决很多问题。

  1. // 查找,返回目标的前趋
  2. Node* find_data(Node* head, int x)
  3. {
  4. if(head->next==NULL) return NULL;
  5. if(head->next->data == x) return head;
  6. return find_data(head->next,x);
  7. }

插入,插入为 p 的后继

image-20211221194940665

任意位置的插入

image-20211221202620325

  1. // 插入 q 为 p 的后继
  2. void insert_node(Node* p, Node* q)
  3. {
  4. q->next = p->next;
  5. p->next = q;
  6. }

image-20211221200027035

  1. // 删除, p: 被删除节点的前趋,
  2. // 返回被删除的节点
  3. Node* del_node(Node* p)
  4. {
  5. Node* q = p->next;
  6. p->next = q->next;
  7. return q;
  8. }

删除并不负责释放空间。这样设计很灵活。

因为,使用者可能想把某个节点摘下来,挂到其它地方去

动态内存的管理

从这个实际的例子看到:

  • 只要可行,就优先执行:谁申请,谁释放 的原则
  • 该原则,很难完全贯彻执行,分离的管理要小心、细致,很容易泄漏
  • 照顾API设计的原则:尽量不要重复;不要冗余;尽量灵活;尽量易于使用

实时效果反馈

1. 下列哪个==不是== 增加表头节点,带来的好处:__

  • A 更方便地返回目标节点的前趋

  • B 可以减少NULL指针的判断

  • C 可以避免总是去更改head

  • D 可以更快找到尾节点

2. 动态内存管理的一般原则是:__

  • A 谁申请,谁负责释放

  • B 被调方申请,主调方释放

  • C 一个被调函数中申请,在另一个被调函数中释放

  • D 较早申请的,较先释放

答案

1=>D 2=>A

双向循环链表

image-20211221132258850

增加表头后,单向链表仍有缺点:

  • 从当前节点找它的前趋很吃力
  • 从表头找到表尾很吃力,因而在表尾添加很麻烦
  • 插入、删除时,总是持有当前节点的前趋,这很不直观。

解决方案:

没有免费的午餐,

牺牲空间,换来方便。。。。。

image-20211222091816204

增加了一个 prev 数据域,与 next 呈对称之势

  1. struct Node{
  2. int data;
  3. Node* prev; // 指向前趋节点
  4. Node* next; // 指向后继节点
  5. };
  • 在栈中创建头节点的正确姿势
  1. Node head = {-1,&head,&head};

这是先让蛇咬住自己的尾巴,形成一个闭合的空环

  • 在 p 节点之前,挂接 q 节点(插入)

image-20211222092150179

先挂红指针,后挂粉色指针

  1. // 插入 q 为 p 的前趋
  2. void insert_node(Node* p, Node* q)
  3. {
  4. Node* t = p->prev;
  5. q->prev = t; // 1. 新加入组织的先动
  6. q->next = p;
  7. t->next = q; // 2. 获得组织承认
  8. p->prev = q;
  9. }
  • 删除当前节点
  1. // 删除: p
  2. Node* del_node(Node* p)
  3. {
  4. Node* p1 = p->prev;
  5. Node* p2 = p->next;
  6. p1->next = p2;
  7. p2->prev = p1;
  8. return p;
  9. }

这里有个API设计技巧:

为什么要返回 p ? p 是主调方送来的,我还原样送回去?

为了 主调方的 接力写法

看这个删除所有节点的表达法:

  1. // 删除所有节点(释放空间)
  2. void del_all(Node* head)
  3. {
  4. while(head->next!=head) free(del_node(head->next));
  5. }

实时效果反馈

1. 双向循环链表的好处,说法==错误==的是:__

  • A 可以快速找到尾节点

  • B 可以很方便在尾部添加

  • C 可以很快求出链表长度

  • D 可以很快找出当前节点的前趋和后继

2. 在双向循环链表中插入新节点 q,需要更改几个指针?:__

  • A 1

  • B 2

  • C 3

  • D 4

答案

1=>C 2=>D

约瑟夫环

image-20211222150054105

【问题】

有n个小朋友排成一个圆圈,编号1~n (顺时针)

从1号开始,顺时针报数1,2,3…,报到3的小朋友出列,下一个继续从1开始报

求最后剩下的小朋友是几号?

【分析】

构建一个与问题相仿的模型:

单向循环链表,无表头

data 中存小朋友的编号

不断重复游戏规则,最后剩下一个小朋友时,输出它的号码

【构建的过程很重要】

  • 自顶向下的风格

    先不要陷入细节,从宏观上写,先写需求,后写实现

    复杂的逻辑分解开,放在不同的函数里

  • 测试驱动的风格

    代码未动,测试先行

    边写边测试,不要等全写完了,一堆的错误

实时效果反馈

1. 使用循环链表,我们==不可以==做?__

  • A 连续地在表头插入时,可以不用更改head指针

  • B 方便地从表尾回到表头

  • C 方便地找到前趋节点

  • D 方便地找到后继节点

2. free 与 delete 的区别是?__

  • A free 只是释放空间,并不知道类型,不会调用相应的析构函数

  • B delete 比 free 释放更规范,是新标准

  • C delete 效率更高

  • D delete 会进行碎片清理

答案

1=>C 2=>A

快速排序

image-20211221132901185

内排序介绍

  • 交换排序

    发现逆序则交换之。

    最典型的是冒泡排序。比较高效的改进版:快速排序

  • 选择排序

    先选出最小的,剩下的再选最小的。

    比较高效的改进版:堆排序

  • 插入排序

    把新元素插入到已有的有序队列

    比较高效的改进版:希尔排序

快速排序的想法

  1. 找一个标尺(一般就选第一个元素),把整个待排区间分成两部分。

    一部分比标尺小,放左边,标尺放中间,剩余放右边。

  2. 两个子区间递归 ① 的做法。

这里要注意一个要点:一次划分后,两个子区间仍然无序,

可以充分利用这个特点,快速划分出这两个区间。

怎样划分两个区?

image-20211222195534264

改进的方案:

image-20211222200857626

image-20211222201126556

从宏观看,“空穴”,左右跳跃,最后固定在某个位置,此时,把红色值填入。

实现

当数据不是很畸形的时候,分区的大小下降很快,可以用递归求解。

  1. void qsort(int* begin, int* end)
  2. {
  3. if(begin>=end) return;
  4. int t = *begin;
  5. int* p = begin;
  6. int* q = end;
  7. while(true){
  8. while(p<q && *q>=t) q--;
  9. if(p==q) break;
  10. *p++ = *q;
  11. while(p<q && *p<t) p++;
  12. if(p==q) break;
  13. *q-- = *p;
  14. }
  15. *p = t;
  16. qsort(begin, q-1);
  17. qsort(q+1, end);
  18. }

实时效果反馈

1. 快速排序属于下列哪种类型的排序算法?__

  • A 交换排序

  • B 选择排序

  • C 插入排序

  • D 基数排序

2. 当初始数据是完全有序的时候,快速排序的效率如何?__

  • A 不确定

  • B 效率最差

  • C 效率最好

  • D 效率接近平均水平

答案

1=>A 2=>B

image-20211221133523907

数组,链表都是 线性结构

它们的特点是:每个节点有唯一的前趋,后继(端点可能没有)

而树是一种分叉的结构

有一个根节点

每个节点可以有若干个子节点(像树木的分支)

最末级的叶子节点没有子节点

典型的分类关系,组织机构关系都是树型关系。

章节一:信息学竞赛 CPP 基础篇 - 图122

树是一种递归结构,它的任意子节点仍然是树。

树的表示

  • 如果孩子数目较少(比如不大于5)

    1. struct TreeNode{
    2. int data; //用户数据
    3. TreeNode* children[5]; // 没有那么多孩子则相应指针为NULL
    4. TreeNode* parent; // 根据情况,也可能不需要
    5. };
  • 孩子数目不定(比如:少了可能没有,多了可能上万)

    1. struct TreeNode;
    2. struct LinkNode{
    3. TreeNode* data;
    4. LinkNode* next;
    5. };
    6. struct TreeNode{
    7. int data; //用户数据
    8. LinkNode* child;
    9. };

    章节一:信息学竞赛 CPP 基础篇 - 图123

  • 其实,树的表示未必拘泥于“形似”,只要逻辑等价即可:

    1. struct TreeNode{
    2. int data;
    3. TreeNode* child;
    4. TreeNode* brother;
    5. };

    image-20211223163844521

树的遍历

  • 深度优先遍历 dfs

    对每个可能的分支,深入到不能再深入为止。

    深度优先比较容易实现,先递归child, 然后递归brother

  • 广度优先遍历 bfs

    其实,就是分层遍历。对高层整层遍历完,再进入下一层。

    实现比较麻烦一点,需要保存当前层的链表,由它生成下一层的链表,

    直到无新增数据。

image-20211223103112600

  1. struct Tree{
  2. int data;
  3. Tree* child;
  4. Tree* brother;
  5. };
  6. // 为 me 增加一个兄弟 br(other)
  7. void add_brother(Tree* me, Tree* br)
  8. {
  9. if(me->brother)
  10. add_brother(me->brother, br);
  11. else
  12. me->brother = br;
  13. }
  14. // 为 me 增加一个孩子 ch(ild)
  15. void add_child(Tree* me, Tree* ch)
  16. {
  17. if(me->child)
  18. add_brother(me->child, ch);
  19. else
  20. me->child = ch;
  21. }
  22. // 深度优先遍历
  23. void dfs(Tree* p)
  24. {
  25. cout << p->data << endl;
  26. if(p->child) dfs(p->child);
  27. if(p->brother) dfs(p->brother);
  28. }

树的节点一般都是动态申请的,千万别忘记释放。

销毁树也很简单,因为是dfs

  1. void del_tree(Tree* p)
  2. {
  3. if(p->child) del_tree(p->child);
  4. if(p->brother) del_tree(p->brother);
  5. delete p;
  6. }

实时效果反馈

1. 关于树的说法,==错误==的是:__

  • A 树的子节点仍然是树

  • B 单个节点也是树

  • C 树只有一个根

  • D 叶子节点不是子树

2. 如下图的树,dfs遍历的节点访问顺序应该是:__

章节一:信息学竞赛 CPP 基础篇 - 图126

  • A ABDEFCGH

  • B ABDEFGCH

  • C ABCDEFGH

  • D ADEFBGHC

答案

1=>D 2=>A

二叉树

章节一:信息学竞赛 CPP 基础篇 - 图127

  • 最多只有两个分支的树

  • 左孩子和右孩子是区分的

二叉树的表示

  1. struct BiTree{
  2. int data;
  3. BiTree* left;
  4. BiTree* right;
  5. };

根据操作的需要可以有或没有 BiTree* parent 项

二叉树的高度(或深度)

空树的高度为 0

树的高度是:左右孩子最大高度 + 1,很容易递归求解。

  1. int get_height(BiTree* p)
  2. {
  3. if(p==NULL) return 0;
  4. return max(get_height(p->left), get_height(p->right)) + 1;
  5. }

二叉树的遍历

  • 先根序(也称为:前序遍历)

    1. void pre_order(BiTree* p)
    2. {
    3. cout << p->data << " ";
    4. if(p->left) pre_order(p->left);
    5. if(p->right) pre_order(p->right);
    6. }
  • 中根序(也称为:中序遍历)

    1. void in_order(BiTree* p)
    2. {
    3. if(p==NULL) return;
    4. in_order(p->left);
    5. cout << p->data << " ";
    6. in_order(p->right);
    7. }
  • 后根序

    1. void post_order(BiTree* p)
    2. {
    3. if(p->left) post_order(p->left);
    4. if(p->right) post_order(p->right);
    5. cout << p->data << " ";
    6. }

image-20211224211719181

遍历用途举例

  • 表达式的求值

    章节一:信息学竞赛 CPP 基础篇 - 图129

    以二叉树形式表达表达式的计算关系是十分自然的。

    此二叉树的中序遍历与表达式形式一致。

    其后序遍历:

    A B C D - * + E F / - 恰为逆波兰表达式,这在表达式求值中十分重要。

  • 也可以从遍历结果来建立二叉树

    例如,如下关系二叉树:

    1. BiTree* r = new BiTree{1,NULL,NULL};
    2. r->left = new BiTree{2,NULL,NULL};
    3. r->right = new BiTree{3,NULL,NULL};
    4. r->left->left = new BiTree{4,NULL,NULL};
    5. r->left->right = new BiTree{5,NULL,NULL};
    6. r->right->right = new BiTree{6,NULL,NULL};
    7. r->right->right->left = new BiTree{7,NULL,NULL};
    8. r->right->right->right = new BiTree{8,NULL,NULL};
    9. r->left->right->left = new BiTree{9,NULL,NULL};

这个建立树的过程也太痛苦了,还容易错。

我们只要把先序遍历稍加改造:

  1. void pre_order(BiTree* p)
  2. {
  3. if(p==NULL){
  4. cout << -1 << ",";
  5. return;
  6. }
  7. cout << p->data << ",";
  8. pre_order(p->left);
  9. pre_order(p->right);
  10. }

输出的序列放到数组里,就可以根据它创建二叉树了。

  1. int a[] = {1,2,4,-1,-1,5,9,-1,-1,-1,3,-1,6,7,-1,-1,8,-1,-1};
  2. BiTree* r;
  3. create_tree(a, r);

建立二叉树的过程:

  1. int create_tree(int* a, BiTree*& r)
  2. {
  3. if(*a == -1){
  4. r = NULL;
  5. return 1;
  6. }
  7. r = new BiTree{*a};
  8. int n1 = create_tree(a+1, r->left);
  9. int n2 = create_tree(a+1+n1, r->right);
  10. return 1 + n1 + n2;
  11. }

这个建树过程,还有个神奇的特性:

可以不告诉它数组有多大,数据够了会自动停止,再多的也不会去读。

实时效果反馈

1. 关于二叉树的说法,正确的是:__

  • A 二叉树如果只有一个分支,则无论左右,都是等价的。

  • B 7个节点的二叉树,最小深度为 3

  • C 二叉树的前序遍历和后序遍历正好是逆序的

  • D 二叉树实现时,必须有parent指针,指向上级节点

2. 二叉树中,叶子节点数为${n0}$,2个分支的结点数为${n2}$,则:__

  • A ${n_0 = n_2 + 1}$

  • B ${n_0 > n_2 + 1}$

  • C ${n_0 = n_2 - 1}$

  • D 没有一定的数量关系

答案

1=>B 2=>A

二叉排序树

image-20211221134829923

二叉树可以很方便地完成快速搜索任务,主要用于:动态查找

所谓动态是指:节点增增删删,一直在动态变化

其副产品是可以排序

  • 插入数据时,保持大小关系

    小于自己的放左边,否则放右边

  • 按中序遍历

    dfs 实现中根序

排序树实现

  • 添加节点

    1. void add(BiTree* r, int x)
    2. {
    3. if(x < r->data)
    4. if(r->left)
    5. add(r->left, x);
    6. else
    7. r->left = new BiTree{x,NULL,NULL};
    8. else
    9. if(r->right)
    10. add(r->right, x);
    11. else
    12. r->right = new BiTree{x,NULL,NULL};
    13. }
  • 中序遍历

    1. void inorder(BiTree* r)
    2. {
    3. if(r==NULL) return;
    4. inorder(r->left);
    5. cout << r->data << " ";
    6. inorder(r->right);
    7. }
  • 删除节点

    删除的要点是:用中序遍历的前趋,或后继来代替自己(移花接木法)

    1. void del_node(BiTree*& p)
    2. {
    3. if(p->left==NULL){
    4. BiTree* t = p;
    5. p = p->right;
    6. delete t;
    7. return;
    8. }
    9. if(p->right==NULL){
    10. BiTree* t = p;
    11. p = p->left;
    12. delete t;
    13. return;
    14. }
    15. // 以下是左右子树都不空
    16. BiTree* pa = p;
    17. BiTree* s = p->left;
    18. while(s->right) {
    19. pa = s; // s“作死”之前,记住它的parent
    20. s = s->right;
    21. }
    22. p->data = s->data; // 李代桃僵
    23. if(pa==p) // 严谨! 可能s就在p的脚边
    24. p->left = s->left;
    25. else
    26. pa->right = s->left;
    27. delete s; // s已无后顾之忧,删除之
    28. }
    29. bool del(BiTree*& r, int x)
    30. {
    31. if(r==NULL) return false;
    32. if(x==r->data) {
    33. del_node(r);
    34. return true;
    35. }
    36. if(x < r->data)
    37. return del(r->left, x);
    38. else
    39. return del(r->right, x);
    40. }
  • 别忘记了销毁

    时刻要绷紧这根弦,否则会内存泄漏

    (请自行完成!)

实时效果反馈

1. 当n很大时,n 个节点的二叉树,尽量使其深度h最小,则h大约为:__

  • A ${\sqrt{n}}$

  • B ${log(n)}$

  • C ${\frac{n}{4}}$

  • D ${n^{\frac{1}{e}}}$

2. 在向二叉排序树添加数据顺序为:1,2,3,4,5 … 时,说法==错误==的是:__

  • A 会产生严重不平衡,数据呈一条线状

  • B 插入和搜索的速度都会变慢

  • C 其排序性质仍然能保证

  • D 前序遍历与中序遍历不同

答案

1=>B 2=>D

AVL树

image-20211221142904576

简单的二叉排序树弱点:

  • 当数据是基本有序时,畸形严重
  • 如果多次添加,删除操作,慢慢会变畸形

AVL 树的关键是 1. 发现不平衡,2. 调整平衡。

AVL定义

  1. 单个节点是AVL树,
  2. a 是AVL树,当且仅当:
    1. a 的左右子树都是 AVL 树
    2. 左右子树高度差不大于 1

平衡因子定义:

左子树高度减去右子树高度

对AVL树,如果平衡因子绝对值超过1,就要调整,

如何调整?

进行添加或删除操作后,都可能会引发不平衡。

调整平衡的本质就是谁当局部根节点的问题

以左边为例(右边类推):

  • 左左型(直线型,扁担型)

章节一:信息学竞赛 CPP 基础篇 - 图132

  • 左右型(之字型,折尺型)

章节一:信息学竞赛 CPP 基础篇 - 图133

添加过程

  1. void add(BiTree*& r, int x)
  2. {
  3. if(x < r->data)
  4. if(r->left)
  5. add(r->left, x);
  6. else
  7. r->left = new BiTree{x, NULL, NULL};
  8. else
  9. if(r->right)
  10. add(r->right, x);
  11. else
  12. r->right = new BiTree{x, NULL, NULL};
  13. //adj(r);
  14. }

调整函数

  1. inline int max(int a, int b){ return a>b? a : b; }
  2. // 树的高度
  3. int h(BiTree* r)
  4. {
  5. if(r==NULL) return 0;
  6. return max(h(r->left), h(r->right)) + 1;
  7. }
  8. //平衡因子
  9. int balance(BiTree* r)
  10. {
  11. return h(r->left) - h(r->right);
  12. }
  13. //校正过程
  14. void adj(BiTree*& r)
  15. {
  16. if(balance(r)<2) return;
  17. if(balance(r)>0){
  18. if(balance(r->left)>0){ //左左型
  19. BiTree* p = r;
  20. BiTree* q = p->left;
  21. r = q;
  22. p->left = q->right;
  23. q->right = p;
  24. }
  25. else{ // 左右型
  26. BiTree* p = r;
  27. BiTree* q = p->left;
  28. BiTree* s = q->right;
  29. r = s;
  30. q->right = s->left;
  31. p->left = s->right;
  32. s->left = q;
  33. s->right = p;
  34. }
  35. }
  36. else{
  37. if(balance(r->right)<0){ //右右型
  38. BiTree* p = r;
  39. BiTree* q = p->right;
  40. r = q;
  41. p->right = q->left;
  42. q->left = p;
  43. }
  44. else{ // 右左型
  45. BiTree* p = r;
  46. BiTree* q = p->right;
  47. BiTree* s = q->left;
  48. r = s;
  49. q->left = s->right;
  50. p->right = s->left;
  51. s->right = q;
  52. s->left = p;
  53. }
  54. }
  55. }

拓展

AVL树平衡条件很苛刻,如果频繁增删,势必频繁调整

实际上,可以更惰性一些,比如左右子树高度相差不超过 2 倍就好

红黑树就做到了这点,调整的频率大为降低,平衡性仍然很好。

实时效果反馈

1. 关于AVL树,说法正确的是:__

  • A AVL树必须在节点结构中记录平衡因子

  • B 对AVL树的某个子树进行平衡调整后,必然会引起父节点的不平衡

  • C 如果某节点左边高为h,右边高为h+2,调整后,必然都变为 h+1

  • D 当 n 很大时,添加一个节点后,最多调整次数大约为:${\frac{n}{2}}$ 次

2. AVL 树缺点是:__

  • A 插入数据快,删除数据慢

  • B 节点数目 n 基本稳定时,随着插入删除的进行,效率变慢

  • C 人为设计一组数据进行添加,会使得检索效率变慢

  • D 添加、删除时,调整过于频繁,不太适合频繁增删的场景

答案

1=>C 2=>D

堆排序

image-20211221135504246

堆排序属于选择排序的一种

它借助于完全二叉树的性质来实现。

概念

  • 满二叉树

    章节一:信息学竞赛 CPP 基础篇 - 图135

    层数为 k 时,节点数为 ${2^k-1}$ 的二叉树

    也就是说,层数不变,节点数已经没法再多了,已经满员了。

  • 完全二叉树

    只在最后一层右边有缺损的满二叉树

    章节一:信息学竞赛 CPP 基础篇 - 图136

完全二叉树可以用数组来存储,不用链式的动态结构

  • 又分为大顶堆小顶堆

    对每个节点,本节点值不小于孩子节点的值 ==> 大顶堆

    注意,两个孩子大小无所谓,实际上也就是,没有左右孩子的区别了。

堆的存储

用数组存储,元素存储位置的关系公式(父子关系):

左孩子 = i * 2 +1

右孩子 = 左孩子 + 1

父节点 = (i - 1) / 2

原地排序的实现

  1. 待排数组,先建立大顶堆

  2. 把堆顶交换到尾部

    堆顶调整过程:

    1. inline swap(int&a, int& b){ int t=a; a=b; b=t; }
    2. // 大顶堆调整(两个孩子都是堆,堆顶不满足)
    3. // 堆数据:data,n // k: 当前要调整的堆顶
    4. void adj(int* data, int n, int k)
    5. {
    6. int l = k * 2 + 1;
    7. int r = l + 1;
    8. if(l >= n) return;
    9. if(r >= n){
    10. if(data[l] >= data[k]) swap(data[l], data[k]);
    11. return;
    12. }
    13. int s = (data[l] > data[r])? l : r;
    14. if(data[s] >= data[k]){
    15. swap(data[s], data[k]);
    16. adj(data, n, s);
    17. }
    18. }

    输出堆顶过程:

    1. // 原地堆排序
    2. void heap_sort(int* data, const int N)
    3. {
    4. // 建立大顶堆
    5. for(int i=N/2; i>=0; i--) adj(data, N, i);
    6. // 堆顶输出到尾部
    7. int* p = data + N - 1;
    8. while(p!=data){
    9. swap(*data, *p);
    10. adj(data, p-data, 0);
    11. p--;
    12. }
    13. }

实时效果反馈

1. 原地堆排序时,为什么选大顶堆,而不是小顶堆?__

  • A 大顶堆比小顶堆生成速度更快

  • B 因为用户需要的是从大到小的顺序

  • C 因为堆顶要输出到数组尾,这样是按照要求排序顺序的逆序输出的。

  • D 其实无所谓,用小顶堆也可以

2. 用数组存储完全二叉树,本节点 k 的左孩子的关系式为(索引从0开始):__

  • A 2 * k + 1

  • B 2 * k - 1

  • C 2 * k

  • D 2 * (k + 1)

答案

1=>C 2=>A

归并排序

image-20211221140934273

想法特别朴素。一句话:两队变一队。

与堆排序一样,归并排序也是一种选择排序

它的好处是:

  1. 是一种稳定的算法。相等的元素排序前后能保序。
  2. 不仅仅适用于内排序,外排序同样适用。

把两个有序的队列合并成一个。

算法朴素,及其容易理解。

美中不足:需要额外的存储空间来缓存。

算法原理

两队变一队,p, q 指向两个有序队列

  1. p, q 中取最小的,输出
  2. 如果p, q 都不空,回到 1
  3. 把 p, 或 q 的剩余部分输出

实现:

章节一:信息学竞赛 CPP 基础篇 - 图138

用两个数据区,来回归并。有序列不断增长。

尽量减少数据复制次数。

  1. // dst: 目标区,有足够空间; src , n : 源数据区
  2. void merge_(int* dst, int* src, int n)
  3. {
  4. if(n==1){
  5. *dst = *src;
  6. return;
  7. }
  8. int m = n / 2; // 前半部分可能略小
  9. merge_(src, dst, m);
  10. merge_(src+m, dst+m, n-m);
  11. int* p = src;
  12. int* q = src + m;
  13. while(p < src+m && q < src+n){
  14. if(*p <= *q)
  15. *dst++ = *p++;
  16. else
  17. *dst++ = *q++;
  18. }
  19. if(p<src+m) memcpy(dst, p, (src+m-p)*sizeof(int));
  20. if(q<src+n) memcpy(dst, q, (src+n-q)*sizeof(int));
  21. }
  22. // 原地排序
  23. void merge_sort(int* data, int N)
  24. {
  25. int* buf = new int [N];
  26. memcpy(buf, data, N*sizeof(int));
  27. merge_(data, buf, N);
  28. delete [] buf;
  29. }

实时效果反馈

1. 归并排序的本质属于:__

  • A 交换排序

  • B 选择排序

  • C 插入排序

  • D 其它排序

2. 排序算法的稳定性,指的是:__

  • A 对于较大的数据量,不会崩溃

  • B 对于故意设计的畸形数据,算法效率依然没有显著降低

  • C 对于已经基本有序或逆序的数据,效率有很大提高

  • D 对于比较中相等的元素,其排序前后次序关系不变。

答案

1=>B 2=>D

折半查找

image-20211221142214136

折半查找的原理很简单,类似于:猜数游戏

不断所有可能的空间,每次大约缩小为原来的一半。

所以,对于大数 N, 次数约为: log(N) 级别。

这个次数不大,完全可以用递归法来解决。

简单插入排序配合上折半查找,效率在很多场合也是也是能接受的。

这个算法的优势:稳定性

并且,不需要额外的存储空间。

折半查找实现:

注意:折半查找不一定是找相等的元素,可能有更复杂的情况。

比如,我们的需求是:在 [begin, end] 有序区间内,找到第一大于 x 的位置。

如果找不到,返回end+1,

仔细思考:

这样才能保证之后插入排序算法的稳定性

任何微妙的设计上的变化有导致不稳定的可能性。

  1. // 查找 >x 的第一个位置,没有则返回 end+1
  2. int* find_pos(int* begin, int* end, int x)
  3. {
  4. if(begin==end){
  5. if(*begin>x) return begin;
  6. return begin+1;
  7. }
  8. int m = (end-begin)/2;
  9. if(begin[m]>x)
  10. find_pos(begin, begin+m, x);
  11. else
  12. find_pos(begin+m+1, end, x);
  13. }

简单插入排序实现:

章节一:信息学竞赛 CPP 基础篇 - 图140

  1. 在已经有序的区间中寻找要插入的位置。
  2. 把待插入数据保存在临时变量(此地一会儿要被踩踏了)
  3. 其位置及其以后都向后平移1,空出一个位置
  4. 把临时数据落入到空位置,此时有序区已经扩大1
  5. 重复1,直到没有待插入的数据
  1. //使用了二分查找的插入排序
  2. void insert_sort(int* data, int N)
  3. {
  4. int* p = data+1;
  5. while(p<data+N){
  6. int* q = find_pos(data, p-1, *p);
  7. if(q!=p){
  8. int t = *p;
  9. // overlap 移动!!
  10. memmove(q+1,q,(p-q)*sizeof(int));
  11. *q = t;
  12. }
  13. p++;
  14. }
  15. }

实时效果反馈

1. 关于折半查找,说法正确的是:__

  • A 对于数据规模很大的N,查找次数上限大约:${log_2N}$

  • B 如果有很多相同的数据,查找效率迅速下降

  • C 要求数据规模是 2 的整数次幂

  • D 折半查找需要额外的log(N)存储空间

2. 简单的插入排序配合折半查找,有何优势?__

  • A 当规模N很大时,排序时间接近常数,与N无关

  • B 可以应用到外排序中

  • C 排序时,比较时等值的元素间,能保证原来的顺序

  • D 当待排数据有序时,排序速度最快。

答案

1=>A 2=>C