重点:大O记法,最大子序列和(4种算法),对数级算法(3个例子:对分查找、欧几里德算法、幂运算)
算法
算法(algorithm)是为求解一个问题需要遵循的、被清楚地指定的简单指令的集合。
数学基础
四个定义:
1.大O表示法:如果存在正常数 c 和 n0 使得当 N ≥ n0时,T(N) ≤ cf(N),则记为T(N) = O(f(N))。
(描述了T(N)的相对增长率小于等于f(N)的相对增长率。)
2.大Ω表示法:如果存在正常数 c 和 n0 使得当 N ≥ n0时,T(N) ≥ cf(N),则记为T(N) = Ω(f(N))。
(描述了T(N)的相对增长率大于等于f(N)的相对增长率。)
3.大Θ表示法:如果 T(N) = O(f(N)) 且 T(N) = Ω(f(N)),则 T(N) = Θ(f(N))。
(描述了T(N)的相对增长率等于f(N)的相对增长率。)
4.小o表示法:如果 T(N) = O(f(N)) 且 T(N) ≠ Θ(f(N)),则 T(N) = o (f(N))。
(描述了T(N)的相对增长率小于f(N)的相对增长率。)
三个结论:
1.如果T1(N) = O(f(N)) 且 T2(N) = O(g(N)),那么
(a). 加法法则:T1(N) + T2(N) = max(O(f(N)), O(g(N)));【大O的和等于大O的最大值】
(b). 乘法法则:T1(N) * T2(N) = O(f(N) * g(N)).【大O的积等于积的大O】
2.如果T(N) 是一个k次多项式,则T(N) = Θ(Nk).
3.对任意常数k,logkN = O(N)。它告诉我们对数增长得非常缓慢。
时间复杂度
一个算法在输入规模为N时运行的耗时称为时间复杂度,常用大O表示。一般来说,它描述了最坏情况下的时间复杂度(平均情况下的时间复杂度需要更加复杂的数学分析)。
为了简化分析,约定:不存在特定的时间单位。因此,常抛弃一些常数系数和低阶项,从而便于计算大O运行时间。
看个例子:
1 int sum(int N) 2 { 3 int i, partialSum; 4 5 partialSum = 0; //1个时间单元 6 for (i = 1; i < N; i++) //初始化耗时1个时间单元,测试比较耗时N+1个时间单元,自增运算耗时N个时间单元 7 partialSum += i * i * i; //4个时间单元(2次乘,1次加,1次赋值),循环N次耗时4N个时间单元 8 return partialSum; //1个时间单元 9 }
声明不耗时间,忽略函数调用和返回值的开销,总共耗时1 + 1 + N + 1 + N + 4N+ 1 = 6N + 4。按照之前的约定,忽略低阶项4和常系数6,我们说该函数是O(N),时间复杂度是线性级。
这仅仅是一个小函数,如果有一个较大的程序,那么计算时间复杂度需要的工作量就太琐碎繁杂了。考虑大O的结果,它只关注得到的最高阶项。常数级运行时间相对于有关输入规模N的语句的耗时是很小的(无关紧要),所以忽略掉常数级O(1)的语句第5行、第7行、第8行,跟输入规模N有关的耗时主要是for循环,循环大小为N,所以该函数的运行时间就是O(N)线性级的。
计算时间复杂度的一般法则
法则1——for循环
一次for循环的运行时间至多是该for循环内语句(包括测试)的运行时间乘以迭代的次数。
法则2——嵌套的for循环
从里向外分析这些循环。在一组嵌套循环内部的一条语句总的运行时间为该语句的运行时间乘以该组所有for循环的大小的乘积。
法则3——顺序语句
将各个语句的运行时间求和即可(这意味着,其中的最大值就是所得的运行时间)
法则4——if/else 语句
一个if/else语句的运行时间从不超过判断再加上分支语句中运行时间长者的总的运行时间。显然在某些情况下这么估计有些过高,但绝不会估计过低。
最大子序列和问题
问题描述:给定整数A1,A2,,... ,AN(可能有负数),求∑jk=i Ak的最大值(为方便起见,如果所有整数均为负数,则最大子序列和为0)。
下面给出四种算法:
1.穷举法:枚举所有的子序列之和,返回最大值。时间复杂度O(n3)。
1 int maxSequenceSum1(const int A[], int N) 2 { 3 int i, j, k, maxSum, thisSum; 4 5 maxSum = 0; 6 for (i = 0; i < N; i++) 7 { 8 for (j = i; j < N; j++) 9 { 10 thisSum = 0; 11 for (k = i; k <= j; k++) 12 thisSum += A[k]; 13 14 if (thisSum > maxSum) 15 maxSum = thisSum; 16 } 17 } 18 return maxSum; 19 }
2.撤销一个for循环,降低立方级的运行时间。考虑到∑jk=i Ak = ∑j-1k=i Ak + Aj。修改如下。算法复杂度O(N2)。
1 int maxSequenceSum2(const int A[], int N) 2 { 3 int i, j, maxSum, thisSum; 4 5 maxSum = 0; 6 for (i = 0; i < N; i++) 7 { 8 thisSum = 0; 9 for (j = i; j < N; j++) 10 { 11 thisSum += A[j]; 12 13 if (thisSum > maxSum) 14 maxSum = thisSum; 15 } 16 } 17 return maxSum; 18 }
3.分治算法:把一个问题分成两个大致相等的子问题,然后递归地对它们求解,这是“分”部分。“治”阶段将两个子问题的解合到一起并可能再做少量的附加工作,最后得到整个问题的解。
思路:最大子序列和只可能出现在三处:左半部分、右半部分、跨越并穿过中间而占据左右两半部分。前两种情况可以递归求解,第三部分的最大和可以通过求出前半部分的最大和(包括前半部分最后一个元素)以及后半部分的最大和(包括后半部分第一个元素)而得到,然后将这两个和加在一起。时间复杂度O(logN)。
考虑下列输入:
前半部分 | 后半部分 |
4 -3 5 -2 | -1 2 6 -2 |
其中前半部分的最大子序列和为6(从元素A1到A3)而后半部分的最大子序列和为8(从元素A6到A7)。
前半部分包含其最后一个元素的最大和是4(从元素A1到A4),而后半部分包含其第一个元素的最大和是7(从元素A5到A7)。因此,跨越这两部分且通过中间的最大和为4+7 = 11(从元素A1到A7)。
1 int maxSubSum(const int A[], int left, int right) 2 { 3 int maxLeftSum, maxRightSum; 4 int maxLeftBorderSum, maxRightBorderSum; 5 int leftBorderSum, rightBorderSum; 6 int center, i; 7 8 if (left == right) /*Base case*/ 9 { 10 if (A[left] > 0) 11 return A[left]; 12 else 13 return 0; 14 } 15 16 center = (left + right) / 2; 17 maxLeftSum = maxSubSum(A, left, center); 18 maxRightSum = maxSubSum(A, center + 1, right); 19 20 maxLeftBorderSum = 0; leftBorderSum = 0; 21 for (i = center; i >= left; i--) 22 { 23 leftBorderSum += A[i]; 24 if (leftBorderSum > maxLeftBorderSum) 25 maxLeftBorderSum = leftBorderSum; 26 } 27 28 maxRightBorderSum = 0; rightBorderSum = 0; 29 for (i = center + 1; i <= right; i++) 30 { 31 rightBorderSum += A[i]; 32 if (rightBorderSum > maxRightBorderSum) 33 maxRightBorderSum = rightBorderSum; 34 } 35 return max(maxLeftSum, maxRightSum, 36 maxLeftBorderSum + maxRightBorderSum); 37 } 38 39 int maxSequenceSum3(const int A[], int N) 40 { 41 return maxSubSum(A, 0, N - 1); 42 }
4.联机算法:每个数据只访问一次。仅需要常量空间并以线性时间运行的联机算法几乎是完美的算法。
1 int maxSequenceSum4(const int A[], int N) 2 { 3 int i, maxSum, thisSum; 4 5 maxSum = 0; thisSum = 0; 6 for (i = 0; i < N; i++) 7 { 8 thisSum += A[i]; 9 10 if (thisSum> maxSum) 11 maxSum = thisSum; 12 else if (thisSum < 0) 13 thisSum = 0; 14 } 15 return maxSum; 16 }
时间复杂度中的对数规律
某些分治算法将以O(NlogN)运行。除分治算法外,可将对数最常出现的规律概括为以下一般法则:
如果一个算法用常数时间O(1)将问题的大小削减为其一部分(通常是1/2),那么该算法就是O(logN)的。另一方面,如果使用常数时间只是把问题减少一个常数(如将问题减少1)那么这种算法那就是O(N)的。
具有对数特点的三个例子
三个例子的时间复杂度均为O(logN)。
1.对分查找:
给定一个整数X和A0,A1,... ,AN-1,后者已经预先排序并在内存中,求使得Ai = X的下标i,如果X不在数据中,则返回i = -1。
1 int binarySearch(const int A[], int N, int X) 2 { 3 int low, high, mid; 4 5 low = 0;high = N - 1; 6 while (low <= high) 7 { 8 mid = (low + high) / 2; 9 if (A[mid] < X) 10 low = mid + 1; 11 else if (A[mid] > X) 12 high = mid - 1; 13 else 14 return mid; 15 } 16 return -1; //not found 17 }
2.欧几里得算法:
计算最大公因数。两个整数的最大公因数(Gcd)是同时整除两者的最大整数。
算法通过连续计算余数为0时停止,最后的非零余数就是最大公因数。
1 unsigned int gcd(unsigned int M, unsigned int N) 2 { 3 int rem; 4 5 while (N > 0) 6 { 7 rem = M % N; 8 M = N; 9 N = rem; 10 } 11 return M; 12 }
3.幂运算:
计算XN。
如果N是偶数,则XN = X(N/2) * X(N/2);如果N是奇数,则XN = X(N-1/2) * X(N-1/2) * X。
1 long pow(long X, unsigned int N) 2 { 3 if (N == 0) 4 return 1; 5 if (N == 1) 6 return X; 7 8 if (isEven(N)) 9 return pow(X * X, N / 2); 10 else 11 return pow(X * X, N / 2) * X; 12 }