ch_2算法分析

2.1 数学基础

算法分析需要一套正式的系统架构,我们先从一些数学定义和法则开始:

这些定义的目的是要在函数间建立一种相对的级别
定义1:如果存在正常数c和n0,使得当N≥n0 T \boldsymbol{T} T(N) ⩽ \leqslant c f \boldsymbol{f} f(N),则记为 T \boldsymbol{T} T(N)=O( f \boldsymbol{f} f(N))
定义2:如果存在正常数c和n0,使得当N≥n0 T \boldsymbol{T} T(N) ⩾ \geqslant c g \boldsymbol{g} g(N),则记为 T \boldsymbol{T} T(N)= Ω \Omega Ω( g \boldsymbol{g} g(N))
定义3: T \boldsymbol{T} T(N)= Θ \Theta Θ( h \boldsymbol{h} h(N)),当且仅当 T \boldsymbol{T} T(N)=O( h \boldsymbol{h} h(N))和 T \boldsymbol{T} T(N)= Ω \Omega Ω( h \boldsymbol{h} h(N))
定义4:如果对所有的常数c存在n0使得当N>n0 T \boldsymbol{T} T(N) p \boldsymbol{p} p(N),则记为 T \boldsymbol{T} T(N)=o( p \boldsymbol{p} p(N))。非正式的定义为:如果 T \boldsymbol{T} T(N)=O( p \boldsymbol{p} p(N))且 T \boldsymbol{T} T(N) ≠ \neq = Θ \Theta Θ( p \boldsymbol{p} p(N)),则 T \boldsymbol{T} T(N)=o( p \boldsymbol{p} p(N))

