[Summary simple analysis tree data structure] (js implemented attached)

This article will be for the tree data structure, elaborate relevant content. In fact, this article should be regarded as one study notes.

Content Overview

  • tree
  • Binary Tree
  • Binary search tree
  • Some operations stack and heap
  • Heapsort
  • Application heap

In addition, I will give here the source address js implementation:

  1. Tree and binary tree
  2. Heap implementation
  3. Heapsort

tree

Here simply say what the next tree yes.

Tree data structure is non-linear. Elements in the tree is called "nodes." Each node has a limited or no child nodes child nodes, and the tree can not have a loop.

5821838-b58e4d5b7f62dbf1.jpg
tree

Coupled to the relationship between two nodes is referred to as "parent-child relationship."

Some terms (taken from Wikipedia):

  1. Of the node: a node number subtree containing of the node is referred to;
  2. Of the tree: a tree, the maximum degree of the tree is called a node;
  3. Terminal node or leaf node: node is equal to zero;
  4. A non-terminal node or a branch node: node degree is not zero;
  5. Father or parent node: If a node comprising a child node, this node is called a child node of its parent node;
  6. Child or children: the root node of a subtree containing the called child nodes of the node;
  7. Sibling nodes: nodes having the same parent node is called mutual sibling nodes;
  8. Level node: from the definition of the start from the root, the root of the first layer, children of the root of the second layer, and so on;
  9. Depth: For any node n, n is the depth of the unique path from the root to the length n, the depth of root is 0;
  10. Height: For any node n, n is the height to a length of the longest path from the leaf of n, the height of all leaves is 0;
  11. Cousins ​​node: parent nodes at the same level mutually cousins;
  12. Ancestor node: All nodes on branches from the root through to the node;
  13. Sons: a node in the subtree rooted at a node referred to in any of the descendant node.
  14. Forest: a set of m (m> = 0) disjoint trees called forest trees;

Binary Tree

There are many types of trees, such as binary, ternary tree, quad tree and so on. But the most common tree is the binary tree.

Each node of a binary tree is only a maximum of two branches of a tree structure, the two branches of nodes is called the left child node and the right child node .

Full Binary , referring to the addition to the leaf nodes, each node has two child nodes of a binary tree.

Complete binary tree : Except for the last one, the number of nodes other layers have the largest, and the last layer arranged in a binary tree nodes left.

5821838-14817bdb3ffbaaa0.jpg
满二叉树

可能有人觉得完全二叉树看起来好像没什么用,怎么还靠左边的,靠中间不行吗?其实靠左是因为二叉树的其中一种数据存储方式是用数组存储,使用完全二叉树就不会浪费数组的空间(不会出现一些数组元素不存储的情况)

二叉树的存储

1. 链式存储法

链式存储法,是通过指针的方式来记录父子关系的一种方法。它有点类似链表,每个节点除了保存自身的数据外,还会有left 和 right 两个指针,指向另外两个节点。

const node = {
    data: 1,         // 节点保存的数据
    left: node2,    // 左子节点指向 node2 节点
    right: null     // null 表示没有右子节点
}
2. 顺序存储法

用数组存储。为了代码可读性更好,我们一般会选择浪费数组下标为 0 的存储位置,即根节点在下标为 1 的位置。 这时父节点和左右节点的下标关系如下:

left = 2 * i;
right = 2 * i + 1;
i = left / 2;   
i = right / 2;  // 这里是向下取整

这里的 i 为父节点下标,left 和 right 为两个子节点下标。

这里有个要注意的地方:这里的父节点的下标值是子节点除以 2 并 取整。(有些编程语言的整数相除,会自动将得到的结果去掉小数部分,而一些编程语言,比如 Javascript,是会得到小数的,需要手动向下取整。)

如果你就是不想浪费数组的第一个元素的存储位置,誓要将根节点保存在数组的第一个位置。那此时父节点和子节点的下标关系为:

left = 2 * i + 1;
right = 2 * i + 2;
i = (left - 1) / 2;
i = (right - 1) / 2;

如果某棵二叉树是一棵完全二叉树,那用数组存储无疑是最节省内存的一种方式。

二叉树的遍历

这个是很常见的面试题呢。

1. 前序遍历

根左右。 这里的“前”描述的是根节点,即根节点最先输出(打印),然后输出左子树,最后输出右子树。

代码中的树是用 链式存储法 存储的。代码实现用到了 递归

// 前序遍历(根左右)
preOrder() {
    let order = '';
    r(this.root);

    order = order.slice(0, order.length - 1); // 这里只是去掉最后的一个逗号。
    return order;

    // 递归函数
    function r(node) {
        if (!node) return;
        order += `${node.val},`;
        r(node.left);
        r(node.right);
    }
},
2. 中序遍历

