zkw线段树

一世执手 提交于 2020-01-29 21:09:16

zkw线段树是zkw大神搞的自底向上线段树,以常数小,代码短著称。然而zkw大神的原ppt中描述简单,想了好长时间才想粗来。
以下内容针对区间最小值,使用更好理解的递归方式描述。

定义

zkw线段树定义如下:
1. 它是一棵满二叉树
2. 他的叶节点是一个数
3. 每一个非叶节点是一个数,且这个数是它的两个孩子中的较小值

显然,zkw线段树和普通线段树类似。他的叶节点从左到右是一个数列A1..n,非叶节点存一些信息以便查询区间最小值。
由于它是一个满二叉树,可以用堆式储存法储存。特别的,由于叶节点的个数为2的正整数幂,对于数据规模n,叶子节点的实际个数为2lgn+1没有数据的节点用填充

性质

  1. 不难发现,一棵有tn个节点的满二叉树有tn-1个非叶子节点。所以在堆式结构中,第i个叶子节点的位置是 tree[tn-1+i] 。这是一个很重要的性质,zkw线段树的许多操作是建立在他的基础上的。
  2. 由1容易得出,对于树上(堆中)任意一个位置i,如果i是偶数,那么它是一个左孩子;否则是一个右孩子。
              1
           2      3
        4    5  6   7
    //显然,所有奇数都是右孩子,偶数都是左孩子

建立数据结构

直接用静态数组建立即可,i的左右孩子分别为i*2,i*2+1

const int maxn = 100000;
// 最多节点数
int tree[maxn*4]; // 足够大,防止越界
int n;
// 数据规模
int tn;
// 叶子节点个数

我们定义一些函数来方便下面的操作

inline int twice(int a)
{
  return a<<1;
  // 二倍
}
inline int half(int a)
{
  return a>>1;
  // 一半
}
inline bool rightc(int a)
{
  return a&1;
  // 性质2得出
}
inline void fix(int i) {
  tree[i] = min(tree[twice(i)], tree[(twice(i)+1)]);
  // 求tree[i]并更新
}

建树

zkw线段树的建树和普通线段树一样,这里不在赘述。不过由于使用了堆式储存,代码大大简化。

int make_tree(int i)
{
  if (i >= tn) 
    return tree[i];
  // 到达叶子节点
  return tree[i] = min(
  make_tree(twice(i)),
  make_tree(twice(i)+1));
  // 否则为左右孩子最小值
}

其中,叶节点即 tree[tn]及以后内容已经被输入,因此直接返回即可。
非递归方式也很简单,递推即可。

void make_tree()
{
  for (int i=tn-1; i>=1; i--) 
    fix(i);
}

复杂度显然为Θ(n)。

查询区间最小值

这就是zkw线段树的精华所在。zkw线段树的更新和查询都是 自底向上 的。他的查询i..j最小值递归方法如下:
1. 找到i, j的实际位置(在调用时处理),为i = tn-1+i, j = tn-1+j
2. 如果j=i,原区间最小值为tree[i];如果j-i==1,原区间最小值为min(tree[i],tree[j])
3. 如果i 是右孩子 ,则原区间最小值为tree[i]和区间i+1..j最小值中的较小值;如果j 是左孩子 ,则原区间最小值是tree[j]和区间i..j-1最小值中的较小值。
4. 否则原区间最小值为区间i/2..j/2的最小值

这种方法的正确性是显然的,只要自己动手试一试就可以明白。2是边界,3是fix;4则运用了zkw线段树的性质——i/2包含了i,i+1两个子树中的最小值,这意味着i+1这个子树不必再进行计算,只需要直接使用i/2的值。
这是一个良好的 尾递归 算法,这意味着即使你不将递归改为循环,编译器也会自动优化他从而使他的效率可以与循环媲美。我们在最后给出循环版本的算法描述。

/*
* 查询i..j的区间应该调用
* ask(tn-1+i, tn-1+j)
*/
int ask (int i, int j)
{
  if (i == j)
    return tree[i];
  // 只包含一个元素
  if (j-i == 1)
    return min(tree[i],tree[j]);
  // case 2
  if (rightc(i))
    return min(tree[i],ask(i+1,j));
  if (!rightc(j))
    return min(tree[j],ask(i,j-1));
  // case 3
  return ask(half(i),half(j));
  // case 4
}

分析算法复杂度
不妨将操作3称为“平移”(左右两边算一次操作),操作4称为“上升”。显然平移操作越多,算法运行的越慢。然而 不可能连续进行两次平移操作 ,所以最多只有一半的操作是平移。
再分析上升。显然的是,一次上升后,区间大小j-i会变成 j/2-i/2 = (j-i)/2 ,即区间缩小一倍。
- 假设共进行一半平移操作。
既然这样,就可以把一次平移和一次上升看作一个整体。n为j-i(区间大小)。列递归式:

T(n)=T(n2)+Θ(1)

解得算法最坏情况为O(lgn)
- 假设没有上升,则效率最好,列递归式

T(n)=T(n2)+Θ(1)