法则1:如果 T 1 \boldsymbol{T_{1}} T1(N)=O( f \boldsymbol{f} f(N))且 T 2 \boldsymbol{T_{2}} T2(N)=O( g \boldsymbol{g} g(N)),那么
(a) T 1 \boldsymbol{T_{1}} T1(N)+ T 2 \boldsymbol{T_{2}} T2(N)=O( f \boldsymbol{f} f(N)+ g \boldsymbol{g} g(N)) \quad\quad (直观非正式表达为max(O( f \boldsymbol{f} f(N),O( g \boldsymbol{g} g(N)))
(b) T 1 \boldsymbol{T_{1}} T1(N) T 2 \boldsymbol{T_{2}} T2(N)=O( f \boldsymbol{f} f(N) g \boldsymbol{g} g(N))
法则2:如果 T \boldsymbol{T} T(N)是一个k次多项式,则 T \boldsymbol{T} T(N)= Θ \Theta Θ(Nk)
法则3:对任意常数,logkN=O(N)

下表为比较后的函数增长率:
ch_2算法分析_第1张图片
注意:
1、将常数项和低阶项放进大O是不好的习惯,低阶项一般可以被忽略,常数可以丢弃
2、可以通过计算极限 lim ⁡ N → ∞ \lim_{N\to\infty } limN f \boldsymbol{f} f(N)/ g \boldsymbol{g} g(N)来确定两个函数相对增长率,必要的时候可以使用洛必达法则,该极限有四种可能的值:

  • 极限为0:意味着 f \boldsymbol{f} f(N)=o( g \boldsymbol{g} g(N))
  • 极限为c ≠ \neq = 0,意味着 f \boldsymbol{f} f(N)= Θ \Theta Θ( g \boldsymbol{g} g(N))
  • 极限为 ∞ \infty ,意味着 g \boldsymbol{g} g(N)=o( f \boldsymbol{f} f(N))
  • 极限摆动

2.2 模型

为了便于分析问题,我们假设一个模型计算机。它执行任何一个基础指令都消耗一个时间单元,并且假设它有无限的内存。

2.3 要分析的问题

算法要分析的最重要资源就是运行时间,我们这里考虑的影响运行时间的因素主要是所使用的算法和算法的输入。算法对于输入N所花费的时间一般定义为平均情形和最坏情形的运行时间。平均情形常常反应典型的结果,最坏情形则代表对任何可能的输入在性能上的一种保证。若无特别说明,我们所需要的量是最坏情况下的运行时间(提供界限并且计算相对容易)。

2.4 运行时间计算

2.4.1 一般法则

为了简化分析,约定:不存在特定的时间单位,所要做的就是计算大O运行时间

for循环: 一个for循环运行时间至多是该循环内语句的运行时间乘以迭代次数
嵌套循环:从里向外分析这些循环。
顺序语句:将各个语句的运行时间求和即可,可用法则1的(a)
If/Else语句:不超过判断再加上不同条件下运行时间较长者的总运行时间

2.4.2 例子

// 书上例程
// 计算i^3的累加求和
int sum (int N)
{
     
    int i, PartialSum;
    PartialSum = 0;             /*1*/
    for(i = 1; i <= N; i++)     /*2*/
        PartialSum += i * i * i;/*3*/
    return PartialSum;          /*4*/
}

这里针对每行进行分析:

  1. 花费1个时间单元:1个赋值
  2. 花费1+N+1+N=2N+2个时间单元:1个赋值、N+1次判断、N次加法
  3. 花费N(2+1+1)=4N个时间单元:2个乘法、1个加法、1个赋值,执行N次
  4. 花费1个时间单元:1个返回

合计花费1+2N+2+4N+1=6N+4个时间单元。

但是实际上我们不用每次都这样分析,因为面对成百上千行的程序时,我们不可能每一行都这样分析。只需计算最高阶。能够看出for循环占用时间最多。因此时间复杂度为O(N)

2.4.3 最大子序列和问题的解

最大子序列和问题:给定整数A1,A2,…,AN(可能有负数),求 ∑ k = 1 j \sum_{k=1}^{j} k=1jAk的最大值(为方便起见,当所有整数均为负数时,则最大子序列和为0)
例如,-2,11,-4,13,-5,-2,答案为20(从A2到A4)
算法1:三个for循环,穷举式地尝试所有可能
O(N3)

int maxsubsum1(const vector<int> &a)
{
     
	int maxsum = 0;
	for(int i = 0;i < a.size(); i++)	//定义子序列起点
	{
     
		for (int j = i; j < a.size(); j++)	//定义子序列终点
		{
     
			int thissum = 0;
			for (int k = i; k <= j; k++)	//子序列元素依次相加
				thissum += a[k];
			if (maxsum < thissum)
				maxsum = thissum;
		}
	}
	return maxsum;
}

算法2:与算法1相比,根据 ∑ k = i j \sum_{k=i}^{j} k=ijAk=Aj+ ∑ k = i j − 1 \sum_{k=i}^{j-1} k=ij1Ak,可以去除了k的循环
O(N2)

int maxsubsum(const vector<int>& a)
{
     
	int maxsum = 0;
	for (int i = 0; i < a.size(); i++)
	{
     
		int thissum = 0;
		for (int j = i; j < a.size(); j++)
		{
     
			thissum += a[j];              //可以使用之前得到的结果,避免二次计算
			if (maxsum < thissum)
				maxsum = thissum;
		}
	}
	return maxsum;
}

算法3:使用分治策略。‘分’为将数据分为左右两部分,即将问题分成两个大致相等的子问题,然后递归的将他们求解;‘治’为将两个子问题的解合并到一起,并可能再做少量的附加工作,最后得到整个问题的解。这个问题中,最大子序列和可能出现三种情况:左半部分,右半部分,跨越左半部分和右半部分。第三种情况的最大子序列和为包含左半部分最后一个元素的最大子序列和加上包含右半部分第一个元素的最大子序列和的总和。
大致计算时间复杂度:定义T(1)为单位时间长度
T(1)=1
T(N)=2 × \times ×T(N/2)+N \quad T(N/2)=2 × \times ×T(N/4)+N/2 → \quad\rightarrow\quad T(N)=4 × \times ×T(N/4)+2N
依次类推,T(N)=常数+N × \times ×logN,便知T(N)=O(NlogN)
O(NlogN)

int maxsumr(const vector<int>& a, int left, int right)         //递归函数
{
     
	if (left == right)							//基准情形
		if (a[left] < 0)
			return 0;
		else
			return a[left];

	int center = (left + right) / 2;
	int maxleftsum = maxsumr(a, left, center);           //递归左半部分
	int maxrightsum = maxsumr(a, center + 1, right);     //递归右半部分
	
	//两个for循环分别计算跨越左半部分和右半部分
	int maxleftbordersum = 0, leftbordersum = 0;        
	for (int i = center; i >= left; i--)
	{
     
		leftbordersum += a[i];
		if (leftbordersum > maxleftbordersum)
			maxleftbordersum = leftbordersum;
	}
	
	int maxrightbordersum = 0, rightbordersum = 0;
	for (int j = center+1; j <= right; j++)
	{
     
		rightbordersum += a[j];
		if (rightbordersum > maxrightbordersum)
			maxrightbordersum = rightbordersum;
	}

	return max3(maxleftsum, maxleftsum, maxleftbordersum + maxrightbordersum);
}

int maxsubsum(const vector<int>& a)
{
     
	return maxsumr(a, 0, a.size()-1);
}

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

算法4:如果a[i]是负的,那么它不可能代表最有序列的起点;同理,任何负的子序列不可能是最优子序列的前缀。如果某一次循环,检测到 a[i] 到 a[j] 的子序列突然从非负变为负的,则我们不仅能把 i 推进到 i+1,实际上可以推进到 j+1。
附带优点:此算法只对数据进行一次扫描,一旦读入并被处理,它就不需要被记忆。如果数组存储在磁盘上,它就可以被顺序读入,在主存中不必存储数组的任何部分。而且任意时刻,算法能对它已经读入的数据给出子序列问题的正确答案。具有这种特性的算法也叫做联机算法(在线算法)。仅需要常量空间并以线性时间运行的在线算法几乎是完美的算法
O(N)

int maxsubsum(const vector<int> &a)
{
     
	int maxsum = 0, thissum = 0;

	for (int j = 0; j <= a.size() - 1; j++)
	{
     
		thissum += a[j];
		if (thissum < 0)            //子序列小于0,便抛弃前面序列,直接置0
			thissum = 0;
		else if (thissum > maxsum)
			maxsum = thissum;
	}
	return maxsum;
}

2.4.4 运行时间中的对数

对数中最常出现的规律一般可概括为以下一般法则:

1、如果一个算法用常数时间(O(1))将问题的大小消减为其一部分(通常是1/2),那么该算法就是O(logN)。
2、如果使用常数时间只是把问题减少一个常数(如将问题减少1),那么这种算法就是O(N)的。

二分搜索

问题:给定一个整数X和整数A0,A1,…,AN-1,后者已经预先排序并在内存中,求下标 i 使得Ai=X,如果X不在数据中,返回 i=-1.
解法:比较X与居中元素。X小,则将相同策略应用到左边排好序的子序列。X大时同理
O(logN)

int binarysearch(const vector<int>& a, const int& x)
{
     
	int left = 0, right = a.size() - 1;

	while (left <= right)         //left等于right时,可能刚好在这个点上,也需要进行循环判断下
	{
     
		int center = (left + right) / 2;

		if (x > a[center])
			left = center + 1;        //由if的判断条件可知,center可以加 1
		else if (x < a[center])
			right = center - 1;
		else
			return center;
	}
	return -1;
}

欧几里得算法

问题:计算最大公因数
解法:欧几里得算法,通过连续计算余数直到余数为0为止,最后的非零余数就是最大公因数。
定理:如果M>N,则M mod N < N/2。 \quad\quad 可根据定理计算O()
O(logN)

long gcd(long m, long n)
{
     
	while(n != 0)
	{
     
		long rem = m % n;
		m = n;
		n = rem;
	}	
	return m;
}

幂运算

问题:处理整数的幂
解法:可以用递归,求解YN,N为偶数时YN =YN/2 × \times × YN/2,N为奇数时YN =YN/2 × \times × YN/2 × \times × Y
O(logN)

long pow(long x, int n)
{
     
	if(n == 0)
		return 1;
	if(n == 1)
		return x;
	if(isEven(n))
		return pow(x * x, n / 2);
	else
		return pow(x * x, n / 2) * x;
}

2.4.5 检验你的分析

  1. 实际编程,观察运行时间结果与分析预测出的运行时间是否匹配。如果N扩大一倍时,线性程序的运行时间乘以系数2,二次程序的运行时间乘以系数4,O(logN)的运行时间知识多加一个常数。
  2. 对N的某个范围(通常是2的倍数隔开)计算比值T(N)/f(N),其中T(N)是观察到的运行时间,f(N)则是理论推导出的运行时间。如果所算出的值收敛于一个正常数,则代表f(N)是运行时间的理想近似;如果收敛于0,则代表f(N)估计过大;如果结果发散,则代表f(N)估计过小。

参考:https://www.cnblogs.com/CrazyCatJack/p/12688582.html

你可能感兴趣的:(数据结构)