左根右。 “中序”的这个“中”也是指的根节点的输出位置是中间。中序遍历先输出左子树,再输出根节点,最后输出右子树。

// 中序遍历
inOrder() {
    let order = '';
    r(this.root);

    order = order.slice(0, order.length - 1);
    return order;

    // 递归函数
    function r(node) {
        if (!node) return;
        r(node.left);
        order += `${node.val},`;
        r(node.right);
    }
},
3. 后续遍历

左右根。 先打印左子树,然后打印根节点,最后打印右子树。

postOrder() {
    let order = '';
    r(this.root);

    order = order.slice(0, order.length - 1);
    return order;

    // 递归函数
    function r(node) {
        if (!node) return;
        r(node.left);
        r(node.right);
        order += `${node.val},`;
    }
},
4. 层次遍历

层次遍历,就是每层的节点从左往右遍历,直到遍历完所有节点。如果是顺序存储法存储的,数组从前往后遍历即可。如果是链式存储法存储树,实现就会复杂一些,要用到一个队列

levelOrder() {
    if (this.root == null) return '';
    let a = [],
        left, right;
    a.push(this.root);

    // 节点入队,指针指向头部元素,如果它有left/right,入队。
    // 指针后移,继续同样步骤。。。直至指针跑到队列尾部后面去。。。
    for(let i = 0; i < a.length; i++) {     // 需要注意的是,数组 a 的长度是动态变化的(不停变长)
        left = a[i].left;
        if (left) a.push(left);

        right = a[i].right;
        if (right) a.push(right);
    }
    return a.map(item => item.val).join(',');
}

二叉查找树

二叉查找树,也叫做 二叉搜索树。此外它也被称为 二叉排序树,因为中序遍历就可以得到有序的数据序列(非常高效,时间复杂度是 O(n))。

二叉查找树的作用是快速查找。除了快速查找,它也支持快速插入、删除数据。

那么什么样的二叉树才是二叉查找树呢?二叉查找树是任意一个节点的左子树的节点都小于该节点,任意一个节点的的右子树的节点都大于该节点的二叉树。

根据定义,二叉查找树是 不允许有两个数据相同的节点的

二叉查找树的查找操作

先和根节点的值比较,如果相等,就找到了;如果要查找的值比根节点小,就在左子树中递归查找;如果比根节点大,就在右子树中递归查找。

find(val) {
    // 假设二叉树没有重复数据
    let p = this.root;
    while(p != null) {
        if (val == p.val) return p;
        else if (val < p.val) p = p.left;
        else p = p.right;
    }
    return null; // 没找到
},

二叉查找树的插入操作

类似查找操作。从根节点开始,比较要插入的数据和节点的大小关系。如果要插入的数据比当前节点的数据大,且右子树为空,就作为该节点的右子节点;如果右子树不为空,就继续递归右子树。同理,比当前节点数据小,就看该节点的左子树,若为空,插入到左子节点位置;若不为空,就递归左子树。

// 插入节点
insert(val) {
    if (this.root == null) {
        this.root = node;
        return true;
    }

    let node = new Node(val);
    let p = this.root;

    while (p != null) {
        if (val < p.val) {
            if (p.left == null) {
                p.left = node;
                return this.inOrder(); 
                // 返回个中序遍历的结果,检查插入是否正确。(你可以改为 true,表示插入成功)
            }
            p = p.left;
        }
        else if (val > p.val) {
            // preP = p;
            if (p.right == null) {
                p.right = node;
                return this.inOrder();
            }
            p = p.right;
        }

        if (val == p.val) {
            console.warn('二叉树中含相同值的数据,无法插入')
            return false;
        }
    }
},

二叉查找树的删除操作

这个就很复杂,要分三种情况:

  1. 要删除的节点没有子节点:直接更新父节点指向其的指针为 null
  2. 要删除的节点只有一个子节点:父节点中指向要删除的节点的指针,更新为要删除节点的那个子节点。
  3. 要删除的节点有两个子节点:找到右子树中的最小节点(一般是叶子节点),替换到要删除的节点上。(当然你也可以选择找左子树的最大节点)
