算法学习--高级数据结构

字典树(Trie树)基础模版

字典树又称单词查找树,Trie树,是一种树形结构,是一种哈希树的变种。典型应用是用于统计,排序和保存大量的字符串(但不仅限于字符串),所以经常被搜索引擎系统用于文本词频统计。

字典树 – 应用

串的快速检索

给出N个单词组成的熟词表,以及一篇全用小写英文书写的文章,请你按最早出现的顺序写出所有不在熟词表中的生词。

在这道题中,我们可以用数组枚举,用哈希,用字典树,先把熟词建一棵树,然后读入文章进行比较,这种方法效率是比较高的。

“串”排序

给定N个互不相同的仅由一个单词构成的英文名,让你将他们按字典序从小到大输出

用字典树进行排序,采用数组的方式创建字典树,这棵树的每个结点的所有儿子很显然地按照其字母大小排序。对这棵树进行先序遍历即可。

最长公共前缀问题

对所有串建立字典树,对于两个串的最长公共前缀的长度即他们所在的结点的公共祖先个数,于是,问题就转化为最近公共祖先问题(以后补上)。

Trie树的基本实现

字母树的插入(Insert)、删除( Delete)和查找(Find)都非常简单,用一个一重循环即可,即第i 次循环找到前i 个字母所对应的子树,然后进行相应的操作。实现这棵字母树,我们用最常见的数组保存(静态开辟内存)即可,当然也可以开动态的指针类型(动态开辟内存)。至于结点对儿子的指向,一般有三种方法:

1、对每个结点开一个字母集大小的数组,对应的下标是儿子所表示的字母,内容则是这个儿子对应在大数组上的位置,即标号;

2、对每个结点挂一个链表,按一定顺序记录每个儿子是谁;

3、使用左儿子右兄弟表示法记录这棵树。

三种方法,各有特点。第一种易实现,但实际的空间要求较大;第二种,较易实现,空间要求相对较小,但比较费时;第三种,空间要求最小,但相对费时且不易写。

这里采用第一种,速度较快,适合ACM竞赛。

MAX 为自定义的。根据实际情况。这是只要考虑小写字母 a-z

//copyright: www.acmerblog.com

#include <stdio.h>
#include <iostream>
using namespace std;
#define  MAX    26

typedef struct TrieNode
{
	int nCount;  // 该节点前缀 出现的次数
	struct TrieNode *next[MAX]; //该节点的后续节点
} TrieNode;

TrieNode Memory[1000000]; //先分配好内存。 malloc 较为费时
int allocp = 0;

//初始化一个节点。nCount计数为1, next都为null
TrieNode * createTrieNode()
{
	TrieNode * tmp = &Memory[allocp++];
	tmp->nCount = 1;
	for (int i = 0; i < MAX; i++)
		tmp->next[i] = NULL;
	return tmp;
}

void insertTrie(TrieNode * * pRoot, char * str)
{
	TrieNode * tmp = *pRoot;
	int i = 0, k;
	//一个一个的插入字符
	while (str[i])
	{
		k = str[i] - 'a'; //当前字符 应该插入的位置
		if (tmp->next[k])
		{
			tmp->next[k]->nCount++;
		}
		else
		{
			tmp->next[k] = createTrieNode();
		}

		tmp = tmp->next[k];
		i++; //移到下一个字符
	}

}

int searchTrie(TrieNode * root, char * str)
{
	if (root == NULL)
		return 0;
	TrieNode * tmp = root;
	int i = 0, k;
	while (str[i])
	{
		k = str[i] - 'a';
		if (tmp->next[k])
		{
			tmp = tmp->next[k];
		}
		else
			return 0;
		i++;
	}
	return tmp->nCount; //返回最后的那个字符  所在节点的 nCount
}

int main(void)
{
	char s[11];
	TrieNode *Root = createTrieNode();
	while (gets(s) && s[0] != '0') //读入0 结束
	{
		insertTrie(&Root, s);
	}

	while (gets(s)) //查询输入的字符串
	{
		printf("%d\n", searchTrie(Root, s));
	}

	return 0;
}

并查集(1)-判断无向图是否存在环

并查集是一种树型的数据结构,用于处理一些不相交集合(Disjoint Sets)的合并及查询问题。常常在使用中以森林来表示。集就是让每个元素构成一个单元素的集合,也就是按一定顺序将属于同一组的元素所在的集合合并。

  • Find:确定元素属于哪一个子集。它可以被用来确定两个元素是否属于同一子集合。
  • Union:将两个子集合并成同一个集合。

