普通视图

发现新文章,点击刷新页面。
昨天 — 2026年4月13日首页

LeetCode 149. 直线上最多的点数:题解深度剖析

作者 Wect
2026年4月13日 10:36

LeetCode 中等难度题目「149. 直线上最多的点数」,这道题核心考察对“直线斜率”的理解和哈希表的运用,看似简单但细节超多,一不小心就会踩坑。下面结合完整代码,一步步讲透解题逻辑,新手也能轻松看懂。

题目回顾

题目很直白:给你一个数组 points ,其中 points[i] = [xi, yi] 表示 X-Y 平面上的一个点。求最多有多少个点在同一条直线上。

举个例子:如果 points = [[1,1],[2,2],[3,3]],那么这三个点在同一条直线上,答案就是 3;如果 points = [[1,1],[3,2],[5,3],[4,1],[2,3],[1,4]],答案则是 4(有4个点共线)。

核心难点:如何表示“同一条直线”?如何避免重复计数?如何处理斜率的精度问题?

解题核心思路

直线的核心特征是「斜率」—— 同一平面内,两点确定一条直线,而斜率相同(且经过同一点)的点,必然在同一条直线上。

基于这个原理,我们可以用「固定一点,遍历其他点」的思路,具体步骤如下:

  1. 边界处理:如果点的数量 ≤ 2,直接返回点的数量(因为两点必然共线);

  2. 遍历每个点 points[i],将其作为「基准点」;

  3. 计算基准点与其他所有点 points[j](j > i)的斜率,用哈希表记录「斜率对应的点的数量」;

  4. 统计当前基准点对应的最大共线点数,更新全局最大值;

  5. 优化剪枝:如果当前全局最大值已经 ≥ 剩余未遍历的点的数量,或者超过总点数的一半,直接终止循环(无需继续计算,因为不可能出现更大值)。

关键细节:斜率的表示(避坑重点)

这道题最容易踩坑的地方,就是「斜率的表示」。直接用 dy/dx (即两点纵坐标差除以横坐标差)会有两个问题:

  • 精度问题:浮点数计算会有误差(比如 1/3 和 2/6 本是同一个斜率,但浮点数表示可能不同);

  • 特殊情况:垂直直线(dx=0,斜率不存在)、水平直线(dy=0,斜率为0),无法用常规除法表示。

解决方案:用「最简整数比」表示斜率,将 dy 和 dx 化简为互质的整数,再用一个唯一的key表示这个比值。

具体做法(对应代码中的gcd函数和key计算):

  1. 计算两点的横坐标差 dx = x_i - x_j,纵坐标差 dy = y_i - y_j;

  2. 特殊处理:

    • dx=0(垂直直线):令 dy=1(统一表示所有垂直直线的斜率);

    • dy=0(水平直线):令 dx=1(统一表示所有水平直线的斜率);

  3. 符号统一:如果 dy 为负,将 dx 和 dy 同时取反(保证斜率的符号一致,比如 2/-3 和 -2/3 是同一个斜率,统一为 2/3);

  4. 化简:用最大公约数(gcd)将 dx 和 dy 化简为互质的整数(比如 dx=4,dy=2,化简为 dx=2,dy=1);

  5. 生成key:将二维的 (dy, dx) 转化为一维key,避免哈希表的key冲突。代码中用「dy + dx * 20001」,因为题目中坐标的范围是 [-10^4, 10^4],dx的最大绝对值是 20000,乘以20001后,再加上dy(范围 [-20000, 20000]),可以保证每个 (dy, dx) 对应唯一的key。

完整代码+逐行解析

先贴完整代码(TypeScript版本),再逐行拆解核心逻辑:

function maxPoints(points: number[][]): number {
  const n = points.length;
  if (n <= 2) return n; // 边界处理:2个及以下点必共线
  let res = 0;

  // 最大公约数函数:用于化简dx和dy
  const gcd = (a: number, b: number): number => {
    return b != 0 ? gcd(b, a % b) : a;
  }

  // 遍历每个点作为基准点i
  for (let i = 0; i < n; i++) {
    // 剪枝:如果当前最大结果已经≥剩余点数量,或超过总点数的一半,无需继续
    if (res >= n - i || res > n / 2) {
      break;
    }
    const map = new Map(); // 记录当前基准点下,斜率对应的点的数量

    // 遍历所有在i之后的点j(避免重复计算,因为i和j与j和i的斜率相同)
    for (let j = i + 1; j < n; j++) {
      let dx = points[i][0] - points[j][0];
      let dy = points[i][1] - points[j][1];

      // 特殊处理:垂直/水平直线,统一斜率表示
      if (dx === 0) {
        dy = 1; // 垂直直线,斜率统一用(1,0)表示
      } else if (dy === 0) {
        dx = 1; // 水平直线,斜率统一用(0,1)表示
      } else {
        // 符号统一:dy为负时,dx和dy同时取反
        if (dy < 0) {
          dx = -dx;
          dy = -dy;
        }
        // 化简dx和dy为互质整数
        const gcdXY = gcd(Math.abs(dx), Math.abs(dy));
        dx /= gcdXY;
        dy /= gcdXY;
      }

      // 生成唯一key,存入哈希表
      const key = dy + dx * 20001;
      map.set(key, (map.get(key) || 0) + 1);
    }

    // 统计当前基准点下,最多的共线点数(map的值是“与基准点共线的点的数量”,需+1包含基准点本身)
    let maxn = 0;
    for (const num of map.values()) {
      maxn = Math.max(maxn, num + 1);
    }
    // 更新全局最大值
    res = Math.max(res, maxn);
  }
  return res;
};

逐行解析核心代码

  1. 边界处理:if (n <= 2) return n; —— 这是最基础的优化,因为1个点返回1,2个点返回2,都无需后续计算。

  2. gcd函数:求两个数的最大公约数,用于化简dx和dy。比如gcd(4,2)=2,gcd(3,5)=1,核心是递归实现“辗转相除法”。

  3. 外层循环(基准点遍历):for (let i = 0; i < n; i++),每个i作为基准点,后续只遍历j > i的点,避免重复计算(比如i=0、j=1和i=1、j=0是同一个斜率,无需重复统计)。

  4. 剪枝逻辑:if (res >= n - i || res > n / 2) break; —— 比如总共有5个点,当前res=3,剩余未遍历的点只有2个(n-i=5-3=2),不可能超过3,直接终止循环;另外,最多共线点数不可能超过总点数的一半(如果超过,早就在之前的基准点中统计到了),这一步能大幅提升效率。

  5. 哈希表map:key是斜率的唯一标识,value是“与基准点i共线且在i之后的点的数量”。

  6. 内层循环(计算斜率):for (let j = i + 1; j < n; j++),计算基准点i和点j的dx和dy,然后进行化简和符号统一,生成key存入map。

  7. 统计当前基准点的最大共线点数:num + 1 是因为map的value是“除基准点外的共线点数”,加上基准点本身才是总共线点数。

  8. 更新全局最大值res:每次遍历完一个基准点,就用当前的maxn更新res,最终res就是答案。

常见坑点&优化建议

坑点1:斜率精度问题

千万不要用 dy/dx 计算斜率(比如用浮点数存储),会出现精度误差。比如 dx=1、dy=3 和 dx=2、dy=6,斜率都是1/3,但浮点数表示可能有微小差异,导致哈希表认为是两个不同的斜率。

坑点2:符号不统一

比如 dx=2、dy=-3 和 dx=-2、dy=3,其实是同一个斜率,但如果不统一符号,会生成两个不同的key。所以代码中才会判断“如果dy<0,dx和dy同时取反”,保证斜率符号一致。

坑点3:重复计算

如果内层循环遍历所有j(j从0到n-1,j≠i),会导致i和j、j和i重复计算,浪费时间。所以内层循环只遍历j > i的点,既避免重复,又提升效率。

优化建议

剪枝逻辑一定要加!尤其是当n较大时(比如n=1000),剪枝能大幅减少循环次数,避免超时。另外,哈希表的key生成方式可以灵活调整,只要能保证“不同斜率对应不同key,相同斜率对应相同key”即可,代码中的「dy + dx * 20001」是结合题目坐标范围的最优选择。

测试用例验证

我们用两个典型测试用例验证代码:

  1. 测试用例1:points = [[1,1],[2,2],[3,3]]

    • i=0(基准点[1,1]),j=1:dx=-1,dy=-1 → 符号统一后dx=1,dy=1 → key=1+1*20001=20002,map={20002:1};

    • j=2:dx=-2,dy=-2 → 化简后dx=1,dy=1 → key=20002,map={20002:2};

    • maxn=2+1=3,res=3;后续循环剪枝,最终返回3。

  2. 测试用例2:points = [[1,1],[3,2],[5,3],[4,1],[2,3],[1,4]]

    • i=0(基准点[1,1]),遍历j=1~5,计算各个斜率,最终map中最大value为3(对应4个点共线),maxn=4,res=4;

    • 后续循环无法超过4,最终返回4。

总结

这道题的核心是「用最简整数比表示斜率」,避免精度和符号问题,再通过「固定基准点+哈希表计数」的思路,统计每个基准点对应的最大共线点数,最后结合剪枝优化提升效率。

整体难度中等,重点在于细节处理——斜率的化简、符号统一、key的生成,这些都是避坑的关键。理解之后会发现,这道题本质是“哈希表的应用+直线斜率的数学理解”,掌握后可以举一反三,应对类似的几何计数问题。

线段树:区间查询的"终极武器",一文看透高效范围统计

2026年4月13日 09:14

为什么股票软件能实时显示任意时间段最高价?
为什么游戏地图能快速检测区域碰撞?
核心都是线段树
今天带你从原理到实战,彻底掌握这个O(log n)区间查询神器

📚 完整教程:  github.com/Lee985-cmd/…
⭐ Star支持 | 💬 提Issue | 🔄 Fork分享


🎯 从一个实际问题说起

假设你在开发一个学生成绩管理系统

const scores = [85, 92, 78, 95, 88, 76, 90, 82];

// 需求1:查询第1-4名学生的总分
// 需求2:查询第3-6名学生的平均分
// 需求3:某学生补考后更新成绩
// 需求4:频繁查询不同区间的统计信息

朴素解法的困境

方法1:每次遍历区间

function rangeSum(scores, left, right) {
    let sum = 0;
    for (let i = left; i <= right; i++) {
        sum += scores[i];
    }
    return sum;
}

// 时间复杂度:O(n)
// 如果查询10000次,就是 O(10000 × n)

问题:  查询太慢!

方法2:前缀和数组

const prefixSum = [0, 85, 177, 255, 350, 438, 514, 604, 686];

function rangeSum(left, right) {
    return prefixSum[right + 1] - prefixSum[left];
}

// 查询:O(1) ✅
// 但更新呢?
scores[2] = 88; // 第3名学生补考
// 需要重新计算整个prefixSum数组:O(n) ❌

问题:  更新太慢!

线段树的解决方案

线段树的优势:
✅ 区间查询:O(log n)
✅ 单点更新:O(log n)
✅ 区间更新:O(log n)(带懒标记)

完美平衡查询和更新的性能!

💡 线段树的核心思想

什么是线段树?

线段树是一种二叉树结构,用于高效处理区间查询和更新:

特点:
1. 每个节点代表一个区间 [left, right]
2. 叶子节点代表单个元素
3. 父节点的区间 = 左右子节点区间的合并
4. 节点存储区间的聚合信息(和、最值等)

可视化理解

假设数组 [1, 3, 5, 7, 9, 11],构建求和线段树:

              [0-5]: 36
            /          \
      [0-2]: 9        [3-5]: 27
      /      \         /      \
  [0-1]: 4  [2-2]: 5 [3-4]: 16 [5-5]: 11
  /    \              /    \
[0-0]:1 [1-1]:3   [3-3]:7 [4-4]:9

观察:

  • 根节点 [0-5] 存储整个数组的和 = 36
  • 左子树 [0-2] 存储前半部分的和 = 9
  • 右子树 [3-5] 存储后半部分的和 = 27
  • 叶子节点存储单个元素的值

为什么叫"线段树"?

因为每个节点代表数组上的一段"线段"(区间),所以叫线段树。


🔍 线段树的基本操作

1. 构建(Build)

算法流程:

构建线段树(递归):

