Redis基础数据结构之 Sorted Set 有序集合 源码解读

news2024/9/21 16:04:42

目录标题

  • Sorted Set 是什么?
  • Sorted Set 数据结构
    • 跳表(skiplist)
      • 跳表节点的结构定义
      • 跳表的定义
      • 跳表节点查询
      • 层数设置
  • Sorted Set 基本操作

Sorted Set 是什么?

有序集合(Sorted Set)是 Redis 中一种重要的数据类型,它本身是集合类型,同时也可以支持集合中的元素带有权重,并按权重排序

  • ZRANGEBYSCORE:按照元素权重返回一个范围内的元素
  • ZSCORE:返回某个元素的权重值

在这里插入图片描述

Sorted Set 数据结构

  • 结构定义:server.h
  • 实现:t_zset.c

结构定义是 zset,里面包含哈希表 dict 和跳表 zsl。zset 充分利用了:

  • 哈希表的高效单点查询特性(ZSCORE)
  • 跳表的高效范围查询(ZRANGEBYSCORE)

typedef struct zset {
    dict *dict;
    zskiplist *zsl;
} zset;

Skiplist:用于快速查找、插入和删除操作,提供近似O(log N)的时间复杂度

Dictionary(Hashtables):用来存储成员与分数的映射关系,确保每个成员的唯一性

跳表(skiplist)

多层的有序链表。下面展示的是 3 层的跳表,头节点是一个 level 数组,作为 level0~level2 的头指针

在这里插入图片描述

跳表节点的结构定义

typedef struct zskiplistNode {
    // sorted set 中的元素
    sds ele;
    // 元素权重
    double score;
    // 后向指针(为了便于从跳表的尾节点倒序查找)
    struct zskiplistNode *backward;
    // 节点的 level 数组
    struct zskiplistLevel {
        // 每层上的前向指针
        struct zskiplistNode *forward;
        // 跨度,记录节点在某一层 *forward 指针和该节点,跨越了 level0 上的几个节点
        unsigned long span;
    } level[];
} zskiplistNode;

跳表的定义

typedef struct zskiplist {
    // 头节点和尾节点
    struct zskiplistNode *header, *tail;
    unsigned long length;
    int level;
} zskiplist;

在这里插入图片描述

跳表节点查询

在查询某个节点时,跳表会从头节点的最高层开始,查找下一个节点:
访问下一个节点

  • 当前节点的元素权重 < 要查找的权重
  • 当前节点的元素权重 = 要查找的权重,且节点数据<要查找的数据
    访问当前节点 level 数组的下一层指针
    当前节点的元素权重 > 要查找的权重
//获取跳表的表头
x = zsl->header;
//从最大层数开始逐一遍历
for (i = zsl->level-1; i >= 0; i--) {
   ...
   while (x->level[i].forward && (x->level[i].forward->score < score || (x->level[i].forward->score == score 
    && sdscmp(x->level[i].forward->ele,ele) < 0))) {
      ...
      x = x->level[i].forward;
    }
    ...
}

层数设置

几种方法:

  • 每层的节点数约是下一层节点数的一半。
    • 好处:查找时类似于二分查找,查找复杂度可以减低到 O(logN)
    • 坏处:每次插入/删除节点,都要调整后续节点层数,带来额外开销

随机生成每个节点的层数。Redis 跳表采用了这种方法。
Redis 中,跳表节点层数是由 zslRandomLevel 函数决定。

int zslRandomLevel(void) {
    int level = 1;
    while ((random()&0xFFFF) < (ZSKIPLIST_P * 0xFFFF))
        level += 1;
    return (level<ZSKIPLIST_MAXLEVEL) ? level : ZSKIPLIST_MAXLEVEL;
}

其中每层增加的概率是 0.25,最大层数是 32。

