第 1 章 绪论
2022.9.8
这是数据结构第二次课,重点讲解了算法和算法分析部分的内容,其中最大子列和问题很重要。
1.3 算法和算法分析
算法
算法是对特定问题求解步骤的一种描述,是指令的有限序列,其中每一条指令表示一个或多个操作。
1️⃣ 算法具有五个特性:
- 有穷性:一个算法必须总是在执行有穷步之后结束,且每一步都在有穷时间内完成
- 确定性:算法中每一条指令必须有确切含义。不存在二义性。相同的输入必然有相同的输出。
- 可行性:一个算法是可行的。即算法描述的操作都是可以通过已经实现的基本运算执行有限次来实现。
- 输入:一个算法有零个或多个输入,这些输入取自于某个特定对象的集合。
- 输出:一个算法有一个或多个输出,这些输出是同输入有着某些特定关系的量
2️⃣ 算法与程序十分相似,又有区别:
- 程序不一定满足有穷性,例如操作系统启动后进入事件处理循环。
- 程序中的指令必须是机器可执行的,而算法无此限制。
- 算法代表了对问题的解,程序是算法在计算机上的特定实现。
- 算法可以用不同的方法来描述,如利于理解的自然语言、流程图、N-S 图等。
为了解决理解和执行之间的矛盾,常使用伪码(pseudo-code)语言描述。
伪码语言忽略程序设计语言中一些严格的语法规则和描述细节,比程序设计语言更易描述和理解。
3️⃣ 评价一个好的算法有以下几个标准:
- 正确性(Correctness):算法应满足具体问题的需求
- 可读性(Readability):算法应该易读。以有利于读者对程序的理解。
- 健壮性(Robustness):算法应具有容错处理。当输入非法数据时,算法应对其作出反应,而不是产生莫名其妙的输出结果。
- 效率与存储量需求:效率指的是算法执行的时间;存储量需求指算法执行过程中所需要的最大存储空间。一般这两者与问题的规模有关。
看一个例子:求解 1+2+3+...+100
// 算法一
int i, sum = 0, n = 100;
for (i = 1; i <= n; i++) {
sum = sum + 1;
}
// 算法二
int i, sum = 0, n = 100;
sum = (1 + n) * n / 2;
上面两个算法中显然算法二的效率比算法一高很多。不过听说还有更快的方法:
printf("5050");
🤪
算法的性能分析
一般从算法的计算时间与所需存储空间来评价一个算法的优劣。
其方法通常有两种:
-
事后统计:计算机内部进行执行时间和实际占用空间的统计。
这种方法的问题在于必须先运行依据算法编制的程序,依赖软硬件环境,容易掩盖算法本身的优劣,一般很难体现算法分析上的实际价值。
-
事前分析:求出该算法的一个时间 / 空间界限函数。
🕐 与计算时间相关的因素有:
- 算法选用何种策略
- 问题的规模
- 程序设计语言的选择(一般情况下,实现语言的级别越低,效率越高)
- 编译程序所产生的机器代码的质量
- 机器执行指令的速度
撇开软硬件等有关因素,可以认为一个特定算法“运行工作量”的大小,只依赖于问题的规模(通常用 n 表示)
或者说,它是问题规模的函数。
时间频度:一个算法中的原操作执行次数陈伟语句频度或时间频度。用 c 表示。
看下面计算时间频度的例子
// 算法一
int i, sum = 0, n = 100; // 1
for (i = 1; i < n; i++) // n + 1
{
sum = sum + i; // n
}
- 声明变量执行 1 次
- 循环条件判断执行 n + 1 次,因为在退出循环最后还有额外的一次自增
- 循环体执行 n 次
总共执行 2n + 2 次
// 算法二
int i, sum 0 n = 100; // 1
sum = (q + n) * n / 2; // 1
- 声明变量执行 1 次
- 计算 sum 执行 1 次
总共执行 2 次
// 算法三
int i, j, sum = 0, n = 100; // 1
for (i = 1; i <= n; i++) // n + 1
{
for (j = 1; j <= n; j++) // (n + 1) * n
{
sum = sum + j; // n * n
}
}
- 声明变量执行 1 次
- 外层循环判断执行 n + 1 次
- 内层循环判断执行 (n + 1) * n 次
- 循环体执行 n * n 次
总共执行 2n2 + 2n + 2 次
💥 通常一个算法不一定会在所有情况下都优于或次于另一个算法
因为不同的函数在不同的自变量下的函数值是不同的,增长有快慢之分。
比如下表中的第一和第四种算法在 n = 1 和 n = 100 两种规模下各有所长:
| 次数 | 4n+8 | n | 2n2+1 | n2 |
|---|---|---|---|---|
| n = 1 * | 12 | 1 | 3 | 1 |
| n = 2 | 16 | 2 | 9 | 4 |
| n = 3 | 20 | 3 | 19 | 9 |
| n = 10 | 48 | 10 | 201 | 100 |
| n = 100 * | 408 | 100 | 20001 | 10000 |
当规模不断增大时,最高阶数相同的算法差异越来越小。于是我们考虑只关注多项式最高次数的那一项,同时忽略系数不同,因为在规模足够大时差异很小。用最高阶多项式表示就是时间复杂度。
时间复杂度
想知道 变化规律。
若有某个辅助函数 是的当 趋近于无穷大时, 的极限值为不等于零的常数,则称 是 的同数量级函数(记作 ),称 为算法的渐进时间复杂度,简称时间复杂度。
例如,若 ,则有 ,故它的时间复杂度为 ,即 与 数量级相同。
一般 增长最慢的算法为最优算法。
✅ 以下六种计算算法时间的多项式是最常用的。其关系为:
指数时间的关系为:
当 n 取得很大时,指数时间算法和多项式时间算法在所需时间上非常悬殊。因此,只要有人能将现有指数时间算法中的任何一个算法化简为多项式时间算法,那就取得了一个伟大的成就。
⚠ 有的情况下,算法中基本操作重复执行的次数还随问题的输入数据集不同而不同。例如冒泡排序:
- 最好情况:已经顺序,扫描一趟后退出。最好时间复杂度为
- 最坏情况:完全逆序,每次都要交换。最坏时间复杂度为
- 平均情况:n 个元素组成的输入集可能有 n! 种排列情况,若各种情况等概率,则冒泡排序的平均时间复杂度为
算法的存储空间
🌊 空间复杂度:算法所需存储空间的度量,记作:
其中 n 为问题的规模(或大小)。
1️⃣ 算法的存储空间有三个方面:
- 输入数据所占空间
- 程序本身所占空间
- 辅助变量所占空间
2️⃣ 还可以分为:
- 固定部分:程序代码、常量、简单变量、定长的结构变量
- 可变部分:与问题规模有关的存储空间
当问题规模较大时,可变部分可能会远大于固定部分,所以一般讨论算法的渐进空间复杂度,分析方法和时间复杂度类似。
最大子列和问题
问题描述
求数组中最大连续子序列和。
教学中给出了四种算法,复杂度依次递减:
穷举法:
- 遍历子序列起点,n 种可能
- 遍历子序列终点,i ~ n 种可能
- 计算 i 到 j 的子列和,每趟执行 n 次
int MaxSubSequenceSum(const int A[], int N)
{
int ThisSum, MaxSum, i, j, k;
MaxSum = 0;
for (i = 0; i < N; i++) { // i为子序列起点,遍历所有的N个可能
for (j = 1; j < N; j++) { // j为子序列终点,遍历所有i~N个可能
ThisSum = 0;
for (k = i; k <= j; k++) { // 计算从i到j的子序列和
ThisSum += A[k];
if (ThisSum > MaxSum)
MaxSum = ThisSum;
}
}
}
}
穷举法-改进:
- 遍历起点,n 种可能
- 从起点开始遍历重点,每次用已经计算的上一个子列和
int MaxSubSequenceSum(const int A[], int N)
{
int ThisSum, MaxSum, i, j, k;
MaxSum = 0;
for (i = 0; i < N; i++) { // i为子序列起点,遍历所有的N个可能
ThisSum = 0;
for (j = i; j < N; j++) { // j为子序列终点,遍历所有i~N个可能
ThisSum += A[j]; // 计算从i到j的子序列和时利用已经计算出的从i到j-1的子序列和
if (ThisSum > MaxSum)
MaxSum = ThisSum;
}
}
}
}
递归-分治法:
- 把整个序列平均分成左右两部分,答案则会在以下三种情况中
- 所求序列完全包含在左半部分的序列中
- 所求序列完全包含在右半部分的序列中
- 所求序列刚好横跨分割点,即左右序列各占一部分(以分割点为起点向左的最大连续序列和 + 以分割点为起点向右的最大连续序列和
完美的联机算法:
- 假设
a[i]为负数,则a[i]不可能为此子序列的起点,同理,若a[i]到a[j]的子序列为负,则a[i]到a[j]不可能为子序列的起点,则可以从a[j+1]开始推进
int MaxSubSequenceSum(const int A[], int N)
{
int ThisSum, MaxSum, j;
ThisSum = MaxSum = 0;
for (j = 0; i < N; i++) {
ThisSum += A[j]; // 计算从i到j的子序列和时利用已经计算出的从i到j-1的子序列和
if (ThisSum > MaxSum)
MaxSum = ThisSum;
else if (ThisSum < 0)
ThisSum = 0;
}
}
这个问题的详细代码可以看 01-复杂度1 最大子列和问题 这篇题解。
1.4 补充 C/C++ 语言知识
需要用到的数据类型
在本课程中,数据的存储结构是用C语言的数据类型描述(定义)的,主要用到下列数据类型:
- 数组
- 指针
- 结构
- 结构指针
基本语言知识,不再赘述。
参数传递
- 值传递
函数在调用时是隐含地把参数 a, b 的值分别赋值给了 x, y 。之后在函数体内一直是对形参 x, y 进行操作。并没有对 a, b 进行任何操作。
- 指针 / 地址传递
函数在调用时是隐含地把参数 a, b 的地址分别传递给了指针 px, py 。之后在函数体内一直是对指针 px, py 进行操作。也就是对 a, b 的地址进行的操作。
- 引用传递
x、y 前都有一个 “&” 。有了这个,调用 Exchg3 时函数会将 a、b 分别代替了 x、y 了,我们称:x、y 分别引用了 a、b 变量。这样函数里操作的其实就是实参 a、b 本身了,因此函数的值可在函数里被修改
C++ 命名空间
随着项目规模的扩大,可能在不同的库中可能存在名字相同的函数、类、变量等。
为了解决这一问题,C++引入了命名空间(namespace)的概念,它可作为附加信息来区分不同库中相同名称的函数、类、变量等。
C++ 标准输入输出流
C++ 编译器根据要输入/输出值的数据类型,选择合适的流提取/流插入运算符来提取值/输出值。
1.5 本章知识点小结
- 数据结构
- 数据类型
- 抽象数据类型
- 数据结构的存储结构
- 算法的概念及特性
- 算法的设计要求
- 算法描述
- 算法的时间复杂度
- 算法的空间复杂度