步骤1: 如果是叶子节点(start == end- 直接存储数组值
  
步骤2: 否则
  - 计算中点 mid = (start + end) / 2
  - 递归构建左子树 [start, mid]
  - 递归构建右子树 [mid+1, end]
  - 当前节点的值 = merge(左子树, 右子树)

代码实现:

_build(node, start, end) {
    // 叶子节点
    if (start === end) {
        this.tree[node] = this.data[start];
        return;
    }

    const mid = Math.floor((start + end) / 2);
    const leftChild = 2 * node;
    const rightChild = 2 * node + 1;

    // 递归构建左右子树
    this._build(leftChild, start, mid);
    this._build(rightChild, mid + 1, end);

    // 合并左右子树的值
    this.tree[node] = this.merge(
        this.tree[leftChild], 
        this.tree[rightChild]
    );
}

时间复杂度:  O(n)

2. 区间查询(Query)

算法流程:

查询区间 [left, right]:

情况1: 当前节点区间完全在查询区间内
  - 直接返回节点值
  
情况2: 当前节点区间与查询区间无重叠
  - 返回单位元(如求和返回0)
  
情况3: 部分重叠
  - 递归查询左右子树
  - 合并结果返回

代码实现:

query(left, right) {
    return this._query(1, 0, this.n - 1, left, right);
}

_query(node, start, end, left, right) {
    // 完全包含
    if (left <= start && end <= right) {
        return this.tree[node];
    }

    // 完全不重叠
    if (right < start || end < left) {
        return this._getIdentity(); // 单位元
    }

    // 部分重叠
    const mid = Math.floor((start + end) / 2);
    const leftResult = this._query(2 * node, start, mid, left, right);
    const rightResult = this._query(2 * node + 1, mid + 1, end, left, right);

    return this.merge(leftResult, rightResult);
}

时间复杂度:  O(log n)

3. 单点更新(Update)

算法流程:

更新索引 index 的值为 value:

步骤1: 从根节点开始
步骤2: 如果到达叶子节点
  - 更新值
  - 返回
步骤3: 否则
  - 判断 index 在左子树还是右子树
  - 递归更新对应的子树
  - 更新当前节点的值 = merge(左子树, 右子树)

代码实现:

update(index, value) {
    this.data[index] = value;
    this._update(1, 0, this.n - 1, index, value);
}

_update(node, start, end, index, value) {
    // 叶子节点
    if (start === end) {
        this.tree[node] = value;
        return;
    }

    const mid = Math.floor((start + end) / 2);

    // 根据索引决定更新左子树还是右子树
    if (index <= mid) {
        this._update(2 * node, start, mid, index, value);
    } else {
        this._update(2 * node + 1, mid + 1, end, index, value);
    }

    // 更新当前节点的值
    this.tree[node] = this.merge(
        this.tree[2 * node],
        this.tree[2 * node + 1]
    );
}

时间复杂度:  O(log n)


💻 完整JavaScript实现

线段树核心实现

class SegmentTree {
    /**
     * 初始化线段树
     * @param {Array} data - 原始数组
     * @param {Function} merge - 合并函数(默认求和)
     */
    constructor(data, merge = (a, b) => a + b) {
        this.data = [...data];
        this.merge = merge;
        this.n = data.length;
        
        // 线段树数组大小通常为4n(保证足够)
        this.tree = new Array(4 * this.n).fill(0);
        
        if (this.n > 0) {
            this._build(1, 0, this.n - 1);
        }
    }

    /**
     * 构建线段树
     */
    _build(node, start, end) {
        if (start === end) {
            this.tree[node] = this.data[start];
            return;
        }

        const mid = Math.floor((start + end) / 2);
        const leftChild = 2 * node;
        const rightChild = 2 * node + 1;

        this._build(leftChild, start, mid);
        this._build(rightChild, mid + 1, end);

        this.tree[node] = this.merge(
            this.tree[leftChild], 
            this.tree[rightChild]
        );
    }

    /**
     * 区间查询
     */
    query(left, right) {
        if (left < 0 || right >= this.n || left > right) {
            throw new Error('查询区间无效');
        }
        return this._query(1, 0, this.n - 1, left, right);
    }

    _query(node, start, end, left, right) {
        // 完全包含
        if (left <= start && end <= right) {
            return this.tree[node];
        }

        // 完全不重叠
        if (right < start || end < left) {
            return this._getIdentity();
        }

        // 部分重叠
        const mid = Math.floor((start + end) / 2);
        const leftResult = this._query(2 * node, start, mid, left, right);
        const rightResult = this._query(2 * node + 1, mid + 1, end, left, right);

        return this.merge(leftResult, rightResult);
    }

    /**
     * 单点更新
     */
    update(index, value) {
        if (index < 0 || index >= this.n) {
            throw new Error('索引越界');
        }
        
        this.data[index] = value;
        this._update(1, 0, this.n - 1, index, value);
    }

    _update(node, start, end, index, value) {
        if (start === end) {
            this.tree[node] = value;
            return;
        }

        const mid = Math.floor((start + end) / 2);

        if (index <= mid) {
            this._update(2 * node, start, mid, index, value);
        } else {
            this._update(2 * node + 1, mid + 1, end, index, value);
        }

        this.tree[node] = this.merge(
            this.tree[2 * node],
            this.tree[2 * node + 1]
        );
    }

    /**
     * 获取单位元
     */
    _getIdentity() {
        if (this.merge === ((a, b) => a + b)) {
            return 0; // 加法的单位元
        } else if (this.merge === Math.min) {
            return Infinity; // min的单位元
        } else if (this.merge === Math.max) {
            return -Infinity; // max的单位元
        }
        return null;
    }

    /**
     * 打印线段树(调试用)
     */
    print() {
        console.log('原始数组:', this.data);
        console.log('线段树数组:', this.tree.slice(1));
    }
}

使用示例

// 区间求和
const arr = [1, 3, 5, 7, 9, 11];
const sumTree = new SegmentTree(arr, (a, b) => a + b);

console.log(sumTree.query(0, 2));  // 1+3+5=9
console.log(sumTree.query(1, 4));  // 3+5+7+9=24

sumTree.update(2, 10);
console.log(sumTree.query(0, 2));  // 1+3+10=14

// 区间最小值
const minTree = new SegmentTree([5, 2, 8, 1, 9], Math.min);
console.log(minTree.query(0, 4));  // 1
console.log(minTree.query(1, 3));  // 1

// 区间最大值
const maxTree = new SegmentTree([5, 2, 8, 1, 9], Math.max);
console.log(maxTree.query(0, 4));  // 9

🎯 实际应用场景

1. 实时数据统计(最经典应用)

股票价格监控系统

class StockPriceMonitor {
    constructor(prices) {
        this.maxTree = new SegmentTree(prices, Math.max);
        this.minTree = new SegmentTree(prices, Math.min);
        this.sumTree = new SegmentTree(prices, (a, b) => a + b);
    }

    // 查询任意时间段的最高价
    getMaxPrice(startTime, endTime) {
        return this.maxTree.query(startTime, endTime);
    }

    // 查询任意时间段的最低价
    getMinPrice(startTime, endTime) {
        return this.minTree.query(startTime, endTime);
    }

    // 查询任意时间段的平均价
    getAvgPrice(startTime, endTime) {
        const sum = this.sumTree.query(startTime, endTime);
        const count = endTime - startTime + 1;
        return sum / count;
    }

    // 实时更新价格
    updatePrice(timeIndex, newPrice) {
        this.maxTree.update(timeIndex, newPrice);
        this.minTree.update(timeIndex, newPrice);
        this.sumTree.update(timeIndex, newPrice);
    }
}

// 使用
const prices = [100, 105, 98, 110, 102, 108, 95, 112];
const monitor = new StockPriceMonitor(prices);

console.log('第1-4天最高价:', monitor.getMaxPrice(0, 3));  // 110
console.log('第1-4天最低价:', monitor.getMinPrice(0, 3));  // 98
console.log('第1-4天平均价:', monitor.getAvgPrice(0, 3).toFixed(2));  // 103.25

// 模拟价格更新
monitor.updatePrice(4, 115);
console.log('更新后整周最高价:', monitor.getMaxPrice(0, 7));  // 115

真实系统中的优化:

  • 分布式存储:海量数据分片
  • 增量更新:只更新变化的部分
  • 缓存层:热点数据Redis缓存
  • 流式计算:Flink实时聚合

2. 游戏地图碰撞检测

RTS游戏单位碰撞

class GameMapCollision {
    constructor(mapSize) {
        this.size = mapSize;
        // 用线段树维护每行/每列的单位数量
        this.rowTree = new SegmentTree(
            new Array(mapSize).fill(0), 
            (a, b) => a + b
        );
        this.colTree = new SegmentTree(
            new Array(mapSize).fill(0), 
            (a, b) => a + b
        );
    }

    // 添加单位
    addUnit(x, y) {
        this.rowTree.update(x, this.rowTree.query(x, x) + 1);
        this.colTree.update(y, this.colTree.query(y, y) + 1);
    }

    // 移除单位
    removeUnit(x, y) {
        this.rowTree.update(x, this.rowTree.query(x, x) - 1);
        this.colTree.update(y, this.colTree.query(y, y) - 1);
    }

    // 查询矩形区域内的单位数量
    queryRegion(x1, y1, x2, y2) {
        let count = 0;
        for (let x = x1; x <= x2; x++) {
            count += this.rowTree.query(x, x);
        }
        return count;
    }

    // 检查区域是否拥挤
    isCrowded(x1, y1, x2, y2, threshold = 10) {
        return this.queryRegion(x1, y1, x2, y2) > threshold;
    }
}

// 使用
const gameMap = new GameMapCollision(100);

gameMap.addUnit(10, 20);
gameMap.addUnit(10, 25);
gameMap.addUnit(15, 20);

console.log('区域[10-15, 20-25]单位数:', 
    gameMap.queryRegion(10, 20, 15, 25));  // 3
console.log('是否拥挤:', gameMap.isCrowded(10, 20, 15, 25));  // false

游戏引擎中的实现:

  • 四叉树/八叉树:2D/3D空间分割
  • BVH(包围盒层次) :快速剔除
  • 空间哈希:离散化网格
  • GPU加速:并行碰撞检测

3. 数据库范围查询优化

SQL查询加速

class DatabaseRangeIndex {
    constructor(records) {
        // 假设records按某个字段排序
        this.records = records.sort((a, b) => a.age - b.age);
        this.values = this.records.map(r => r.age);
        this.indexTree = new SegmentTree(this.values, Math.min);
    }

    // 查询年龄在[minAge, maxAge]范围内的记录
    queryByAgeRange(minAge, maxAge) {
        // 二分查找边界
        const left = this.lowerBound(minAge);
        const right = this.upperBound(maxAge) - 1;

        if (left > right) return [];

        // 验证范围内确实有符合条件的(线段树快速检查)
        const minInRange = this.indexTree.query(left, right);
        if (minInRange > maxAge) return [];

        // 返回范围内的记录
        return this.records.slice(left, right + 1);
    }

    lowerBound(target) {
        let left = 0, right = this.values.length;
        while (left < right) {
            const mid = Math.floor((left + right) / 2);
            if (this.values[mid] < target) {
                left = mid + 1;
            } else {
                right = mid;
            }
        }
        return left;
    }

    upperBound(target) {
        let left = 0, right = this.values.length;
        while (left < right) {
            const mid = Math.floor((left + right) / 2);
            if (this.values[mid] <= target) {
                left = mid + 1;
            } else {
                right = mid;
            }
        }
        return left;
    }
}

// 使用
const users = [
    { name: 'Alice', age: 25 },
    { name: 'Bob', age: 30 },
    { name: 'Charlie', age: 22 },
    { name: 'David', age: 35 },
    { name: 'Eve', age: 28 }
];

const dbIndex = new DatabaseRangeIndex(users);
const result = dbIndex.queryByAgeRange(25, 30);
console.log(result);
// [{ name: 'Eve', age: 28 }, { name: 'Alice', age: 25 }, { name: 'Bob', age: 30 }]

真实数据库的实现:

  • B+树索引:磁盘友好的平衡树
  • 位图索引:低基数字段
  • 倒排索引:全文搜索
  • 覆盖索引:避免回表

4. 图像处理中的区域统计

积分图加速

class ImageRegionStats {
    constructor(imageData) {
        // imageData是二维数组,表示像素亮度
        this.rows = imageData.length;
        this.cols = imageData[0].length;
        
        // 为每行构建线段树
        this.rowTrees = imageData.map(row => 
            new SegmentTree(row, (a, b) => a + b)
        );
    }

    // 查询矩形区域的平均亮度
    getAverageBrightness(x1, y1, x2, y2) {
        let totalBrightness = 0;
        
        for (let y = y1; y <= y2; y++) {
            totalBrightness += this.rowTrees[y].query(x1, x2);
        }

        const pixelCount = (x2 - x1 + 1) * (y2 - y1 + 1);
        return totalBrightness / pixelCount;
    }

    // 查询最亮区域
    findBrightestRegion(regionWidth, regionHeight) {
        let maxBrightness = -Infinity;
        let bestRegion = null;

        for (let y = 0; y <= this.rows - regionHeight; y++) {
            for (let x = 0; x <= this.cols - regionWidth; x++) {
                const brightness = this.getAverageBrightness(
                    x, y, x + regionWidth - 1, y + regionHeight - 1
                );

                if (brightness > maxBrightness) {
                    maxBrightness = brightness;
                    bestRegion = { x, y, brightness };
                }
            }
        }

        return bestRegion;
    }
}

// 使用
const image = [
    [100, 120, 110, 130],
    [140, 150, 160, 145],
    [130, 125, 135, 140],
    [110, 115, 120, 125]
];

const imgStats = new ImageRegionStats(image);
console.log('区域[1-2, 1-2]平均亮度:', 
    imgStats.getAverageBrightness(1, 1, 2, 2).toFixed(2));
// 151.25

const brightest = imgStats.findBrightestRegion(2, 2);
console.log('最亮2x2区域:', brightest);

专业图像库的实现:

  • 积分图(Integral Image) :O(1)区域求和
  • 金字塔图像:多尺度分析
  • 直方图均衡化:对比度增强
  • GPU shader:并行像素处理

⚡ 高级功能:懒标记(Lazy Propagation)

问题:区间更新效率低

如果要给区间 [left, right] 的所有元素加上一个值 val

// ❌ 朴素做法:逐个更新
for (let i = left; i <= right; i++) {
    segmentTree.update(i, segmentTree.data[i] + val);
}
// 时间复杂度:O((right-left+1) × log n)

太慢了!

解决方案:懒标记

核心思想:  延迟更新,只在必要时才下传

class SegmentTreeWithLazy {
    constructor(data, merge = (a, b) => a + b) {
        this.data = [...data];
        this.merge = merge;
        this.n = data.length;
        this.tree = new Array(4 * this.n).fill(0);
        this.lazy = new Array(4 * this.n).fill(0); // 懒标记数组
        
        if (this.n > 0) {
            this._build(1, 0, this.n - 1);
        }
    }

    _build(node, start, end) {
        if (start === end) {
            this.tree[node] = this.data[start];
            return;
        }

        const mid = Math.floor((start + end) / 2);
        this._build(2 * node, start, mid);
        this._build(2 * node + 1, mid + 1, end);
        this.tree[node] = this.merge(this.tree[2 * node], this.tree[2 * node + 1]);
    }

    /**
     * 区间更新:给 [left, right] 的所有元素加上 val
     */
    rangeUpdate(left, right, val) {
        this._rangeUpdate(1, 0, this.n - 1, left, right, val);
    }

    _rangeUpdate(node, start, end, left, right, val) {
        // 完全包含
        if (left <= start && end <= right) {
            this.tree[node] += val * (end - start + 1);
            this.lazy[node] += val; // 标记延迟更新
            return;
        }

        // 下传懒标记
        this._pushDown(node, start, end);

        const mid = Math.floor((start + end) / 2);

        if (left <= mid) {
            this._rangeUpdate(2 * node, start, mid, left, right, val);
        }
        if (right > mid) {
            this._rangeUpdate(2 * node + 1, mid + 1, end, left, right, val);
        }

        this.tree[node] = this.merge(this.tree[2 * node], this.tree[2 * node + 1]);
    }

    /**
     * 下传懒标记
     */
    _pushDown(node, start, end) {
        if (this.lazy[node] !== 0) {
            const mid = Math.floor((start + end) / 2);
            const leftChild = 2 * node;
            const rightChild = 2 * node + 1;

            // 更新左子树
            this.tree[leftChild] += this.lazy[node] * (mid - start + 1);
            this.lazy[leftChild] += this.lazy[node];

            // 更新右子树
            this.tree[rightChild] += this.lazy[node] * (end - mid);
            this.lazy[rightChild] += this.lazy[node];

            // 清除当前节点的懒标记
            this.lazy[node] = 0;
        }
    }

    query(left, right) {
        return this._query(1, 0, this.n - 1, left, right);
    }

    _query(node, start, end, left, right) {
        if (left <= start && end <= right) {
            return this.tree[node];
        }

        // 下传懒标记
        this._pushDown(node, start, end);

        if (right < start || end < left) {
            return 0;
        }

        const mid = Math.floor((start + end) / 2);
        const leftResult = this._query(2 * node, start, mid, left, right);
        const rightResult = this._query(2 * node + 1, mid + 1, end, left, right);

        return leftResult + rightResult;
    }
}

// 使用
const arr = [1, 2, 3, 4, 5];
const lazyTree = new SegmentTreeWithLazy(arr);

console.log('初始 [0-4] 的和:', lazyTree.query(0, 4));  // 15

// 给 [1-3] 的所有元素加10
lazyTree.rangeUpdate(1, 3, 10);

console.log('更新后 [0-4] 的和:', lazyTree.query(0, 4));  // 45
console.log('更新后 [1-3] 的和:', lazyTree.query(1, 3));  // 39

时间复杂度:  O(log n),无论区间多大!


🆚 线段树 vs 其他区间数据结构

数据结构 构建 查询 更新 适用场景
线段树 O(n) O(log n) O(log n) 通用区间操作
前缀和 O(n) O(1) O(n) 静态数据查询
树状数组 O(n) O(log n) O(log n) 前缀和、简单区间
稀疏表 O(n log n) O(1) 不支持 静态RMQ
分块 O(n) O(√n) O(√n) 实现简单

选择建议:

  • 需要区间更新 → 线段树(带懒标记)
  • 只需前缀和 → 树状数组(代码更简洁)
  • 静态数据RMQ → 稀疏表(查询O(1))
  • 追求简单 → 分块(容易实现)

🐛 常见坑与解决方案

坑1:数组大小不够

// ❌ 错误:tree数组太小
this.tree = new Array(2 * this.n).fill(0);
// 可能导致越界

// ✅ 正确:至少4倍
this.tree = new Array(4 * this.n).fill(0);

症状:  Cannot read property of undefined

原因:  线段树是满二叉树,节点数可能接近4n

坑2:单位元错误

// ❌ 错误:求最小值时返回0
_getIdentity() {
    return 0; // 如果所有元素都是正数,会出错
}

// ✅ 正确:根据merge函数返回合适的单位元
_getIdentity() {
    if (this.merge === Math.min) return Infinity;
    if (this.merge === Math.max) return -Infinity;
    if (this.merge === ((a, b) => a + b)) return 0;
}

症状:  查询结果错误

坑3:忘记下传懒标记

// ❌ 错误:查询时忘记pushDown
_query(node, start, end, left, right) {
    // 直接使用tree[node],但可能有未下传的懒标记
    return this.tree[node];
}

// ✅ 正确:先下传
_query(node, start, end, left, right) {
    this._pushDown(node, start, end); // 必须先下传
    // ...
}

症状:  查询结果不一致

坑4:递归深度溢出

// ❌ 错误:超大数组导致栈溢出
const hugeArr = new Array(1000000).fill(0);
const tree = new SegmentTree(hugeArr);
// Maximum call stack size exceeded

// ✅ 解决:改用迭代或增加栈大小
// 或者使用非递归实现的线段树

症状:  Maximum call stack size exceeded

解决:

  • 限制数组大小
  • 使用非递归实现
  • Node.js中用 --stack-size 参数

📊 性能测试数据

不同操作的性能对比

数组大小   | 构建时间 | 单次查询 | 单次更新
----------|---------|---------|--------
1,000     | 1ms     | 0.01ms  | 0.01ms
10,000    | 10ms    | 0.02ms  | 0.02ms
100,000   | 100ms   | 0.03ms  | 0.03ms
1,000,000 | 1s      | 0.04ms  | 0.04ms

与前缀和对比

操作           | 前缀和 | 线段树
--------------|--------|-------
构建           | O(n)   | O(n)
查询           | O(1)   | O(log n)
单点更新       | O(n)   | O(log n)
10000次查询    | 0.1ms  | 0.3ms
10000次更新+查询| 5000ms | 0.6ms

结论:  动态数据用线段树,静态数据用前缀和


🎓 LeetCode相关题目

掌握了线段树,这些题轻松搞定:

  1. [LeetCode 307] 区域和检索 - 数组可修改

    • 线段树模板题
  2. [LeetCode 303] 区域和检索 - 数组不可变

    • 前缀和解法
  3. [LeetCode 699] 掉落的方块

    • 线段树 + 坐标离散化
  4. [LeetCode 715] Range模块

    • 线段树维护区间集合
  5. [LeetCode 732] 我的日程安排表 III

    • 线段树求最大值

🔮 线段树的未来发展

1. 持久化线段树

支持历史版本查询:

class PersistentSegmentTree {
    constructor() {
        this.versions = [];
        this.currentVersion = 0;
    }

    update(index, value) {
        // 创建新版本,而不是修改原树
        const newRoot = this._cloneAndUpdate(
            this.versions[this.currentVersion],
            index, value
        );
        this.versions.push(newRoot);
        this.currentVersion++;
    }

    query(version, left, right) {
        // 查询历史版本
        return this._query(this.versions[version], left, right);
    }
}

应用:  Git-like的版本管理、数据库MVCC

2. 二维线段树

处理矩阵区域查询:

class SegmentTree2D {
    constructor(matrix) {
        this.rows = matrix.length;
        this.cols = matrix[0].length;
        // 每行一个线段树
        this.rowTrees = matrix.map(row => 
            new SegmentTree(row, (a, b) => a + b)
        );
    }

    // 查询矩形区域的和
    query(x1, y1, x2, y2) {
        let sum = 0;
        for (let y = y1; y <= y2; y++) {
            sum += this.rowTrees[y].query(x1, x2);
        }
        return sum;
    }
}

应用:  图像处理的区域统计、地理信息系统

3. 动态开点线段树

节省空间,适合稀疏数据:

class DynamicSegmentTree {
    constructor() {
        this.root = null;
        this.nodeCount = 0;
    }

    _createNode() {
        return {
            left: null,
            right: null,
            value: 0,
            id: this.nodeCount++
        };
    }

    update(index, value, node = this.root, start = 0, end = 1e9) {
        if (!node) {
            node = this._createNode();
            if (!this.root) this.root = node;
        }

        if (start === end) {
            node.value = value;
            return node;
        }

        const mid = Math.floor((start + end) / 2);
        if (index <= mid) {
            node.left = this.update(index, value, node.left, start, mid);
        } else {
            node.right = this.update(index, value, node.right, mid + 1, end);
        }

        node.value = (node.left?.value || 0) + (node.right?.value || 0);
        return node;
    }
}

应用:  坐标范围极大但数据稀疏的场景


💡 总结

线段树的三大优势

  1. 查询更新平衡:都是O(log n),没有短板
  2. 灵活性强:支持各种聚合操作(和、最值、GCD等)
  3. 可扩展性好:懒标记、持久化、多维扩展

核心要点回顾

✅ 每个节点代表一个区间
✅ 叶子节点是单个元素
✅ 父节点 = merge(左子树, 右子树)
✅ 查询和更新都是O(log n)
✅ 懒标记实现高效的区间更新

学习建议

  1. 先手写一遍:不要复制粘贴,自己实现
  2. 画图理解:画出树的结构和递归过程
  3. 对比实验:和前缀和、树状数组对比
  4. 实际应用:做个实时数据统计demo

📚 延伸阅读

  • 《算法竞赛入门经典》- 线段树章节
  • 《挑战程序设计竞赛》- 高级数据结构
  • CP-Algorithms - 线段树进阶技巧

完整代码已开源:  github.com/Lee985-cmd/…

觉得有用?欢迎Star、Fork、提Issue!

下一篇预告:  《并查集:连通性问题的终极解决方案》

Hello 算法:贪心的世界

作者 灵感__idea
2026年4月13日 00:21

每个系列一本前端好书,帮你轻松学重点。

本系列来自上海交通大学硕士,华为高级算法工程师 靳宇栋《Hello,算法》

一轮轮的简单选择中,时刻追求自身成长的最大可能,逐步导向最佳答案。

本篇话题展开之前,先看个日常很常见的问题:零钱兑换

你去超市购物,给收银员100元,而你购买的商品只需要2元,他要怎样给你找钱?

很简单,会100内加减的都能轻易搞定,你会根据还剩余的找零额度,从可选择的币种中选择面值最大的,直至达到数额为止。

其实你会发现,“零钱”只是一种比较具象的表达,把“找零”这件事进一步抽象,可用于解决很多领域的问题。

它,就是“贪心算法”。

贪心算法

贪心算法(greedy algorithm)是一种常见的解决优化问题的算法,其基本思想是在问题的每个决策阶段,都选择当前看起来最优的选择,即贪心地做出局部最优的决策,以期获得全局最优解。

贪心算法简洁且高效,在许多实际问题中有着广泛的应用。

除了找零钱,它还可用于解决“分数背包问题”:

给一个背包,有容量限制,另有N件物品,每件物品都有它的重量和价值,每件物品只能选择一次,但可以选择物品的一部分,价值根据选择的重量比例计算

求:在限定背包容量下,背包中物品的最大价值。

这个问题的解答策略和“找零”类似:最大化背包内物品总价值,本质上是最大化单位重量下的物品价值

  1. 将物品按照单位价值从高到低进行排序。
  2. 遍历所有物品,每轮贪心地选择单位价值最高的物品。
  3. 若剩余背包容量不足,则使用当前物品的一部分填满背包。

注意:这里说的是“分数背包”,不是“0-1背包”。

代码实现

以“找零”为例,贪心算法的核心实现:

/* 零钱兑换:贪心 */
function coinChangeGreedy(coins, amt) {
    // 假设 coins 数组有序
    let i = coins.length - 1;
    let count = 0;
    // 循环进行贪心选择,直到无剩余金额
    while (amt > 0) {
        // 找到小于且最接近剩余金额的硬币
        while (i > 0 && coins[i] > amt) {
            i--;
        }
        // 选择 coins[i]
        amt -= coins[i];
        count++;
    }
    // 若未找到可行方案,则返回 -1
    return amt === 0 ? count : -1;
}

示意图如下:

微信图片_20260413001704_150.jpg

优点与局限

贪心算法的优点是操作直接、实现简单,通常效率也很高。

但不是所有分步解决的问题都适合使用贪心,什么样的问题适合呢?

主要关注两个性质。

  • 贪心选择性质:只有当局部最优选择始终可以导致全局最优解时,贪心算法才能保证得到最优解。
  • 最优子结构:原问题的最优解包含子问题的最优解。

关键词:最优解

还是找零问题,贪心能找到最优解的前提是,有足够的币值种类可选,如果没有,像下面这样:

给定币值:[1,20,50],目标值是 60,使用贪心策略,它会找到 50 + 1*10,总数是11,但实际更优的做法是 20 * 3,只需要3就可以。

所以可以理解为,贪心适合的场景是“想要多少(比如10),就有多少”,而不是妥协退而求其次。

其他应用

除了上面介绍的两种,贪心的适用场景还包括但不限于:

  • 区间调度问题:假设你有一些任务,每个任务在一段时间内进行,你的目标是完成尽可能多的任务。如果每次都选择结束时间最早的任务,那么贪心算法就可以得到最优解。
  • 股票买卖问题:给定一组股票的历史价格,你可以进行多次买卖,但如果你已经持有股票,那么在卖出之前不能再买,目标是获取最大利润。
  • 霍夫曼编码:霍夫曼编码是一种用于无损数据压缩的贪心算法。通过构建霍夫曼树,每次选择出现频率最低的两个节点合并,最后得到的霍夫曼树的带权路径长度(编码长度)最小。
  • Dijkstra 算法:它是一种解决给定源顶点到其余各顶点的最短路径问题的贪心算法。

小结

贪心是必掌握的经典算法之一,实现也不难,重点是吃透它的使用场景。

下一篇,将是本系列的终篇,让我们一起期待会是什么。

更多好文第一时间接收,可关注公众号:“前端说书匠”

昨天以前首页

大禹平台:流批一体离线Dump平台的设计与应用|得物技术

作者 得物技术
2026年3月19日 10:47

一、前言

大禹平台是一个离线 Dump 平台。在不同的场景都有自己的 Dump 流程,我们这里的 Dump 特指在搜索、推荐、广告(后续简称 “搜推广”)的场景中,将异构数据源加工处理后给到索引平台做索引的流程。

Dump 流程有如下一些特点:

  • 多源异构的数据:包括 MySQL、ODPS、HBase 和 Kafka 等各种数据源。
  • 多样化的输出:输出支持搜推广引擎构建倒排索引、Summary 服务构建 kv/kkv 索引等。
  • 流批数据结合:一般会有全量和增量,需要保证处理逻辑一致,增量能达到秒级更新。
  • 数据处理能力:例如多表 Join、UDF、Filter 等,以方便业务的开发和接入。

离线 Dump 流程

二、项目背景

现状

当前 dump 开发模式

如上图是当前常见的 Dump 开发模式,采用了流批分离架构:流处理通过 DTS 订阅 binlog,由 Flink 消费主表变更事件并反查关联表构建宽表,实现增量更新;批处理则将 MySQL 数据抽取至 ODPS,通过 Spark 处理多源数据并按业务逻辑拼接,最终输出 ODPS 表。这种架构存在以下问题:

当前 dump 开发的问题

目标

依托社区搜索核心场景,构建流批一体化的新质 Dump 架构,实现以下三大核心能力突破:

  • 工程效率: 基于可视化 DAG 编排工具,提供低代码开发能力,通过拖拽式界面实现复杂任务流程的快速搭建与迭代,显著降低开发门槛。
  • 数据质量: 基于流批一体架构,通过统一逻辑开发范式实现流批数据同源同构,从根本上提升数据准确性与可靠性。
  • 稳定性保障: 通过引入镜像表和状态大宽表,提高了数据的查询效率,系统性降低对源库的反查压力,确保系统长期稳定运行。

二、大禹平台介绍

平台设计

系统架构

平台架构

如上图是大禹平台技术架构,底层依赖公司的 DJob Cron 定时任务、Flink/Spark 流批计算能力以及多种存储系统;上层为平台支持的搜推广多种场景业务。

大禹平台分为管理平台与后台系统两部分。管理平台完成处理逻辑的 DAG 开发和相关 Debug、回归验证、监控大盘等能力;后台系统将管理平台的配置转为执行任务,然后依托流批框架生成 Flink/Spark 执行实例,通过调度引擎完成全流程任务执行。

如下图是新版 Dump 流程,将 Dump 拆分为三个阶段:镜像阶段、宽表阶段、导出阶段,以及流、批两种处理模式。新版流程处理过程有如下优化:

  • MySQL 镜像至 HBase: 平台将任务依赖的 MySQL 数据统一同步至 HBase 构建镜像层,实现与上游 RDS 解耦。有效规避多任务并发反查导致的数据库压力,支持跨任务共享复用 HBase 镜像表,显著提升数据源稳定性与资源利用率。
  • Binlog 订阅平台化: 将 RDS Binlog 订阅流程深度内嵌,自动完成 DTS 订阅创建与 Kafka 资源申请,封装为标准化服务。开发者无需关注底层链路,一键配置即可获取实时变更流,降低接入复杂度,保障流式数据可靠性。
  • 状态大宽表消除反查: 基于 HBase 构建持久化状态大宽表,完整记录字段中间状态。任务处理时直接读取状态数据,彻底规避冗余反查逻辑,简化开发流程。

新版 Dump 流程

调度引擎

大禹平台利用得物 DJob Cron 自建调度系统,通过搭建多个 Cron Job 轮训的方式,完成对任务分阶段的处理。

Cron Job 构建调度系统

一个执行实例的全流程

执行框架

在镜像、宽表、导出三个阶段,分别都有对应 Spark 和 Flink 处理框架。其中,镜像阶段完成 MySQL 数据同步,导出阶段完成状态宽表到引擎数据源的导出流程,宽表阶段是具体的业务逻辑实现。

宽表 Spark 框架逻辑: 任务严格遵循 DAG 拓扑顺序,依次执行各算子节点(数据源→业务逻辑→导出)的数据处理流水线,最终通过 BulkLoad 方式将结果高效写入 HBase。

宽表阶段 Spark 框架逻辑

宽表 Flink 框架逻辑: 消费非维表节点的增量,依据节点依赖关系进行拓扑排序后依次执行各节点计算逻辑,将产出字段更新至状态宽表,并实时同步至下游导出链路。

宽表阶段 Flink 框架逻辑

流批一体保障数据质量

平台采用统一的 DAG 编排引擎,将流处理与批处理任务抽象为相同的计算拓扑,从架构层面保障数据源头的天然一致性,彻底规避因不同环境下开发导致的数据偏差风险。同时,平台内置标准化的 UDF(用户自定义函数)开发模板与运行时框架:开发者只需专注业务逻辑实现,编写的 UDF 代码经一次注册,即可无缝嵌入流式与批量处理流程,真正实现 “一次开发、流批复用”,显著提升开发效率,降低维护成本,保障 Dump 开发从数据源头到处理逻辑各环节的流批一致性。

平台通过定义 AlgoDumpUDF 方法类,完成消息类型封装,用户可以利用 UDF 实现数据过滤和驱动删除等逻辑。

public abstract class AlgoDumpUDF implements UDFFunction, Serializable {
    //消息类型 add/delete/drop 三种
    public AlgoDumpMessageType algoDumpMessageType = 
    AlgoDumpMessageType.MESSAGE_TYPE_ADD;
    @Override
    public AlgoDumpMessageType getStatus() {
        return algoDumpMessageType;
    }


    //调用该方法实现增量驱动删除
    @Override
    public void delete(Object key, String reason) {
        this.algoDumpMessageType = AlgoDumpMessageType.MESSAGE_TYPE_DELETE;
    }
    //调用该方法实现增量过滤
    @Override
    public void drop(Object key, String reason) {
        this.algoDumpMessageType = AlgoDumpMessageType.MESSAGE_TYPE_DROP;
    }
    /**
     * 用户重写该方法完成业务逻辑开发
     */
    public void process() throws Exception {
    }
}

CASE示例:用户通过重写process()方法, 实现自己的业务逻辑,实现时可以利用drop方法把无效数据过滤,利用delete方法实现对下游索引发送删除消息。

public class MyUdf extends AlgoDumpUDF implements Serializable {
    public  Tuple2<String, String> process(String id, String taskname) 
    throws Exception {
        //过滤消息
        if(StringUtils.isBlank(id)) {
            this.drop(id, "drop by id null");
        }


        //驱动增量删除消息
        if(id.equals(0)) {
            this.delete(id, "delete by id = 0");
        }


        //用户写具体业务逻辑
        String a1 = "";
        if (taskname.equals("dddddd")) {
            a1 = "ddd";
        }
        String b1 = "test";
        return new Tuple2<>(a1, b1);
    }
}

小全量模式加速数据Dump

大禹支持任务实例按照大全量和小全量两种模式运行,针对部分频繁更新部分字段需求的任务可实现快速加载。

  • 大全量: 对数据源执行全量同步重建,生成全新的状态大宽表,并同步刷新流批处理链路,实现数据基准的彻底更新与端到端一致性保障。
  • 小全量: 基于现有状态大宽表,仅针对批处理来源字段加载最新数据源快照,经处理后通过 BulkLoad 高效写入 HBase;依托 HBase 多版本特性实现新旧数据平滑切换,确保批处理数据增量更新过程中查询服务零中断、数据时效性与业务连续性兼得。

小全量模式

任务复用支持数据分层管理

大禹平台支持任务产出的双重应用:既可对接计算引擎(如 CEngine),亦可作为公共数据被下游任务高效复用。平台通过标准化的 MirrorOut(导出)与 MirrorIn(接入)算子构建清晰的数据复用链路 —— 上游任务将公共数据配置为 MirrorOut 导出,下游任务通过 MirrorIn 算子一键引用,无需重复开发与数据搬运,实现数据资产的即产即用、任务依赖的显式管理,显著提升开发效率与数据复用性。

任务复用

管理平台

任务开发与运维

管理平台提供一站式任务开发生命周期管理,涵盖任务创建、可视化流程编排、实例调度与资源管控等核心环节;其中,Dump 任务通过可视化编排实现业务配置——用户仅需拖拽算子节点、配置参数,即可直观构建数据处理逻辑,显著提升开发效率与配置准确性。

如下图,通过拖拽算子的方式,可以直观地构建 dump 任务的流程图,实现便捷高效的开发体验。

图画编排式开发任务

执行实例以可视化流程图形式完整呈现任务执行全流程,每个节点清晰展示输入参数与输出结果,并支持对指定节点进行手动重试或终止操作,便于问题定位与流程干预。

执行实例状态

辅助工具

数据回归验证

平台提供流批数据回归验证能力,支持模板化配置与一键复用,高效保障数据质量与业务稳定性。

  • 批量回归: 多版本批数据快速比对,一键校验全量一致性,适用于版本迭代验证;
  • 流式回归: 基于索引表增量变更抽样,对指定时间窗口内实时数据进行跨索引一致性校验,精准定位流式链路异常。

创建批数据回归任务

创建流数据回归任务

数据Debug

大禹平台构建了覆盖全链路的数据运维干预能力,确保数据处理的可靠性与灵活性。

  • 组图配置: 支持对源端组图配置进行主动干预与调整,实现配置策略的快速生效。
  • Dump流程: 支持Dump构建流程的调控,实现对全链路流程的问题快速定位,保障数据产出的稳定性与高效性。
  • 在线索引: 提供线上索引数据的实时干预能力,支持对增量数据进行修正,确保索引内容的及时性与准确性。

四、业务场景实践

社区搜索倒排表链路

如下图所示,社区搜索倒排表 Dump 任务以动态内容为核心实体,融合动态实时内容流、天级统计特征及商品多维特征,通过流批一体处理生成高时效的倒排索引宽表。

社区搜索倒排宽表链路

穿搭精选推荐链路

如下图所示,穿搭精选推荐 Dump 任务以动态-商品关系为核心主表,融合动态维度的多源流批特征数据(如内容特征基础表、内容审核表、天级离线统计特征表等),利用DAG 编排构建动态-商品的大宽表。

穿搭精选推荐链路

五、未来规划

平台能力持续增强

  • 算子体系完善: 基于业务场景持续增强关键算子(如维表动态更新、Service 服务化算子、UDTF 部署优化等)和优化调度流程,强化数据处理灵活性;
  • 性能深度优化: 引入任务剪枝、智能倾斜治理等策略,提升资源利用率与执行效率;
  • 可观测性升级: 构建覆盖全局大盘与任务粒度的监控体系,完善资源消耗追踪、Debug 与全链路 Trace 能力,夯实平台稳定性与运维支撑基础。

深化协同共建,释放平台价值

  • 纵向提效: 聚焦索引构建效率攻坚,与索引平台深度协同重构数据同步链路。以社区搜索大宽表为例,当前同步耗时近3小时,通过消除冗余中间状态、精简处理流程,可以实现索引构建端到端提速,显著压缩数据准备周期。
  • 横向赋能: 平台能力已在社区域多业务场景完成验证,后续可以联动其他业务场景共建;同时平台的子功能也具有通用能力,可将数据回归验证、索引监控大盘等高复用能力模块化开放,赋能各业务线“即插即用”,加速技术资产沉淀与跨域协同创新。

大禹未来规划

往期回顾

1.基于 Cursor Agent 的流水线 AI CR 实践|得物技术

2.从IDE到Terminal:适合后端宝宝体质的Claude Code工作流|得物技术

3.AI编程能力边界探索:基于 Claude Code 的 Spec Coding 项目实战|得物技术

4.搜索 C++ 引擎回归能力建设:从自测到工程化准出|得物技术

5.得物社区搜推公式融合调参框架-加乘树3.0实战

文 /野雨

关注得物技术,每周更新技术干货

要是觉得文章对你有帮助的话,欢迎评论转发点赞~

未经得物技术许可严禁转载,否则依法追究法律责任。

Matrix获取卡顿堆栈 (Point Stack)

作者 KangJX
2026年3月17日 17:23

matrix 是腾讯微信团队开源的一套移动端性能监控与分析框架,核心目标是帮助开发者定位、解决移动端(iOS/Android)应用的性能问题,是微信内部大规模验证过的成熟工具,本文通过阅读源码,详细介绍了针对卡顿日志获取的核心原理。

Matrix 通过周期性采集主线程堆栈并保存在循环数组中,在检测到卡顿时,使用 Point Stack 算法找出最有可能导致卡顿的堆栈。

核心思想

时间流逝
   ↓
每 50ms 采集一次主线程堆栈
   ↓
保存到循环数组(固定大小,如 20 个)
   ↓
检测到卡顿时
   ↓
分析循环数组,找出 Point Stack(最可能导致卡顿的堆栈)
   ↓
生成卡顿报告

设计目标

目标 实现方式
及时性 每 50ms 采集一次,不错过卡顿过程
完整性 保存一个周期内的所有堆栈(通常 20 个)
准确性 通过算法找出真正导致卡顿的堆栈
高效性 固定大小循环数组,避免内存膨胀
低开销 CPU 占用 < 3%,不影响用户体验

核心时间参数

三个关键参数

// 1. RunLoop 超时阈值(卡顿判定阈值)
static useconds_t g_RunLoopTimeOut = 2000000;  // 2000ms = 2秒
// 作用:超过此时间判定为卡顿

// 2. 检查周期(单次采集周期)
static useconds_t g_CheckPeriodTime = 1000000;  // 1000ms = 1秒
// 作用:一轮堆栈采集的总时间,通常为超时阈值的一半

// 3. 堆栈采集间隔
static useconds_t g_PerStackInterval = 50000;  // 50ms
// 作用:每次堆栈采集之间的时间间隔

参数关系

┌────────────────────────────────────────────────┐
│ g_RunLoopTimeOut (2秒) - 卡顿判定阈值          │
└────────────────────────────────────────────────┘
                    │
                    ├─ 一半
                    ↓
┌────────────────────────────────────────────────┐
│ g_CheckPeriodTime (1秒) - 检查周期             │
└────────────────────────────────────────────────┘
                    │
                    ├─ 除以
                    ↓
┌────────────────────────────────────────────────┐
│ g_PerStackInterval (50ms) - 堆栈间隔           │
└────────────────────────────────────────────────┘
                    ↓
┌────────────────────────────────────────────────┐
│ g_MainThreadCount = 1000ms / 50ms = 20         │
│ (循环数组大小 = 一个周期内采集的堆栈数量)      │
└────────────────────────────────────────────────┘

时间轴示意

时间线(以2秒卡顿为例):

T=0ms      开始监控
|
T=50ms     采集第1个堆栈 ← S0
|
T=100ms    采集第2个堆栈 ← S1
|
T=150ms    采集第3个堆栈 ← S2
|
...
|
T=950ms    采集第19个堆栈 ← S18
|
T=1000ms   采集第20个堆栈 ← S19  ← 完成一轮采集
|          ↓
|          检查是否卡顿(检查RunLoop执行时间)
|          如果未卡顿,进入下一轮采集
|
T=1050ms   采集第21个堆栈 ← S20(覆盖S0)
|
...
|
T=2000ms   检查发现 RunLoop 执行超过 2秒
|          ↓
|          触发卡顿检测
|          ↓
|          分析循环数组中的 20 个堆栈
|          ↓
|          找出 Point Stack(最可能导致卡顿的堆栈)
|          ↓
|          生成卡顿报告

堆栈获取流程

整体流程图

┌──────────────────────────────────────────┐
│ 监控线程主循环                            │
│ while (YES) {                            │
│   check();            // 检测卡顿         │recordCurrentStack(); // 采集堆栈       │
│ }                                        │
└──────────────────────────────────────────┘
                    ↓
┌──────────────────────────────────────────┐
│ recordCurrentStack() 方法                │
│                                          │
│ 外层循环:遍历检查周期                    │
│   nTotalCnt = m_nIntervalTime /         │
│               g_CheckPeriodTime         │
│   通常 = 1000ms / 1000ms = 1次          │
│                                          │
│   内层循环:在一个周期内多次采集          │
│     intervalCount = g_CheckPeriodTime / │
│                     g_PerStackInterval  │
│     通常 = 1000ms / 50ms = 20次         │
│                                          │
│     每次循环:                            │
│       1. usleep(50ms)  // 等待          │2. 获取主线程堆栈                   │
│       3. 添加到循环数组                   │
└──────────────────────────────────────────┘

详细步骤

步骤 1:初始化
// 在 WCBlockMonitorMgr 的 start 方法中
- (void)start {
    // 计算循环数组大小
    g_MainThreadCount = g_CheckPeriodTime / g_PerStackInterval;
    // 例如:1000ms / 50ms = 20
    
    // 创建主线程堆栈处理器
    m_pointMainThreadHandler = [[WCMainThreadHandler alloc] 
                                 initWithCycleArrayCount:g_MainThreadCount];
    
    // g_MainThreadCount = 20
    // 意味着循环数组可以保存 20 个堆栈
}
步骤 2:周期性采集
- (void)recordCurrentStack {
    // ================================================================
    // 外层循环:决定执行几个检查周期
    // ================================================================
    // 正常情况:m_nIntervalTime = 1000ms
    // 退火情况:m_nIntervalTime = 2000ms, 3000ms, 5000ms...
    unsigned long nTotalCnt = m_nIntervalTime / g_CheckPeriodTime;
    
    for (int nCnt = 0; nCnt < nTotalCnt && !m_bStop; nCnt++) {
        // 记录本轮开始时间(用于检测系统挂起)
        gettimeofday(&m_recordStackTime, NULL);
        
        if (g_MainThreadHandle) {
            // ========================================================
            // 内层循环:在一个检查周期内多次采集
            // ========================================================
            // intervalCount = 1000ms / 50ms = 20
            int intervalCount = g_CheckPeriodTime / g_PerStackInterval;
            
            for (int index = 0; index < intervalCount && !m_bStop; index++) {
                // 1️⃣ 等待 50ms
                usleep(g_PerStackInterval);  // 50000 微秒 = 50ms
                
                // 2️⃣ 分配内存
                size_t stackBytes = sizeof(uintptr_t) * g_StackMaxCount;
                uintptr_t *stackArray = (uintptr_t *)malloc(stackBytes);
                if (stackArray == NULL) {
                    continue;  // 内存分配失败,跳过本次
                }
                
                // 3️⃣ 初始化
                __block size_t nSum = 0;
                memset(stackArray, 0, stackBytes);
                
                // 4️⃣ 获取主线程堆栈
                [WCGetMainThreadUtil
                 getCurrentMainThreadStack:^(NSUInteger pc) {
                     stackArray[nSum] = (uintptr_t)pc;  // 保存程序计数器
                     nSum++;
                 }
                 withMaxEntries:g_StackMaxCount        // 最大100个栈帧
                 withThreadCount:g_CurrentThreadCount];
                
                // 5️⃣ 添加到循环数组
                [m_pointMainThreadHandler addThreadStack:stackArray 
                                           andStackCount:nSum];
                // 注意:stackArray 的所有权转移给 m_pointMainThreadHandler
            }
        }
        
        // ============================================================
        // 检测是否被系统挂起
        // ============================================================
        struct timeval tvCur;
        gettimeofday(&tvCur, NULL);
        unsigned long long diff = [WCBlockMonitorMgr diffTime:&m_recordStackTime 
                                                      endTime:&tvCur];
        
        if (diff > DETECTION_THREAD_JUDGE_SUSPEND_THRESHOLD) {
            // 实际消耗时间 > 10秒,说明被挂起了
            gettimeofday(&g_tvRun, NULL);  // 更新时间,避免误报
            MatrixInfo(@"挂起后运行,差值 %llu", diff);
            return;
        }
    }
}
步骤 3:获取主线程堆栈(底层实现)
// WCGetMainThreadUtil 内部使用 backtrace
+ (void)getCurrentMainThreadStack:(StackCallback)callback 
                   withMaxEntries:(size_t)maxEntries
                  withThreadCount:(NSUInteger)threadCount {
    // 1. 获取主线程
    thread_t mainThread = pthread_mach_thread_np(pthread_main_thread_np());
    
    // 2. 暂停主线程(非常短暂,微秒级)
    thread_suspend(mainThread);
    
    // 3. 获取线程状态
    _STRUCT_MCONTEXT machineContext;
    mach_msg_type_number_t state_count = THREAD_STATE_COUNT;
    kern_return_t kr = thread_get_state(mainThread,
                                        THREAD_STATE,
                                        (thread_state_t)&machineContext.__ss,
                                        &state_count);
    
    // 4. 回溯堆栈
    if (kr == KERN_SUCCESS) {
        uintptr_t backtraceBuffer[maxEntries];
        size_t backtraceLength = ksbt_backtraceLength(&machineContext);
        
        // 遍历堆栈帧
        for (size_t i = 0; i < backtraceLength && i < maxEntries; i++) {
            uintptr_t pc = ksbt_framePointer(&machineContext, i);
            callback(pc);  // 回调传递每个栈帧的地址
        }
    }
    
    // 5. 恢复主线程
    thread_resume(mainThread);
}

循环数组存储机制

数据结构设计

@interface WCMainThreadHandler {
    // ================================================================
    // 循环数组配置
    // ================================================================
    int m_cycleArrayCount;  // 数组大小,例如 20
    
    // ================================================================
    // 循环数组(核心存储结构)
    // ================================================================
    uintptr_t **m_mainThreadStackCycleArray;  // 二维数组
    // 第一维:堆栈索引 [0, 19]
    // 第二维:堆栈地址数组 uintptr_t[]
    
    size_t *m_mainThreadStackCount;  // 每个堆栈的深度
    // 例如:[50, 48, 52, ..., 45]
    
    uint64_t m_tailPoint;  // 尾指针,指向下一个写入位置
    
    // ================================================================
    // 分析数据(用于 Point Stack 算法)
    // ================================================================
    size_t *m_topStackAddressRepeatArray;  // 栈顶地址连续重复次数
    // 例如:[0, 1, 2, 0, 1, 0, ...]
    
    int *m_mainThreadStackRepeatCountArray;  // Point Stack 地址总重复次数
    // 动态分配,在找到 Point Stack 后创建
}

循环数组可视化

初始化状态(m_cycleArrayCount = 5):

索引:    0     1     2     3     4
       ┌─────┬─────┬─────┬─────┬─────┐
数组:   │NULL │NULL │NULL │NULL │NULL │
       └─────┴─────┴─────┴─────┴─────┘
          ↑
     m_tailPoint = 0


添加第 1 个堆栈(S0):

索引:    0     1     2     3     4
       ┌─────┬─────┬─────┬─────┬─────┐
数组:   │ S0  │NULL │NULL │NULL │NULL │
       └─────┴─────┴─────┴─────┴─────┘
               ↑
          m_tailPoint = 1


添加第 2-5 个堆栈:

索引:    0     1     2     3     4
       ┌─────┬─────┬─────┬─────┬─────┐
数组:   │ S0  │ S1  │ S2  │ S3  │ S4  │
       └─────┴─────┴─────┴─────┴─────┘
          ↑
     m_tailPoint = 0 (回绕)


添加第 6 个堆栈(S5,覆盖 S0):

索引:    0     1     2     3     4
       ┌─────┬─────┬─────┬─────┬─────┐
数组:   │ S5  │ S1  │ S2  │ S3  │ S4  │
       └─────┴─────┴─────┴─────┴─────┘
               ↑
          m_tailPoint = 1

时间顺序:S1 → S2 → S3 → S4 → S5(最新)

添加堆栈的实现

- (void)addThreadStack:(uintptr_t *)stackArray 
         andStackCount:(size_t)stackCount {
    if (stackArray == NULL) {
        return;
    }
    
    pthread_mutex_lock(&m_threadLock);
    
    // ================================================================
    // 1. 将堆栈写入循环数组
    // ================================================================
    
    // 如果当前位置已有堆栈,先释放旧的
    if (m_mainThreadStackCycleArray[m_tailPoint] != NULL) {
        free(m_mainThreadStackCycleArray[m_tailPoint]);
    }
    
    // 保存新堆栈
    m_mainThreadStackCycleArray[m_tailPoint] = stackArray;
    m_mainThreadStackCount[m_tailPoint] = stackCount;
    
    // ================================================================
    // 2. 统计栈顶地址连续重复次数(核心!)
    // ================================================================
    
    // 计算上一个位置的索引
    uint64_t lastTailPoint = (m_tailPoint + m_cycleArrayCount - 1) % m_cycleArrayCount;
    
    // 获取上一个堆栈的栈顶地址
    uintptr_t lastTopStack = 0;
    if (m_mainThreadStackCycleArray[lastTailPoint] != NULL) {
        lastTopStack = m_mainThreadStackCycleArray[lastTailPoint][0];
    }
    
    // 获取当前堆栈的栈顶地址
    uintptr_t currentTopStackAddr = stackArray[0];
    
    // 比较栈顶地址
    if (lastTopStack == currentTopStackAddr) {
        // 栈顶地址相同,累加重复次数
        size_t lastRepeatCount = m_topStackAddressRepeatArray[lastTailPoint];
        m_topStackAddressRepeatArray[m_tailPoint] = lastRepeatCount + 1;
    } else {
        // 栈顶地址不同,重置重复次数
        m_topStackAddressRepeatArray[m_tailPoint] = 0;
    }
    
    // ================================================================
    // 3. 移动尾指针
    // ================================================================
    
    m_tailPoint = (m_tailPoint + 1) % m_cycleArrayCount;
    
    pthread_mutex_unlock(&m_threadLock);
}

栈顶地址重复次数统计示例

假设连续采集到以下堆栈(简化为栈顶地址):

时间: T0   T50  T100 T150 T200 T250 T300 T350 T400
索引:  0    1    2    3    4    5    6    7    8
堆栈: S0   S1   S2   S3   S4   S5   S6   S7   S8
栈顶: A    B    C    C    C    C    C    D    D

m_topStackAddressRepeatArray 的值:
     [0,   0,   0,   1,   2,   3,   4,   0,   1]
      ↑    ↑    ↑    ↑    ↑    ↑    ↑    ↑    ↑
      A    B    C   CCCCD   D重
     首次  首次 首次  复2345  首次  复2

分析:
- S6(索引6)的栈顶地址 C 连续重复了 4 次(从 S2S6- 说明主线程在函数 C 上停留了 5 × 50ms = 250ms
- S6 就是最有可能导致卡顿的堆栈(Point Stack

什么是 Point Stack?

Point Stack(关键堆栈) 是指在一个检查周期内,最有可能导致卡顿的主线程堆栈

一、核心数据结构

1. 循环数组配置

变量名 类型 说明
m_cycleArrayCount int 循环数组大小(例如:20)
m_tailPoint uint64_t 循环数组尾指针,指向下一个写入位置
pthread_mutex_t m_threadLock 线程锁,保护循环数组的并发访问

循环数组原理:

数组大小 = 检查周期 / 堆栈间隔
例如:1000ms / 50ms = 20

索引:    0    1    2    3    4    ...   19
       ┌────┬────┬────┬────┬────┬ ─ ─ ┬────┐
堆栈:   │ S0 │ S1 │ S2 │    │    │     │    │
       └────┴────┴────┴─▲──┴────┴ ─ ─ ┴────┘
                        │
                   m_tailPoint

当数组满时,从头开始覆盖(FIFO)

2. 堆栈存储数组(二维数组)

变量名 类型 维度 说明
m_mainThreadStackCycleArray uintptr_t ** [cycleArrayCount][stackDepth] 堆栈地址二维数组
m_mainThreadStackCount size_t * [cycleArrayCount] 每个堆栈的深度数组

数据结构示意:

m_mainThreadStackCycleArray:
  [0] → [0x1000, 0x2000, 0x3000, ...]  // 第0个堆栈,深度=3
  [1] → [0x1000, 0x2000, 0x3000, ...]  // 第1个堆栈,深度=3
  [2] → [0x1000, 0x2000, 0x4000, ...]  // 第2个堆栈,深度=3
  ...
  [19] → NULL                          // 尚未写入

m_mainThreadStackCount:
  [0] = 3   // 第0个堆栈深度
  [1] = 3   // 第1个堆栈深度
  [2] = 3   // 第2个堆栈深度
  ...
  [19] = 0  // 尚未写入

3. 栈顶重复次数数组

变量名 类型 说明
m_topStackAddressRepeatArray size_t * 每个堆栈的栈顶地址连续重复次数

用途: 找出 Point Stack(栈顶重复次数最多的堆栈)

数据示例:

假设连续采集的堆栈栈顶地址:
索引:    0      1      2      3      4
栈顶:    A      A      A      B      B

m_topStackAddressRepeatArray:
       [0]    [1]    [2]    [3]    [4]
       0      1      2      0      1

解释:
- 索引0: 第一次出现A,重复0- 索引1: 第二次出现A(与前一个相同),重复1- 索引2: 第三次出现A(与前一个相同),重复2- 索引3: 出现B(改变了),重复0- 索引4: 第二次出现B(与前一个相同),重复1次

结果:索引2的重复次数最多(2次),所以索引2Point Stack

4. Point Stack地址重复次数数组

变量名 类型 说明
m_mainThreadStackRepeatCountArray int * Point Stack中每个地址的总重复次数(动态分配)

用途: 统计 Point Stack 中每个地址在所有堆栈中的总出现次数,识别热点函数

数据示例:

假设有4个堆栈,Point Stack是索引2Stack 0:        Stack 1:        Stack 2(Point):  Stack 3:
0x1000          0x1000          0x1000          0x1000
0x2000          0x2000          0x2000          0x2000
0x3000          0x3000          0x3000          0x4000
0x4000          0x5000          0x6000

Point Stack (索引2) 的地址:
  [0] = 0x1000
  [1] = 0x2000
  [2] = 0x3000

统计结果 m_mainThreadStackRepeatCountArray:
  [0] = 4   // 0x1000 在4个堆栈中都出现
  [1] = 4   // 0x2000 在4个堆栈中都出现
  [2] = 3   // 0x3000 在3个堆栈中出现

符号化后:
  [0] main           (4次) ← 所有堆栈都有,基础函数
  [1] viewDidLoad    (4次) ← 所有堆栈都有,入口函数
  [2] heavyWork      (3次) ← 75%的时间在这里,瓶颈!⚠️

image.png

算法流程

总体流程图

开始
  ↓
1. 查找最大重复次数
  ↓
2. 按时间顺序找出第一个等于最大值的堆栈索引
  ↓
3. 复制 Point Stack
  ↓
4. 计算 Point Stack 中每个地址的总重复次数
  ↓
5. 创建 KSStackCursor 并返回
  ↓
结束

步骤 1:查找最大重复次数

目的: 找出 m_topStackAddressRepeatArray 中的最大值。

size_t maxValue = 0;
BOOL trueStack = NO;

// 第一次遍历:只找最大值(不记录索引)
for (int i = 0; i < m_cycleArrayCount; i++) {
    size_t currentValue = m_topStackAddressRepeatArray[i];
    if (currentValue >= maxValue) {
        maxValue = currentValue;
        trueStack = YES;
    }
}

if (!trueStack) {
    return NULL;  // 没有有效堆栈
}

步骤 2:找出 Point Stack 的索引

目的: 按时间顺序(从新到旧)找第一个重复次数等于 maxValue 的堆栈。

size_t currentIndex = (m_tailPoint + m_cycleArrayCount - 1) % m_cycleArrayCount;

// 第二次遍历:按时间顺序(从新到旧)
for (int i = 0; i < m_cycleArrayCount; i++) {
    // 计算真实索引
    int trueIndex = (m_tailPoint + m_cycleArrayCount - i - 1) % m_cycleArrayCount;
    
    // 找到第一个等于最大值的
    if (m_topStackAddressRepeatArray[trueIndex] == maxValue) {
        currentIndex = trueIndex;
        break;  // 找到最新的,立即停止
    }
}

索引计算公式:

trueIndex = (m_tailPoint + m_cycleArrayCount - i - 1) % m_cycleArrayCount

参数说明:
- m_tailPoint: 下一个要写入的位置
- i: 遍历变量(0 = 最新,1 = 次新,...)
- m_cycleArrayCount: 数组大小(如20)

例子:
假设 m_tailPoint = 1, m_cycleArrayCount = 5

i=0: trueIndex = (1+5-0-1) % 5 = 0  → 最新堆栈
i=1: trueIndex = (1+5-1-1) % 5 = 4  → 次新堆栈
i=2: trueIndex = (1+5-2-1) % 5 = 3  → 第三新堆栈
i=3: trueIndex = (1+5-3-1) % 5 = 2  → 第四新堆栈
i=4: trueIndex = (1+5-4-1) % 5 = 1  → 最旧堆栈(空)

步骤 3:复制 Point Stack

size_t stackCount = m_mainThreadStackCount[currentIndex];
size_t pointThreadSize = sizeof(uintptr_t) * stackCount;
uintptr_t *pointThreadStack = (uintptr_t *)malloc(pointThreadSize);

// 复制堆栈地址
for (size_t idx = 0; idx < stackCount; idx++) {
    pointThreadStack[idx] = m_mainThreadStackCycleArray[currentIndex][idx];
}

步骤 4:计算地址总重复次数

三层循环统计:

// 分配重复次数数组
m_mainThreadStackRepeatCountArray = (int *)malloc(stackCount * sizeof(int));
memset(m_mainThreadStackRepeatCountArray, 0, stackCount * sizeof(int));

// 外层循环:遍历 Point Stack 的每个地址
for (size_t i = 0; i < stackCount; i++) {
    uintptr_t targetAddress = m_mainThreadStackCycleArray[currentIndex][i];
    
    // 中层循环:遍历循环数组中的每个堆栈
    for (int innerIndex = 0; innerIndex < m_cycleArrayCount; innerIndex++) {
        size_t innerStackCount = m_mainThreadStackCount[innerIndex];
        
        // 内层循环:遍历当前堆栈的每个地址
        for (size_t idx = 0; idx < innerStackCount; idx++) {
            // 比较是否匹配
            if (targetAddress == m_mainThreadStackCycleArray[innerIndex][idx]) {
                m_mainThreadStackRepeatCountArray[i] += 1;
            }
        }
    }
}

算法分析:

  • 时间复杂度:O(n × m × k)
    • n = Point Stack 深度(通常 < 100)
    • m = 循环数组大小(通常 20)
    • k = 平均堆栈深度(通常 < 50)
  • 实际数据量很小,性能可接受

步骤 5:创建 KSStackCursor

KSStackCursor *pointCursor = (KSStackCursor *)malloc(sizeof(KSStackCursor));
kssc_initWithBacktrace(pointCursor, pointThreadStack, (int)stackCount, 0);
return pointCursor;

作用: 将原始堆栈数组包装成 KSCrash 能使用的标准格式。


至于堆栈的获取,可以参考我的另一篇文章ARM64 调用栈回溯原理

iOS PDF阅读器段评实现:如何从 PDFSelection 精准还原一个自然段

作者 iceiceiceice
2026年3月5日 18:13

iOS PDF 阅读器段评实现:如何从 PDFSelection 精准还原一个自然段

目标读者:有 PDFKit 使用经验的 iOS 开发者。
本文重点:几何分块算法、段落识别逻辑、跨栏语义合并三个核心难点。


背景:段评是什么,难在哪里

杂志类 App 有一个常见需求——用户长按某段正文,划出一段话,然后对这段话写评论。这个交互在微信读书、Kindle 里都很成熟,但它们针对的是结构化的电子书格式(ePub、MOBI),正文结构天然清晰。

PDF 没有这种结构。一份杂志 PDF 在底层只有一堆带坐标的"文字片段"(glyph run),没有段落、没有栏、没有语义层次。PDFKit 提供的 PDFSelectionselectionsByLine 能给你"行",但它不知道哪些行属于同一个段落,也不知道这一页有几栏。

因此,段评的核心问题是:给定用户选中的一行文字,如何还原它所在的完整自然段?

这个问题比想象中复杂,主要难点有三个:

  1. 几何噪声:PDF 的行坐标存在浮点误差,标题、页码、图注混杂其中,必须过滤。
  2. 多栏布局:杂志常见双栏、三栏排版,阅读顺序不是简单地从上到下。
  3. 跨栏断段:一个自然段可能从左栏末尾延续到右栏开头,PDFKit 对此一无所知。

XLPDFParagraphEngine 的设计思路,就是用纯几何方法逐层解决这三个问题。


整体架构:四层流水线

整个引擎的入口是:

/// 自定义PDFView里面获取menu
+ (NSString *)paragraphTextFromSelection:(PDFSelection *)selection
                                document:(PDFDocument *)document;

它的内部执行路径是一条清晰的四层流水线:

PDFSelection
    │
    ▼
① buildLinesFromSelection     — 行提取 + 噪声过滤
    │
    ▼
② buildBlocksFromLinesIteratively  — 几何连通分块
    │
    ▼
③ readingOrderForBlock         — 列识别 + 段落切分
    │
    ▼
④ mergeSemanticContinuousBlocks — 跨栏语义合并
    │
    ▼
paragraphTextFromLines         — 拼接文本输出

每一层解决一个独立问题,下面逐层展开。


第一层:行提取与噪声过滤

PDFKit 的 selectionsByLine 会把选区内的每一行作为独立的 PDFSelection 返回,这是我们的原始数据源。但原始数据有大量噪声需要清理。

/// 获取所有lines
+ (NSArray<XLPDFLine *> *)buildLinesFromPage:(PDFPage *)page document:(PDFDocument *)document {
    CGRect pageRect = [page boundsForBox:kPDFDisplayBoxMediaBox];
    PDFSelection *pageSelection = [page selectionForRect:pageRect];
    return [self buildLinesFromBaseSelection:pageSelection document:document];
}

/// 获取选中的lines
+ (NSArray<XLPDFLine *> *)buildLinesFromSelection:(PDFSelection *)selection document:(PDFDocument *)document {
    return [self buildLinesFromBaseSelection:selection document:document];
}

+ (NSArray<XLPDFLine *> *)buildLinesFromBaseSelection:(PDFSelection *)baseSelection document:(PDFDocument *)document {

    NSMutableArray<XLPDFLine *> *lines = [NSMutableArray array];

    NSArray<PDFPage *> *pages = baseSelection.pages;

    for (PDFSelection sel in baseSelection.selectionsByLine) {

        NSString *text = sel.string;
        if (text.length == 0) continue;

        // 找到当前行所属 page
        PDFPage *linePage = nil;
        CGRect rect = CGRectZero;

        for (PDFPage *page in pages) {
            rect = [sel boundsForPage:page];
            if (!CGRectIsEmpty(rect)) {
                linePage = page;
                break;
            }
        }

        if (!linePage) continue;

        if (CGRectIsEmpty(rect)) continue;

        // ========= 公共过滤逻辑 =========

        CGFloat width = CGRectGetWidth(rect);

        CGFloat height = CGRectGetHeight(rect);

        // 过滤竖排
        if (text.length > 1 && height > width * 2.0) continue;
        // 过滤异常高度
        CGRect pageRect = [linePage boundsForBox:kPDFDisplayBoxMediaBox];
        CGFloat pageHeight = CGRectGetHeight(pageRect);
        if (height > pageHeight * 0.05) continue;

  
        NSString *trimText = [text stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]];

        if (trimText.length == 0) continue;

        // 过滤纯数字编号(01、02、1、2、一、二 等页码/序号)
        NSString *numberPattern = @"^\\s*[零一二三四五六七八九十百\\d]+[、.]?\\s*$";
        NSPredicate *numberPredicate = [NSPredicate predicateWithFormat:@"SELF MATCHES %@", numberPattern];
        if ([numberPredicate evaluateWithObject:trimText]) continue;

        // ========= 构建模型 =========
        XLPDFLine *line = [XLPDFLine new];
        line.selection = sel;
        line.page = linePage;
        line.rect = rect;
        line.text = trimText;

        NSInteger pageIndex = [document indexForPage:linePage];
        line.pageIndex = pageIndex == NSNotFound ? -1 : pageIndex;
        line.font = [**self** dominantFontFromSelection:sel]; // 提取该行的主体字体
        [lines addObject:line];
    }

    return lines;
}

这里的过滤策略针对杂志 PDF 的典型噪声:

  • 竖排文字:部分杂志有竖向装饰文字,height > width * 2.0 可以有效识别并排除。
  • 异常高度:正文行高通常不超过页面高度的 5%,超出这个比例的往往是大图、横幅或装饰元素。
  • 页码与序号:正则 ^[零一二三四五六七八九十百\d]+[、.]?$ 可以匹配中英文页码和列表编号,避免它们干扰后续分段判断。

过滤完成后,每一行被封装成 XLPDFLine 模型,携带 textrectpagepageIndex 等属性,供后续层使用。


第二层:几何连通分块

拿到干净的行列表后,下一个问题是:这一页上有几个独立的文字区域?

杂志版式复杂,一页上可能同时存在主正文区、侧边栏、图注、引言框等多个互不相连的文字区域。如果不先区分这些区域,段落识别就会跨区混淆。

引擎使用了一个经典的**几何连通图(Connected Components)**算法:

将每一行的 rect 向外膨胀(inflate)半个行高
如果两行膨胀后的 rect 有交叉 → 认为它们"连通"
对所有行做图的连通分量遍历 → 每个连通分量就是一个 Block

膨胀量选择行高的 50%,是一个关键的经验值设定:

+ (BOOL)linesConnected:(XLPDFLine *)a other:(XLPDFLine *)b {
    CGFloat insetA = a.rect.size.height * 0.5;
    CGFloat insetB = b.rect.size.height * 0.5;
    CGRect ra = CGRectInset(a.rect, -insetA, -insetA);
    CGRect rb = CGRectInset(b.rect, -insetB, -insetB);
    return CGRectIntersectsRect(ra, rb);
}

为什么不用固定像素值?因为杂志里的字号差异很大——正文可能是 10pt,大标题可能是 36pt。固定像素膨胀会导致小字号的脚注与正文粘连,或者大标题与相邻栏文字误连。用行高比例膨胀,让每行的"感知范围"与自身字号成正比,鲁棒性更好。

连通分量的遍历使用迭代 DFS:

+ (NSArray *)buildBlocksFromLinesIteratively:(NSArray *)lines {
    NSMutableArray *remaining = [lines mutableCopy];
    NSMutableArray *resultBlocks = [NSMutableArray array];

    while (remaining.count > 0) {
        NSArray *block = [self buildSingleBlockFromLines:remaining];
        [resultBlocks addObject:block];
        [remaining removeObjectsInArray:block];
    }
    return resultBlocks;
}

每次从剩余行中任取一行作为起点,BFS/DFS 扩展出整个连通分量,然后从剩余集合中移除,直到所有行都被分配完毕。


第三层:阅读顺序还原与段落切分

每个 Block 内部可能还有多列(例如一个双栏正文区,在几何上是一个连通分量)。这一层先识别列,再在每列内部切分段落。

3.1 列识别:X 轴区间合并

+ (NSArray<XLPDFLine *> *)readingOrderForBlock:(NSArray<XLPDFLine *> *)block {
 
    NSArray *ranges = [self xRangesFromBlock:block]; // 投影到X轴:x+w
    NSArray *columnRanges = [self mergeXRanges:ranges]; // 算出有多少列
    NSArray *columns = [self splitBlock:block intoColumns:columnRanges]; // 划入列里
    NSMutableArray *result = [NSMutableArray array];
    NSInteger paragraphIndex = 0;

    // 列里面直接按照Y排序即可
    for (NSArray<XLPDFLine *> *column in columns) {
        // 分段
        NSArray *ordered = [self readingOrderForColumnByIndentOnly:column paragraphStartIndex:&paragraphIndex];
        [result addObjectsFromArray:ordered];
    }

    return result;
}

具体做法:把 Block 内每一行的 [minX, maxX] 区间收集起来,按 minX 排序后做区间合并(sweep line),相互重叠或相接的区间合并为一个列边界。最终得到若干互不重叠的列区间,每一行按其中心 X 坐标归入对应的列。

这个方法的优势是完全不依赖任何先验知识,无论一页有几栏、栏宽是否均等,都能正确识别。

3.2 段落切分:三条几何规则

列识别完成后,列内的行按 Y 坐标从上到下排好序。接下来要判断相邻两行是否属于同一段落,引擎使用了三条互补的规则:

+ (NSArray<XLPDFLine *> *)readingOrderForColumnByIndentOnly:(NSArray<XLPDFLine *> *)column paragraphStartIndex:(NSInteger *)paragraphIndex {

    NSArray<XLPDFLine *> *sorted = [self sortLinesByYDescending:column];
    CGFloat baseMinX = [self baseMinXForColumn:sorted];
    CGFloat baseMaxX = [self baseMaxXForColumn:sorted];
    CGFloat columnWidth = baseMaxX - baseMinX;

    [sorted enumerateObjectsUsingBlock:^(XLPDFLine * _Nonnull line, NSUInteger idx, BOOL * _Nonnull stop) {

        if (idx > 0) {

            XLPDFLine *prevLine = sorted[idx - 1];

            // 条件1:当前行首行缩进
            CGFloat indent = CGRectGetMinX(line.rect) - baseMinX;
            BOOL currentLineIsHead = indent > 10.0;

            // 条件2:上一行是尾行(右侧留白超过列宽 10%)
            CGFloat prevLineTrailingGap = baseMaxX - CGRectGetMaxX(prevLine.rect);
            BOOL prevLineIsTail = prevLineTrailingGap > columnWidth * 0.1;

            // 条件3:行间距超过行高阈值
            CGFloat gap = CGRectGetMinY(prevLine.rect) - CGRectGetMaxY(line.rect);
            CGFloat lineHeight = CGRectGetHeight(line.rect);
            BOOL hasLargeGap = gap > lineHeight * 0.8;

            if (currentLineIsHead || prevLineIsTail || hasLargeGap) {
                (*paragraphIndex)++;
            }
        }

        line.paragraphIndex = *paragraphIndex;
    }];

    return sorted;

}

规则1(首行缩进) 是中文排版最常见的段落标记,10pt 的阈值约等于一个汉字的宽度。

规则2(末行留白) 是规则1的补充:段落末行通常不会写满整行。15% 的阈值过滤掉因行尾标点导致的微小留白,同时能识别出明显的短尾行。注意这里使用的 baseMaxX 是列内所有行 maxX 的中位数,而不是最大值,这样对行尾有标点突出的情况更鲁棒。

规则3(行间距) 用于处理无缩进、无留白但通过空行分隔的段落风格(英文排版常见)。

三条规则取 OR,任意一条满足就认为新段落开始,paragraphIndex 递增。


第四层:跨栏语义合并(最难的部分)

前三层解决了单个 Block 内部的问题,但杂志双栏排版有一个特殊情况:

一个自然段从左栏末尾开始,写满后在右栏顶部继续。

这两部分在几何上属于不同的 Block(左栏和右栏不连通),但语义上是同一个段落。这就是跨栏语义合并问题。

4.1 判断标准:段尾 + 段首

合并的充要条件是:左栏某 Block 的最后一行是段尾行(Tail) ,同时右栏某 Block 的第一行是段首行(Head) ,且两者字号一致。

段尾判断:末行写满(右侧留白 < 列宽10%)且没有句末标点(。!?;…等):

+ (BOOL)isTailBlock:(NSArray *)block {
    XLPDFLine *lastLine = block.lastObject;
    CGFloat trailingGap = columnMaxX - CGRectGetMaxX(lastLine.rect);
    BOOL noTrailingGap  = trailingGap <= columnWidth * 0.1;
    BOOL noEndingSymbol = ![self lineEndsWithParagraphSymbol:lastLine];
    return noTrailingGap && noEndingSymbol;
}

段尾判断:判断一行是否以句末标点结尾(处理引号包裹和英文小数)

+ (BOOL)lineEndsWithParagraphSymbol:(XLPDFLine *)line {

    NSCharacterSet *endingSymbols = [NSCharacterSet characterSetWithCharactersInString:@"。!?;.!?;…"];
    NSCharacterSet *wrapperSet =
    [NSCharacterSet characterSetWithCharactersInString:@"”’\"'))】》〉 "];
    NSString *trimmed = [line.text stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]];

    if (trimmed.length == 0) return NO;

    NSInteger index = (NSInteger)trimmed.length - 1;

    // 跳过包裹字符
    while (index >= 0 &&
           [wrapperSet characterIsMember:[trimmed characterAtIndex:index]]) {
        index--;
    }

    if (index < 0) return NO;

    unichar c = [trimmed characterAtIndex:index];

    // 英文小数点不算句末(3.14)
    if (c == '.' && index > 0 &&
        [[NSCharacterSet decimalDigitCharacterSet]
         characterIsMember:[trimmed characterAtIndex:index - 1]]) {
        return NO;
    }

  
    return [endingSymbols characterIsMember:c];
}

段首判断:首行无明显缩进(缩进量 ≤ 10pt),说明这一行是接续上一栏的内容,而不是新段落的起点:

+ (BOOL)isHeadBlock:(NSArray *)block {
    XLPDFLine *firstLine = block.firstObject;
    CGFloat indent = CGRectGetMinX(firstLine.rect) - columnMinX;
    return indent <= 10.0;
}

解决多栏PDF的跨列语义连续问题

/// blocks先要进行font过滤,保留主体字体的block进行跨列连续合并
/// 先对所有blocks里面的pageLines进行分列(大概2、3列)
/// 将所有block归列,每一列N个block
/// 每一列的从后往前找可能是段尾的block
/// 从第二列内部blocks遍历从前往后判断是否是段首
/// 合并段位和段首,处理blockIndex、paragraphIndex
+ (NSArray<NSArray<XLPDFLine *> *> *)mergeSemanticContinuousBlocks:(NSArray<NSArray<XLPDFLine *> *> *)blocks {

    if (blocks.count < 2) return blocks;
    // 直接从 blocks 展平生成 pageLines
    NSMutableArray<XLPDFLine *> *pageLines = [NSMutableArray array];
    for (NSArray<XLPDFLine *> *block in blocks) {
        [pageLines addObjectsFromArray:block];
    }
    NSArray<NSValue *> *columnRanges = [self detectColumnRanges:pageLines];

    if (columnRanges.count < 2) return blocks;

    // 按列分组(保持列内Y排序)
    NSMutableArray<NSMutableArray *> *columns = [NSMutableArray array];
    for (NSInteger i = 0; i < columnRanges.count; i++) {
        [columns addObject:[NSMutableArray array]];
    }

    for (NSArray<XLPDFLine *> *block in blocks) {

        NSInteger colIdx = [self columnIndexForBlock:block inRanges:columnRanges];
        if (colIdx >= 0) [columns[colIdx] addObject:[block mutableCopy]];
    }

    for (NSMutableArray *column in columns) {
        [column sortUsingComparator:^NSComparisonResult(NSArray *a, NSArray *b) {

            CGFloat maxYA = 0, maxYB = 0;
            for (XLPDFLine *l in a) maxYA = MAX(maxYA, CGRectGetMaxY(l.rect));
            for (XLPDFLine *l in b) maxYB = MAX(maxYB, CGRectGetMaxY(l.rect));
            return maxYA > maxYB ? NSOrderedAscending : NSOrderedDescending;
        }];
    }

    // 跨列合并
    for (NSInteger col = 0; col < (NSInteger)columns.count - 1; col++) {

        NSMutableArray *currentCol = columns[col];
        NSMutableArray *nextCol    = columns[col + 1];
        if (currentCol.count == 0 || nextCol.count == 0) continue;

        CGFloat dominantLineHeight = [self dominantLineHeightInColumn:currentCol];
        NSMutableArray<XLPDFLine *> *tailBlock = nil;

        for (NSInteger blockIdx = (NSInteger)currentCol.count - 1; blockIdx >= 0; blockIdx--) {
            // 特别注意要倒叙,然后过滤飞主体文本block
            NSMutableArray<XLPDFLine *> *block = currentCol[blockIdx];
            if ([self lineHeightMatches:block withHeight:dominantLineHeight] &&
                [self isTailBlock:block]) {
                tailBlock = block;
                break;
            }
        }

        if (!tailBlock) continue;

        NSInteger searchCol = col + 1;
        while (searchCol < (NSInteger)columns.count) {

            NSMutableArray *searchNextCol = columns[searchCol];
            if (searchNextCol.count == 0) {
                searchCol++;
                continue;
            }

            NSArray<XLPDFLine *> *headBlock = nil;
            NSInteger headIdx = -1;
            for (NSInteger i = 0; i < (NSInteger)searchNextCol.count; i++) {
                NSArray<XLPDFLine *> *block = searchNextCol[i];
                if ([self isHeadBlock:block] &&
                    [self blockContainsParagraphEndingSymbol:block] &&
                    [self lineHeightMatches:tailBlock with:block]) {
                    headBlock = block;
                    headIdx   = i;
                    break;
                }
            }

            if (!headBlock) break;

            [self mergeBlock:headBlock intoBlock:tailBlock];
            [searchNextCol removeObjectAtIndex:headIdx];

            if (![self isTailBlock:tailBlock]) break;

            searchCol++;
        }
    }

    // 重整blockIndex + 构建结果数组
    NSMutableArray<NSArray<XLPDFLine *> *> *result = [NSMutableArray array];
    NSInteger idx = 0;
    for (NSMutableArray *column in columns) {
        for (NSArray<XLPDFLine *> *block in column) {
            for (XLPDFLine *line in block) line.blockIndex = idx;
            idx++;
            if ([self blockContainsParagraphEndingSymbol:block] || block.count > 6) {
                [result addObject:block];
            }
        }
    }

    return [result copy];
}

4.2 列检测:中心 X 聚类

跨栏合并需要先知道页面有几列,以及每列的 X 边界。引擎用了一个轻量的聚类方法:

// 收集所有行的中心X,排序后按间隙聚类
// 相邻 centerX 差值超过页宽的 10% → 认为是列间距
CGFloat gapThreshold = CGRectGetWidth(pageRect) * 0.10;

通过这个间隙阈值,可以把所有行的中心 X 分成若干簇,每簇的 [minX, maxX] 加上半行高的 padding 就是列的 X 范围。这比依赖页面宽度平均分割更准确,因为杂志栏宽不一定均等。

4.3 合并过程:倒序扫描 + 链式追踪

for (NSInteger col = 0; col < columns.count - 1; col++) {

    // 在当前列,倒序找最后一个"主体文字"的段尾 Block
    // (倒序是为了跳过可能存在的图注、小标题等非主体 Block)
    NSMutableArray *tailBlock = nil;
    CGFloat dominantLineHeight = [self dominantLineHeightInColumn:currentCol];
    for (NSInteger i = currentCol.count - 1; i >= 0; i--) {
        NSArray *block = currentCol[i];
        if ([self lineHeightMatches:block withHeight:dominantLineHeight] &&
            [self isTailBlock:block]) {
            tailBlock = block;
            break;
        }
    }

    if (!tailBlock) continue;

    // 在下一列,找第一个满足条件的段首 Block
    // 字号一致 + 无缩进 + 含句末标点(保证是正文段落,不是纯标题)
    NSArray *headBlock = nil;
    for (NSArray *block in nextCol) {
        if ([self isHeadBlock:block] &&
            [self blockContainsParagraphEndingSymbol:block] &&
            [self lineHeightMatches:tailBlock with:block]) {
            headBlock = block;
            break;
        }
    }

    // 合并:将 headBlock 的所有行追加进 tailBlock,修正 blockIndex 和 paragraphIndex
    [self mergeBlock:headBlock intoBlock:tailBlock];
    [nextCol removeObject:headBlock];

    // 如果合并后 tailBlock 仍是段尾 → 继续追踪到下下列(三栏情况)
    if ([self isTailBlock:tailBlock]) { /* 继续向右搜索 */ }
}

合并时对 paragraphIndex 的修正是一个容易出错的地方。next Block 的 paragraphIndex 从 0 开始编号,合并时需要续接 prev Block 的最大 paragraphIndex,同时修正 blockIndex 保持一致:

for (XLPDFLine *line in next) {
    line.blockIndex     = prevBlockIndex;
    line.paragraphIndex = (line.paragraphIndex - nextBaseIndex) + maxParagraphIndex;
    [prev addObject:line];
}

段落 ID 的设计

完成以上步骤后,每一行都携带了 pageIndexblockIndexparagraphIndex 三个坐标。段落 ID 由此生成:

mgid_pageIndex_blockIndex_paragraphIndex

例如:mag001_3_2_1 表示杂志 mag001,第 3 页,第 2 个文字区域,第 1 个段落。

这个 ID 有两个关键用途:

写入评论时:通过 paragraphIDFromSelection:document:mgid: 生成 ID,与评论数据一起存储到服务端。

读取评论时:通过 paragraphTextFromParagraphID:document: 反向解析 ID,定位到页面 → Block → paragraphIndex,取出对应行集合,用于高亮展示或文字复原。

反向定位的路径:

// 1. 解析 ID,得到 pageIndex / blockIndex / paragraphIndex
// 2. 取出对应页面
PDFPage *page = [document pageAtIndex:pageIndex];
// 3. 对整页重新执行分块
NSArray *pageBlocks = [self pageLinesBlocksFromPage:page document:document];
// 4. 按 blockIndex 取出对应 Block
NSArray *block = pageBlocks[blockIndex];
// 5. 按 paragraphIndex 过滤出段落行
NSArray *paragraph = [self paragraphLinesForParagraphIndex:paragraphIndex inBlock:block];

评论气泡(PDFAnnotation)的锚点应该定位在段落最后一行的位置,这样气泡显示在段尾更自然,同时把段尾行的位置信息传给服务器,服务端也能精确还原气泡坐标。


几个值得关注的工程细节

同行判断的阈值:PDF 中同一行的不同字符因字体 baseline 差异,midY 可能相差 1~3pt。引擎用行高的 50% 作为阈值,而不是固定的 1pt,避免同行字符被误判为不同行:

+ (BOOL)isSameLineByY:(CGRect)r1 rect:(CGRect)r2 {
    CGFloat threshold = MIN(CGRectGetHeight(r1), CGRectGetHeight(r2)) * 0.5;
    return fabs(CGRectGetMidY(r1) - CGRectGetMidY(r2)) < threshold;
}

列 maxX 用中位数baseMaxXForColumn: 返回的是所有行 maxX 的中位数,而不是最大值。这样可以过滤掉个别行尾有标点符号溢出导致的 maxX 偏大问题,让"末行留白"的判断更稳定。

主体行高过滤:在跨栏合并中,用 dominantLineHeightInColumn: 计算列内出现频率最高的行高(取整后做频次统计),作为主体正文的行高基准。倒序扫描段尾 Block 时,只考虑字号与主体行高接近的 Block,这样可以跳过可能夹在正文之间的小字号图注或大字号小标题。


局限性与未来方向

当前实现在以下场景有一定局限:

  • 竖排中文:过滤规则直接丢弃竖排行,不支持竖排杂志。
  • 不规则分栏:栏宽差异极大时(如 1:3 的图文混排),X 轴聚类可能误判列数。
  • 跨页段落:目前只处理单页内的跨栏,跨页的段落连续暂不支持。
  • 表格内文字:表格单元格中的文字可能因行高相近而被当作正文处理。

小结

XLPDFParagraphEngine 的核心设计思路可以归纳为:用几何信息替代语义信息,逐层收敛不确定性

层次 输入 输出 解决的问题
行提取 PDFSelection XLPDFLine 数组 去除噪声行
几何分块 XLPDFLine 数组 Block 数组 区分独立文字区域
列识别 + 分段 Block 带 paragraphIndex 的行 还原阅读顺序和段落边界
跨栏合并 Block 数组 合并后的 Block 数组 修复跨栏断段

整个流水线不依赖任何 PDF 元数据,仅凭坐标和文本表面特征运作,因此对不同来源、不同排版风格的杂志 PDF 均有较好的适应性。

DEMO地址

得物社区搜推公式融合调参框架-加乘树3.0实战

作者 得物技术
2026年3月5日 10:15

一、背景简介

近年来,搜索/推荐/广告系统在粗排(Pre-ranking)与精排(Ranking)阶段的模型训练中,呈现出一个明确的趋势:从单目标优化转向多目标建模 + 多目标融合。模型目标多、融合公式复杂,给工程维护、算法迭代效率都带来了挑战。

为了明文化直白展示公式全景、方便决策调参方向,直接配公式、线上自动算(既支持精排预估目标融合、也支持业务条件boost)。我们设计并落地了加乘树调参框架。从1.0优化至3.0,我们提供了:一个调参框架(Java版、同时引擎基建同学落地了C++版)能支持不同算法环节“公式即配即用”,一个打通AB实验的一站式产品化平台,支持一站式“辅助配置->调试->开实验->变更管控”。

带来收益:无论是粗排还是精排,“训多目标、融公式” 已成为工业界标准范式。在得物社区搜索、推荐的模型迭代实践中,我们也确实走“模型多目标训练 + 融合公式调参”范式,2025在社区推荐、社区搜索落地了几十次LR(社区推荐内外流精排、粗排,社区搜索精排)、近百次加乘树推全。

二、即配即用:算法爆发的催化剂,工程稳定的绊脚石?

在算法领域,“即配即用”的工程框架多次成为推动算法快速迭代甚至“爆发式增长”的关键基础设施。面对粗、精排“多目标建模 + 多目标融合”这一建模范式,社区算法和工程提出了如下基建目标:

即配即用提人效: 实时调整配置、线上就能自动生效数学逻辑,使算法工程师从过去几天才能完成一次调参,转变为一天内可进行多次迭代,从而将精力集中在模型和融合公式本身。

全量配置+增量配置范式: 实验只配要改的几行,降低配错风险。全量配置不动,形成天然降级能力。

DSL可解释性强: 粗、精排的融合公式配置量大,数学变换复杂,容易配错。我们提供的DSL让算法同学直接写数学公式/逻辑表达式。明文公式形成策略全景,方便算法同学决策调参方向。

编译校验与降级体系筑牢稳定性防线: 即配即用+数学公式DSL的需求,给工程稳定性带来极大挑战。我们采用“编译语法校验 + 自动用全量配置降级 + 手动切换编译/解释模式”三位一体保障稳定性。

三、可信赖底座:让复杂公式配置既灵活又可靠

全量配置+增量配置范式

传统的KV、JSON 或 YAML等配置格式在面对上百行数学公式时已显乏力:一方面配置体量大、人工修改易出错且缺乏容错机制;另一方面可读性差,难以维护和审查。

我们采用“全量配置+增量配置”的设计,天然解决了使用门槛&自动降级问题:

  • 只配增量,让使用更轻松、出错更可控: 全量配置锁定为只读,确保基线稳定;算法同学只需声明需要新增或修改的增量配置(upsert)。系统在运行时将增量动态合并到全量配置中,生成最终生效的实验配置——既简化了操作;又避免了误改全局参数的风险。
  • 增量可试,基线兜底: 增量配置有误,自动回退至基线,形成天然降级机制。

给一个社区搜索主搜精排的样例:

DSL接近数学公式/逻辑表达式明文

社区搜索、社区推荐的精排融合公式,服务了“多目标融合+业务boost调权”,语义包含:数学变换、逻辑判断、自定义UDF。当算法写下一串sin(log(max(UDF(x), y))),框架能否接住?框架必须托底,正确校验与执行,杜绝“配错即崩”。

从加乘树1.0到3.0,公式解析统一选用 ANTLR。相比手搓“逆波兰表达式”或“Flex & Bison”,它基于AST校验更可靠,且Java开发门槛低。实际加乘树的配置结构里,公式按KV配置(Key 为结果名,Value 为表达式),支持跨行引用——前序公式的输出可作为后序公式的输入,形成可串联的计算链,直至得出最终结果。

  • 公式链转DAG: 在加乘树3.0中,有相互依赖关系的多行公式,被框架解析成DAG。每个item都通过这套DAG计算融合分,1个item可能有多个融合分、每棵DAG的根结点对应1个融合分。
  • AST驱动逐行校验: 每行公式都依托编译原理,校验&解析为抽象语法树(AST)。结构化的AST可支撑后续可靠计算。
  • 加乘树3.0把DAG和AST直接翻译成代码: 框架将公式链直接翻译成可执行代码,用字节码技术加载到JVM中。每个item直接计算即可。

编译校验与降级体系筑牢稳定性防线

即配即用给算法同学迭代提效带来便利,同时给工程维稳带来挑战。尤其加乘树面临的配置是可自由组合、千变万化的数学公式时,绝对不能出现“配错即崩”的情况。我们做了如下一整套安全设计:

  • 编译原理强校验: 如何应对无限组合的公式配置?加乘树选择了编译原理强校验,用了ANTLR框架,把公式校验&解析成严谨的可访问结构(AST)。
  • DAG强校验公式链: 加乘树3.0初始化阶段自动解析公式链间的依赖关系,一边将公式链解析成DAG、一边强校验。能通过校验、最终编排成DAG的公式,才会进入实际计算;不能通过校验的危险配置(漏配公式、公式配错)都会在初始化阶段就被拦截,不会进入实际计算。
  • 自动降级范式: 加乘树设计了一套自动降级范式,方便“前置拦截错误、事中有效托底、后置发出告警”。一旦有错误的实验开流量,加乘树初始化阶段就会校验出错误,当次请求忽略AB实验配置、直接用全量配置计算,并及时发出“实验配置有误”的告警。
  • 串行重算托底: 如果有“编译原理校验”、“DAG校验”没有校验出的意外怎么办?如果框架仅仅是高峰期计算超时失败了怎么办?加乘树最后一层安全托底是“用全量配置串行重算”。无论如何保证线上效果。

四、核心攻坚:加乘树3.0升级编译执行

加乘树2.0在社区搜索落地后,“每次请求3000个item、线程并发拆的多”的情况,暴露出加乘树耗CPU、耗线程的弱点。C++版加乘树替换了计算引擎,没有采用antlr visitor解释执行数学运算的方式,而是用exprtk框架、收获了更高的性能。

受C++版加乘树的启发,我们计划替换Java版加乘树的计算引擎,降CPU消耗、降执行平响。加乘树3.0变成“直接将配置翻译成代码,字节码加载,直接计算”的编译执行形态。

极致性能:配置直译硬代码,零中间损耗 + 最优 JIT

Antlr翻译&Javassist加载,直接“公式翻译成可执行代码”: 包括多行公式的依赖关系、数学计算&UDF调用,直接拉平成硬代码。硬代码执行效率最高,没有map缓存、递归调用栈等损耗。

多行公式传递中间结果,map换POJO: 每个item维护自己的缓存map,高并发put/resize,造成明显的CPU消耗、youngGC压力。本次会初始化时决策缓存POJO,避免resize、且读写更高效。

核心Javassist管理类借鉴Dubbo写法: Dubbo的ClassGenerator写法,对内存管理考虑比较完善。本次借鉴ClassGenerator,把动态生成代码收入唯一管理单例类。

性能收益

晚高峰模块平响、CPU火焰图消耗和内存分配火焰图消耗均显著降低。

典型踩坑

字节码加载不容忍语法糖:

动态生成的字节码必须严格遵循JVM 范,平时习惯手写的Java法糖是不容忍的。例如,Float a = (float) b; 在源码中合法,但若b是Double类型,该语句涉及拆箱 + 窄化转换 + 装箱,而字节码层面需显式插入doubleValue() → (float) cast → Float.valueOf() 等指令。若直接按表面类型生成字节码,将触发VerifyError。

OOM在多处需要关注:

Javassist使用不当容易OOM:Javassist 在生成和操作字节码时(如通过 CtClass),因为其缓存机制,需要开发者主动管理资源释放。每次parse字节码的CtClass要及时释放,否则高频生成字节码容易触发OOM。这一点上,加乘树参照了Dubbo的ClassGenerator写法,创建、销毁内聚在同一个类里,即用即释放。

动态生成ClassLoader/Class/Instance要能GC:Instance能GC,ClassLoader/Class能GC吗?答案是能,只有从ClassLoader -> Class -> Instance全链路都GC Root不可达了,这一串才能GC。所以用Spring的ClassLoader这类常驻ClassLoader加载动态生成类是不行的,必须用即用即弃的自定义ClassLoader,并注意全链路的强引用问题。

我们实际验证了动态生成的类确实能被GC掉。

多重护航:防止非法Java字节码引发线上问题

ASM + Javassist双重检验: 翻译生成的代码,经Javassist生成字节码后,除Javassist .toClass()的自检验,我们还让字节码过了ASM的字节码静态校验(会运行类似JVM的类型推断验证,确保每条指令执行前后,局部变量表和操作数栈的状态是类型安全的)。

沙箱加载: 我们将加乘树管理平台封装成了一个沙箱,算法同学调试公式点击“校验”,平台会用同一套SDK模拟线上全套加载流程:“AST强校验 -> DAG强校验 -> 真实翻译代码 -> Javassist & ASM 双校验 -> 反射调用构造器创建实例”,一整套无误后才往线上推配置。

线上异步加载,任何问题自动降级: “可执行代码(执行器)初始化”读写分离,新配置上线是异步刷新,刷新错误只会造成线上流量过来找不到执行器,自动降级走全量配置(并发出告警),不影响效果。

可回退解释执行: 加乘树2.0、1.0的解释执行能力十分稳定、只是性能略差,3.0可以一键回退解释执行。

加乘树管理平台:一站式配置、调试与实验平台

面向算法同学: 做了一套一站式“辅助配置->校验->实时调试->开实验->变更管控”的使用体验,告别繁琐配置、体感更丝滑。

面向系统稳定: 加乘树管理平台把自己封装成了一个沙箱,如上一个模块所述,一切风险都拦截在沙箱爆炸。

五、稳扎稳打:从1.0到3.0的演进

加乘树1.0: 支持配公式、框架直接算公式,支持UDF,解释执行。加乘树2.0: 少量性能优化,抽象成SDK。加乘树3.0: 升级为编译执行,外观简化为只需要配公式、框架自动解析DAG。

加乘树1.0和2.0都是用的解释执行,antlr visitor遍历AST做“数学/逻辑/if判断”运算。加乘树3.0升级成了编译执行,多行公式解析DAG、每行公式用antlr解析AST时,直接翻译成Java执行代码,用字节码技术把执行代码加载进JVM直接执行。同时加乘树3.0也支持降级至解释执行。

加乘树1.0

解决:落地即配即用公式,解决手搓硬代码迭代效率低、代码腐化导致生效逻辑不清晰的问题。缺陷:费线程&CPU。

加乘树1.0于2025年1月在社区推荐外流精排落地,配法(使用外观)、降级机制是后续迭代不变的:

  • 配法:1): “全量配置+叠实验改动”的配置机制 2)配置总共分 consts(输入物料)、paramBranch(条件分支替换参数)、formulas(公式)、root(融合结果字段名)。
  • 降级机制:1): 初始化阶段就检测公式配错、漏配公式等,一旦检出就自动降级走全量配置、并发出告警 2)少量运行时才能发现的问题,串行重算、降级算全量配置。

