记录一次在业务中正儿八经使用算法的例子

1,386 阅读6分钟

前言

大家好这里是阳九,一个文科转码的野路子码农,热衷于研究和手写前端工具(现在是全栈了).

今天来聊聊一个困扰广大新手程序员(我)的问题:为什么要学算法?

"刷算法很痛苦,工作中又用不到" __某京东JAVA后端

"算法可以让我们了解数据结构" __某架构师

然而最近我也是在工作中首次遇到了算法问题, 算法确确实实在本次的业务中帮助了我,记录一下

不枉我写了这么多没啥卵用的题,书到用时方恨少啊

本次的算法非常简单,就是一个BFS广度优先遍历树,(我先想到的是力扣的一道题"二叉树的层序遍历")

起因

我在做一个IPV6地址处理的功能,有这样一个需求,给出一个不完整的IPV6地址,生成多个完整的IPV6地址

(由于涉及到业务机密,这里不能详细说明,只能简易阐述要做的事情)

let ipv6_part = "268b:7360:0000:0000:****:****:****:****"

其中,"*"的位置是未知的16进制字符, (0-f)

我们需要根据配置,补全这个不完整的IPV6地址,从而获得多个完整的IPV6地址字符串。

而之前的同事,使用了递归进行补位, 也就是IPV6逐个+1,最后将其余的*号用0填充

268b:7360:0000:0000:****:****:****:***0
268b:7360:0000:0000:****:****:****:***1
268b:7360:0000:0000:****:****:****:***2
...
268b:7360:0000:0000:****:****:****:***f
268b:7360:0000:0000:****:****:****:**10

那么这样生成的IPV6地址,基本上都是分布在同一个IPV6段里,在使用的时候就会有问题(要么可以用,要么不能用)

我们需要让生成的IPV6地址足够分散, 所以需要从第一个""开始补位,也就是这样,最后再将其余的号用0填充

268b:7360:0000:0000:0***:****:****:****
268b:7360:0000:0000:1***:****:****:****
268b:7360:0000:0000:2***:****:****:****
...
268b:7360:0000:0000:f***:****:****:****
268b:7360:0000:0000:10***:****:****:****

简单递归算法

生成二进制数字 我们可以通过树来生成二进制数字,沿着树的路径走下去,就可以生成二进制数字,也就是所谓的二叉树

image.png

同样的 我们可以这样处理10进制,

image.png

现在我们需要处理的是一个IPV6地址,也就是16叉树

image.png

我们可以发现,如果是这种模式生成IPV6,就是所谓的"深度优先递归遍历树"DFS, 那么我们得到的结果就不分散,而是非常聚拢。

268b:7360:0000:0000:****:****:****:***0
268b:7360:0000:0000:****:****:****:***1
268b:7360:0000:0000:****:****:****:***2
...

我们需要将深度优先转换为广度优先BFS, 也就是这样

算法代码实现

一般来说我们有两种方法实现广度优先遍历, 可以参考力扣的一道题102. 二叉树的层序遍历 - 力扣(Leetcode) 我们的广度优先遍历16叉树,也就是16叉树的层序遍历

使用队列实现

我们可以通过队列, 每次解析队列头元素,然后解析出的结果再次推入队列,实现BFS

function breadthFirstSearch(root) {
    let queue = [root]; 
    let result = [];
    
    while (queue.length > 0) {
      let node = queue.shift();
      result.push(node.value);
      
      if (node.left) {queue.push(node.left)}
      if (node.right) { queue.push(node.right)}
    }
    
    return result;
  }

使用for循环实现

我们也可以将每层的解析结果推入数组,保存起来,传入下一个递归函数中,执行for循环

const result = []

var breadthFirstSearch = function (curNodeList) {
    // 保存本层结果
    let nextNodeList = []
    //  遍历上一层的所有节点
    curNodeList.forEach((node) => {
        result.push(node.val) // 写入结果

        if (node.left) { nextNodeList.push(node.left) }
        if (node.right) { nextNodeList.push(node.right) }
    })
    // 本层结果继续传入下层计算
    nextNodeList.length > 0 && deep(nextNodeList)
};

breadthFirstSearch([root])

image.png

实际代码实现中的问题

  • 由于我们处理的节点都是一个IPV6字符串,也就是128位, (计算机内部优化为16字节)
  • 需要处理的IPV6数量极大 超过一个亿

考虑到性能问题,我们发现

  1. 使用队列和传递数组到下层两种方式进行遍历 时间复杂度均为On
  2. 队列最大需要存储的元素数量不会超过一层的节点数目(即16^n个,n为层数)
  3. 数组保存的元素也为一层的节点数目(即16^n个,n为层数)

那么 当层数达到6层时 16^6 = 1677万

如果把这么多IPV6存到数组,轻而易举的超过了1G,而我们的Node服务肯定不会为这个计算留出这么多内存 image.png

使用文件进行缓存

之后我使用了文件进行缓存,传输数据 具体做法有两种

  1. 使用队列算法,用txt文件模拟递归队列,不停的从文件头读取IPV6,生成的结果推入IPV6
  2. 使用for循环算法,用txt文件模拟传入下层的数组,每次for循环,读取上一个文件的结果文件
// 结果文件
const resultFileStream = fs.createWriteStream(resultFile, { flags: 'a' });

var breadthFirstSearch = function (floor) {

    // 读取上层结果文件 floor为层数
    let regionsFile = `./cache/${floor}.txt`
    const readStream = fs.createReadStream(regionsFile)

    // 创建本层结果regions文件(给下一层使用)
    let nextRegionsFile = `./cache/${floor + 1}.txt`
    const nextRegionsStream = fs.createWriteStream(nextRegionsFile);

    // 逐行读取文件内容
    const rl = readline.createInterface({
        input: readStream,
        crlfDelay: Infinity // 使用默认的换行符号
    })

    // 每个结果生成16个子结果
    rl.on('line', (ipv6) => {
        for (let j = 1; j < 16; j++) {
            let newIpv6 = handle(ipv6)
            nextRegionsStream.write(newIpv6 + "\n");// 写入本层遍历结果
            resultFileStream.write(newIpv6 + "\n");// 写入结果文件
        }

    })

    // 文件处理结束  进行下一层处理
    rl.on("close", () => {
        breadthFirstSearch(floor + 1)
    })
};

多线程再次优化

最终我使用了for循环的算法, 因为这样可以单独遍历每一层的结果,当本层结果过大时,我们可以将单层的结果进行拆分,开启多个线程进行处理(后面直接将计算逻辑用GO语言重写了,方便开启多线程)

image.png

结尾

本次的事情告诉我,比起刷算法题,在业务中想到算法并结合背景合理使用才是真正的难点,需要强大的算法基础。(主要是想不到)

反观各种工具,框架,真正干到底层无非就两个东西, 编译和算法

而且算法能培养人的思维,写代码么,主要就是一个聪明。

算法题写多了,会觉得平时大部分业务都没什么难度,很快就写了(无法就是麻烦)

所以把,哎 算法还是得写。 难顶哦