引言
本章将介绍算法及其在现代计算系统中的作用。此外,本章还引入渐近符号的概念,用于分析函数的增长情况,特别是描述算法运行时间的函数。我们首先简要说明常用的渐近符号,并通过示例说明其应用。随后,我们将给出各种渐近符号的正式定义,并规范其正确使用方法。
章节结构
本章涵盖以下主题:
- 算法介绍
- 算法复杂度
- 渐近符号
- 程序分析的一般规则
- 递推关系
目标
算法与程序性能的目标涵盖提升计算过程效率和效果的一系列关键方向。其中一个主要目标是优化计算效率,从而加快处理速度。这包括开发能够更快速、高效执行任务的算法和程序。同时,优化内存和硬件的使用以减少资源消耗也至关重要,确保计算资源得到合理利用。加速程序任务执行,提高操作的灵活性和响应速度,也是重点之一,从而提升整体系统的响应能力。此外,算法设计还需考虑处理更大规模数据集和更高计算需求的能力,以适应现代应用和系统日益增长的需求。最终,这些目标共同促进提升用户满意度,带来更为高效和灵敏的计算体验。
算法介绍
算法是一组结构化的步骤或方法,用于解决问题或完成任务。它由一套明确的规则或指令组成,规定了一系列操作,使计算机能够执行特定任务或计算。算法是计算机科学的基础,在数据处理、软件开发、人工智能及计算问题求解等多个领域有着广泛应用。
本质上,算法为任务执行提供了路线图,给出一套明确的指导方针,能够被遵循以达成特定目标。算法的设计与评估是理解计算过程及复杂性理论的关键,同时保证计算资源的有效利用。优秀的算法设计对软件系统及计算任务的性能、效率和可靠性有显著影响。
算法定义
算法定义为:一组明确无歧义的指令序列,用于解决问题,即对于任意合法输入,在有限时间内得到所需输出。
该定义可通过图1.1加以说明:
算法在多个领域中发挥着关键作用,并广泛应用于各种场景,具体包括:
- 在计算机科学中,算法是从简单的排序、查找到复杂功能(如人工智能(AI)和机器学习(ML))的基础构件。
- 在数学领域,算法对于解决问题至关重要,例如求解线性方程组的最优解或在图中寻找最短路径。
- 在运筹学中,算法有助于优化运输、物流和资源分配的决策流程。
- 在人工智能领域,算法构成了AI和ML的核心,支持构建能够执行图像识别、自然语言处理和决策制定等任务的智能系统。
- 在数据科学中,算法是分析、处理大规模数据集并提取洞见的不可或缺工具,广泛应用于市场营销、金融和医疗等行业。
- 在加密货币和区块链领域,算法确保交易安全并维护区块链网络的完整性。
- 在网络路由中,算法决定数据包通过网络的最优路径,保障数据传输的快速与可靠。
- 在安全系统中,算法用于加密敏感数据、保障通信安全以及防御网络威胁和未经授权的访问。
- 在推荐系统中,算法分析用户偏好,提供个性化的产品、服务和内容推荐。
这些实例强调了算法应用的多样性。随着技术和各领域的不断发展,算法的作用依然不可替代,彰显其在现代社会中的重要地位。
我们以准备一顿饭为例,这个简单的算法为烹饪多种菜肴提供了基础框架。具体步骤可能根据菜谱的复杂程度和烹饪方法有所增减。
算法1.1:烹饪食物的算法
步骤如下:
- 收集所有必要的食材和烹饪用具。
- 清理并整理烹饪区域。
- 根据菜谱测量并准备所需食材。
- 确保食材被正确清洗、去皮并根据需要切割。
- 将烹饪设备(如炉灶、烤箱)加热至适宜温度。
- 按照菜谱说明,按正确顺序将食材混合。
- 烹饪过程中定时观察,防止过熟或烧焦。
- 如有必要,调整火候以维持理想烹饪温度。
- 加入调味料和香料,丰富菜肴风味。
- 通过品尝对味道进行微调。
- 将烹饪好的食物装盘。
- 讲究摆盘,使其外观赏心悦目。
- 趁热将菜肴端上餐桌。
- 与家人、朋友或独自享用美餐。
同样,算法帮助编程中执行任务以达成预期输出。所设计的算法不依赖特定编程语言,而是由简单明确的指令组成,可用任何语言实现,保证预期结果的一致性。
算法的必要性
让我们假设有两个孩子,Richard 和 Henry,试图还原魔方。Richard 有一套固定的步骤方法可以在限定步数内完成还原;而 Henry 自信自己能还原,但不知道具体步骤。结果,Richard 仅用两分钟成功还原,而 Henry 整整折腾了一天,最终勉强完成(尽管可能用了不合规的方法)。这个例子说明,拥有一个明确的步骤或算法能显著加快解决问题的效率,比没有算法盲目尝试要高效得多。因此,算法的重要性显而易见。
算法的特征
正如做菜时不一定照着食谱上的文字说明操作,而是按照常规步骤进行,编程中并非所有书面指令都能称为算法。要成为算法,指令必须具备以下特征:
- 清晰与精确:算法必须完全无歧义,每一步都应明确且唯一解释。
- 明确定义的输入:算法所需的输入必须明确,可以有零个或多个输入。
- 明确定义的输出:算法应明确指出产生的输出,且至少包含一个结果。
- 有限性:算法必须是有限的,即经过有限步数后终止。
- 可行性:算法应实用、通用且简单,能在现有资源下执行,不依赖未来技术或其它不切实际条件。
- 语言无关性:算法设计不能依赖具体编程语言,应由简单明了的步骤组成,可用任何语言实现,输出结果保持一致。
- 输入处理:算法可以有零个或多个输入,且包含基本运算的指令可接受零个或多个输入。
- 输出产生:算法应至少产生一个输出,每条含基本运算的指令可接受零个或多个输入。
- 确定性:算法中所有指令必须无歧义、精确且易懂,查阅任何指令都应明确其含义和所需操作。
- 有限终止:算法应在所有测试用例中有限步数内结束,基本运算指令须在有限时间完成。无限循环或无基准条件的递归不符合有限性要求。
- 有效性:算法应基于基本、简单且实用的操作构建,且可仅用纸和笔完成追踪验证。
算法设计
由于算法不依赖具体编程语言,它用来描述解决问题的逻辑步骤。但在设计算法之前,应考虑以下要点:
- 清晰与精确:算法步骤必须明确无歧义。
- 输入灵活性:算法可以接受零个或多个明确定义的输入。
- 输出一致性:应产生一个或多个明确定义且符合期望的输出。
- 终止性:算法必须在特定步数后结束。
- 有限性:算法应在有限步数内达到终点。
- 语言中立:算法步骤应不依赖任何具体计算机语言。
下面给出几个算法示例:
算法1.2:设计一个算法,实现两个数相乘并输出结果。
- 开始
- 明确输入需求。需要三个变量:a 和 b 为用户输入,c 用于存储结果。
- 定义变量 a、b、c。a 和 b 接收用户输入,c 用于存储结果。
- 从用户获取变量 a 和 b 的值。
- 计算 c = a * b。
- 输出结果 c。
- 结束
算法1.3:设计一个算法,找出数组中所有元素的最大值。
- 开始
- 定义变量 max,并赋值为数组第一个元素。
- 使用循环将 max 与数组剩余元素依次比较。
- 若 max < 当前元素,则将 max 更新为当前元素值。
- 若无剩余元素,则返回 max,否则回到步骤3。
- 结束
算法1.4:设计一个算法,计算三个科目的平均分。
- 开始
- 读取三个科目的成绩,记为 S1、S2、S3。
- 计算三科总分 Sum = S1 + S2 + S3。
- 计算平均分 Average = Sum / 3。
- 输出平均分 Average。
- 结束
算法复杂度
算法复杂度指的是解决一个问题或执行一项任务所需的资源量,包括时间和内存。时间复杂度是一个广泛使用的度量,表示算法生成结果所花费的时间相对于输入规模的关系。内存复杂度则表示算法的内存消耗。算法设计者旨在构建时间和内存复杂度尽可能低的算法,以提高效率和可扩展性。
算法的复杂度
算法的复杂度是定义一个函数,用来描述算法在处理数据量时的效率。
分析算法时,通常评估其时间复杂度和空间复杂度。设计高效算法的目标是确保处理逻辑时消耗尽可能少的时间。
时间复杂度
确定算法解决问题所需时间时,需要评估循环次数、比较操作及相关因素。时间复杂度表示算法所需时间与输入规模的关系。这里的“时间”可以指内存访问次数、整数比较次数、内层循环操作次数,或任何与算法实际运行时间相关的单位。
空间复杂度
空间复杂度指算法在解决问题过程中使用的内存量。这包括必要的输入变量所占用的内存,以及算法使用的额外空间(不包括输入空间)。例如,如果使用了哈希表这样的数据结构,存储这些值的数组即作为辅助空间计入算法的空间复杂度。这部分额外空间称为辅助空间。空间复杂度是衡量算法所需内存量相对于输入规模的指标。尽管空间复杂度有时会被忽视(因为内存使用量较少或显而易见),但它同样可能成为与时间复杂度同等重要的问题。
程序的内存需求包括以下几个部分:
-
指令空间:存储程序编译后指令所需的空间。
-
数据空间:存储常量和变量值所需的内存。数据空间包括两部分:
- 程序中常量和简单变量占用的空间。
- 动态分配对象(如数组、类实例)占用的空间。
-
环境栈空间:环境栈用于保存部分执行函数的恢复信息。
指令空间大小取决于多种因素,包括:
- 用于将程序转换成机器码的编译器。
- 编译时所采用的编译器选项。
- 目标计算机的规格参数。
算法设计的主要目标包括最大化时间效率、节约空间以及保证可靠性。程序的优劣通常通过其执行速度体现,因此节省时间被视为核心目标。同样,优先考虑空间效率在同类程序中也极具价值。此外,确保程序稳定运行、避免系统卡死或数据损坏等问题,是维护良好声誉的重要因素。
算法的运行时间函数 f(n) 不仅依赖于输入数据的大小 n,还依赖于具体的数据内容。复杂度函数 f(n) 在不同情境下有不同的形式:
- 最优情况(Best case):f(n) 的最小可能值。
- 平均情况(Average case):f(n) 的期望值。
- 最坏情况(Worst case):对于任意输入,f(n) 的最大可能值。
算法分析
计算机科学中研究算法效率的领域称为算法分析。
算法可以通过不同的基准进行评估。通常,本章重点理解解决更大规模问题时所需时间或空间的增长情况。我们将问题的规模定义为一个整数,作为输入数据量的度量标准。
渐近符号
以下几种符号常用于性能分析中,用来描述算法的复杂度:
- 大O符号(Big–O,记作 O)
- 大Ω符号(Big–Omega,记作 Ω)
- θ符号(Theta,记作 θ)
大O符号(Big–O)
该符号表示函数的精确上界。通常可以写作 f(n) = O(g(n)),意思是在 n 取较大值时,函数 f(n) 的增长速度不会超过 g(n) 的增长速度。
举例来说,若算法的时间复杂度表示为 f(n) = n³ + 10n² + 5n + 10,则这里的 g(n) = n³。也就是说,当 n 趋近于无穷大时,n³ 是 f(n) 增长的最高阶项,决定了其增长速度的上限。
形式定义为:
O(g(n)) = { f(n) : 存在正常数 c 和 n₀,使得对所有 n ≥ n₀,有 0 ≤ f(n) ≤ c·g(n) }。
这里,g(n) 是 f(n) 的渐近紧上界(asymptotic tight upper bound)。
图 1.2 中的 n₀ 表示我们开始评估算法增长率的临界点,在此之前,函数增长率可能存在波动。
大Ω符号(Big–Omega,记作 Ω)
该符号表示函数的渐近下界,即给出算法复杂度的一个严格的下限,通常写作 f(n) = Ω(g(n))。这意味着当 n 趋近于较大值时,函数 f(n) 的增长速度至少与 g(n) 一样快。图 1.3 形象地展示了这一点。
举例来说,若 f(n) = 10n² + 8n + 5,则 g(n) = n² 是其渐近下界,即 f(n) = Ω(n²)。
形式定义为:
Ω(g(n)) = { f(n) : 存在正常数 c 和 n₀,使得对所有 n ≥ n₀,有 0 ≤ c·g(n) ≤ f(n) }。
这里,g(n) 是 f(n) 的渐近紧下界(asymptotic tight lower bound)。
θ符号(Theta,记作 θ)
θ符号用于描述函数的渐近紧界,即判断一个函数的上界和下界是否相同。当算法的平均运行时间始终处于上下界之间,且上界(O)和下界(Ω)一致时,θ符号表示函数的增长率就是这个共同的界限。
举例来说,若 f(n) = 5n + 4n,则其紧上界 g(n) = O(n)。在这种情况下,最佳情况的增长率 g(n) = O(n) 与最坏情况的增长率一致,因此平均情况的增长率也为 θ(n)。图 1.4 展示了 θ 符号的示意。
形式定义为:
θ(g(n)) = { f(n) : 存在正常数 C₁、C₂ 和 n₀,使得对所有 n ≥ n₀,有
C₁·g(n) ≤ f(n) ≤ C₂·g(n) }。
其中,g(n) 是 f(n) 的渐近紧界(asymptotic tight bound)。
算法的时间效率通常可以划分为少数几类。表1.1列出了这些复杂度类别,按照增长速度由低到高排列,并附有名称和描述:
| 类别编号 | 名称 | 描述 |
|---|---|---|
| 1 | 常数时间 | 程序中大部分指令执行次数固定,执行一次或几次,其运行时间被视为常数。 |
| log n | 对数时间 | 运行时间随着输入规模n增大而缓慢增长,通常见于通过将问题分割成较小部分解决的算法。比如,当n为100万时,log n 翻倍,且log n增长速度远小于n。 |
| n | 线性时间 | 每个输入元素通常只进行少量计算,适合需要处理n个输入的算法,被认为是理想的效率。 |
| n log n | 线性对数时间 | 通过将问题递归分割成子问题,分别解决后合并的算法,如归并排序,运行时间比线性增长快,但仍远优于多项式增长。 |
| n² | 平方时间 | 常见于对所有数据对进行处理的算法(如嵌套循环),适用于较小规模问题,n加倍时时间增加四倍。 |
| n³ | 立方时间 | 处理数据三元组的算法(如三层嵌套循环),仅适合较小问题,n加倍时时间增加八倍。 |
| 2ⁿ | 指数时间 | 典型的暴力破解算法,只有少数指数时间算法可实际应用,n加倍时时间增加四倍。 |
| n! | 阶乘时间 | 多见于生成n元素集合的所有排列组合的算法。 |
表1.1:基本的渐近效率类别
示例 1.1
考虑如下简短代码片段:
x = 3*y + 2;
z = z + 1;
这里,y和z是标量变量,该代码段的运行时间是固定的,记为 O(1)。具体耗时难以精确测量,但每次执行耗时恒定。
示例 1.2
程序1的运行时间为 100n² 毫秒,程序2为 5n³ 毫秒,哪一个更优?
虽然常数系数不同,但时间复杂度的比较通常忽略这些常数。比较两者:
5n3100n2=n20\frac{5n^3}{100n^2} = \frac{n}{20}
当 n < 20 时,程序2 (5n³) 的运行时间更短,适合处理小规模输入。但随着 n 增大,运行时间比值 n/20 逐渐变大,程序1(100n²)在大规模输入时更优。因此,优先选择增长率较低的算法(如 O(n) 或 O(n log n))能保证更好的性能。
示例 1.3
分析简单的 for 循环:
for (i = 1; i <= n; i++)
v[i] = v[i] + 1;
该循环执行了恰好 n 次,每次操作耗时常数,总运行时间与 n 成正比,记为 O(n)。无论具体执行时间是 17n 微秒还是 17n + 3 微秒,渐近表示仍为 O(n)。
示例 1.4
分析嵌套 for 循环:
for (i = 1; i <= n; i++)
for (j = 1; j <= n; j++)
a[i, j] = b[i, j] * x;
外层循环运行 n 次,内层循环每次也运行 n 次,总计执行 n * n = n² 次,且每次操作时间为常数,因此整体运行时间为 O(n²),属于平方时间复杂度。
示例 1.5
矩阵乘法示例:计算两个 n × n 矩阵 A 和 B 的乘积 C = A * B:
for (i = 1; i <= n; i++)
for (j = 1; j <= n; j++) {
C[i, j] = 0;
for (k = 1; k <= n; k++)
C[i, j] = C[i, j] + A[i, k] * B[k, j];
}
三层嵌套循环,每层循环执行 n 次,最内层语句为常数时间操作,故整体运行时间为 O(n³),属于立方时间复杂度。
程序分析的一般规则
通常,一条语句或一组语句的运行时间可以用输入规模和/或一个或多个变量来描述。对于整个程序的运行时间,唯一允许的参数是 n,表示输入的规模。
- 每条赋值语句、读写语句的运行时间通常可视为 O(1)。
- 语句序列的运行时间遵循加法法则,即序列的运行时间(在常数因子范围内)等于序列中运行时间最大的语句的运行时间。
- if 语句的运行时间包括条件判断的时间和条件成立时或不成立时执行语句的时间。条件判断通常是 O(1) 时间。对于 if-then-else 结构,运行时间等于条件判断时间加上条件为真时语句和条件为假时语句中较大时间的那个。
- 循环的运行时间是所有迭代执行体和终止条件判断的时间之和(终止条件判断通常为 O(1))。忽略常数因子时,循环时间通常是迭代次数乘以单次循环体最大执行时间。但需要针对每个循环单独分析以保证准确性。
递推关系
递推关系(Recurrence relation)是一个方程,它将数列 S 中除有限项外的所有项与前面的若干项 {a₀, a₁, a₂, ..., a_{n-1}} 联系起来,适用于所有 n ≥ n₀,其中 n₀ 为非负整数。递推关系也称为差分方程。
递推
递推是一种数学表达式或不等式,它通过较小输入对应函数值来刻画该函数。
数列通常用递推关系以最简单的方式定义,但直接用递推关系计算项往往耗时较长。通过递推关系推导数列通项的过程称为“求解递推关系”。求解递推关系的常用试探方法包括:
- 对输入做简化假设。
- 建立递推的初始值表。
- 识别模式并提出解。
- 推广结果,去除简化假设。
例如,阶乘、斐波那契数列、快速排序、二分查找等算法都可通过递推关系描述。
递推关系本质上是一个自引用定义的方程,没有单一方法可以解决所有递推关系。实际上,有些递推关系是不可解的。大多数常见递推关系为带常数系数的线性递推关系。常用的求解方法包括代换法、数学归纳法、特征根法和生成函数法。
解决递推关系的四种主要方法:
- 代换法(Substitution method)
- 迭代法(Iteration method)
- 递归树法(Recursion tree method)
- 主定理法(Master method)
代换法(Substitution method)
代换法包含两个步骤:
- 用符号常数预测解的结构形式。
- 利用数学归纳法验证该解的正确性并确定常数值。
通过将预期解代入较小输入规模的函数中,利用归纳假设,故称为代换法。该方法虽强大,但需要先猜测解的形式。尽管猜测合适形式较难,但通过练习可快速培养直觉。
示例 1.6
考虑递推关系:
目标是证明其渐近界为 。
解法:假设 T(n) = O(log n),需要证明存在常数 c,使得
将该假设代入递推式,验证不等式成立。
由此得出 。
示例 1.7
考虑递推关系:
目标是证明其渐近界为 。
解法:假设 ,需要证明存在常数 c,使得
将该假设代入递推式,验证不等式成立。
由此得出 。
示例 1.8
通过变量替换求解递推关系
假设
因此
我们有
递推关系转化为关于 S(m) 的形式,且已知 ,则代回原变量:
迭代法(Iteration method)
迭代法的核心思想是展开递推关系,将其表示为关于 n 和初始条件的项的和。
示例 1.9
求解递推关系:
展开:
取 ,则:
所以:
示例 1.10
利用二分查找的递推关系,求其时间复杂度:
展开:
因此,
选择 ,使得
因此,
则:
由 得到
因此,二分查找算法的时间复杂度为。
递归树法
递归树分析法用于解决递推关系。其核心是将递推关系转化为一系列递归树,每个节点表示递归中不同阶段累积的开销。通过求和各阶段开销,可得到总开销。使用递归树法解决递推关系的步骤如下:
- 画出对应递推关系的递归树。
- 计算每个阶段的开销,并确定递归树的总层数。
- 统计最后一层节点总数并计算最后一层的开销。
- 将所有层的开销相加,得到递归树的总开销。
示例 1.11:用递归树法求解递推关系 。
解:
- 画出递归生成的树(见图1.5)。
T(n)
/ \
T(n/2) T(n/2)
/ \ / \
T(n/4) T(n/4) T(n/4) T(n/4)
... ... ... ...
- 计算树每层的工作量,统计层数(见图1.6)。
n (cost c) 2^0 c
/ \
n/2 n/2 c + c = 2c 2^1 c
/ \ / \
n/4 n/4 n/4 n/4 c + c + c + c = 4c 2^2 c
... ... ... ...
- 选择最长路径,从根节点到叶节点。
- 最后一层问题规模为:
- 递归树总层数为 k+1 = n+1。
3. 统计最后一层节点数及开销。
- 第0层的节点数
- 第1层的节点数
- 第n层的节点数
- 第n层(最后一层)子问题的总开销
- 求和所有层开销:
上述数列是一个等比数列(Geometric Progression,简称 GP),所以:
因此,。
主方法(Master Method)
主方法是一种系统化策略,用于识别大量递推方程的渐近解,适用于形如:
的递推关系,其中, 为整数常数,且 为正数,函数 对所有 正值。主方法通过比较 与特定函数 来确定递推的解。
定理 1.1:给定函数 和递推 ,有三种情况:
- 情况1:若存在小常数 ,使得 ,则 。
- 情况2:若存在常数 ,使得 ,则 。
- 情况3:若存在小常数 和 ,使得 并且满足 对所有 ,则 。
情况1对应 比 多项式阶低的情况。
情况2对应 渐近等于 的情况。
情况3对应 多项式阶高于 的情况。
示例 1.12:求解递推关系
解:
这里 ,且 。
由于 ,其中 ,应用主方法情况1,解为:
示例 1.13:求解递推关系
解:
这里 , f(n)=1,且 。
由于 ,应用主方法情况2,解为:
总结
本章强调了优化计算效率、减少资源消耗和提升整体系统响应速度的重要性。通过优先考虑这些因素,可以实现更快的处理速度、缩短执行时间,并确保在处理大量数据和更高计算需求时系统依然稳定高效。这些努力有助于更有效地利用计算资源,最终提升程序性能和用户体验。
下一章将概述计算机科学和编程领域中常用的基本数据结构。