当时是从手搓硬代码做公式融合,无DIFF迁移过来,解决了如下2个迭代痛点:

  • 迭代效率: 除调参是可配,调公式形态、调生效条件等都需要开发&上线。
  • 逻辑黑盒: boost、融合公式迭代复杂之后,生效逻辑变得黑盒,不容易分析调参方向。

加乘树1.0的实现要点

纯item维度(请求维度的公式也会每个item重复计算)。consts->paramBranch->formulas串行计算。antlr解析单行公式成AST,框架递归解析树依赖,antlr visitor解释执行。

为什么用antlr

DSL语法校验: 我们需要一种配置设计,能尽可能简洁地表征模型融合公式(支持逻辑判断/复杂数学变换/UDF)——接近Java语法&数学公式的DSL(当时有对标字节的配置外观)。我们需要准确校验DSL配置正确、并正确解析DSL配置——在antlr、手搓逆波兰表达式、flex&bison里,选了用antlr校验、解析DSL(用AST校验原理可靠,Java上手难度低)。

antlr visitor解释执行: 依靠AST解析计算是一种可靠的计算逻辑。我们需要稳定靠谱的计算引擎,因为算法同学大规模使用后、会出现大量千变万化的公式组合——依靠AST解析计算是一种可靠的计算逻辑。

