复杂度
在竞赛中,每道题目通常会给出这样的限制,在测试程序时,平台经常会给出“运行超时”OT/TLE,或者“内存超限”MLT。除了程序本身出错,题目无法得分往往是由于我们写的程序没有满足这些限制。
算法是处理数据,得到使自己满意的结果的一组方法。一般来说,执行哈里发所需要的时间和运行中用来储存各种变量的空间,会随着数据规模(记为 n n n)增大而增大,在竞赛中,我们一般更关注时间复杂度。(不过也不是说可以随便浪费空间)
时间复杂度
考虑我们在一个长度为
n
n
n的(互不相同的)数组中搜索一个大小为
x
x
x
的值。(假设我们一定找得到)
- 最简单地,我们会从头到尾一个一个找,直到找到这个 x x x为止
arr=[1,2,4,5,7,8,9,12,67]
x=8
for n in range arr:
if n==x:
# TODO
- 平均而言,这个 x x x在数组的正中间,我们在找到 x x x之前,一共要循环 n / 2 n/2 n/2次,我们记为这个算法的平均时间复杂度为: Ω ( n 2 ) = Ω ( n ) \Omega (\frac n2)=\Omega (n) Ω(2n)=Ω(n)
- 最好的情况下,这个 x x x就在第一位,一共要循环 1 1 1次,也就是算法的最佳时间复杂度为: o ( 1 ) o(1) o(1)
- 最坏的情况下,这个 x x x在数组的最后一位,也就是算法的最坏时间复杂度为: O ( n ) O(n) O(n)
注意:
- 时间复杂度我们往往只关注 n n n非常大时的最坏情况。
- n n n非常大:此时算法有多快,只和 n n n的函数形式有关,与常数无关。例如当 n → ∞ n\to\infty n→∞时, O ( n ) O(n) O(n)与 O ( λ n ) O(\lambda n) O(λn)( λ \lambda λ为常数)算法之间的速度差异远远小于 O ( n ) O(n) O(n)与 O ( n 2 ) O(n^2) O(n2)之间的差异——至少在测试点真的想考察程序的运算速度时。
- 下面是常见复杂度对于不同规模数据的运行速度差异。(一般来说,除了图论问题,复杂度高于
O
(
n
3
)
O(n^3)
O(n3)的算法都不予考虑)
- 复杂度计算:
- 线性遍历数据的循环,复杂度一律是 O ( n ) O(n) O(n)
- 二分遍历数据的循环,复杂度是 O ( log n ) O(\log n) O(logn)(不管底数是多少,反正就差个常数)
- 循环嵌套,复杂度相乘,就是
O
(
n
)
⋅
O
(
n
2
)
=
O
(
n
3
)
O(n)\cdot O(n^2)=O(n^3)
O(n)⋅O(n2)=O(n3)
- 特别地,希尔排序复杂度约为 O ( n 1.7 ) O(n^{1.7}) O(n1.7)
- 循环结束后再次循环,复杂度取最大的,就是 O ( n ) + O ( n 2 ) = O ( n 2 ) O(n)+O(n^2)=O(n^2) O(n)+O(n2)=O(n2)
- 和数据规模无关的语句复杂度都是杂鱼 O ( 1 ) O(1) O(1)
二分法
考虑在下面这组(长度为
n
n
n的)数组中寻找特定数字
x
=
21
x=21
x=21:
2
,
3
,
5
,
7
,
11
,
13
,
17
,
19
,
21
,
13
,
29
,
31
,
37
,
41
2,3,5,7,11,13,17,19,21,13,29,31,37,41
2,3,5,7,11,13,17,19,21,13,29,31,37,41
是否存在比线性复杂度更快的算法?
- 考虑数组是有序的
- 我们判断某一区间
[
a
r
r
l
e
f
t
,
a
r
r
r
i
g
h
t
]
[arr_{left},arr_{right}]
[arrleft,arrright]是否存在
x
x
x,不需要搜索该区间内的每一个数字,只要判断条件
a
r
r
l
e
f
t
≤
x
≤
a
r
r
r
i
g
h
t
arr_{left}\leq x\leq arr_{right}
arrleft≤x≤arrright是否满足。
- 为什么要加等号?不加等号行吗?
- 如果
x
<
a
r
r
l
e
f
t
x<arr_{left}
x<arrleft,那么只要整个区间左移就好了。(为什么?)反之,如果
x
>
a
r
r
r
i
g
h
t
x>arr_{right}
x>arrright,只要整个区间右移就好了
- 要移动多大的范围?或者说,新的区间要怎么确定?
- 怎么不断缩小区间的长度,直到找到我们要的 x x x?
二分法的代码如下:
def dichotomy(arr,x):
l,r = 0,len(arr)-1
while(l<=r): # 为什么是去等
m = (l+r)//2 # 注意是整除
if x<arr[m] :
r=m-1 # 为什么不直接等于m
elif x>arr[m]:
l=m+1 # 同上,为啥
else:
return m
return -1;
if __name__ == '__main__': # 这一行判断是什么意思?
arr=[2,3,5,7,11,13,17,19,21,13,29,31,37,41]
x=21
print(dichotomy(arr,x))
二分法是最容易写错的算法之一(可能是其他算法手写太麻烦了)要多记多理解
关于二分法,这里会讲一下列车调度那道题
树
树比链表更复杂,但有清晰的层次结构。
就是说,节点之间不能跨层连接,而且每个节点有且只能有一个父节点(考虑到政治因素,也可以叫双亲节点,但会导致混淆),但可以有多个子节点
一般我们用的多的是二叉树,下面是最简单的节点结构
struct node{
T val;
address left;
address right;
// 有时也会记录其父节点,这种树有时被叫做“双向树”
}
对于孩子节点多于2的数,有两种储存方法
- 连接多个子节点,子节点间不连接
struct node{
T val;
address child[];
}
这个在单纯记录多叉树的结构,而不需要支持其他算法时比较方便
- 只连接一个子节点,子节点间相互连接
struct node{
T val;
address left; // 记录的是同一深度位于自己左边的,距有共同父亲的节点
address right; //记录的是右边的
address child; //记录孩子节点中的任意一个
}
像是斐波纳契堆就借鉴了这种方法
遍历算法
我们主要研究二叉树。
首先一个要考虑的问题是,既然我们用树来储存数据,那么就需要一种算法来保证我们可以访问所有(也就是遍历)节点。
树的结构非常计算机,它是递归的:一个节点的子孙节点也满足树的结构要求。也就是说,虽然我们能够将树看成节点和子孙节点之间的连接,也可以看成根节点和子树之间的连接,而子树也可以看成根节点和子树的连接。
所以,我们的遍历程序可以用递归的方式完成:
注意到当前递归结束后,程序会回到上一层递归(这里能够用栈去理解);所以,以下三个操作的顺序可以互换。根据“访问root
”操作的次序,可以将遍历分为前序遍历,中序遍历,后序遍历。
一般来说,我们会将操作“遍历L”放在“遍历R”之前(这样能够避免歧义)所以三种遍历代码实现如下:
def preorder(root): # 先序遍历:root->L->R
print(root.val) # 访问root
if(root.left!= Tree.NULL): # Tree.NULL定义了树结构的空节点
preorder(root.left)
if(root.right != Tree.NULL):
preorder(root.right)
def inorder(root): # 中序遍历:L->root->R
if(root.left!= Tree.NULL):
inorder(root.left)
print(root.val) # 访问root
if(root.right != Tree.NULL):
inorder(root.right)
def preorder(root): # 后序遍历:L->R->root
if(root.left!= Tree.NULL):
preorder(root.left)
if(root.right != Tree.NULL):
preorder(root.right)
print(root.val) # 访问root
例如,树
的遍历结果:
- 先序:
1 2 4 5 3 6
- 中序:
4 2 5 1 3 6
- 后序:
4 5 2 6 3 1
此外,由于树严格的层次结构,我们也可以按照树的层次访问节点,这种遍历方法叫做层次遍历,例如,上面这棵树的层次遍历结果为:
1 2 3 4 5 6
层次遍历一般通过队列实现:
def hierachical(root): # 层次遍历
queue=[] # 队列
queue.append(root)
while(len(queue) != 0): # 当队列为空时,结束循环
tmp=queue.pop(0) # 让队头节点出队(注意c++中pop方法是void的)
queue.push(tmp.left) # 左子节点入队
queue.push(tmp.right) # 右子节点入队
二叉搜索树BST
二分查找要求数据本身是有序的,如果原始数据是无序的,那么在读入全部数据后,还需要对数据进行排序,才能进行二分查找。如果题目要求一边读入一边进行某些操作,那么在每次读入——操作之前,都要对已读入的数据进行排序,这样时间代价是相当可观的。
二叉搜索树用树来储存已读入的数据,就时间复杂度来说,插入一个新数据需要
O
(
log
n
)
O(\log n)
O(logn)的时间,查找某个数据需要
O
(
log
n
)
O(\log n)
O(logn)的时间。
插入 | 查询 | |
---|---|---|
排序——二分 | O ( n log n ) O(n\log n) O(nlogn) | O ( log n ) O(\log n) O(logn) |
二叉树 | O ( log n ) O(\log n) O(logn) | O ( log n ) O(\log n) O(logn) |
BST的定义也是递归的:一棵BST的左子树所有节点val
值,都小于根节点val
值;而右子树所有节点val
值,都大于根节点val
值。
我们可以发现,上面的定义和这个是等价的:BST的根节点val
大于左子树根节点val_left
;小于右子树根节点val_right
。(证明留作习题)
由此,我们可以用后者来构造BST的插入算法(build
只是构造一个树节点,不存在比较,就很简单)
void insert(BST &root, TNode x){ // 向根节点为root的BST中插入val为x的节点x———递归地
if(x->val<=root->val) // 非递归的话,也可以用循环实现
if(root->left!=NULL) // (因为忽略掉条件语句,这里是尾递归)
insert(root->left,x);
else
root->left=x;
else if(root->right!=NULL)
insert(root->right,x);
else
root->right=x;
}
而查找和插入的思路是相同的,唯一需要考虑的是找不到需查找元素的情况(而插入——显然,是一定能插进去的),代码在这里省略。
例如,数据1 1 4 5 1 4
按照从左到右的顺序输入,按照前面的insert
,构造出来的BST如图:
平衡问题
虽然对数据的(动态)排序能够有效地降低查询时的开销,但是,如果数据本身就是有序的,例如5 4 4 1 1 1
这个数据序列构造出来的BST如下:
BST在这种情况下退化成了线性结构,插入和查找的复杂度都是
O
(
n
)
O(n)
O(n),和单链表是一样的。
不过,还是从树的角度来看,我们可以发现,这棵树非常不“平衡”——BST中的每棵子树都只有左子树。
如果我们通过某种方法重构这棵树,使得它尽可能地平衡,例如:
这和之前的insert
原则略有不同,不过从观感上看,这棵BST“平衡”了许多——根据平衡的算法不同,其插入、删除、查询复杂度各不相同,但一般都不超过
O
(
n
)
O(n)
O(n)。我们将这种考虑了平衡(也就是树的深度)的BST称为平衡二叉树BBST。
一般地,实现BBST的方法主要有:AVL,红黑树,替罪羊;我们也可以将堆(Heap),看作一种平衡的二叉树(虽然逻辑上堆一般是完全二叉树),例如:二叉堆,二项堆,斐波那契堆
公共祖先LCA
题目建议
基础题
- 分析插入排序的复杂度
- 天梯赛座位分配(基础题,循环,模拟输出)
二分茶轴
树
- 二叉树的后序遍历(树,递归)
复习题
链表
- 链表中倒数第k个节点(链表,双指针)
- 合并两个有序链表(链表,递归/循环)