【二叉树】平衡二叉树

1 平衡二叉树简介

AVL树是最早被发明的自平衡二叉查找树。在AVL树中,任一节点对应的两棵子树的最大高度差为1,因此它也被称为高度平衡树。查找、插入和删除在平均和最坏情况下的时间复杂度都是O(logn)。增加和删除元素的操作则可能需要借由一次或多次树旋转,以实现树的重新平衡。AVL树得名于它的发明者G. M. Adelson-Velsky和Evgenii Landis,他们在1962年的论文An algorithm for the organization of information 中公开了这一数据结构。 节点的平衡因子是它的左子树的高度减去它的右子树的高度(有时相反)。带有平衡因子1、0或 -1的节点被认为是平衡的。带有平衡因子 -2或2的节点被认为是不平衡的,并需要重新平衡这个树。平衡因子可以直接存储在每个节点中,或从可能存储在节点中的子树高度计算出来。

2 为什么需要平衡二叉树

首先平衡二叉树是二叉搜索树,而一般的二叉查找树的查询复杂度取决于目标结点到树根的距离(即深度),因此当结点的深度普遍较大时,查询的均摊复杂度会上升。为了实现更高效的查询,产生了平衡树。如下例子

一般的二叉搜索树在构建的时候,容易退化成单链表,导致查找效率降为O(n);而平衡二叉树则会克服这种问题。这里先引入平衡因子的概念,如图:

左边这棵树,左子树为空,右子树高度为3;左子树高度-右子树高度 = -3。

右边这棵树,左子树高度为2,右子树高度为1。左子树高度-右子树高度 = 1。

一颗没有左右子树的结点,高度为1。

还可以发现,对于右边这棵树的每一个结点,其左右子树高度之差的绝对值没有大于1的。所以,如果当一个树或者子树的左右子树高度之差绝对值大于等于2了,我们认为它失衡了,这棵树改一改还能更高效。同时,对于每个结点,以这个结点为根的子树,其左右子树高度之差叫做这个子树或者这个结点的平衡因子BF。而平衡二叉树是所有结点的平衡因子都小于2。

3 旋转

调整二叉搜索树为平衡二叉树一般是旋转二叉树,分为左旋和右旋,下面分别介绍两者。

3.1 左旋

总共分为3步:

1当前节点root的右子树会作为新树的根结点;

2当前节点root会作为新树根结点的左子树;

3如果新的树根,原来有左子树,原来的左子树就作为旧根结点的右子树。

见下图:

这里树的树高定义为:1 + max(左子树树高, 右子树树高)

代码

// 定义左旋转函数
Node* leftRoate(Node* root) {
    // 1当前节点root的右子树会作为新树的根结点
    // 2当前节点root会作为新树根结点的左子树
    // 3如果新的树根,原来有左子树,原来的左子树就作为旧根结点的右子树

    // 1当前节点root的右子树会作为新树的根结点
    Node* newRoot = root->right;
    // t2保存新树根原来的左子树
    Node* t2 = newRoot->left;

    // 2当前节点root会作为新树根结点的左子树
    newRoot->left = root;

    // 3如果新的树根,原来有左子树,原来的左子树就作为旧根结点的右子树
    root->right = t2;

    // 更新树高 root和newRoot
    // 左子树树高 和 右子树树高最大值 + 1
    root->height = 1 + max(getHeight(root->left), getHeight(root->right));
    newRoot->height = 1 + max(getHeight(newRoot->left), getHeight(newRoot->right));

    return newRoot;
}
3.2 右旋

总共分为3步:

1当前节点root的左子树会作为新树的根结点

2当前节点root会作为新树根结点的右子树

3如果新的树根,原来有右子树,原来的右子树就作为旧根结点的左子树

代码

// 定义右旋转函数
Node* rightRoate(Node* root) {
    // 1当前节点root的左子树会作为新树的根结点
    // 2当前节点root会作为新树根结点的右子树
    // 3如果新的树根,原来有右子树,原来的右子树就作为旧根结点的左子树

    // 1当前节点root的左子树会作为新树的根结点
    Node* newRoot = root->left;
    // t2保存新树根原来的右子树
    Node* t2 = newRoot->right;

    // 2当前节点root会作为新树根结点的右子树
    newRoot->right = root;

    // 3如果新的树根,原来有右子树,原来的右子树就作为旧根结点的左子树
    root->left = t2;

    // 更新树高 root和newRoot
    // 左子树树高 和 右子树树高最大值 + 1
    root->height = 1 + max(getHeight(root->left), getHeight(root->right));
    newRoot->height = 1 + max(getHeight(newRoot->left), getHeight(newRoot->right));

    return newRoot;
}