其实判断一个图是否存在环已经有相应的算法,此文用并查集来判断一个图是否有环。

我们可以用一个一维数组parent[] 来记录子集合。

看下面这个图:

1 0
2 |  \
3 |    \
4 1-----2

对每一条边的两个顶点加入集合,发现两个相同的顶点在一个子集合中,就说明存在环。

初始化:parent[n] 的每个元素都为-1,共有n个子集合,表示集合只有当前顶点一个元素。

1 0 1 2
2 -1 -1 -1

然后逐个处理每条边。

边0-1:我们找到两个子集合 0 和1,因为他们在不同的子集合,现在需要合并他们(Union). 把其中一个子集合作为对方的父集合.

1 0   1   2    <----- 1 成为 0 的 父集合 (1 现在代表集合 {0, 1})
2 1  -1  -1

 边0-2:1属于属于子集合1,2属于子集合2,因此合并他们。

1 0   1   2    <----- 2 作为 1的父集合 (2 现在代表集合 {0, 1, 2})
2 1   2  -1

 边0-2: 0是在子集和2和2也是在子集合2, 因为 0->1->2 // 1 是0 父集合 并且  2 是1的父集合 。因此,找到了环。

代码:

// 用并查集判断是否存在环
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

// 图中的边
struct Edge
{
    int src, dest;
};

// 图结构体
struct Graph
{
    // V-> 顶点个数, E-> 边的个数
    int V, E;

    // 每个图就是 边的集合
    struct Edge* edge;
};

// 创建一个图
struct Graph* createGraph(int V, int E)
{
    struct Graph* graph = (struct Graph*) malloc( sizeof(struct Graph) );
    graph->V = V;
    graph->E = E;

    graph->edge = (struct Edge*) malloc( graph->E * sizeof( struct Edge ) );

    return graph;
}

// 查找元素i 所在的集合( 根 )
int find(int parent[], int i)
{
    if (parent[i] == -1)
        return i;
    return find(parent, parent[i]);
}

// 合并两个集合
void Union(int parent[], int x, int y)
{
    int xset = find(parent, x);
    int yset = find(parent, y);
    parent[xset] = yset;
}

// 检测环
int isCycle( struct Graph* graph )
{
    int *parent = (int*) malloc( graph->V * sizeof(int) );

    // 初始化所有集合
    memset(parent, -1, sizeof(int) * graph->V);

    // 遍历所有边
    for(int i = 0; i < graph->E; ++i)
    {
        int x = find(parent, graph->edge[i].src);
        int y = find(parent, graph->edge[i].dest);

        if (x == y) //如果在一个集合,就找到了环
            return 1;

        Union(parent, x, y);
    }
    return 0;
}

// 测试
int main()
{
    /* 创建一些的图
         0
        |  \
        |    \
        1-----2 */
    struct Graph* graph = createGraph(3, 3);

    // 添加边 0-1
    graph->edge[0].src = 0;
    graph->edge[0].dest = 1;

    // 添加边 1-2
    graph->edge[1].src = 1;
    graph->edge[1].dest = 2;

    // 添加边 0-2
    graph->edge[2].src = 0;
    graph->edge[2].dest = 2;

    if (isCycle(graph))
        printf( "Graph contains cycle" );
    else
        printf( "Graph doesn't contain cycle" );

    return 0;
}

并查集(2)-按秩合并和路径压缩

在上面一讲是 并查集(1)-判断无向图是否存在环 . 我们使用了并查集的两个操作:  union()  和  find()
//  find 的原始实现
int find(int parent[], int i)
{
    if (parent[i] == -1)
        return i;
    return find(parent, parent[i]);
}

// union()的原始实现
void Union(int parent[], int x, int y)
{
    int xset = find(parent, x);
    int yset = find(parent, y);
    parent[xset] = yset;
}
上述union()和find()是直接的,最坏的情况下的时间复杂度是线性的。表示子集的树可能会倾斜,像一个链表。下面是一个例子最坏情况的场景。


01 假设有4个元素 0, 1, 2, 3
02 初始化
03 0 1 2 3
04  
05 Do Union(0, 1)
06    1   2   3 
07   /
08  0
09  
10 Do Union(1, 2)
11      2   3  
12     /
13    1
14  /
15 0
16  
17 Do Union(2, 3)
18          3   
19         /
20       2
21      /
22    1
23  /
24 0

