从整体到细节,自顶向下,从抽象到具体的框架思维都是通用的,不只是学习数据结构和算法,学习其他知识都是高效的。
1.数据结构的存储方式
数据结构的存储方式只有两种,数组(顺序存储)和链表(链式存储)。
散列表,栈,队列,堆,树,图等等都是“上层建筑”,而数组和链表才是“基础结构”。
比如:队列,栈,这两种数据结构既可以用数组,也可以用链表来实现。用数组实现就要处理扩容,缩容的问题;用链表实现,就没有这个问题,但是需要更多的内存空间存储节点指针。
图,的两种表示方法,邻接表就是链表,邻接矩阵就是二维数组。邻接矩阵判断连通性迅速,并可以进行矩阵运算解决一些问题,但是如果图比较稀疏的话就耗费空间。邻接表比较节省空间,但是操作效率上肯定比不过邻接矩阵。
散列表,就是通过散列函数把键映射到一个大数组里。而且对于解决散列冲突的方法,拉链法需要链表特性,操作简单,但需要额外的空间存储指针;线性探查法就需要数组特性,以便连续寻址,不需要指针的存储空间,但操作稍微复杂些。
树,用数组实现就是堆,因为堆是一个完全二叉树,用数组存储不需要节点指针,操作也比较简单;用链表实现就是常见的那种树,因为不一定是完全二叉树,所以不适合用数组存储。为此,在这种链表树结构之上,又衍生出各种巧妙的设计,比如二叉搜索树,AVL树,红黑树,区间树,B树等等,以应对不同的问题。
了解redis数据库的朋友也可能知道,Redis提供列表,字符串,集合,哈希等等几种常用的数据结构,但是对于每种数据结构,底层的存储方式都至少有两种,以便根据数据的实际情况使用合适的存储方式。
综上,数据结构种类很多,甚至你也可以发明自己的数据结构,但是底层存储无非都是数组或者链表,二者优缺点如下:
数组,是由紧凑连续存储,可以随机访问,通过索引快速找到对应元素,而且相对节省空间。但正因为连续存储,内存空间必须一次性分配够,所以说,数组如果要扩容,需要重新分配一块更大的空间,再把数据全部复制过去,时间复杂度O(n);而且,如果要在数组中间进行插入和删除,每次必须搬移后面所有的数据以保持连续,时间复杂度O(n)。
链表,因为元素不连续,而是靠指针指向下一个元素的位置,所以不存在数组的扩容问题;如果知道如果知道一个元素的前驱和后驱,操作指针即可删除该元素,或者新增元素,时间复杂度O(1)。但是正因为存储空间不连续,你无法根据一个索引算出对应元素的地址,所以不能随机访问;而且每个元素必须存储指向前后元素位置的指针,会消耗更多的存储空间。
2.数据结构的基本操作
对于任何数据结构,基本操作无非是遍历 + 访问,再具体点就是:增删改查。
数据结构的种类很多,但是它们存在的目的都是在不同的应用场景,尽可能高效的增删改查。
如何遍历 + 访问?各种数据的遍历访问都是两种形式:线性的和非线形的。
线性就是for/while迭代为代表,非线性就是递归为代表。再具体一点就是以下几种框架:
数组遍历框架,典型的线性迭代结构:
void traverse(int[] arr) {
for (int i = 0; i < arr.length; i++) {
//迭代访问arr[i]
}
}
链表遍历框架,兼具迭代和递归结构:
class ListNode {
int val;
ListNode next;
}
void traverse(ListNode head) {
for (ListNode p = head; p != null; p = p.next) {
//迭代访问 p.val;
}
}
void traverse(ListNode head) {
//终止条件
if (head == null) {
return;
}
//访问 p.val
traverse(head.next);
}
二叉树遍历框架,典型的非线性递归遍历结构:
class TreeNode {
int val;
TreeNode left, right;
public TreeNode() {}
public TreeNode(int val) {
this.val = val;
}
public TreeNode(int val, TreeNode left, TreeNode right) {
this.val = val;
this.left = left;
this.right = right;
}
}
//后序遍历
void traverse(TreeNode root) {
traverse(root.left);
traverse(root.right);
//访问根结点 root.val
}
你看二叉树的递归遍历方式和链表递归遍历方式,相似不?二叉树结构和单链表结构,相似不?如果再多几条叉,N叉树你会不会遍历?
二叉树框架可以扩展为N叉树的遍历框架:
class TreeNode {
int val;
TreeNode[] children;
}
void traverse(TreeNode root) {
if (root == null) {
return;
}
//访问跟节点,root.val
for (TreeNode child : root.children) {
traverse(child);
}
}
N叉树的遍历又可以扩展为图的遍历,因为图就是好几颗N叉树的结合体。你说图会出现环?这很好办,用个布尔数组visited做标记就行了。
所谓框架,就是套路。不管增删改查,这些代码都是永远无法脱离的结构,你可以把这个结构作为大纲,根据具体问题在框架上添加代码就行了,下面会具体举例。
3.算法刷题指南
建议:先刷二叉树。
因为二叉树最容易培养框架思维,而且大部分算法技巧,本质上都是树的遍历访问题。