4 四种需要旋转的情况

想一个问题,会等到不平衡因子到3了再旋转吗?不可能,当不平衡情况刚出现,就要立马进行旋转,也就是说,当不平衡因子是2的时候就要旋转了。

有这么一种情况,插入了一个结点,导致一个子树不平衡了, 导致整个二叉树也不平衡了,该转子树还是整个树?肯定是先转发生不平衡的最小的那个子树,那个子树转好了,说不定整个树就不用转了。当然还有一种情况,就是子树不平衡,但是整个树是平衡的,这样不用说,只用转子树就行了。所以,综上,我们要转的就是发生不平衡的最小的那个二叉树。当最小的不平衡二叉树转平衡了,如果还不平衡,再找下一个最小不平衡的二叉树进行修改。

上面已经介绍了两种基础的旋转情况,后面的4四种导致二叉平衡树失衡的都可以通过上面两种基础的旋转来解决

什么情况可能导致失衡呢?

  • 插入

  • 删除

插入可能导致哪几种情况的失衡呢?

  • 在左孩子的左子树多了结点后导致失衡 LL型

  • 在左孩子的右子树多了结点后导致失衡 LR型

  • 在右孩子的左子树多了结点后导致失衡 RL型

  • 在右孩子的右子树多了结点后导致失衡 RR型

注意:这里我们要把失衡的情况和旋转分开,LL型,LR型等,这里的LR指的是导致失衡的叶子结点在哪个位置上,不是说LL型就是左转再左转。这两个一定要区分开。

总之,我们就是要用上面介绍的两种:左旋右旋来解决出现失衡的这四种情况。

通过穷举法,我们穷举出了一共可能得4种情况,那对应的这4种情况应该怎么修正呢?

首先,插入和删除都会导致不平衡,所以,我们的插入和删除的时候,先按照常规的二叉搜索树的逻辑进行插入和删除,然后,我们检查这个树是不是平衡二叉树,如果不是,找出来是上面四种情况的哪一种导致失衡的情况进行修改就行了。无论是插入还是删除,最终导致失衡的无非都是上面给出的四种情况,所以,接下来我们讨论给出的四种失衡情况该如何修改。

4.1 LL型失衡

描述:最小失衡子树的左孩子的左子树多了个结点导致失衡了。

解决:

  • 使用右旋就能解决

右旋步骤:

1当前节点root的左子树会作为新树的根结点

2当前节点root会作为新树根结点的右子树

3如果新的树根,原来有右子树,原来的右子树就作为旧根结点的左子树

图1是LL型,可以看到这个树不是平衡二叉树,而首先是以3根结点的二叉树不是平衡二叉树,先调整以3为根结点的二叉树,对其进行右旋,结果如图2所示;此时图2所示的位二叉树仍然不是平衡二叉树,为了方便演示,将其颜色变成图3所示,再对其进行右旋,结果如图4所示。

问题来了,怎么判断是LL型呢?

LL:该结点的平衡因子大于1 && 其左子树的平衡因子大于0

4.2 LR型失衡

描述:最小失衡子树的左孩子的右子树多了个结点导致失衡了。

解决:

  • 左子树先进行左旋,这时候就可以看成了LL型

  • 然后,进行右旋

问题来了,怎么判断是LR型呢?

LR:该结点的平衡因子大于1 && 其左子树的平衡因子小于0

4.3 RR型失衡

描述:最小失衡子树的右孩子的右子树多了个节点导致失衡了

解决:

  • 使用左旋就能解决了

图1是RR型,可以看到这个树不是平衡二叉树,而首先是以2根结点的二叉树不是平衡二叉树,先调整以2为根结点的二叉树,对其进行左旋,结果如图2所示;此时图2所示的位二叉树仍然不是平衡二叉树,为了方便演示,将其颜色变成图3所示,再对其进行左旋,结果如图4所示0。

问题来了,怎么判断是RR型呢?

