算法入门

算法入门

初识算法

算法(algotithm)顾名思义,就是计算的方法。
计算机和编程语言只是解决问题的工具,而算法则是解决问题的方法。
如果要使用计算机解决某个具体问题,我们需要对计算的步骤做出相应的描述,
进而用特定的编程语言来实现。

对算法的研究主要是解决两个问题:“怎么算” 和 “怎样算更好”。

怎样计算

首先我们希望的是,对于任意一个突然摆在面前的问题,能给出一个可行的算法把结果算出来。
这就是 “怎么算” 的问题。

在确定了可能的解的范围的情况下,一个自然的想法就是搜索。搜索可以抽象为走迷宫:
把所有可能的解(岔路口)都查一遍,从而把真正的解(出口)给找出来。

深度优先搜索

深度优先搜索(Depth First Search,DFS) 类似于一个对迷宫一无所知的人在走迷宫时会采用的方法:
径直走下去,如果遇到岔路口就选择一条岔路向前,
如果走到死胡同就原路返回到之前走过的岔路口走其他岔路,
直到走到出口或排除掉所有岔路。

我们一般将深度优先搜索写成函数递归的形式。这样,
函数结束后一层层回溯的过程就是返回原岔路口的过程。大致的 C++ 代码参考如下:

void dfs(/* 当前状态 */){
	if(/*当前为终止状态(死胡同或出口)*/){
		/*更新解*/
		return;
	}
	while(/*扫描所有岔路*/) if(/*岔路可以走*/){
		//更新状态
		dfs(/*下一状态*/);//走到下一条岔路
		//撤回到原状态
	}
}

深度优先搜索的典型例题是 洛谷 P1706 全排列问题

广度优先搜索

广度优先搜索(Breath First Search,BFS) 类似于 “水走迷宫”:
在入口处浇水,水流遇到岔路会分成多股同时走,最终总有一股水流能到终点。

我们用一个队列来模拟“多股水流同时移动”的过程。队列中存储的是所有待移动的水流
(即上图的箭头),且时间戳靠前的水流在队列中也靠前。大致的 C++ 代码参考如下:

void bfs(){
	queue<type> q;
	q.push(/*初始状态*/);
	while(!q.empty()){
		type now=q.front();//取出时间戳最靠前的水流(now)
		q.pop();
		while(/*扫描所有岔路*/) if(/*岔路可以走*/){
			type nxt;
			/*nxt = now 的下一个状态*/
			q.push(now);//下一个状态时间戳最靠后,放在队列的末尾
		}
	}
}

广度优先搜索的典型例题是 洛谷 P1141 01迷宫

怎样算更好

虽然具有强大的计算能力,计算机的计算能力终究是有限的,
具体表现在计算机有着有限的计算速度和内存空间。

在这两个因素的衡量下,同一问题的不同算法显然有优劣之分。例如,欲计算

$$1+2+3+\cdots+n$$

代入求根公式 $\dfrac{n(n+1)}{2}$ 计算显然比用循环直接计算要更优。

“怎样算更好” 就是研究如何用尽可能少的时间和空间解决给定的问题。

下面我们用排序,即 “将 $n$ 个数从小到大(或从大到小)排成一列” 为例,
来具体讨论这个问题。

插入排序

如何将 $n$ 个数从小到大排成一列?一个自然的想法是,在前 $k\ (1\le k<n)$ 个数已经有序的情况下,
将第 $k+1$ 个数插入到这 $k$ 个数中。这就是 插入排序(Insertion Sort)

插入排序的 C++ 代码如下:

void insertionSort(int a[],int n){//待排序的数为 a[1]~a[n],从小到大排序
	for(int k=1;k<n;++k)
		for(int i=k;i>0&&a[i]>a[i+1];--i){
			int get=a[i];
			a[i]=a[i+1],a[i+1]=get;
		}
}

如何衡量插入排序的用时呢?我们注意到,插入排序总共要进行 $n-1$ 次插入,
第 $k$ 次插入最多发生 $k$ 次交换,因此总的交换次数最多为

$$1+2+\cdots+(n-1)=\dfrac{n(n-1)}{2}$$

算法的运行时间是一个复杂的变量,与具体的硬件和编程语言均有关。
但是,我们并不关心实际的运行时间,而只关心运行时间随数据规模 $n$ 的增长率。
因此,我们称插入排序的 时间复杂度

$$\Omicron(n^2)$$

其意义是,在 $n$ 足够大时,插入排序的运行时间以二次函数的速度增长。

类似的,可以定义一个算法的 空间复杂度。显然插入排序的空间复杂度为

$$\Omicron(n)$$

归并排序

如何做到比 $\Omicron(n^2)$ 更快呢?我们在插入排序的基础上进一步思考这个问题。

具体地,我们不是考虑将单个数逐个插入已经排好序的数组中,而是考虑将两个已经排好序的数组合并。
显然这是容易实现的:将两个排好序的数组分别装入两个队列中,
每次取出两个队列的队头中较小(或较大)的那一个,其 C++ 代码如下:

int* merge(int *a,int n,int *b,int m){
	//将 a[0]~a[n-1],b[0]~b[m-1] 合并,均为从小到大排序
	int *out=(int*)malloc((n+m)*4);//答案数组
	int k=0,p=0,q=0;//队列的队头

	while(p<n&&q<m)
		if(a[p]<b[q]) out[k++]=a[p++];
		else out[k++]=b[q++];
	//两个队列均非空时,取出两个队列的队头中较小的那一个

	while(p<n) out[k++]=a[p++];
	while(q<m) out[k++]=b[q++];
	//两个队列中有一个为空的情况

	return out;
}

显然,合并操作 merge 的时间复杂度为 $\Omicron(n+m)$。

只有一个数的数组一定是有序的。我们将待排序的数组分成两半,两半分别排好序后,
即可整体合并成一个排好序的数组。这就是 归并排序(Merge Sort)
在我们已经实现了合并操作的基础上,其 C 代码如下:

void mergeSort(int *a,int n){
	if(n==1) return;
	int mid=n/2;//将数组分成两半
	mergeSort(a,mid),mergeSort(a+mid,n-mid);//两半分别排序
	int *b=merge(a,mid,a+mid,n-mid);//将两个数组合并
	for(int i=0;i<n;++i) a[i]=b[i];
	free(b);
}

归并排序的时间复杂度是多少呢?设归并排序的时间复杂度为 $T(n)$,则由排序过程得

$$T(n)=2T\left(\dfrac{n}{2}\right)+\Omicron(n)$$

设 $f_s=T(2^s)$,求解 $T(n)$ 等价于解递推式

$$f_s=2f_{s-1}+2^s$$

解得 $f_s=(f_0+s)2^s$。也就是说,归并排序的时间复杂度为

$$\Omicron(n\log n)$$

在计算机科学中 $\log n$ 一般指以 $2$ 为底的对数 $\log_2 n$。

如何更进一步的学习

以上只是对算法的一个初步介绍。算法的学习内容实际上远不止这些,
例如搜索算法还有启发式搜索(A*)等,排序算法还有堆排序(Heap Sort),基数排序(Radix Sort)等。

你还可以在 OI wiki 作进一步的学习。