以上操作可以优化到O(Log n)在最糟糕的情况下。方法就是在每次合并都把深度较小的集合合并在深度较大的集合下面 。这种技术被称为按秩合并。这样可以防止树的退化,最坏情况不会出现。

01 继续用上面的例子
02 0 1 2 3
03  
04 Do Union(0, 1)
05    1   2   3 
06   /
07  0
08  
09 Do Union(1, 2)
10    1    3
11  /  \
12 0    2
13  
14 Do Union(2, 3)
15     1   
16  /  |  \
17 0   2   3

第二个要优化的就是find(). 路径压缩实际上是在找完根结点之后,在递归回来的时候顺便把路径上元素的父亲指针都指向根结点。

01 假设集合{0, 1, .. 9} 的树表示如下所示:  当调用 find(3)时
02  
03               9
04          /    |    \ 
05         4     5      6
06      /     \        /  \
07     0        3     7    8
08             /  \
09            1    2 
10  
11 我们向上查找,找到是这个集合的根节点. 路径压缩就是:直接把 3的 父节点 设置为 9
12 当下次再查找 1, 2 或 3 时,查找的路径就会变短。
13  
14                9
15          /    /  \    \
16         4    5    6     3
17      /           /  \   /  \
18     0           7    8  1   2

这两个优化方法互为补充。每个操作的时间复杂度比O(Logn)要小。事实上,摊销时间复杂度实际上变成了小的常数。

// 用并查集判断是否存在环
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

// 图中的边
struct Edge
{
    int src, dest;
};

// 图结构体
struct Graph
{
    // V-> 顶点个数, E-> 边的个数
    int V, E;

    // 每个图就是 边的集合
    struct Edge* edge;
};

struct subset
{
    int parent;
    int rank;
};

// 创建一个图
struct Graph* createGraph(int V, int E)
{
    struct Graph* graph = (struct Graph*) malloc( sizeof(struct Graph) );
    graph->V = V;
    graph->E = E;

    graph->edge = (struct Edge*) malloc( graph->E * sizeof( struct Edge ) );

    return graph;
}

// 使用路径压缩
int find(struct subset subsets[], int i)
{
    // 找到 root并使  root 作为 i 的父节点 
    if (subsets[i].parent != i)
        subsets[i].parent = find(subsets, subsets[i].parent);

    return subsets[i].parent;
}

// 使用按秩合并
void Union(struct subset subsets[], int x, int y)
{
    int xroot = find(subsets, x);
    int yroot = find(subsets, y);

    // 将深度较小的集合 合并到深度大的集合下面
    if (subsets[xroot].rank < subsets[yroot].rank)
        subsets[xroot].parent = yroot;
    else if (subsets[xroot].rank > subsets[yroot].rank)
        subsets[yroot].parent = xroot;
    else //深度一样,任选一个,并增加另一个
    {
        subsets[yroot].parent = xroot;
        subsets[xroot].rank++; 
    }
}

int isCycle( struct Graph* graph )
{
    int V = graph->V;
    int E = graph->E;

    struct subset *subsets =
        (struct subset*) malloc( V * sizeof(struct subset) );

    for (int v = 0; v < V; ++v)
    {
        subsets[v].parent = v;
        subsets[v].rank = 0;
    }

    for(int e = 0; e < E; ++e)
    {
        int x = find(subsets, graph->edge[e].src);
        int y = find(subsets, graph->edge[e].dest);

        if (x == y)
            return 1;

        Union(subsets, x, y);
    }
    return 0;
}

// Driver program to test above functions
int main()
{
    /* 构造以下的图
         0
        |  \
        |    \
        1-----2 */

    int V = 3, E = 3;
    struct Graph* graph = createGraph(V, E);

    // add edge 0-1
    graph->edge[0].src = 0;
    graph->edge[0].dest = 1;

    // add edge 1-2
    graph->edge[1].src = 1;
    graph->edge[1].dest = 2;

    // add edge 0-2
    graph->edge[2].src = 0;
    graph->edge[2].dest = 2;

    if (isCycle(graph))
        printf( "Graph contains cycle" );
    else
        printf( "Graph doesn't contain cycle" );

    return 0;
}

后缀树简介

模式匹配问题

给定一个文本text[0...n-1], 和一个模式串 pattern[0...m-1],写一个函数 search(char pattern[], char text[]) ,打印出pattern在text中出现的所有位置(n > m)。