RR:该结点的平衡因子小于-1 && 其右子树的平衡因子小于0

4.4 RL型失衡

描述:最小失衡子树的右孩子的左子树多了个节点导致失衡了

解决:

  • 对右子树先进行右旋,这样就会变成RR型失衡

  • 对整个树左旋

问题来了,怎么判断是RR型呢?

RL:该结点的平衡因子小于-1 && 其左子树的平衡因子大于0

4.5 判断旋转
  • LL:该结点的平衡因子大于1 && 其左子树的平衡因子大于0
  • LR:该结点的平衡因子大于1 && 其左子树的平衡因子小于0
  • RR:该结点的平衡因子小于-1 && 其右子树的平衡因子小于0
  • RL:该结点的平衡因子小于-1 && 其左子树的平衡因子大于0

5 插入代码

#include <stdio.h>
#include <stdlib.h>

// 定义树的结构
typedef struct Node {
    int val;                // 数据
    int height;             // 树高
    struct Node* left;      // 左子树
    struct Node* right;     // 右子树
} Node;


// 定义生成新的结点,返回值是指向这个结点的指针
Node* newNode(int val) {
    Node* node = (Node*)malloc(sizeof(Node));
    node->val = val;
    node->height = 1;
    node->left = NULL;
    node->right = NULL;

    return node;
}

// 获取树的高度
int getHeight(Node* node) {
    if (node == NULL) {
        return 0;
    }

    return node->height;
}

int max(int a, int b) {
    return a > b ? a : b;
}


// 获取平衡因子的函数
int getBalance(Node* node) {
    return getHeight(node->left) - getHeight(node->right);
}

// 定义左旋转函数
Node* leftRoate(Node* root) {
    // 1当前节点root的右子树会作为新树的根结点
    // 2当前节点root会作为新树根结点的左子树
    // 3如果新的树根,原来有左子树,原来的左子树就作为旧根结点的右子树

    // 1当前节点root的右子树会作为新树的根结点
    Node* newRoot = root->right;
    // t2保存新树根原来的左子树
    Node* t2 = newRoot->left;

    // 2当前节点root会作为新树根结点的左子树
    newRoot->left = root;

    // 3如果新的树根,原来有左子树,原来的左子树就作为旧根结点的右子树
    root->right = t2;

    // 更新树高 root和newRoot
    // 左子树树高 和 右子树树高最大值 + 1
    root->height = 1 + max(getHeight(root->left), getHeight(root->right));
    newRoot->height = 1 + max(getHeight(newRoot->left), getHeight(newRoot->right));

    return newRoot;
}


// 定义右旋转函数
Node* rightRoate(Node* root) {
    // 1当前节点root的左子树会作为新树的根结点
    // 2当前节点root会作为新树根结点的右子树
    // 3如果新的树根,原来有右子树,原来的右子树就作为旧根结点的左子树

    // 1当前节点root的左子树会作为新树的根结点
    Node* newRoot = root->left;
    // t2保存新树根原来的右子树
    Node* t2 = newRoot->right;

    // 2当前节点root会作为新树根结点的右子树
    newRoot->right = root;

    // 3如果新的树根,原来有右子树,原来的右子树就作为旧根结点的左子树
    root->left = t2;

    // 更新树高 root和newRoot
    // 左子树树高 和 右子树树高最大值 + 1
    root->height = 1 + max(getHeight(root->left), getHeight(root->right));
    newRoot->height = 1 + max(getHeight(newRoot->left), getHeight(newRoot->right));

    return newRoot;
}


