Note-2

本文探讨了算法复杂度在解决竞赛问题中的重要性,特别是时间复杂度,强调了二分法在有序数组搜索中的高效性,并介绍了二叉搜索树和平衡二叉树的概念,以及它们在动态数据排序和查询中的优势。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

复杂度

在这里插入图片描述
  在竞赛中,每道题目通常会给出这样的限制,在测试程序时,平台经常会给出“运行超时”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} arrleftxarrright是否满足。
    • 为什么要加等号?不加等号行吗?
  • 如果 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	# 同上,为啥
		elsereturn 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的树
访问root
遍历左子树L
遍历右子树R
访问L
遍历L的左子树
遍历L的右子树
......

  注意到当前递归结束后,程序会回到上一层递归(这里能够用栈去理解);所以,以下三个操作的顺序可以互换。根据“访问root”操作的次序,可以将遍历分为前序遍历中序遍历后序遍历

访问root
遍历左子树L
遍历右子树R

  一般来说,我们会将操作“遍历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

题目建议

基础题

二分茶轴

复习题

链表

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值