这个问题已经有两个经典的算法:KMP算法 ,有限自动机,前者是对模式串pattern做预处理,后者是对待查证文本text做预处理。在进行完预处理后,可以到达O(n)的时间复杂度,n是text的长度。

后缀树可以用对text进行预处理,构造一个text的后缀树,就可以在 O(m) 的时间内搜索任意一个pattern,m是模式串pattern的长度。

后缀字典树

想象一下,你刚成为一个DNA序列化项目中的程序员。研究人员正在对病毒的遗传物质进行切片和切块,以产生核苷酸序列的片段。他们把这些遗传物质序列片段送人你的服务器进行匹配,希望能够在基因组数据库中定位到这些序列。一个特定的病毒的基因组可能有数十万的核苷酸,并且在你的数据库中存储了成千上万的病毒的信息。希望你能够实现一个C/S结构的项目,来实时的为研究人员提供服务。怎么做才比较好呢?

因为数据库是不变的,所以对它进行预处理以使得搜索变得简单。一种预处理的方式是建立一个字典树(Trie树) ,该字典树中存放的是给定字符串的所有后缀,用这种方式生成的字典树,称为后缀字典树(suffix trie),后缀字典树只差一步就是我最终要引入的数据结构——后缀树。字典树是一种N叉树,其中N是字母表的大小。

对于字符串:“banana\0″ 的所有后缀为:

1 banana\0
2 anana\0
3 nana\0
4 ana\0
5 na\0
6 a\0
7 \0

其标准的后缀字典树结构是这样的:

suffixtrie

很显然,有了这样的一个树,我只需要4次字符比较就可以确定pattern:“nana”是否出现。当然仅仅有一个小问题会拖后腿儿,那就是创建字典树所耗费的时间。

但是,构造后缀字典树需要O(N2)时间和空间复杂度。这种平方级的性能使它不能在最需要用到它的地方使用,即不能在大数据量场合使用。

后缀树

Edward McCreight 在1976年提出了一个合理的解决方法摆脱了后缀字典树在应用上的困境,他发表的论文中提出了后缀树(suffix tree)

一个给定的文本text的后缀树就是一个压缩的后缀字典树。压缩至的是路径压缩,去除了只有一个子边的节点。例如对上上图中最左边的 banana\0  ,即为一个没有分叉的单边,可以进行压缩:

suffix-tree

上面这个图是靠是手动生成的,实际上这里还是有一些比较复杂的算法,后续再做讨论。节点数量的减少,使构造后缀树的时间和空间复杂度从O(N2)减少到了O(N)。在最坏的情况下,一个后缀树最多包含2N个节点,其中N是输入文本的长度。所以对输入文本的一次性投资,可以使我们的每次搜索都受益。

应用

(1). 查找字符串o是否在字符串S中。
方案:用S构造后缀树,按在trie中搜索字串的方法搜索o即可。
原理:若o在S中,则o必然是S的某个后缀的前缀。
例如S: leconte,查找o: con是否在S中,则o(con)必然是S(leconte)的后缀之一conte的前缀.有了这个前提,采用trie搜索的方法就不难理解了。
(2). 指定字符串T在字符串S中的重复次数。
方案:用S+’$’构造后缀树,搜索T节点下的叶节点数目即为重复次数
原理:如果T在S中重复了两次,则S应有两个后缀以T为前缀,重复次数就自然统计出来了

(3). 字符串S中的最长重复子串
方案:原理同2,具体做法就是找到最深的非叶节点。
这个深是指从root所经历过的字符个数,最深非叶节点所经历的字符串起来就是最长重复子串。
为什么要非叶节点呢?因为既然是要重复,当然叶节点个数要>=2。
(4). 两个字符串S1,S2的最长公共部分
方案:将S1#S2$作为字符串压入后缀树,找到最深的非叶节点,且该节点的叶节点既有#也有$(无#)。

后缀数组及应用


后缀数组

建议先了解下后缀树:后缀树简介

在字符串处理当中,后缀树和后缀数组都是非常有力的工具,其中后缀树大家了解得比较多,关于后缀数组则很少见于国内的资料。其实后缀数组是后缀树的一个非常精巧的替代品,它比后缀树容易编程实现,能够实现后缀树的很多功能而时间复杂度也不太逊色,并且,它比后缀树所占用的空间小很多。

后缀树组是一个字符串的所有后缀的排序数组。后缀是指从某个位置 i 开始到整个串末尾结束的一个子串。字符串 r 的从 第 i 个字符开始的后缀表示为 Suffix(i) ,也就是Suffix(i)=r[i..len(r)] 。

