常见数据结构简介

来源:互联网 发布:淘宝钻石展位怎么推广 编辑:程序博客网 时间:2024/06/06 01:17

### Basics

  • Stack: Array Implementation: 数组从前往后插入,从后往前删除,用 top 指针指示栈顶(待插入位置)。

  • Stack: Linked List Implementation Top: 指针指向非空的链头元素,每次插入都从 top指向的链头位置插入,删除也是从链头。

  • Queues: Array Implementation: 数组,head 指向队首元素(在左边),tail 指向队尾元素后面的待插入位置(在右边),插入元素在 tail 处,删除元素在 head 处,两个指针都是从左往右走。

  • Queues: Linked List Implementation: head 指向队首元素(在左边),tail 指向队尾元素(在右边),从 tail 插入,从 head 删除。

### Recursion

  • Factorial: 求 n 的阶乘。函数if 分支给出分解终止条件;else 分支给出分解函数(自调用)和回溯(整合)的递推式。用 Python 写的。简单、经典。

  • Reversing a String: 翻转字符串。过程同上,关键是分解方案。

  • N-Queens Problem: n 阶皇后问题:给出 n*n 的棋盘,放上 n 个皇后,使得任意两个横着竖着斜着都不相邻。这个问题之所以用到递归,是因为在试探性(确定性试探,有固定顺序)地放置过程中,如果走入死胡同就需要回溯,倒退到之前的某一步。本质上是图的深度优先搜索策略。Python 代码有点复杂,需要研究并练习。

### Indexing

  • Binary Search Trees: 二叉查找树,简单好用。对于每一个节点,左子树任意节点都比他小,右大。中序遍历是一个升序序列。要会插入、删除和查找操作。复杂度为 O(h)。

  • AVL Trees (Balanced binary search trees): 平衡二叉树,首先是二叉查找树,然后满足平衡条件。平衡就是说任意节点的左右子树深度不得超过1,否则就需要旋转变换。这里最重要的知识点就是在插入、删除时,不满足条件时需要进行旋转操作。有三种形式:左旋、右旋、双旋。插入和删除时的旋转是不同的。平衡,保证了树的查找深度可以更小。

  • Red-Black Trees: 红黑树,首先也是二叉查找树。然后根据一些规则,保证从跟节点,走到每个叶子节点,经过的黑色节点数相通。这也是保证「平衡」和较小深度的一种方法。
    红黑树五个性质:1. 只有红黑。2. 根黑。 3. 叶(NULL)黑。 4. 红点黑儿子。5. 条条大路同黑数。
    插入过程中,新插入的节点N总是红色的。然后根据其父亲节点P和叔父节点U的情况,进行适当的旋转和变色:P 红 N 红,右右,单旋;P 红 N 红,右左,双旋;P 红 U 红 N 红,黑色下沉一级。
    删除时,先找到替换节点,直接替换,如果不满足结构,则调整。

  • Splay Trees: 伸展树,除了二叉查找树的性质,最新访问的节点总是调整到根节点处。这是基于查找的时间和空间局部性原理,可用于 cache。那么,关键之处就在调整了。三种:
    zig-step: p为 root,只需简单旋转。
    zig-zig step:有 g p n,且 p n 都是左或都是右,那么,先旋 g p,再旋 n。
    zig-zag step: 有 g p n,且 p n 不同为左或右,那么先 n p,再 g。

  • Open Hash Tables (Closed Addressing): 同一个位置上,用链表链接多个节点。对于 strings 的 hash,如’hello’,先32位0与’o’的 ascii 求和;结果左移四位,用0补齐;左数11-14位,与’0000’异或;结果与’l’的 ascii 求和…最后的结果,求出10进制,然后hash 到某位置。

  • Closed Hash Tables (Open Addressing): 区别于上面那个,某位置被占后,不可再添加新的元素,应该用再探测法找到其他位置。再探测法包括:线性探测(依次往后找空位),二次探测(1,4,9…)和再 hash(用 hash2)。

  • Closed Hash Tables, using buckets: 每连续三个位置为一组(bucket),编号。hash 时,先 hash 到对应的组,如果第一个被占,则用第二个,否则第三个;如果三个都已经被占,则用最后面的额外 overflow 区。

  • B Trees: 就是有些书上说的 B-树,英文是 B-tree,是多路查找树,也是平衡树,用于磁盘之类的存储结构。B 树在数据结构一书中是着重学习过的,主要知识点是 m 阶B 树的性质、插入后的分裂操作和删除后的合并操作。

  • B+ Trees: 跟 B 树挺像,但有些不同:每层包括所有元素,每层的节点之间有链表链接,有更好的索引性能。在数据结构学习中,这种结构不是重点。


