一、数据结构的存储方式
数据结构的存储方式只有两种:数组(顺序存储)和链表(链式存储)
不是还有散列表、栈、队列、堆、树、图等数据结构吗?
这些都是术(上层建筑),而数组和链表才是道(结构基础)。那些多样化的数据结构,究其源头,都是在链表或数组上的特殊操作,API不同而已。
比如队列、栈这两种数据结构既可以用链表也可以用数组实现。用数组实现,就要处理扩容缩容问题;用链表实现,没有这个问题,但需要更多的内存空间存储节点指针。
〔 图〕的两种表示方式,邻接表就是链表,邻接矩阵就是二维数组。邻接矩阵判断连通性迅速,并可以进行矩阵运算解决一些问题,但是如果图比较稀疏的话很耗费空间。邻接表比较节省空间,但很多操作的效率上肯定比不过邻接矩阵。
〔散列表〕就是通过散列函数把键映射到一个大数组里。而且对于解决散列冲突的方法,拉链法需要链表特性,操作简单,但需要额外空间存储指针;线性探查法就需要数组特性,以便连续寻址,不需要指针的存储空间,但操作稍微复杂些。(注:解决冲突常用方法有拉链法和开放地址检测法(包括线性探测、平方探测、双散列),不常用的有再散列。这里没有看出拉链法相比其他方法的简单之处?另,散列表本身即数组特性,拉链法加入了链表特性)
〔树〕,用数组实现就是〔堆〕,因为〔堆〕是一个完全二叉树,用数组存储不需要节点指针,操作也比较简单(really?);用链表实现就是很常见的那种〔树〕,不一定是完全二叉树(则不适合数组存储)。为此,在这种链表〔树〕结构之上,又衍生出各种巧妙的设计,比如二叉搜索树、AVL树、红黑树、区间树、B树等等,以对应不同的问题
Redis提供列表、字符串、集合等几种常用的数据结构,但对于每种数据结构,底层的存储方式都至少有两种,以便根据存储数据的实际情况使用合适的存储方式。
数组由于是紧凑连续存储,可以随机访问,通过索引快速找到对应元素,而且的相对节约存储空间。但正因为连续存储,内存空间必须一次性分配够,数组如果要扩容需要分配一块更大的空间,再把数据全部复制过去,时间复杂度O(N);在数组中间插入和删除每次也必须搬移后面的所有数据以保持连续,时间复杂度O(N)。
链表因为元素不连续,而是靠指针指向下一个元素的位置,所以不存在数组的扩容问题;如果知道某一元素的前驱和后继,操作指针即可删除该元素或者插入新元素,时间复杂度O(1)。但正是因为存储空间不连续,你无法根据一个索引算出对应元素的地址,所以不能随机访问;而且由于每个元素必须存储指向(前)后元素位置的指针,会消耗更多的存储空间。
二、数据结构的基本操作
对于任何数据结构,基本操作无非遍历+访问,再具体一点就是:增删查改。各种数据结构的遍历+访问无非两种形式:线性的和非线性的。
线性就是for/while迭代为代表,非线性就是递归为代表。再具体一点无非以下几种框架:
1. 数组遍历框架,典型的线性迭代结构
2. 链表遍历框架,兼具迭代和递归结构:
class ListNode{
int val;
ListNode next;
}
void traverse(ListNode head) {
// 迭代访问
for(ListNode p = head; p != null; p = p.next){
}
// 递归访问
while(head != null)
traverse(head.next);
}
3.二叉树遍历框架,典型的非线性递归遍历结构:
class TreeNode {
int val;
TreeNode left, right;
}
void traverse(TreeNode root) {
if(root == null) return;
// 前序
root.val;
traverse(root.left);
traverse(root.right);
}
二叉树和的递归遍历方式其实和链表的递归遍历方式很相似!它们的结构也很相似!二叉树扩展到多叉树也很类似:
class TreeNode {
int val;
TreeNode[] children;
}
void traverse(TreeNode root){
// 前序
root.val;
for(TreeNode child : root.children)
traverse(child);
}
N叉树的遍历又可以扩展为图的遍历,因为图就是好几棵N叉树的结合体。而对于有环图,用个布尔数组visited做标记就行了。
三、算法刷题指南
先刷二叉树
因为二叉树最容易培养框架思维,且大部分算法技巧本质上都是树的遍历问题。
leetcode124题,难度hard,求二叉树中最大路径和,主要代码如下:
int ans = INT_MIN;
int oneSideMax(TreeNode root) {
if(root == null) return 0;
int left = max(0, oneSideMax(root.left));
int right = max(0, oneSideMax(root.right));
ans = max(ans, left + right + root.val);
return max(left + right) + root.val;
}
这就是一个后序遍历。
leetcode 105题,难度medium,根据前序遍历和中序遍历的结果还原一棵二叉树,很经典的问题,主要代码如下:
TreeNode buildTree(int[] preorder, int preStart, int preEnd, int[] inorder, int inStart, int inEnd, Map<Integer, Integer> inMap) {
if(preStart > preEnd || inStart > inEnd) return null;
TreeNode root=new TreeNode(preOrder[preStart]);
int inRoot = inMap.get(root.val);
int numsLeft = inRoot - inStart;
root.left = buildTree(preorder, preStart + 1, preStart +numsLeft, inorder, inStart, inRoot - 1, inMap);
root.right = buildTree(preorder, preStart + numsLeft +1, preEnd, inorder, inRoot + 1, inEnd, inMap);
return root;
}
不要看这个函数参数很多,只是为了控制数组索引而已,本质上也就是一个前序遍历。
leetcode 99题,难度hard,恢复一棵BST,主要代码如下:
void traverse(TreeNode node) {
if(node == null) return;
traverse(node.left);
if(node.val < pre.val) {
s = (s == null) ? prev : s;
t = node;
}
prev = node;
traverse(node.right);
}
这是一个中序遍历,一棵BST的中序遍历意味着值的升序排列。
刷完整个二叉树专题,再回去做回溯动规分治专题,会发现只要涉及递归的问题,都是树的问题。
很多动态规划问题就是在遍历一棵树,你如果对树的遍历操作烂熟于心,起码知道怎么把思路转化成代码,也知道如何提取别人解法的和新思路。
回溯算法就是个N叉树的前后序遍历问题,没有例外。比如N皇后问题,主要代码如下:
void backtrack(int[] nums, LinkedList<Integer> track) {
if(track.size() == nums.length) {
res.add(new LinkedList(track));
return;
}
for(int i = 0; i < nums.length; i++){
if(track.contains(nums[i])) continue;
track.add(nums[i]);
// 进入下一层决策树
backtrack(nums, track);
track.removeLast();
}
}
// 提取出N叉树遍历框架
void backtrack(int[] nums, LinkedList<Integer> track) {
for(int i = 0; i <nums.length; i++) {
backtrack(nums, track);
}
}
框架思维很重要,动态规划讲解中总结的找状态转移方程的几步流程,有时候按照流程写出解法,莫名其妙就对了。这就是框架的力量,能保证你在快睡着的时候,依然能写出正确的程序。
四、总结
数据结构的基本存储方式就是链式和顺序两种,基本操作就是增删改查,遍历方式无非迭代和递归。
刷算法题建议从〔树〕分类开始,结合框架思维,把这几十道题刷完,对于树的理解应该就到位了。这时候去看回溯、动态规划、分治等算法专题,对思路的理解可能会更加深刻一些。