数据结构(一)数据抽象和算法复杂度分析

在开始谈数据结构之前,先聊一聊什么是数据抽象抽象数据类型(ADT)

数据抽象和抽象数据类型

数据抽象

数据类型是指 一组值一组对这些值的操作 的集合,Java中有多种 原始数据类型,如intint-2^312^31 - 1 之间的这些整数值,以及加、减、乘、除等这些操作的集合。

理论上所有程序只需要使用这些原始数据类型(int double char等)即可,但如果我们能把原始数据类型抽象成更高级的数据类型(string queue stack等),无疑会更加方便程序的编写。

2^31-12147483647 ,21.4亿。

我们把定义和使用我们自己的数据类型的这个过程,叫做数据抽象

抽象数据类型

抽象数据类型(ADT) 是一种能够对使用者隐藏数据表示的数据类型。它将数据和函数的实现关联,并将数据的表示方式隐藏起来。我们在使用抽象数据类型时,主要关注如何操作而不关心数据本身是怎么表示的。也就是说,使用一个抽象数据类型,并不需要了解其实现细节。

ADT


三种简单的集合类数据类型

背包

背包是一种不支持从中删除元素的集合数据类型。它主要用于帮助用例收集元素,然后遍历这些的元素。这些元素没有顺序。

队列

队列是一种先进先出(FIFO)策略的集合类型。

栈是一种后进先出(LIFO)策略的集合类型。栈的应用非常广泛,例如几乎每个编辑器都有的Undo操作(撤销)、操作系统中的程序调用栈、括号/符号匹配等等。

栈的典型应用

  1. 逆序输出(十进制转其他进制)
  2. 括号匹配

Dijkstra双栈算式表达式求值法,将操作数和运算符分别放入两个栈中,遇到左括号“(” 则忽略,遇到操作数则将操作数压入栈1中,遇到运算符将它压入栈2中,遇到右括号,则弹出运算符和操作数,计算结果后重新压入栈中。

如何用两个栈实现一个队列?

添加时往A栈添加。删除时,先判断B栈是否为空,如果非空,直接从B栈取出最顶元素删除,如果为空,先把A栈里的元素全部倒入B栈中,然后删除最顶元素。

什么是对象的游离

在一个栈中,当我们使用pop()弹栈的时候,被弹出的元素我们再也不需要用到它了。但它的引用还存在于数组中。这种情况就称为游离。在 Java 中,避免对象游离很容易,只需将其设为 null 即可。这样系统(Java垃圾回收策略)就可以在使用完后将其回收。


算法的复杂度分析

在讨论数据结构和算法时,我们通常用算法的复杂度来描述一个算法的好坏,复杂度包括:时间复杂度空间复杂度。计算机本质上是一个状态机,内存里的数据构成了当前的状态,CPU利用当前的状态计算出下一个状态。所谓的空间复杂度就是为了支持你的计算所必需存储的状态最多有多少,所谓时间复杂度就是从初始状态到达最终状态中间需要多少步!

时间复杂度

算法的时间复杂度,也就是算法的时间量度,记作:T(n) = O(f(n))。它表示随问题规模 n 的增大,算法执行时间的增长率和 f(n) 的增长率相同,称为算法的渐近时间复杂度,简称为时间复杂度。其中 f(n) 是规模 n 的某个函数。

如何推导时间复杂度

  1. 用常数 1 取代运行时间中的所有加法常数。
  2. 在修改后的运行次数函数中,只保留最高阶项。
  3. 如果最高阶项存在,且不是 1 ,则去除与这个项相乘的常数。

例如:

1
2
3
int sum = 0, n = 100;   // 执行 1 次
sum = (1 + n) * n / 2; // 执行 1 次
printf("%d", sum); // 执行 1 次

这个算法的运行次数函数是 f(n) = 3。根据推导规则,第一步把常数项 3 改为 1。第二步保留最高阶项,它没有最高阶项,所以这个算法的时间复杂度为 T(n) = O(1)

常数阶

当 n = 1 时,算法执行次数为 3, 当 n = 100 时,算法的执行次数还是 3,所以我们可以看出这个算法的执行次数与 n 的规模没关系。我们把这种与问题的大小(n 的大小)无关,执行时间恒定的算法,叫作常数阶。

线性阶

下面这段代码的时间复杂度为 T(n) = O(n),因为循环体中的代码必须要执行 n 次。

1
2
3
for (i = 0; i < n; i++) {
/* 时间复杂度为 O(1) 的程序步骤序列 */
}

对数阶

1
2
3
4
5
int count = 1;
while (count < n) {
count = count * 2;
/* 时间复杂度为 O(1) 的程序步骤序列 */
}

由于每次 count 乘以 2 以后,就越来越接近于 n,也就是说有多少个 2 相乘后大于 n,则会退出循环。由 2^x = n 得到 x = log2n。所以这个算法的时间复杂度为 T(n) = O(logn)

平方阶

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 例1
int i, j;
for (i = 0; i < n; i++) {
for (j = 0; j < n; j++) {
/* 时间复杂度为 O(1) 的程序步骤序列 */
}
}

// 例2
int i, j, m;
for (i = 0; i < m; i++) {
for (j = 0; j < n; j++) {
/* 时间复杂度为 O(1) 的程序步骤序列 */
}
}

在 例1 中内循环时间复杂度为O(n),而对于外层的循环,不过是这个内循环再循环 n 次。所以这段代码的时间复杂度为 O(n^2)

在 例2 中,时间复杂度就变为 O(m^n)

常见的时间复杂度耗费时间

从小到大依次是:

O(1) < O(logn) < O(n) < O(nlogn) < O(n^2) < O(n^3) < O(2^n) < O(n!) < O(n^n)

空间复杂度

算法的空间复杂度通过计算算法所需的存储空间实现,算法空间复杂度的计算公式:

1
S(n) = O(f(n))

其中 n 为问题的规模,f(n)为语句关于 n 所占存储空间的函数。

复杂度分析的主要方法

  1. 迭代:级数求和
  2. 递归:递归跟踪 + 递推方程

猜测 + 验证