说实话,对于数据结构与算法,是存在畏惧心理的。但是,一点一点地学,总会能克服掉这种心理吧。
算法,对应的英文单词是 algorithm,最早来自数学领域。
在数学领域,算法是用于解决某一类问题的公式和思想。如等差数列的公式:
在计算机科学领域,算法的本质是一系列程序指令,用于解决特定的运算和逻辑问题。如给出一系列整数,找出最大的整数,或者给出一系列整数,按从小到大排序。
时间复杂度和空间复杂度。
考量一个算法好坏主要从算法所占用的时间和空间两个维度去考量。
数据结构,对应的英文单词是 data structure,是数据的组织、管理和存储格式,使用数据结构的目的是为了更加高效地访问和修改数据。
数据结构是算法的基石。如果把算法比作美丽灵动的舞者,那么数据结构就是舞者脚下广阔而坚实的舞台。
数据结构和算法是互不分开的。离开了算法,数据结构就显得毫无意义;而没有了数据结构,算法就没有实现的条件了。在解决问题时,不同的算法会选用不同的数据结构。
通过统计代码的绝对执行时间:代码的绝对执行时间只有在实际运行后才能得到,但是绝对执行时间会受到运行环境(机器性能的高低)和输入规模(数据规模的大小)的影响,所以通过比较代码的绝对执行时间来确定算法的时间复杂度有很大的局限性。
通过预估代码的基本操作次数:这需要对一个算法流程非常熟悉,然后写出这个算法流程中,发生了多少次基本执行操作,进而总结出基本操作次数的表达式。基本操作次数的表达式会转化为时间复杂度指标来表示。
如果时间复杂度指标无法区分算法好坏,就需要通过实际运行代码的方式来比较算法好坏了。
如果一个操作花费的时间是固定的并且和样本的数据量没有关系,就把这样的操作称为基本操作,或常数操作。
常见的基本操作有:
常见的非基本操作有:
这里分场景来举例说明。
场景1:
从链表中取出第 i 个元素,每次查找下一个节点耗时为 1 时间单位:
public static void case1() {
LinkedList<Integer> list = new LinkedList<>();
for (int i = 0; i < 2000; i++) {
list.add(i);
}
// 取出第 1 个元素
Integer a = list.get(0);
// 取出第 999 个元素
Integer c = list.get(999);
}
则基本操作次数表达式 T(n) = n
,因为第 n
个元素需要从链表头查找 n
次才可以获取到。
场景2:
给定一个整数 n
,求初始值为 1
的整数乘以多少次 2
才可以达到 n
:
public static void case2() {
int i = 1;
int n = 16;
while (i < n) {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
i = i * 2;
}
System.out.println("i = " + i);
}
这里我们把循环内的休眠时间和 i = i * 2
作为一个大的常数操作,而忽略掉 i < n
这个常数操作时间。
可以知道,
如果 n = 2,则 T = 1;
如果 n = 4,则 T = 2;
如果 n = 16,则 T = 4;
总结一下,T(n) = log2^n
;
场景3:
从数组中取出第 i 个元素,每次挪动指针耗时为 1 时间单位:
public static void case3() {
int[] arr = new int[2000];
for (int i = 0; i < 2000; i++) {
arr[i] = i;
}
// 实际上第一次挪动比较耗时
int a = arr[0];
// 以后的获取耗时相当
int b = arr[1];
int c = arr[1999];
}
T(n) = 1 时间单位。
场景4:
打印 n
行 n
列的星号:
public static void case4() {
int n = 6;
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= n; j++) {
System.out.print("*");
}
System.out.println();
}
}
打印第 1 行,需要 1 次 int i = 1;
赋值操作;1 次 i <= n
比较;n 次 j <= n
比较;n 次 j++
操作;n 次 System.out.print("*");
,1 次System.out.println();
换行操作 ,总共需要 3n + 3 次常数操作;
打印第 2 行,需要 1 次 i++
操作;1 次 i <= n
比较; n 次 j <= n
比较;n 次 j++
操作;n 次 System.out.print("*");
,1 次System.out.println();
换行操作 ,总共需要 3n + 3 次常数操作;
…
打印第 n 行,需要 1 次 i++
操作;1 次 i <= n
比较; n 次 j <= n
比较;n 次 j++
操作;n 次 System.out.print("*");
,1 次System.out.println();
换行操作 ,总共需要 3n + 3 次常数操作。
最后还需要 1 次 i <= n
比较;结束循环。
所以,总共需要的常数操作次数 T(n) = (3n + 3) * n + 1 = 3n^2 + 3n + 1
。
若存在函数 f(n),使得当 n 趋于无穷大时,T(n) / f(n) 的极限值为不等于零的常数,则称 f(n) 是 T(n) 的同数量级函数。记作 T(n) = O(f(n)),称为O(f(n)),O为算法的渐进时间复杂度,简称为时间复杂度。
因为渐进时间复杂度用大写O来表示,所以也被称为大O表示法。大O用来表示上界,表示了算法的最坏情况运行时间。
场景1:T(n) = n。
不是常数量级,最高阶项是 n,则转化的时间复杂度为:T(n)=O(n);
场景2:T(n) = log2^n。
不是常数量级,最高阶项是 log2^n,省略常数 2,则转化的时间复杂度为:T(n)=O(logn);
场景3:T(n) = 1。
是常数量级,则转化的时间复杂度为:T(n)=O(1);
场景4:T(n) = 3n^2 + 3n + 1。
不是常数量级,最高阶项是 3n^2,去除系数 3,则转化的时间复杂度为:T(n)=O(n ^ 2);
空间复杂度是对一个算法在运行过程中临时占用存储空间大小的量度,同样使用大O(读作欧,不是零)表示法。
程序占用空间大小的计算公式记作 S(n) = O(f(n)),其中 n 为问题的规模,f(n) 为算法所占存储空间的函数。
这里需要分情况来说明。
场景1:常量空间
当算法分配的存储空间大小是固定的,和输入规模没有直接的关系时,空间复杂度记作 O(1)。如:
public static void case1(int n) {
// 给变量 i 分配的存储空间大小是固定的,跟输入规模 n 没有直接的关系。
int i = 0;
for (; i < n; i++) {
// 遍历元素。
}
}
场景2:线性空间
当算法分配的存储空间是一个线性的集合(如数组或链表),并且集合大小和输入规模 n 成正比时,空间复杂度记作 O(n)。如:
public static void case2(int n) {
int[] arr = new int[n];
}
场景3:二维空间
当算法分配的存储空间是一个二维数组集合,并且二维数组的行和列都与输入规模 n 成正比时,空间复杂度记作 O(n^2)。如:
public static void case3(int n) {
int[][] arr = new int[n][n];
}
场景4:递归空间
计算机在执行程序时,会专门分配一块内存,用来存储方法调用栈(栈是一种先进后出的数据结构,或者说后进先出的数据结构)。
方法调用栈包括进栈和出栈两种操作。
/**
* 斐波那契数列指的是这样一个数列:0,1,1,2,3,5,8,13,21,34,55,89...
*/
public static int fibonacci(int n) {
if (n <= 1) {
return n;
}
return fibonacci(n - 2) + fibonacci(n - 1);
}
当 n = 1 时,会有 fibonacci 方法和 n = 1 入栈:在方法内部,满足 n <= 1,所以直接返回,有 1 个栈帧;
当 n = 2 时,会有 fibonacci 方法和 n = 2 入栈:在方法内部,fibonacci 方法和 n = 0 入栈,fibonacci 方法和 n = 1 入栈,总共会有 3 个栈帧;
当 n = 3 时,会有 fibonacci 方法和 n = 3 入栈:在方法内部,fibonacci 方法和 n = 1 入栈,fibonacci 方法和 n = 2 入栈,总共会有 5 个栈帧;
当 n = 4 时,会有 fibonacci 方法和 n = 4 入栈:在方法内部,fibonacci 方法和 n = 2 入栈,fibonacci 方法和 n = 3 入栈,总共会有 9 个栈帧;
当 n = 5 时,会有 fibonacci 方法和 n = 5 入栈:在方法内部,fibonacci 方法和 n = 3 入栈,fibonacci 方法和 n = 4 入栈,总共会有 15 个栈帧。
当 n = 6 时,会有 fibonacci 方法和 n = 6 入栈:在方法内部,fibonacci 方法和 n = 4 入栈,fibonacci 方法和 n = 5 入栈,总共会有 25 个栈帧。
S(n) ≈ (n - 1) ^ 2 = O(n^2);
再举一个递归的例子:求和
public static int sum(int n) {
if (n <= 1) {
return 1;
}
return n + sum(n - 1);
}
当 n = 1 时,有 sum 方法和 n = 1 入栈,方法内部:满足 n <= 1,直接返回。总共有 1 个栈帧;
当 n = 2 时,有 sum 方法和 n = 2 入栈,方法内部:有 sum 方法和 n = 1 入栈。总共有 2 个栈帧;
当 n = 3 时,有 sum 方法和 n = 3 入栈,方法内部:有 sum 方法和 n = 2 入栈。总共有 3 个栈帧;
所以,S(n) = n = O(n);
在绝大多数时候,时间复杂度更重要一些,也就是说,哪怕要多分配一些内存空间,也要提升程序的执行速度。
比如,查找一个数组中的重复数字的例子。
采用牺牲时间换空间的实现如下:
/**
* i = 0 时,外部循环:1 次 int i = 0; 操作,1 次 i < arr.length; 操作,内部循环:1 次 int j = 0; 操作,1 次 j < i; 操作,共 4 次操作
* i = 1 时,外部循环:1 次 i++ 操作,1 次 i < arr.length; 操作,内部循环:1 次 int j = 0; 操作,2 次 j < i; 操作,1 次比较操作,1 次 j++ 操作,共 7 次操作
* i = 2 时,外部循环:1 次 i++ 操作,1 次 i < arr.length; 操作,内部循环:1 次 int j = 0; 操作,3 次 j < i; 操作,2 次比较操作,2 次 j++ 操作,共 10 次操作
* i = n 时,外部循环:1 次 i++ 操作,1 次 i < arr.length; 操作,内部循环:1 次 int j = 0; 操作,n + 1 次 j < i; 操作,n 次比较操作,n 次 j++ 操作,共 3n + 4 次操作
* 总共:T(n) = (3n + 4)*n / 2 = 1.5n^2 + 2n = O(n^2)
*/
public static void findTheSameTwoNumber1() {
int[] arr = new int[]{3, 1, 2, 5, 4, 9, 7, 2};
for (int i = 0; i < arr.length; i++) {
for (int j = 0; j < i; j++) {
if (arr[j] == arr[i]) {
System.out.println("the same number is: " + arr[j]);
break;
}
}
}
}
/**
时间复杂度是 O(n^2),空间复杂度是 O(1)。
采用牺牲空间换时间的实现如下:
/**
* i = 0 时,1 次 int i = 0; 操作,1 次 i < arr.length; 操作,1 次 hashSet.add(arr[i]) 操作,共 3 次操作
* i = 1 时,1 次 i++ 操作,1 次 i < arr.length; 操作,1 次 hashSet.add(arr[i]) 操作,共 3 次操作
* i = n 时,1 次 i++ 操作,1 次 i < arr.length; 操作,1 次 hashSet.add(arr[i]) 操作,共 3 次操作
* 所以 T(n) = 3n = O(n);
*/
public static void findTheSameTwoNumber2() {
int[] arr = new int[]{3, 1, 2, 5, 4, 9, 7, 2};
// 借助中间数据,一个哈希集合,需要开辟一定的内存空间来存储有用的数据信息。
HashSet<Integer> hashSet = new HashSet<>();
for (int i = 0; i < arr.length; i++) {
if (!hashSet.add(arr[i])) {
System.out.println("the same number is: " + arr[i]);
break;
}
}
}
时间复杂度是 O(n),空间复杂度是 O(1)。
本文主要学习时间复杂度和空间复杂度的概念以及例子。