类SIMD设计使性能可接受: antlr解析AST非常耗时,必须一次parse多次复用,不能在item维度重复parse。一般用antlr visitor做线上实时计算,性能是不可接受的。我们采用了一种类SIMD的代码写法,使落地性能可接受——类SIMD的设计,一次antlr visitor算一批item。最终落地的性能、没有因为antlr visitor拖过多后腿,性能比旧版硬代码融合公式还要好。

antlr语法定义文件

antlr visitor如何通过访问AST计算1行公式

加乘树2.0

解决:抽象成SDK;执行计划自动识别请求维度公式、便于序融合等逻辑写UDF。缺陷:受限于解释执行,仍然比较耗线程。

加乘树2.0于2025年9月在社区搜索落地。优化点如下:

  • 使用体验: 配置json结构简化,只需要配递归的一组公式即可(砍掉了consts、paramBranch)。if()的配法简化:旧版编译器设计的简单,将 “logic表达式”与“math表达式”分别放在2个编译器里,使用者不允许if里嵌套函数,加乘树2.0合并了编译器,if()里可以嵌套函数。支持“隐式item正排”。

  • 性能: 框架自动识别Req维度的公式,全局只计算1次。执行计划加缓存,砍掉“每次请求都重新build执行计划”,平响降低。
  • 横向扩展: Java版加乘树抽象为SDK,方便扩场景直接引用。