例子:

01 字符串: "banana"的所有后缀如下:
02  
03 0 banana                          5 a
04 1 anana     对所有后缀排序        3 ana
05 2 nana      ---------------->     1 anana 
06 3 ana        字典序               0 banana 
07 4 na                              4 na  
08 5 a                               2 nana
09  
10 所以 "banana" 的后缀数组SA为: {5, 3, 1, 0, 4, 2}

名次数组:名次数组Rank[i]保存的是以i开头的后缀的排名,与SA互为逆。简单的说,后缀数组是“排在第几的是谁”,名次数组是“你排第几”。

构造算法

求解后缀数组的算法主要有两种:倍增算法DC3算法。在这里使用的是许智磊的倍增算法,复杂度为nlogn。关于详细求解后缀数组的算法,详见许智磊2004国家集训队论文

这里只给出最直接的求解算法,就是先求得所有的后缀子串,再进行一次排序。

// 朴素的后缀树组构造算法
#include <iostream>
#include <cstring>
#include <algorithm>
using namespace std;

// 表示一个后缀,index是后缀的开始下标位置
struct suffix
{
    int index;
    char *suff;
};

// 字典序比较后缀
int cmp(struct suffix a, struct suffix b)
{
    return strcmp(a.suff, b.suff) < 0? 1 : 0;
}

// 构造txt的后缀数组
int *buildSuffixArray(char *txt, int n)
{
    //结果
    struct suffix suffixes[n];

    for (int i = 0; i < n; i++)
    {
        suffixes[i].index = i;
        suffixes[i].suff = (txt+i);
    }

    // 排序
    sort(suffixes, suffixes+n, cmp);

    // 排在第几的是谁
    int *suffixArr = new int[n];
    for (int i = 0; i < n; i++)
        suffixArr[i] = suffixes[i].index;

    return  suffixArr;
}

//打印
void printArr(int arr[], int n)
{
    for(int i = 0; i < n; i++)
        cout << arr[i] << " ";
    cout << endl;
}

int main()
{
    char txt[] = "banana";
    int n = strlen(txt);
    int *suffixArr = buildSuffixArray(txt,  n);
    cout << "Following is suffix array for " << txt << endl;
    printArr(suffixArr, n);
    return 0;
}


输出:

1 Following is suffix array for banana
2 5 3 1 0 4 2

上面算法的实际复杂度为O(n2Logn),虽然排序的复杂度为O(nLogn) ,但是后缀之间的比较复杂度为 O(n)。

如何利用后缀数组来匹配字符串?

在回到那个经典的字符串匹配问题,如何在text中查找模式串pattern?有了后缀数组,我们就可以用二分查找来进行搜索。下面是具体的算法:

void search(char *pat, char *txt, int *suffArr, int n)
{
    int m = strlen(pat);  

    int l = 0, r = n-1;  
    while (l <= r)
    {
        // 查看 'pat'是否是中间的那个后缀的前缀字串
        int mid = l + (r - l)/2;
        int res = strncmp(pat, txt+suffArr[mid], m);

        if (res == 0)
        {
            cout << "Pattern found at index " << suffArr[mid];
            return;
        }
        if (res < 0) r = mid - 1;
        else l = mid + 1;
    }
    cout << "Pattern not found";
}

int main()
{
    char txt[] = "banana";  // text
    char pat[] = "nan";   // 模式串

    // 构造后缀数组
    int n = strlen(txt);
    int *suffArr = buildSuffixArray(txt, n);

    // 在txt中搜索pat是否出现
    search(pat, txt, suffArr, n);
    return 0;
}

输出:

1 Pattern found at index 2

上面这个搜索算法的复杂度为O(mLogn),其实还有更高效的基本后缀数组的算法,后续再做讨论。

后缀数组的应用

先定义height数组,height[i] = suffix(SA[i-1])和suffix(SA[i])的最长公共前缀,也就是排名相邻的两个后缀的最长公共前缀。

suffix-array

例1:最长公共前缀
给定一个串,求任意两个后缀的最长公共前缀。
解:先根据rank确定这两个后缀的排名i和j(i<j),在height数组i+1和j之间寻找最小值。(可以用rmq优化)

例2:最长重复子串(不重叠)(poj1743)
解:二分长度,根据长度len分组,若某组里SA的最大值与最小值的差>=len,则说明存在长度为len的不重叠的重复子串。