#define ZSKIPLIST_MAXLEVEL 32 /* Should be enough for 2^64 elements */
#define ZSKIPLIST_P 0.25      /* Skiplist P = 1/4 */
跳表插入节点 zslInsert
zskiplistNode *zslInsert(zskiplist *zsl, double score, sds ele) {
    zskiplistNode *update[ZSKIPLIST_MAXLEVEL], *x;
    unsigned int rank[ZSKIPLIST_MAXLEVEL];
    int i, level;

    serverAssert(!isnan(score));
    x = zsl->header;
    // 从最高层的 level 开始找
    for (i = zsl->level-1; i >= 0; i--) {
        // 每层待插入的位置
        rank[i] = i == (zsl->level-1) ? 0 : rank[i+1];
        // forward.score < 待插入 score || (forward.score < 待插入 score && forward.ele < ele)
        while (x->level[i].forward &&
               (x->level[i].forward->score < score ||
                (x->level[i].forward->score == score &&
                 sdscmp(x->level[i].forward->ele, ele) < 0))) {
            // 在同一层 level 找下一个节点
            rank[i] += x->level[i].span;
            x = x->level[i].forward;
        }
        update[i] = x;
    }

    // 随机层数
    level = zslRandomLevel();

    // 如果待插入节点的随机层数 > 跳表当前的层数
    if (level > zsl->level) {
        // 增加对应的层数
        for (i = zsl->level; i < level; i++) {
            rank[i] = 0;
            update[i] = zsl->header;
            update[i]->level[i].span = zsl->length;
        }
        zsl->level = level;
    }
    // 新建节点
    x = zslCreateNode(level, score, ele);
    // 设置新建节点的 level 数组
    for (i = 0; i < level; i++) {
        x->level[i].forward = update[i]->level[i].forward;
        update[i]->level[i].forward = x;

        /* update span covered by update[i] as x is inserted here */
        x->level[i].span = update[i]->level[i].span - (rank[0] - rank[i]);
        update[i]->level[i].span = (rank[0] - rank[i]) + 1;
    }

    for (i = level; i < zsl->level; i++) {
        update[i]->level[i].span++;
    }

    x->backward = (update[0] == zsl->header) ? NULL : update[0];
    if (x->level[0].forward)
        x->level[0].forward->backward = x;
    else
        zsl->tail = x;
    zsl->length++;
    return x;
}

跳表删除节点 zslDelete

int zslDelete(zskiplist *zsl, double score, sds ele, zskiplistNode **node) {
    zskiplistNode *update[ZSKIPLIST_MAXLEVEL], *x;
    int i;

    x = zsl->header;
    // 找到待删除的节点
    for (i = zsl->level-1; i >= 0; i--) {
        while (x->level[i].forward &&
                (x->level[i].forward->score < score ||
                    (x->level[i].forward->score == score &&
                     sdscmp(x->level[i].forward->ele,ele) < 0)))
        {
            x = x->level[i].forward;
        }
        update[i] = x;
    }
    x = x->level[0].forward;
    // 判断节点的 score 和 ele 是否符合条件
    if (x && score == x->score && sdscmp(x->ele,ele) == 0) {
        // 删除该节点
        zslDeleteNode(zsl, x, update);
        if (!node)
            // 释放内存
            zslFreeNode(x);
        else
            *node = x;
        return 1;
    }
    return 0; /* not found */
}

Sorted Set 基本操作

首先看下如何创建跳表,代码在 object.c 中,可以看到会调用 dictCreate 函数创建哈希表,之后调用 zslCreate 函数创建跳表。

robj *createZsetObject(void) {
    zset *zs = zmalloc(sizeof(*zs));
    robj *o;

    zs->dict = dictCreate(&zsetDictType,NULL);
    zs->zsl = zslCreate();
    o = createObject(OBJ_ZSET,zs);
    o->encoding = OBJ_ENCODING_SKIPLIST;
    return o;
}

哈希表和跳表的数据必须保持一致。我们通过 zsetAdd 函数研究一下。

zsetAdd
啥都不说了,都在流程图里。
在这里插入图片描述
首先判断编码是 ziplist,还是 skiplist。

ziplist 编码
里面需要判断是否要转换编码,如果转换编码,则需要调用 zsetConvert 转换成 ziplist 编码,这里就不叙述了。