加乘树3.0

解决:升级为编译执行,性能大幅提升。

加乘树3.0于2026年1月在社区搜索落地。之前“核心攻坚”模块有提到,高并发&计算量大的情况下,暴露出加乘树耗CPU、耗线程的弱点(类SIMD设计虽然能让性能可接受,但毕竟antlr visitor计算方式需要升级)。

加乘树3.0替换了执行引擎。我们观察火焰图发现“按公式逻辑直接裸写的java代码”性能最高效,但是迭代效率最低。加乘树为了即配即用公式,性能却打了折扣。为了平衡“即配即用”的迭代效率问题和“性能”,我们“将配置公式直接翻译成可执行代码,用字节码技术加载到JVM中直接计算”,这让加乘树从解释执行升级为编译执行。

六、还能更好

多语言 & 模块化: 加乘树有Java版,同时有C++版,是引擎同学创新实现的另一个高性能版本。支持多种业务场景及模块(如粗排、精排),可灵活接入 Java 业务引擎或 C++ 高性能引擎。欢迎其他场景和模块接入。

稳定性 & 产品化: 重点打磨“加乘树管理平台沙箱拦截 -> 线上容错降级 -> 失败监控告警发现 -> 解释执行托底” 的有效性,定期演练降级、验证算法效果。增强“加乘树管理平台”DIFF能力,扩展展示“调试DAG”、“可DIFF动态生成的代码”,打通实时debug平台,可以“DAG展开看计算的中间结果”。

