算法分析:以异序词检测为例详解复杂度推导
作者:高玉涵
时间:2025.07.18 11:07
博客:blog.csdn.net/cg_i
语言:Python
一、何谓算法分析
对于刚接触计算机科学的同学而言,常常会将自己的程序与他人的进行比较。不难发现,计算机程序,特别是简单程序,往往在外观上极为相似。这就引发了一个有趣的问题:当两个看似不同的程序解决同一个问题时,它们是否存在优劣之分呢?
要回答这个问题,首先需要明确算法和实现它的程序有着本质区别。算法是为逐步解决问题而设计的一系列通用指令,它是解决某一类问题任一实例的方法。给定特定输入,算法能够产生对应的输出结果。而程序则是用某种编程语言对算法进行编码实现。由于不同的程序员和编程语言的存在,同一个算法可以对应多个不同的程序。
算法分析主要关注的是基于所使用的计算机资源对算法进行比较。我们说甲算法优于乙算法,依据是甲算法能更高效地利用资源或使用更少的资源。
这里存在一个关键问题:计算资源究竟指的是什么?可以从两个角度进行思考。一方面,考虑算法在解决问题时需要占用的空间或内存。一般来说,解决方案所需的空间总量由问题实例本身决定,例如字符串的长度。但有些算法也会产生特定的空间需求,如使用辅助数组或递归栈等。
另一方面,可以根据算法执行所需的时间进行分析和比较,这个指标有时被称为算法的执行时间或运行时间。然而,由于它依赖于特定的计算机、程序、运行时间、编译器以及编程语言,因此并不能为我们提供一种理想的衡量方式。我们期望找到一种独立于程序或计算机的衡量指标,这样能够帮助我们仅关注算法本身的特性,并且可以对不同实现下的算法进行比较。
二、大 O 记法:算法效率的通用语言
当我们试图摆脱程序或计算机的影响来描述算法的效率时,对算法的操作或步骤进行量化就显得尤为重要。如果将每一个步骤视为基本计算单位,那么算法的执行时间就可以表示为解决问题所需的步骤数。确定合适的基本计算单位较为复杂,且依赖于算法的具体实现。
函数 T ( n ) T(n) T(n),其中参数 n n n 通常被称为问题规模,该函数可理解为:“当问题规模为 n n n 时,解决问题所需的时间是 T ( n ) T(n) T(n)”。
计算机科学家进一步深入分析,发现精确的步骤并非在 T ( n ) T(n) T(n) 函数中起主导作用。也就是说,随着问题规模 n n n 的增长, T ( n ) T(n) T(n) 函数的某一部分会比其余部分增长得更快,而起主导作用的部分最终会被用于比较算法效率。数量级函数描述了当 n n n 增长时, T ( n ) T(n) T(n) 增长最快的部分。**数量级(order of magnitude)**通常被称为 大 O 记法(“O”代表 order),记作 O ( f ( n ) ) O(f(n)) O(f(n))。它为计算所需实际步骤数提供了一个近似值,其中 f ( n ) f(n) f(n) 函数为 T ( n ) T(n) T(n) 函数中起主导作用的部分提供了简洁的表示。
举个例子,假设某算法的步骤数是 T ( n ) = 5 n 2 + 27 n + 1005 T(n)=5n^2+27n+1005 T(n)=5n2+27n+1005。当 n n n 很小时,例如 n = 1 n=1 n=1 或 n = 2 n=2 n=2,常数 1005 似乎是该函数中起主导作用的部分。然而,随着 n n n 的增大, n 2 n^2 n2 项变得愈发重要。实际上,当 n n n 足够大时,系数 5 的作用也不再显著。因此,可以说函数 $T(n) $的数量级是 f ( n ) = n 2 f(n)=n^2 f(n)=n2 或者表示为 O ( n 2 ) O(n^2) O(n2)。
算法的性能有时不仅取决于问题规模,还与数据值有关。对于这类算法,我们需要使用最坏情况、最好情况和平均情况来描述其性能。最坏情况指的是存在某个数据集会使算法的性能极差;相反,另一个数据集可能会使同一个算法的性能极佳(最好情况)。在大多数情况下,算法的性能介于这两个极端之间(平均情况)。计算机科学家需要理解这些区别,以免被某个特例误导。
三、异序词检测:清点法的深度拆解
如果一个字符串只是重排了另一个字符串的字符,那么这个字符串就是另一个的异序词(Anagram),比如 heart
与 earth
,以及 python
与 typhon
。为了简单一些,我们假设要检查的两个字符串长度相同,并且都是由 26 个英文字母的小写形式组成。我们的目标是编写一个能接收两个字符串并判断它们是否为异序词的布尔函数。
3.1 算法逻辑:暴力匹配的思路
- 检查长度:不同则直接排除;
- 把
s2
转成列表char_list
,方便标记匹配的字符。 - 遍历
s1
的每个字符(用i
索引),每次都要在char_list
(s2
的列表副本)里线性查找匹配字符:- 找到就把
char_list
里对应的位置标记为None
(避免重复匹配)。 - 若未找到,返回 False(not anagram)。
- 找到就把
3.2 代码与执行流程(以 s1=heart、s2=earth 为例)
def anagram_solution_1(s1, s2):
if len(s1) != len(s2): # 长度不同直接排除
return False
char_list = list(s2) # 转为列表以便标记
i = 0 # 外层循环索引(遍历s1)
while i < len(s1):
j = 0 # 内层循环索引(遍历char_list)
found = False
# 在char_list中线性查找s1[i]
while j < len(char_list) and not found:
if s1[i] == char_list[j]:
found = True
char_list[j] = None # 标记为已匹配
else:
j += 1
if not found: # 字符未找到
return False
i += 1
return True
3.3 逐步分析代码执行过程
3.3.1 初始条件检查
if len(s1) != len(s2):
return False
- 执行:
len(s1) = 5
,len(s2) = 5
,长度相等,继续执行。
3.3.2. 初始化变量
char_list = list(s2) # ['e', 'a', 'r', 't', 'h']
i = 0
- 执行:将
s2
转换为列表char_list
,便于后续标记已匹配的字符。
3.3.3. 外层循环(遍历s1
的每个字符)
while i < len(s1) :
# 内层循环代码...
i += 1
- 执行:
i
从0
到4
,共 5 次循环。
3.3.4. 内层循环(在char_list
中查找当前字符)
j = 0
found = False
while j < len(char_list) and not found:
if s1[i] == char_list[j]:
found = True
char_list[j] = None
else:
j += 1
- 执行:对
s1
的每个字符,若找到匹配,将char_list
对应位置设为None
(避免重复匹配)。
3.3.5. 如果字符未找到
if not found:
return False
i += 1
- 执行:在每次内层循环结束后,如果
found
为False
,则直接返回False
,否则继续匹配下一个字符。
3.4 逐步执行过程
第 1 轮外层循环(i = 0
,处理 s1[0] = 'h'
)
-
内层循环:在
char_list = ['e', 'a', 'r', 't', 'h']
中查找'h'
。j = 0
:char_list[0] = 'e'
≠'h'
→j = 1
j = 1
:char_list[1] = 'a'
≠'h'
→j = 2
j = 2
:char_list[2] = 'r'
≠'h'
→j = 3
j = 3
:char_list[3] = 't'
≠'h'
→j = 4
j = 4
:char_list[4] = 'h'
=='h'
→found = True
-
标记操作:
char_list[4] = None
,char_list
变为['e', 'a', 'r', 't', None]
。 -
更新:
i = 1
。
第 2 轮外层循环(i = 1
,处理 s1[1] = 'e'
)
-
内层循环:在
char_list = ['e', 'a', 'r', 't', None]
中查找'e'
。j = 0
:char_list[0] = 'e'
=='e'
→found = True
-
标记操作:
char_list[0] = None
,char_list
变为[None, 'a', 'r', 't', None]
。 -
更新:
i = 2
。
第 3 轮外层循环(i = 2
,处理 s1[2] = 'a'
)
-
内层循环:在
char_list = [None, 'a', 'r', 't', None]
中查找'a'
。j = 0
:char_list[0] = None
≠'a'
→j = 1
j = 1
:char_list[1] = 'a'
=='a'
→found = True
-
标记操作:
char_list[1] = None
,char_list
变为[None, None, 'r', 't', None]
。 -
更新:
i = 3
。
第 4 轮外层循环(i = 3
,处理 s1[3] = 'r'
)
- 内层循环 :在
char_list = [None, None, 'r', 't', None]
中查找'r'
。j = 0
:char_list[0] = None
≠'r'
→j = 1
j = 1
:char_list[1] = None
≠'r'
→j = 2
j = 2
:char_list[2] = 'r'
=='r'
→found = True
- 标记操作:
char_list[2] = None
,char_list
变为[None, None, None, 't', None]
。 - 更新:
i = 4
。
第 5 轮外层循环(i = 4
,处理 s1[4] = 't'
)
-
内层循环:在
char_list = [None, None, None, 't', None]
中查找't'
。j = 0
:char_list[0] = None
≠'t'
→j = 1
j = 1
:char_list[1] = None
≠'t'
→j = 2
j = 2
:char_list[2] = None
≠'t'
→j = 3
j = 3
:char_list[3] = 't'
=='t'
→found = True
-
标记操作:
char_list[3] = None
,char_list
变为[None, None, None, None, None]
。 -
更新:
i = 5
。
最终结果
- 退出条件:
i = 5
不满足i < len(s1)
,退出外层循环。 - 返回值:所有字符均匹配成功,返回
True
,说明s1
和s2
是变位词。
四、复杂度分析
在对该算法进行分析时,需要注意对于 s1
中的 n
个字符,检查每一个字符时都需要遍历 s2
中的 n
个字符。为了匹配 s1
中的一个字符,需要访问 s2
列表中的
n
n
n 个位置。总访问次数是
n
+
(
n
−
1
)
+
⋯
+
1
n+(n−1)+⋯+1
n+(n−1)+⋯+1,这本质上是一个首项为 1
、末项为 n
、项数为 n
的等差数列求和。
4.1 什么是 “等差数列” ?
简单来说,相邻两项的差相等 的数列就是等差数列。例如,上述代码中的总次数 1 , 2 , 3 , … , n 1, 2, 3,\dots , n 1,2,3,…,n(倒过来 n , n − 1 , … , 1 n, n - 1, \dots, 1 n,n−1,…,1 也是,差均为 1 1 1),就是典型的等差数列,具有以下特征:
- 首项 a 1 a₁ a1:数列第一个数(这里是 1 1 1 ,或者倒过来的 n n n ,不影响求和)。
- 末项 a n aₙ an:数列最后一个数(这里是 n n n )。
- 公差 d d d:相邻两项的差(这里 d = 1 d=1 d=1 ,因为 2 − 1 = 1 2-1=1 2−1=1, 3 − 2 = 1 … 3-2=1 \dots 3−2=1… )。
- 项数 n n n:数列里有多少个数(这里刚好是 n n n 项,因为从 1 1 1 到 n n n 共 n n n 个数 )。
4.2 等差数列求和公式推导
想求 1 + 2 + 3 + ⋯ + n 1 + 2 + 3 + \dots + n 1+2+3+⋯+n 的和,可采用 “配对思路”(经典数学技巧),核心是通过 “正序数列与倒序数列逐项配对”,将复杂的累加运算转化为简单的乘法运算。以下结合具体例子和图示,详细解释这一思路:
4.2.1 核心问题:求连续整数的和
我们需要计算的是 “从 1 到 n 的连续整数之和”,即:
S = 1 + 2 + 3 + ⋯ + ( n − 1 ) + n S = 1 + 2 + 3 + \dots + (n - 1) + n S=1+2+3+⋯+(n−1)+n
当 n n n 较小时(如 n = 5 n=5 n=5),可以直接累加: 1 + 2 + 3 + 4 + 5 = 15 1+2+3+4+5=15 1+2+3+4+5=15。但当 n n n 很大时(如 n = 10000 n=10000 n=10000),直接累加繁琐且低效。“配对思路” 的价值在于:用数学技巧简化计算,推导出通用公式。
4.2.2 配对思路的具体步骤
1. 构造 “正序数列” 和 “倒序数列”
- 正序数列:按自然顺序排列的数列,即我们要求和的目标数列:
S = 1 + 2 + 3 + ⋯ + ( n − 1 ) + n S=1+2+3+⋯+(n−1)+n S=1+2+3+⋯+(n−1)+n
- 倒序数列:将正序数列反转后得到的数列(各项顺序颠倒,但数值不变):
S = n + ( n − 1 ) + ( n − 2 ) + ⋯ + 2 + 1 S=n+(n−1)+(n−2)+⋯+2+1 S=n+(n−1)+(n−2)+⋯+2+1
注意:倒序数列的和与正序数列的和完全相同,都等于 S S S。
2. 逐项配对,计算 “和的两倍”
将正序数列与倒序数列逐项相加,得到一个新的数列:
3. 发现规律:每对的和相等
观察配对后的每一项,会发现惊人的规律:
- 第 1 对:
1 + n
(正序第 1 项 + 倒序第 1 项) - 第 2 对:
2 + (n-1)
(正序第 2 项 + 倒序第 2 项) - 第 3 对:
3 + (n-2)
(正序第 3 项 + 倒序第 3 项) - …
- 第 n 对:
n + 1
(正序第 n 项 + 倒序第 n 项)
每一对的和都是 n + 1
例如,当 n=5
时:
- 正序:
1 + 2 + 3 + 4 + 5
- 倒序:
5 + 4 + 3 + 2 + 1
- 配对相加:
(1+5) + (2+4) + (3+3) + (4+2) + (5+1) = 6 + 6 + 6 + 6 + 6
4. 计算总项数,推导公式
由于正序数列和倒序数列各有 n
项,配对后共有 n
对,且每对的和都是 n + 1
。因此:
2 S = n × ( n + 1 ) 2S = n \times (n + 1) 2S=n×(n+1)
这里的 S 代表 “ 1 1 1 到 n n n 的整数和”(比如 S = 1 + 2 + 3 + ⋯ + n S = 1 + 2 + 3 + \dots + n S=1+2+3+⋯+n),而等式表示:“ 1 1 1 到 n n n 的整数和的 2 倍 等于 n 个 ( n + 1 ) (n + 1) (n+1) 相加” 。
两边同时除以 2,即可得到 “1 到 n 的整数和” 公式:
S = n × ( n + 1 ) 2 S = \frac{n \times (n+1)} {2} S=2n×(n+1)
“配对思路” 的核心是利用对称性抵消数列的 “递增” 特性,将 “复杂的累加” 转化为 “简单的乘法”。通过正序与倒序的对称配对,让每一对的和相等,从而用 “项数 × 单对和” 快速计算总和,避免逐项累加的繁琐。
5.为什么要 “两边同时除以 2”?
我们的目标是单独求出 S S S(即 “ 1 1 1 到 n n n 的整数和” ),但当前等式里 S S S 前面有系数 2(也就是 2 × S 2 \times S 2×S )。
根据等式的基本性质:
等式两边同时除以同一个非零数,等式仍然成立。
因为 2 ≠ 0 2 \neq 0 2=0,所以我们可以在等式两边同时除以 2 2 2,把 S 的系数变成 1 1 1,从而单独解出 S S S 。
6.实际推导过程
原等式:
2 S = n × ( n + 1 ) 2S=n×(n+1) 2S=n×(n+1)
两边同时除以 2: 2 S 2 = n × ( n + 1 ) 2 \frac{2S}{2} = \frac{n \times (n + 1)}{2} 22S=2n×(n+1)
左边约分后, 2 S 2 = S \frac{2S}{2} = S 22S=S ,所以最终得到: S = n × ( n + 1 ) 2 S = \frac{n \times (n + 1)}{2} S=2n×(n+1)
7.结合例子理解
假设 n = 5 n = 5 n=5(即求 1 + 2 + 3 + 4 + 5 1 + 2 + 3 + 4 + 5 1+2+3+4+5 的和 ):
- 根据公式, S = 5 × ( 5 + 1 ) 2 = 30 2 = 15 S = \frac{5 \times (5 + 1)}{2} = \frac{30}{2} = 15 S=25×(5+1)=230=15 ,而实际累加和也是 15 ( 1 + 2 + 3 + 4 + 5 = 15 15 (1 + 2 + 3 + 4 + 5 = 15 15(1+2+3+4+5=15 )。
推导过程中,“两边同时除以 2 2 2” 就是为了把 2 S 2S 2S 里的系数消掉,直接算出 S S S 的值,这样就能得到通用的 “ 1 1 1 到 n n n 的整数和” 公式。
五、 回到代码场景理解
在异序词检测的代码中,嵌套循环的总执行次数是 1 + 2 + 3 + . . . + n 1 + 2 + 3 + ... + n 1+2+3+...+n,正好对应这一等差数列求和问题。通过配对思路推导出的公式 S = ( n + 1 ) 2 S = \frac{(n+1)}{2} S=2(n+1)。
5.1 对 n ( n + 1 ) 2 \frac{n(n + 1)}{2} 2n(n+1)进行展开化简
根据乘法分配律
a
(
b
+
c
)
=
a
b
+
a
c
a(b+c)=ab + ac
a(b+c)=ab+ac ,对
n
(
n
+
1
)
2
\frac{n(n + 1)}{2}
2n(n+1) 进行展开:
n
(
n
+
1
)
2
=
n
×
n
+
n
×
1
2
=
n
2
+
n
2
=
1
2
n
2
+
1
2
n
\frac{n(n+1)}{2}=\frac{n \times n + n \times 1}{2} =\frac{n^2 + n}{2} =\frac{1}{2}n^2+\frac{1}{2}n
2n(n+1)=2n×n+n×1=2n2+n=21n2+21n
能帮我们快速确定算法的时间复杂度为
O
(
n
2
)
O(n^2)
O(n2)(二次项主导)。
六、 算法复杂度总结
在大 O O O 记法中,我们只关注随着问题规模增长起主导作用的部分。当 n n n 足够大时,主导部分是 n 2 n^2 n2,因此该算法的时间复杂度为 O ( n 2 ) O(n^2) O(n2)。这意味着随着输入字符串长度的增加,算法的执行时间将呈平方级增长。
此外,该算法在执行过程中使用了一个额外的列表 char_list
来存储 s2
的字符副本,其空间复杂度为
O
(
n
)
O(n)
O(n),因为需要额外的空间来存储与输入字符串长度相同的列表。
综上所述,该异序词检测算法的时间复杂度为 O ( n 2 ) O(n^2) O(n2),空间复杂度为 O ( n ) O(n) O(n)。在实际应用中,我们可以考虑更高效的算法来降低时间复杂度,例如使用哈希表来统计字符出现的次数,将时间复杂度优化到 O ( n ) O(n) O(n)。