// ziplist 编码时的处理逻辑
if (zobj->encoding == OBJ_ENCODING_ZIPLIST) {
    unsigned char *eptr;

    // zset 存在要插入的元素
    if ((eptr = zzlFind(zobj->ptr, ele, &curscore)) != NULL) {
        // 存储要插入的元素时,在 not exist 时更新
        if (nx) {
            *out_flags |= ZADD_OUT_NOP;
            return 1;
        }

        ……
        if (newscore) *newscore = score;

        // 原来的 score 和待插入 score 不同
        if (score != curscore) {
            // 先删除原来的元素
            zobj->ptr = zzlDelete(zobj->ptr, eptr);
            // 插入新元素
            zobj->ptr = zzlInsert(zobj->ptr, ele, score);
            *out_flags |= ZADD_OUT_UPDATED;
        }
        return 1;
    }
    // zset 中不存在要插入的元素
    else if (!xx) {

        // 检测 ele 是否过大 || ziplist 过大
        if (zzlLength(zobj->ptr) + 1 > server.zset_max_ziplist_entries ||
            sdslen(ele) > server.zset_max_ziplist_value ||
            !ziplistSafeToAdd(zobj->ptr, sdslen(ele))) {
            // 转换成 skiplist 编码
            zsetConvert(zobj, OBJ_ENCODING_SKIPLIST);
        } else {
            // 在 ziplist 中插入 (element,score) pair
            zobj->ptr = zzlInsert(zobj->ptr, ele, score);
            if (newscore) *newscore = score;
            *out_flags |= ZADD_OUT_ADDED;
            return 1;
        }
    } else {
        *out_flags |= ZADD_OUT_NOP;
        return 1;
    }
}
skiplist 编码
// skiplist 编码时的处理逻辑
if (zobj->encoding == OBJ_ENCODING_SKIPLIST) {
    zset *zs = zobj->ptr;
    zskiplistNode *znode;
    dictEntry *de;

    // 从哈希表中查询新增元素
    de = dictFind(zs->dict, ele);

    // 查询到该元素
    if (de != NULL) {
        /* NX? Return, same element already exists. */
        if (nx) {
            *out_flags |= ZADD_OUT_NOP;
            return 1;
        }

        ……
        if (newscore) *newscore = score;

        // 权重发生变化
        if (score != curscore) {
            // 更新跳表节点
            znode = zslUpdateScore(zs->zsl, curscore, ele, score);
            // 让哈希表的元素的值指向跳表节点的权重
            dictGetVal(de) = &znode->score; /* Update score ptr. */
            *out_flags |= ZADD_OUT_UPDATED;
        }
        return 1;
    }
        // 如果新元素不存在
    else if (!xx) {
        ele = sdsdup(ele);
        // 在跳表中插入新元素
        znode = zslInsert(zs->zsl, score, ele);
        // 在哈希表中插入新元素
        serverAssert(dictAdd(zs->dict, ele, &znode->score) == DICT_OK);
        *out_flags |= ZADD_OUT_ADDED;
        if (newscore) *newscore = score;
        return 1;
    } else {
        *out_flags |= ZADD_OUT_NOP;
        return 1;
    }
}

zsetAdd 整体代码