多层公式组成DAG(打磨中)

配置生成的可执行代码做DIFF(建设中)

打通模型调用自动化: 在加乘树这里打通精排模型调用,对精排模型的调用也高度抽象,一配即用、一配即可加入公式融合。

往期回顾

1.深入剖析Spark UI界面:参数与界面详解|得物技术

2.Sentinel Java客户端限流原理解析|得物技术

3.社区推荐重排技术:双阶段框架的实践与演进|得物技术

4.Flink ClickHouse Sink:生产级高可用写入方案|得物技术

5.服务拆分之旅:测试过程全揭秘|得物技术

文 /啊俊 风林 益嘉

关注得物技术,每周更新技术干货

要是觉得文章对你有帮助的话,欢迎评论转发点赞~

未经得物技术许可严禁转载,否则依法追究法律责任。

社区推荐重排技术:双阶段框架的实践与演进|得物技术

作者 得物技术
2026年2月12日 13:48

一、背景

推荐系统典型pipeline

在推荐系统多阶段Pipeline(召回→粗排→精排→重排)中,重排作为最终决策环节,承担着将精排输出的有限候选集(通常为Top 100–500个Item)转化为最优序列的关键职责。数学定义为在给定候选集C={x1,x2,,xn}C = \lbrace x_1,x_2,……,x_n \rbrace与目标列表长度LL,重排的目标是寻找一个排列πP(C,L)\pi^* \in P(C,L),使得全局收益函数最大化。

