数据结构与算法分析-算法分析

算法分析

算法(algorithm)是为求解一个问题需要遵循的、被清楚地指定的简单指令的集合。

数学基础

定义: 如果存在正常数ccn0n_0使得当Nn0Ngeq n_0T(N)cf(N)T(N)leq cf(N),则记为T(N)=O(f(N))T(N)=O(f(N))

定义: 如果存在正常数ccn0n_0使得当Nn0Ngeq n_0T(N)cg(N)T(N)geq cg(N),则记为T(N)=Ω(f(N))T(N)=Omega(f(N))

定义:T(N)=Θ(h(N))T(N)=Theta(h(N))当且仅当T(N)=O(h(N))T(N)=O(h(N))T(N)=Ω(h(N))T(N)=Omega(h(N))

定义: 如果T(N)=O(p(N))T(N)=O(p(N))T(N)Θ(p(N))T(N) eqTheta(p(N)),则T(N)=o(p(N))T(N)=o(p(N))

这些定义的目的是要在函数之间建立一种相对的级别。给定两个函数,通常存在一些点,在这些点上一个函数的值小于另一个函数的值,因此,像f(N)<g(N)f(N)<g(N)这样的声明是没有什么意义的。于是,我们比较它们的相对增长率relative rate of growth)。

如果用传统的不等式来计算增长率,那么第一个定义是说T(N)T(N)的正常率小于等于f(N)f(N)的增长率。第二个定义T(N)=Ω(g(N))T(N)=Omega(g(N))(念成“Omega”)是说T(N)T(N)的增长率大于等于g(N)g(N)的增长率。第三个定义T)N=Θ(h(N))T)N=Theta(h(N))(念成“Theta”)是说T(N)T(N)的增长率等于h(N)h(N)增长率。最后一个定义T(N)=o(p(N))T(N)=o(p(N))(念成“小o”)说的则是T(N)T(N)的增长率小于p(N)p(N)的增长率。它不同于大O,因为大O包含增长率相同这种可能性。

当我们说T(N)=O(f(N))T(N)=O(f(N))时,我们是在保证函数T(N)T(N)是在不快于f(N)f(N)的速度增长;因此f(N)f(N)T(N)T(N)的一个上界(upper bound)。与此同时,f(N)=Ω(T(N))f(N)=Omega(T(N))意味着T(N)T(N)f(N)f(N)的一个下界(lower bound)。

作为一个例子,N3N^3的增长比N2N^2快,因此我们说N2=O(N3)N^2=O(N^3)N3=Ω(N2)N^3=Omega(N^2)f(N)=N2f(N)=N^2g(N)=2N2g(N)=2N^2以相同的速率增长时,从而f(N)=O(g(N))f(N)=O(g(N))f(N)=Ω(g(N))f(N)=Omega(g(N))都是正确的。直观地说,如果g(N)=2N2g(N)=2N^2,那么g(N)=O(N4)g(N)=O(N3)g(N)=O(N2)g(N)=O(N^4),g(N)=O(N^3),g(N)=O(N^2)从技术上都是成立的,但最后一个选择是最好的答案。

法则1

