目录
2.2 栈底(bottom)
}
大数乘大数
节点:包含一个数据元素及若干指向子树分支的信息 。
节点的度:一个节点拥有子树的数目称为节点的度 。
叶子节点:也称为终端节点,没有子树的节点或者度为零的节点。
分支节点:也称为非终端节点,度不为零的节点称为非终端节点 。
树的度:树中所有节点的度的最大值。
节点的层次:从根节点开始,假设根节点为第1层,根节点的子节点为第2层,依此类推,如果某一个节点位于第L层,则其子节点位于第L+1层 。
树的深度:也称为树的高度,树中所有节点的层次最大值称为树的深度 。
有序树:如果树中各棵子树的次序是有先后次序,则称该树为有序树。
无序树:如果树中各棵子树的次序没有先后次序,则称该树为无序树。
森林:由m(m≥0)棵互不相交的树构成一片森林。如果把一棵非空的树的根节点删除,则该树就变成了一片森林,森林中的树由原来根节点的各棵子树构成。
二叉树的节点数量
二叉树的遍历
先序遍历
中序遍历
二叉树遍历的总结
特殊的二叉树
二叉搜索树
平衡二叉树
二分查找
二分查找也称折半查找( Binary Search),它是一种效率较高的查找方法。但是,折半查找要求线性表必须采用顺序存储结构,而且表中元素按关键字有序排列。
二分查找的基本思想
STL与二分查找
upper_bound
lower_bound
二分查找的适用条件
二分答案的思想
二分答案的技巧
二分答案适用的条件
链表的分类
单向链表
链表的常用操作
查找操作
插入操作 链表与数组
数组的插入
数组的删除
链表的应用
Vector
遍历vector
队列
map的功能
使用map
定义
set的定义
添加元素
正序遍历
set s;
set::iterator it;
for (it = s.begin(); it != s.end(); it++)
cout << *it << endl;
反序遍历
容器与迭代器
vector,stack,queue,map,set都是stl提供的标准数据结构,他们共同的特点是都是容器,用来存储元素集合。为了统一他们的使用方法,stl提供了迭代器。
康托展开
康托展开运算
康托展开逆运算
康托展开是一个全排列到一个自然数的双射,因此是可逆的。即对于上述例子,在给出61可以算出起排列组合为34152。由上述的计算过程逆推回来,具体过程如下:
位运算
“按位与”运算符(&)
左移运算符(<<)
右移运算符(>>)
顾名思义,枚举便是依次列举出所有可能产生的结果,根据题中的条件对所得的结果进行逐一的判断,过滤掉那些不符合要求的,保留那些符合要求的,也可以称之为暴力算法。
枚举法的优缺点
时间复杂度
空间复杂度
常见算法复杂度
复杂度分析基础
复杂度分析进阶
空间换时间
常用的空间换时间方法
前缀和优化
ST表
XOR的特性
尺取法
概念 尺取法也常被称为双指针,是很常用的一种优化算法。通常是对数组保存一对下标,即所选取的区间的左右端点,然后根据实际情况不断地推进区间左右端点以得出答案。尺取法比直接暴力枚举区间效率高很多。
适用条件
贪心算法
贪心算法进阶
贪心的适用条件
栈
stack的基本操作
在C++的标准库中, 有封装好的栈 stack , stack 是一个模板类,定义stack的示例代码如下:
stack<类型> 对象:
stack<int> s;
一种常见的数据结构,遵循后进先出,先进后出的原则。有一个连续的内存区域组成,栈顶(top)
线性表允许插入和删除的那一段。值得注意的是,栈顶指针top的指向是有些两种方式的,一种是指向栈顶当前元素,一种是指向栈顶的下一位置。两种指向方式对栈的操作影响不大,只是在判断栈顶位置的时候略有差异,本文以指向当前栈顶元素为例。另一种指向方式读者可以自行实践。
2.2 栈底(bottom)
固定的,不允许进行插入和删除的另一端。
进栈 push top>=n; Top++; 退栈pop
Top<=n; Top--;
#define n 100
int top=0,x;
void push(int 2[],x){
if(top==n)
else top++;
s[top]=x;
}
void pop(int s[]){
if(top==0) {
cout<<"underflow";
}
Else{top--;
} }
int stackArray[1000], index = -1;
void push(int x) {
index++;
stackArray[index] = x;
}
int pop() {
int value = stackArray[index];
index--;
return value;
}
int top( ) {
return stackArray[index];
}
int size( ) {
return index + 1;
}
int main( ) {
push(1);
push(2);
push(3);
cout << "顶:" << top() << endl;
cout << "大小:" << size() << endl;
pop();
cout << "顶:" << top() << endl;
cout << "大小:" << size() << endl;
pop();
cout << "顶:" << top() << endl;
cout << "大小:" << size() << endl;
pop();
return 0;
}
操作 | 代码 | 解释 |
入栈 | s.push(x) | 将x元素入栈 |
出栈 | s.pop() | 弹出栈的第一个元素,并不会返回元素的值 |
栈顶元素 | s.top() | 获取栈的第一个元素 |
元素个数 | s.size() | 获取栈中的元素个数,返回int |
判空 | s.empty() | 栈是否为空,返回bool,相当于s.size() == 0 |
汉诺塔
void hanoi(int n,char a,char b,char c){
if(n==1){
cout<<a<<"-->"<<c<<endl;
}else{
hanoi(n-1,a,c,b);
cout<<a<<"-->"<<c<<endl;
hanoi(n-1,b,a,c); }}
int main(){
int n; cin>>n;
hanoi(n,'A','B','C');
return 0;}
高精度数(大数)
分类 | 整形数组 | 字符数组 |
储存方式 | 按字符串输入,通过循环语句,将字符转换位数字 | 直接输入,以字符方式存储 |
获取位数 | 计算每个数组的元素位数再相加 | strlen( )函数 |
输出 | 采用循环语句打印数组元素 | 字符串输出,方便 |
运算 | 直接计算 | 转化为数字后计算 |
Strlen Strrev
高精度加法
#include <bits/stdc++.h>
using namespace std;
int main() {
char a1[5005], b1[5005];
int a[5005], b[5005], c[5005];
int lena, lenb, lenc = 1, x, i;
memset(a, 0, sizeof(a));
memset(b, 0, sizeof(b));
memset(c, 0, sizeof(c));
cin>>a1>>b1;
lena = strlen(a1);
lenb = strlen(b1);
for(i=0; i<lena; i++) a[lena - i] = a1[i] - '0';
for(i=0; i<lenb; i++) b[lenb - i] = b1[i] - '0';
x = 0;
while(lenc <= lena || lenc <= lenb) {
c[lenc] = a[lenc] + b[lenc] + x;
x = c[lenc] / 10;
c[lenc] %= 10;
lenc++;
}
c[lenc] = x;
if(c[lenc] == 0) {
lenc--;
}
for(int i=lenc; i>=1; i--) {
cout<< c[i];
}
return 0;
}高精度减法
strcmp 比较compare
Strcpy 复制copy
char a1[1001]={},b1[1001]={},c1[1001]={};
int a[1001]={},b[1001]={},c[1001]={};
int lena,lenb,lenc=0,i;
cin>>a1>>b1;
lena=strlen(a1);
lenb=strlen(b1);
if(strlen(a1)<strlen(b1)||(strlen(a1)==strlen(b1)&&strcmp(a1,b1)<0)){
strcpy(c1,a1);
strcpy(a1,b1);
strcpy(b1,c1);
cout<<"-";
}
lena=strlen(a1);
lenb=strlen(b1);
for(i=0;i<lena;i++){
a[lena-i-1]=a1[i]-48;
}
for(i=0;i<lenb;i++){
b[lenb-i-1]=b1[i]-48;
}
i=0;
while(i<lena||i<lenb){
if(a[i]<b[i]){
a[i]+=10;
a[i+1]--;
}
c[i]=a[i]-b[i];
i++;
}
lenc=i;
while((c[lenc]==0)&&(lenc>1)){
lenc--;
}
for(i=lenc;i>=0;i--){
cout<<c[i];
}
cout<<endl;
高精度乘法
- 输入被乘数和乘数。
- 将char类型的被乘数转化为int 类型。
- 将乘数与被乘数各个位相乘计算,并处理进位。
- 输出乘积。
(多位乘一位)
char a[201]={};
int a1[201],b1[202],b,x=0,s;
cin>>a>>b;
if(b==0){
cout<<0;
return 0;
}
int len=strlen(a);
for(int i=0;i<len;i++){
a1[i+1]=a[i]-48;
}
for(int i=len;i>=0;i--){
s=a1[i]*b+x;
b1[i]=s%10;
x=s/10;
}
int i=0;
while(b1[i]==0 &&i<len){
i++;
}
for(i;i<=len;i++){
cout<<b1[i];
}
大数乘大数
当乘数和被乘数都是大数时,套用上面大数乘 intint 的方法,逐个计算并进位,最终合并相加。
例如: 6789854321238763×77198343712321636789854321238763×7719834371232163
转换后 | |
6789854321238763 | 6789|8543|2123|8763 |
7719834371232163 | 7719|8343|7123|2163 |
8763 | 67641597|73109709|62418849|18954369 |
进位 | 6764|8908|5951|0744|4369 |
2123 | 16387437|17712189|15122129|4592049 |
进位 | 1638|9208|3701|2588|2049 |
8543 | 65943417|71274249|60851789|18478509 |
进位 | 6595|0545|0334|3636|8509 |
6789 | 52404291|56640627|48358047|14684607 |
进位 | 5240|9955|5462|9515|4607 |
最后进行位移相加:
6764 | 8908 | 5951 | 0744 | 4369 | ||||
1638 | 9208 | 3701 | 2588 | 2049 | ||||
6595 | 0545 | 0334 | 3636 | 8509 | ||||
++ | 5240 | 9955 | 5462 | 9515 | 4607 | |||
5241 | 6550 | 7647 | 5823 | 0853 | 7048 | 2793 | 4369 |
多位乘多位
int main(){
char a1[1001]={},b1[1001]={};
int a[1001]={},b[1001]={},c[2002]={};
cin>>a1>>b1;
int lena=strlen(a1);
int lenb=strlen(b1);
for(int i=0;i<lena;i++){
a[lena-i]=a1[i]-48;
}
for(int i=0;i<lenb;i++){
b[lenb-i]=b1[i]-48;
}
for(int i=1;i<=lenb;i++){
int x=0;
for(int j=1;j<=lena;j++){
c[j+i-1]=a[j]*b[i]+x+c[i+j-1];
x=c[i+j-1]/10;
c[i+j-1]%=10;
}
c[i+lena]=x;
}
int lenc=lena+lenb;
while(c[lenc]==0&&lenc>1){
lenc--;
}
for(int i=lenc;i>0;i--){
cout<<c[i];
}
return 0;
}
两个数相乘积不可能超过两数位数之和。 a1[1001]={ },b1[1001]={ },c[2002]={}
高精度除法
(高精除高精)
#include<bits/stdc++.h>
using namespace std;
int a[101],b[101],c[101],d,i;
void init(int a[]){
string s;
cin>>s;
a[0]=s.length();
for(i=1;i<=a[0];i++)
a[i]=s[a[0]-i]-'0';
}
void print(int a[]){
if(a[0]==0) {
cout<<0<<endl;
return ;
}
for(int i=a[0];i>0;i--) cout<<a[i];
cout<<endl;
return ;
}
int compare(int a[],int b[]){
if(a[0]>b[0]) return 1;
if(a[0]<b[0]) return -1;
for(int i=a[0];i>0;i--){
if(a[i]>b[i]) return 1;
if(a[i]<b[i]) return -1;
}
return 0;
}
void jian(int a[],int b[]){
int flag,i;
flag=compare(a,b);
if(flag==0) {a[0]=0;return;}
if(flag==1){
for(i=1;i<=a[0];i++){
if(a[i]<b[i]){
a[i+1]--;
a[i]+=10;
}
a[i]-=b[i];
}
while(a[0]>0&&a[a[0]]==0) a[0]--;
return;
}
}
void numcpy(int p[],int q[],int det){
for(int i=1;i<=p[0];i++)q[i+det-1]=p[i];
q[0]=p[0]+det-1;
}
void chu(int a[],int b[],int c[]){
int i,tmp[101];
c[0]=a[0]-b[0]+1;
for(i=c[0];i>0;i--){
memset(tmp,0,sizeof(tmp));
numcpy(b,tmp,i);
while(compare(a,tmp)>=0){
c[i]++;
jian(a,tmp);
}
}
while(c[0]>0&&c[c[0]]==0) c[0]--;
return ;
}
int main(){
memset(a,0,sizeof(a));
memset(b,0,sizeof(b));
memset(c,0,sizeof(c));
init(a);
init(b);
chu(a,b,c);
print(c);
print(a);
return 0;
}排序
冒泡排序 快速排序 选择排序 二叉树排序
插入排序 堆排排序
选择排序
每一趟从待排序的数据元素中选出最大(或最小)的一个元素,顺序放在待排序的数列的最前面,直到全部待排序的数据元素排完。
- 读入数据存放在a数组中。
- 在a[1]~a[n]中选择值最小的元素,与第1位置元素进行交换,则把最小值元素放入a[1]中。
- 在a[2]~a[n]中选择值最小的元素,与第2位置元素进行交换,则把最小元素放入a[2]中。
- 知道第n-1个元素与第n 个元素比较排序为止。
int n,k,i,j;
int a[1001];
cin>>n;
for(i=0;i<n;i++){
cin>>a[i];
}
for(i=0;i<n;i++){
k=i;
for(j=i+1;j<n;j++){
if(a[j]<a[k]){
k=j;
}
}
if(k!=i){
swap(a[i],a[k]);
}
}
for(i=0;i<n;i++){
cout<<a[i]<<" ";
}
冒泡排序
- 读入数据存放在a数组中。
- 比较相邻的前后两个数据,如果前面数据大于后面的数据,就将两个数据交换。
- 对数组的第0个数据到n-1个数据进行一次遍历后,最大的一个数据就“冒”到数组第n-1个位置。
- 重复前面第二步,至道排序完成。
int n,k,i,j;
int a[1001];
cin>>n;
for(i=0;i<n;i++){
cin>>a[i];
}
for(i=n-1;i>=1;i--){
for(j=0;j<i;j++){
if(a[j]>a[j+1]){
swap(a[j],a[j+1]);
}}}
for(i=0;i<n;i++){
cout<<a[i]<<" ";
}
6 | 5 | 3 | 4 | 1 | 2 |
5 | 6 | 3 | 4 | 1 | 2 |
5 | 3 | 6 | 4 | 1 | 2 |
5 | 3 | 4 | 6 | 1 | 2 |
5 | 3 | 4 | 1 | 6 | 2 |
5 | 3 | 4 | 1 | 2 | 6 |
图
邻接数组表示法
图:是一种非线性的数据结构,有多点连接在一起。图是由有限个数的顶点和顶点之间边的组成,通常表示为:G(V,E),其中,G表示一个图,V是图G中顶点的集合,E是图G中边的集合。
无向图:在途中任一顶点上的边都是没有方向性的。
无向边的表示:用圆括号将两端的顶点括起来(Vi,Vj).
有向图:在图中任意顶点上的边都是有方向的。
有向边的表示:用尖括号将边两边的顶点括起来<Vi,Vj>
有向完全图:有向图中,如果任意两个顶点之间都存在方向互为相反的两条弧。含有n个顶点的有向完全图有n*(n-1)条边。
无向完全图:无向图中,任意两个顶点之间都存在边。含有n个顶点的无向完全图有n*(n-1)/2条边。
子图:图形中取出的部分集合。
度:顶点V的度是和V相关联的边的数目。
入度:有向图中以顶点V为终点的有向图边的数目。
出度:有向图中以顶点V为起点的有向边的数目。
权值:边的“费用”,可以形象的理解为边的长度。
回路:起点和终点相同的路径,称为回路。或“环”
连通图形:在无向图中,任意两个顶点皆连通,则称为连通图形。即任意两个顶点皆存在一条路径可到达。
强连通图形:在有向图中,任意两个顶点间皆存在一条路径可到对方,则称为强连通图形。
强连通分量:有向图中任意两点都连通的最大子图,包括单个顶点。
邻接数组
邻接数组表示法是以一个n*n的数组来表示一个具有n个顶点的图形,我们以数组的索引值来表示顶点,以数组的内容值来表示顶点间的边是否存在。如图所示:
0 | 1 | 2 | 3 | 4 | |
0 | 0 | 1 | 1 | 0 | 0 |
1 | 1 | 0 | 1 | 1 | 1 |
2 | 1 | 1 | 0 | 1 | 0 |
3 | 0 | 1 | 1 | 0 | 0 |
4 | 0 | 1 | 0 | 0 | 0 |
遍历:从图中某一顶点出发系统的访问图中的所有顶点,是每个顶点恰好被访问一边。
树
由n(>0)个元素组成的有限集合,每个元素称为结点,有一个特定的结点,称为根结点。
除了根节点外,其余节点称为子树。一个结点的子树个数,称为这个结点的度。度为0的结点称为叶节点.
二叉树度为二。二叉树的每个结点最多有两个结点。
在二叉树的第i层上最多有(2i-1)个结点。(i>=1)
深度为k的二叉数至多有(2k-1)个结点。(k>=1)
满二叉树:除最后一层无结点外,每一层上的所有结点都有两个子结点。在满二叉树中,每一层上的结点数都达到最大值。
完全二叉树:除最后一层外,每一层上的结点数均达最大值,在最后一层上只缺少右边的若干结点。
二叉树
在计算机科学中,二叉树是每个节点最多有两个子树的树结构。通常子树被称作“左子树”和“右子树”。
节点 | 左子树 | 右子树 |
0 | 子树 1 | 子树 7 |
1 | 子树 2 | 子树 6 |
2 | 无 | 无 |
链表也可以看作是一棵特殊的二叉树,因为链表每个结点只有一个子节点,满足二叉树的定义。二叉树与普通树的差别在于,普通树可以有 22 个以上的子节点,而二叉树的子节点不能超过 22 个。
链表 | 二叉树 | 树 | |
后继节点 | 1个 | 不超过 2个 | 任意个 |
定义关系 | 链表属于二叉树 | 二叉树属于树 |
节点:包含一个数据元素及若干指向子树分支的信息 。
节点的度:一个节点拥有子树的数目称为节点的度 。
叶子节点:也称为终端节点,没有子树的节点或者度为零的节点。
分支节点:也称为非终端节点,度不为零的节点称为非终端节点 。
树的度:树中所有节点的度的最大值。
节点的层次:从根节点开始,假设根节点为第1层,根节点的子节点为第2层,依此类推,如果某一个节点位于第L层,则其子节点位于第L+1层 。
树的深度:也称为树的高度,树中所有节点的层次最大值称为树的深度 。
有序树:如果树中各棵子树的次序是有先后次序,则称该树为有序树。
无序树:如果树中各棵子树的次序没有先后次序,则称该树为无序树。
森林:由m(m≥0)棵互不相交的树构成一片森林。如果把一棵非空的树的根节点删除,则该树就变成了一片森林,森林中的树由原来根节点的各棵子树构成。
二叉树的节点数量
一棵高度为n的二叉树,最多包括2n−1个节点。树高为n,共有n 层,第一层有1 个根节点,根据二叉树的定义,后面每层节点的数量最多为上一层的2 倍,因此最多有:1+2+4+....+2n−1=2n−1个节点。
上图为一棵高度为3的二叉树,共有7个节点。
那么一棵高度为n的二叉树,最少包括多少个节点呢?最少有n个节点,是一个链表。
二叉树的存储
可以继续沿用存储树的方法,来存储二叉树。
struct node{
int value;
vector<int> childs; //用来记录所有子节点的编号
}nodes[10000];
int root;
//root为根节点
也可以利用二叉树的特性,用左右子树的方式,来存储二叉树。
用结构体来表示二叉树的节点, 每个节点存储了当前节点的值, 以及左子树和右子树的编号。
struct node{
int value;
int left;
int right;
}nodes[10000];
int root; //root为根节点
二叉树的遍历
遍历二叉树的方法可以沿用遍历树的方法——递归。
DFS(当前节点u){
DFS(u.left);
DFS(u.right);
}
在此基础上,二叉树的遍历又分为以下三种:
顺序 | ||
先序遍历 | 根左右 | 27,14,10,19,35,31,42 |
中序遍历 | 左根右 | 10,14,19,27,31,35,42 |
后序遍历 | 左右根 | 10,19,14,31,42,35,27 |
先序遍历
遍历顺序规则为:根左右,遍历方法:
(1)访问根节点
(2)采用先序递归遍历左子树
(3)采用先序递归遍历右子树
先序遍历结果:ABDFECGHI。
中序遍历
遍历顺序规则为:左根右,遍历方法:
(1)采用中序遍历左子树
(2)访问根节点
(3)采用中序遍历右子树
中序遍历结果: DBEF A GHCI。
二叉树遍历的总结
三种方法遍历过程中经过节点的路线一样,只是访问各个节点的顺序不同。
二叉树的先序、中序、后序遍历我们已经学会了, 那么给定其中任意两种遍历, 我们能否推出唯一的第三种遍历么?
答案:给定先序+中序可以推出后序。后序+中序可以推出先序。但是先序+后序是无法推出中序的。
这里我们只是以:知道先序遍历和中序遍历,推断后序遍历作为例子,其他组合方式原理是一样的。
要完成这个任务,我们首先要利用以下几个特性:
特性A,对于先序遍历,第一个肯定是根节点
特性B,对于后序遍历,最后一个肯定是根节点
特性C,利用先序或后序遍历,确定根节点,在中序遍历中,根节点的两边就可以分出左子树和右子树;
特性D,对左子树和右子树分别做前面3点的分析和拆分,相当于做递归,我们就可以重建出完整的二叉树。
所以我们可以靠保存先序+中序,或者后序+中序2个顺序,来保存整个二叉树的结构。
以下为先序遍历结果为1,2,3的所有二叉树。
编号 | 中序 | 后序 |
1 | 3,2,1 | 3,2,1 |
2 | 1,2,3 | 3,2,1 |
3 | 2,3,1 | 3,2,1 |
4 | 2,1,3 | 2,3,1 |
5 | 1,3,2 | 3,2,1 |
上面的表格中,所有的中序遍历结果都不相同,因此给出中序就可以确定是哪棵二叉树。
但后序遍历的结果有重复的情况,因此如果给出后序遍历的结果,并不能确定对应的是哪一棵二叉树。
特殊的二叉树
二叉搜索树
二叉搜索树(Binary Search Tree),(又:二叉排序树)它或者是一棵空树,或者是具有下列性质的二叉树: 若它的左子树不空,则左子树上所有节点的值均小于它的根节点的值; 若它的右子树不空,则右子树上所有节点的值均大于它的根节点的值; 它的左、右子树也分别为二叉搜索树。
上图为二叉搜索树,以子树20为例,左子树的节点包括:3,14。均小于20,右子树的节点包括:35。大于20。二叉搜索树是特殊的二叉树,因为除了满足二叉的条件之外,还满足节点有序。二叉搜索树有许多扩展,被用于各类数据结构。因为在二叉查找树中,找一个符合条件的值,时间复杂度同这个树的深度相关。而我们知道,假如这个二叉搜索树的每一个节点都是满的,其所有叶子节点的深度都是 d ,那么这棵树的节点数量可以达到O(2d)。这时查找的复杂度为O(log(n))。与我们之前学习的二分查找复杂度相同。能够快速查找数据也是二叉搜索树被用于各类数据结构的原因,但这种快速是有限制的,即不会出现退化,假如二叉搜索树退化为链表,则无法做到快速查找数据的要求,所以又出现了平衡二叉树。
平衡二叉树
平衡二叉树(Balanced Binary Tree)具有以下性质:它是一棵空树或它的左右两个子树的高度差的绝对值不超过1,并且左右两个子树都是一棵平衡二叉树。
平衡二叉树有效的限制了树高,使得二叉树不会退化为一个链表。
平衡二叉树如果同时也是一棵二叉搜索树,那么查找某个节点的效率是O(log(n))的。
很多数据结构,不能完美的做到平衡,但也能有效的控制树高,例如:红黑树。也被经常用于数据的快速查找。
- 根据后序找出根节点e。
- 根据中序确定左右子树。
- 确定左右子树根节点。
满二叉数有2k-1个结点
struct node{
char value;
int left,right;
}data[101];
int root=0,cnt;
char ch;
int buildTree(int bt){
cin>>ch;
if(ch=='.'){
bt==0;
return bt;
}else{
bt=++cnt;
data[bt].value=ch;
data[bt].left=data[bt].right=0;
data[bt].left=buildTree(bt);
data[bt].right=buildTree(bt);
}
return bt;
}
void postorder(int bt){
if(bt){
postorder(data[bt].left);
postorder(data[bt].right);
cout<<data[bt].value;
}
}
int main(){
root=0;
cnt=0;
root=buildTree(0);
postorder(root);
return 0;
}
共同特点
- 最优子结构
母问题的最优解包含其子问题的最优解,我们就称此问题具有最优子结构。
- 子问题重叠
子问题本质上是和母问题一样的,只是问题的输入参数不一样,就可以称之为子问题重叠。 这类问题的解决途径------【动态规划】
背包问题
int w[200],c[200],f[200][200];
int m,n;
cin>>m>>n;
for(int i=1;i<=n;i++){
cin>>w[i]>>c[i];
}
for(int i=1;i<=n;i++){
for(int j=1;j<=m;j++){
if(j>=w[i]){
f[i][j]=max(f[i-1][j-w[i]]+c[i],f[i-1][j]);
}else{
f[i][j]=f[i-1][j];
}
}
}
cout<<f[n][m];
算法
算法是对特定问题求解步骤的一种描述,他是指令的有限序列,其中那个每一条指令表示一个或多个操作。简单来说,算法就是解决问题的操作步骤。
1有穷性 2确定性 3输入 4输出 5可行性
时间复杂度 :计算工作量
空间复杂度 :内存空间
结构:顺序结构 选择结构 循环结构
算法
- 划分阶段:按照问题的特征,把问题分为若干个阶段。
- 确定状态和状态变量:将问题发展到各个阶段所处的各种情况用不同的状态表示出来。
- 确定决策并写出状态转移方程:根据相邻状态之间的关系确定决策。
- 寻找边界条件:递推式的边界条件。
特征 | 具体描述 |
有穷性 | 对于任意一组合法的输入值,算法的每个操做步骤都蒙在有限时间内完成 |
确定性 | 算法中的每一步都必须是有明确的定义,不允许歧义性和多义性 |
输入 | 一个算法应该有0个或多个输入,以刻画预算对象的初始情况。 |
输出 | 一个算法应该有一个或多个输出,以反映对输入数据加工后的结果 |
可行性 | 在特定的接替规则中允许使用的,可执行的并可以通过执行有限次来实现。 |
二分法
每次找一半 优先级:括号>非>与>或、异或。
int maxline=200,kz;
int reverse(char s[]){
int i,j,t;
for(i=0,j=strlen(s)-1;i<j;i++,j--){
t=s[i];s[i]=s[j];s[j]=t;
}
return 0;}
int main(){
char line[100];
cin>>kz;
while(kz!=-1){
cin>>line;
reverse(line);
cout<<line<<endl;
cin>>kz; }
return 0;}
二分查找
二分查找也称折半查找( Binary Search),它是一种效率较高的查找方法。但是,折半查找要求线性表必须采用顺序存储结构,而且表中元素按关键字有序排列。
给一个不重复有序的数组, 要找到某一个数出现的位置, 最朴素的做法就是遍历一遍数组, 复杂度是O(n),但是通过二分查找, 可以在O(log(n))的复杂度下完成查找。如数组1,2,3,4,5,6,7,8,9 ,查找元素6,用二分查找的算法执行的话,其顺序为:第一步查找中间元素,即5,由于5<6,则 6 必然在5之后的数组元素中,那么就在6,7,8,9中查找,寻找6,7,8,9 的中位数,为 7 , 7>6 ,则6应该在7左边的数组元素中,那么只剩下6,即找到了。
二分查找的基本思想
A想好一个100以内的自然数,由B来猜。每次A都会告诉B猜大了还是小了,或者猜对了。B的最优策略就是先猜50,如果大了再猜25,如果小了则猜75。这就用到了二分查找的思想。设 R[low..high]是当前的查找区间首先确定该区间的中点位置:mid=⌊(low+high)/2⌋。然后将待查的K值与 a[mid] 比较:若相等,则查找成功并返回此位置,否则须确定新的查找区间,继续二分查找,具体方法如下:若a[mid]>K,则由表的有序性可知a[mid..n]均大于K,因此若表中存在关键字等于K的结点,则该结点必定是在位置 mid 左边的子表a[low..mid−1]中,故新的查找区间是左子表R[low..mid−1]。若a[mid]<K,则要查找的K必在mid 的右子表R[mid+1..high]中,即新的查找区间是右子表 R[mid+1..high]。下一次查找是针对新的查找区间进行的。因此,从初始的查找区间R[1..n]开始,每经过一次与当前查找区间的中点位置上的结点关键字的比较,就可确定查找是否成功,不成功则当前的查找区间就缩小一半。这一过程重复直至找到关键字为 KK 的结点,或者直至当前的查找区间为空(即查找失败)时为止。 因为每次都可以将区间大小缩小一半,所以最多只需log(n)log(n) 次就可完成查找,所以二分查找的复杂度是O(log(n))。
int Binary_Search(int a[], int n, int key){
int low, high, mid;
low = 1; /*定义最底下标为记录首位*/
high = n; /*定义最高下标为记录末位*/
while (low <= high)
{
mid = (low + high) / 2; /*折半*/
if (key < a[mid])
high = mid - 1;
else if (key > a[mid])
low = mid + 1;
else
return mid;
}
return -1; // 没找到
}
STL与二分查找
upper_bound
upper_bound() 是 C++C++ 标准库提供的二分查找函数,可以在一个排好序的数组中,对指定数值进行查找。
upper_bound(begin,end,num)会从数组的 begin 位置到 end−1 位置二分查找第一个大于num的数字。同排序sort函数一样,upper_bound()提供了一个重载函数,允许我们自己定义什么是大于,upper_bound(begin,end,num,cmp)。通过cmp函数定于大于或小于。需要注意:如果原数组不是通过 cmp 进行排序的,则不符合二分的条件,因为二分查找过程中,会认为原数组是无序的。upper_bound(),最终返回的是一个地址,即第一个大于num 的数字的位置,如果不存在则返回end 。通过返回的地址减去起始地址begin ,得到找到数字在数组中的下标。
int main(){
int num[6]={1,2,4,7,15,34};
sort(num, num + 6); //按从小到大排序
int pos = upper_bound(num, num + 6, 7) - num;//返回数组中第一个大于被查数的值
cout << pos << " " << num[pos] << endl;
return 0;
}
输出结果如下:4 15
使用自定义的比较函数 cmpcmp 。
int cmp(int a,int b){
return a > b;
}
int main(){
int num[6]={34, 15, 7, 4, 2, 1};
sort(num, num + 6, cmp); //按从大到小排序
int pos=upper_bound(num, num+6, 7, cmp) - num; //返回数组中第一个小于被查数的值
cout << pos << " " << num[pos] << endl;
return 0;
}
输出结果:3 4
lower_bound
lower_bound()是C++标准库提供的另一个二分查找函数,与upper_bound() ,lower_bound(begin,end,num)会从数组的begin位置到end−1 位置二分查找第一个大于或等于num的数字。
lower_bound()也提供了一个重载函数,允许我们自己定义什么是大于,lower_bound(begin,end,num,cmp) 。
数组 | 返回 | |
upper_bound() | 必须有序 | 大于 numnum 的数 |
lower_bound() | 必须有序 | 大于或等于 numnum 的数 |
int main(){
int num[6]={1,2,4,7,15,34};
sort(num,num+6); //按从小到大排序
int pos=lower_bound(num, num+6,7) - num; //返回数组中第一个大于或等于被查数的值
cout<<pos<<" "<< num[pos]<<endl;
return 0;
}
输出结果:3 7
使用自定义的比较函数 cmpcmp 。
int cmp(int a,int b){
return a > b;
}
int main(){
int num[6]={34, 15, 7, 4, 2, 1};
sort(num, num + 6, cmp); //按从大到小排序
int pos = lower_bound(num, num + 6, 7, cmp) - num; //返回数组中第一个小于或等于被查数的值
cout << pos << " " << num[pos] << endl;
return 0;
}
输出结果:2 7
二分查找的适用条件
虽然二分查找的效率高,但是要将表按关键字排序。而排序本身是一种很费时的运算。如果所给的数组无序的,则需要先用的复杂度将数组进行排序,才能完成二分查找。
二分答案的思想
二分答案就是对最终答案的值进行二分,具体做法就是每次取一个中间答案, 把答案作为已知条件, 判断根据当前答案是否满足题意, 根据判断结果再将区间缩小。写法和二分查找基本相同, 只不过要判断的东西比较复杂, 一般写法是通过写一个\(check\) 函数来实现。
二分答案的技巧
二分答案通常是在寻找“合法”与“不合法”的边界,进一步说,是在寻找“刚好合法”的那个位置。二分离不开单调性,我们做这类题目最重要的就是要找到题目中的某个与“合法性”有单调关系的量。单调关系通常可以表现为:A越大,越合法,A越小,越不合法。在题目中总结出这种单调关系,十分有助于进一步思考。
二分答案适用的条件
一个问题如果可以用二分答案的方法来解决,一定要满足某种单调性。这样通过二分答案,我们可以将一个本来需要复杂计算的问题,转为一个判定性问题,即成立或不成立,没有严格的单调性保障,二分答案可能会给出错误的答案。
链表
链表是一种物理存储单元上非连续,非顺序的储存结构,数据元素的逻辑顺序是通过链表中的指针连续实现的。
单向链表:数据以节点来表示(只能一个方向)
双向链表:每个数据节点中都有两个指针,(可以两个方向)
循环链表:表中最后一个结点的指针域指向头节点,整个链表形成一个环。
数组是在内存中一段连续的存储空间,
可以在常数时间内访问任意位置的元素, 但是数组也有缺点, 无法做到快速的插入和删除, 因为空间是连续且固定的, 想要在 p 位置插入/删除一个元素, 则 p 之后的位置的元素都需要移动。
为了能够在常数时间内实现元素的插入和删除, 我们引入 链表 这种数据结构。
链表是一种物理存储单元上非连续、非顺序的存储结构,数据元素的逻辑顺序是通过链表中的指针链接次序实现的。链表由一系列结点(链表中每一个元素称为结点)组成,结点可以在运行时动态生成。
那么非连续、非线性有什么含义呢?这表明链表的内存是不连续的,前一个元素存储地址的下一个地址中存储的不一定是下一个元素。链表通过一个指向下一个元素地址的引用将链表中的元素串起来。
链表的分类
分类 | 描述 |
单向链表 | 每一个节点包含了数据块和指向下一个节点的指针 |
双向链表 | 每一个节点包含了数据块和指向下一个节点的指针以及指向前一个节点的指针 |
单向链表
单向链表是最简单的链表形式。我们将链表中最基本的数据称为节点(node),每一个节点包含了数据块(data)和指向下一个节点的指针(next),链表有一个头节点,图中以head表示。可以看出,head指向第一个元素,第一个元素的next又指向第二个元素……直到最后一个元素,该元素不再指向其他元素,它称为表尾,它的 next 为空(NULL),链表到此结束。
可以看到,要找链表中的某一元素,必须先找到上一个元素,根据它提供的下一个元素的地址才能找到下一个元素。如果不提供头指针,则整个链表都无法访问。链表如同一条铁链一样,一环扣一环,中间是不能断开的。
我们使用数组模拟的方法来学习链表。我们创建一个结构体, 这个结构体有两个int型的变量,分别是data和next,data就是链表当前节点存储的数据,next指向下一个节点的节点编号(在数组中的编号,这样可以避免使用指针)。
struct node{ //next即后面节点的编号,data就是需要维护的数据
int next,data;
}a[10000];
int head; //head即头指针
//head节点是头结点, 不存储数据, 作用只是用来找到链表的第一个节点。
链表的常用操作
操作 | 描述 |
查找 | 找到符合条件的节点 |
插入 | 添加一个新节点 |
删除 | 删除一个存在的节点 |
查找操作
如何根据给出的数据,找到链表中符合条件的节点呢?需要从头节点开始,逐个向后遍历节点,比较 p 的 data 同待查找的数据是否相同,相同则返回当前节点。
struct node{ //next即后面节点的编号,data就是需要维护的数据
int next,data;
}a[10000];
int head; //head即头节点的编号
node find(int value){
int p = head; //从
while(a[p] != NULL) {
if(value==a[p].data)
return a[p];
p = a[p].next;
}
return NULL;
}
插入操作 链表与数组
操作 | 链表 | 数组 |
查找 | 从头节点开始查找 | 从下标0开始查找 |
插入 | 很快,因为只需要修改pre以及新节点的next编号 | 所有后面的节点都要向后移1位 |
删除 | 很快,因为只需要修改pre的next编号 | 所有后面的节点都要向前移1位 |
数组的插入
数组的删除
链表的应用
由于链表存储不连续,一般来讲,访问效率低于数组。但链表的删除插入效率高。
欧拉回路:奇数点可各遍历一遍。
捆绑法,插空法,排除
STL 标准模板库
obj.pop_back()删除数组中的最后一个数据
typede起别名 Insert 添加函数
Vector:只关心随机存取,不在乎插入和删除
List 只关心插入删除,不在乎随机存取。
动态规划
int main(){
int a[101][101];
for(int i=0;i<=8;i++){
for(int j=1;i<=8;j++){
if(i==1&&j==1){
a[i][j]=1;
}else{
a[i][j]=a[i-1][j]+a[i][j-1];
}
}
}
cout<<a[8][8];
深搜:能深则深,不能深就退。
Vector
基本操作
vector<int> c
c.clear() //移除容器中所有数据。
c.empty() //判断容器是否为空。
c.erase(pos) //删除pos位置的数据
c.erase(beg,end) //删除[beg,end)区间的数据
c.front() //传回第一个数据。
c.insert(pos,elem) //在pos位置插入一个elem拷贝
c.pop_back() //删除最后一个数据。
c.push_back(elem) //在尾部加入一个数据。
c.resize(num) //重新设置该容器的大小
c.size() //回容器中实际数据的个数。
c.begin() //返回指向容器第一个元素的迭代器
c.end() //返回指向容器最后一个元素的迭代器
遍历vector
访问 vector 的方法有两种:
方法一:直接访问
方法二:迭代器访问
int main(){ //顺序访问
vector<int>obj;
for (int i = 0; i < 10; i++)
obj.push_back(i);
cout << "直接利用下标:";
for (int i = 0; i < obj.size(); i++)//方法一
cout << obj[i] << " ";
cout << endl;
cout << "利用迭代器:"; //声明一个迭代器,来访问vector容器,作用:遍历或者指向vector容器的元素
vector<int>::iterator it;
for(it = obj.begin();it != obj.end(); it++)
cout << *it << " ";
return 0;}
队列
队列是一种特殊的线性表。队列的原则是先进先出(FIFO — first in first out)。先进先出来,后进后出。
队列在队头做删除操作,在队尾做插入操作:
队列的数据元素又称为队列元素。在队列中插入一个队列元素称为入队,从队列中删除一个队列元素称为出队。因为队列只允许在一端插入,在另一端删除,所以只有最早进入队列的元素才能最先从队列中删除,故队列又称为先进先出(FIFO—first in first out)线性表。
操作 | 函数 | 解释 |
入队 | push(x) | 将x元素入队 |
出队 | pop() | 弹出队首元素,返回值为队首元素值 |
元素个数 | size() | 获取队列中的元素个数,返回int |
获取队首元素 | front() | 获取队列第一个元素 |
在 C++的标准库中, 有封装好的队列 queue,queue是一个模板类,定义 queue 对象的示例代码如下: queue<int> q1; queue<double> q2;
操作 | 代码 | 解释 |
入队 | q.push(x) | 将x元素放到队列的末端 |
出队 | q.pop() | 弹出队列的第一个元素,并不会返回元素的值 |
队首元素 | q.front() | 获取队列的第一个元素 |
队尾元素 | q.back() | 获取队列的最后一个元素 |
元素个数 | q.size() | 获取队中的元素个数,返回int |
判空 | q.empty() | 队列是否为空,返回bool,相当于q.size() == 0 |
int main() {
queue<int> q; //定义队列q
q.push(1); //放入元素3
q.push(2);
q.push(3);
cout << "头:" << q.front( ) << endl;
cout << "尾:" << q.back( ) << endl;
cout << "大小:" << q.size( ) << endl;
q.pop(); //弹出队列的第一个元素
cout << "头:" << q.front( ) << endl;
cout << "尾:" << q.back( ) << endl;
cout << "大小:" << q.size( ) << endl;
q.pop( );
cout << "头:" << q.front( ) << endl;
cout << "尾:" << q.back( ) << endl;
cout << "大小:" << q.size( ) << endl;
q.pop( );
return 0;
}
MAP
map是C++标准库的一个关联容器,是一个模板类。
它提供一对一(其中第一个可以称为关键字,每个关键字只能在 map中出现一次,第二个可能称为该关键字的值)的数据处理能力,由于这个特性,它完成有可能在我们处理一对一数据的时候,在编程上提供快速通道。map就是从键( key)到值(value)的映射。因为重载了[ ][ ]运算符,map像是数组的“高级版”。
例如可以用一个 map<string, int>month_name 来表示“月份名字到月份编号”的映射,然后用 monthname["July"]=7 这样的方式来赋值。
这里说下map 内部数据的组织,map 内部自建一颗红黑树(一 种非严格意义上的平衡二叉树),这颗树具有对数据自动排序的功能,所以在map 内部所有的数据都是有序的,后边我们会见识到有序的好处。map 是一类关联式容器。它的特点是增加和删除节点对迭代器的影响很小,除了那个操作节点,对其他的节点都没有什么影响。
如果没有map,利用结构体数组查找某个键对应的值,就需要遍历整个数组,这样很低效。map的内部实现是一种名为红黑树的数据结构,当将不同的键值放入 map,这种数据结构会自动将所有键值变为有序的(按照键key排序)。
如放入:8[A],1[F],7[Y],4[L],10[X]后(中括号内为值),map中保存的数据顺序为 1[F],4[L],7[Y],8[A],10[X] 。
因为有序,当我们查找某个值的时候,就无须遍历整个数组,而是可以用二分的方法快速定位。还是以上面的数据为例,我们想要查找4对应的值,查找过程会先找到中间的7,发现7比4大,因而只在前一半中进行查找,这样每次查找的范围会缩小一半,关于二分的知识,我们会在后续课程中讲解,这里不展开介绍。
map并不是唯一支持从 key 找 value的数据结构,还有一种更快的以空间换时间的方法叫做 hash。
map的功能
自动建立Key-value 的对应。key和value可以是任意你需要的类型。
根据key值快速查找记录,查找的复杂度基本是Log(N)Log(N)
1.快速插入Key−Value记录
2.快速删除记录。
3.根据Key修改
.value记录
4.遍历所有记录。
使用map
定义
map<string,int> my_Map;
SET关联式容器
set作为一个容器也是用来存储同一数据类型的数据类型,并且能从一个数据集合中取出数据,在 set中每个元素的值都唯一,而且系统能根据元素的值自动进行排序。应该注意的是 set 中数元素的值不能直接被改变。与 map的使用方法大致都相同。
set的定义
set<类型> 对象名; 如:set<int> s;
添加元素
set<int> s;
s.insert(8);
s.insert(10);
s.insert(6);
s.insert(8); //重复元素不会插入
set遍历
setset 的遍历也是使用迭代器进行遍历, 可以正序遍历也可以反序遍历。
正序遍历
set<int> s;
set<int>::iterator it;
for (it = s.begin(); it != s.end(); it++)
cout << *it << endl;
反序遍历
set<int>::reverse_iterator it;
for (it = s.rbegin(); it != s.rend(); it++)
cout << *it << endl
容器与迭代器
vector,stack,queue,map,set都是stl提供的标准数据结构,他们共同的特点是都是容器,用来存储元素集合。为了统一他们的使用方法,stl提供了迭代器。
容器 | 迭代器功能 | 解释 |
vector | 随机访问 | |
deque | 随机访问 | 双端队列,可以从前后push,pop 元素 |
list | 双向 | 链表 |
set/multiset | 双向 | multiset是允许有重复元素的set,元素保持有序 |
map/multimap | 双向 | multimap是允许有重复元素的map,元素是pair保持有序 |
stack | 不支持迭代器 | 栈 |
queue | 不支持迭代器 | 队列 |
priority_queue | 不支持迭代器 | 优先队列,每次可以从栈顶取出当前最小 |
容器 | 添加元素的效率 | 删除元素的效率 | 查找元素 | 访问中间的元素 |
递归
在函数内部,可以调用其他函数。如果一个函数在内部调用自己本身,这个函数就是递归函数。如果一个函数在调用自己,则被调用的”自己”再调用“自己”。想象一下,这将会无限循环下去,所以递归函数中肯定有办法在某种情况下停止调用自己,即停止递归,一般来说使用if语句实现在满足特定的条件下终止递归。
函数不再递归的情况称作基本情形(base case,也称基本情况)。函数调用自身来执行子任务的情况就称作递归情形(recursive case)。
操作格式
递归函数格式如下:
函数类型 函数名(形参1){
if(条件1)
该条件下的函数值;
else if(条件2)
另一种条件下的函数值;
......
执行操作并进行递归调用;
}
递归与辗转相除
辗转相除法,又名欧几里德算法。是求最大公约数的一种方法。它的具体做法是:用较大数除以较小数,再用出现的余数(第一余数)去除除数,再用出现的余数(第二余数)去除第一余数,如此反复,直到最后余数是0为止。如果是求两个数的最大公约数,那么最后的除数就是这两个数的最大公约数。
int gcd(int a, int b) {
if (b == 0)
return a;
return gcd(b, a % b);
}
递归替代循环
void printHello(int n) { // 传入一个参数n
if(n == 0)
return;
cout << "Hello" << endl;
printHello(n - 1);
}
int main() { // 主函数
printHello(5); // 调用函数
}
循环替代递归
斐波那契数列
int Fib(int n){
int a = 1, b = 1;
for (int i = 2; i < n; i++){
int t = b; b = a; a += t;
}
return a;
}
int main(){
cout << Fib(10);
return 0;
}
康托展开
康托展开是一个全排列到一个自然数的双射,常用于构建哈希表时的空间压缩。 康托展开的实质是计算当前排列在所有由小到大全排列中的顺序,因此是可逆的。
康托展开运算
X=an(n−1)!+an−1(n−2)!+...a10!其中ai为整数,并且满足0≤ai<i,1≤i≤nai表示原数的第i位在当前未出现的元素中排在第几。
例:在12345的排列组合中,计算34152的康托展开值。
值 | 未出现且小于当前值的数 | 展开值 | |
33 | 1,2=>a5=2 | a5×4! | 4848 |
44 | 1,2=>a4=2 | a4×3! | 1212 |
11 | 无 =>a3=0=>a3=0 | a3×2!a3×2! | 00 |
55 | 2=>a2=12=>a2=1 | a2×1!a2×1! | 11 |
22 | 无 =>a1=0=>a1=0 | a1×0!a1×0! | 00 |
所以比34152小的组合有61个,即34152是排第62。
康托展开逆运算
康托展开是一个全排列到一个自然数的双射,因此是可逆的。即对于上述例子,在给出61可以算出起排列组合为34152。由上述的计算过程逆推回来,具体过程如下:
61/4!=2余13,说明,说明比首位小的数有2个,所以首位为3 。
13/3!=2余11,说明,说明在第二位之后小于第二位的数有2 个,所以第二位为4。
1/2!=0余1,说明,说明在第三位之后没有小于第三位的数,所以第三位为1。
用1/1!=1余0,说明,说明在第四位之后小于第四位的数有1个,所以第四位为5。
最后一位自然就是剩下的数2。通过以上分析,所求排列组合为34152 。
位运算
位运算是指按二进制进行的运算。在系统软件中,常常需要处理二进制位的问题。C语言提供了6 个位操作运算符。这些运算符只能用于整型操作数,即只能用于带符号或无符号的 char,short,int与longlong 类型。
运算符 | ||
& | 按位与 | 如果两个相应的二进制位都为1,则该位的结果值为1,否则为0 |
| | 按位或 | 两个相应的二进制位中只要有一个为1,该位的结果值为1 |
^ | 按位异或 | 若参加运算的两个二进制位值相同则为0,否则为1 |
~ | 取反 | ~是一元运算符,用来对一个二进制数按位取反,将0变1,将1变0 |
<< | 左移 | 用来将一个数的各二进制位全部左移N位,右补0 |
>> | 右移 | 将一个数的各二进制位右移N位,移到右端的低位被舍弃,对于无符号数,高位补0 |
“按位与”运算符(&)
参加运算的两个数据,按二进制位进行“与”运算。如果两个相应的二进制位都为 1 ,则该位的结果值为 1 。否则为 0 。
按位与(and) |
1&1=1 |
1&0=0 |
0&1=0 |
0&0=0 |
00101& 11100
以前我们判断一个数n是奇数还是偶数通常使用n%2的结果来判断,1为奇数0为偶数,现在我们也可以使用 &操作来判断奇偶,n&1的结果就是取n的二进制的最末位。二进制的最末位为0表示n为偶数,最末位为1表示n为奇数。
位运算应用
左移运算符(<<)
左移运算符是用来将一个数的各二进制位左移若干位,移动的位数由右操作数指定(右操作数必须是非负值),其右边空出的位用 00 填补,高位左移溢出则舍弃该高位。
例如:将a的二进制数左移2位,右边空出的位补0,左边溢出的位舍弃。若a=15 即(00001111)2,左移2位得(00111100) 。
左移1位相当于该数乘以2,左移2位相当于该数乘以2×2=4 。
15<<2=60,即乘了4。但此结论只适用于该数左移时被溢出舍弃的高位中不包含 1的情况。
右移运算符(>>)
右移一位相当于除以2,右移n位相当于除以2n。
在右移时,需要注意符号位问题。对无符号数,右移时左边高位移入0。对于有符号的值,如果原来符号位为0(该数为正),则左边也是移入0,如果上例表示的那样,如果符号位原来为1(该数为负),则左边移入的0 还是1,要取决于所用的计算机系统。移入0称为 逻辑右移,即简单右移。移入1称为算术右移。最后一位直接去掉。
简单枚举
顾名思义,枚举便是依次列举出所有可能产生的结果,根据题中的条件对所得的结果进行逐一的判断,过滤掉那些不符合要求的,保留那些符合要求的,也可以称之为暴力算法。
枚举结构:循环+判断语句。
应用场合
在竞赛中,并不是所有问题都可以使用枚举算法来解决(事实上,只有少数),只有当问题的所有解的个数不太多时,并在我们题目中可以接受的时间内得到问题的解,才可以使用枚举。
枚举法的优缺点
枚举法的优点:
由于枚举算法一般是现实生活中问题的“直译”,因此比较直观,易于理解;
由于枚举算法建立在考察大量状态、甚至是穷举所有状态的基础上,所以算法的正确性比较容易证明。
枚举法的缺点:
枚举算法的效率取决于枚举状态的数量以及单个状态枚举的代价,因此效率比较低。
直译”枚举:直接根据题意设定枚举对象、范围和约束条件。
算法复杂度是指算法在编写成可执行程序后,运行时所需要的资源,资源包括时间资源和内存资源。通常我们用时间复杂度和空间复杂度,来描述算法所需要的时间资源和内存资源。
时间复杂度
时间复杂度表示程序运行占用时间的多少,是评估算法效率的重要指标。一般表示为关于n的某个函数。其中n往往对应输入数据的规模,用T(n)表示。
若有某个辅助函数f(n),存在一个正常数c使得f(n)×c>=T(n) 恒成立。记作T(n)=O(f(n)),称O(f(n))为算法的渐进时间复杂度,简称时间复杂度。
时间复杂度常用大O符号表述,不包括这个函数的低阶项和首项系数。
例如:如果一个算法对于任何大小为n(必须比n0大)的输入,它至多需要5n3+3n的时间运行完毕,那么它的渐进时间复杂度是O(n3)。
我们知道常数项对函数的增长速度影响并不大,所以当 T(n)=c,c为一个常数的时候,我们说这个算法的时间复杂度为O(1)。如果 T(n) 不等于一个常数项时,直接将常数项省略。比如:cout << "hello world" << endl;
就是O(1)的。
一个函数中最高次项对函数的影响是最大,所以在计算时间复杂度时刻直接忽略其他较低次项。
比如T(n)=n3+n2+1,则对应的时间复杂度为O(n3)。
综合起来:如果一个算法的执行次数是T(n),那么只保留最高次项,同时忽略最高项的系数后得到函数f(n),此时算法的时间复杂度就是O(f(n))。在第二节中将介绍如何分析时间复杂度。
按数量级递增排列,常见的时间复杂度有:
常数阶O(1),对数阶O(log2n)(以2为底n的对数,下同),线性阶O(n) ,线性对数阶O(nlog2n,平方阶O(n2),立方阶O(n3),...,k次方阶O(nk),指数阶O(2n)。随着问题规模n的不断增大,上述时间复杂度不断增大,算法的执行效率越低。时间复杂度也是我们在做题时首先就要考虑的问题。
从运行效率来看:
别名 | 效率从高到低 | |
多项式复杂度 | 常数阶 | O(1) |
多项式复杂度 | 对数阶 | O(log(log(n))) |
多项式复杂度 | 对数阶 | O(log(n)) |
多项式复杂度 | 对数阶 | O((logn)2) |
多项式复杂度 | O(n−−√)O(n) | |
多项式复杂度 | O(n2/3logn) | |
多项式复杂度 | 线性阶 | O(n) |
多项式复杂度 | O(nlog(n)) | |
多项式复杂度 | 平方阶 | O(n2) |
多项式复杂度 | 立方阶 | O(n3) |
多项式复杂度 | O(nk) | |
指数复杂度 | 斐波那契 | O(Fib(n)) |
指数复杂度 | 指数阶 | O(2n) |
指数复杂度 | 指数阶 | O(3n) |
指数复杂度 | 指数阶 | O(n!) |
指数复杂度 | 指数阶 | O(nn) |
注意:log2n=log210×log10n 所以O(log2n)=O(log10n),所以在谈论对数复杂度的算法时,我们忽略是以2为底还是以10为底,统称为O(log(n)) 。
对于所有复杂度为:O(nk)的算法,不论k有多大,我们都称其为多项式时间的算法。所以O(nklogn)也是多项式时间的,因为O(nklogn)≤O(nk+1) 。
对于所有复杂度为:O(kn) 的算法(k>1),我们都可以称其为非多项式时间的算法,经常会简化为指数级算法。
空间复杂度
空间复杂度(Space Complexity)是对一个算法在运行过程中临时占用存储空间大小的量度。
比如 int a[100]; 所占用的内存空间大小 就是 100 ∗ 4 ∗ 8 bit 。一个长度为n的数组对应的空间复杂度为 O(n) 。
需要注意的是,一段程序运行的时间复杂度,不可能低于他的空间复杂度。
一个算法的优劣主要从算法的执行时间和所需要占用的存储空间两个方面衡量。当追求一个较好的时间复杂度时,可能会使空间复杂度的性能变差,即可能导致占用较多的存储空间。反之,当追求一个较好的空间复杂度时,可能会使时间复杂度的性能变差,即可能导致占用较长的运行时间。
常见算法复杂度
复杂度 | |
遍历 | O(n) |
冒牌排序 | O(n2) |
快速排序 | O(nlog(n)) |
全排列 | O(n!) |
复杂度分析基础
下面通过一些代码具体分析一下时间复杂度。
void aFunc(int n) {
for(int i = 0; i < n; i++) { // 循环次数为 n
cout << "Hello, World!" << endl; // 循环体时间复杂度为 O(1)
}
这段程序的循环运行次数为n次,每次输出看作O(1),因此时间复杂度为O(n)。
void aFunc(int n) {
for(int i = 0; i < n; i++) { // 循环次数为 n
for(int j = 0; j < n; j++) { // 循环次数为 n
cout << "Hello, World!" << endl; // 循环体时间复杂度为 O(1)
}
}
}
这段程序的外层循环运行次数为n次,内层循环运行次数也是n次,每次输出看作O(1),总共输出了n2行,因此时间复杂度为O(n2) 。
void aFunc(int n) { // 第一部分时间复杂度为 O(n2)
for(int i = 0; i < n; i++) {
for(int j = 0; j < n; j+=2) {
cout << "Hello, World!" << endl;
}
} // 第二部分时间复杂度为 O(n)
for(int j = 0; j < n; j++) {
cout << "Hello, World!" << endl;
}
}
第一段程序会执行n2/2次,第二段程序会执行n次,共执行n2/2+n 次,因此时间复杂度为O(n2)。
void aFunc(int n) {
for (int i = 1; i * i <= n; i++)
cout << "Hello, World!" << endl;
}
当 i>n−−√i>n 时,会退出循环。复杂度 O(n−−√)O(n) 。
void aFunc(int n) {
for (int i = 1; i <= n; i += i)
cout << "Hello, World!" << endl;
}
i的取值每次会翻倍,因此程序只会执行log(n),复杂度为O(log(n))。对数复杂度的程序执行效率很高,哪怕n=1018,也只会执60多次。
时间复杂度分析的基本策略:从内向外分析,从最深层开始分析。如果遇到函数调用,要深入函数进行分析。
在学会了分析算法的复杂度后, 我们要学会根据对应的数据范围设计出题目时间要求的时间复杂度。一般我们做题系统的评测机1s可运行百万级别, 即每秒进行大约107次基础运算。设输入的数据量为n,当n<1000000时,运行一段时间复杂度为O(n)的程序,大约需要不到1s。而如果时间复杂度为O(n2)。则可能需要运行几天。
时间复杂度分析
复杂度分析进阶
void aFunc(int n) {
for (int i = 2; i <= n; i *= i)
cout << "Hello, World!" << endl;
}
这个i=i×i函数的增长是很快的,复杂度为O(log(log(n)))。在 n=21024时,也只会执行10 次左右。
void aFunc(int n) {
for (int i = 1; i <= n; i++)
{
for (int j = 1; j <= i; j += j)
cout << "Hello, World!" << endl;
}
}
外层循环n次,内层循环log(i)次,因此总共循环的次数是:
log(1)+log(2)+log(3)+....log(n)=∑i=1nlog(i)
那么这个复杂度究竟是多少呢?我们来进行一下推导:
nlog(n)≥∑i=1nlog(i)≥∑i=n/2
将所有log(i)换为log(n),所以有nlog(n)≥∑ni=1log(i)。
去掉∑ni=2log(i) 中小于 n/2的项,所以有∑ni=1log(i)≥∑ni=n/2log(i)把所有∑ni=n/2log(i)(i)中的i都换成 n/2,所以有∑i=n/2nlog(i)≥(n/2)log(n/2)
有了这几个式子的比较,我们看,第1个式子对应的复杂度是O(nlog(n)),最后一个式子对应的复杂度也是O(nlog(n)),因此 ∑ni=1log(i)∑i=1nlog(i) 对应的复杂度是O(nlog(n))。
void aFunc(int n) {
for (int i = 1; i <= n; i+=i) {
for (int j = 1; j <= i; j++)
cout << "Hello, World!" << endl;
}
}
外层循环共log(n)次,内层每次循环到i。这个看起来似乎和上面的问题很像。但还需要仔细分析。我们先看n为2的幂的情况。总共循环的次数为:
因此这个程序的复杂度为O(n)。下面的程序也是O(n)的。
空间换时间
在介绍复杂度的概念时曾讲过,当追求一个较好的时间复杂度时,可能会使空间复杂度的性能变差,即可能导致占用较多的存储空间。很多时候优化时间复杂度的方法都是基于空间换时间的方法。
常用的空间换时间方法
在计算中,有些数值会反复被用到,如果每次都重新计算,则会影响算法效率。我们可以通过一些手段,提前处理好这些计算结果,在计算过程中直接调用已经算出的部分。
方法 | |
数组前缀和 | O(1)求区间和 |
矩阵前缀和 | 容斥O(1) 求子矩阵和 |
质数表 | 加快分解质因数等等 |
打表 | 预先处理好需要重复计算的值 |
前缀和优化
给一个长度为n的数组,有m次询问,每一次询问区间[l,r]的和。最简单的做法是每一次询问都用for循环遍历数组[l,r]求一次和。这样复杂度是O(m×n) 。
我们可以预先处理一个数组 presum, presum[i]就是[0,i] 的区间和, 那么每当询问一个区间[l,r]时, 答案就是presum[r]−presum[l−1]。可以在O(1)时间得到答案。
总的复杂度就是O(m+n) 。例如:
原数组为:{1,5,9,4,3,3} 。
前缀和数组为:{0,1,6,15,19,22,25} 。
除了前缀和,常用的其实还有后缀和、前缀积、后缀积。核心思想都是经典的预处理思想。通过预处理将O(n×m)的复杂度降到O(n+m),只是额外的使用了O(n)的空间,这就是空间换时间的思想。
前缀和的逆运算是差分,与差分相关的内容暂时不展开。
ST表
RMQ(Range Maximum Query)问题是指:对于长度为n的数列A,回答若干询问Q(A,i,j)(i,j<=n),返回数列A中下标在(i−>j) 里的最大值。RMQRMQ 问题是指求区间最值的问题。
RMQ问题有多种解决方式,不同方法的复杂度不尽相同。这里只讲解最简单的 STST 表的方法。
XOR的特性
XOR运算具有一些独特的性质满足:
- 交换律,a xor b=b xor a。
- 结合律,(a xor b)xor c=a xor (b xor c)
- 实际应用中,我们还可以将 XOR其看作不进位的二进制加法。
尺取法
概念 尺取法也常被称为双指针,是很常用的一种优化算法。通常是对数组保存一对下标,即所选取的区间的左右端点,然后根据实际情况不断地推进区间左右端点以得出答案。尺取法比直接暴力枚举区间效率高很多。
这么说可能有些抽象,我们来举一个具体的例子。
在一个有序数组a 里,所有数都大于0,我希望为每个ai找到最小的>=ai×2 的数。
这里需要注意一个点,就是数组a是有序的,这是能够适用尺取法的前提。
对于a0,我们可以从a1开始暴力找到第一个符合条件的ai,对于a1我们就没有必要从a2开始找了,因为a1>=a0,所以-
+2a1>=2a0>=ai,所以我们可以从ai 开始找即可,以此类推直到处理完an−1。
这个算法的复杂度怎么分析呢?左端点从a0变换为a1,右端点也许直接从ai变为an。左端点走一步,右端点走多少步并不确定,最坏情况下要走O(n)步,那么会不会这是一个最坏情况下O(n2)的算法呢?
这时我们需要从总量上进行分析,我们可以想象,区间的左端点从0−(n−1),右端点也是从0−(n−1),并且不会向回走,因此总的复杂度为O(n) 。
提到有序,我们会联想到之前学习过的二分查找,对于上面这个问题,应用二分查找也是可以的。但我们来计算一下复杂度,二分查找单次的复杂度为O(log(n)),共需要执行n次,因此总的复杂度为O(nlog(n)),而运用尺取法,复杂度优化为O(n)。
适用条件
尺取法通常适用于具备单调性的情况,比如上面例子中,a为有序数组,所以右端点可以一直向右移动。如果没有这个条件,则无法适用尺取法。这时我们可以先对a排序,然后再使用尺取法。
谈到单调性,我们容易联想到二分。二分在查找具体一个元素时,可以达到O(log(n))的复杂度,但如果处理 n个数,则总的复杂度会变为O(nlog(n)),这时如果可以使用尺取法,可以将总的复杂度降为O(n) 。
贪心算法
贪心算法是指,在对问题求解时,总是做出在当前看来是最好的选择。也就是说,不从整体最优上加以考虑,他所做出的是在某种意义上的局部最优解。
贪心算法不是对所有问题都能得到整体最优解,关键是贪心策略的选择,选择的贪心策略必须具备无后效性,即某个状态以前的过程不会影响以后的状态,只与当前状态有关。举例来说:用2,4,6,8组成最大的4位数。我们很自然的会想到8642,因为在选择千位的数字时,我们只需要考虑让这一位最大,而后面的不论如何选,都不会影响这一位的选择,因此我们会选择8,后面的处理也是一样。
贪心算法进阶
贪心的适用条件
贪心算法在对问题求解时,总是做出在当前看来是最好的选择。也就是说,不从整体最优上加以考虑,他所做出的是在某种意义上的局部最优解。因此利用贪心算法来求最优解,一定要满足当前最优即整体最优这个条件。