例3:最长重复子串(可重叠)
解:height数组里的最大值。这个问题等价于求两个后缀之间的最长公共前缀。

例4:至少重复k次的最长子串(可重叠)(poj3261)
解:二分长度,根据长度len分组,若某组里的个数>=k,则说明存在长度为len的至少重复k次子串。

例5:最长回文子串(ural1297)
给定一个串,对于它的某个子串,正过来写和反过来写一样,称为回文子串。
解:枚举每一位,计算以这个位为中心的的最长回文子串(注意串长要分奇数和偶数考虑)。将整个字符串反转写在原字符串后面,中间用$分隔。这样把问题转化为求某两个后缀的最长公共前缀。

例6:最长公共子串(poj2774)
给定两个字符串s1和s2,求出s1和s2的最长公共子串。
解:将s2连接到s1后,中间用$分隔开。这样就转化为求两个后缀的最长公共前缀,注意不是height里的最大值,是要满足sa[i-1]和sa[i]不能同时属于s1或者s2。

例7:长度不小于k的公共子串的个数(poj3415)
给定两个字符串s1和s2,求出s1和s2的长度不小于k的公共子串的个数(可以相同)。
解:将两个字符串连接,中间用$分隔开。扫描一遍,每遇到一个s2的后缀就统计与前面的s1的后缀能产生多少个长度不小于k的公共子串,这里s1的后缀需要用单调栈来维护。然后对s1也这样做一次。

例8:至少出现在k个串中的最长子串(poj3294)
给定n个字符串,求至少出现在n个串中k个的最长子串。
将n个字符串连接起来,中间用$分隔开。二分长度,根据长度len分组,判断每组的后缀是否出现在不小于k个原串中。





01 假设有4个元素 0, 1, 2, 3
02 初始化
03 0 1 2 3
04  
05 Do Union(0, 1)
06    1   2   3 
07   /
08  0
09  
10 Do Union(1, 2)
11      2   3  
12     /
13    1
14  /
15 0
16  
17 Do Union(2, 3)
18          3   
19         /
20       2
21      /
22    1
23  /
24 0

以上操作可以优化到O(Log n)在最糟糕的情况下。方法就是在每次合并都把深度较小的集合合并在深度较大的集合下面 。这种技术被称为按秩合并。这样可以防止树的退化,最坏情况不会出现。

01 继续用上面的例子
02 0 1 2 3
03  
04 Do Union(0, 1)
05    1   2   3 
06   /
07  0
08  
09 Do Union(1, 2)
10    1    3
11  /  \
12 0    2
13  
14 Do Union(2, 3)
15     1   
16  /  |  \
17 0   2   3

第二个要优化的就是find(). 路径压缩实际上是在找完根结点之后,在递归回来的时候顺便把路径上元素的父亲指针都指向根结点。

01 假设集合{0, 1, .. 9} 的树表示如下所示:  当调用 find(3)时
02  
03               9
04          /    |    \ 
05         4     5      6
06      /     \        /  \
07     0        3     7    8
08             /  \
09            1    2 
10  
11 我们向上查找,找到是这个集合的根节点. 路径压缩就是:直接把 3的 父节点 设置为 9
12 当下次再查找 1, 2 或 3 时,查找的路径就会变短。
13  
14                9
15          /    /  \    \
16         4    5    6     3
17      /           /  \   /  \
18     0           7    8  1   2

这两个优化方法互为补充。每个操作的时间复杂度比O(Logn)要小。事实上,摊销时间复杂度实际上变成了小的常数。


线段树:

实际上还是称为区间树更好理解一些。
树:是一棵树,而且是一棵二叉树。
线段:树上的每个节点对应于一个线段(还是叫“区间”更容易理解,区间的起点和终点通常为整数)
同一层的节点所代表的区间,相互不会重叠。
叶子节点的区间是单位长度,不能再分了。

 

线段树是一棵二叉树,树中的每一个结点表示了一个区间[a,b]。a,b通常是整数。每一个叶子节点表示了一个单位区间。对于每一个非叶结点所表示的结点[a,b],其左儿子表示的区间为[a,(a+b)/2],右儿子表示的区间为[(a+b)/2,b](除法去尾取整)。

线段树的基本用途:
线段树适用于和区间统计有关的问题。比如某些数据可以按区间进行划分,按区间动态进行修改,而且还需要按区间多次进行查询,那么使用线段树可以达到较快查询速度。