// 定义插入结点的函数
Node* insertNode(Node* node, int key) {
    // 先二叉搜索树插入数据
    if (node == NULL) {
        return newNode(key);
    }
    if (key < node->val) {
        node->left = insertNode(node->left, key);
    } else if (key > node->val) {
        node->right = insertNode(node->right, key);
    } else {
        return node;
    }
    // 到这里都是二叉搜索树的插入操作
    // 更新树高
    node->height = 1 + max(getHeight(node->left), getHeight(node->right));

    // 获取当前结点的平衡因子(根据平衡因子决定是否调整)
    // LL型失衡-右转(满足:该结点的平衡因子大于1 && 其左子树的平衡因子大于0)
    int balance = getBalance(node);
    if (balance > 1 && getBalance(node->left) > 0) {
        return rightRoate(node);
    }

    // LR型失衡-对root左子树左旋,再对root进行右旋 (满足:该结点的平衡因子大于1 && 其左子树的平衡因子小于0)
    if (balance > 1 && getBalance(node->left) < 0) {
        node->left = leftRoate(node->left);
        return rightRoate(node);
    }

    // RR型失衡-左转(满足:该结点的平衡因子小于-1 && 其右子树的平衡因子小于0)
    if (balance < -1 && getBalance(node->right) < 0) {
        return leftRoate(node);
    }

    // RL型失衡-对root右子树右旋,再对root进行左旋 (满足:该结点的平衡因子小于-1 && 其左子树的平衡因子大于0)
    if (balance < -1 && getBalance(node->right) > 0) {
        node->right = rightRoate(node->right);
        return leftRoate(node);
    }

    return node;
}


// 先序遍历
void preOrder(Node* root) {
    if (root == NULL) {
        return;
    }
    printf("%d ", root->val);
    preOrder(root->left);
    preOrder(root->right);
}

// 中序遍历
void midOrder(Node* root) {
    if (root == NULL) {
        return;
    }
    midOrder(root->left);
    printf("%d ", root->val);
    midOrder(root->right);
}


// 查找函数
Node* findNode(Node* root, int key, int* counter) {
    Node* curNode = root;
    while (curNode != NULL) {
        if (key < curNode->val) {
            curNode = curNode->left;
            (*counter)++;
        } else if (key > curNode->val) {
            curNode = curNode->right;
            (*counter)++;
        } else {
            return curNode;
        }
    }

    return NULL;
}


// 测试函数
void test() {
    Node* root = NULL;
    root = insertNode(root, 10);
    root = insertNode(root, 20);
    root = insertNode(root, 30);
    root = insertNode(root, 40);
    root = insertNode(root, 50);
    root = insertNode(root, 60);
    root = insertNode(root, 70);

     int counter = 0;
     Node* result = findNode(root, 70, &counter);
     printf("找了%d次\n", counter);

    printf("-----------先序遍历----------\n");
    preOrder(root);

    printf("\n-----------中序遍历----------\n");
    midOrder(root);
}

int main(int argc, char const *argv[])
{
    test();
    return 0;
}

测试

6 删除节点

// 删除节点
// 前半部分是二叉搜索树的删除,后面是插入节点导致失衡,稍作调整
Node* deleteNode(Node* node, int key) {
    if (node == NULL) {
        return node;
    }
    if (node->val > key) {
        node->left = deleteNode(node->left, key);
    } else if (node->val < key) {
        node->right = deleteNode(node->right, key);
    } else if (node->val == key) {
        // 情况1,叶子结点
        if (node->left == NULL && node->right == NULL) {
            Node* temp = node;
            node = NULL;
            free(temp);
        } else if (node->left == NULL && node->right != NULL) {
            // 情况2:只有右子树
            Node* temp = node;
            node = node->right;
            free(temp);
        } else if (node->left != NULL && node->right == NULL) {
            // 情况3:只有左子树
            Node* temp = node;
            node = node->left;
            free(temp);
        } else if (node->left != NULL && node->right != NULL) {
            // 情况4:左右子树都有
            // 找到右子树上最左边的结点来接替该结点
            Node* curNode = node->right;
            while (curNode->left != NULL) {
                curNode = curNode->left;
            }
            node->val = curNode->val;
            node->right = deleteNode(node->right, curNode->val);
        }
    }
    // 删除完成,接下来是调整
    if (node == NULL) {
        return node;
    }
    // 更新树高
    node->height = 1 + max(getHeight(node->left), getHeight(node->right));

    // 计算平衡因子
    int balance = getBalance(node);

    // LL型-右转
    if (balance > 1 && getBalance(node->left) >= 0) {
        return rightRoate(node);
    }

    // LR型-对node左子树左旋,再对node进行右旋
    if (balance > 1 && getBalance(node->left) < 0) {
        node->left = leftRoate(node->left);
        return rightRoate(node);
    }

    // RR型-左转
    if (balance < -1 && getBalance(node->right) <= 0) {
        return leftRoate(node);
    }

    // RL型-对node右子树右旋,再对node进行左旋
    if (balance < -1 && getBalance(node->right) > 0) {
        node->right = rightRoate(node->right);
        return leftRoate(node);
    }

    return node;
}

