当开发一些图表类页面时,经常需要对一组数据求百分比,而像是常用的 Echarts 图表,在内部已经计算妥当了,保证一组数据在计算完百分比之后,这些百分比相加后仍然等于 100% 。而这种计算百分比的算法之一就是 —— 最大余额法。

核心思想就是,根据每部分所占比例的大小,按照从高到低的顺序去分配剩余部分。

比如,一组数据 [ 4, 4, 3 ],所占百分比为 [ 36.36363636363636687, 36.36363636363636687, 27.2727272727272734 ]。先取出整数部分,得到 [ 36, 36, 27 ],累加后总和为 99,还剩余 1。接下来再取出小数部分,得到 [ 0.36363636363636687, 0.36363636363636687, 0.2727272727272734 ]。把最后剩余的 1 根据小数部分的大小,优先加到最大的部分,如果有多个,则索引在前的优先级高。这里就是加到索引为 0 的位置上,最终得到结果 [ 37, 36, 27 ]。如果有多个剩余,则同理,每次找最大的小数部分,然后追加 1。需要注意:已经追加过 1 的部分不能再次追加。

有同学可能会有疑惑,会不会出现:有 N 个数,剩余为 M,且 M > N ?答案是 否定 的。因为 M 等于 N 个数的小数部分之和,而且小数部分都小于 1,N 个小于 1 的数之和一定小于 N 。所以,M < N 恒成立。也就意味着,每个部分最多追加一次余额,不会出现追加两次的情况。

理清思路后,先来实现一个简单版本。

/**
 * 计算各个数值所占百分比
 * @param {number[]} data 源数据
 * @returns {number[]}
 */
function getPercentValue(data) {
  if (!data.length) {
    return []
  }

  // 求和
  const sum = getSum(data)
  // 初始化剩余为 100
  let remainSum = 100
  // 记录整数部分
  const integerPart = []
  // 记录小数部分
  const decimalPart = []
  for (const v of data) {
    // 因为要计算百分比,需要先乘以 100
    const newVal = v * 100
    // 计算实际的百分比
    const percent = newVal / sum
    // 得到整数部分
    const integer = Math.floor(percent)
    // 得到小数部分
    const decimal = percent - integer
    // 将整数、小数部分分别存入对应的数组中
    integerPart.push(integer)
    decimalPart.push(decimal)
    // 更新剩余,减去当前的整数部分
    remainSum -= integer
  }

  // 如果剩余大于 0,循环去消减剩余
  while (remainSum > 0) {
    // 找到小数部分数组中最大值的索引
    const maxIdx = findMaxValIndex(decimalPart)
    // 将整数部分对应索引的值加 1
    integerPart[maxIdx] += 1
    // 将本次找到的小数部分置为负数,防止重复查找
    decimalPart[maxIdx] *= -1
    // 剩余减 1
    remainSum--
  }

  // 整数部分就是最后的结果
  return integerPart

  // 计算当前数组之和
  function getSum(arr) {
    return arr.reduce((prev, sum) => prev + sum, 0)
  }

  // 查找当前数组中最大值索引
  function findMaxValIndex(arr) {
    let max = 0
    let maxIdx = -1
    for (let i = 0; i < arr.length; i++) {
      if (arr[i] > max) {
        max = arr[i]
        maxIdx = i
      }
    }
    return maxIdx
  }
}

小测一下:

getPercentValue([6, 6, 8]) // [ 30, 30, 40 ]
getPercentValue([6, 6, 8, 6, 8]) // [ 18, 18, 23, 18, 23 ]
getPercentValue([4, 4, 3]) // [ 37, 36, 27 ]
getPercentValue([4]) // [ 100 ]
getPercentValue([3, 3, 3, 3, 3, 3]) // [ 17, 17, 17, 17, 16, 16 ]
getPercentValue([30, 20, 6, 1]) // [ 53, 35, 10, 2 ]

效果不错,very nice

接下来,再加一个功能,因为有时候百分比不一定都是整数,也需要保留到小数点后几位。新增一个参数 precision,默认为 0,即保留到整数位,若为 2,则保留到小数点后 2 位。

/**
 * 计算各个数值所占百分比
 * @param {number[]} data 源数据
 * @param {number} precision 精度,默认为 0,即保留到整数位
 * @returns {number[]}
 */
function getPercentValue(data, precision = 0) {
  if (!data.length) {
    return []
  }

  // 除了基本的需要乘以 100 之外,还需要根据精度大小,再乘以 10^n 次方
  const base = 100 * Math.pow(10, precision)
  // 初始化剩余为基数值
  let remainSum = base
  const sum = getSum(data)
  const integerPart = []
  const decimalPart = []
  for (const v of data) {
    // 不再乘以 100,这里乘以基数
    const newVal = v * base
    const percent = newVal / sum
    const integer = Math.floor(percent)
    const decimal = percent - integer
    integerPart.push(integer)
    decimalPart.push(decimal)
    remainSum -= integer
  }

  while (remainSum > 0) {
    const maxIdx = findMaxValIndex(decimalPart)
    integerPart[maxIdx] += 1
    decimalPart[maxIdx] *= -1
    remainSum--
  }

  // 根据精度,挪动小数点位置
  return integerPart.map(v => v / Math.pow(10, precision))

  // 计算当前数组之和
  function getSum(arr) {
    return arr.reduce((prev, sum) => prev + sum, 0)
  }

  // 查找当前数组中最大值索引
  function findMaxValIndex(arr) {
    let max = 0
    let maxIdx = -1
    for (let i = 0; i < arr.length; i++) {
      if (arr[i] > max) {
        max = arr[i]
        maxIdx = i
      }
    }
    return maxIdx
  }
}

最后测试一下:

console.log(getPercentValue([6, 6, 8], 1)) // [ 30, 30, 40 ]
console.log(getPercentValue([6, 6, 8, 6, 8], 2)) // [ 17.65, 17.65, 23.53, 17.64, 23.53 ]
console.log(getPercentValue([4, 4, 3], 3)) // [ 36.364, 36.363, 27.273 ]
console.log(getPercentValue([4])) // [ 100 ]
console.log(getPercentValue([3, 3, 3, 3, 3, 3], 2)) // [ 16.67, 16.67, 16.67, 16.67, 16.66, 16.66 ]
console.log(getPercentValue([30, 20, 6, 1], 3)) // [ 52.632, 35.088, 10.526, 1.754 ]

完美 ~~~