int zsetAdd(robj *zobj, double score, sds ele, int in_flags, int *out_flags, double *newscore) {
    /* Turn options into simple to check vars. */
    int incr = (in_flags & ZADD_IN_INCR) != 0;
    int nx = (in_flags & ZADD_IN_NX) != 0;
    int xx = (in_flags & ZADD_IN_XX) != 0;
    int gt = (in_flags & ZADD_IN_GT) != 0;
    int lt = (in_flags & ZADD_IN_LT) != 0;
    *out_flags = 0; /* We'll return our response flags. */
    double curscore;

    /* NaN as input is an error regardless of all the other parameters. */
    // 判断 score 是否合法,不合法直接 return
    if (isnan(score)) {
        *out_flags = ZADD_OUT_NAN;
        return 0;
    }

    /* Update the sorted set according to its encoding. */

    // ziplist 编码时的处理逻辑
    if (zobj->encoding == OBJ_ENCODING_ZIPLIST) {
        unsigned char *eptr;

        // zset 存在要插入的元素
        if ((eptr = zzlFind(zobj->ptr, ele, &curscore)) != NULL) {
            // 存储要插入的元素时,在 not exist 时更新
            if (nx) {
                *out_flags |= ZADD_OUT_NOP;
                return 1;
            }

            /* Prepare the score for the increment if needed. */
            if (incr) {
                score += curscore;
                if (isnan(score)) {
                    *out_flags |= ZADD_OUT_NAN;
                    return 0;
                }
            }

            /* GT/LT? Only update if score is greater/less than current. */
            if ((lt && score >= curscore) || (gt && score <= curscore)) {
                *out_flags |= ZADD_OUT_NOP;
                return 1;
            }

            if (newscore) *newscore = score;

            // 原来的 score 和待插入 score 不同
            if (score != curscore) {
                // 先删除原来的元素
                zobj->ptr = zzlDelete(zobj->ptr, eptr);
                // 插入新元素
                zobj->ptr = zzlInsert(zobj->ptr, ele, score);
                *out_flags |= ZADD_OUT_UPDATED;
            }
            return 1;
        }
            // zset 中不存在要插入的元素
        else if (!xx) {

            // 检测 ele 是否过大 || ziplist 过大
            if (zzlLength(zobj->ptr) + 1 > server.zset_max_ziplist_entries ||
                sdslen(ele) > server.zset_max_ziplist_value ||
                !ziplistSafeToAdd(zobj->ptr, sdslen(ele))) {
                // 转换成 skiplist 编码
                zsetConvert(zobj, OBJ_ENCODING_SKIPLIST);
            } else {
                // 在 ziplist 中插入 (element,score) pair
                zobj->ptr = zzlInsert(zobj->ptr, ele, score);
                if (newscore) *newscore = score;
                *out_flags |= ZADD_OUT_ADDED;
                return 1;
            }
        } else {
            *out_flags |= ZADD_OUT_NOP;
            return 1;
        }
    }

    /* Note that the above block handling ziplist would have either returned or
     * converted the key to skiplist. */

    // skiplist 编码时的处理逻辑
    if (zobj->encoding == OBJ_ENCODING_SKIPLIST) {
        zset *zs = zobj->ptr;
        zskiplistNode *znode;
        dictEntry *de;

        // 从哈希表中查询新增元素
        de = dictFind(zs->dict, ele);

        // 查询到该元素
        if (de != NULL) {
            /* NX? Return, same element already exists. */
            if (nx) {
                *out_flags |= ZADD_OUT_NOP;
                return 1;
            }

            // 从哈希表中查询元素的权重
            curscore = *(double *) dictGetVal(de);

            // 如果要更新元素权重值
            if (incr) {
                score += curscore;
                if (isnan(score)) {
                    *out_flags |= ZADD_OUT_NAN;
                    return 0;
                }
            }

            /* GT/LT? Only update if score is greater/less than current. */
            if ((lt && score >= curscore) || (gt && score <= curscore)) {
                *out_flags |= ZADD_OUT_NOP;
                return 1;
            }

            if (newscore) *newscore = score;

            // 权重发生变化
            if (score != curscore) {
                // 更新跳表节点
                znode = zslUpdateScore(zs->zsl, curscore, ele, score);
                // 让哈希表的元素的值指向跳表节点的权重
                dictGetVal(de) = &znode->score; /* Update score ptr. */
                *out_flags |= ZADD_OUT_UPDATED;
            }
            return 1;
        }
            // 如果新元素不存在
        else if (!xx) {
            ele = sdsdup(ele);
            // 在跳表中插入新元素
            znode = zslInsert(zs->zsl, score, ele);
            // 在哈希表中插入新元素
            serverAssert(dictAdd(zs->dict, ele, &znode->score) == DICT_OK);
            *out_flags |= ZADD_OUT_ADDED;
            if (newscore) *newscore = score;
            return 1;
        } else {
            *out_flags |= ZADD_OUT_NOP;
            return 1;
        }
    } else {
        serverPanic("Unknown sorted set encoding");
    }
    return 0; /* Never reached. */
}
zsetDel
int zsetDel(robj *zobj, sds ele) {
    // ziplist 编码
    if (zobj->encoding == OBJ_ENCODING_ZIPLIST) {
        unsigned char *eptr;

        // 找到对应的节点
        if ((eptr = zzlFind(zobj->ptr, ele, NULL)) != NULL) {
            // 从 ziplist 中删除
            zobj->ptr = zzlDelete(zobj->ptr, eptr);
            return 1;
        }
    }
    // skiplist 编码
    else if (zobj->encoding == OBJ_ENCODING_SKIPLIST) {
        zset *zs = zobj->ptr;
        // 从 skiplist 中删除
        if (zsetRemoveFromSkiplist(zs, ele)) {
            if (htNeedsResize(zs->dict)) dictResize(zs->dict);
            return 1;
        }
    } else {
        serverPanic("Unknown sorted set encoding");
    }
    return 0; /* No such element found. */
}