测试

#include <stdio.h>
#include <stdlib.h>

// 定义树的结构
typedef struct Node {
    int val;                // 数据
    int height;             // 树高
    struct Node* left;      // 左子树
    struct Node* right;     // 右子树
} Node;


// 定义生成新的结点,返回值是指向这个结点的指针
Node* newNode(int val) {
    Node* node = (Node*)malloc(sizeof(Node));
    node->val = val;
    node->height = 1;
    node->left = NULL;
    node->right = NULL;

    return node;
}

// 获取树的高度
int getHeight(Node* node) {
    if (node == NULL) {
        return 0;
    }

    return node->height;
}

int max(int a, int b) {
    return a > b ? a : b;
}


// 获取平衡因子的函数
int getBalance(Node* node) {
    return getHeight(node->left) - getHeight(node->right);
}

// 定义左旋转函数
Node* leftRoate(Node* root) {
    // 1当前节点root的右子树会作为新树的根结点
    // 2当前节点root会作为新树根结点的左子树
    // 3如果新的树根,原来有左子树,原来的左子树就作为旧根结点的右子树

    // 1当前节点root的右子树会作为新树的根结点
    Node* newRoot = root->right;
    // t2保存新树根原来的左子树
    Node* t2 = newRoot->left;

    // 2当前节点root会作为新树根结点的左子树
    newRoot->left = root;

    // 3如果新的树根,原来有左子树,原来的左子树就作为旧根结点的右子树
    root->right = t2;

    // 更新树高 root和newRoot
    // 左子树树高 和 右子树树高最大值 + 1
    root->height = 1 + max(getHeight(root->left), getHeight(root->right));
    newRoot->height = 1 + max(getHeight(newRoot->left), getHeight(newRoot->right));

    return newRoot;
}


// 定义右旋转函数
Node* rightRoate(Node* root) {
    // 1当前节点root的左子树会作为新树的根结点
    // 2当前节点root会作为新树根结点的右子树
    // 3如果新的树根,原来有右子树,原来的右子树就作为旧根结点的左子树

    // 1当前节点root的左子树会作为新树的根结点
    Node* newRoot = root->left;
    // t2保存新树根原来的右子树
    Node* t2 = newRoot->right;

    // 2当前节点root会作为新树根结点的右子树
    newRoot->right = root;

    // 3如果新的树根,原来有右子树,原来的右子树就作为旧根结点的左子树
    root->left = t2;

    // 更新树高 root和newRoot
    // 左子树树高 和 右子树树高最大值 + 1
    root->height = 1 + max(getHeight(root->left), getHeight(root->right));
    newRoot->height = 1 + max(getHeight(newRoot->left), getHeight(newRoot->right));

    return newRoot;
}


// 定义插入结点的函数
Node* insertNode(Node* node, int key) {
    // 先二叉搜索树插入数据
    if (node == NULL) {
        return newNode(key);
    }
    if (key < node->val) {
        node->left = insertNode(node->left, key);
    } else if (key > node->val) {
        node->right = insertNode(node->right, key);
    } else {
        return node;
    }
    // 更新树高
    node->height = 1 + max(getHeight(node->left), getHeight(node->right));

    // 获取当前结点的平衡因子(根据平衡因子决定是否调整)
    // LL型失衡-右转(满足:该结点的平衡因子大于1 && 其左子树的平衡因子大于0)
    int balance = getBalance(node);
    if (balance > 1 && getBalance(node->left) > 0) {
        return rightRoate(node);
    }

    // LR型失衡-对root左子树左旋,再对root进行右旋 (满足:该结点的平衡因子大于1 && 其左子树的平衡因子小于0)
    if (balance > 1 && getBalance(node->left) < 0) {
        node->left = leftRoate(node->left);
        return rightRoate(node);
    }

    // RR型失衡-左转(满足:该结点的平衡因子小于-1 && 其右子树的平衡因子小于0)
    if (balance < -1 && getBalance(node->right) < 0) {
        return leftRoate(node);
    }

    // RL型失衡-对root右子树右旋,再对root进行左旋 (满足:该结点的平衡因子小于-1 && 其左子树的平衡因子大于0)
    if (balance < -1 && getBalance(node->right) > 0) {
        node->right = rightRoate(node->right);
        return leftRoate(node);
    }

    return node;
}