在推荐系统、搜索排序等AI领域,Pointwise 建模是精排阶段的核心方法,即对每个 Item 独立打分后排序,pointwise 建模范式面临挑战:

  • 多样性约束:精排按 item 独立打分排序 → 高分 item 往往语义/类目高度同质(如5个相似短视频连续曝光)。
  • 位置偏差:用户注意力随位置显著衰减,且不同item对位置敏感度不同。
  • 上下文建模:用户决策是序列行为,而非独立事件。

二、重排架构演进:生成式模型实践

我们的重排系统采用G-E两阶段协同框架:

  • 生成阶段(Generation):高效生成若干高质量候选排列。
  • 评估阶段(Evaluation):对候选排列进行精细化打分,选出全局最优结果。

不考虑算力和耗时的情况下,通过穷举所有排列P(C,L)P(C,L)

生成阶段主要依赖启发式规则、随机扰动 + beamSearch算法生成候选list,双阶段范式存在显著的痛点:

  • 质量-延迟-多样性的“不可能三角”:在实践中,增加生成候选list数一般可以提升最终list的质量,但边际收益递减;优化过程中,我们通过增加多目标、多样性等策略都取得了消费指标的提升,但在候选list达到百量级时,单纯增加候选集对指标的提升,同时还有:
  1. 增加beam width,系统耗时增加,DCG@K提升逐渐减少。

  2. 增加通道数,通道间重叠度逐渐增加,去重list增加逐渐减少。

  • 阶段间目标不一致:
  1. 分布偏移:启发式生成Beam Search输出的Top排列中,20%被评估模型否定,生成阶段搜索效率浪费。

  2. 梯度断层:Beam Search含argmax操作,双阶段无法端到端优化;生成模型无法感知评估反馈,优化方向偏离全局最优。