Redis 的有序集合通过跳跃表和字典的结合,既保证了成员的唯一性,又提供了高效的排序和检索能力,使其成为处理需要排序数据的理想选择。

在这里插入图片描述

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/2152744.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

Wpf使用NLog将日志输出到LogViewer

1 LogViewer LogViewer是通过UDP传输的高性能实时log查看器。 具有一下特性&#xff1a; 通过UDP读取日志通过文件导入日志导出日志到一个文件中排序、过滤&#xff08;日志树&#xff0c;日志等级&#xff09;和查找突出显示搜索文本从UPD接收日志时忽略IP地址列表多接收器支…

灾备技术演进之路 | 虚拟化无代理备份只能挂载验证和容灾吗?只能无代理恢复吗?且看科力锐升级方案

灾备技术演进之路系列 虚拟化备份技术演进 摆脱束缚&#xff0c;加速前行 无代理备份仅能挂载/恢复验证吗&#xff1f; ——科力锐极简验证演练无代理备份来了 无代理备份无法应对平台级故障吗&#xff1f; ——科力锐应急接管无代理备份来了 无代理备份仅能同平台挂载吗&a…

Java笔试面试题AI答之单元测试JUnit(7)

文章目录 37. 请列举一些JUnit扩展 &#xff1f;1. 参数化测试2. 条件测试执行3. 临时目录4. 时间测试5. 重复测试6. 前置/后置条件7. Mockito8. Spring Test9. JUnit Vintage10. Testcontainers11. 自定义注解和扩展12. 测试监听器&#xff08;TestListener 和 RunListener&am…

python中ocr图片文字识别样例(二)

一、说明 本次解决图片相关出现中文乱码问题&#xff0c;属于上篇文章的优化&#xff0c;前提条件依赖上篇文章的包&#xff0c;当然ocr的具体应用场景很多&#xff0c;根据自身需求进行调整 二、具体实现 2.1 代码实现&#xff1a; # -*- coding: utf-8 -*- import easyoc…

CertiK因发现Apple Vision Pro眼动追踪技术漏洞,第6次获苹果认可

​2024年9月20日&#xff0c;头部Web3.0安全机构CertiK自豪地宣布&#xff0c;CertiK的工程师因发现Apple Vision Pro MR&#xff08;混合现实&#xff09;头显设备中的关键漏洞而获得Apple公司认可&#xff0c;这已经是Apple公司第六次公开发布对CertiK的致谢&#xff0c;Cert…

day21JS-npm中的部分插件使用方法

1. 静态资源目录 静态资源目录就是访问服务器的某些路劲时候&#xff0c;服务器可以吐出一个写好的指定页面。 实现思路&#xff1a; 1、先判断要找的路径是否是文件&#xff0c;如果是文件&#xff0c;就加载发给对方。 2、如果是文件夹&#xff0c;找到这个文件夹所在路径中…

828华为云征文|华为云Flexus云服务器X实例之openEuler系统下部署Joplin笔记工具

828华为云征文&#xff5c;华为云Flexus云服务器X实例之openEuler系统下部署joplin笔记工具 前言一、Flexus云服务器X实例介绍1.1 Flexus云服务器X实例简介1.2 Flexus云服务器X实例特点1.3 Flexus云服务器X实例使用场景 二、Joplin介绍2.1 Joplin简介2.2 Joplin主要特点 三、本…

倒排索引(反向索引)

倒排索引&#xff08;Inverted Index&#xff09;是搜索引擎和数据库管理系统中常用的一种数据结构&#xff0c;用于快速检索文档集合中的文档。在全文搜索场景中&#xff0c;倒排索引是一种非常高效的手段&#xff0c;因为它能够快速定位到包含特定关键词的所有文档。 1、基本…

Java面试——集合篇

1.Java中常用的容器有哪些&#xff1f; 容器主要包括 Collection 和 Map 两种&#xff0c;Collection 存储着对象的集合&#xff0c;而 Map 存储着键值对(两个对象)的映射表。 如图&#xff1a; 面试官追问&#xff1a;说说集合有哪些类及他们各自的区别和特点&#xff1f; S…

@Mapper 和 @MapperScan