// 删除
remove(val) {
    // 假设二叉树没有重复数据
    let p = this.root;
    let parent, dir;   // 暂不考虑只有根节点一个的情况。
    while(p != null) {
        
        if (val == p.val) {
            // 要删除的节点没有子节点
            if (p.left == null && p.right == null) {
                parent[dir] = null;
                return true;
            } 
            // 要删除的节点只有一个子节点
            else if (p.left == null && p.right != null) {
                parent[dir] = p.right
                console.log('只有右节点');
                return true;
            } else if (p.right == null && p.left != null) {
                parent[dir] = p.left;
                console.log('只有左节点');
                return true;
            } 
            
            // 要删除的节点有两个子节点
            // 可以将右子树的最小结点替换到被删除的节点位置,并删除这个最小节点
            // 当然你也可以在左子树中找最大节点。
            else if (p.left != null && p.right != null) {
                // 因为要记录最小节点的父节点,所以不能用 this.findMin()
                // 第一步:找出最小节点 minP
                let minParent,
                    minP = p;
                while (minP) {
                    if (minP.left == null) {
                        // 找到。
                        break;
                        // return minP;
                    }
                    minParent = minP;
                    minP = minP.left;
                }

                // 第二步:替换(把数据转移过去即可)
                p.val = minP.val;

                // 第三步:删除最小节点
                if (minP.right == null) {
                    minParent.left = null;
                    console.log('最小节点没有子节点');
                    return true;
                } else if (minP.right != null) {
                    minParent.left = minP.right;
                    console.log('最小节点只有右节点');
                    return true;
                } 

            }
            return p;
        } 

        // 继续找要删除的节点。
        else {
            parent = p;
            if (val < p.val) {
                p = p.left;
                dir = 'left';
            } else {
                p = p.right;
                dir = 'right';
            }
        }
    }
    return null;   // 没找到

    // 要保存父节点,且要记录当前节点是父节点的 left 还是 right。
},

还有另一种简单的删除操作,就是标记一个节点为“已删除”,虽然操作变得简单了,但“已删除”的数据仍然在内存中,会浪费内存空间。

支持重复数据的二叉查找树

一般来说,根据定义,二叉查找树是不允许有重复数据的。但实际开发中,数据一般不可能不重复。所以我们看看怎么使二叉查找树支持重复数据存储:

  1. 节点改为可以存储多个数据,而不是只有一个数据。可以考虑链表和动态扩容数组。
  2. 插入过程中,如果发现已经有重复的数据了,就放到这个节点的右子树的最左节点的位置(当然你也可以考虑放到左子树的最右边节点位置)。如果是这样的实现的话,查找操作和删除操作就要跟着做一些小修改。

平衡二叉树

维基百科的定义:平衡二叉搜索树(英语:Balanced Binary Tree)是一种结构平衡的二叉搜索树,即叶节点高度差的绝对值不超过1,并且左右两个子树都是一棵平衡二叉树。它能在O(logn)内完成插入、查找和删除操作,最早被发明的平衡二叉搜索树为AVL树。

平衡二叉树的发明,是为了解决二叉树在不断插入、删除等动态操作后,导致时间复杂度退化的问题。它会让二叉树尽量地保持平衡,即保持 矮矮胖胖* 的样子。

平衡二叉树中,最为有名的就是 红黑树 了。是不是经常有群友开玩笑说他们招人,要求当场手写红黑树呢。红黑树的性能很好,广泛用于实际开发中,其他的平衡二叉树则很少出现在人们的视野之中。

平衡二叉树的实现很复杂,就暂时不详细分析了。

堆是一个 每个节点的数据都大于等于(或小于等于)它的子节点的完全二叉树。

对于每个节点的值都大于等于子树中每个节点值的堆,我们叫作 大顶堆。对于每个节点的值都小于等于子树中每个节点值的堆,我们叫作 小顶堆

因为堆是完全二叉树,所以我们用数组存储。

1. 插入一个元素

插入元素到堆,具体做法是插入到数组的末尾,然后通过 堆化 操作,对树进行调整,重新变成堆。

堆化(heapify),是指一个节点,不停地向上或向下进行交换,直到找到合适的位置,使当前的二叉树变成堆。

插入时进行的堆化是 从下往上堆化。新插入的元素位于末尾,需要不停地和父元素进行比较和进行交换,直到找到合适的位置结束,此时树就会又变成堆。

// 入堆,从下往上堆化。
insert(val) {
    // count 指的是当前数组存储数据的大小,n 为 数组的容量
    //(当然js的数组是动态数组。这里的 n 是我手动加的限制)
    if (this.count >= this.n) {   
        console.log('堆满了,别加了!!')
        return;
    }
    this.count++;

    let a = this.a;  // a 是存储数据的数组
    a[this.count] = val;

    let i = this.count,
        j = Math.floor(i/2);   // 临时存储 i/2

    while (i > 1 && a[i] > a[j]) {
        [a[i], a[j]] = [a[j], a[i]];
        i = j;
        j = Math.floor(i/2);
    }
    return true;
}

2. 删除堆顶元素

删除了堆顶元素后,我们需要把最后一个元素移动到堆顶元素位置,然后进行 从上往下的堆化

