1.1 算法基本概念与复杂度分析
算法是一种解决问题的具体方法。在这一章节中,我们将会了解到算法的基本概念,包括输入、输出、算法正确性、算法复杂度等。我们还将会学习如何分析算法的时间复杂度和空间复杂度,以便在实际应用中选择最优的算法。
什么是算法?
算法是一种解决问题的具体方法。我们可以将生活中的很多问题都看作一个算法。例如,制作一杯咖啡的过程就是一个算法:
- 准备一个空杯子。
- 将咖啡豆研磨成合适的粒度。
- 将咖啡粉放入咖啡机中。
- 加入足够的水。
- 启动咖啡机。
- 等待几分钟,直到咖啡机完成任务。
- 将咖啡倒入杯子中。
- 按需添加糖或牛奶。
- 享受你的咖啡。
这个过程就是一个算法,它有输入(空杯子、咖啡豆、水)、输出(一杯咖啡)、过程(研磨、冲泡)、正确性(研磨成合适的粒度,加入足够的水等)和复杂度(咖啡豆的研磨时间、水的加入量等)。同样,制作一个蛋糕、修理一辆车、打扫房间等都可以看作是一个算法,只是它们的 输入、输出、过程、正确性 和 复杂度 都不同而已。
初识复杂度
复杂度是一个用于衡量算法好坏的参考标准。就像我们评价一个人的诚信度,可以使用芝麻分作为参考一样。
那什么又算是好的算法呢,下面我们来看一张图:
从图中我们可以看出,蓝色的线随着数据规模的增加,程序运行时间并没有显著增加。而红色的线,随着数据规模增加,运行时间增长的非常快,以至于数据规模到达一定程度,程序的运行时间会变得不可接受。
你可能已经发现了,所谓的复杂度其实就是一个
时间复杂度和空间复杂度
复杂度分析中我们通常会讨论两种复杂度:
- 时间复杂度:程序的运行时间随数据规模变化的函数。
- 空间复杂度:程序占用的存储空间随数据规模变化的函数。
复杂度更优的算法,可以起到事半功倍的效果。而复杂度过高的算法,只能停留在理论层面,无法在真实世界中解决工程问题,毕竟如果做一次计算需要一万年,那没人会真正去运行这个程序。
在当今这个时代,计算机的存储成本已经极其低廉,所以我们更侧重关注时间复杂度,有时还会刻意的牺牲空间来换取时间。
复杂度的计算和表示方式
用一台20年前的计算机,和你现在正在用的电脑,运行同一个程序的话,你觉得哪台设备速度更快?显然,这没有可比性。所以复杂度的计算,需要排除硬件和环境的干扰,我们纯粹在理论层面去评判算法的优劣。
可以假设任何计算机的CPU运行一行代码需要使用1个单位时间(Unit Time),记作 1(ut) 。
那么我们看看下面这个函数:
int fun1(int n){return n + 1;
}
核心代码只有一行 return n + 1;
,我们粗略的将其运行时间记作 1(ut),随着输入参数n的变化,函数的运行时间永远都是 1(ut)。
接下来看看这个函数:
int fun2(int n){n /= 2;n = n * n * 3;return n + 5;
}
核心代码有三行 ,我们粗略的将其运行时间记作 3(ut),随着输入参数 n 的变化,函数的运行时间永远都是 3(ut)。
上面的两个例子中的算法,无论输入数据怎么变化,近似的计算时间永远是一个常数,我们把这种算法叫做常数阶。
习惯上我们用一种叫做”大O表示法的形式”,将上面两个程序的时间复杂度记为 O(1) 和 O(3)。
由于任何常数在无穷大面前都可以忽略不计,所以我们在表示常数阶时,一般不会保留具体的数字,而是全部简单的用数字 1 来代替,所以上面两个算法的复杂度都可以写为 O(1)。
下面以Java代码为例,列举常见的各种复杂度的示例算法。
- O(1) 常数阶函数:该函数的运行时间与输入规模无关。
public static int constantTime(int x, int y) {return x + y; // 该函数的运行时间不随输入规模而变化
}
- O(log n) 对数阶函数:该函数的运行时间随着输入规模的增长而增长,但增长缓慢。
public static int logarithmicTime(int n) {int count = 0;for (int i = 1; i < n; i *= 2) {count++;}return count; // 该函数的运行时间随输入规模的增长而增长,但增长缓慢
}
- O(n) 线性阶函数:该函数的运行时间随着输入规模的增长而线性增长。
public static int linearTime(int[] arr) {int sum = 0;for (int i = 0; i < arr.length; i++) {sum += arr[i];}return sum; // 该函数的运行时间随输入规模的增长而线性增长
}
- O(n log n) 线性对数阶函数:该函数的运行时间随着输入规模的增长略微超过线性增长。
public static int linearithmicTime(int[] arr) {Arrays.sort(arr);int sum = 0;for (int i = 0; i < arr.length; i++) {sum += arr[i];}return sum; // 该函数的运行时间随输入规模的增长略微超过线性增长
}
- O(n^2) 平方阶函数:该函数的运行时间随着输入规模的增长呈平方级增长。
public static int quadraticTime(int n) {int sum = 0;for (int i = 0; i < n; i++) {for (int j = 0; j < n; j++) {sum++;}}return sum; // 该函数的运行时间随输入规模的增长呈平方级增长
}
- O(n^3) 立方阶函数:该函数的运行时间随着输入规模的增长呈立方级增长。
public static int cubicTime(int n) {int sum = 0;for (int i = 0; i < n; i++) {for (int j = 0; j < n; j++) {for (int k = 0; k < n; k++) {sum++;}}}return sum; // 该函数的运行时间随输入规模的增长呈立方级增长
}
- O(2^n) 指数阶函数:该函数的运行时间随着输入规模的增长呈指数级增长。
public static int exponentialTime(int n) {if (n <= 0) {return 1;}return exponentialTime(n-1) + exponentialTime(n-2);// 该函数的运行时间随输入规模的增长呈指数级增长
}
- O(n!) 阶乘阶函数:该函数的运行时间随着输入规模的增长呈阶乘级增长。
public static int factorialTime(int n) {if (n == 1) {return 1;}return n * factorialTime(n-1);// 该函数的运行时间随输入规模的增长呈阶乘级增长
}
中文名称 | 英文名称 | 复杂度表示 | 描述 |
---|---|---|---|
常数阶 | Constant Time | O(1) | 算法的运行时间不随输入规模而变化 |
对数阶 | Logarithmic Time | O(log n) | 算法的运行时间随输入规模的增长而增长,但增长缓慢 |
线性阶 | Linear Time | O(n) | 算法的运行时间随输入规模的增长而线性增长 |
线性对数阶 | Linearithmic Time | O(n log n) | 算法的运行时间随输入规模的增长而略微超过线性增长 |
平方阶 | Quadratic Time | O(n^2) | 算法的运行时间随输入规模的增长而呈平方级增长 |
立方阶 | Cubic Time | O(n^3) | 算法的运行时间随输入规模的增长而呈立方级增长 |
指数阶 | Exponential Time | O(2^n) | 算法的运行时间随输入规模的增长而呈指数级增长 |
阶乘阶 | Factorial Time | O(n!) | 算法的运行时间随输入规模的增长而呈阶乘级增长 |
需要注意的是,对于一个算法,我们考虑他的整体复杂度时,通常只保留最高阶的,而忽略随着数据规模增加,影响可以忽略不计的低阶。
例如,如果有一个算法中既包含常数阶,对数阶又包含平方阶 O(5 + log n + 3*n^2),我们可以直接将其记作 O(n^2)。