您的位置:首页 > 理论基础 > 数据结构算法

常见数据结构简介

2017-07-31 14:36 246 查看
### 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
45

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
45

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


其他

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

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

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

内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签:  数据结构 算法