从上往下堆化具体的实现是:最后一个元素替换掉堆顶元素后,就比较堆顶元素和它的两个子节点,看看谁最大,如果不是堆顶元素最大,堆顶元素就和值最大的子节点交换。重复上面的步骤,直到当前节点最大或者当前节点成为叶子节点(到底了)。

// 删除堆顶元素
removeMax() {
    if (this.count == 0) return false;  // 堆为空

    this.a[1] = this.a[this.count];
    this.a[this.count] = undefined;
    this.count--;

    // 从上往下 堆化
    let i = 1,
        maxPos = i;

    while (true) {
        if (i * 2 <= this.count && this.a[i*2] > this.a[maxPos]) maxPos = i * 2;
        if (i * 2 + 1 <= this.count && this.a[i*2 + 1] > this.a[maxPos]) maxPos = i * 2 + 1;
        if (maxPos == i) {
            break;
        }
        [this.a[i], this.a[maxPos]] = [this.a[maxPos], this.a[i]];
        i = maxPos;
    }
    return true;
}

堆排序

堆排序算法分两个步骤:先建堆,然后进行排序。

1. 建堆

对数组进行 原地 建堆。原地是指在原数组上进行操作,不需要另开一个堆。

建堆的方式有两种:
  1. 借助前面提到的插入操作的方式。

这里就是往 “堆区域” 末尾插入元素,然后从下往上堆化。类似插入排序,我们将数组分为 “堆区域” 和 “未处理区域”,不停地将“未处理区域”里的元素插入到“堆区域” 中,直到遍历完整个数组。

  1. 从最后一个非叶子节点开始往前,进行 从上往下的堆化

叶子节点因为没有子节点,所以不需要进行堆化。另外,对于完全二叉树来说,最后一个非叶子节点的下标为 i / 2(i 从1开始,你可以自己画个完全二叉树验证一下)。

建堆的复杂度是 O(n)。

2. 排序

将堆顶节点和最后一个节点进行交换,然后对剩余的 n -1 个元素进行从上往下堆化。然后我们再交换堆顶节点和第 n - 1 个元素,然后对剩余的 n - 2 个元素进行从上往下堆化,就这样不停地交换和堆化,直到堆中只有一个元素。

性能分析

1. 堆排序的时间复杂度是 O(nlogn)。

建堆的时间复杂度是 O(n),排序过程的时间复杂度是 O(nlogn),所以堆排序的时间复杂度是 O(nlogn)。

2. 堆排序是不稳定的排序

因为排序过程中,堆顶元素会和堆的最后一个元素进行交换,导致排序不稳定。

3. 堆排序是原地排序

堆排序和快速排序的比较

快速排序比堆排序好。理由如下:

  1. 堆排序访问数据方式不好,是跳着访问数组元素的,不利于 CPU缓存
  2. 堆排序的交换操作更多。堆排序的建堆完成后,会降低数据的有序堆,这样会使得交换操作变多。

代码实现是在原数组上进行数据交换的。

堆的应用

1. 优先级队列

优先级可以用于解决 合并有序小文件、高性能定时器 等问题。

2. 求 TopK 数据

这个就是维护一个大小为 k 的小顶堆。将未入堆的元素和堆顶元素比较,如果比堆顶元素大,就入堆,直到所有元素都入堆后,这个堆就是 TopK 元素了。

时间复杂度是 O(nlogK)。最坏情况下,一次堆化需要 O(logK),要进行 n 次堆化操作。

3. 求中位数

如果是静态数据,先排序,然后求中位数即可,这样边际成本低。

如果是动态数据,那就需要维护一个 大顶堆 和一个小顶堆。要求大顶堆的数量等于小顶堆的数量或小顶堆的数量+1,且大顶堆的元素都小于小顶堆的元素。中位数即大顶堆的堆顶元素。

5821838-d9f37d64efbec5cc.jpg
大顶堆和小顶堆

初始化的时候,可以用类似 topK 算法,弄一个数量为 k 为 n/2 的小顶堆放数组右边,然后将剩余的元素转换为大顶堆。

Then each time data is added, the top of the stack elements, respectively, are relatively large and small stack top top top of the heap stack element, which side is inserted into the decision. After insertion, but also for some of the stacks of insertions and deletions in order to maintain both the number of stacks to be about the same (about the same number of elements or the left side of the heap one more than the right).

In addition to using the heap seeking the median, we can also use the computing stack "99% response time" problem. I.e., to maintain an amount of 99 / n and a large number of small top stack top stack of 1 / n.

references

  1. Wikipedia - tree (data structure)
  2. US data structures and algorithms of

Guess you like

Origin blog.csdn.net/weixin_34314962/article/details/90937016