如果T1(N)=O(f(N)T_1(N)=O(f(N)T2(N)=O(g(N))T_2(N)=O(g(N)),那么

  • T1(N)+T2(N)=max(O(f(N),O(g(N))T_1(N)+T_2(N)=max(O(f(N),O(g(N))
  • T1(N)T2(N)=O(f(N)g(N))T_1(N)*T_2(N)=O(f(N)*g(N))。

法则2

如果T(N)T(N)是一个kk次多项式,则T(N)=Θ(Nk)T(N)=Theta(N^k)

法则3

对任意常数k,logkN=O(N)k,log^kN=O(N)。它告诉我们对数增长得非常缓慢。

函数 名称
cc 常数
logNlogN 对数级
log2Nlog^2N 对数平方根
NN 线性级
NlogNNlog N
N2N^2 平方级
N3N^3 立方级
2N2^N 指数级

我们总能通过计算极限limx0f(N)g(N)lim_{x o0}frac{f(N)}{g(N)}来确定两个函数f(N)f(N)g(N)g(N)的相对增长率,必要的时候可以使用洛必达法则。该极限可以有四种可能的值:

  • 极限是00:这意味着f(N)=o(g(N))f(N)=o(g(N))
  • 极限是c0c eq 0:这意味着f(N)=Θ(g(N))f(N)=Theta(g(N))
  • 极限是infty:这意味着g(N)=o(f(N))g(N)=o(f(N))
  • 极限摆动:二者无关

运行时间计算

一个简单的例子

这里是计算i=1Ni3sum_{i=1}^Ni^3的一个简单的程序片段:

	int Sum(int N) {
        int i, PartialSum;
/* 1*/	PartialSum = 0;
/* 2*/  for (i = 1; i <= N; i++)
/* 3*/      PartialSum += i * i * i;
/* 4*/  return PartialSum;
    }

对这个程序的分析很简单。声明不计时间,第1行和第4行各占一个时间单元。第3行每执行一次占用四个时间单元(两次乘法、一次加法和一次赋值),每执行NN次共占用4N4N个时间单元。第二行在初始化ii、测试iNileq N和对ii的自增运算中隐含着开销。所有这些的总开销是初始化1个时间单元,所有的测试N+1N+1个时间单元,以及所有的自增运算NN个时间单元,共2N+22N+2。我们忽略调用函数和返回值的开销,得到总量是6N+46N+4。因此,我们说该函数是O(N)O(N)

如果我们每次分析一个程序都要演示所有这些工作,那么这项任务很快就会变成不可行的工作。幸运的是,由于我们有了大OO的结果,因此就存在许多可以采取的捷径并且不影响最后的结果。这使得我们得到若干一般法则。

一般法则

法则1——for循环

一次for循环的运行时间至多是该for循环内语句(包括测试)的运行时间乘以迭代的次数。

法则2——嵌套的for循环

从里向外分析这些循环。在一组嵌套循环内部的一条语句总的运行时间为该语句的运行时间乘以该组所有的for循环的大小的乘积。

作为一个例子,下列程序片段为O(N2)O(N^2)

for (i=0; i<N; i++)
    for(j=0; j<N; i++)
        k++;

法则3——顺序语句

将各个语句的运行时间求和即可(这意味着,其中的最大值就是所得的运行时间)。

作为一个例子,下面程序片段先用去O(N)O(N),再花费O(N2)O(N^2),总的开销也是O(N2)O(N^2)

for(i=0; i<N; i++)
    A[i] = 0;
for(i=0; i<N; i++)
    for(j=0; j<N; j++)
        A[i] += A[j] + i + j;

法则4——if/else语句

对于程序片段:

if(Condition)
    S1
else
    S2

一个if/else语句的运行时间从不超过判断再加上S1和S2中运行时间长者的总的运行时间。

其他的法则都是显然的,但是,分析的基本策略是从内部(或最深层部分)向外展开的。如果有函数调用,那么这些调用要首先分析。如果有递归过程,那么存在几种选择。若递归只是被薄面纱遮住的for循环,则分析通常是很简单的。例如,下面的函数实际上就是一个简单的循环,从而其运行时间为O(N)O(N)

long int Factorial(int N) {
    if (N <= 1)
        return 1;
    else
        return N * Factorial(N - 1);
}

这个例子中对递归的使用实际上并不好。当递归被正常使用时,将其转换成一个简单的循环结构是相当困难的。在这种情况下,分析将涉及求解一个递推过程。为了观察到这种可能发生的情形,考虑下列程序,实际上它对递归使用的效率低得令人诧异。

long int Fib(int N) {
/* 1*/	if (N <= 1)
/* 2*/		return 1;
    	else
/* 3*/		return Fib(N - 1) + Fib(N - 2);
}

T(N)T(N)为函数Fib(N)的运行时间。如果N=0N=0N=1N=1,则运行时间是某个常数值,即第一行上做判断以及返回所用的时间。若N>2N>2,则执行该函数时间是第一行上的常数工作加上第三行上的工作。第三行由一次加法和两次函数调用组成。由于函数调用不是简单的运算,必须通过它们本身来分析。第一次函数调用是Fib(N - 1),从而按照TT的定义,它需要T(N1)T(N-1)个时间单元。类似的论证指出,第二次函数调用需要T(N2)T(N-2)个时间单元。此时总的时间需求为T(N1)+T(N2)+2T(N-1)+T(N-2)+2,其中"2"指的是第一行上的工作加上第三行上的加法。于是对于N2Ngeq2我们有下列关于Fib(N)的运行时间公式:

T(N)=T(N1)+T(N2)+2T(N)=T(N-1)+T(N-2)+2

但是Fib(N)=Fib(N1)+Fib(N2)Fib(N)=Fib(N-1)+Fib(N-2),因此由归纳法容易证明T(N)Fib(N)T(N)geq Fib(N)。而Fib(N)<(53)NFib(N)< (frac{5}{3})^N,类似的计算可以证明(对于N>4N>4Fib(N)(32)NFib(N)geq (frac 32)^N,可见,这个程序的运行时间以指数的速度增长。

最大子序列和问题的解

给定整数A1,A2,,ANA_1,A_2,cdots,A_N(可能有负数),求k=ijAksum_{k=i}^jA_k的最大值(为方便起见,如果所有整数均为负数,则最大子序列和为0)。

我们将要叙述四个算法来求解最大子序列和问题。

第一个算法为:

int MaxSunSequenceSum(coonst int A[], int N) {
		int ThisSum, MaxSum, i, j, k;
/* 1*/	MaxSum = 0;
/* 2*/	for(i=0; i<N; i++)
/* 3*/		for(j=i; j<N; j++) {
/* 4*/			ThisSum = 0;
/* 5*/			for(k=i; k<=j; j++)
/* 6*/				ThisSum += A[k];
/* 7*/			if(ThisSum > MaxSum)
/* 8*/				MaxSum = ThisSum;
			}
/* 9*/	return MaxSum;
}

它只是穷举式地尝试所有的可能。再有,本算法不计算实际的子序列;实际的计算还要添加一些额外的程序。

该算法肯定会正确运行。运行时间为O(N3)O(N^3),这完全取决于第5行和第6行,第6行由一个含于三重嵌套for循环中的O(1)O(1)语句组成。第2行上的循环大小为NN

第2个循环大小为NiN-i,它可能要小,但也可能是NN。我们必须假设最坏的情况,而这可能会使得最终的界有些大。第3个循环的大小为ji+1j-i+1,我们也要假设它的大小为NN。因此总数为O(1NNN)=O(N3)O(1cdot Ncdot Ncdot N)=O(N^3)。语句1总共的开销只是O(1)O(1),而语句7和8总共开销也只不过O(N2)O(N^2),因为它们只是两层循环内部的简单表达式。

事实上,考虑到这些循环的实际大小,更精确的分析指出答案是Θ(N3)Theta(N^3),而我们上面的估计高出个因子6(不过这并无大碍,因为常数不影响数量级)。一般来说,在这类问题中上述结论是正确的。精确的分析由i=0N1j=iN1k=ij1sum_{i=0}^{N-1}sum_{j=i}^{N-1}sum_{k=i}j1得到,该和指出程序的第6行被执行的次数。经过计算我们得到i=0N1j=iN1k=ij1=N3+3N2+2N6sum_{i=0}^{N-1}sum_{j=i}^{N-1}sum_{k=i}j1=frac{N^3+3N^2+2N}{6}

下面指出一种改进的算法,该算法显然是O(N2)O(N^2)

int MaxSunSequenceSum(const int A[], int N) {
		int ThisSum, MaxSum, i, j;
/* 1*/	MaxSum = 0;
/* 2*/	for(i=0; i<N; i++) {
/* 3*/		ThisSum = 0;
/* 4*/		for (j=i; j<N; j++) {
/* 5*/			ThisSum += A[j];
/* 7*/			if(ThisSum > MaxSum)
/* 8*/				MaxSum = ThisSum;
			}
		}
		return MaxSum;
}

对于这个问题有一个递归和相对复杂的O(NlogN)O(Nlog N)解法,要是真的没出现O(N)O(N)(线性的)解法,这个算法就会是体现递归威力的极好的范例了。

该算法采用一种“分治(divide-and-conquer)”策略。其想法就是把问题分成两个大致相等的子问题,然后递归地对它们求解,这是“分”部分。“治”阶段将两个子问题的解合并到一起并可能再做些少量的附加工作,最后得到整个问题的解。

在这个问题中,最大子序列和可能在三处出现。或者整个出现在输入数据的左半部,或者整个出现在右半部,或者跨越输入数据的中部从而占据左右两半部分。前两种情况可以递归求解。第三种情况的最大和可以通过求出前半部分的最大和(包含前半部分的最后一个元素)以及后半部分的最大和(包含后半部分的第一个元素)而得到。然后将这两个和加在一起。作为一个例子,考虑下列输入:

前半部分 | 后半部分

4 -3 5 -2 -1 2 6 -2

其中前半部分的最大子序列和为6而后半部分的最大子序列和为8。

前半部分包含其最后一个元素的最大和是4,而后半部分包含其第一个元素的最大和是7。因此,横跨这两部分且通过中间的最大和为4+7=114+7=11

下面提出了实现这种策略的一种实现手段。

int Max3(int a, int b, int c)
{
    return a > b ? a > c ? a : c : b > c ? b : c;
}

int MaxSubSum(const int A[], int Left, int Right)
{
    int MaxLeftSum, MaxRightSum;
    int MaxLeftBorderSum, MaxRightBorderSum;
    int LeftBorderSum, RightBorderSum;
    int Center, i;

    /* 1*/ if (Left == Right) /* Base Case */
        /* 2*/ if (A[Left] > 0)
            /* 3*/ return A[Left];
        else
            /* 4*/ return 0;

    /* 5*/ Center = (Left + Right) / 2;
    /* 6*/ MaxLeftSum = MaxSubSum(A, Left, Center);
    /* 7*/ MaxRightSum = MaxSubSum(A, Center + 1, Right);

    /* 8*/ MaxLeftBorderSum = 0;	LeftBorderSum = 0;
    /* 9*/ for (i = Center; i >= Left; i--)
    {
        /* 10*/ LeftBorderSum += A[i];
        /* 11*/ if (LeftBorderSum > MaxLeftBorderSum)
            /* 12*/ MaxLeftBorderSum = LeftBorderSum;
    }

    /* 13*/ MaxRightBorderSum = 0;	RightBorderSum = 0;
    /* 14*/ for (i = Center + 1; i <= Right; i++)
    {
        /* 15*/ RightBorderSum += A[i];
        /* 16*/ if (RightBorderSum > MaxRightBorderSum)
            /* 17*/ MaxRightBorderSum = RightBorderSum;
    }
    /* 18*/ return Max3(MaxLeftSum, MaxRightSum, MaxLeftBorderSum + MaxRightBorderSum);
}

int MaxSubSequnceSum3(const int A[], int N)
{
    return MaxSubSum(A, 0, N - 1);
}

第1行至第4行处理基准情况。如果Left == Right,那么只有一个元素,并且当该元素非负时它就是最大子序列和。Left > Right的情况是不可能出现的,除非NN是负数。第6行和第7行执行两次递归调用。我们可以看到,递归调用总是小于原问题的问题进行。第8行至12行以及第13行至第17行计算达到中间分界处的两个最大和的和数。这两个最大和的和为扩展到左右两边的最大和。

T(N)T(N)是求解大小为NN的最大子序列和问题所花费的时间。如果N=1N=1,则算法3执行程序第1行到第4行花费某个时间常量,我们称之为一个时间单元。于是,T(1)=1T(1)=1。否则,程序必须运行两次递归调用,即在第9行和第17行之间的两个for循环,还需某个小的簿记量,如在第5行和第18行。这两个for循环总共接触到A0A_0AN1A_{N-1}的每一个元素,而在循环内部的工作量是常量,因此,在第9到17行花费时间为O(N)O(N)。第1行到第5行,第8、13和18行上的程序的工作量都是常量,从而与O(N)O(N)相比可以忽略。其余就是第6、7行上运行的工作。这两行求解大小为N2frac N2的子序列问题(假设NN是偶数)。因此 ,这两行每行花费T(N2)T(frac N2)个时间单元,共花费2T(N2)2T(frac N2)个时间单元。算法3花费的总的时间为2T(N2)+O(N)2T(frac N2)+O(N)。我们得到方程组:

T(1)=1T(1)=1

T(N)=2T(N2)+O(N)T(N)=2T(frac N2)+O(N)

为了简化计算,可以用NN代替上面方程中的O(N)O(N)项;由于T(N)T(N)最终还是要用大OO表示的,因此这么做并不影响答案。如果T(N)=2T(N2)+NT(N)=2T(frac N2)+N,且T(1)=1T(1)=1,那么T(2)=4=22,T(4)=12=34,T(8)=32=48T(2)=4=2*2,T(4)=12=3*4,T(8)=32=4*8,以及T(16)=80=516T(16)=80=5*16。其形式是显然并且可以得到,即若N=2kN=2^k,则T(N)=N(k+1)=NlogN+N=O(NlogN)T(N)=N*(k+1)=Nlog N+N=O(Nlog N)

下面是第4种算法的代码:

int MaxSubSequenceSum(const int A[], int N)
{
    	int ThisSum, MaxSum, j;
/* 1*/	ThisSum = MaxSum = 0;
/* 2*/	for (j = 0; j < N; j++)
		{
/* 3*/		ThisSum += A[j];
/* 4*/		if (ThisSum > MaxSum)
/* 5*/			MaxSum = ThisSum;
/* 6*/		else if (ThisSum < 0)
/* 7*/				ThisSum = 0;
    	}
/* 8*/	return MaxSum;
}

该算法的一个附带优点是,它只对数据进行一次扫描,一旦A[i]被读入并被处理,它就不再需要被记忆。因此,如果数组在磁盘或磁带上,它就可以被顺序读入,在主存中不必存储数组的任何部分。不仅如此,在任意时刻,算法都能对它已经读入的数据给出子序列问题的正确答案(其他算法不具有这个特性)。具有这种特性的算法叫做联机算法(online algorithm)。仅需要常量空间并以线性时间运行的联机算法几乎是完美的算法。

运行时间中的对数

分析算法最混乱的方面大概集中在对数上面。我们已经看到,某些分治算法将以O(NlogN)O(NlogN)时间运行。除分治算法外,可将对数最常出现的规律概括为下列一般法则:如果一个算法用常数时间(O(1)O(1))将问题的大小削减为其一部分(通常是12frac 12),那么该算法就是O(NlogN)O(Nlog N)。另一方面,如果使用常数时间只是把问题减少一个常数(如将问题减少1),那么这种算法就是O(N)O(N)的。

显然,只有一些特殊种类的问题才能够呈现除O(logN)O(log N)型。例如,若输入NN个数,则一个算法只是把这些数读入就必须耗费Ω(N)Omega(N)的时间量。因此,当我们谈到这类问题的O(logN)O(log N)算法时,通常是假设输入数据已经提前读入。我们提供具有对数特点的三个例子。

对分查找(binary search)

给定一个整数XX和整数A0,A1,,AN1A_0,A_1,cdots ,A_{N-1},后者已经预先排序并在内存中,求使得Ai=XA_i=X的下标ii,如果XX不再数据中,则返回i=1i=-1*

下面给出代码实现:

int BinarySearch(const ElementType A[], ElementType X, int N)
{
    	int Low, Mid, High;
/* 1*/	Low = 0;	High = N - 1;
/* 2*/	while (Low <= High)
		{
/* 3*/		Mid = (Low + High) / 2;
/* 4*/		if (A[Mid] < X)
/* 5*/		Low = Mid + 1;
/* 6*/		else if (A[Mid] > X)
/* 7*/				High = Mid - 1;
				else
/* 8*/				return Mid; /* Found */
		}
/* 9*/	return NotFound; /* NotFound is defined as -1 */
}

显然,每次迭代在循环内的所有工作花费为O(1)O(1),因此分析需要确定循环的次数。循环从HighLow=N1High-Low=N-1开始并在HighLow1High-Lowgeq-1结束。每次循环后HighLowHigh-Low的值至少将该次循环前的值折半;于是,循环的次数最多为log(N1)+2left lceil log(N-1) ight ceil+2。(例如,若HighLow=128High-Low=128,则在各次迭代后HighLowHigh-Low的最大值是64,32,16,8,4,2,1,0,-1。)因此,运行时间是O(logN)O(log N)

欧几里得算法

计算最大公因数的欧几里得算法。两个整数的最大公因数(GcdGcd)是同时整数二者的最大整数。于是,Gcd(50,15)=5Gcd(50,15)=5。下面代码实现这个算法,假设MNMgeq N。(如果N>MN>M,则循环的第一次迭代将它们互相交换)。

unsigned int Gcd(unsigned int M, unsigned int N)
{
    	unsigned int Rem;
/* 1*/	while (N > 0)
    	{
/* 2*/		Rem = M % N;
/* 3*/		M = N;
/* 4*/		N = Rem;
		}
    	return M;
}

算法通过连续计算余数直到余数是-为止,最后的非零余数就是最大公因数。因此,如果M=1989M=1989N=1590N=1590,则余数序列是399,393,6,3,0。从而,Gcd(1989,1590)=3Gcd(1989,1590)=3

在两次迭代以后,余数最多是原始值的一半。这就证明了,迭代次数至多是2logN=O(logN)2log N=O(log N)从而得到运行时间。

定理2.1

如果M>NM>N,则Mmod  N<M2Mmod N<frac M2

欧几里得算法在平均情况下的性能需要大量篇幅的高度复杂的数学分析,其迭代的平均次数约为12ln2lnNπ2+1.47frac{12ln2ln N}{pi^2}+1.47

幂运算

将用乘法的次数作为运行时间的度量。

计算XNX^N的常见的算法是使用N1N-1次乘法自乘。下面的 递归算法更好:

long int Pow(long int X, unsigned int N)
{
/* 1*/	if (N == 0)
/* 2*/		return 1;
/* 3*/	if (N == 1)
/* 4*/		return X;
/* 5*/	if (IsEven(N))
/* 6*/		return Pow(X * X, N / 2);
		else
/* 7*/		return Pow(X * X, N / 2) * X;
}

第1行至第4行处理基准情形。如果NN是偶数,我们有XN=XN2XN2X^N=X^{frac N2}cdot X^{frac N2},如果NN是奇数,则XN=XN12XN12XX^N=X^{frac{N-1}2}cdot X^{frac{N-1}2}cdot X

例如,为了计算X62X^{62},算法将如下进行,它只用到9次乘法:

X3=(X2)X,X7=(X3)2X,X15=(X7)2X,X31=(X15)2X,X62=(X31)2X^3=(X^2)X,X^7=(X^3)^2X,X^{15}=(X^7)^2X,X^{31}=(X^{15})^2X,X^{62}=(X^{31})^2

显然,所需要的乘法次数最多是2logN2log N,因为把问题分半最多需要两次乘法(如果NN是奇数)。

第3行到第4行实际上不是必须的,因为如果NN是1,那么第7行将做同样的事情。第7行还可以写成

/* 7*/	return Pow(X, N - 1) * X;

而不影响程序的正确性。事实上,程序仍将以O(logN)O(log N)运行,因为乘法的序列同以前一样。

检验你的分析

一旦分析进行过后,则需要看一看答案是否正确,是否尽可能地好。一种实现方法是编程并比较实际观察到的运行时间与通过分析所描述的运行时间是否相匹配。当NN扩大一倍时,则线性程序的运行时间乘以因子22,二次程序的运行时间乘以因子44,而三次程序则乘因子88。以对数时间运行的程序当NN增加一倍时其运行时间只是多加一个常数,而以O(NlogN)O(Nlog N)运行的程序则花费比在相同环境下运行时间的两倍稍多一些的时间。如果低阶项的系数相对地大,并且NN又不是足够地大,那么运行时间的变化量很难观察清楚。

检验一个程序是否时O(f(N))O(f(N))的另一个常用的技巧是对NN的某个范围(通常用2的倍数隔开)计算比值T(N)f(N)frac{T(N)}{f(N)},其中T(N)T(N)是凭经验观察到的运行时间。如果f(N)f(N)是运行时间的理想近似,那么所算出的值收敛于一个正常数。如果f(N)f(N)估计过大,则算出的值收敛于00。如果f(N)f(N)估计过低从而程序不是O(f(N))O(f(N))的,那么算出的值发散。

不一定每天 code well 但要每天 live well
原文地址:https://www.cnblogs.com/geekfx/p/12423070.html