生成模型优化

生成分为启发式方法和生成式模型方法, 一般认为生成式模型方法要好于启发式方法。生成式模型逐渐成为重排主流范式,主要分为两类:自回归生成模型、非自回归生成模型。

  • 自回归生成:按位置顺序逐个生成物品,第 t 位的预测依赖前 t-1 位已生成结果。
  1. 优点:

a. 序列依赖建模强,天然捕获物品间的顺序依赖。

b. 训练简单稳定,每步使用真实前序作为输入,收敛快。

c. 生成质量高,逐步细化决策,适合长序列精细优化。

  1. 缺点:

a. 推理延迟高,生成 L 个物品需 L 次前向传播,线上服务难以满足毫秒级要求。

b. 局部最优风险,早期错误决策无法回溯修正,影响整体序列质量。

  • 非自回归生成:一次性预测整个推荐序列的所有位置,各位置预测相互独立。
  1. 优点:

a. 推理速度极快:生成整个序列仅需1次前向传播。

2.缺点:

b.条件独立性假设过强:各位置并行预测,难以显式建模物品间复杂依赖关系。

非自回归模型

为了对齐双阶段一致性,同时考虑线上性能,我们推进了非自回归模型的上线。模型结构如下图:

模型包括Candidates Encoder和Position Encoder,Candidates Encoder是标准的Transformer结构, 用于获取item间的交互信息;Position Encoder额外增加了Cross Attention,期望Position序列同时关注Candidate序列。

  • 模型特征:用户信息、item特征、位置信息、上游精排打分特征。
  • 模型输出:一次性输出 n×L 的位置-物品得分矩阵(n 为候选 item 数,L 为目标列表长度),支持高效并行推理
p^ij=exp(xitj)i=1nexp(xitj)\hat{p}_{ij} = \frac{\exp(\mathbf{x}_i^\top \mathbf{t}_j)}{\sum_{i=1}^n \exp(\mathbf{x}_i^\top \mathbf{t}_j)}
  • 位置感知建模:引入可学习位置嵌入,显式建模“同一 item 在不同位置表现不同”的现象(如首屏效应、位置衰减)。
  • 训练目标:模型使用logloss,让正反馈label序列的生成概率最大, 同时负反馈label序列的生成概率最小:
Llog=i[pijyilog(pij^)+pij(1yi)log(1pij^)]\mathcal{L}_{\log} = -\sum_{i} \big[ p_{ij}y_i \log(\hat{p_{ij}}) + p_{ij}(1-y_i) \log(1-\hat{p_{ij}}) \big]

其中,pijp_{ij}表示位置i上是否展示物品j,yiy_{i}表示位置i上的label。

线上实验及收益:

  • 一期新增了非自回归生成通道,pvctr +0.6%,时长+0.55%。
  • 二期在所有通道排序因子中bagging非自回归模型,pvctr +1.0%,时长+1.13%。

自回归模型

由于条件独立性假设, 非自回归模型对上下文信息建模是不够的,近期我们重点推进了自回归模型的开发。

模型通过Transformer架构建模list整体收益,我们使用单向transformer模拟用户浏览行为的因果性,同时解决自回归生成的暴露偏差问题,保持训练和推理的一致性。结构如下:

  • 模型特征:用户信息、item特征、位置信息、上游精排打分特征。
  • 训练目标:模型使用有序回归loss,在评估多个回合中不同长度的子列表时,能够很好地体现出序列中的增量价值。是用于判断长度为j的子列表是否已经达到i次点击或转化的损失函数。
Li,j(θj)=k=1N([yk<i]log(1pi,j(xk))+[yki]log(pi,j(xk)))L_{i,j}(\theta_j) = -\sum_{k=1}^{N} \left([y_k < i]\log(1-p_{i,j}(x_k)) + [y_k \geq i]\log(p_{i,j}(x_k))\right)

线上模型推理效率优化及实验效果:

自回归生成模型推理延迟高,生成 L 个物品需 L 次前向传播,线上服务难以满足毫秒级要求。因此,我们在传统自回归生成模型的基础上增加MTP(multi token prediction)结构,突破生成式重排模型推理瓶颈。其核心思想是将传统自回归的单步预测扩展为单步多token联合预测,显著减少生成迭代次数。

自回归生成模型在社区推荐已完成了推全,实验中我们新增了自回归生成模型通道,但不是完全体,仅部分位置生成调用了模型:

  • 一期调用两次模型,每次预测4个位置,pvctr +0.69%,有效vv +0.58%。
  • 二期调用两次模型,每次预测5个位置,pvctr +0.54%,有效vv +0.40%。

三、推理性能优化:端到端生成的效率保障

工程架构

为解决CPU推理模型延迟高、制约业务效果的问题,我们对DScatter模型服务进行升级,引入高性能GPU推理能力,具体方案如下:

  • GPU推理框架集成与升级:
  1. 框架升级:将现有依赖的推理框架升级为支持GPU的高性能服务框架。

  2. 硬件资源引入:引入 NVIDIA L20 等专业推理显卡,为当前的listwise评估模型及自回归生成模型提供专用算力,实现模型推理的硬件加速。

  • DScatter模型服务独立部署与容量提升:
  1. 为解决模型部署效率低与资源竞争问题,将DScatter的模型打分逻辑从现有重排服务中完全解耦,构建并部署独立的 DScatter-Model-Server 集群,从根本上消除与重排服务在CPU、内存等关键资源上的竞争。

模型优化

  • 模型格式转换与加速:

导出为 ONNX 格式,使用 TensorRT 进行量化、层融合、动态张量显存等技术加速推理。

  • Item Embedding缓存:

预计算item静态网络,线上直接查询节省计算量。

  • 自回归生成模型核心优化,KV Cache 复用:

缓存已生成token的KV和attention值,仅计算增量token相关值,避免重复计算。

  • 其他LLM推理加速技术应用落地,例如GQA

四、未来规划:迈向端到端序列生成的下一代重排架构

当前“生成-评估”双阶段范式虽在工程落地性上取得平衡,但其本质仍是局部优化:生成阶段依赖启发式规则或浅层模型生成候选,评估阶段虽能识别优质序列,却无法反向指导生成过程,导致系统能力存在理论上限。为突破这一瓶颈,我们规划构建端到端序列生成(End-to-End Sequence Generation) 架构,将重排从“候选筛选”升级为“序列创造”,直接以全局业务目标(如用户停留时长、互动深度、内容生态健康度)为优化目标。

核心架构设计:

  • 统一生成器:以 Transformer 为基础架构,搭建自回归序列建模能力,采用分层混合生成策略:
  1. 粗粒度并行生成:首层预测序列骨架(如类目分布、内容密度)等。

  2. 细粒度自回归精调:在骨架约束下,自回归生成具体 item,确保局部最优。

  • 序列级Reward Modeling:
  1. 构建多目标 reward 函数:xtr、多样性。

  2. Engagement:基于用户滑动轨迹建模序列累积收益(如滑动深度加权CTR)。

  3. Diversity:跨类目/创作者/内容形式的分布熵。

4.Fairness:冷启内容、长尾创作者曝光保障。

训练范式升级:强化学习与对比学习融合

推进自回归生成模型的架构升级与训练体系重构,引入强化学习微调(PPO/DPO)与对比学习机制,提升序列整体效率。

  • 搭建近线系统,生成高质量list候选,提升系统能力上限:

1.基于 DCG 的列表质量打分:

a. 对每个曝光列表L,计算其 DCG@K作为质量分数:

DCG(L)=j=1Kgain(itemj)log2(j+1)\text{DCG}(L) = \sum_{j=1}^{K} \frac{\text{gain}(item_j)}{\log_2(j + 1)}

其中 gain(item)可定义为:

若点击:+1.0

若互动(点赞/收藏):+1.5

若观看 >5s:+0.8

否则:0

2.构造偏好对:

a.对同一用户在同一上下文下的两个列表 LwL_w(win)和 LlL_l(lose)。

b.若 DCG(Lw)>DCG(Ll)+δDCG(L_w) > DCG(L_l) + \delta(δ 为 margin,如 0.1),则构成一个有效偏好对。

  • 引入强化学习微调(PPO/DPO)与对比学习机制,提升序列整体效率:
  1. 模型结构:

a.使用当前自回归生成模型作为策略模型。

b.固定预训练模型作为参考策略 (即 DPO 中的“旧策略”)。

2.DPO损失:

LDPO(θ)=E(x,yw,yl)D[logσ(β(logπθ(ywx)πref(ywx)logπθ(ylx)πref(ylx)))]\mathcal{L}_{\text{DPO}}(\theta) = -\mathbb{E}_{(x, y_w, y_l) \sim \mathcal{D}} \left[ \log \sigma \left( \beta \left( \log \frac{\pi_\theta(y_w \mid x)}{\pi_{\text{ref}}(y_w \mid x)} - \log \frac{\pi_\theta(y_l \mid x)}{\pi_{\text{ref}}(y_l \mid x)} \right) \right) \right]
  • 技术价值:
  1. 突破“质量-延迟-多样性”不可能三角:通过序列级优化,在同等延迟下实现质量与多样性双提升。

  2. 为AIGC与推荐融合铺路:端到端生成器可无缝接入AIGC内容,实现“内容生成-序列编排”一体化。

参考文献:

  1. Gloeckle F, Idrissi B Y, Rozière B, et al. Better & faster large language models via multi-token prediction[J]. arXiv preprint arXiv:2404.19737, 2024.
  2. Ren Y, Yang Q, Wu Y, et al. Non-autoregressive generative models for reranking recommendation[C]//Proceedings of the 30th ACM SIGKDD Conference on Knowledge Discovery and Data Mining. 2024: 5625-5634.
  3. Meng Y, Guo C, Cao Y, et al. A generative re-ranking model for list-level multi-objective optimization at taobao[C]//Proceedings of the 48th International ACM SIGIR Conference on Research and Development in Information Retrieval. 2025: 4213-4218.
  4. Zhao X, Xia L, Zhang L, et al. Deep reinforcement learning for page-wise recommendations[C]//Proceedings of the 12th ACM conference on recommender systems. 2018: 95-103.
  5. Feng Y, Hu B, Gong Y, et al. GRN: Generative Rerank Network for Context-wise Recommendation[J]. arXiv preprint arXiv:2104.00860, 2021.
  6. Pang L, Xu J, Ai Q, et al. Setrank: Learning a permutation-invariant ranking model for information retrieval[C]//Proceedings of the 43rd international ACM SIGIR conference on research and development in information retrieval. 2020: 499-508.

往期回顾

1.Flink ClickHouse Sink:生产级高可用写入方案|得物技术

2.服务拆分之旅:测试过程全揭秘|得物技术

3.大模型网关:大模型时代的智能交通枢纽|得物技术

4.从“人治”到“机治”:得物离线数仓发布流水线质量门禁实践

5.AI编程实践:从Claude Code实践到团队协作的优化思考|得物技术

文 /张卓

关注得物技术,每周一、三更新技术干货

要是觉得文章对你有帮助的话,欢迎评论转发点赞~

未经得物技术许可严禁转载,否则依法追究法律责任。

❌
❌