#Sorting

  • Comparison Sorting
    • Bubble Sort: 从头到尾,两两比较,若>,则交换,再往后比较;实际上就是传递大个,从头往后,遇到大个就让大个往后走,直到最大的挪到最后面。然后从头开始第二遍,一直到倒数第二个…每一次遍历,确定队尾部分的一个元素。
    • Selection Sort: 有一个 min 指针,先指向第一个,然后依次与后面的比较,如果>,则 min 挪到该出,再继续往后比较,直到最后,然后把 min 处和开始处交换,这样就得到了第一个最小的。第二次,从第二个开始,重复上述过程。每一次遍历,确定队首部分的一个元素。
    • Insertion Sort: 从头到尾,依次把当前元素插入到前面合适的位置,插入时从后往前比较,前面的部分已经有序,但不是最终结果。这样,走到最后,就排序成功了。
    • Shell Sort:希尔排序,第一次以 d1为步长对列表分段,每个片段里对应元组排序;第二次以 d2(<d1)为步长…最后一次以1为步长。维基百科的分组表示形式也很直观容易理解。
    • Merge Sort:归并排序,排序的操作为:1-2,3-4,1-4;5-6,7-8,5-8;1-8;9-10,11-12。。。也就是说,从左到右,把2个排好,再来2个,拍前4个;后面再找4个,像前面一样递归排序;然后合并这8个,再找8个,像前面一样递归排序。。。
    • Quck Sort:快速排序,稍微复杂,这篇文章给出一个直观的描述,叫「挖坑填数+分治法」非常好。首先让 i 指向第一个,j 指向最后一个,挖走第一个数存在 X;从 j 开始向左找到一个小于 X 的数,挖走放在刚才的坑,形成新坑;从 i 开始向右找到一个大于 X 的数,挖走放在上一个坑,形成新坑,再从 j 开始向左找。。。直到i 和 j 相遇,把 X 放在最后一个坑中。这时左边都比 X 小,右边都比 X 大。这样,我们就用 X 把原问题划分了一下,左边和右边。第二次就用同样的方法分治 X 左边的和 X 右边的,用递归调用方法。如图:
  • Bucket Sort:桶排序。先创建一个同样大小的数组,把待排序数组中的数 Hash(Open Hash) 到这个数组, 形成的新数组中从左到右是有序的,每个位置的链表也是有序的。第二步是从左到右遍历,把元素都放回去。这种方法特别快,但耗空间。截图如下,非常直观:
    第一步进行中
    第二步进行中

  • Counting Sort:计数排序,很有技巧很好玩。这篇文章讲的很好理解:整个过程分为拉选票和入桶两个过程。投票完毕是这样的:
    待排数组[ 6 2 4 1 5 9 ]
    票箱数组[ 4 1 2 0 3 5 ]
    带排数组中每个元素,比如6,要拉票,所有小于我的都给我投票,所以一共4票。其他元素依次拉票,票箱数组显示了最终拉到的票数。第二步就是入桶:
    入桶后 [ 1 2 4 5 6 9 ]
    6有4票,入的是4号桶;2有1票,入的是1号桶,以此类推。
    (有重复数字怎么处理,日后再研究)

  • Radix Sort:基数排序,按照个位数用上述计数排序方法(或其他稳定排序方法)排好,在此基础上按照十位数排序,依次类推。

  • Heap Sort:堆排序,利用小顶堆的特点进行排序。在堆调整的过程中同时对数组中的元素调整顺序。堆调整完毕,数组就排序完毕了。如图:


