覆盖的知识点
语法
- 递归
数据结构
- 链表
- 栈&&队列
- 二叉树
- BST
- AVL
- 线段树
- 红黑
- 堆
- 二叉堆
- 图
算法
- 排序
- BFS/DFS
- Dijkstra/Floyd
- LCA
- 并查集
基础语法
基础题
递归
-
将递归看作深度不定,循环体可拆开的循环
递归类似于循环,但循环一般(不全是)要求给定一个深度,也就是循环次数。而且,循环结构遵循:“判断”->“循环体”->“判断”->…的流程,除非中途跳出,循环体必须从头开始全部执行。
相对而言,递归基本上不需要给定深度(当然还是需要跳出递归的判断条件的),而且也可以在执行了一部分代码后,先进入下一层递归,等到递归全部处理完后才执行剩余的代码。
其实,尾递归(也就是递归函数在自己的末尾)等价于循环,是可以简单地用循环重写的。 -
从伪代码角度去理解
另一方面,如果无法理解递归函数的执行逻辑,就尝试脱离具体的语句,将函数抽象为伪代码的形式。一般而言,递归函数的格式为:
whatever_type recursion( paraments ){
//terminal condition ( return before enter the next recursion )
//what need to do before
recursion( new_paraments );
// what need to do after recursions
return sth.;// if you need
}
这样抽象考虑的重点在于,不要“进入”递归函数,只要知道递归函数实现了它要做的事情就足够了,具体怎么实现的,在此时不需要考虑。这样,能够帮助我们厘清递归前后要做什么,以及参数要如何更新。
以汉诺塔为例:(这里借用wiki的代码)
# 北京八维研修学院人工智能学院1906A班学员
# 2021年1月20日 星期三 下午17:28
# 听许朋飞老师讲解python基础之递归函数后做出
# 不足之处请各位不吝赐教
# 本代码侧重展示解题细节,没有刻意追求效率
xpillars = {'A', 'B', 'C'} # 汉诺塔的三个柱子
def xhanoi(xtop, xbottom, xsrc='A', xdest='C'):
'''
解汉诺塔
:param xtop: 移动的“整体”的最顶层,只能是1或xbottom
:param xbottom: 移动的“整体”的最底层,正整数
:param xsrc: 从哪个柱子移动(起始柱子)
:param xdest: 移动到哪个柱子(目标柱子)
:return: None
'''
if not (xtop == 1 and xbottom >= xtop or 1 < xtop == xbottom) \
or not (xsrc in xpillars) \
or not (xdest in xpillars):
print('非法参数')
return
if xtop == xbottom: # 仅移动一个盘子
print(xtop, xsrc, '-->', xdest)
else: # 移动两个以上盘子
xaux = (xpillars - {xsrc, xdest}).pop() # 三个柱子里用作“中转”的柱子
# 递归算法
xhanoi(1, xbottom - 1, xsrc, xaux) # 先把1到倒数第2个盘子移动到中转柱子
xhanoi(xbottom, xbottom, xsrc, xdest) # 移动最底下的盘子
xhanoi(1, xbottom - 1, xaux, xdest) # 再把1到倒数第2个盘子移动到目标柱子
if '__main__' == __name__:
xhanoi(1, 3) # 移动从1层到3层,从A到C。也就是求解3层汉诺塔。
该代码结构大致如下:
xpillars = {'A', 'B', 'C'} # 汉诺塔的三个柱子
def xhanoi(xtop, xbottom, xsrc='A', xdest='C'):
# terminal condition
if xtop == xbottom:
print(xtop, xsrc, '-->', xdest)
else:
# what need todo before recursion
# here's to find the intermediate pillar, which is the new parament
xaux = (xpillars - {xsrc, xdest}).pop()
# recursion body
xhanoi(1, xbottom - 1, xsrc, xaux) # 先把1到倒数第2个盘子移动到中转柱子
xhanoi(xbottom, xbottom, xsrc, xdest) # 移动最底下的盘子
xhanoi(1, xbottom - 1, xaux, xdest) # 再把1到倒数第2个盘子移动到目标柱子
# nothing need todo after it
抽象了具体的语法后,其结构大致如下:
def xhanoi(xtop, xbottom, xsrc='A', xdest='C'):
# 盘子是不是能直接移动了 # 判断是不是不需要继续递归了
# 找中转柱子 # 更新参数 # 进入下一层递归前要做的事
# 将xtop-xbottom代表的 # 递归主体
# “大的”汉诺塔看作一个整体,
# 整个移动到中转柱子上(不需要考虑具体怎么移动)
# 结束了 # 递归结束后要做的事
-
题目(这两道题已经品鉴得够多了,题干可以直接在网上找到)
斐波那契:已知斐波那契数列的第0项,第1项分别为1,1。用递归求第n项。
八皇后:
国际象棋的皇后可以向着8个方向移动。如何能够在8×8的国际象棋棋盘上放置八个皇后,使得任何一个皇后都无法直接吃掉其他的皇后?为了达到此目的,任两个皇后都不能处于同一条横行、纵行或斜线上。
.
更一般地,如何求解n皇后问题。
链表
类似于数组,其不同之处是将索引(也就是下标)用更一般化的地址代替了。一般而言,链表节点分为两部分,一部分储存节点的数据(value
),另一部分储存下一个节点的地址(address
)。
从逻辑上讲,链表和数组都是一个按顺序储存数据的序列。但是从内存,或者说物理,上讲,数组的数据都是连续储存的(因此数组又可以叫“线性表”),它的索引实际上代表着内存上“偏移”的距离。而链表,因为储存的是具体的地址,因此每个节点不一定要连续储存。
不过,在竞赛中,我们需要吸收主要是用(各种各样的)地址来代替索引的这种思想。也就是说,用一层层的指示关系来实现在
O
(
1
)
O(1)
O(1)内找到数据的目的。
经典的链表节点结构如下:
struct node{
T val; //T means the type of val
address next;
}
但竞赛中为了方便,我们往往用这种方式表示链表:
int nxt[],root;
T val[];
例如一个结构为a->b->c->d的链表,可以直接用数组储存地址。其中val:
0 1 2 3
d b a c
如此,nxt:
0 1 2 3
-1 3 1 0
root=2;
这样,第一个节点的地址是root,我们想访问这个节点,只需要访问val[root]
val[root]->val[2]->a;
而a的下一个节点地址是nxt[root]
val[nxt[root]]->val[nxt[2]]->val[1]->b
实际用的时候赋中间值就可以了。
这样看着很多此一举,但在建立二叉树和图的时候,这样可以节省不少代码量(以及运算时间)。
cpp的stl中,
vector
头文件相当于链表这一容器
进一步地,索引不一定要是数字。(不过这一点没什么意义)
推广
栈
先进后出。类似于只有一端能进出的通道。
递归都可以用栈重写。
cpp的stl中,
stack
头文件包含了栈这一容器
struct stack{
int val[],_size;
int top(){return val[_size-1];}
void push(int x){val[_size++]=x;}
void pop(){_size--;}
int size(){return _size;}
}
class stack:
val=[]
size=0
int top(self):
return self.val[self.size-1]
void push(self,int x):
self.val[self.size]=x
self.size+=1
void pop(self):
self.size-=1
队列
先进先出。也就是一端进一端出的线性表。
cpp的stl中,
queue
头文件包含了队列这一容器
struct queue{
int val[],head,rear;
int top(){return val[head];}
void push(int x){val[rear++]=x;}
void pop(){head++;}
}
class queue:
val=[]
head=rear=0
int top(self):
return self.val[head]
void push(self,int x):
self.val[self.rear]=x
self.rear+=1
void pop(self):
self.head+=1
优先队列(堆)
映射(也就是python里的字典)
考虑前文提及的不是数字的索引,我们可以直接抛开索引上“顺序”的概念(或者说,放松索引集合上全序关系的限制),直接建立“键-值”之间的对应关系。stl
中的map
用红黑树储存,对于规模为n
的数据-索引,可以在
O
(
log
n
)
O(\log n)
O(logn)内找到索引对应的键值。
cpp的stl中,
map
头文件包含了映射这一容器
- 烂键盘
[hint:用map储存某个字符是否只出现了一次。当然,字符数有限,开一个256长度的数组储存更方便]
题目思路
列车调度
题目可以按照这样的方式考虑:车先按照输入顺序驶入某条轨道,然后再按降序依次驶出。
样例8 4 2 5 3 9 1 6 7
的整个调度过程大概是这样:
我们用模拟的想法,可以写出来:
int main(){
vector<int> arr(100001,100001);
int cnt=1,n;
cin>>n;
while(n--){
int t,i;
cin>>t;
for(i=0;i<cnt;i++)
if(t<arr[i]){
arr[i]=t;
break;
}
if(i==cnt)arr[cnt++]=t;
}
cout<<cnt;
}
但这个大数据会超时,特别是顺序到来的列车,上边的代码非常慢。由于发现轨道末端的列车序号是递增的,我们考虑特判需要新开轨道的情况:
int main(){
vector<int> arr(100001,100001);
int cnt=1,n;
cin>>n;
while(n--){
int t;
cin>>t;
if(t<arr[cnt-1])
for(int i=0;i<cnt;i++){
if(t<arr[i]){
arr[i]=t;
break;
}
}
else arr[cnt++]=t;
}
cout<<cnt;
}
但是这个对随机大数据的运算速度并没有显著提升,甚至测试点3只能碰运气过。
究其原因,我们判断列车应驶入哪条轨道的语句——也就是第二重循环——是线性的,时间复杂度是
O
(
n
)
O(n)
O(n),二重循环的总时间复杂度就是
O
(
n
2
)
O(n^2)
O(n2)。
考虑到数据是有序的,我们考虑二分查找,这样时间复杂度就是
O
(
log
n
)
O(\log n)
O(logn),总复杂度就是
O
(
n
log
n
)
O(n\log n)
O(nlogn)(当然用set
或者heap
是可以的,不过set
的upbound
不一定能想起来)
二分代码如下:(注意跳出二分查找的条件)
#include<iostream>
#include<vector>
using namespace std;
int main(){
vector<int> arr(100001,100001);
int cnt=1,n;
cin>>n;
while(n--){
int t;
cin>>t;
int l=0,r=cnt-1,m; // Dichotomy to find the minimum number greater than t.
while(l<=r){
m=(l+r)/2;
if(t<arr[m]&&(m==0||t>arr[m-1<0?0:m-1])){
arr[m]=t;
break;
}
else if(t<arr[m])r=m-1;
else l=m+1;
}
if(m==cnt-1)++cnt;
}
cout<<cnt-1;
}