本文参考网课为 数据结构与算法
1 第一章概论,主讲人 张铭 、王腾蛟 、赵海燕 、宋国杰 、邹磊 、黄群。
本文使用IDE为 Clion
,开发环境 C++14
。
更新:2023 / 10 / 15
数据结构与算法 | 第一章:概论
- 数据结构
- 概念
- 逻辑
- 存储
- 运算
- 抽象数据类型
- 栈
- 算法
- 概念
- 特性
- 分类
- 示例
- 穷举法
- 递归分治
- 二分法找K值
- 复杂性分析
- 表达式
- 大O表达式
- 大Ω表达式
- 大Θ表达式
- 示例
- 顺序找K值
- 递归分治
- 二分法找K值
- 时间 / 空间权衡
- 数据结构和算法的选择
- 面向对象与流
- 类与对象
- 概念
- 定义类
- 使用类
- 默认函数 —— 构造、析构、复制构造、赋值与取址
- 特殊成员 —— this 指针
- 函数模板与类模板
- 函数模板
- 类模板
- 流
- 概念
- 标准输入输出流
- 标准输入流
- 标准输出流
- 流操纵算子
- 单个字符
- 整型
- 浮点数
- 文件输入输出流
- 基本操作
- 文件指针
- 参考链接
数据结构
概念
结构
,即实体
+ 关系
。
数据结构
,即按照逻辑关系组织起来的一批数据,按一定的存储方法被存储在计算机中,在这些数据上定义了一个运算的集合。示意图如下所示:
逻辑
数据结构
的 逻辑
是指数据本身及其之间的关系。主要有2类:
线性结构
线性表
表
、栈
、队列
、串
等
非线形结构
树
二叉树
、Huffman树
、二叉检索树
等图
有向图
、无向图
等
存储
数据结构
的 存储
是 逻辑
结构到( 内存
的)物理存储单元的映射。
计算机主存储器,即 内存
,是由非负整数进行低地址到高地址编码的线性结果,基本单位是 字节
。我们想访问哪一个地址单元,就可以立即访问到且需要的时间基本相同,不需要在整个内存中搜索或者遍历。
数据结构
的 存储
结构主要有 4 类:
顺序
是指存储单元的顺序地址。是线性化的结构组织方法。链接
是指针的地址指向关系。是非线性话的结构组织方法。索引
对数据建立索引表,并通过该表能找到相应的数据的存储地址。散列
运算
数据结构
的 运算
是指数据操作,体现为函数。
抽象数据类型
抽象数据类型
,被简称为 ADT
( Abstract Data Type
),定义了一组运算的数学模型,与物理存储结构无关,使得软件系统建立在数据之上(面向对象)。
模块化的思想的发展的要求,使得运算实现的细节和内部数据结构被隐藏,便于软件复用。
栈
栈
的抽象数据类型 ADT
:
逻辑结构
线性表数据操作
- 只允许在一端进行插入、删除操作。最后进入的数据,必须最先取出。最先进入的数据,无法随便访问。
- 入栈(
push
)、出栈(pop
)、取栈顶(top
) 、判断栈是否为空(isEmpty
)
template <class T> // 栈的元素类型为T
class stack{
public: // 栈的运算集
void clear(); // 变为空栈
bool push(const T item) // item入栈,成功则返回真,否则为假
bool pop(T & item); // 弹栈顶,成功则返回真,否则为假
bool top(T & item); // 读栈顶但不弹出,成功则返回真,否则为假
bool isEmpty(; // 若栈已空 返回真
bool isFull(; // 若栈已满 返回真
};
算法
概念
目标是对 问题
求解。
问题
(Problem
)
是一个函数,是从输入到输出的一种映射。算法
(Algorithm
)
是对特定问题
求解过程的描述,是指令的有限序列。程序
(Program
)
是算法
在计算机程序设计语言中的实现。
特性
通用性
- 对参数化输入进行问题求解
- 保证计算结果的正确性
有效性
- 算法是有限条指令组成的指令序列
- 由一系列具体步骤组成
确定性
- 算法描述的下一步应执行的步骤必须明确
- 有穷性
- 算法的执行必须在有限步内结束,即不能存在死循环
分类
- 穷举法
- 顺序找K值
- 回溯、搜索
- 八皇后、树和图遍历
- 递归分治
- 二分找K值、快速排序、归并排序
- 贪心法
- Huffman 编码树、最短路 Dijkstra 算法、最小生成树 Prim 算法
- 动态规划
- 最短路 Floyd 算法
示例
穷举法
以下面的例子为例,顺序在一个无序的数组里找K值,在索引为0的地方放置一个监视哨。
#include<iostream>
#include<string.h>
using namespace std;
template <class Type>
class Item{
private:
Type key; // 关键码域
// 其它域
public:
Item(Type value):key(value){}
Type getKey(){return key;} // 取关键码值
void setKey(Type k){key=k;} // 置关键码
};
vector<Item<Type>*> dataList;
template <class Type> int SeqSearch(vector<Item<Type>*>& dataList, int length, Type k){
int i=length;
dataList[0]->setKey(k); // 将第0个元素设为待检索值
while (dataList[i]->getKey()!=k) i--;
return i; // 返回元素位置
}
递归分治
二分法找K值
对于已排序的顺序线性表,
- 数组中间位置的元素
Kmid
- 当
Kmid
=K
,结束; - 当
Kmid
>K
,继续向前检索; - 当
Kmid
<K
,继续向后检索;
- 当
#include<iostream>
#include<string.h>
using namespace std;
template <class Type> int BinSearch(vector<Item<Type>*>& dataList, int length, Type k) // 传入数组、数组长度以及待检索的k值
{
int low=1, high=length, mid;
while (low<=high){
mid=(low+high)/2;
if (k<dataList[mid] -> getKey())
high = mid-1; // 右区间边界左移,缩小搜索区间
else if (k>dataList[mid] -> getKey())
low = mid+1; // 左区间边界右移,缩小搜索区间
else return mid; // 成功返回k值的位置索引
}
return 0; // 检索k值失败,返回
}
复杂性分析
表达式
大O表达式
算法的复杂性分析中最重要的表达式是 大O表达式
,被用来表达函数增长率上限。
函数 f
、g
定义域为自然数,值域为非负实数集。
如果存在正数 c
和算法规模 n0
,使得对任意的 n>=n0
,都有 f(n)<=cg(n)
,则称 f(n)
在集合 O(g(n))
中。
f(n) = O(g(n))
,当且仅当存在两个参数 c>0
,n0>0
,对于所有的 n>n0
都有 f(n) <= cg(n)
。
当 n
足够大时,g(n)
是 f(n)
的上界。
如何衡量 大O表示法
呢?即看算法段内它的基本运算是什么。
- 简单布尔或算数运算
- 简单I/O
指函数的输入/输出,例如从数组读数据等操作,不包括键盘等外设设备的I/O - 函数返回
大O表示法
有哪些重要的运算规则呢?
- 加法规则
f1(n) + f2(n) = O(max(f1(n), f2(n)))
如果有2个程序段的复杂度分别是f1(n)
和f2(n)
,则实际上最耗时(复杂度最大)的那个会更重要。- 顺序结构
if
结构,switch
结构
- 顺序结构
- 乘法规则
f1(n)*f2(n)=O(f1(n)*f2(n))
对于嵌套的程序段,其内层循环的算法代价乘以外层循环的算法代价,总共耗费的时间即两层循环的一个乘积。- 循环结构
for
结构、while
结构、do-while
结构
- 循环结构
大Ω表达式
算法的复杂性分析的 大Ω表达式
,被用来表达函数增长率的所有下限中那个最 “紧”(即最大)的下限。
函数 f
、g
定义域为自然数,值域为非负实数集。
如果存在正数 c
和算法规模 n0
,使得对任意的 n>=n0
,都有 f(n)>=cg(n)
,则称 f(n)
在集合 Ω(g(n))
中。
它和 大O表达式
的唯一区别在于不等式的方向。
大Θ表达式
当上、下限相同时则可用 大Θ表达式
。
函数 f
、g
定义域为自然数,值域为非负实数集。
如果一个函数既在集合 O(g(n))
中又在集合 Ω(g(n))
中,则称其为 Θ(g(n))
。即,当上、下限相同时则可用 大Θ表达式
。
存在正常数 c1
、c2
,以及正整数 n0
,使得对于任意的正整数 n>n0
,有下列两不等式同时成立:
c1g(n)<=f(n)<=c2g(n)
示例
顺序找K值
- 算法
顺序从一个规模为n
的一维数组中找出一个给定的K
值。 - 算法复杂性分析
- 最佳情况
数组中第1
个元素就是K
,即只需要检查一个元素,复杂度为O(1)
- 最差情况
K
是数组中最后一个元素。检查数组中的所有n
个元素,才能发现K
是最后一个元素,复杂度为O(n)
- 平均情况
- 等概率分布
K
值出现在n
个位置上的概率都是1/n
,则平均代价为O(n)
,(1+2+…+n)/n=(n+1)/2` - 不等概率分布
出现在第1个位置的概率为1/2
出现在第2个位置的概率为1/4
出现在其它位置的概率是:(1-1/2-1/4)/(n-2)=1/4(n-2)
平均代价为O(n)
,1+(n+3)/8
- 等概率分布
- 最佳情况
递归分治
二分法找K值
- 算法
使用二分法从一个已排序的顺序线性表中找到一个给定的K
值。 - 算法复杂性分析
- 最佳情况
数组中中间元素就是K
,即只需要检查一个元素,复杂度为O(1)
- 平均情况
平均检索代价为O(logn)
- 最佳情况
时间 / 空间权衡
数据结构
要求:
空间
一定的空间
来存储它的每一个数据项时间
一定的时间
来执行单个基本操作
意味着我们需要对 时间
和 空间
进行权衡,也是对 代价
和 效益
的权衡:
- 增大
空间
开销,可能改善算法的时间
开销 - 可以节省
空间
,往往需要增大运算时间
数据结构和算法的选择
-
仔细分析所要解决的问题
特别是求解问题所设计的数据类型
和数据间逻辑
关系 —— 问题抽象、数据抽象
数据结构
的初步设计往往先于算法设计 -
注意
数据结构
的可扩展性
考虑当输入数据的规模发生改变时,数据结构
是否能够适应求解问题的演变和扩展
总结以上几点,可分为以下几步:
- 问题求解
- 数据模型
- 算法模型
- 算法与数据结构相关
面向对象与流
类
与对象
类
的概念及基本语法
C
语言是一门面向过程的程序设计语言。
C++
语言是一门面向对象的程序设计语言,对象
是类
的实例。因此,有必要了解类
的概念。- 默认函数 – 构造、析构、复制构造、赋值与取址
- 特殊成员 –
this
指针 - 模版类
流
- 标准输入、输出流
- 流操纵算子
- 文件输入、输出流
类与对象
概念
举以下手机的例子来简要说明 类
与 对象
的概念:
A使用H品牌的手机,B使用A品牌的手机,C使用O品牌的手机。
- 尽管这3部手机的品牌不尽相同,但是属于手机这一个品
类
,因其具有相同的特点(或,属性
),例如亮度、电池电量、运营商。 - 每一部手机都是一个
对象
。 - 我们在操作手机时可以使用共同的方法,例如开关机、调节亮度、发送短信等等,这些都称为
类
的方法
。我们可以对同一类
中的对象
使用相同的方法
进行操作。 - 在使用某一具体的
方法
时需指定具体对象
,比如,C的手机。从手机这一类
到具体某部手机对象
的过程,称为类
的实例化。
共同特点( 变量
)构成数据结构
归纳行为( 函数
)操作数据结构(抽象)
当对 变量
、函数
进行捆绑和封装,便形成了 类
。
定义类
以定义一个 矩形
类
为例,
class Rectangle{
public: // 用户访问
int w, h; // 定义变量:长、宽
int Area(){ // 定义方法:计算面积
return w*h;
}
int Perimeter(){
return 2*(w+h); // 定义方法:计算周长
}
void Init(int w_, int h_){ // 设置长宽
w = w_;
h = h_;
}
};
使用类
int main(){ // 定义主函数
int w, h; // 定义长和宽
Rectangle r; // r是一个对象,是类的具体实例化
cin >> w >>h; // 读入长和宽的数据
r.Init(w,h); // 使用Init方法设置长和宽
cout << r.Area() << endl << r.Perimeter(); // 调用Area和Perimeter,并输出结果
return 0;
}
- 在使用
类
时,如果我们像定义变量一样定义的类
,例如
Rectangle r1, r2;
我们用 对象名.成员名
来使用 类
的成员函数和成员变量:
r1.w=5; r2.Init(5,4);
- 如果我们定义的
类
是一个指针,例如
Rectangle * p1=&r1, p2=&r2;
我们用 指针->成员名
来使用 类
的成员函数和成员变量:
p1->w=5; p2->Init(5,4);
- C++中还可以引用,引用和这个变量本身没有区别,只是相当于给这个变量赋予了新的名字,例如
Rectangle & rr = r2;
我们用 引用名.成员名
来使用 类
的成员函数和成员变量:
rr.w = 5; rr.Init(5,4);
声明和定义可以分离。
class Rectangle{
public:
int w,h;
int Area(); // 声明方法:Area
int Perimeter(); // 声明方法:Preimeter
void Init(int w_h, int h_); // 声明变量:长、宽
};
int Rectangle::Area() {return w*h;} //::表示这是Rectangle类的成员函数Area
int Rectangle::Perimeter() {return 2*(w+h);}
void Rectangle::Init(int w_, int h_){w=w_; h=h_;}
对于成员函数中的成员的访问权限,
成员 | 权限 |
---|---|
private | 只能在成员函数中访问 |
public | 可以在任何地方访问 |
protected | 此处暂不介绍 |
class className{
private: // 私有属性和函数,在类的成员函数内部能够访问
public: // 公有属性和函数,当前对象的全部属性、函数
protected: // 保护属性和函数,同类其他对象的全部属性、函数
};
默认函数 —— 构造、析构、复制构造、赋值与取址
当我们声明1个类时,就会有一些成员函数自动生成。我们只需要定义一个空的类,它们就会存在。
- 1.
构造函数
与析构函数
class cellphone{
public:
cellphone();
~cellphone();
};
- 2. 可以被复制
cellphone(const cellphone&);
cellphone & operator=(const cellphone&);
- 3. 可以取地址
cellphone * operator&();
const cellphone* operator&() const;
特殊成员 —— this 指针
this
指针并非对象的成员函数,是一个常量指针。
每个对象可以使用 this
指针访问自己的地址。
在成员函数调用时,this
指针为隐式参数。
它的功能有防止自赋值、返回以连续调用。
下面是一个 this
指针的例子,
比如说,我们定义一个类叫做 Complex
,
class Complex{
float real, image; // 默认是private成员,不可以被访问的
public:
Complex * ReturnAddress(){ // 定义成员函数ReturnAddress(), 其返回的是Complex类的指针类型
return this;
} // c.ReturnAddress() 等效于 &c
float ReturnReal(){
return this -> real; // 等效于 return real;通过this指针访问其中的成员变量/函数
}
};
函数模板与类模板
函数模板
函数模板
提供一种方法,让我们能够一次性定义一系列函数。
比如说,如果我们需要定义一个排序函数,如果对于整型定义一个排序函数、对于浮点数型定义一个排序函数、对于每一个不同类型的数据都定义一个排序函数,这是非常麻烦的。因此,我们可以使用类似于下方的函数模板定义对不同类型数据均可用的排序函数 sort
。
template<class T> // 以template开头表示这是一个模板函数,后面跟着的class T表示这个函数模板的参数
return-type sort(...T...) // 返回值后面跟函数名sort,sort后的()填参数列表T
再比如,我们可以定义一个实际的输出函数:
template<class T>
void print(const T array[], int size){
int i;
for (i=0; i<size; i++) cout<<array[i];
return;
}
然后使用 int a[10]; print(a,10);
来调用函数。
由于 a
是 int
类型,编译器自动会把 T
替换成 int
来执行这个过程。
一个函数模板可以有多个待替换的参数。像下面的例子一样,它现在有2个参数,除此之外我们也可以在形参列表里来定义其它我们需要的变量,之后进行输出,
template<class T1, class T2>
void print(T1 arg1, T2 arg2, string s, int k)
{cout<<arg1<<s<<arg2<<k<<endl; return;}
类模板
类模板
和 函数模板
的功能差不多。
为了多快好省地定义出一批相似的类,可以定义 类模板
,然后由 类模板
生成不同的类。
在 类
中我们经常会实现一种数据结构,看起来像一个容器,比如说 数组
。它可以容纳很多的数据,数据的种类可以是多种多样的,比如 整数
、字符串
等。
在定义 类
的时候可以给它一个/多个参数,这个/些参数表示不同的数据类型。在调用 类模板
时,指定参数,由编译系统根据参数提供的数据类型自动产生相应的 模板类
。
下面看一个具体的例子,
template<class T> // 以template开头表示这是一个函数/类模板,后面跟着的class T表示这个类模板的参数
class Carray{
T *ptrElement;
int size;
public:
Carray(int length);
~ Carray();
int len();
void setElement(T arg, int index);
T getElement(int index);
};
在我们使用 函数模板
时可以直接使用 函数模板
而不必指定参数的类型。然而,在使用 类模板
时需要显式地指定参数的类型。
具体的使用方式如下,是在 类
后面加上类似于 <int>
就可以产生对应的 模板类
。
Carray<int> arrayInt(50), *ptrArrayInt; // 创建一个元素类型为int的Carray模板类,并声明该模板类的一个对象以及一个指针
需要注意地是,不同的模板参数产生的 模板类
不是同一个 类
,无法互相调用彼此的资源。
流
概念
C++
中使用 流
进行输入和输出。
输入和输出的过程就像一个连绵不断的串。当我们读取的时候,相当于从这个串中拿出一些内容而其后面的会向前顶;而当我们输出的时候,我们会在输出串的结尾流入一些新的内容。所以我们将之称为 流
。
标准输入输出流
标准输入流
对一个变量 x
,如果想从屏幕中读取相关的数据,我们可以使用 cin
来实现:
cin >> x;
- 读取整型数时以第一个非数字为终结;
- 读取字符串时以第一个空格、tab或换行符为终结;
此外,也可以用如下的 getline
来读取一整行:
cin.getline(str, len, ch); // 读入一个字符串。ch被从流中提出但不存入str。
// str 目标字符串,len 最长长度,ch 终止字符。读入结果会存入目标字符串str,如果读入字符串长度(str的长度)达到len或遇到终止字符ch,则结束读入
ch=cin.get(); // 读入一个单独的字符
cin.ignore(len, ch); // 忽略流中最近的一段长度达到len或者终止字符为ch的字符串
如何判断输入的结束呢?
int x;
while (cin >> x){
...
}
return 0;
当读入结束时会返回文件结束符 EOF
。遇到这个字符我们就可以结束 while 循环,结束这个程序。
键盘读入时使用 ctrl-z 结束,相当于给出
EOF
,文件读入时读到文件末尾。
标准输出流
可以使用如下的 cout
进行流的输出,
cout << y;
cout
输出到标准设备;
cerr
输出错误信息;
clog
输出错误日志;
流操纵算子
单个字符
输出单个字符可以使用如下的方式,
cout.put('A').put('a');
输出结果如下所示:
cout.put('A').put('a');
整型
输出整型数,
int n = 10;
cout << n << endl;
cout << hex << n << endl
<< dec << n << endl
<< oct << n << endl;
输出结果如下所示:
10
a
10
12
浮点数
输出浮点数,
#include <iostream>
#include <iomanip>
using namespace std;
int main(){
double x=123567.89, y=12.34567;
int n=1234567;
int m=12;
cout << setprecision(6) << x << endl // 默认精度为6位有效数字
<< y << endl
<< setprecision(4) << y << endl
<< setprecision(3) << n << endl
<< m << endl;
cout << setiosflags(ios::fixed)
<< setprecision(6) << x << endl
<< y << endl
<< n << endl
<< m;
}
输出结果如下所示:
123568
12.3457
12.35
1234567
12
123567.890000
12.345670
1234567
12
setprecision(n)
,n
为正整数,对浮点数的所有有效数字位数进行控制;
setiosflags(ios::fixed)
,对浮点数的小数点后的有效数字位数进行控制;
文件输入输出流
当我们需要对文件进行操作时,输入、输出的方式和屏幕的标准输入 cin
、输出方式 cout
基本是一样的,只不过我们需要用到文件流:
基本操作
ifstream fin; // 输入文件流fin.open("input.txt", ios::in); // ios::in
fin >> ...
ofstream fout; // 输出文件流
fout.open("output.txt", ios::out); // ios::out 输出到文件,删除原有内容(默认打开文件选项)
// ios::app 输出到文件,保留原有内容,总是在尾部进行添加
// ios::ate 输出到文件,保留原有内容,可以在文件任意位置进行添加
fout << ...
文件指针
在文件操作时,我们可以对文件指针进行操作移位,即可以跳过当前位置而跳到任意我们想要操作的位置。
以输出为例,通过 tellp
和 seekp
操作文件指针:
ofstream fout("a1.out", ios::ate);
long location = fout.tellp(); // 通过tellp获取写指针的位置
location = 10L;
fout.seekp(location); // 通过seekp将写指针移动到location(第10个字节)处
fout.seekp(location, ios::beg); // 通过seekp将写指针从头(ios::beg)移动到第location处
fout.seekp(location, ios::cur); // 通过seekp将写指针从当前位置(ios::cur)移动到第location处
fout.seekp(location, ios::end); // 通过seekp将写指针从尾部(ios::end)移动到第location处
对输入操作,文件指针则为 tellg
、seekg
参考链接
数据结构与算法 ↩︎