解得最好情况为Ω(lgn)
所以求区间最值复杂度为Θ(lgn)

点修改操作

点修改十分简单,因为第i个数简单的是 tree[tn-1+i] ,所以不再有向下试探的操作,只需要自顶向上的修改即可。
递归形式的代码(同样是尾递归的)

// tree[tn-1+i] = j
// change(tn-1+i)
void change(int i) {
  if (i == 1) return;
  fix(half(i));
  change(half(i));
}

循环也不难得出

void change(int i, int j) {
  i += tn-1;
  tree[i] = j;
  i = half(i);
  while (i != 1) {
    fix(i);
    i = half(i);
  }
}

复杂度分析
由递归版本得出递归式

T(n)=T(n2)+Θ(1)

解得 T(n)=Θ(lgn)

zkw线段树的空间优越性

你也许会认为开一个so big的数组空间会爆,实际上恰恰相反。 zkw线段树的空间利用率高于普通线段树 。这是因为普通线段树有大量的 指针 占用空间,zkw只使用下标索引,空间大大降低。
经测试,zkw线段树占用空间为普通线段树的一半(最大数据)。

zkw线段树为什么是高效的

zkw线段树自底向下是他的先天优势,这意味着他不再需要向下试探。zkw线段树算法是 一次方法 ,因此不论是递归还是循环都减少了一半的工作量。且自底向上不需要区间覆盖的几种情况, 编程复杂度 大大降低。

完整程序

例题为tyvj模板题 忠诚2

代码本来可以更紧凑,但是为了保留可读性,写的并没有zkw大神的魔性
最后Orz zkw大神

循环版

#include <iostream>
#include <cstdio>
#include <cmath>
#include <cstring>
using namespace std;
const int maxn = 100000;

int tree[maxn*4];
int n;
int tn;

inline int twice(int a)
{
  return a<<1;
}
inline int half(int a)
{
  return a>>1;
}
inline bool rightc(int a)
{
  return a&1;
}
inline void fix(int i) {
  tree[i] = min(tree[twice(i)], tree[(twice(i)+1)]);
}

void make_tree()
{
  for (int i=tn-1; i>=1; i--) fix(i);
}

int ask (int i, int j)
{
  int ans = 100000000;
  for (i+=tn-1,j+=tn-1; j>i;i=half(i),j=half(j)) {
    if (rightc(i)) ans = min(ans,tree[i++]);
    if (!rightc(j)) ans = min(ans,tree[j--]);
  }
  if (i == j) ans = min(ans, tree[j]);
  return ans;
}

void change(int i, int j) {
  i += tn-1;
  tree[i] = j;
  i = half(i);
  while (i != 1) {
    fix(i);
    i = half(i);
  }
}

int main() {
  int m,x,y;
  int c;
  scanf ("%d%d", &n,&m);
  memset(tree,127,sizeof tree);
  tn = n*2;
  tn = 1<<(int)(log(tn)/log(2));
  for (int i = 1; i <= n; i++) 
    scanf ("%d", &tree[tn-1+i]);
  make_tree();
  for (int i = 1; i <= m; i++) {
    scanf("%d %d %d",&c,&x,&y);
    if (c == 1)
      printf("%d ",ask(x, y));
    else {
      change(x, y);
    }
  }
  return 0;
}

递归版

#include <iostream>
#include <cstdio>
#include <cmath>
#include <cstring>
using namespace std;
const int maxn = 100000;

inline int twice(int a)
{
  return a<<1;
}
inline int half(int a)
{
  return a>>1;
}
inline bool rightc(int a)
{
  return a&1;
}

int tree[maxn*4];
int n;
int tn;

int make_tree(int i)
{
  if (i >= tn) 
    return tree[i];
  return tree[i] = min(
  make_tree(twice(i)),
  make_tree(twice(i)+1));
}

int ask (int i, int j)
{
  if (i == j)
    return tree[i];
  if (j-i == 1)
    return min(tree[i],tree[j]);
  if ( rightc (i))
    return min(tree[i],ask(i+1,j));
  if (! rightc (j))
    return min(tree[j],ask(i,j-1));
  return ask(half(i),half(j));
}

inline void fix(int i) {
  tree[i] = min(tree[twice(i)], tree[(twice(i)+1)]);
}

void change(int i) {
  if (i == 1) return;
  fix(half(i));
  change(half(i));
}

int main() {
  int m,x,y;
  int c;
  scanf ("%d%d", &n,&m);
  memset(tree,127,sizeof tree);
  tn = n*2;
  tn = 1<<(int)(log(tn)/log(2));
  for (int i = 1; i <= n; i++) 
    scanf ("%d", &tree[tn-1+i]);
  make_tree(1);
  for (int i = 1; i <= m; i++) {
    scanf("%d %d %d",&c,&x,&y);
    if (c == 1)
      printf("%d ",ask(tn-1+x, tn-1+y));
    else {
      tree[tn-1+x] = y;
      change(tn-1+x);
    }
  }
  return 0;
}
易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!