// 先序遍历
void preOrder(Node* root) {
    if (root == NULL) {
        return;
    }
    printf("%d ", root->val);
    preOrder(root->left);
    preOrder(root->right);
}

// 中序遍历
void midOrder(Node* root) {
    if (root == NULL) {
        return;
    }
    midOrder(root->left);
    printf("%d ", root->val);
    midOrder(root->right);
}


// 查找函数
Node* findNode(Node* root, int key, int* counter) {
    Node* curNode = root;
    while (curNode != NULL) {
        if (key < curNode->val) {
            curNode = curNode->left;
            (*counter)++;
        } else if (key > curNode->val) {
            curNode = curNode->right;
            (*counter)++;
        } else {
            return curNode;
        }
    }

    return NULL;
}

// 删除节点
// 前半部分是二叉搜索树的删除,后面是插入节点导致失衡,稍作调整
Node* deleteNode(Node* node, int key) {
    if (node == NULL) {
        return node;
    }
    if (node->val > key) {
        node->left = deleteNode(node->left, key);
    } else if (node->val < key) {
        node->right = deleteNode(node->right, key);
    } else if (node->val == key) {
        // 情况1,叶子结点
        if (node->left == NULL && node->right == NULL) {
            Node* temp = node;
            node = NULL;
            free(temp);
        } else if (node->left == NULL && node->right != NULL) {
            // 情况2:只有右子树
            Node* temp = node;
            node = node->right;
            free(temp);
        } else if (node->left != NULL && node->right == NULL) {
            // 情况3:只有左子树
            Node* temp = node;
            node = node->left;
            free(temp);
        } else if (node->left != NULL && node->right != NULL) {
            // 情况4:左右子树都有
            // 找到右子树上最左边的结点来接替该结点
            Node* curNode = node->right;
            while (curNode->left != NULL) {
                curNode = curNode->left;
            }
            node->val = curNode->val;
            node->right = deleteNode(node->right, curNode->val);
        }
    }
    // 删除完成,接下来是调整
    if (node == NULL) {
        return node;
    }
    // 更新树高
    node->height = 1 + max(getHeight(node->left), getHeight(node->right));

    // 计算平衡因子
    int balance = getBalance(node);

    // LL型-右转
    if (balance > 1 && getBalance(node->left) >= 0) {
        return rightRoate(node);
    }

    // LR型-对node左子树左旋,再对node进行右旋
    if (balance > 1 && getBalance(node->left) < 0) {
        node->left = leftRoate(node->left);
        return rightRoate(node);
    }

    // RR型-左转
    if (balance < -1 && getBalance(node->right) <= 0) {
        return leftRoate(node);
    }

    // RL型-对node右子树右旋,再对node进行左旋
    if (balance < -1 && getBalance(node->right) > 0) {
        node->right = rightRoate(node->right);
        return leftRoate(node);
    }

    return node;
}


// 测试函数
void test() {
    Node* root = NULL;
    root = insertNode(root, 10);
    root = insertNode(root, 20);
    root = insertNode(root, 30);
    root = insertNode(root, 40);
    root = insertNode(root, 50);
    root = insertNode(root, 60);
    root = insertNode(root, 70);

     int counter = 0;
     Node* result = findNode(root, 70, &counter);
     printf("找了%d次\n", counter);

    printf("-----------先序遍历----------\n");
    preOrder(root);

    printf("\n-----------中序遍历----------\n");
    midOrder(root);

    counter = 0;
    root = deleteNode(root, 10);
    root = deleteNode(root, 20);
    root = deleteNode(root, 30);
    result = findNode(root, 40, &counter);
    printf("找了%d次\n", counter);

    printf("-----------先序遍历----------\n");
    preOrder(root);

    printf("\n-----------中序遍历----------\n");
    midOrder(root);
}

int main(int argc, char const *argv[])
{
    test();
    return 0;
}

结果

参考文献

[1]平衡树 - 维基百科,自由的百科全书 (wikipedia.org)

[2]【平衡二叉树(AVL树)】这个视频解决你关于平衡二叉树的所有问题_哔哩哔哩_bilibili

[3]AVL 树 - OI Wiki (oi-wiki.org)

[4]什么是平衡二叉树(AVL) - 知乎 (zhihu.com)

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值