#Heap-like Data Structures

  • Heaps:小顶堆(二叉树,完全树),每个节点都比它的左右子树小。按照层级从左到右插入节点,然后自下向上调整大小。删除最小值的时候,直接删除根节点(一直是最小的),然后把最后一个节点移到根节点,然后自顶向下调整大小。若给出一个已经建立好的完全树,想调整为堆,则需要自底向上、从右到左地逐层调整,调整时还需要考虑子树是否不再满足堆条件,if so,自顶向下调整。由于堆是一个按照层次编号的完全树,所以用一个数组作为数据结构。操作堆就是操作数组。

  • Binomial Queues:二项队列,也叫Binomial heap,是一种形状很有特点的树。首先要知道什么是二项树:如图为四个不同的二项树,其规律为:度数为k的二项树有一个根结点,根结点下有k个子女,每个子女分别是度数分别为k-1,k-2,…,2,1,0的二项树的根。度数为k的二项树共有2^{k}个结点,高度为k。在深度d处有{\tbinom nd}(二项式系数)个结点。二项堆自然就是满足节点相对大小关系的二项树。二项堆的插入很简单,只需要新建一个节点,并合并。合并的对象只能是两个度相同的二项堆:比较二个根结点关键字的大小,其中含小关键字的结点成为结果树的根结点,另一棵树则变成结果树的子树。删除最小的节点就是删除根节点,根节点下的几个子树全部分开。二项堆在各类操作中时间复杂度都为O(logn),可以说性能很好。其存储结构一般为链表。

  • Fibonacci Heaps:如图,斐波那契堆由这样一些小顶堆组成,其中每个节点ADT 如下:
1
2
3
4
5
6
7
8
9
struct FibonacciHeapNode {
int key; //结点
int degree; //度
FibonacciHeapNode * left; //左兄弟
FibonacciHeapNode * right; //右兄弟
FibonacciHeapNode * parent; //父结点
FibonacciHeapNode * child; //第一个孩子结点
bool marked; //是否被删除第1个孩子
};

并且用min 指向最小的根节点。每个节点有一个指针指向其一个子女,它的所有子女由双向循环链表连接,不同的小顶堆的根节点也是通过这种链表连接,叫做根表。

插入新元素时,只要将其作为单元素 F 堆,并跟原有堆合并,合并的方式是新 F 堆加入原 F 堆的根表中。

删除元素时,先把 min 指向的最小节点删除,然后将剩下的小顶堆分开,并按照二项堆组合的方式重新组合。

  • Leftist Heaps:左偏堆,每个节点除了有左右孩子和键值外,还有一个距离。什么是距离呢? 引用维基百科的话:「当且仅当节点 i 的左子树且右子树为空时,节点被称作外节点(实际上保存在二叉树中的节点都是内节点,外节点是逻辑上存在而无需保存。把一颗二叉树补上全部的外节点,则称为extended binary tree)。节点 i 的距离是节点 i 到它的后代中的最近的外节点所经过的边数。特别的,如果节点 i 本身是外节点,则它的距离为 0;而空节点的距离规定为-1 。」如图。除了堆的性质外,还有一条「节点的左子节点的距离不小于右子节点的距离。」其插入删除等基本操作都是基于合并。怎样合并呢?找到 root 键值最小的那个,用其右子树与其他树合并,若右子树为空,把另外的树直接弄过来,若此时右子树距离比左子树大了,那就交换左右子树;若右子树不空,把右子树摘下来与其他树合并,如此递归进行。删除堆中最小值时,先删除它,再把遗留的几个子树按照前述方法合并。左偏堆合并操作的平摊时间复杂度为O(log n)。

  • Skew Heaps:斜堆,上述左偏堆就是一种特殊的斜堆。斜堆没有距离的概念,其合并过程与左偏堆几乎一样,只是在每次合并之后都要左右子树互换一下(启发规则)。这样往往能导致最后形成的树中左子树比右子树深,所以是斜的。