线段树的构建

   createSeg   //以节点v为根建树、v对应区间为[l,r]
{
    对节点v初始化
     if (l!=r)
    {
        以v的左孩子为根建树、区间为[l,(l+r)/2]
        以v的右孩子为根建树、区间为[(l+r)/2+1,r]
    }
}
例题:

/*
Given an integer array nums, find the sum of the elements between indices i and j (i ≤ j), inclusive.
The update(i, val) function modifies nums by updating the element at index i to val.
Example:
Given nums = [1, 3, 5]
sumRange(0, 2) -> 9
update(1, 2)
sumRange(0, 2) -> 8
Note:
The array is only modifiable by the update function.
You may assume the number of calls to update and sumRange function is distributed evenly.
*/

public class NumArray {
    
    public class segmentTree {
        public int start;
        public int end;
        public segmentTree left;
        public segmentTree right;
        public int sum;
        
        public segmentTree(int start, int end){
            this.start = start;
            this.end = end;
            sum = 0;
            left = null;
            right = null;
        }
    }
    
    private segmentTree root;
    
    public NumArray(int[] nums) {
        if(nums.length != 0){
            root = build(0,nums.length-1,nums);
        }
    }
    
    segmentTree build(int start, int end, int [] nums){
        segmentTree node = new segmentTree(start,end);
        if(start == end){
            node.sum = nums[start];
        }
        else{
            int mid = start + (end-start)/2;
            node.left = build(start,mid,nums);
            node.right = build(mid+1,end,nums);
            node.sum = node.left.sum + node.right.sum;
        }
        return node;
    }

    void update(int i, int val) {
        update(root,i,val);
    }
    
    void update(segmentTree root, int i, int val){
        if(root.start == root.end){
            root.sum = val;
        }
        else{
            int mid = root.start + (root.end-root.start)/2;
            if(i <= mid){
                update(root.left,i,val);
            }
            else{
                update(root.right,i,val);
            }
            root.sum = root.left.sum + root.right.sum;
        }
    }

    public int sumRange(int i, int j) {
        return sumRange(root,i,j);
    }
    
    int sumRange(segmentTree root, int i, int j){
        if(root.start == i && root.end == j){
            return root.sum;
        }
        else{
            int mid = root.start + (root.end-root.start)/2;
            if(j <= mid){
                return sumRange(root.left,i,j);
            }
            else if(i >= mid+1){
                return sumRange(root.right,i,j);
            }
            else{
                return sumRange(root.left,i,mid) + sumRange(root.right,mid+1,j);
            }
        }
    }
}<span style="font-size:32px;">
</span>


树状数组(binary indexes tree)

1.“树状数组”数据结构的一种应用

  对含有n个元素的数组(a[1],...,a[k],...,a[n]):

  (1)求出第i个到第j个元素的和,sum=a[i]+...+a[j]。

    进行j-i+1次加法,复杂度为O(j-i+1)

  (2)任意修改其中某个元素的值。

    使用数组下标可以直接定位修改,时间复杂度为O(1)

   对于同时支持上述两种操作的系统中,求和操作(1)求任意连续个数组元素和的平均时间复杂度为O(n),修改操作(2)时间复杂度是O(1)。如果系统中大量进行上述两种操作m次,其中执行操作(1)概率1/p,操作(2)概率1-1/p,则系统时间复杂度为:

  可以使用树状数组使得上述两种操作的时间复杂度为O(m*logn)

