数据结构与算法
绪论
算法
计算=信息处理
借助某种工具,遵照一定的规则,以明确而机械的形式进行。
计算模型 = 计算机 = 信息处理工具
算法:指的是在特定计算模型下,,旨在解决特定问题的指令序列。
输入 待处理的信息(问题) 输出 待处理的信息(答案) 正确性 的确可以解决特定的问题 确定性 任一算法都可以描述成为一个由基本操作组成的序列 可行性 每一个基本操作都可以实现,且在常数时间里面完成 有穷性 对于任何输入,经过又穷次数操作后,都可以得到输出 关于有穷性
$$
Hailson(n) =
\begin{cases}
{1} \ \ \ \ \ \ \ \ \ \ n \leq 1 \
{n}\ \cup \ Hailstone(n/2) \ \ \ n = 2K \
{n}\ \cup \ Hailstone(3n+1) \ \ \ n = 2K - 1
\end{cases}
$$
1 | int Hailstone(int n) |
关于这个Hailstone函数,我们输入的常数未必与我们的输出的结果成正比。
对于任意的n,我们都一定拿到有穷性的Hailstone长度吗!
所以说,上述的Hailstone是一个程序,不是一个算法。
$$
程序\neq 算法
$$
好算法
- 正确: 符合语法,能够编译,链接。
- 能够处理简单的输入
- 能够处理大规模的输入
- 能够处理一般性的输入
- 能够处理退化的输入
- 能够处理任意合法的输入
- 健壮: 能够辨别不合法的输入,且能够做适当的处理,也就是异常处理。
- 可读: 结构化 +准确命名+注释+···
- 效率: 速度尽可能的快,存储空间尽可能的少。
$$
\begin{matrix}
Algorithms + Data\ Structures = Programs \
(Algorithms + Data\ Structures)\times efficient = Computing
\end{matrix}
$$
计算模型
- 成本: 运行时间 + 所需储存空间
不同的算法解决的问题规模不太相同,相同问题规模的算法计算成本大体差不多。
- 在考察算法的效率的时候,我们需要使用该算法解决问题的最坏的情况去描述这个算法的效率,也就是计算成本最高的情况。如前面的Hailstone,不可以找到最坏的情况,所以很难对这个算法提供一些描述性信息。
Turing Machine(图灵机)
- Tape: 依次均匀的划分单元格,各注有一段字符,默认为‘#’
- Alphabet 字符的种类有限
- Head 总是对准某一个单元格,并且可以读取和改写其中的字符,每经过一个节拍,可转向左侧或者右侧。
- State TM总是位于有限种状态中的某一种状态,每次经过与1个节拍,可以安好规则转向另外一种状态。
- Transition Function: (q, c; d, L/R, p)
- q为读写头
- c为被读取的字符
- d为即将替换成为的字符
- L/R为转换方向
- p为状态,如果为h则图灵机终止
RAM: Random Access Machine
寄存器顺序编号,总数没有限制
R[0], R[1], R[2], ...
每一项基本操作仅仅需要常数时间
1
2
3R[i] <- c R[i] <- R[R[j]] R[i] <- R[j] + R[k]
R[i] <- R[j] R[R[i]] <- R[j] R[i] <- R[j] - R[k]
IF R[i] = 0 GOTO 1 IF R[i] > 0 GOTO 1 GOTO 1 STOP
- 与TM模型一样,RAM模型也是一般计算工具的简化与抽象使得我们能够在独立的平台上进行比较与评判
- 算法的运行时间 取决于 算法需要执行的基本操作的次数
- T(n) = 算法为求解规模为n的问题所执行的基本操作次数
大o记号
渐进分析法: 大o记号
Asymtotic analysis 当n >> 2 之后,对于规模为n的输入,算法
- 所需要的基本操作次数: T(n) = ?
- 需要占用的存储单元数: S(n) = ?
Big-o notation
$$
\begin{matrix}
大o记号(big-o notation)\
使用放缩的方法取得最高次项,忽略常数
\end{matrix}
$$
Big Ω 在特殊的情况下会考虑这些其他的分析法。
分类
o(1): 这类算法的效率最高,在不含调用,不含循环,不含转向的大部分都是o(1)级别的算法。
o(logn):
- 对数中不在标明底数,经过对数变换后始终能转换底。常系数忽略。
- 对数中指数幂次项不影响o的次数,指数可以提出来作为常数
- 指数多项式仍然找到最高次项作为o
o(n ^ c):
- 找到最高次项的复杂度
- o(n)线性复杂度,已经可以是非常满意了。
o(2 ^ n) : 指数复杂度
- 只要我们的常数是大于1的,则认为这个是很难以解决的。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33// 难解
int fnt(int number)
{
if (number == 1 || number == 0)
{
return 1
}
return fint(number - 1) + fint( number - 2);
}
// 多项式复杂度
unsigned long int fib2(unsigned int number)
{
unsigned long int result = 0;
unsigned long int a = 1;
unsigned long int b = 1;
int count = 2;
if (number == 1 or number == 2)
{
return 1;
}
else{
while (count < number)
{
result = a + b;
b = a;
a = result;
count ++;
}
return a;
}
}Subset
- [问题描述]
- S包含n个正整数, Σs = 2m
- S是否有子集T,使得∑T=m?
- 这个问题就是我们所说的NP-C问题,遍历所以有的结果才能得出想要的解。
- [问题描述]
不存在多项式优化解,只能使用最差的解,也就是遍历。(???)
算法分析
$$
两个主要任务= 正确性(不变性\times 单调性)+ 复杂度
$$
- C++等高级语言的基本指令,均等效于常数条RAM的基本指令;在渐进下,二者的效果大抵相同。
- 分支转向: GOTO
- 迭代循环: for(), while() //本质上是 IF goto
- 调用 + 递归 //本质上都是goto
- 复杂度分析的主要方法:
- 迭代: 级数求和
- 递归: 递归追踪 + 递推方程
- 猜测 + 验证
级数
- 算数级数:与末项平方同阶
$$
T(n) = 1+2+3+4+ \dots + n= \frac{n(n+1)}{2}=O(n^2)
$$
- 幂方级数:比幂次方高一阶
$$
\sum_{k=0}^{n}k^d \approx \int_{0}^nx^{d}dx=\frac{1}{d+1}x^{d+1}|_{0}^{n}=\frac{1}{d+1}n^{d+1}=O(n^{d+1})
$$
几何级数:
从某项开始复杂度都呈现倍数的增长。(等比数列, 或者类等比数列)
收敛级数:
$$
例1: 1 + \frac{1}{2^2}+\dots + \frac{1}{n^2} < 1 + \frac{1}{2^2}+\dots = \frac{\pi^2}{6}=O(1)
$$
$$
例2:\frac{1}{3}+\frac{1}{7}+\frac{1}{8}+\frac{1}{15}+\dots =1=O(1)
$$
- 调和级数
$$
h(n)=1 + \frac{1}{2}+\frac{1}{3}+ \frac{1}{4}+\frac{1}{5}+\dots=\Theta(logn)
$$
- 对数级数
$$
log1 + log2 + log 3 + \dots + logn=log(n!)=\Theta (nlogn)
$$
循环
- 算数级数:
$$
算数级数: \sum_{i=1}^{n}k=1 + 2 + 3 + \dots + n = \frac{n(n+1)}{2}=O(n^2)
$$
![KKPR__D`F08_TWY_S7D_YIJ.png](https://i.loli.net/2021/01/02/Z3FhknW6KOdPJRU.png)
- 几何级数:
1 | void geometricSeries(int n) |
$$
几何级数: 1 + 2 + 4 + \dots + 2 ^ {[log_2(n-1)]}=\sum_{k=0}^{|log_2(n-1)|}2^ {log_2i}=2^{|log_2n|}-1 = O(n)
$$
这是由于 i 的增长是几何递增的,当几何增长到n时,增长的次数是log的形式,只有这样,才能算出来是几何指数。
如果说单纯的几何级数,不是这种限定条件的话,就会使几何级数递增。
1 | for (int i = 0; i <= n; i ++) |
$$
\begin{align}几何级数: 分析:由于内部增长过快,导致有时候外部循环的增长不能满足内部,也就是说存在\
有些i会得到相同的输出次数,这也就是内部的增长速度限定外部的打印次数的情况。这也就变相导\
致模型的复杂度是O(logn * 2 ^ {logn})
\end{align}
$$
冒泡排序
代码:
1 | void bubblesort(int input[], int length) |
$$
\begin{matrix}
在冒泡排序中,用最坏的来预测这个算法的复杂度,也就是完全逆序,\
T(n)= n + (n-1)+ \dots + 1 = \frac{n(n+1)}{2}=O(n^2)
\end{matrix}
$$
封地估算
$$
\begin{matrix}
时间估计: \
1d = 10 ^ 5 sec \
100Y = 3 \times 10 ^ 9\
300Y \approx 10^{10}
\end{matrix}
$$
迭代与递归
迭代
Decrease-and-conquer
- 为了求解一个大问题,可以
- 将其划分为多个小问题
- 分别求解子问题
- 合并所有子问题的解,得到原来问题的解
Divide-and-conquer
- 为了求解一个大规模的问题,可以
- 将大问题划分为若干的子问题,规模大致相当
- 分别求解子问题的解
- 由子问题的解得到原问题的解
递归方程
$$
T(n)=T(n-1) + O(?)\
T(1) = ?\
综合判断这个递归效果究竟如何?
$$
数组倒置
递归代码:
1 | void seqReverse(int *head, int start, int end) { |
迭代版本代码:
1 | void seReverse(int * head, int start, int end) { |
迭代版本的与递归版本的时间复杂度都是O(n)基本差不了太多。
二分递归
1 | int sum(int *list, int begin, int end) |
递归分析
- 首先各层是2的倍数,但是被总数n制约
$$
T(n) = O(1)(2^0 + 2^1 + 2^2 + \dots + 2^{logn})\
T(n) = O(1) \times O(n)=O(n)
$$
- 制约的最高层数
$$
由于最后一层是所有元素:故最后一层为n\
那么设这第i层\
2^i=n \Rightarrow i=logn \
这也就是为什么最后一层为 2^{logn} 的原因
$$
递推方程
$$
T(n) = 2T(n/2) + O(1)\
T(1) = O(1) \
求和最后得到的还是O(n)
$$
- Max2
从数组A中找到最大的两个值,并且返回其下标
解法一: 蛮力划分区找最值
1 | void max2(int *list, int lo, int hi, int x1, int x2) { |
解法二:动态的构建一个数组存放两个下标
1 | void max2Two(int *list, int lo, int hi, int x1, int x2) { |
x1负责去找最大值,当找到新的最大值之后丢给x2这样就总能够保证x2比x1小了。
解法三: 二分递归
1 | void max2(int list[], int lo, int hi, int &x1, int &x2) { |
$$
由于使用了二分递归,这个算法的复杂度达到目前最低。\Theta(n) = \frac{5}{3}n -1
$$
动态规划
$$
Make\ \ it \ \ work, \
make \ \ it \ \ right, \
make \ \ it \ \ fast. \
-Kent \ \ Beck
$$
所谓动态规划就是使用递归解决之后,找到对应的迭代形式。
- 最长公共子序列 (subsequence)
解法一:递归求解
1 | //待完成,我感觉递归真的做出来就是憨憨,超级麻烦 |
解法二: 动态规划,迭代求解。
1 | void lcs(string str1, string str2, int **&m) { |
所以说动态规划的核心就在于使用递归找出合理解法之后,使用迭代去描述这个算法,得到一个更加高效的解。
向量(Vector)
抽象数据类型 = 数据模型 + 定义该模型上的一组操作
抽象定义 外部的逻辑特性 操作&语义
一种定义 不考虑时间复杂度 不涉及数据的存储方式
数据结构 = 基于某种特定语言,实现ADT的一整套算法
具体实现 内部的表示与实现 完整的算法
多种实现 与复杂度密切相关 要考虑数据的具体存储机制
向量ADT
- 从数组到向量
引入,c或者c++中的数组都是一个个的物理地址与对应的值所组成的,而这个地址又可以写成线性的形式.
$$
A[i]的物理地址=A + i\times s
$$
s是该种数据的空间大小,所以我们又把数组叫做线性数组(Linear array)
向量
- 向量是数组的抽象与泛化,由一组元素按照线性次序封装而成
- 各个元素与[0, n)内的秩(rank)一一对应
- 元素的类型不受限制
- 操作简单,维护容易,安全,统一。
- 方便实现更加复杂的数据结构的定值
向量ADT接口
操作 | 功能 | 适用对象 |
---|---|---|
size() | 报告向量当前的规模(元素总数) | 向量 |
get(r) | 获得秩为r的元素 | 向量 |
put(r, e) | 用元素e替换秩为r的数值 | 向量 |
insert(r, e) | e作为秩为r的元素插入,原后继元素往后推移 | 向量 |
remove(r) | 删除秩为r的元素,返回该元素中原本存在的对象 | 向量 |
disordered() | 判断是否所有元素已经是非降序排列了 | 向量 |
sort() | 调整所有元素的未知,使之按照非降序排列 | 向量 |
find(e) | 查找目标元素e | 向量 |
search(e) | 查找目标元素e,返回不大于e且秩最大的元素 | 有序向量 |
deduplicate() | 剔除出重复元素 | 向量 |
uniquify() | 剔除重复向量 | 有序向量 |
traverse() | 遍历向量并且统一处理向量中的元素,处理方法由函数对象指定 | 向量 |
- 构造与解析
Vector模板类
1 | typedef int Rank; // 秩 |
1 | //构造与析构 |
- 复制
1 | template <typename T> //T为基本数据类型,或者已经重载的复制操作符 “=” |
可扩充向量
静态空间管理策略
- 开辟内部数组_elem[]并使用一段连续的物理空间
- _capacity : 总容量
- _size : 当前实际规模n
- 若采用静态空间管理策略,容量_capacity固定,则有明显不足
- 上溢(overflow): _elem[]不足以存放所有的元素,尽管这时候系统还有足够的空间。
- 下溢(underflow): _elem[]中的元素少
$$
装填因子(load\ factor)\ \lambda = \frac{_size}{_capacity}\ <<\ 50%
$$
- 更加糟糕的是,一般应用环境难以精准预测空间需求量。
动态空间管理策略
- 在即将发生上溢的时候,适当扩大内部数组的容量,使之能够容纳新的元素。
- 当检测到即将溢出的时候把原来的copy到新的空间中,并且把原来的释放。
1 | template <typename T> |
扩容方法
递增式扩容
T* oldElem = _elem; _elem = new T[_capacity += INCREMENT]
最坏的情况下每次都要花时间进行扩容
$$
T(n)=1+2+\dots+n=\frac{n(n+1)}{2}=O(n^2)
$$
- 显然就单单花在扩容上的时间成本消耗就很大。
加倍扩容
T* oleElem = _elem; _elem = new T[_capacity * 2]
最坏的情况是:在初始化为1的满向量中连续的插入n = 2^m >> 2 个元素
于实在第1,2, 4, 8, 16。。。次需要扩容
在各次插入时候的复制元素的时间成本是
$$
1 + 2 + 4 + 8 + 2 ^m = O(2^{logn})= O(n)
$$
- 这种扩容方法相比于递增式扩容方法会降低时间成本。
- 这里的实际上就是几何级数,由于空间的大小2^m限制了的增长。
- 每次都又多余的空间被创造出来。
倍增的是在空间上做出了牺牲使得时间成本下降。
分摊复杂度
平均复杂度或者说期望复杂度( average complexity )
根据数据结构各种操作出现的概率的分布,将对应的成本加权平均
各种可能的操作,作为独立的时间分别考察
割裂了操作之间的相关性和连贯性
往往不能准确评估数据结构和算法的真实性能
分摊复杂度( amortized complexity )
对数阶结构连续地实施足够多次的操作,所需要的总体成本分担至单次操作
实现了对于数据结构与算法的整体考察
更加忠实的刻画了整体
更真实的反映数据结构与算法的效率
无序向量
元素访问
1 | template<T> |
插入
1 | template<T> |
区间删除
1 | template<typename T> |
单元素删除
1 | template<typename T> |
查找
1 | template<typename T> |
唯一化
在这个列表的前面开始查找,如果有重复的就剔除,使用remove函数,如果不行则继续下一个。
1 | template<typename T> |
但是值得注意的是:这个方法的复杂度分析
$$
\begin{matrix}
算法的复杂度累计在while函数,与find与remove函数中\
累积起来的复杂度是O(n^2)
\end{matrix}
$$
遍历操作
- 遍历操作,统一对各个元素进行visit操作
- 使用函数指针
1 | template <typename T> |
- 使用函数对象机制,可以做到全局性修改
1 | template <typename T> template<typename VST> |
1 | template<typename VST> |
有序向量
有序性
$$
\begin{matrix}
无序向量 = 需要比对\
有序向量 = 需要比较大小\
\end{matrix}
$$
1 | template<typename T> |
唯一化
1 | template<typename T> |
二分查找
- 语义约定
- 至少有利于自身的维护, v.insert(1 + v.search(e), e)
- 即使是失败了也得给新元素提供参考
- 允许重复元素,则每一组也需要按其插入的次序排列
- 约定:在有序向量区间v[lo, hi)中,确定不大于e的最后一个元素
- 若e小于v[lo],则返回lo - 1
- 若e大于v[hi - 1] 则返回hi - 1
1 | template<typename T> |
FibonacciSearch
1 | //代码待实现 |
1 | template <typename T> |
向量完全体
1 | // |