#Graph Algorithms

  • Breadth-First Search:广度优先搜索。图可分为有向图和无向图(都可应用本算法),其表示形式有图形、邻接表、临界矩阵,注意图中 Parent 和 Visited 两个数组。

  • Depth-First Search:深度优先搜索,基本同上,除了搜索的顺序不同。搜索过程中会涉及到回溯问题,而回溯可以用栈这种数据结构,也可以用递归的这种运行形式。广度优先搜索则不会有回溯的情况。

  • Connected Components:连通分量,对于无向图,就是这样的一个子图:任意两个节点都可以路径可达,再加入该子图之外的节点后就不满足任意可达性了,所以也可以叫做最大连通子图。其实每个独立的无向图都是连通分量。还有一个叫强联通分量,是对应于有向图的,这时就不太容易寻找一个有向图的强连通分量了,因为要保证任意两个节点是互相可达的。Kosaraju算法、Tarjan算法、Gabow算法是目前比较有效的算法。DSV 中用的哪种,我还没看明白,等看完《算法概论》中介绍的那种再说。

  • Dijkstra’s Shortest Path:用于求解带权有向图(也可求无向图)的单源最短路径。如图,我们用这样一个表格来演示并记录。Vertex 表示图中的节点;Known 表示运行到目前为止,是否确定了该节点的最终最短路径,初始为 F(否);Cost 表示目前为止从源节点到该节点的最短路径,初始为 INF(无穷大); Path 是源点经过哪个节点到达的这个节点,初始为-1(无)。算法运行的过程:假设源点为2,此时2为 T,寻找2的直接邻节点,并更新 Cost(如果新 cost 比当前的小就更新,否则不更新),同时更新 Path 为2;然后,在所有F 的节点中,选择当前 Cost 最大的一个,标记为 T,这个节点的全局最短路径就确定下来了,以此作为中间节点,寻找其直接邻节点,并更新 Cost(注意,已经为 T 的不再更新)和 Path;如此循环,直到所有节点都为 T。

最终得到如下图所示的表格。接下来就是把 Path 表示出来。比如运行到7的时候,7的 path 是5,5的是6,6的是2,所以7的最终 path 是 2 6 5 7.

  • Prim’s Minimum Cost Spanning Tree:Prim 算法,对于给定的无向图,找出最小生成树。最小生成树就是包含图中所有节点和部分边是一个树,并且其权值之和最小。该算法的运行过程就是从源节点出发,选择权值最小的邻近节点,再以此为当前节点,选择未访问的权值最小的邻近节点,如此循环,若到头了,则回溯。总体来说是大 DFS 中包含着小 BFS。具体的运行过程可以使用Dijkstra算法中使用的表格形式。

  • Topological Sort (Using Indegree array):对于一个DAG,一定存在拓扑排序,使得对于 uv,在拓扑排序中,u 一定在 v 前面。这里使用直观的Kahn算法:有两个集合,L 表示已经排好的,S 表示入度为0的节点;每次从 S 中取出一个节点 n 放入 L 中,并查看从 n 出发的节点中是否有入度减为0的,若有,加入到 S 中,然后再从 S 中取节点,循环。。。如果最后剩下边,说明该图是有环的,不存在拓扑序列,否则最后得到的 L 即拓扑序列。从算法的执行过程来看,它是基于队列的。

  • Topological Sort (Using DFS):基于 DFS 的拓扑排序,这里 S 是所有出度为0的节点的集合。对于每个节点,递归访问以它为尾的所有节点,并标记为 Visited,并按照递归的退出顺序把节点加入到 L 中。这种方法其实也很直观,出度为0的节点,回溯到头一般就是入度为0的节点了。深度优先遍历会用到递归或栈。

  • Floyd-Warshall (all pairs shortest paths):留着,太复杂了。

  • Kruskal Minimum Cost Spanning Tree Algorithm:Kruskal 算法,对于给定的无向图,找出最小生成树。其方法很简单,就是从所有边中,依次挑选最小的边形成树的一个边,加入到当前的树中,如果这个边使树成为图,就舍弃,寻找次最小的,直到所有的节点都进入这个树中。