2.树状数组介绍

  核心思想:

    (1)树状数组中的每个元素是原数组中一个或者多个连续元素的和。

    (2)在进行连续求和操作a[1]+...+a[n]时,只需要将树状数组中某几个元素的和即可。时间复杂度为O(lgn)

    (3)在进行修改某个元素a[i]时,只需要修改树状数组中某几个元素的和即可。时间复杂度为O(lgn)

  下图就是一个树状数组的示意图:

  解释如下:

  1) a[]: 保存原始数据的数组。(操作(1)求其中连续多个数的和,操作(2)任意修改其中一个元素)

    e[]: 树状数组,其中的任意一个元素e[i]可能是一个或者多个a数组中元素的和。如e[2]=a[1]+a[2]; e[3]=a[3]; e[4]=a[1]+a[2]+a[3]+a[4]。 

  2) e[i]是几个a数组中的元素的和?

    如果数字 i 的二进制表示中末尾有k个连续的0,则e[i]是a数组中2^k个元素的和,则e[i]=a[i-2^k+1]+a[i-2^k+2]+...+a[i-1]+a[i]。

    如:4=100(2)  e[4]=a[1]+a[2]+a[3]+a[4];

      6=110(2)  e[6]=a[5]+a[6]

      7=111(2)  e[7]=a[7]

  3) 后继:可以理解为节点的父亲节点。离它最近的,且编号末位连续0比它多的就是的父亲,如e[2]是e[1]的后继;e[4]是e[2]的后继。

      如e[4] = e[2]+e[3]+a[4] = a[1]+a[2]+a[3]+a[4] ,e[2]、e[3]的后继就是e[4]。

      后继主要是用来计算e数组,将当前已经计算出的e[i]添加到他们后继中。

    前驱:节点前驱的编号即为比自己小的,最近的,最末连续0比自己多的节点。如e[7]的前驱是e[6],e[6]的前驱是e[4]。

        前驱主要是在计算连续和时,避免重复添加元素。

      如:Sum(7)=a[1]+...+a[7]=e[7]+e[6]+e[4]。(e[7]的前驱是e[6], e[6]的前驱是e[4])

    计算前驱与后继:

      lowbit(i) = ( (i-1) ^ i) & i ; 利用二进制补码有:lowbit(i) = i&(-i)

      节点e[i]的前驱为 e[ i - lowbit(i) ];

      节点e[i]的后继为 e[ i + lowbit(i) ]

例题:同线段树的例题一样,解法如下:

public class NumArray {
    /**
     * Binary Indexed Trees (BIT or Fenwick tree):
     * https://www.topcoder.com/community/data-science/data-science-
     * tutorials/binary-indexed-trees/
     * 
     * Example: given an array a[0]...a[7], we use a array BIT[9] to
     * represent a tree, where index [2] is the parent of [1] and [3], [6]
     * is the parent of [5] and [7], [4] is the parent of [2] and [6], and
     * [8] is the parent of [4]. I.e.,
     * 
     * BIT[] as a binary tree:
     *            ______________*
     *            ______*
     *            __*     __*
     *            *   *   *   *
     * indices: 0 1 2 3 4 5 6 7 8
     * 
     * BIT[i] = ([i] is a left child) ? the partial sum from its left most
     * descendant to itself : the partial sum from its parent (exclusive) to
     * itself. (check the range of "__").
     * 
     * Eg. BIT[1]=a[0], BIT[2]=a[1]+BIT[1]=a[1]+a[0], BIT[3]=a[2],
     * BIT[4]=a[3]+BIT[3]+BIT[2]=a[3]+a[2]+a[1]+a[0],
     * BIT[6]=a[5]+BIT[5]=a[5]+a[4],
     * BIT[8]=a[7]+BIT[7]+BIT[6]+BIT[4]=a[7]+a[6]+...+a[0], ...
     * 
     * Thus, to update a[1]=BIT[2], we shall update BIT[2], BIT[4], BIT[8],
     * i.e., for current [i], the next update [j] is j=i+(i&-i) //double the
     * last 1-bit from [i].
     * 
     * Similarly, to get the partial sum up to a[6]=BIT[7], we shall get the
     * sum of BIT[7], BIT[6], BIT[4], i.e., for current [i], the next
     * summand [j] is j=i-(i&-i) // delete the last 1-bit from [i].
     * 
     * To obtain the original value of a[7] (corresponding to index [8] of
     * BIT), we have to subtract BIT[7], BIT[6], BIT[4] from BIT[8], i.e.,
     * starting from [idx-1], for current [i], the next subtrahend [j] is
     * j=i-(i&-i), up to j==idx-(idx&-idx) exclusive. (However, a quicker
     * way but using extra space is to store the original array.)
     */

    int[] nums;
    int[] BIT;
    int n;

    public NumArray(int[] nums) {
        this.nums = nums;

        n = nums.length;
        BIT = new int[n + 1];
        for (int i = 0; i < n; i++)
            init(i, nums[i]);
    }

    public void init(int i, int val) {
        i++;
        while (i <= n) {
            BIT[i] += val;
            i += (i & -i);
        }
    }

    void update(int i, int val) {
        int diff = val - nums[i];
        nums[i] = val;
        init(i, diff);
    }

    public int getSum(int i) {
        int sum = 0;
        i++;
        while (i > 0) {
            sum += BIT[i];
            i -= (i & -i);
        }
        return sum;
    }

    public int sumRange(int i, int j) {
        return getSum(j) - getSum(i - 1);
    }
}




  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值