今天在学习项目的时候&#xff0c;遇到一个小问题&#xff0c;先记录下来。 Mapper 和 MapperScan 的效果是一样的 Mapper 加到对应的接口上MapperScan 则通常在一个单独的配置类中&#xff0c;在这个类中使用MapperScan声明要扫描的 mapper 接口。也就是说&#xff0c;这两个…

进程分析工具Process Explorer使用

进程分析工具Process Explorer使用 Process Explorer让使用者能了解看不到的在后台执行的处理程序&#xff0c;能显示目前已经载入哪些模块&#xff0c;分别是正在被哪些程序使用着&#xff0c;还可显示这些程序所调用的DLL进程&#xff0c;以及他们所打开的句柄。Process Expl…

MySQL-DDL/DML(数据定义/操作语言)

数据定义语言(DDL-Data Definition Language) 代表关键字:create ,drop,alter 数据操作语言(DML-Data Manipulation Language) 代表关键字&#xff1a;insert,delete,update 1、表的操作 1.1 创建表 create table 表名(字段1,字段2,字段3.....字段n) [charset字符集][type表类…

【JavaScript】LeetCode:46-50

文章目录 46 翻转二叉树47 对称二叉树48 二叉树的直径49 二叉树的层序遍历50 将有序数组转换为二叉搜索树 46 翻转二叉树 递归前序遍历 / 后序遍历&#xff0c;这里给出前序遍历的代码。遍历节点&#xff0c;交换左右子树。 /*** Definition for a binary tree node.* functio…

使用`fetch`和`async/await`进行前端数据请求:详解与优化

个人名片 🎓作者简介:java领域优质创作者 🌐个人主页:码农阿豪 📞工作室:新空间代码工作室(提供各种软件服务) 💌个人邮箱:[2435024119@qq.com] 📱个人微信:15279484656 🌐个人导航网站:www.forff.top 💡座右铭:总有人要赢。为什么不能是我呢? 专栏导…

群晖NAS使用Docker本地部署网页版Ubuntu系统并实现无公网IP远程访问

文章目录 前言1. 下载Docker-Webtop镜像2. 运行Docker-Webtop镜像3. 本地访问网页版Linux系统4. 群晖NAS安装Cpolar工具5. 配置异地访问Linux系统6. 异地远程访问Linux系统7. 固定异地访问的公网地址 前言 本文旨在详细介绍如何在群晖NAS部署docker-webtop&#xff0c;并结合c…

【hot100-java】【搜索旋转排序数组】

R8-二分查找篇 等等&#xff0c;不是&#xff1f;O(logn)&#xff1f;那岂不是一次遍历数组都超时了hh. 二分查找分类 class Solution {public int search(int[] nums, int target) {if (numsnull||nums.length0){return -1;}int start0;int endnums.length-1;int mid;while …

1--SpringBoot外卖项目介绍及环境搭建 详解

目录 软件开发整体流程 软件开发流程 角色分工 软件环境 苍穹外卖项目介绍 项目介绍 产品原型 技术选型 开发环境搭建 前端环境搭建 后端环境搭建 完善登录功能 导入接口文档 Swagger 介绍 使用方式 常用注解 软件开发整体流程 软件开发流程 需求分析&#x…

1、等保测评介绍

数据来源&#xff1a;等保测评基础知识学习(1.02.0)2024最新版_哔哩哔哩_bilibili 等级保护的定义&#xff1a; 对国家秘密信息、法人或其他组织及公民专有信息以及公开信息&#xff0c;按照其重要程度对信息系统实施分等级安全保护。这包括对使用的安全产品进行等级管理&…

Vue 项目实战4-无缝轮播图

养成好习惯&#xff0c;先赞后看&#xff0c;感谢对作者大大的支持 一、话不多说&#xff0c;直接上效果图&#xff1a; 完整视频展示链接如下&#xff1a; https://item.taobao.com/item.htm?ftt&id833405684191 二、实现思路 HTML结构 文档头部设置&#xff1a;定义…

TAPD 卓越版

一、版本概述 TAPD 卓越版提供 项目协同生态管理 解决方案&#xff0c;适合小团队轻量协作。TAPD 卓越版在专业版基础上增加任务、甘特图、协公协同模块三大应用&#xff0c;同时项目数上限增加至200个&#xff0c;能够帮助团队更高效协作、快速沟通&#xff0c;实时管理工作进…