序
大家好,我是小张同学。今天我们开始学习数据结构与算法。
想必打算学习算法的同学听说过这样一个概念:程序≈数据结构+算法。其中数据结构是实现程序的基本框架。而运用算法就是优化程序执行时间或空间上的必要措施。即寻找一个方法使程序的性能达到最优解。
而判断一个算法的优劣,常常就要从时间和空间上来断定。这时我们就要了解时间复杂度与空间复杂度这两个概念,本章主要讲解的是时间复杂度。
时间复杂度的表达式概念
大家在听老师讲解的时候,可能常常会听到老师说这个程序的时间复杂度是O(n)、O(n^2)、O(logn)……那么老师所说的这些奇怪的表达式到底是什意思呢?我们今天就来一起探究一下。
时间复杂度,顾名思义,就是程序所运行的所需要的时间。而我们这里的时间所指的并不是日常听课中以时分秒为单位的时间。在程序里,我们将程序运行一次的时间称为一个单位时间。比如说下面的例子:
int num = 7;
if (num % 2==0) {
System.out.println("true");
} else {
System.out.println("false");
}
在这个例子中,定义一个num变量花费了一个单位时间。而判断他取余二是否等于零花费了一个单位时间。加起来一共就是两个单位时间。时间复杂度就是像这样顺序排列的单次判断,假设他一共有a个然后它的时间复杂度就是O(a)。因为其中a是常数项。在时间复杂度的计算中,我们只需要知道一个近似的值。因此可以将常数项和系数忽略掉。所以以上程序的时间复杂度就为O(1)。
我们定义一个一维数组,数组的大小设为n。我们想要遍历打印这个数组中的数据。就需要如下的操作:
int n=100;
int[] array = new int[n];
for (int i = 0; i < n; i++) {
array[i] = i;
}
for (int i = 0; i < n; i++) {
System.out.println(array[i]);
}
在这段代码中,我们设置n的大小为100。通过遍历给n赋值1~100,再通过遍历数组将1~100打印出来。这段代码花费的单位时间为1+n+n,而他的时间复杂度则为O(n),可以看出在时间复杂度的计算中,我们将花费单位时间的最高次项,视为时间复杂度的表达式。
时间复杂度表达式分类
我们一般所用到的时间复杂度表达式一共有六种:
- 常数时间复杂度 O(1):无论问题规模如何变化,算法的运行时间都保持不变。
- 线性时间复杂度 O(n):当输入规模n线性增加时,算法的运行时间呈现出线性增长趋势。
- 对数时间复杂度 O(log n):当输入规模n呈指数增长时,算法的运行时间呈对数增长趋势。
- 平方时间复杂度 O(n^2):当输入规模n线性增加时,算法的运行时间呈现出平方增长趋势。
- 立方时间复杂度O(n^3):当输入规模n线性增加时,算法的运行时间呈现出立方增长趋势。
- 指数时间复杂度 O(2^n):当问题规模成指数增长时,算法的运行时间将会急剧增加。
在设计和优化算法时,理解算法的时间复杂度非常重要。因为时间复杂度直接影响着算法的效率而在各种环境中算法所花费的时间也并不能以时间复杂度一概而论。
时间复杂度是衡量一个量级上的差距,这个量级上的差距表现在当n突破到一个点的时候,时间复杂度低的算法就一定要比时间复杂度高的算法快,而且n越大,这个优势越明显。而我们研究算法就是要处理大规模的数据,如果数据比较小的话,时间总是够用的。
不过我们可以看到,复杂度高的算法可能它会有前面常数小的优势,所以在我们数据规模比较小的时候,它是有意义的,比如所有的高级排序算法,当数据规模小到一定程度时,我们都可以转而使用插入排序法,来进行优化,而这个优化一般有百分之十到十五的,在一些情况下还是很有意义的,但是这是细节上的优化,但整体上我们还是要追求复杂度低的算法。
时间复杂度表达式实际应用举例
1、常数时间复杂度 O(1)
常数时间复杂度 O(1) 的算法指的是无论输入规模如何变化,该算法的运行时间都保持不变。这种算法的执行时间与具体输入的数据规模无关。
通常的操作有:
判断语句
if (true) {
} else {
}
选择语句
switch (num) {
case 1:
System.out.println("1");
break;
case 2:
System.out.println("2");
break;
case 3:
System.out.println("3");
break;
default:
System.out.println("default");
break;
}
数组声明、查找、赋值
int array[] = {1, 2, 3, 4, 5}; // 声明一个数组
int x = array[2]; // 读取数组中第三个元素,即3
array[3] = 10; // 修改数组中第四个元素,将其改为10
总之,大多的基础语法代码都是属于O(1)复杂度,在与高时间复杂度的代码段放在一起时,甚至可以忽略不计。
2、线性时间复杂度 O(n)
线性时间复杂度 O(n) 的算法指的是随着输入规模n的增长,该算法的运行时间呈现出线性增长趋势。也就是说,当输入规模n增加1倍时,算法的运行时间也增加了1倍。通常情况下,O(n)的算法需要对数据进行从头到尾的遍历处理。
大体上,对于使用for循环遍历一维数组的结构大多是以时间复杂度O(n)为单位的。
通常有:
数组求和
int[] a = {15, 42, 7, 89, 34};
int add=0;
for (int k : a) {
add += k;
}
System.out.println(add);
求数组中最大值
int max=0;
for (int j : a) {
if (j > max) {
max = j;
}
}
System.out.println(max);
遍历输出数组
for (int j : a) {
System.out.println(j);
}
3、对数时间复杂度 O(log n)
对数时间复杂度 O(log n) 的算法指的是随着输入规模n的增长,该算法执行时间呈现出对数增长趋势。例如,当输入规模n增加1倍时,算法的运行时间可能会增加约2倍。常见的O(log n)算法通常是使用二分查找或者树结构等数据结构实现的。
最简单的对数时间复杂度程序如下:
int i = 1;
while (i <= n) {
i = i * 2;
}
从代码中可以看出,变量 i 的值从 1 开始取,每循环一次就乘以 2。当大于 n 时,循环结束。实际上,变量 i 的取值就是一个等比数列。如果我把它一个一个列出来,就应该是这个样子的:
因为我们只考虑最高次项,所以在计算时间复杂度时,。
所以这段代码的时间复杂度为O(logn);
而常见的O(log n)算法通常是使用二分查找或者树结构等数据结构实现的。
二分查找
//array为待查数组,target为目标值
public static int binarySearch(int[] array, int target) {
int left = 0;
int right = array.length - 1;
while (left <= right) {
int mid = left + (right - left) / 2;
if (array[mid] == target) return mid;
else if (array[mid] < target) left = mid + 1;
else right = mid - 1;
}
return -1;
}
二分查找算法每次将元素范围缩小一半,时间复杂度为 O(log n)
树结构涉及更深的算法知识,我们以后再详谈。
4、平方时间复杂度O(n^2)
双重for循环
冒泡排序
public static void bubbleSort(int[] arr) {
int n = arr.length;
for (int i = 0; i < n-1; i++) {
for (int j = 0; j < n-i-1; j++) {
if (arr[j] > arr[j+1]) {
int temp = arr[j];
arr[j] = arr[j+1];
arr[j+1] = temp;
}
}
}
}
以上代码中,冒泡排序算法使用每一次内层循环来比较相邻元素的大小,并根据需要交换它们的位置,时间复杂度为 O(n^2)。在实现时,我们使用两个for循环分别遍历整个数组并比较相邻的元素。
插入排序
public static void insertionSort(int[] array) {
int n = array.length;
for (int i = 1; i < n; i++) {
int key = array[i];
int j = i - 1;
// 将 array[i] 与已排序好的 array[0...i-1] 中的元素比较,找到合适的位置插入
while (j >= 0 && array[j] > key) {
array[j + 1] = array[j]; // 将较大的元素向后移动
j = j - 1;
}
array[j + 1] = key; // 将 key 插入到正确的位置
}
}
5、n次方时间复杂度
即套用多重循环实现各种功能。
在实际编程中,由于立方阶算法的效率非常低下,通常应该尽可能避免使用它,或者通过一些技巧将其转化为更高效的算法,以提高程序的性能。
6、指数时间复杂度 O(2^n)
指数时间复杂度是指算法执行的时间与数据规模 n 的指数成正比,通常表示为 O(2^n)。一种计算方式是,在算法中使用了嵌套循环或递归,每次运算次数都是上一次的两倍或更多,这种情况就容易出现指数级别的时间复杂度。
较典型的就是从高中一直陪伴到我们现在的斐波那契额数列
public class Fibonacci {
public static int fibonacci(int n) {
if (n <= 1) {
return n;
}
return fibonacci(n - 1) + fibonacci(n - 2);
}
public static void main(String[] args) {
int n = 10; // 我们想计算的费布那切数列的项数
for (int i = 0; i < n; i++) {
System.out.print(fibonacci(i) + " ");
}
}
}
这段代码将打印出费布那切数列的前10项。但是,请注意,这种递归方法在 n 较大时会非常慢,因为它会进行很多重复计算。
在实际开发中,我们也要尽量避免这种复杂度的出现。
函数递归
因为每个函数表达式不同,在递归时也会有不同的结果,所以要具体问题具体分析。