【程序人生】数据结构杂记(一)
说在前面
个人读书笔记
起泡排序
在由一组整数组成的序列 A [ 0 , n − 1 ] A[0, n - 1] A[0,n−1]中,满足 A [ i − 1 ] < = A [ i ] A[i - 1] <= A[i] A[i−1]<=A[i]的相邻元素称作顺序的,否则是逆序的。不难看出,有序序列中每一对相邻元素都是顺序的,亦即,对任意 1 < = i < n 1 <=i < n 1<=i<n都有 A [ i − 1 ] < = A [ i ] A[i - 1] <= A[i] A[i−1]<=A[i];反之,所有相邻元素均顺序的序列,也必然整体有序。
由有序序列的上述特征,我们可以通过不断改善局部的有序性实现整体的有序:从前向后依次检查每一对相邻元素,一旦发现逆序即交换二者的位置。对于长度为 n n n的序列,共需做 n − 1 n - 1 n−1次比较和不超过 n − 1 n - 1 n−1次交换,这一过程称作一趟扫描交换。
可见,经过这样的一趟扫描,序列未必达到整体有序。果真如此,则可对该序列再做一趟扫描交换。事实上,很有可能需
要反复进行多次扫描交换,直到在序列中不再含有任何逆序的相邻元素。多数的这类交换操作,都会使得越小(大)的元素朝上(下)方移动,直至它们抵达各自应处的位置。
排序过程中,所有元素朝各自最终位置亦步亦趋的移动过程,犹如气泡在水中的上下沉浮,起泡排序(bubblesort)算法也因此得名。
经过
k
k
k趟扫描交换之后,最大的前
k
k
k个元素必然就位;经过k趟扫描交换之后,待求解问题的有效规模将缩减至
n
−
k
n - k
n−k
时间复杂度
随着输入规模的扩大,算法的执行时间将如何增长?执行时间的这一变化趋势可表示为输入规模的一个函数,称作该算法的时间复杂度(time complexity)。具体地,特定算法处理规模为n的问题所需的时间可记作 T ( n ) T(n) T(n)。
大 O O O记号
同样地出于保守的估计,我们首先关注
T
(
n
)
T(n)
T(n)的渐进上界。为此可引入所谓“大
O
O
O记号”(big-O notation)。具体地,若存在正的常数
c
c
c和函数
f
(
n
)
f(n)
f(n),使得对任何
n
>
>
2
n >> 2
n>>2,都有
T
(
n
)
<
=
c
∗
f
(
n
)
T(n) <= c * f(n)
T(n)<=c∗f(n)。则可认为在
n
n
n足够大之后,
f
(
n
)
f(n)
f(n)给出了
T
(
n
)
T(n)
T(n)增长速度的一个渐进上界。此时,记之为:
T
(
n
)
=
O
(
f
(
n
)
)
T(n)=O(f(n))
T(n)=O(f(n))
由这一定义,可导出大
O
O
O记号的以下性质:
- 对于任一常数 c > 0 c > 0 c>0,有 O ( f ( n ) ) = O ( c ∗ f ( n ) ) O(f(n)) = O(c * f(n)) O(f(n))=O(c∗f(n))
- 对于任意常数 a > b > 0 a > b > 0 a>b>0,有 O ( n a + n b ) = O ( n a ) O(n^a + n^b )=O(n^a ) O(na+nb)=O(na)
前一性质意味着,在大 O O O记号的意义下,函数各项正的常系数可以忽略并等同于1。后一性质则意味着,多项式中的低次项均可忽略,只需保留最高次项。可以看出,大O记号的这些性质的确体现了对函数总体渐进增长趋势的关注和刻画。
以大 O O O记号形式表示的时间复杂度,实质上是对算法执行时间的一种保守估计,称作最坏实例或最坏情况。
起泡排序的时间复杂度
bubblesort1A()算法由内、外两层循环组成。内循环从前向后,依次比较各对相邻元素,如有必要则将其交换。故在每一轮内循环中,需要扫描和比较n - 1对元素,至多需要交换n - 1对元素。元素的比较和交换,都属于基本操作,故每一轮内循环至多需要执行2(n - 1)次基本操作。另外,外循环至多执行n - 1轮。因此,总共需要执行的基本操作不会超过 2 ( n − 1 ) 2 2(n - 1)^2 2(n−1)2次。若以此来度量该算法的时间复杂度,则有 T ( n ) = O ( 2 ( n − 1 ) 2 ) T(n)=O(2(n-1)^2) T(n)=O(2(n−1)2)
根据大
O
O
O记号的性质,可进一步简化和整理为:
T
(
n
)
=
O
(
n
2
)
T(n) = O(n^2)
T(n)=O(n2)
复杂度分析
常数时间复杂度 O ( 1 ) O(1) O(1)
一般地,仅含一次或常数次基本操作的算法均属此类。此类算法通常不含循环、分支、子程序调用等。
对数时间复杂度 O ( l o g n ) O(logn) O(logn)
考查如下问题:对于任意非负整数,统计其二进制展开中,数位1的总数。
根据右移运算的性质,每右移一位,n都至少缩减一半(n是输入的十进制整数)。也就是说,至多经过 1 + l o g 2 n 1 + log_2n 1+log2n次循环,n必然缩减至0,从而算法终止。实际上从另一角度来看, 1 + l o g 2 n 1 + log_2n 1+log2n恰为n二进制展开的总位数,每次循环都将其右移一位,总的循环次数自然也应是 1 + l o g 2 n 1 + log_2n 1+log2n。
无论是该循环体之前、之内还是之后,均只涉及常数次(逻辑判断、位与运算、加法、右移等)基本操作。因此,countOnes()算法的执行时间主要由循环的次数决定,亦即:
O
(
1
+
l
o
g
2
n
)
=
O
(
l
o
g
2
n
)
O(1 + log_2n)=O(log_2n)
O(1+log2n)=O(log2n)
由大
O
O
O记号定义,在用函数
l
o
g
r
n
log_rn
logrn界定渐进复杂度时,常底数
r
r
r的具体取值无所谓,故通常不予专门标出而笼统地记作
O
(
l
o
g
n
)
O(logn)
O(logn)。
线性时间复杂度 O ( n ) O(n) O(n)
对于输入的每一单元,此类算法平均消耗常数时间。就大多数问题而言,在对输入的每一单元均至少访问一次之前,不可能得出解答。以数组求和为例,在尚未得知每一元素的具体数值之前,绝不可能确定其总和。
递归
以数组求和问题为例。易见,若n = 0则总和必为0,这也是最终的平凡情况;否则一般地,总和可理解为前n - 1个整数
(
A
[
0
,
n
−
1
)
)
(A[0, n - 1))
(A[0,n−1))之和,再加上末元素
(
A
[
n
−
1
]
)
(A[n - 1])
(A[n−1])。
按这一思路,可基于线性递归模式,设计出另一sum()算法如下图所示。
由此实例,可以看出保证递归算法有穷性的基本技巧:
首先判断并处理n = 0之类的平凡情况,以免因无限递归而导致系统溢出。这类平凡情况统称“递归基”(base case of recursion)。平凡情况可能有多种,但至少要有一种(比如此处),且迟早必然会出现。
线性递归的模式,往往对应于所谓减而治之(decrease-and-conquer)的算法策略:
递归每深入一层,待求解问题的规模都缩减一个常数,直至最终蜕化为平凡的小(简单)问题。
按照减而治之策略,此处随着递归的深入,调用参数将单调地线性递减。因此无论最初输入的n有多大,递归调用的总次数都是有限的,故算法的执行迟早会终止,即满足有穷性。当抵达递归基时,算法将执行非递归的计算(这里是返回0)。
为保证有穷性,递归算法都必须设置递归基,且确保总能执行到。为此,针对每一类可能出现的平凡情况,都需设置对应的递归基,故同一算法的递归基可能(显式或隐式地)不止一个。
递归算法所消耗的空间量主要取决于递归深度
递归要保证子问题与原问题在接口形式上的一致
结语
如果您有修改意见或问题,欢迎留言或者通过邮箱和我联系。
手打很辛苦,如果我的文章对您有帮助,转载请注明出处。