#Dynamic Programming

Calculating nth Fibonacci number,
Making Change,
Longest Common Subsequence:动态规划适用于有最优子结构和重叠子问题的问题。最优子结构是指局部最优解能决定全局最优解,这样我们才能把问题分解成子问题来解决。子问题重叠性质是指「在用递归算法自顶向下对问题进行求解时,每次产生的子问题并不总是新问题,有些子问题会被重复计算多次。动态规划算法正是利用了这种子问题的重叠性质,对每一个子问题只计算一次,然后将其计算结果保存在一个表格中,当再次需要计算已经计算过的子问题时,只是在表格中简单地查看一下结果,从而获得较高的效率。
」最常用的例子是斐波那契数列。其求解最常用的算法是如下递归:

1
2
3
4
function fib(n)
if n = 0 or n = 1
return 1
return fib(n − 1) + fib(n − 2)

虽然通过递归把问题分解为子问题了,但是,递归过程中很多子问题被重叠计算了,比如当n=5时,fib(5)的计算过程如下:

1
2
3
4
5
fib(5)
fib(4) + fib(3)
(fib(3) + fib(2)) + (fib(2) + fib(1))
((fib(2) + fib(1)) + (fib(1) + fib(0))) + ((fib(1) + fib(0)) + fib(1))
(((fib(1) + fib(0)) + fib(1)) + (fib(1) + fib(0))) + ((fib(1) + fib(0)) + fib(1))

改进的方法是,我们可以通过保存已经算出的子问题的解来避免重复计算:

1
2
3
4
5
array map [0...n] = { 0 => 0, 1 => 1 }
fib( n )
if ( map m does not contain key n)
m[n] := fib(n − 1) + fib(n − 2)
return m[n]

其他

  • Disjoint Sets:并查集,也叫union-find algorithm,因为该数据结构上的主要操作是 find 和 union,还有一个基本的 makeset 操作。

数据结构是一个树,每个节点除了有值外,还有一个指针指向父节点。一个树就是一个集合,树的根节点代表这个集合。由于只需要一个指针指向父节点,一般用一个数组表示这棵树。

数据结构说清楚了,接下来谈谈其操作。makeset 的作用是建立一个只含X 的集合。find 的作用是找到 X 的根节点,即找到 X 属于哪个集合。union 的作用是把 x 和 y 代表的集合合并。这三个操作的代码非常简单,但是得到的树会很高很偏,在之后的操作中效率就低了。对此有两种优化方法:路径压缩和按秩合并。下面给出最终代码,在注释处注明其改进:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40

int father[MAX];//用数组表示树,记录每个节点的父节点索引
int rank[MAX];//

/* 初始化集合*/
void Make_Set(int x)
{

father[x] = x; //只有一个元素的集合,父节点是自身,也可指定其他,如-1.
rank[x] = 0; //只有一个节点,秩(深度)为0
}

/* 查找x元素所在的集合,回溯时压缩路径*/
int Find_Set(int x)
{

if (x != father[x])
{
father[x] = Find_Set(father[x]); //回溯时把中间经过的节点的父节点都指向根节点,使树扁平化,压缩路径
}
return father[x];
}

/* 按秩合并x,y所在的集合 */
void Union(int x, int y)
{

x = Find_Set(x);
y = Find_Set(y);
if (x == y) return;//在一个集合中,不用合并
if (rank[x] > rank[y])
{
father[y] = x;//总是把秩小的树的根节点指向较大的树的根节点,这样秩不会增加
}
else
{
if (rank[x] == rank[y])
{
rank[y]++;
}
father[x] = y;
}
}

原创粉丝点击