- 信息学竞赛 CPP 基础篇
- c++语言概述
- 开发环境介绍
- 基本数据类型
- 变量及其作用域
- 常数与常量
- 类型修饰符
- 基本运算符
- 选择与分支
- switch
- while循环
- for循环
- 函数入门
- 指针入门
- 引用类型
- 数组入门
- 函数进阶
- 数组与指针进阶
- 数组名字的本质
- 数组元素搬运
- 插入排序法
- 二维数组
- 动态内存分配
- 筛法求素数
- 指针数组与数组指针
- const 修饰符
- break,continue,return
- 数学运算
- 表达式的特性
- 按位运算
- 数据类型转换
- c串入门
- 进制问题
- 加密解密入门
- c串的库函数
- c风格输入与输出
- 格式控制
- 文件分割与名字空间
- 递归入门
- 递归的构建方法
- 递归与循环的关系
- 经典问题-生成全排列
- 经典问题-所有组合
- 栈溢出
- 函数指针与回调函数
- 内存泄漏
- 程序调试
- 结构体
- 联合体
- 链表入门
- 带表头的单链表
- 双向循环链表
- 约瑟夫环
- 快速排序
- 树
- 二叉树
- 二叉排序树
- AVL树
- 堆排序
- 归并排序
- 折半查找
信息学竞赛 CPP 基础篇
c++语言概述
聊聊cpp的历史,背景,特点。。。
语言简史—里程碑
- 1972 c语言的最早版本,目标是开发unix系统
- 1989 c语言的第一个标准 ANSI C
著名的c89/c90
- 1980 贝尔实验室,==c语言 + 面向对象== ==>> 第一个c++版本
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 现代的设计理念
python ruby php js … 脚本语言更灵活、敏捷
c/c++一直保持大量的用户群,在操作系统,嵌入式,实时等关键领域垄断地位
c/c++ 历久弥新的原因?
平衡易用性(对人友好)和直达性(对机器高效)
简洁高效,非必要不过度抽象 ==>> 执行效率
- 兼容性很重要!!!
c++ 完全兼容 c,现代的编译器兼容上个世纪80年代的库
高质量的库,种类多,数量大,是资产,也是包袱。。。
编译速度? 通过预编译头等策略
c++语言的特性
编译型 编译 vs 解释,半编译,JIT
静态类型 编译器可以更多地介入和监督。 有利于:编译期间发现错误 有利于:编译器对目标代码进行深度优化
弱类型 编译器非必要不强迫。
源码保护 从产品很难复原出源码。维护著作权。
勿于浮沙筑高塔
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
- 命令行窗口
windows: cmd窗口,powershell
linux: bash, csh, ksh
编译环境 编译器是任务是:把源代码翻译成机器能执行的二进制指令
- 微软的: vs默认 cl.exe
- GNU的: gcc/g++
- 苹果的: clang 支持 c, c++, objective-c
- 第一个程序 helloworld
编辑器编写代码,并保存为 a.cpp
#include <stdio.h>
int main()
{
printf("hello!\n");
return 0;
}
- 编译为可执行文件: g++ a.cpp
- 执行exe文件: a
轻量 IDE
代码着色,自动编译,代码补全??
vscode: 现代,网页风格,主题丰富,插件丰富
vscode可以支持很多语言的开发,需要安装相应的插件。
vscode可以根据扩展名,自动识别语言的类型,给出插件建议。
一般按照建议,安装语言默认插件即可。
sublimeText:反应快,界面主题丰富,扩展强,安装容易,有商业支持
devCpp, codeBlocks 开源,简洁
devcpp 是开源产品,文件很小,无需安装。不用建立工程,直接对.cpp, .c 文件操作
devcpp 是很多竞赛,考试的指定编译环境
重型 IDE
重型的优势:
代码着色,代码补全,语法检查,提示等更准确
对软件的==工程化==开发支持好,比如调试,测试,重构,优化等
vc++6.0 国内(尤其高校)曾经十分流行
很古老了,win98时代的产品。微软出品,品质还是能保底的。尤其调试功能很容易上手。
vs系列
当然强大!一般是收费的。
visual studio 2010 社区版,学习,调试
QtCreator
Qt开发套件的标配工具,有开源版本。
对初学者,重量级武器唯一缺点:再小的任务也要先建立个项目,然后。。。
安装体积大,耗时。。。不算缺点
实时效果反馈
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
基本数据类型
概览
c++的数据类型分为:
基本数据类型:布尔,整数,浮点
派生类型:修饰类型,指针类型,用户定义的复合类型等
基本类型表
关键字 | 含义 | 说明 |
---|---|---|
bool | 布尔 | 表示是否的概念 false true 0 1 |
int | 整数 | 表示计数或数量 |
float | 单精度 | 近似表达实数的概念 |
double | 双精度 | 近似表达实数 |
char | 字符 | 代表一个ASCII码符号 -128 - 127 |
void | 无 | 表示没有,或者空的概念 |
基本类型占用空间
现在就开始实验,不要依赖于文档
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
变量及其作用域
变量好比一个小盒子,能容纳数据,有名字,有地址,有大小,类型。。。
两个重要概念:
- 生存期:什么时候分配内存,什么时候释放内存
- 作用域:在什么位置可见,什么位置不可见
全局变量
定义在函数外的变量,静态的生存期
- 全局变量可以初始化,可以用表达式
- 全局变量可以在多个函数间,多个文件间共享
- 全局位置不能执行非定义语句
int a = 100;
int b = 200;
int c;
c = a + b; // 编译错误!
int main()
{
cout << c << endl;
return 0;
}
此是反面教材, 不能在全局位置执行其它语句
变量可以在声明时初始化,也可以只声明。 当未初始化时,变量的值是随机的,此是很多bug发源地
局部变量:
函数内部定义,函数执行时存在
局部变量可以和全局同名,会产生覆盖效果
变量赋值
赋值是最常见的动作,但其意义非凡!!
理解这个很重要,澄清将来的指针动作全靠它!
变量是一个小盒子,装着数据。
a = b; 的意思是:把 b 中的数据复制一份,放到 a 中。
a 中原来的数据被“踩死”了。
试试看,a b 现在值多少?
int a = 5, b = 8;
a = a + b;
b = a - b;
a = a - b;
cout << a << "," << b << endl;
……?????#$@
如何交换两个变量?
如何交换:酱油和醋,找个空瓶子
空瓶 = 酱油;酱油 = 醋;醋 = 空瓶;
【示例】交换 a, b 变量的值
int a = 5, b = 8;
int t = a; a = b; b = t;
cout << a << "," << b << endl;
这样做,还有什么缺点吗?
大括号作用域
大括号开辟了,新的一层,嵌套作用域
- 大括号作用域可以嵌套,实际上局部作用域就是==最外一层==大括号。
- 大括号作用域可以嵌套覆盖
- 编程习惯:尽量缩小变量的作用域。
更好的交换变量方法???
int a = 5, b = 8;
{ int t = a; a = b; b = t; }
cout << a << "," << b << endl;
实时效果反馈
1.在全局作用域==不==可以对变量做哪些事情?
A 声明变量
B 给变量赋一个初始值
C 在变量初始值中使用表达式
D 执行声明,初始化以外的其它语句
2.局部变量的作用域是?
A 本程序范围内
B 本函数范围内
C 最近一层的左大括号到右大括号
D 可以被全局变量覆盖掉
答案
1=>D 2=>C
常数与常量
先看看最粗略的内存结构。
常数
也叫立即数,或者“字面量”,就是硬编码到程序中的那些“死”数据。
字符常数 单引号:’A’, ‘x’ 有些字符无法表示,可以直接整数,或:转义字符
char a = 'A';
char b = 9;
char c = 66;
‘\t’ 与 9 是同等效果的 ‘\n’ 是什么?它的 ASCII 码是多少? 可以实验确定
char a = '\n';
int x = a;
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; 实际上相当于:
enum week{Mon, Tue, Wed, Thu, Fri, Sat, Sun};
enum week a;
这实际上是同时定义了三样东西: 类型: enum week 类型中的常数符号: Wed, Sat 等 该类型的变量: a
enum 的典型错误用法:
enum week{Mon, Tue, Wed, Thu, Fri, Sat, Sun};
int a = Thu;
//a = 100;
cout << a << endl;
可以通多 typedef 来定义enum的别名,避免定义多个变量时的尴尬。
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 开始废弃
static 修饰,静态空间,与全局变量同在
extern 跨文件的伪声明
并不分配内存,只是告诉编译器,全局变量会在另一个地方声明
内存的类型
程序分配的内存,主要在“栈”和“堆”这两部分。“堆”比“栈”复杂
- 只读代码: 存代码,也包括立即数,以及定常资源
- 静态空间: 存放static变量,全局变量,常量
- 栈区(stack): 自动变量,函数执行时的上下文环境
- 堆(heap): 程序运行中,动态地申请及归还
- 堆和栈的比较
比较项目 | 栈 | 堆 | 说明 |
---|---|---|---|
谁来管理 | 自动申请,自动释放 | 程序员代码申请,代码释放 | |
时间特性 | 后申请的,必然先释放 | 没有限制 | |
空间特性 | 所有申请的空间都是连续在一起的 | 空间上没有规律 | 可能产生能“碎片” |
大小限制 | 有大小限制,默认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;
本质上说,运算符就是一种函数,只是表现形式更加人性化
==目==的概念
目就是参加运算操作数的个数
- 主要有单目,双目,三目(只有一个运算符)
- 同一运算符也可以有多目
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
- if 的条件中可能会发生短路求值现象
if(a表达式 && b表达式) ...
当a表达式
为假的时候,b表达式
根本不会执行 同理,if(a表达式 || b表达式) ...
int a = 5, b = 8;
int x = 100;
if(a>10 && (x=b)){
cout << "here" << endl;
}
cout << x << endl;
单 if 语句
尽量使用这个语句,避免 else
试一试: 给定年份,求是不是闰年
bool leap_year = x % 4 == 0 && x % 100 != 0 || x % 400 == 0;
晕不晕?
可以采用渐进的方案,逐渐接近目标:假设修正法
if .. else
if 语句是可以嵌套的,嵌套是 bug 的发源地之一(哪个else 对应哪个if)
- 试一试:给定 a, b, c 三个数,求居中的一个(假设没有相等的情况)
int a = 5, b = 2, c = 8;
if(a>b){
if(b>c)
cout << b << endl;
else
if(c>a) cout << a << endl;
else cout << c << endl;
}
else{
if(b<c)
cout << b << endl;
else
if(c<a) cout << a << endl;
else cout << c << endl;
}
有很多的改进方法:
- 使用函数,避免 if 嵌套
- 使用 ?: 表达式
- 使用”冒泡排序法”
// 伪代码
if(a>b) a <---> b
if(b>c) b <---> c
if(a>b) a <---> b
print(b)
?: 表达式
?: 是唯一的三目运算符
xxx = a ? b : c; //重点在求值,不在分支
与 if else 相比,它的重点是求值,而不是:执行一系列动作 改进的求居中方案:
int a = 5, b = 2, c = 8;
if(a>b && a>c) cout << (b > c? b : c) << endl;
if(b>a && b>c) cout << (a > c? a : c) << endl;
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 …
嵌套过多,维护不方便。
switch(表达式){
case 1:
代码块1;
break;
case 2:
代码块2;
break;
default:
默认处理
}
要求:
switch 圆括号中的值必为:整数,字符 或者 枚举类型
如果不满足这个要求,可以用表达式换算为符合要求。
示例: 对学生成绩进行分类,输出:A, B, C, D, E
5个等级
分类标准:
级别 | 分数范围 | 备注 |
---|---|---|
A | 90-100 | 相当于:优秀 |
B | 80-89 | 良好 |
C | 70-79 | 中等 |
D | 60-69 | 及格 |
E | 0-59 | 不及格 |
int score = 85;
char level = 'E';
switch (score / 10) {
case 9:
case 10:
level = 'A';
break;
case 8:
level = 'B';
break;
case 7:
level = 'C';
break;
case 6:
level = 'D';
}
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循环
循环是程序逻辑的关键点,是代码复杂度的源泉,需要谨慎对待
最可靠的循环—死循环
while(true){
执行一些动作;
}
配合的出口语句:break
if (条件) break;
示例: 对学生成绩进行分类,输出:A, B, C, D, E
5个等级
int score = 100;
int a = score - 60;
char level = 'E';
while(true){
if(a<0) break;
level--;
a -= 10; // a = a - 10;
if(level=='A') break; // 封顶
}
cout << level << endl;
示例: 已知正整数x,从低位到高位,输出每个十进制数位
提示:通过不断整除,消掉最后一位,直到变成0
while 和 do..while
do-while 循环并不常用,所有情况,可以用内部的break来解决
实时效果反馈
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 循环的语法:
for(初始化循环变量; 循环保持条件; 循环变量变化){
循环体
}
- 循环变量的生存期 循环变量仅仅在 for 语句范围内有效,是局部变量,放在栈中 循环变量的生存期,比循环体内的局部变量更长
相当于:
for语句{
循环变量 i ...
{
循环体内,可以有自己的局部变量
}
}
- continue 直接进入下一轮循环
if(条件) continue;
越过循环的某些轮次不做
示例 打印九九乘法表
可以用双层嵌套
for(int j=1; j<=9; j++){
for(int i=1; i<=j; i++){
int k = i * j;
cout << j << "x" << i << "=" << k << " ";
}
cout << endl;
}
示例 100以内的素数
素数:就是不能再等分的数,比如:2,5,19.... 最小的素数是2
设计思路:
for(n 从 2 到 100){
先标记:n 是素数
for(从 2 开始到 n-1 试着除 n){
如果能除尽 {
标记:n 不是素数
跳出循环;
}
}
if(标记为素数) 输出 n;
}
示例 分解质因数
90 = 2 x 3 x 3 x 5
设计思路:
for(i 从 2 到 n-1){
if(n 能除开 i){
输出 i
n = n / i
设法阻止 i 的自增行为
}
}
if(剩下的 n > 1) 输出 n;
实时效果反馈
1.关于for的循环变量,正确的说法是:
A 循环变量在每轮循环重新分配
B 循环变量只能是整数
C 循环变量不能是char
D 循环变量的生存期大于循环体内的局部变量的生存期
2. 下列关于循环的说法,正确的是:
A while 循环至少会执行一次
B for 循环会至少执行一次
C do-while 循环会至少执行一次
D 所有循环都可能一次也不执行
答案
1=>D 2=>C
函数入门
函数,在数学上的定义:对于输入的若干值,返回唯一的值。
函数的定义
返回值类型 函数名(输入参数列表 。。。){
函数体
return 返回值
}
例子:
int myadd(int a, int b){
int t = a * 10 + b;
return t;
}
调用它的方法:int x = myadd(5, 8);
函数定义时的参数,称为:形式参数,简称:形参
调用函数时,传给它的参数,称为:实际参数,简称:实参
一般的情况下,形参的改变不影响实参。
形参是局部变量,在栈中分配
形参的寿命小于实参
函数的用处
函数是重要的==抽象==手段
- 人类解决复杂问题的法宝:大问题分解为小问题
这就是著名的
自顶向下
的设计 - 避免重复做某件事,去掉冗余 (don’t repeat yourself) 重复是恶趣之始 拷贝是万恶之源
- 消除 if..else 的嵌套,增加
可读性
【三数取中】的例子,再次函数版本的实现:三个数居中 = 两两最大值的最小值
- 消除 循环嵌套,增加
可读性
【九九乘法表】的例子,再次函数版实现
// 输出乘法表的某一项
// row: 行号,i: 列号
void t99_item(int row, int i)
{
cout << i << "x" << row << "=" << row * i;
}
// 输出乘法表的一行
// row: 行号
void t99_row(int row)
{
for(int i=1; i<=row; i++){
t99_item(row,i);
cout << " ";
}
}
int main()
{
for(int i=1; i<=9; i++){
t99_row(i);
cout << endl;
}
return 0;
}
返回值
返回值可以没有, 类型写 void
有返回值的时候,调用方也可以当作没有来用。
有返回值的时候,忘记了返回会怎样?
实时效果反馈
1.关于函数的说法,错误的是:
A 使用函数可以避免重复代码
B 使用函数可以把使程序更容易读懂
C 使用函数可以把大问题分解为小问题
D 使用函数可以提高执行速度
2. 下列关于返回值的说法,正确的是:
A 有返回值的函数,调用方必须接收返回值
B 没有返回值的函数,调用方也可以假装接收返回值
C 有返回值的函数,如果忘记了返回,会返回默认值
D c++只能返回一次值(不能多个)
答案
1=>D 2=>D
指针入门
指针就是存放其它变量地址的小盒子 所说的地址,是虚拟内存的地址,本质上就是整数,默认以十六进制表示
指针的定义
类型 * 指针名
初始化: 指针 = &某变量
通过指针,访问被指向的变量
可以读取
myxx = *p;
也可以写入
*p = ...
要十分谨慎
通过指针写入是危险的,这是c++强大的原因,也是bug发源地
可以在参数中使用指针
形参是指针类型时,可以改变实参的值 我们可以利用这个特性,间接地实现多个返回值。
【示例】 交换连个变量
void swap(int* a, int* b)
{
int t = *a;
*a = *b;
*b = t;
}
【示例】 改写三数居中的例子,使用冒泡排序法
冒泡排序:
如果相邻的连个元素逆序,则交换它们
多次重复这个动作
伪代码如下:
int mid(int a, int b, int c)
{
// 我们心中的目标是: a <= b <= c
if(a > b) 交换a,b
if(b > c) 交换b,c
...
}
可以返回指针类型
避免返回局部变量的地址
int* f(){
int a = 100;
//....
return &a;
}
int main(){
int* p = f();
....
}
当我们拿到返回值的时候,变量 a 已经被释放了。
我们的指针指向了已经无效的内存,产生了:悬挂指针
这是很常见的bug之源
实时效果反馈
1.对指针类型,说法正确的是? __
A 虚拟内存的地址,本质上就是整数
B 对 cpu 的一种特殊的指令
C 程序的特殊区域
D c++语言独有的秘密武器
2. 悬挂指针是怎么产生的?
A 一个指针长时间没用,被回收了
B 一个指针指向了无效的内存
C 一个指针,其值为0
D 一个指针,长夜漫漫,黯然神伤
答案
1=>A 2=>B
引用类型
引用在语义上看,是:别名机制
在实现上,被转换为对指针的操作。
int a = 100;
int& b = a;
b++; //实际上就是 a++
变量可以看作是:内存中小盒子, 变量名可以看作是盒子的标签 引用则可以想象为盒子上的另一个标签 就是说,一个盒子有多少个名字
有了指针,为什么还要引入别名?
为了语义上更清晰,更接近人的思考习惯。 【改写】 swap 函数
void swap(int& a, int& b)
{
int t = a;
a = b;
b = t;
}
int main()
{
int a = 5, b = 8;
swap(a, b);
cout << a << "," << b << endl;
return 0;
}
引用是不能运算的(比如++,—),只能去引用原来的值 相当于给“指针”这匹野马,套上约束,使用上更安全。
不存在空引用,但空指针很普遍,是bug之源
创建时初始化,不存在随机垃圾内存现象
引用绑定了一个目标,不能中途更换,指针随便
引用可以作为函数参数,也可以是返回值
当作形参时,可以看作实参的别名 当作返回值时,注意不要引用已经释放的变量
返回不存在的对象的引用,与返回不存在的对象的指针,都是典型的错误。
实现多返回
表面上看,c++的函数只能返回一个值,无法实现多值返回。
实际上,我们可以变通。
【示例】 通过引用实现返回多个值的效果
编写一个函数,传入a,b,返回a除以b的商,和余数
提示:可以用参数接收返回值。
实时效果反馈
1.引用和指针的关系:
A 引用比指针操作更快
B 引用比指针有更多的操作,功能更强大
C 引用总是比指针更省内存
D 引用比指针更安全,更人性化
2. 哪个==不是==使用引用的优点
A 必须初始化,不可能有垃圾值
B 无法更换绑定的变量,防止误操作
C 不能进行++ —的运算,更安全
D 看起来更酷,更唬人
答案
1=>D 2=>D
数组入门
数组的由来
3个数求最大值
int a=5, b=2, c=8;
int m = a;
if(b>m) m = b;
if(c>m) m = c;
10个数求最大值
不可能定义 10 个变量啊!!
。。。要不 100 个数??
int a[9] = {9,7,2,5,3,6,1,4,8};
int m = a[0];
for(int i=1; i<9; i++){
if(a[i]>m) m = a[i];
}
cout << m << endl;
一个变量名,能操纵多个变量
通过==下标==(index)来操纵每个元素
下标从==0==开始到==n-1==
可以对元素==读==,也可以==写==
... = a[5]; // 读取
a[5] = ... // 写入
当心! c++ 不会去保证下标合理(不做运行时检查)
数组是什么?
数组就是存在一起的一组相同类型的变量
int a[3]; //定义含有三个元素的整型数组
- 每个变量的地址是==紧挨着==的
- 每个变量都是==相同==的类型(意味着占用相同大小的空间)
- 只有一个名字,才能==统一==操作
数组存在哪里?
答曰:栈
(更严格地说,还可以是静态空间,比如:全局数组)
一般情况下,数组是局部变量
局部变量在==栈==中分配空间
自动分配,自动释放
怎么知道数组占用多大空间?
两种方案?
元素个数 x 单个元素的大小
#define N 100
...
int a[N];
x = sizeof(int) * N;
sizeof(数组变量名)
#define N 100
...
int a[N];
x = sizeof(a);
元素初始化
指定元素个数
int a[3] = {10,20,50};
不指定元素个数(自动推断)
int a[] = {3,6,9};
指定值的个数少于元素个数
int a[10] = {10,20,30};
当心!
没有赋初始值的元素是==随机值==
拓展
==长定==数组,就是数组创建后,大小就==固定==了,无法改变
通常,大小直接常数,或define一个常数(==定长==数组,编译器知道大小)
// 怎样避免魔法数字??
#define N 10 // 老方案
const int N = 10; // 新方案(提倡)
c++99 中允许使用==变量==(运行时求值)
int n = f(2)+@$@#$@!...; //一顿操作猛如虎,最后得到一个数
int a[n]; // 初始化免谈,因为编译器也不知道到底有多大!!
动态数组是:开始时有个==初始==大小,将来可以==按需==扩大或缩小
许多高级语言中的数组就是这个。
通过==重新分配==内存,并==拷贝==旧的内容来实现。
实时效果反馈
1.下列说法正确的是__
A 数组的大小一经创建就无法改变了
B 数组中可以包含不同类型的数据
C 数组在定义时,必须赋给初始值
D 如果不对数组赋初始值,其值为0
2.对数组的下标,说法==错误==的是__
A 数组的下标总是从 0 开始
B 数组的下标最大为:元素个数-1
C 当访问a[N]的时候(N是数组a的大小),会产生下标越界
D 可以使用负数下标
答案
1=>A 2=>D
函数进阶
- 数学上的函数是:
输入数据 ===> 函数(加工机器) ===>吐出一个值
程序中的函数行为更复杂
主要表现在函数执行时,可能会影响调用者
也叫“副作用”
被调方(callee)如何影响主调方(caller)
返回值
这是最正经的渠道,调用方自己来决定如何使用返回值
可以:
x = f(...) // 接收返回值
也可以:丢弃
指针参数
参数类型为指针的话,就复杂了,callee 直接操刀。。。
形参是:指针的指针
比如:
int** p
这个就更恐怖了,实质与前述相同
形参是:引用
这个看着唬人,实际上最后,还是转换为指针的方案
指针形参
这种方式,好比调用方准备了一个盒子,把盒子地址传过去,被调方往里塞东西
讨饭的例子。。。
其本质是:两个指针指向同一个东西
void f(int* p) { *p = .... }
....
int x = ...
int* a = &x;
f(a);
【示例】数组排序
冒泡排序法
每一趟排序,把最大的归位
核心代码:
void bubble_sort(int* a, const int N)
{
for(int j=0; j<N-1; j++){
for(int i=0; i<N-j-1; i++){
if(a[i]>a[i+1]) swap(a[i],a[i+1]);
}
}
}
// 调用方:
int a[] = {3,5,9,2,1,4,6};
bubble_sort(a, sizeof(a) / sizeof(int));
指针的指针形参
这是为了修改调用方指针本身(不是指针指向的值),使得它指向所需的内容
【示例】
准备一个整数数组,准备2个指针 p 和 q
调用函数 f 后,使得p指向==第一个==负数,q指向==最后一个==负数
void f(int* a, const int N, int** p, int** q)
{
for(int i=0; i<N; i++){
if(a[i]<0) {
*p = &a[i];
break;
}
}
for(int i=N-1; i>=0; i--){
if(a[i]<0){
*q = &a[i];
break;
}
}
}
来看调用方
int a[] = {3, 5, -3, 9, -5, 2, 1, -1, -2, 4, 6};
const int N = sizeof(a) / sizeof(int);
int *p, *q; // int* p, q;
f(a, N, &p, &q);
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
数组与指针进阶
子曰:了解事物本质的方法是:观察事物的行为
数组名字的本质
准备两个数组 a, b,一个指针 p
观察下面的实验:
int a[3] = {1, 2, 3};
int b[3] = {1, 2, 3};
int* p = a;
cout << (a == b) << endl; // == 的含义?
cout << a << endl;
cout << b << endl;
cout << p << endl;
a = b; //编译不过去!
cout << sizeof(a) << "," << sizeof(p) << endl;
观察如下实验:
int a[3] = {1,2,3};
cout << a << endl; // 它的值是多少?类型?等同不?
cout << &a << endl;
cout << &a[0] << endl; // &(a[0])
结果相同,这三者==等同==不?
观察如下实验:
int a[3] = {1,2,3};
cout << a << endl;
cout << &a << endl;
cout << a + 1 << endl;
cout << &a + 1 << endl;
cout << (a == &a) << endl;
思考与结论:
数组不会作为整体来==比较==,或者==赋值==,输出的时候,与指针无二
数组名不能被赋值,它只是编译器记录的信息,不是变量。
数组名取大小,包含了数组长度信息;赋值给指针,则==丢掉==了这个信息
数组名取地址,得到一个指向整个数组类型的==指针==
重点:
指针的==值==,决定了它指向哪块数据
指针的==类型==,决定了它的运算行为(比如 +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 的含义
int a;
int* p = &a; cout << p << endl;
p++; cout << p << endl;
char b;
char* q = &b; cout << q << endl;
q++; cout << q << endl;
结论:
p + n 真实的增量是 n * sizeof(单个元素)
指针运算与[ ]运算的等效性
*(p + n) === p[n]
*p === p[0]
请反复验证:
指针可以当数组用;数组也可以当指针用
子曰:c语言中本没有数组,它只是指针的幻影
拓展:数组引用(用作参数的时候,有区别)
//作为形式参数时:
int (&p)[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
数组元素搬运
数组的拷贝
// 错误的拷贝方案
int a = {1,2,3};
int b = {7,8,9};
a = b;
需要自己定义函数 mycopy
mycopy(…) 如何安排 mycopy 的参数?关键是==长度==信息。
两种设计风格:
// 目标指针,源指针,拷贝个数
void mycopy(int* dst, int* src, const int n);
// 目标指针,源指针,源阻拦索
void mycopy(int* dst, int* src, int* src_end);`
它们都很灵活,可以支持部分拷贝
void mycopy(int* dst, int* src, int* src_end)
{
for(int* p = src; p < src_end; p++) *dst++ = *p;
}
void mycopy(int* dst, int* src, const int n)
{
for(int i=0; i<n; i++) dst[i] = src[i];
}
void show(int* a, const int n)
{
for(int i=0; i<n; i++) cout << a[i] << " ";
cout << endl;
}
int main()
{
int a[] = {1,2,3,4,5};
int b[] = {10,20,30,40,50,60};
//mycopy(b+2, a+2, 3);
mycopy(b+2, a+2, a+2+3);
show(b,6);
return 0;
}
数组拷贝大坑—overlap
当源位置与目标位置==重叠==的时候,我们的代码失效了
秘诀
拷贝的次序很重要!!
void mycopy(int* dst, int* src, int* src_end)
{
if(dst==src) return;
int n = src_end - src;
if(dst>src)
for(int i=n-1; i>=0; i--) dst[i] = src[i];
else
for(int i=0; i<n; i++) dst[i] = src[i];
}
数组元素的删除
- 数组元素的删除,就是把元素后面的内容,向前平移(本质=overlap拷贝)
数组元素的插入
- 插入一个元素,就意味着:需要把那个位置开始的后面内容,向后平移
- 本质是向后平移,空出位置
注意:
数组的结构决定了,它不适用于频繁地插入和删除,尤其数组很大的时候
如果需要,我们可以使用:==链表==,==哈希表==,==块链==等更复杂的结构
实时效果反馈
1.怎样把数组元素,正确地从地址A,搬运到地址B
A 从低地址到高地址拷贝每个元素
B 从高地址到低地址拷贝每个元素
C 先拷贝中间地址重叠的部分,后拷贝两边的
D 根据 A, B 的位置关系,决定拷贝的次序
2.关于向数组中插入元素, 说法正确的是__
A 调用 a.insert(索引,值);
B 插入元素后,数组变大了
C 先要把插入位置开始的元素都向后移动,为新来的元素腾出空间
D 在数组的任何位置插入新元素都是相同的代价
答案
1=>D 2=>C
插入排序法
问题:
给定一个数组,用简单插入法,排序。(模拟,扑克牌抓牌的动作)
要点
把一个元素插入到有序的表中,并让表的长度增长
方案
我们尽量不利用额外的空间。
- 可以把数组分开成两个区域,左边是排好序的,右边是待排序的。
- 从右边拿一个数,插入左边适当的位置
- 寻找恰当的位置
- 把那个位置的数据向后平移
- 放入插入的数据
- 重复2,直到没有剩余数据
场景
实现
int* find_pos(int* begin, int* end, int x)
{
for(int* p = begin; p<end; p++){
if(*p >= x) return p;
}
return NULL;
}
void mycopy(int* dst, int* src, int* src_end)
{
if(dst==src) return;
int n = src_end - src;
if(dst>src)
for(int i=n-1; i>=0; i--) dst[i] = src[i];
else
for(int i=0; i<n; i++) dst[i] = src[i];
}
void mysort(int* a, const int N)
{
for(int*p=a; p<a+N; p++){
int t = *p;
int* q = find_pos(a, p, t);
if(q!=NULL){
mycopy(q+1, q, p);
*q = t;
}
}
}
拓展
简单插入排序,比较的次数很多
我们可以采用==折半查找==来提高效率
实时效果反馈
1.关于简单插入排序,说法正确的是__
A 排序较慢,且与待排的数据情况无关
B 当数据基本有序时,排序会更快
C 当数据是==逆序==时,排序更快
D 当数据都相同的时候,排序最慢
2.关于空指针,正确的说法是__
A 空指针也是一个普通的指针,其值为0
B 空指针不指向任何位置
C 空指针指向它自己
D 空指针指向NULL
答案
1=>B 2=>A
二维数组
二维数组的存储规律
列优先,存完第一行,存第二行,。。。
二维数组元素访问方法
a[行号][列号] = ...
【示例】,填写一个数组,如下数据:
可以利用存储关系,用一维方式来填充
int a[3][6];
int *p = &a[0][0]; // == &(a[0][0])
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】
// 已知二维数组中的字母阵列,请输出如下图形:
A B C D
E F G H
I J K L
【实战2】
二维数组边沿初始化
把边数组的”边框“清零
void show(int* a, const int W, const int H)
{
for(int j=0; j<H; j++){
for(int i=0; i<W; i++){
cout << a[j*W+i] << " ";
}
cout << endl;
}
}
void clear_border(int* a, const int W, const int H)
{
for(int j=0; j<H; j++){
for(int i=0; i<W; i++){
if(i>0 && i<W-1 && j>0 && j<H-1) continue;
a[j*W+i] = 0;
}
}
}
//..... 调用处:
int a[3][6];
int *p = &a[0][0]; // == &(a[0][0])
for(int i=0; i<sizeof(a)/sizeof(int); i++) p[i] = i+1;
clear_border(&a[0][0],6,3);
show((int*)a,6,3);
实时效果反馈
1.关于二维数组,说法正确的是__
A 二维数组就是一个数组的元素本身又是数组
B 二维数组中,每行的元素个数可以不同
C 二维数组的数据可能分别存储在多个位置
D 二维数组作为参数类型传入函数时,保持了宽度和高度信息
2.二维数组的第x个元素,所在的列号算法是:
A x / 数组宽度
B x % 数组宽度
C x / 数组高度
D x % 数组高度
答案
1=>A 2=>B
动态内存分配
内存的分配与释放
栈空间(stack)的分配与释放是自动进行的,是隐式的
堆空间(heap)的分配与释放是代码显式操作行为
堆像辽阔的海滩,我们可以随意挖坑,也别忘记了填坑。
有的坑特别巨大,有的很小,有的保留一年,有的三秒消失…..
使用场合(目的)
不能事先确定需要多少空间,以及需要占用多久
- 比较栈:事先可以确定变量到底占用多少空间,也能确定用多久。
(后来的必定比先来的短命)
需要的空间太大,栈不够用
内存的管理的一般原则:谁申请,谁释放。但有时无奈。
函数的设计模式,当我需要一些数据(好比需要一些肉),
- 直接返回在栈中,用完即可自动释放
- 调用方提供空间,并负责释放:”把肉放在我的碗里“
- 函数方提供碗和肉,调用方使用完毕后,别忘记归还碗(动态申请的最普遍用法)
使用方法
malloc用法:
void* malloc(申请字节数);
需要include头,一般可以用 stdlib
- 需要自己计算到底需要多少字节内存
- 返回的指针需要强制
- 不会做初始化,返回的内存中的内容是随机值
- 配合 free, 千万不要忘记释放
int a[3] = {10,20,30};
int* b = (int*)malloc(sizeof(int) * 3);
for(int i=0; i<3; i++) b[i] = a[i];
cout << b[2] << endl;
free(b);
new/delete 用法:
- new 类型 [ 个数];
- 返回元素类型的指针,不用强制转换
- 配合 delete [ ] 使用
==当心!== 释放时,别忘记了方括号
int a[3] = {10,20,30};
//int* b = (int*)malloc(sizeof(int) * 3);
int* b = new int [3];
for(int i=0; i<3; i++) b[i] = a[i];
cout << b[2] << endl;
//free(b);
delete [] b;
实例
设计一个函数,传入整数,返回所有的因子
例如:传入30,则返回 [2,3,5,6,10,15]
设计策略,被调用方动态分配内存,主调方用完释放。
int* get_factor(int x)
{
int n = 1; // 多分配一个用于结束标志
for(int i=2; i<x; i++) if(x%i==0) n++;
int* data = (int*)malloc(n * sizeof(int));
n = 0;
for(int i=2; i<x; i++) if(x%i==0) data[n++] = i;
data[n] = -1;
return data;
}
// 以下是调用方:
int* a = get_factor(60);
for(int* p=a; *p>0; p++) cout << *p << " ";
cout << endl;
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
筛法求素数
到目前,最古老的筛法,仍然是求素数的最有效解法之一。
普通素数算法
核心是:bool is_prime(int x)
如果 x 是素数,就返回 true
bool is_prime(int x)
{
for(int i=2; i<x; i++) if(x%i==0) return false;
return true;
}
小知识
素数的密度, 当n很大时,在 n 附近为: $$ p(n) = \frac{1}{ln(n)} $$ 也就是说,在10000附近,大约 ln(10000) ~= 9.2 个数中出现一个素数
n 越大,这个数字越准确。
筛法
【示例】求第1000000(百万)个素数
在数组中放入自然数,划掉2的倍数,。。。
从2开始,找到第一个没有划掉的数 3,划掉所有3的倍数
。。。
// 把p指向位置数字的整数倍划去
void put_tag(int* p, int* end)
{
for(int*q = p + *p; q<end; q += *p) *q = -1;
}
// 从当前位置找一个最靠近的没有划去的数字
int* next_pos(int* p, int* end)
{
while(++p<end && *p <0);
return p<end ? p : NULL;
}
// 取得第 x 个素数
int get_nth(int x)
{
const int N = x * 20; //需要筛选 N 个数字
int* a = new int [N]; // 堆上分配
for(int i=0; i<N; i++) a[i] = i+2; //初始化
int* p = a; // 标尺,即将标记它的所有倍数
while(p!=NULL){
put_tag(p, a+N);
p = next_pos(p, a+N); // 指向下一个没有被划掉的数
}
int t = -1;
for(int i=0; i<N; i++){
if(a[i] > 0) x--;
if(x==0) {
t = a[i];
break;
}
}
delete [] a;
return t;
}
实时效果反馈
1.素数的出现规律是__
A 素数的出现不确定,没有一定规律
B 在某个局部没有规律,但总体上数越大越稀少
C 按指数规律减少
D 按线性规律减少
2. 求第n个素数时,为什么要动态分配内存?
A 因为不确定需要多少内存
B 这个内存需要一直保留,函数返回也不释放
C 内存的申请者和释放者不在同一函数里
D 内存可能很大,栈空间不够
答案
1=>B 2=>D
指针数组与数组指针
指针数组
指针数组是一个数组,它的每个元素都是指针,这个指针可以是任何类型
当然,也可以是指向另一个数组的指针(可以仿造出类似java中的不等长数组)
int* p[9]; // p 是含有9个元素的数组,每个元素类型为 int*
p[0] = new int[1]; // 第一个指针指向:一个元素的数组
p[1] = new int[2]; // 第二个指针指向:二个元素的数组
【示例】用指针数组,保存如下的三角矩阵:
这只是用于教学的例子,当只有这么少的数据的时候,没有必要大动干戈
这里假设我们锱铢必较,一定要节省每一个字节的内存
int** get_table()
{
int **p = new int* [9+1]; //多分配一个,末尾存NULL
for(int j=0; j<9; j++){
p[j] = new int[j+1+1]; //多分配一个,开始存个数
p[j][0] = j+1;
for(int i=1; i<j+1+1; i++) p[j][i] = i*(j+1);
}
p[9] = NULL;
return p;
}
注意: 当我们返回给调用者一个动态申请的内存时,需要想办法告诉它内存的大小信息。
因为,返回的指针本身不携带任何额外信息。
调用方,用完如何释放
int** p = get_table();
cout << (p[4])[0] << endl;
for(int** q=p; *q; q++) delete [] *q;
delete [] p;
数组指针
相对而言,数组指针用的比较少。
它的定义形如: int (*p)[5]
它的类型是: int (*)[5]
这里,使用括号,是因为:[ ]的优先级高
用途
可以用于,指向二维数组中的第二维数组;
它本身携带了宽度信息(不是指针携带,是指针类型携带,这个类型是给编译器看的)。
但,鉴于它==怪异==的类型,一般初学者不喜欢,宁可多传递一个宽度信息。
【示例】修改二维数组每行的第一个数
//n 是二维数组的高度信息,宽度信息在指针类型中携带了
void f(int (*p)[3], int n)
{
for(int i=0; i<n; i++){
p[i][0] *= p[i][0];
}
}
调用方:
int a[3][3] = {{1,2,3},{4,5,6},{7,8,9}};
f(a,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 修饰符
修饰的花样
const 可以放在被修饰类型的左边或右边,默认是向左结合的
- 当修饰普通变量的时候
const int M = 5;
int const N = 6; // 本来这个是标准写法,但看着别扭
- 当修饰数组的时候:
数组名本身就是read-only的,其含义只能是修饰元素
const int a[] = {5}; //修饰的是 int
int const b[] = {6}; //同上,修饰 int
b[0]++; //会被编译器阻止
- 当修饰指针的时候,就复杂了
int x = 5;
const int * p = &x; //修饰int, 元素不能变,也就是保护x
int const * q = &x; //同上
int * const r = &x; //修饰的是指针,r将来不能指向别人,但x可以改
甚至可以用多个const
int const * const p = &x; // 指针,被指向的东西都不能改
修饰的目的
修饰,为防止意外的修改。相当于”加锁“,
防止他人修改的企图
防止自己无意修改
来看串拷贝的定义:
void StringCopy(char* strDestination, const char *strSource);
// 加const, 防止拷贝函数把源数据破坏了
修饰符在函数中使用
可以作用于参数,也可以作用于返回值。
当是简单类型的时候,修饰没有意义
void f(const int a){ .... }
....
int x = 10;
f(x); // 函数的执行过程: 创建形参,实参拷贝到形参
演示:函数调用过程
void f(x, y){ .... }
...
f(a, b);
当有指针的时候,修饰指针本身没意义,修饰被指向的元素有意义
void f(int* const p); // 这个p是复制过来的,改了又如何?
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
它们都是流程控制的语句,还有goto
break, continue, 只能作用于最内层的循环(break还可以跳出switch分支)
return 可以跳出多层循环
恰当使用,可以使代码简洁
【示例】100以内的素数
for(int n=2; n<100; n++){
bool is_prime = true;
for(int i=2; i<n; i++){
if(n % i == 0){
is_prime = false;
break;
}
}
if(!is_prime) continue;
cout << n << " ";
//if(is_prime) cout << n << " ";
}
cout << endl;
试着按下方要求,改变代码的表述方式
- if( xxx == true) 有问题不?
- 已经发现了 is_prime为 false, 还有必要再下一个 i 吗?
- 可以在 打印 n 之前,不用 if 吗?
- 可以去掉 is_prime 变量不?
- 可以不用 break 不?
可以通过定义函数来避免复杂度高
bool is_prime(int x)
{
for(int i=2; i<x; i++) if(x % i == 0) return false;
return true;
}
int main()
{
for(int n=2; n<100; n++)
if(is_prime(n)) cout << n << " ";
cout << endl;
return 0;
}
用 goto 也可以简洁:
for(int n=2; n<100; n++){
for(int i=2; i<n; i++) if(n % i == 0) goto noprint;
cout << n << " ";
noprint: ;
}
注意
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
数学运算
头文件
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() | 产生一个伪随机数 | 只要种子相同,每次序列是一样的 |
【示例】求三角面积
已知了三个边长,用海伦定理
海伦公式:${S = \sqrt{p(p-a)(p-b)(p-c)}}$ , 其中,${p=\frac{a+b+c}{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) $$
double a[3][2] = {{2,4},{5,2},{1,1}};
for(int i=0; i<3; i++){
a[i][0] -= a[2][0];
a[i][1] -= a[2][1];
}
double s = fabs(a[0][0]*a[1][1] - a[0][1]*a[1][0]) * 0.5;
随机数
rand() 产生 0 ~ RAND_MAX 之间的一个伪随机整数
这个序列每次都一样,这样==方便测试==
当需要不一样的时候,可以用初始化==随机种子==
srand(time(NULL))
【示例】洗牌动作
void shuffle(int* begin, int* end)
{
int n = end - begin;
srand(time(NULL));
for(int i=0; i<100; i++){
int a = rand() % n;
int b = rand() % n;
{int t=begin[a]; begin[a]=begin[b]; begin[b] = t;}
}
}
实时效果反馈
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
表达式的特性
==表达式==可以做的事情,函数也能做。
但,为了简洁
a = 2 + 3;
vs. a = myadd(2,3);
一个表达式==必须==要返回一个值
一个表达式也可能会==改变==变量的值
int a = 5; // 这是语句
// 因为你不能这么写:
x = (int a = 5);
=
是运算符,因而,a = b
是表达式
a = b; // 这是表达式
// 因为你可以这么写:
x = (a = b);
// = 是右结合,所以,括号可以省略
x = a = b;
if(a = b) { .... } // 这是什么含义
// 1. 把 b 的值赋给 a,如果这个值不是 0, 就。。。。
// 2. 也很可能是想写 if(a==b) 笔误了
当心!
不小心把 == 误写为 =,就会掉入这个坑。
?:是运算符,而 if … else 是语句
x = a > 3 ? 0 : 1 // 与下边的等价:
if(a > b)
x = 0;
else
x = 1;
// 但是你无法这样写:
x = if(a>3) 0 else 1;
++ — 运算符的特殊性
前加,后加的概念
x = ++y; // 含义是:
y += 1;
x = y;
x = y++ // 含义是:
y0 = y;
y = y + 1;
x = y0;
++是试金石,它直击表达式的两个要害处:副作用和返回值
++ — 的优先级很高,与 * !&
是同级别的,并且右结合
所以,*p++
的意思是:*(p++)
惯用法
p ,q 都是指针
找到第一个正数:
while(*p <= 0) ++p;
把 p 位置的内容拷贝到 q 位置,直到碰上了负数
while(*p >= 0) *q++ = *p++;
把 p 位置的内容拷贝到 q 位置,直到 p 指针撞墙
// end 也是指针,放在p的必经之路,阻止p的前行
while(p < end) *q++ = *p++;
把 p 位置的串拷贝到 q 位置
while(*q++ = *p++);
这个很巧妙,最后一个结束符’\0’,恰好就是0,恰好就是 false
实时效果反馈
1. 相比于:*x=*y; x+=1; y+=1;
,x++ = y++; 的优点是:
A 功能更强大
B 效率更高,机器算得快
C 更简洁,增加可读性
D 更酷炫,适合装x
2. 下列代码后,x 的值是__
int x=0; x = x++ + x++;
A 0
B 1
C 2
D 3
答案
1=>C 2=>B
按位运算
性别,开关,已完成/未完成,都可以表示为布尔量 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 (按有符号计算) |
有符号和无符号的移位区别
char a = 0xff;
a >>= 1; // 此时 a 内值为: 0xff
unsigned char b = 0xff;
b >>= 1; // 此时 b 内值为: 0x7f
实用技巧
奇偶性判断
if(n % 2) ...; //如果是奇数
if(n & 1) ...; //如果是奇数
交换两个数(不借助临时变量)
int a = 5, b = 8;
a = a ^ b;
b = b ^ a;
a = a ^ b;
p, q, r 是三个指针,把其中的两个指针通过x ,y传入,需要计算出第三个指针
int n = p ^ q ^ r;
void* s = (void*) (n ^ x ^ y);
按位存储bool数组
// 把第n位设置为x
void set_nth(unsigned char* data, int n, bool x)
{
int a = n / 8, b = n % 8;
if(x)
data[a] |= 0x80 >> b; //目标位为1,其它为0
else
data[a] &= ~(0x80 >> b); //目标位为0,其它位为1
}
// 取得第n位的值(0或1)
bool get_nth(unsigned char* data, int n)
{
int a = n / 8, b = n % 8;
return data[a] & (0x80 >> b);
}
// 主调方需要准备bool数组的存储空间
int main()
{
unsigned char data[20];
set_nth(data, 100, true);
cout << get_nth(data, 100) << endl;
return 0;
}
实时效果反馈
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
数据类型转换
静态类型意味着:
变量类型自始至终不变
在程序执行前,编译器就知道变量的类型
类型决定了:能对变量进行哪些操作
弱类型
相对于强类型而言,弱类型不拘束于类型
可以把 A 类型的东西硬当成 B 类型来用
有什么用处?
【示例】 请问 int a = -25, 在内存中怎么存的?
int a = -25;
unsigned char* p = (unsigned char *)&a;
cout << hex << (int)p[0] << endl;
cout << hex << (int)p[1] << endl;
cout << hex << (int)p[2] << endl;
cout << hex << (int)p[3] << endl;
可以观察到:
- 一个int, 硬是当成 4 个unsigned char 来看待
- char 的默认输出是打印字符的对应字形,我们想看的是ascii值,强转为整数
- windows上的字节序:低地址,低字节(Little Endian)
- 负数的补码表示:符号位为1,其它位取反,再加1
类型自动转换
有些类型间的转换危险较小,为了方便,可以自动转换
char -> short -> int -> long -> long long -> float -> double
- 小类型向大类型转的时候,可以自动转换
- 整数常数不加说明,默认为 int
类型强制转换
浮点数的取整
- (int)x 会丢弃零头部分,只保留整数部分
对负数要注意,是向着原点方向取整的
更科学系统的方案是math.h中的,ceil floor
四舍五入 +0.5
指针类型的强制转换
- 任何类型的指针,本质都是一个整数而已,物理上都能转换
void p 类型只是限制了 p 这样的动作,与其它指针无异
把某种类型的指针,转为 void* 会丢失类型信息,但实际上没进行任何其它动作
// 指针的本质就是一个整数,
// 只不过,观念上,编译器认为它是某块数据的地址
void *p = (void *)1234567;
cout << p << endl;
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* 是串吗?
char a = 'A';
char* p = &a; // p 与 int* 性质相同,本身并不是串
char b[] = {'A', 0}; // b 是串,因为它所指向的区域,以0为结束符
char c[] = {'A', 0, 'B', 'C'}; // c是串,且与b同
我们看到:串是:char* + 零结尾的约定
- 串的存储类型?栈?堆?静态?
char a[] = {'A','B','C','\0'}; // 串在栈中
char* b = new char[4];
b[0] = 'A'; b[1] = 'B'; b[2]='C'; b[3] = '\0'; //串在堆中
char* c = "ABC"; //串在只读区
char d[] = "ABC"; // 串有两份,只读区,且拷贝到了栈中
- 零的 3 种形态
0, 暗示为 int 类型
‘\0’, 暗示为 char 类型
NULL, 暗示为 * 类型
- 空串可不是空指针
char* a = "";
char* b = NULL;
串的赋值
串的赋值,并没有拷贝串的内容,只是拷贝了==指针==!!!
向函数中传递串,传递的是指针,不是串的拷贝
两根绳子牵着同一宠物
char* p = ....;
char* q = p; // 没有拷贝串的内容,只是复制了指针
参数在函数间传递就是这个效果,实参拷贝给形参
串的拷贝
自己写的拷贝:
while(*dst++ = *src++);
不够严谨,不能支持 overlap情形
使用标准库string.h
中的:strcpy,试试,依然有问题!!
char a[6] = "ABCD"; //多分配了一个冗余空间,为了...
strcpy(a+1,a); //重叠拷贝
cout << a << endl;
解决方案:
- 自己写个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
进制问题
进制问题只存在于数字的显示,而不是数字的存储(存储总是2进制)
数值本身总是用二进制的形式存储的
任意进制的转换问题
数 ==> 串
串 ==> 数
正整数的十进制显示
void itodec(char* buf, int x);
int main()
{
char buf[100]; // 主调方负责提供足够的空间
itodec(buf, 5301); //被调方负责往 buf 中写
cout << buf << endl;
return 0;
}
正整数的2进制,16进制
10进制简单,10 => 2
16代码稍加改变即可:
void itobin(char* buf, int x)
{
char* p = buf;
do{
*p++ = (x % 2) + '0';
x /= 2;
}while(x>0);
*p = '\0';
reverse(buf, p);
}
void itohex(char* buf, int x)
{
char* p = buf;
do{
int t = x % 16;
*p++ = t<10? t + '0' : t-10+'A';
x /= 16;
}while(x>0);
*p = '\0';
reverse(buf, p);
}
10进制数字串转为int
int dectoi(char* p)
{
int x = 0;
while(*p){
x = x * 10 + (*p-'0');
p++;
}
return x;
}
实时效果反馈
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
加密解密入门
明文 + 密钥 => 密文
密文 + 密钥 => 明文
最简单的加密法
循环移位法
字母平移,其它符号不动
void encode(char* p, int k)
{
while(*p){
if(*p >= 'a' && *p <= 'z'){
*p = (*p - 'a' + 26 + k) % 26 + 'a';
}
p++;
}
}
int main()
{
char buf[] = "I love beijing tian an men";
encode(buf, 5);
cout << buf << endl;
encode(buf, -5);
cout << buf << endl;
return 0;
}
只要一个encode就可以了,同时用于加密和解密
异或法
void encode(char* buf, const char* key, int n)
{
for(int i=0; i<n; i++) buf[i] ^= key[i];
}
int main()
{
char buf[30] = "I love beijing tian an men";
const char* key = "@#$#$%##$%#^$%%%3@#$@$@#$#$$#@#@$@$@#$RTERTE";
encode(buf, key, 30);
buf[30-1] = 0; //封口
cout << buf << endl;
encode(buf, key, 30);
buf[30-1] = 0;
cout << buf << endl;
return 0;
}
数字签名
也称为数字指纹。正式名字是:==摘要==
目的是:防止明文数据被篡改
明文 ==> 哈希算法 ==> 固定长度的摘要(比如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"
注意用双引号,不是尖括号,表示相对路径,尖括号为:系统路径
拓展
正式环境,使用 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串的库函数
求长度:
strlen(s)
查找字符
char buf[] = "abcde";
cout << strchr(buf,'c') << endl;
cout << (char*)memchr(buf, 'c', 6) << endl;
还有 strrchr(s, c) 是从后往前找第一个
- 查找子串
char buf[] = "abccde";
cout << strstr(buf, "cd") << endl;
- 拼接
char buf[100];
const char* s1 = "abc";
const char* s2 = "1234";
strcpy(buf, s1);
strcat(buf, s2);
cout << buf << endl;
建议用strncat代替strcat,多传入一个长度限制 n
建议用strncpy代替strcpy,多传入一个长度限制n
- 比较
cout << strcmp("abc","ABC") << endl;
相同,则返回值为0,第一个大,返回正数,否则负数
建议用 strncmp 代替strcmp,多传入一个长度限制n
- mem** 类的
memchr 类似 strchr,但忽略’\0’,传入长度 n
memcmp 类似 strcmp,传入n, 当成两个字符数组比较
memcpy 类似 strcpy, 但通过长度 n 控制拷贝个数
memmove 类似 memcpy,但可以正确地处理==重叠区域==的问题
memset 用一个字符填充一块内存区域
大小写转换
自己写吧,很简单
void to_lower(char* s)
{
while(*s){
if(*s >= 'A' && *s<= 'Z') *s += 32;
s++;
}
}
void to_upper(char* s)
{
while(*s){
if(*s >= 'a' && *s<= 'z') *s -= 32;
s++;
}
}
小试牛刀
【示例】
给定一个绝对路径表示的文件名,把它分为4个部分:盘符,路径,文件基名,扩展名
要仔细考虑各种可能的情况
bool splitpath(const char* s, char* buf, char**path,
char** base, char** ext);
内存惯例职责的设计:
主调方先提供一个足够大的 buf, 并传入待分解的 s
被调方,把分解好的各个部分都存到 buf中,其中盘符就存在buf起始位置,
每个部分都有 ‘\0’结束符
后3部分的首地址通过形参来返回
希望的使用方法:
char* s = "c:\\abc\\my\\path\\zhansan.back.txt";
char buf[300];
char* path;
char* base;
char* ext;
splitpath(s, buf, &path, &base, &ext);
cout << buf << endl;
cout << path << endl;
cout << base << endl;
cout << ext << endl;
bool splitpath(const char* s, char* buf, char**path,
char** base, char** ext)
{
char* p = strchr(s,':');
if(p==NULL) return false;
memcpy(buf, s, p-s);
*path = buf + (p-s);
*(*path)++ = '\0';
char* q = strrchr(s,'\\');
if(q==NULL) return false;
memcpy(*path, p+1, q-p);
*base = *path + (q-p);
*(*base)++ = '\0';
char* r = strrchr(s,'.');
if(r==NULL) r = (char*)s + strlen(s);
memcpy(*base, q+1, r-q-1);
*ext = *base + (r-q-1);
*(*ext)++ = '\0';
if(*r)
strcpy(*ext, r+1);
else
*ext = '\0';
return true;
}
这只是个初步的版本,还有很多细节:
比如,文件名是: 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风格输入与输出
在c++中,使用了面向对象的 cout, cin 来支持标准输入,输出。
它们是==对象类型==敏感的,会根据类型自动决定用什么样的格式。
与之相反,c风格的输入输出提供了最原始,最直接的控制方式,
需要我们手动指定:==格式==
printf
printf 把 c 语言的弱类型特点体现得淋漓尽致
数据是什么类型不重要,重要的是:你认为它是什么类型,或你把它==当作==什么类型
支持可变长度参数列表,格式串丰富强大,与后边提供的参数==数目要吻合==
int printf(const char * format, ...);
格式串是在==编译==阶段解析的,如果不合理,编译器会警告或阻止你
- 最基本的控制符号:
int a = 97;
printf("%d %X %c \n", a, a, a); // 97 61 a
const char* p = "abc";
printf("%s, %d, %x, %p\n", p, p, p, p);
//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 无符号 |
更丰富、细腻的格式控制
%[标志] [宽度] [.精度] 类型
方括号中的表示可有可无
double a = 125.34567;
printf("%f, %9.1f, %-9.1f, %09.1f \n", a, a, a, a);
scanf
int scanf(const char* format, ...);
// 格式与prinf类似
// 返回成功读取的项数
c/c++语言的惯用方案:主调方提供内存管理,把指针传给被调方
读入两个整数,求和
printf("please input 2 int: ");
int a, b;
scanf("%d%d", &a, &b);
printf("%d + %d = %d\n", a, b, a+b);
读入串
char buf[100];
printf("your name? ");
scanf("%s", buf);
printf("hello, %s\n", buf);
注意,%s 格式遇到空白就会结束,
因为空白字符(空格,回车,换行,tab)是默认分隔符
使用扫描字符集
%[….] 可以指定需要的字符集,属于这个集,则读入buf,不属于则中止,并写’\0’
scanf("%[a-z]", buf);
只认小写字母,遇到其它的结束
scanf("%[a-z ]", buf); //注意方括号里有个空格
只认小写和空格
scanf("%[^\n]", buf);
除了回车,啥都认(解决了前边不能输入空格的问题)
实时效果反馈
1. 关于printf,说法正确的是:__
A printf 格式串中格式数量与后边提供的参数个数不同,则编译器不通过
B prinf 格式串中使用了错误的格式,到运行时才会发现
C 我们提供的格式串中的类型,可以与对应的实际变量的类型不同
D 除了格式串,printf 必须至少提供一个要输出的数据项
2. 用scanf输入串时,遇到空格就结束,下列办法,哪个是==错的==:__
A 换用
%[^\n]
做格式串B 用 gets() 一次读取整行
C 用 %c 一次读一个字符,套上循环使用
D 找编译选项,让编译器忽略空格
答案
1=>C 2=>D
格式控制
sprintf
int sprintf(char *buf, const char *format, ...)
与printf类似,只是把结果写入到一个缓冲区。
缓冲区由主调方提供,并保证其空间足够。
返回的整数表示:成功写入了多少字节(不包括’\0’)
【示例】输出杨辉三角形
首先忽略格式,看规律:
1 1 1 1 2 1 1 3 3 1 1 4 6 4 1 1 5 10 10 5 1 下一行的等于上一行当前位置 + 前一个位置,末尾补 1
int a[20];
int k = 0; // 当前有效数据个数
for(int j=0; j<10; j++){
int t0 = 0, t;
for(int i=0; i<k; i++){
t = a[i]; //实现 a[i] = a[i] + a[i-1]
a[i] += t0;
t0 = t;
}
a[k++] = 1;
show(a,k);
}
show最开始版本
void show(int* a, int k)
{
for(int i=0; i<k; i++){
printf("%3d ", a[i]);
}
printf("\n");
}
先到串,然后一次输出
void show(int* a, int k)
{
char buf[200];
char* p = buf;
for(int i=0; i<k; i++){
p += sprintf(p, "%3d ", a[i]);
}
printf("%s\n", buf);
}
最终:
void show(int* a, int k)
{
const int w = 50;
char buf[200];
char* p = buf;
for(int i=0; i<k; i++){
p += sprintf(p, "%3d ", a[i]);
}
int sp = (w - strlen(buf)) / 2;
printf("%*s%s\n", sp, "", buf);
}
sscanf
与scanf类似,只是从字符串中读取
可用于把数字串,转为数字值。
const char* a = "125.67";
double d;
sscanf(a, "%lf", &d);
cout << d << endl;
实际上,简单的数字转换可以调用系统函数:
atoi
atof
sscanf 适用于更复杂的局面中提取数值
比如:[min=15.2, max=28.0] 中提取最高,最低温度
char* s = "[min=15.2, max=28.0]";
double a,b;
sscanf(s, "[min=%lf, max=%lf]", &a, &b);
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
文件分割与名字空间
把源文件分为两个部分:
==头文件==和==体文件==
头文件中包含了:函数调用格式声明,常量声明,类型声明等
体文件则包含具体的实现细节
为什么要分开?
- 源码保护,将来可以只提供 .o 文件和 .h 文件,不提供源程序
- 分离功能与实现,隐藏细节(这是面向对象的基本思维),便于工程化管理
编译过程:
- xx.cpp(包含了 .h) ==> 生成 xx.o 文件
- x1.o + x2.o + 库 ===> 生成最终产品(可以是 exe, 可以是动态库等)
- 其中如果有 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操作】
项目名字上,右键 | 添加新文件
添加: my.h, myc1.cpp, myc2.cpp
拷贝如下代码:
my.h
#ifndef MY_H
#define MY_H
// r: 半径,返回圆的面积
double get_area(double r);
// a, b: 矩形的长和宽,返回面积
double get_area(double a, double b);
#endif // MY_H
myc1.cpp
#include "my.h"
#include <math.h>
double get_area(double r)
{
return M_PI * r * r;
}
myc2.cpp
#include "my.h"
double get_area(double a, double b)
{
return a * b;
}
main.cpp
#include <iostream>
#include <string.h>
#include "my.h"
using namespace std;
int main()
{
cout << get_area(2.5) << endl;
cout << get_area(5,6) << endl;
return 0;
}
使用名字空间
名字空间可以解决名字冲突的问题。
不同来源的源代码被分割在不同的名字空间里,便于管理。
//.h 中
namespace ggg{
void f(int a, int b);
....
}
// .cpp 实现的时候
void ggg::f(int a, int b) .....
// 调用的时候
ggg::f(2,3);
// 可以省略 ggg::
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
递归入门
- 递归是解决复杂问题的有效并且优美的手段之一
通俗的说法:函数自己调用自己
函数可以调用任何其它的函数,当然也可以包括自己。
递归 ~= 数学上==递推法==
把一个大问题,分解为==与自己相似==的规模更小的问题
【示例】阶乘
一句话定义:n的阶乘,就是 n-1的阶乘 * n
long long fac(int x)
{
if(x==0) return 1;
return fac(x-1) * x;
}
【示例】斐波那契数列
一句话定义:后一项等于前2项之和
long long fib(int x)
{
if(x<2) return 1;
return fib(x-1) + fib(x-2);
}
递归注意
递归一定要有出口
在某种条件下,不再递归
出口多不怕,怕漏
递归有可能很慢
这是因为有重复计算问题
如何避免重复计算? 动态规划或哈希表缓存
递归有可能引发 stackoverflower
递归的深度要有个估计
【示例】 从 1 到 n 求和
int mysum(int x)
{
if(x<1) return 0;
return mysum(x-1) + x;
}
间接递归
有的时候,递归也很“委婉”
a 调 b, b 再调 a,形成间接递归
【示例】上台阶问题
n 层台阶,每步只能迈上 1 级或 2 级,要求用偶数步上完台阶。
共有多少种不同的方案?
可以扩展为两个问题:
n层台阶,奇数步完成
+ n层台阶,偶数步完成
int feven(int n); // 需要提前声明一下,与头文件原理类似
// 奇数步完成
int fodd(int n)
{
if(n<=0) return 0;
return feven(n-1) + feven(n-2);
}
// 偶数步完成
int feven(int n)
{
if(n<0) return 0;
if(n==0) return 1;
return fodd(n-1) + fodd(n-2);
}
实时效果反馈
1. 关于递归出口的说法,正确的是:__
A 间接递归程序不需要写出口
B 出口多了容易死循环
C 只能写一个出口
D 必须提供至少一个出口(不再递归)
2. 对递归程序的弱点,说法==错误==的是:__
A 可读性不好,代码冗长
B 容易因为重复调用而耗时
C 递归深度太大,可能会栈溢出
D 忘记写出口,造成死循环,最终栈溢出
答案
1=>D 2=>A
递归的构建方法
考虑相似性
设法把一个问题分解出相似性来,这是硬功夫
试着增加参数
如果实在弄不成形似问题,试着增加一些参数
考虑出口
开始可以多考虑些出口,有冗余,保险点儿
熟练后,可以提供精当出口
【示例1】组合数
从 m 个不同物体中,任意取 n 个,有多少种不同的取法?
我们来个“无中生有”法,
假设其中有 1 个球很特殊,结果集被分割为 2 部分。
// m 个球中,取 n 个
int qu(int m, int n)
{
if(n<0) return 0;
if(m==n) return 1;
return qu(m-1, n-1) + qu(m-1, n);
}
【示例2】打印字母金字塔
这样设计,可否找到相似性?
void ta(int 层数);
仔细观察,ta(层数-1) 与 ta(层数)有哪个不同的地方?
// sp: 塔底左边距,h:塔高
void ta(int sp, int h)
{
if(h<=0) return;
ta(sp+1, h-1);
for(int i=0; i<sp; i++) printf(" ");
for(int i=0; i<h; i++) printf("%c", 'A'+i);
for(int i=h-1-1; i>=0; i--) printf("%c", 'A'+i);
printf("\n");
}
实时效果反馈
1. 设计递归程序的两个要点是:__
A 相似性和出口
B 相似性和返回值
C 出口和返回值
D 问题本身的可递推性,以及问题的规模
2. 设计递归时,如果找不到相似性,一般问题出在哪里?__
A 问题分解的不够小,继续分解试试
B 可能参数太少,增加参数试试
C 问题本身不能递归解法
D 脑袋进水,出去晒太阳再继续
答案
1=>A 2=>B
递归与循环的关系
在逻辑层面,递归与循环是等价的。
任何循环形式,必然能用递归来表达。
面向函数的语言 haskell, erlang 等, 语言本身根本就没有循环语句,只能递归
循环改递归的练习
【示例1】打印 1~10 的数字
for(int i=1; i<=10; i++) cout << i << endl;
抽象为:打印 从 begin 到 end 间的所有数字
递归的秘诀:==踢皮球==
不要自己揽活儿,想办法把工作分配出去!
我做一点点 + 大部分 交给别人做
【示例2】 模仿 strlen()
请放弃循环,且代码不要超过 2 行
【示例3】 模仿 strstr()
bool mystrstarts(char* buf, char* s) //buf是否以s为头
{
if(*s=='\0') return true;
if(*buf=='\0') return false;
if(*s != *buf) return false;
return mystrstarts(buf+1,s+1);
}
char* mystrstr(char* buf, char* s)
{
if(*s=='\0') return buf;
if(*buf=='\0') return NULL;
if(mystrstarts(buf, s)) return buf;
return mystrstr(buf+1,s);
}
拓展:尾递归
如果一个函数的末尾一句话是调用另一个函数
void f(...)
{
....
return g(...); // 不能进行任何进一步的运算
}
有些语言会做尾递归的优化,直接优化为 goto语句,不会进行压栈调用
scheme 语言有严格的尾递归优化
g++ version 11, 编译器在 优化级别 -02 的时候,会进行尾递归优化
实时效果反馈
1. 递归与循环的关系,说法正确的是:__
A 单层循环可以改写为递归形式,双层的不可以
B goto造成的循环不能改写为递归
C while(true) {… } 这样的死循环无法改写为递归
D 理论上,所有循环都可以用递归来描述
2. 关于尾递归,说法==不正确==的是:__
A 有些语言环境支持尾递归的优化,有些不支持
B 尾递归优化的主要目的是:减少对栈空间的依赖
C c++ 完全不支持尾递归优化
D 尾递归就是在函数最后一句调用递归函数,但不可以放在表达式中
答案
1=>D 2=>C
经典问题-生成全排列
【问题】 “abcd”,生成它的全排列:
“abcd”,”abdc”,”acbd”,”adbc”, ….
想用递归?这个问题如何==踢皮球==?
考虑把某个字母放在首位,剩下的,求全排列。。。。
void swap(char* p, char* q)
{
char t = *p; *p = *q; *q = t;
}
void pai(char* buf, int k)
{
int n = strlen(buf);
if(k==n){
printf("%s\n", buf);
return;
}
for(int i=k; i<n; i++){
swap(buf+k, buf+i); //放置一个字符(试探)
pai(buf, k+1); //递归
swap(buf+k, buf+i); //恢复(回溯)
}
}
STL标准库的方案
// 需要 <algorithm> 头
char buf[] = "abcd";
do{
cout << buf << endl;
}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
经典问题-所有组合
【问题】从 m 个中取 n 个的所有方案
实际上,就是要组合每样东西的取或不取。
- 如果有 a, b, c 三个字母的情况,列出所有情况的最笨的办法:
三层 for 循环,每层取两个值:
for(int i=0; i<=1; i++)
for(int j=0; j<=1; j++)
for(int k=0; k<=1; k++){
if(i) cout << "a";
if(j) cout << "b";
if(k) cout << "c";
cout << endl;
}
- 稍微改一下,a,b,c,d 四个字母,取2个:
for(int i=0; i<=1; i++)
for(int j=0; j<=1; j++)
for(int k=0; k<=1; k++)
for(int l=0; l<=1; l++){
if(i+j+k+l != 2) continue;
if(i) cout << "A";
if(j) cout << "B";
if(k) cout << "C";
if(l) cout << "D";
cout << endl;
}
循环方案的弱点:
- 层数太多话,。。。。
- 如果实现不知道几个字母,是动态的,怎么办?
递归的方案
递归的想法也是考虑取和不取,但,
它只考虑局部,”大头“的任务交给”别人“做
// s: 待取串, n: 还要取几个, pick: 选哪些,k:考虑第k个物品
void zuhe(char* s, int n, int* pick, int k)
{
if(n==0){
for(int i=0; i<k; i++) if(pick[i]) cout << s[i];
cout << endl;
return;
}
if(k==strlen(s)) return;
pick[k] = 0;
zuhe(s, n, pick, k+1);
pick[k] = 1;
zuhe(s, n-1, pick, k+1);
}
void zuhe(char* s, int n)
{
int pick[100];
zuhe(s, n, pick, 0);
}
拓展:有重复和无重复
”AABBBCCDDDD“ 取3个,所有方案?
基本物品还是:”ABCD”,每个物品有个最大数目
只需要把前边的方案再加个参数 max数组,记录着每件物品的可取数目上限
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
栈溢出
栈 是程序的常务工作间
使用不当,会: stackoverflow
不同环境下表现不同。qtcreator里显示为 内存段错误。
什么情况下会栈溢出
例如:数组开得太大了
例如:递归调用的深度太大了,或者干脆就没有出口
观察栈的调用深度
以求阶乘为例:
int f(int x)
{
if(x==0) return 1;
return x * f(x-1);
}
qtcreator里可以:
- 设置调试断点: 鼠标点击代码最左边
- 单步跟踪执行
- 进入函数
- 跳出函数
- 查看栈图景 views | stack
自己写个函数,玩命递归,看结果:
解决办法:
类unix环境(unix,linux,苹果os)
可以通过设置用户的权限来指定 栈 的大小
这个观点是 栈 也像文件,cpu 一样是一种珍贵资源,需要定额配给
windows环境,栈的大小写在 exe 文件中,
可以用工具修改,也可以连接时指定。
可以在编译时 附加指令,
不同环境指令不同
较新的 win32-g++ 指令选项是: -Wl,--stack=16777216
(后面的数字是栈大小,此处为16M)
在qtcreator中,指定编译选项的办法:
打开 .pro文件,在尾巴上添加:
QMAKE_LFLAGS += "-Wl,--stack=16777216"
注意:大写W后是小写的L,可不是数字1
实时效果反馈
1. 关于栈溢出的原因,说法正确的是:__
A 可能是因为数组访问越界引起的
B 可能是因为使用了空指针去求它指向的值
C 可能是因为机器硬件上,内存太小
D 可能是因为递归函数忘记了写出口
2. windows上怎样设置栈的大小:__
A 配置用户的权限表
B 给编译器添加编译选项(前提是该编译器支持)
C 在源码中设置
D 运行exe时,加命令行参数
答案
1=>D 2=>B
函数指针与回调函数
正如,可以通过名字访问单个变量,也可以通过数组,访问一组变量;
通过函数名,可以调用单个函数,通过函数指针数组,可以调用一组函数
int myadd(int x, int y)
{
return x * 10 + y;
}
...
int (*p)(int, int); //定义了函数指针类型的变量p
p = myadd; //p指向函数
cout << (*p)(5,8) << endl;
使用函数指针,可以得到动态性
void f0(int a)
{
cout << a << endl;
}
void f1(int a)
{
cout << a + 1 << endl;
}
void f2(int a)
{
cout << a * a << endl;
}
typedef void (*MYF)(int);
int main()
{
MYF f[] = {f0,f1,f2};
int a, b;
scanf("%d%d", &a, &b);
(*f[b])(a);
return 0;
}
回调函数
在一个函数中,如果,执行什么动作是需要传递给我的信息,则可以选择用回调函数。
回调函数就是形参是函数指针,函数中,调用这个函数指针的用法。
【示例】
typedef int (*MYF)(int); // 函数指针类型繁琐,通常定义类型别名
int f1(int a) { return a*2; }
int f2(int a) { return a/2; }
// fp: 希望用户执行 f 的时候,动态传递给我的函数变量
void f(int x, MYF fp)
{
cout << "I will call fp: ";
cout << (*fp)(x) << endl;
}
回调函数有何实际用处?
比如:用户界面,但点击按钮执行什么动作,可以通过函数传递入主框架。
而关于如何相应用户输入的主框架是通用逻辑,一般是系统准备好的,
不需要每个应用程序员都去写框架。
比如:执行sort的程序,可能面对用户自定义的类型,它怎么知道——
两个元素谁大谁小?
正确的姿势是:传入一个参数,它是一个用户定义的函数,用于比较大小
【示例】更通用的冒泡排序
typedef int ITEM; // 将来可以换位任何用户定义的类型
void swap(ITEM& a, ITEM& b){ ITEM t=a; a=b; b=t; }
void bubble_sort(ITEM* buf, int n,
int (*fp)(ITEM& a, ITEM& b) )
{
for(int j=0; j<n-1; j++){
for(int i=0; i<n-j-1; i++){
if((*fp)(buf[i],buf[i+1])>0)
swap(buf[i],buf[i+1]);
}
}
}
/////-------以上是通用算法部分,排序过程与用户的具体问题无关---------
int f(int&a, int&b) // 用户自己提供的比较函数
{
return a%10 - b%10;
}
void show(int* buf, int n)
{
for(int i=0; i<n; i++) cout << buf[i] << " ";
cout << endl;
}
int main()
{
int a[] = {4,17,2,19,9,28,31,22,10};
bubble_sort(a,9,f); //比较方式,动态传入
show(a,9);
return 0;
}
实时效果反馈
1. 关于函数指针,说法正确的是:__
A 函数指针是一个函数类型的指针,通过它可以执行所指向的函数
B 函数指针是函数的别名
C 函数指针指向的函数不能有返回值
D 函数指针是c++的特性,c 中不能使用
2. 以下哪种情况,适合用回调函数?:__
A 不能确定参数的个数,运行时才知道
B 不能确定参数的具体类型,需要运行时才知道
C 不能确定需要执行的具体动作,需要用户从参数传入
D 不能确定所需内存的大小,是运行时计算出来的
答案
1=>A 2=>C
内存泄漏
内存如何泄漏?
在堆空间中分配的内存,忘记了回收,或者没有恰当回收,则产生泄漏
堆空间的特点:
- 可用空间很大,可以分配大块内存(越用越碎,产生内存碎片)
- 手工分配,手工释放,需要自己精心维护
- 任意时间分配,任意时间释放,生存期灵活
一般常用的调用:
malloc / free
new [ ] / delete [ ] (最终还是调用 malloc/free)
区别在于:malloc/free 是库函数,无法处理对象的更高级需求
new / delete 是运算符,可以重载
new 申请的内存可以初始化,自动调用构造函数
delete 释放时,可以自动调用析构函数
观察内存耗尽的现象
【示例】 设置内存分配失败的函数指针
为了当内存分配失败时,执行特定的行为,我们提供一个回调函数
void f()
{
cout << "there is no space any more" << endl;
}
在主程序中设置这个回调函数
set_new_handler(f);
然后准备一个动态分配内存的函数,在主程序中多次调用它
int* bad()
{
return new int[1000 * 1000];
}
// 以下在 main.cpp 中多次调用它,试着改变循环次数,看最大可以多少次
for(int i=0; i<490; i++){
cout << i << " ";
bad();
}
最常见的失误举例
malloc/free, new / delete 没有配对儿使用
可以静态检查,通过源码查找关键字发现
delete 忘记方括号
int* p = new int[15];
// dosomething...
delete p;
双层动态申请,忘记了内层
int** p = new int* [3];
p[0] = new int[20];
p[1] = new int[30];
p[2] = new int[40];
// do something here
delete [] p;
这个的主要危害,在于面向对象时,忘记了调用析构函数,对简单的 int 倒是无妨
正确的释放写法是:
for(int i=0; i<3; i++)
delete [] p[i];
delete [] p;
着急return, 忘记了资源
char* p = new char[1000];
char* q = new char[1000];
// do something here
delete [] q;
// do something....
if(...) return -1; // 忘记了还有 p 没放
delete [] p;
指针折腾,不小心把某个给“踩死”了
int* p = malloc(...);
int* q = malloc(...);
//....
if(...) p = q; // 此时, p 指向的内存再也不会释放
//....
free(q);
free(p);
如何检测是否泄漏?
各种第三方的检测方法,比如:微软的 vs 内置了检测手段(比较难用)
自己动手,丰衣足食。。。。
最原始的版本:
int MEMCT; // 全局变量,静态空间,自动初始化
void* my_alloc(int n)
{
void* p = malloc(n);
if(p==NULL) return NULL;
MEMCT++;
return p;
}
void my_free(void* p)
{
if(p==NULL) return;
free(p);
MEMCT--;
}
实时效果反馈
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
程序调试
观察程序行为的常见方法
- 单元测试
- 打印中间步骤的状态信息
- 单步跟踪调试
输出调试信息
printf (或者cout)可以输出,但有以下弱点:
与逻辑输出混在一起,无法统一管理
怎样重定向到文件中(log)
怎样方便地去掉调试信息?
怎样附加通用信息?文件名,时间,行号等
使用qDebug 代替 printf 或 cout 输出
需要选择Qt工程,暂时可选 qt console,没有GUI的工程
包含头文件:
#include <qdebug.h>
使用qDebug() 来代替 cout
它会在各个项间自动加空格,结尾自动加 ‘\n’
设置.pro 文件,阻止 debug输出
DEFINES += QT_NO_DEBUG_OUTPUT
需要切换,则使用注释
# DEFINES += QT_NO_DEBUG_OUTPUT
附加信息
__FILE__ // 文件名
__LINE__ // 行号
__TIME__ // 时间
跟踪调试
- 设置断点(单击,行号左边)
- 单步跟踪
- 观察变量
增加表达式(局部变量和表达式窗,右键 | 选第一项)
进入函数
- 跳出函数
- 观察栈信息(可点击,查看调用关系)
实时效果反馈
1. 使用qDebug的好处,说法==错误==的是:__
A 便于统一去掉或显示调试信息
B 信息输出形式书写更方便、快捷
C 可以方便显示 文件名,行号等信息
D 只在debug版显示,在release版本中不显示
2. 使用跟踪调试时,如何查看指针 p 指向的值?__
A 所有情况下,调试器都会自动显示指针指向的值
B 增加输出语句,打印指针指向的值
C 把鼠标放在 p 上不动,一会儿就会显示 p 指向的值
D 增加表达式,(类型) *p
答案
1=>D 2=>D
结构体
结构体是一种复合类型,也是一种用户定义类型
通俗地说,它捆绑多个变量,使之成为一个整体
比如,平面上点坐标:
struct Point{
int x;
int y;
}; // 新定义了一个类型,名字:Point,地位相当于:int
c++ 的结构体统一了 class 和 struct,不再需要typedef 才能定义结构体的类型
struct 与 class 的唯一区别是:struct 内默认都是public
用sizeof 可以查看结构体大小
struct 初始化
void show(Point& pt)
{
cout << "Point: (" << pt.x << "," << pt.y << ")";
cout << endl;
}
// 调用处 .....
Point a = Point{3,5}; //字面量生成“立即数”
Point b = {10,20}; // 类似数组赋初值
Point c = a; // 拷贝每个成员的意思
show(a);
结构体是值语义
a = b; 的结果是拷贝了b中所有项的数据到a,a,b数据是独立的
结构体作为参数传递时,值语义。
当结构体较大时,有时为了效率,我们可以传递引用
如果不需要修改结构体,最好用const修饰,增加安全性
访问结构体的成员
data.x
或者 p->x
void inc(Point* p) // 与主调方共享同一struct数据
{
p->x++; // 相当于:((*p).x)++
p->y++;
}
结构体中的指针和数组
结构体中使用指针类型数据,一定要当心,尤其是char*,最容易误用!
struct STU2
{
char* name;
int age;
};
// 使用:
char name[] = "zhangsan";
STU2 t1 = {name, 15};
STU2 t2 = t1;
cout << sizeof(t1) << endl;
t1.name[0] = 'X';
cout << t2.name << endl;
注意,出现了名字共享的现象。
结构体可以嵌套
struct Point{int x; int y};
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
联合体
联合体也是一种复合结构,用户定义类型
struct 可以看作成员的与
,union可以看作成员间或
的关系
也就是说,union的成员是共享同一块存储的。
各成员所占用的空间可以不等长,整体取最大的。
比如,
union MyIP{
unsigned char ipv4[4]; // 4 字节的IP地址
unsigned char ipv6[6]; // 6 字节的IP地址
};
union 经常和 struct 配合使用。
联合体的作用
多视角,不同的观点对待同样的数据
struct STU
{
char name[20];
int age;
double score;
};
union STU_U
{
STU stu;
unsigned char data[32];
};
使用时,
STU_U x;
strcpy(x.stu.name,"zhangsan");
x.stu.age = 15;
// x.data[...] ... 对其进行加密处理
假如没有 union, 这与直接用 (unsigned char* ) 有什么两样吗?
用union更优美,满屏的强制,看起来很不文明
更重要的,编译器知道我们的用意,能够提供更多的帮助
表达类型“或”的概念,可以同时容纳多种类型
如果区分?
一般在头部设置个 char tag 作为标记
更规范地,会定义为
enum
类型sizeof?
取最大宽度
可能存在字对齐问题
【示例】
表达图形的概念:
图形可能有很多种类:三角形,圆形,矩形,它们的数据结构各不相同
// 平面直角坐标上的点
struct Point{
int x;
int y;
};
// 圆
struct Circle{
Point pt; //圆心
int r; // 半径
};
// 矩形
struct Rect{
Point pt1; // 左上角
Point pt2; // 右下角
};
// 枚举图形的种类
enum ShapeKind{
CIRCLE, RECT, TRIANGLE
};
// 图形
struct Shape{
ShapeKind tag;
union {
Circle circle;
Rect rect;
};
};
如何使用呢?
Shape a;
a.tag = CIRCLE;
a.circle.pt = Point{5,5};
a.circle.r = 10;
// 使用 a:
switch (a.tag) {
case CIRCLE:
cout << a.circle.r << endl;
break;
default:
break;
}
实时效果反馈
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
链表入门
链表是复合类型,可以用struct定义它的每个节点
节点是递归类型,它的数据包含了指向本类型的指针
// 定义一个节点类型,包含了数据和指向下一个节点的指针
struct Node{
int data; // “乘客”
Node* next; // 这里是递归定义
};
注意,只可以包含 Node* 类型的变量,
不可以包含 Node next;
想想看,如果这样,sizeof(Node) = ????
节点的内存一般是动态申请的。
当增加数据的时候,申请内存。
删除数据的时候,释放内存。
对节点的常规操作:
求链表的长度
这是个耗时的动作,可以想办法优化
// 故意用了递归,从效率考虑,应该用循环
int get_len(Node* head)
{
if(head==NULL) return 0;
return get_len(head->next) + 1;
}
增加
// 在表头插入,返回新的head
Node* add_data(Node* head, int x)
{
return new Node{data, head};
}
为什么要返回?
为什么不在尾巴上添加?
查找
// 查找
Node* find_data(Node* head, int x)
{
if(head==NULL) return NULL;
if(head->data == x) return head;
return find_data(head->next,x);
}
删除
删除需要取得目标的前一个节点,原理:
// 删除值为x的首次匹配节点
Node* del_data(Node* head, int x)
{
if(head==NULL) return NULL;
// 如果是删除第一个,特殊处理
if(head->data == x){
Node* p = head->next;
delete head;
return p;
}
// 找到目标的前趋节点
Node* p = head;
while(p->next){
if(p->next->data == x){
Node* q = p->next;
p->next = q->next;
delete q;
return head;
}
p = p->next;
}
return head;
}
实时效果反馈
1. 怎样在链表表尾添加新节点?__
A 首先让表头指针指向表尾
B 首先让表尾指针指向表头
C 首先根据表头,找到表尾节点
D 首先把表尾next置为NULL
2. 当单向链表较长时,下列哪个操作特别耗时?:__
A 求链表的长度
B 在表头添加节点
C 删除表头
D 判断是否为空链表
答案
1=>C 2=>A
带表头的单链表
最简单链表的弱点:
- 在指定位置 插入、删除,都需要该位置的前趋节点
- 为了方便查找后进一步处理,查找后返回目标的前趋,可是还要兼顾“找不到”这个信息
- 每次操作后都要返回新的头节点很不方便。
- 删除和查找,逻辑重复,坏味道。。。
- 删除所有节点怎么处理?
增加一个头节点,即可解决很多问题。
// 查找,返回目标的前趋
Node* find_data(Node* head, int x)
{
if(head->next==NULL) return NULL;
if(head->next->data == x) return head;
return find_data(head->next,x);
}
插入,插入为 p 的后继
任意位置的插入
// 插入 q 为 p 的后继
void insert_node(Node* p, Node* q)
{
q->next = p->next;
p->next = q;
}
// 删除, p: 被删除节点的前趋,
// 返回被删除的节点
Node* del_node(Node* p)
{
Node* q = p->next;
p->next = q->next;
return q;
}
删除并不负责释放空间。这样设计很灵活。
因为,使用者可能想把某个节点摘下来,挂到其它地方去
动态内存的管理
从这个实际的例子看到:
- 只要可行,就优先执行:谁申请,谁释放 的原则
- 该原则,很难完全贯彻执行,分离的管理要小心、细致,很容易泄漏
- 照顾API设计的原则:尽量不要重复;不要冗余;尽量灵活;尽量易于使用
实时效果反馈
1. 下列哪个==不是== 增加表头节点,带来的好处:__
A 更方便地返回目标节点的前趋
B 可以减少NULL指针的判断
C 可以避免总是去更改head
D 可以更快找到尾节点
2. 动态内存管理的一般原则是:__
A 谁申请,谁负责释放
B 被调方申请,主调方释放
C 一个被调函数中申请,在另一个被调函数中释放
D 较早申请的,较先释放
答案
1=>D 2=>A
双向循环链表
增加表头后,单向链表仍有缺点:
- 从当前节点找它的前趋很吃力
- 从表头找到表尾很吃力,因而在表尾添加很麻烦
- 插入、删除时,总是持有当前节点的前趋,这很不直观。
解决方案:
没有免费的午餐,
牺牲空间,换来方便。。。。。
增加了一个 prev
数据域,与 next
呈对称之势
struct Node{
int data;
Node* prev; // 指向前趋节点
Node* next; // 指向后继节点
};
- 在栈中创建头节点的正确姿势:
Node head = {-1,&head,&head};
这是先让蛇咬住自己的尾巴,形成一个闭合的空环
- 在 p 节点之前,挂接 q 节点(插入)
先挂红指针,后挂粉色指针
// 插入 q 为 p 的前趋
void insert_node(Node* p, Node* q)
{
Node* t = p->prev;
q->prev = t; // 1. 新加入组织的先动
q->next = p;
t->next = q; // 2. 获得组织承认
p->prev = q;
}
- 删除当前节点
// 删除: p
Node* del_node(Node* p)
{
Node* p1 = p->prev;
Node* p2 = p->next;
p1->next = p2;
p2->prev = p1;
return p;
}
这里有个API设计技巧:
为什么要返回 p ? p 是主调方送来的,我还原样送回去?
为了 主调方的 接力写法
看这个删除所有节点的表达法:
// 删除所有节点(释放空间)
void del_all(Node* head)
{
while(head->next!=head) free(del_node(head->next));
}
实时效果反馈
1. 双向循环链表的好处,说法==错误==的是:__
A 可以快速找到尾节点
B 可以很方便在尾部添加
C 可以很快求出链表长度
D 可以很快找出当前节点的前趋和后继
2. 在双向循环链表中插入新节点 q,需要更改几个指针?:__
A 1
B 2
C 3
D 4
答案
1=>C 2=>D
约瑟夫环
【问题】
有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
快速排序
内排序介绍
交换排序
发现逆序则交换之。
最典型的是
冒泡排序
。比较高效的改进版:快速排序
选择排序
先选出最小的,剩下的再选最小的。
比较高效的改进版:
堆排序
插入排序
把新元素插入到已有的有序队列
比较高效的改进版:希尔排序
快速排序的想法
找一个标尺(一般就选第一个元素),把整个待排区间分成两部分。
一部分比标尺小,放左边,标尺放中间,剩余放右边。
两个子区间递归 ① 的做法。
这里要注意一个要点:一次划分后,两个子区间仍然无序,
可以充分利用这个特点,快速划分出这两个区间。
怎样划分两个区?
改进的方案:
从宏观看,“空穴”,左右跳跃,最后固定在某个位置,此时,把红色值填入。
实现
当数据不是很畸形的时候,分区的大小下降很快,可以用递归求解。
void qsort(int* begin, int* end)
{
if(begin>=end) return;
int t = *begin;
int* p = begin;
int* q = end;
while(true){
while(p<q && *q>=t) q--;
if(p==q) break;
*p++ = *q;
while(p<q && *p<t) p++;
if(p==q) break;
*q-- = *p;
}
*p = t;
qsort(begin, q-1);
qsort(q+1, end);
}
实时效果反馈
1. 快速排序属于下列哪种类型的排序算法?__
A 交换排序
B 选择排序
C 插入排序
D 基数排序
2. 当初始数据是完全有序的时候,快速排序的效率如何?__
A 不确定
B 效率最差
C 效率最好
D 效率接近平均水平
答案
1=>A 2=>B
树
数组,链表都是 线性结构
它们的特点是:每个节点有唯一的前趋,后继(端点可能没有)
而树是一种分叉的结构
树有一个根节点
每个节点可以有若干个子节点(像树木的分支)
最末级的叶子节点没有子节点
典型的分类关系,组织机构关系都是树型关系。
树是一种递归结构,它的任意子节点仍然是树。
树的表示
如果孩子数目较少(比如不大于5)
struct TreeNode{
int data; //用户数据
TreeNode* children[5]; // 没有那么多孩子则相应指针为NULL
TreeNode* parent; // 根据情况,也可能不需要
};
孩子数目不定(比如:少了可能没有,多了可能上万)
struct TreeNode;
struct LinkNode{
TreeNode* data;
LinkNode* next;
};
struct TreeNode{
int data; //用户数据
LinkNode* child;
};
其实,树的表示未必拘泥于“形似”,只要逻辑等价即可:
struct TreeNode{
int data;
TreeNode* child;
TreeNode* brother;
};
树的遍历
深度优先遍历 dfs
对每个可能的分支,深入到不能再深入为止。
深度优先比较容易实现,先递归child, 然后递归brother
广度优先遍历 bfs
其实,就是分层遍历。对高层整层遍历完,再进入下一层。
实现比较麻烦一点,需要保存当前层的链表,由它生成下一层的链表,
直到无新增数据。
struct Tree{
int data;
Tree* child;
Tree* brother;
};
// 为 me 增加一个兄弟 br(other)
void add_brother(Tree* me, Tree* br)
{
if(me->brother)
add_brother(me->brother, br);
else
me->brother = br;
}
// 为 me 增加一个孩子 ch(ild)
void add_child(Tree* me, Tree* ch)
{
if(me->child)
add_brother(me->child, ch);
else
me->child = ch;
}
// 深度优先遍历
void dfs(Tree* p)
{
cout << p->data << endl;
if(p->child) dfs(p->child);
if(p->brother) dfs(p->brother);
}
树的节点一般都是动态申请的,千万别忘记释放。
销毁树也很简单,因为是dfs
void del_tree(Tree* p)
{
if(p->child) del_tree(p->child);
if(p->brother) del_tree(p->brother);
delete p;
}
实时效果反馈
1. 关于树的说法,==错误==的是:__
A 树的子节点仍然是树
B 单个节点也是树
C 树只有一个根
D 叶子节点不是子树
2. 如下图的树,dfs遍历的节点访问顺序应该是:__
A ABDEFCGH
B ABDEFGCH
C ABCDEFGH
D ADEFBGHC
答案
1=>D 2=>A
二叉树
最多只有两个分支的树
左孩子和右孩子是区分的
二叉树的表示
struct BiTree{
int data;
BiTree* left;
BiTree* right;
};
根据操作的需要可以有或没有 BiTree* parent 项
二叉树的高度(或深度)
空树的高度为 0
树的高度是:左右孩子最大高度 + 1,很容易递归求解。
int get_height(BiTree* p)
{
if(p==NULL) return 0;
return max(get_height(p->left), get_height(p->right)) + 1;
}
二叉树的遍历
先根序(也称为:前序遍历)
void pre_order(BiTree* p)
{
cout << p->data << " ";
if(p->left) pre_order(p->left);
if(p->right) pre_order(p->right);
}
中根序(也称为:中序遍历)
void in_order(BiTree* p)
{
if(p==NULL) return;
in_order(p->left);
cout << p->data << " ";
in_order(p->right);
}
后根序
void post_order(BiTree* p)
{
if(p->left) post_order(p->left);
if(p->right) post_order(p->right);
cout << p->data << " ";
}
遍历用途举例
表达式的求值
以二叉树形式表达表达式的计算关系是十分自然的。
此二叉树的中序遍历与表达式形式一致。
其后序遍历:
A B C D - * + E F / -
恰为逆波兰表达式,这在表达式求值中十分重要。也可以从遍历结果来建立二叉树
例如,如下关系二叉树:
BiTree* r = new BiTree{1,NULL,NULL};
r->left = new BiTree{2,NULL,NULL};
r->right = new BiTree{3,NULL,NULL};
r->left->left = new BiTree{4,NULL,NULL};
r->left->right = new BiTree{5,NULL,NULL};
r->right->right = new BiTree{6,NULL,NULL};
r->right->right->left = new BiTree{7,NULL,NULL};
r->right->right->right = new BiTree{8,NULL,NULL};
r->left->right->left = new BiTree{9,NULL,NULL};
这个建立树的过程也太痛苦了,还容易错。
我们只要把先序遍历稍加改造:
void pre_order(BiTree* p)
{
if(p==NULL){
cout << -1 << ",";
return;
}
cout << p->data << ",";
pre_order(p->left);
pre_order(p->right);
}
输出的序列放到数组里,就可以根据它创建二叉树了。
int a[] = {1,2,4,-1,-1,5,9,-1,-1,-1,3,-1,6,7,-1,-1,8,-1,-1};
BiTree* r;
create_tree(a, r);
建立二叉树的过程:
int create_tree(int* a, BiTree*& r)
{
if(*a == -1){
r = NULL;
return 1;
}
r = new BiTree{*a};
int n1 = create_tree(a+1, r->left);
int n2 = create_tree(a+1+n1, r->right);
return 1 + n1 + n2;
}
这个建树过程,还有个神奇的特性:
可以不告诉它数组有多大,数据够了会自动停止,再多的也不会去读。
实时效果反馈
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
二叉排序树
二叉树可以很方便地完成快速搜索任务,主要用于:动态查找
所谓动态是指:节点增增删删,一直在动态变化
其副产品是可以排序
插入数据时,保持大小关系
小于自己的放左边,否则放右边
按中序遍历
dfs 实现中根序
排序树实现
添加节点
void add(BiTree* r, int x)
{
if(x < r->data)
if(r->left)
add(r->left, x);
else
r->left = new BiTree{x,NULL,NULL};
else
if(r->right)
add(r->right, x);
else
r->right = new BiTree{x,NULL,NULL};
}
中序遍历
void inorder(BiTree* r)
{
if(r==NULL) return;
inorder(r->left);
cout << r->data << " ";
inorder(r->right);
}
删除节点
删除的要点是:用中序遍历的前趋,或后继来代替自己(移花接木法)
void del_node(BiTree*& p)
{
if(p->left==NULL){
BiTree* t = p;
p = p->right;
delete t;
return;
}
if(p->right==NULL){
BiTree* t = p;
p = p->left;
delete t;
return;
}
// 以下是左右子树都不空
BiTree* pa = p;
BiTree* s = p->left;
while(s->right) {
pa = s; // s“作死”之前,记住它的parent
s = s->right;
}
p->data = s->data; // 李代桃僵
if(pa==p) // 严谨! 可能s就在p的脚边
p->left = s->left;
else
pa->right = s->left;
delete s; // s已无后顾之忧,删除之
}
bool del(BiTree*& r, int x)
{
if(r==NULL) return false;
if(x==r->data) {
del_node(r);
return true;
}
if(x < r->data)
return del(r->left, x);
else
return del(r->right, x);
}
别忘记了销毁
时刻要绷紧这根弦,否则会内存泄漏
(请自行完成!)
实时效果反馈
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树
简单的二叉排序树弱点:
- 当数据是基本有序时,畸形严重
- 如果多次添加,删除操作,慢慢会变畸形
AVL 树的关键是 1. 发现不平衡,2. 调整平衡。
AVL定义:
- 单个节点是AVL树,
- a 是AVL树,当且仅当:
- a 的左右子树都是 AVL 树
- 左右子树高度差不大于 1
平衡因子定义:
左子树高度减去右子树高度
对AVL树,如果平衡因子绝对值超过1,就要调整,
如何调整?
进行添加或删除操作后,都可能会引发不平衡。
调整平衡的本质就是谁当局部根节点的问题
以左边为例(右边类推):
- 左左型(直线型,扁担型)
- 左右型(之字型,折尺型)
添加过程
void add(BiTree*& r, int x)
{
if(x < r->data)
if(r->left)
add(r->left, x);
else
r->left = new BiTree{x, NULL, NULL};
else
if(r->right)
add(r->right, x);
else
r->right = new BiTree{x, NULL, NULL};
//adj(r);
}
调整函数
inline int max(int a, int b){ return a>b? a : b; }
// 树的高度
int h(BiTree* r)
{
if(r==NULL) return 0;
return max(h(r->left), h(r->right)) + 1;
}
//平衡因子
int balance(BiTree* r)
{
return h(r->left) - h(r->right);
}
//校正过程
void adj(BiTree*& r)
{
if(balance(r)<2) return;
if(balance(r)>0){
if(balance(r->left)>0){ //左左型
BiTree* p = r;
BiTree* q = p->left;
r = q;
p->left = q->right;
q->right = p;
}
else{ // 左右型
BiTree* p = r;
BiTree* q = p->left;
BiTree* s = q->right;
r = s;
q->right = s->left;
p->left = s->right;
s->left = q;
s->right = p;
}
}
else{
if(balance(r->right)<0){ //右右型
BiTree* p = r;
BiTree* q = p->right;
r = q;
p->right = q->left;
q->left = p;
}
else{ // 右左型
BiTree* p = r;
BiTree* q = p->right;
BiTree* s = q->left;
r = s;
q->left = s->right;
p->right = s->left;
s->right = q;
s->left = p;
}
}
}
拓展
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
堆排序
堆排序属于选择排序的一种
它借助于完全二叉树的性质来实现。
概念
满二叉树
层数为 k 时,节点数为 ${2^k-1}$ 的二叉树
也就是说,层数不变,节点数已经没法再多了,已经满员了。
完全二叉树
只在最后一层右边有缺损的满二叉树
完全二叉树可以用数组来存储,不用链式的动态结构
堆
又分为大顶堆和小顶堆
对每个节点,本节点值不小于孩子节点的值 ==> 大顶堆
注意,两个孩子大小无所谓,实际上也就是,没有左右孩子的区别了。
堆的存储
用数组存储,元素存储位置的关系公式(父子关系):
左孩子 = i * 2 +1
右孩子 = 左孩子 + 1
父节点 = (i - 1) / 2
原地排序的实现
待排数组,先建立大顶堆
把堆顶交换到尾部
堆顶调整过程:
inline swap(int&a, int& b){ int t=a; a=b; b=t; }
// 大顶堆调整(两个孩子都是堆,堆顶不满足)
// 堆数据:data,n // k: 当前要调整的堆顶
void adj(int* data, int n, int k)
{
int l = k * 2 + 1;
int r = l + 1;
if(l >= n) return;
if(r >= n){
if(data[l] >= data[k]) swap(data[l], data[k]);
return;
}
int s = (data[l] > data[r])? l : r;
if(data[s] >= data[k]){
swap(data[s], data[k]);
adj(data, n, s);
}
}
输出堆顶过程:
// 原地堆排序
void heap_sort(int* data, const int N)
{
// 建立大顶堆
for(int i=N/2; i>=0; i--) adj(data, N, i);
// 堆顶输出到尾部
int* p = data + N - 1;
while(p!=data){
swap(*data, *p);
adj(data, p-data, 0);
p--;
}
}
实时效果反馈
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
归并排序
想法特别朴素。一句话:两队变一队。
与堆排序一样,归并排序也是一种选择排序
它的好处是:
- 是一种稳定的算法。相等的元素排序前后能保序。
- 不仅仅适用于内排序,外排序同样适用。
把两个有序的队列合并成一个。
算法朴素,及其容易理解。
美中不足:需要额外的存储空间来缓存。
算法原理
两队变一队,p, q 指向两个有序队列
- p, q 中取最小的,输出
- 如果p, q 都不空,回到 1
- 把 p, 或 q 的剩余部分输出
实现:
用两个数据区,来回归并。有序列不断增长。
尽量减少数据复制次数。
// dst: 目标区,有足够空间; src , n : 源数据区
void merge_(int* dst, int* src, int n)
{
if(n==1){
*dst = *src;
return;
}
int m = n / 2; // 前半部分可能略小
merge_(src, dst, m);
merge_(src+m, dst+m, n-m);
int* p = src;
int* q = src + m;
while(p < src+m && q < src+n){
if(*p <= *q)
*dst++ = *p++;
else
*dst++ = *q++;
}
if(p<src+m) memcpy(dst, p, (src+m-p)*sizeof(int));
if(q<src+n) memcpy(dst, q, (src+n-q)*sizeof(int));
}
// 原地排序
void merge_sort(int* data, int N)
{
int* buf = new int [N];
memcpy(buf, data, N*sizeof(int));
merge_(data, buf, N);
delete [] buf;
}
实时效果反馈
1. 归并排序的本质属于:__
A 交换排序
B 选择排序
C 插入排序
D 其它排序
2. 排序算法的稳定性,指的是:__
A 对于较大的数据量,不会崩溃
B 对于故意设计的畸形数据,算法效率依然没有显著降低
C 对于已经基本有序或逆序的数据,效率有很大提高
D 对于比较中相等的元素,其排序前后次序关系不变。
答案
1=>B 2=>D
折半查找
折半查找的原理很简单,类似于:猜数游戏。
不断所有可能的空间,每次大约缩小为原来的一半。
所以,对于大数 N, 次数约为: log(N) 级别。
这个次数不大,完全可以用递归法来解决。
简单插入排序配合上折半查找,效率在很多场合也是也是能接受的。
这个算法的优势:稳定性。
并且,不需要额外的存储空间。
折半查找实现:
注意:折半查找不一定是找相等的元素,可能有更复杂的情况。
比如,我们的需求是:在 [begin, end] 有序区间内,找到第一大于 x 的位置。
如果找不到,返回end+1,
仔细思考:
这样才能保证之后插入排序算法的稳定性,
任何微妙的设计上的变化有导致不稳定的可能性。
// 查找 >x 的第一个位置,没有则返回 end+1
int* find_pos(int* begin, int* end, int x)
{
if(begin==end){
if(*begin>x) return begin;
return begin+1;
}
int m = (end-begin)/2;
if(begin[m]>x)
find_pos(begin, begin+m, x);
else
find_pos(begin+m+1, end, x);
}
简单插入排序实现:
- 在已经有序的区间中寻找要插入的位置。
- 把待插入数据保存在临时变量(此地一会儿要被踩踏了)
- 其位置及其以后都向后平移1,空出一个位置
- 把临时数据落入到空位置,此时有序区已经扩大1
- 重复1,直到没有待插入的数据
//使用了二分查找的插入排序
void insert_sort(int* data, int N)
{
int* p = data+1;
while(p<data+N){
int* q = find_pos(data, p-1, *p);
if(q!=p){
int t = *p;
// overlap 移动!!
memmove(q+1,q,(p-q)*sizeof(int));
*q = t;
}
p++;
}
}
实时效果反馈
1. 关于折半查找,说法正确的是:__
A 对于数据规模很大的N,查找次数上限大约:${log_2N}$
B 如果有很多相同的数据,查找效率迅速下降
C 要求数据规模是 2 的整数次幂
D 折半查找需要额外的log(N)存储空间
2. 简单的插入排序配合折半查找,有何优势?__
A 当规模N很大时,排序时间接近常数,与N无关
B 可以应用到外排序中
C 排序时,比较时等值的元素间,能保证原来的顺序
D 当待排数据有序时,排序速度最快。
答案
1=>A 2=>C