震惊!我竟然在伙伴匹配系统....

(手动狗头)抱歉鱼友们,因为这是本人第一次发学习帖子,所以只能以这种方式博眼球了。

其实这个帖子,是分享一下我对 伙伴匹配系统中的编辑距离算法使用上的一个优化。

为什么想写这篇文章?

其实是在上次面试时被面试官问到这个编辑距离算法了。

我说通过定时任务,计算所有人的匹配用户(所以需要双层循环,每一个都要计算)。


面试官:你在这个项目中用了编辑距离算法,你说说怎么实现的。

我:(随便说了一通)......总之就是涉及了双层循环遍历。

面试官:那你有没有想过,这样写的话时间复杂度就太高了,你该怎么优化。

我:嗯.....不太会(我真的比较菜,想不出优化方法,当时我已经蒙圈了)

面试官:如果让你多线程,你该怎么优化。

我:额......就是......可以实现Callable接口,额....然后实现它的call方法,外层循环获取列表,然后多线程执行编辑距离算法(实际上应该是使用线程池比较好一点)


虽然最后面试通过了,但这个问题确实是我不太会的点,多线程我也不是很熟练,借此机会学习一下。

(以下所有业务场景,都是获取每一个用户的最匹配的用户,所以要进行双层循环,每个列表都要在内部循环计算一次)

原版编辑距离算法

原本的编辑距离算法是比较两个字符串的距离,在循环中判断字符是否相等。

但是稍微修改,就可以用于计算两个字符串列表的距离,在循环中直接判断字符串是否相等。但是这样有一个弊端,字符串列表内容的顺序不同,得出的结果有可能不同(比如**["张三","李四"]["李四","张三"],这两个顺序不同但内容相同**的列表在匹配同一个列表时得到的答案可能不一样)

java
复制代码
public static int minDistance(List<String> tagList1, List<String> tagList2){ int n = tagList1.size(); int m = tagList2.size(); if(n * m == 0) return n + m; int[][] d = new int[n + 1][m + 1]; for (int i = 0; i < n + 1; i++){ d[i][0] = i; } for (int j = 0; j < m + 1; j++){ d[0][j] = j; } for (int i = 1; i < n + 1; i++){ for (int j = 1; j < m + 1; j++){ int left = d[i - 1][j] + 1; int down = d[i][j - 1] + 1; int left_down = d[i - 1][j - 1]; if (!Objects.equals(tagList1.get(i - 1), tagList2.get(j - 1))) left_down += 1; d[i][j] = Math.min(left, Math.min(down, left_down)); } } return d[n][m]; }

通过多线程优化后

创建线程池并分配线程数,不推荐给太多,因为cpu真的会起飞的。

总体思路:将数据分批次处理,每个线程处理一部分数据,提高cpu利用率,减少计算时间。当然,最后的编辑距离算法还是单线程执行的,这里的优化只是把原来所有数据交给一个线程,变为了把所有数据交给多个线程处理

java
复制代码
public static List<Integer> getCloseDistance(List<List<String>> list) throws ExecutionException, InterruptedException { int size = list.size(); // 存放结果 List<Integer> result = new ArrayList<>(Collections.nCopies(size, Integer.MAX_VALUE)); // 获取当前线程数 int cors = Runtime.getRuntime().availableProcessors(); // 创建线程池(如果这里设置线程数为最大的话,运行起来cpu会直接起飞,物理意义上的起飞) ExecutorService executorService= Executors.newFixedThreadPool(cors-5); List<Future<?>> futureList=new ArrayList<>(); // 设置块大小 int batch=100; // 创建任务 for(int start=0;start<size;start+=batch){ final int shunkStart=start; final int shunkEnd=Math.min(start+batch,size); // 每个线程执行100条数据,当然,每次都需要遍历所有元素 futureList.add(executorService.submit(()->{ for(int i=shunkStart;i<shunkEnd;i++){ int min=Integer.MAX_VALUE; for(int j=0;j<size;j++){ if(i!=j){ int distance = Distance(list.get(i), list.get(j)); min= Math.min(min, distance); } } result.set(i,min); } })); } for(Future<?>future :futureList){ future.get(); } executorService.shutdown(); return result; } //单线程编辑距离算法 public static int Distance(List<String> temp,List<String> target){ int m=temp.size(); int n=target.size(); if(m*n==0){ return m+n; } int[][] d=new int[m+1][n+1]; for(int i=0;i<m+1;i++){ d[i][0]=i; } for(int j=0;j<n+1;j++){ d[0][j]=j; } for(int i=1;i<m+1;i++){ for(int j=1;j<n+1;j++){ if(temp.get(i-1).equals(target.get(j-1))){ d[i][j]=d[i-1][j-1]; }else{ d[i][j]=1+Math.min(d[i-1][j],Math.min(d[i][j-1],d[i-1][j-1])); } } } return d[m][n]; }

测试结果

原版:

处理一万条数据(因为是双层循环,所以实际计算是1e次),需要时间为13262毫秒

image-20250531173039847.png

优化后:

只需要1710毫秒,大大提高计算效率!

image-20250531173554988.png

其余代码

java
复制代码
// 生成随机的3个数据 public static List<String> getRandom(){ // 打乱顺序 Collections.shuffle(PGL_LIST); // 打乱后截取前三个,某种意义上也是随机了吧 return PGL_LIST.stream().limit(3).collect(Collectors.toList()); } // 生成指定数量的列表 public static List<List<String>> getList(int count){ return IntStream.range(0, count).mapToObj(obj -> getRandom()).collect(Collectors.toList()); }

但实际业务上,我们需要的应该是最匹配的用户的id或者是最匹配的一些用户的id,而不仅仅是最小的距离,这个数据并没什么意义。所以还要在此基础上做一些修改。在这里就不赘述了。

其他优化思路

最好是在计算时,获取前100位(数字随意)用户或队伍,然后缓存到Redis,之后用户在心动模式下进行匹配时,可以直接从缓存的心动用户列表中随机获取某一位用户。

当用户量大时,计算所有用户的心动用户,计算量就有点大了。可以考虑筛选出 近七天活跃用户,在此基础上再做计算。或者是 标记重点用户,对重点用户的心动用户提前计算,而非重点用户则是点击后再单独计算。

以上仅本人浅薄的观点(本人确实比较菜),可能有很多不对的地方,还希望大家发现后指点一二(抱拳)。

震惊!我竟然在伙伴匹配系统项目中优化了编辑距离算法的打开方式!

0个评论
点击登录,快来和大家讨论吧~
表情
图片
暂无评论
只想摆烂
作者分享
Day 8 昨天把面试鸭上背八股又复习了一下加深印象,有很多基本都快忘完了。 今天把手写rpc的注解驱动写完了,算是完结了,但是之前的哪个tcp协议有报错,暂时搁置了,还得把那个写一下。 注解驱动,感觉也不是很难,定义好注解,配置好注解范围,然后定义spring容器初始化时的行为(初始化rpc,提供者启动服务器,消费者不需要启动)。 对于服务提供者,我们期望的是,给想要提供的服务添加上注解@RpcService ,就可以实现服务注册的功能,即,消费者可以通过注解来调用我们的服务。通过Spring的Bean监听机制,在Bean加载完成后,扫描含有@RpcService 注解的bean并获取注解的内容进行服务注册。 对于消费者,我们期望的是,添加注解注入对象后,可以直接调用对象的方法。这实际上是利用注解生成代理对象后注入到创建的对象上。在Bean加载完后,根据注解里的信息生成代理对象。 在这里我做了一些改进,也是从评论区看到的思路。在RpcReference注解中我们定义了很多属性比如重试机制,负载均衡的类别,但是在实际代码中是直接通过rpcConfig读取的默认配置,如果我们允许用户在注解中选中某些属性值,比如不用默认的负载均衡类型,而改用其他的,就需要将注解中的内容读出来,存储到一个map中,然后在动态代理时根据map里的信息从工厂中获取对应的实现类。 思路是, 1.创建一个新的生成代理对象方法,另外接收一个参数map,里面存放内容为注解内容 2.自定义一个RpcReference对象,对象属性为@RpcReference 内的内容,这里不能直接从该注解获取内容,因为如果导入starter依赖,就会造成循环依赖,所以我的解决方法是创建一个class对象。 3.在ServiceProxy中创建一个RpcReference属性,创建有参构造方法,接收map,将map中的值赋值给RpcReference,不必担心用户填了哪些值,哪些值没填,因为在@RocReference注解中已经对这些值写了默认值 4.在invoke方法中,将原来的从rpcConfig中获取字符串,从工厂实例化改为直接从RpcReference中获取字符串然后工厂创建。
3
Day 7 ✅ 今天做了: 面试鸭: 1.springboot的自动配置是如何实现的? 简单总结一下,@EnableAutoConfiguration注解下的@Import注解会引入一个AutoConfigurationImportSelector加载器,它会扫描META-INF/spring.factories下定义的所有要自动配置的类路径。还提供了一些条件加载注释比如@ConditionalOnClass,@ConditionOnMissingBean,以及优先级自动配置注解@AutoConfigurationBeFore,@AutoConfigurationAfter等注解 2.Mysql的主从同步机制 主从同步就是将主库中的数据同步到一个或多个从数据库中。 分为三种:异步复制,同步复制,半同步复制。 手写RPC框架-重试机制 没什么难点,失败了就重试,学了两种重试算法:1.不重试,2.固定重试间隔 主要就是创建一个重试类,编写doRetry方法,接收参数为一个Callable<返回参数>的任务对象,并在内部写入重试机制(如使用RetryerBuilder类定制重写机制),最后.call()提交。
1
Day 6 每天学完累了就像休息,总是忘了打卡。。。 ✅ 今天做了: 面试鸭: 1.HashTable,HashMap,TreeMap的区别? HashTable线程安全,不可存入null的key或value,比较古老,性能不好。 HashMap非线程安全,可存入一个null的key和多个null的value,多线程可用concurrentHashMap TreeMap非线程安全,底层由红黑树实现,支持key的自然排序或者自定义排序,不可存入null的key,但可存入null的value 2.Redis如何实现分布式锁 Redis通过setnx和Lua脚本实现分布式锁,上锁通过setnx获取锁,并设置过期时间,解锁通过lua脚本实现,解锁时还需要判断锁是否是自己的,避免释放别人的锁。 Redis设置锁的过期时间要合理,即不能太长,占用资源,也不能太短,业务还未执行结束就提前释放。 并且,在主从模式下,如果主节点获取锁,在还未进行主从同步时,就宕机了,哨兵选举出新的主节点,由于没有主从同步,新的主节点又创建了锁,而此时主节点恢复了,此时就出现了两把锁,可能造成数据不一致。 Redis为了解决这一问题,推出了红锁。 红锁的实现基于多台Redis,客户端获取锁,需要向所有Redis发送上锁请求,只有当过半的Redis同意上锁,才可以上锁,否则获取锁失败。但是红锁开发成本较高。 手写RPC框架-负载均衡 在之前的代码中,获取服务,直接从serverList中get(0),获取第一个服务,然后直接调用,而经过增加负载均衡服务,可以从serverList中选择一个调用。 随机负载:创建一个随机数,每次都从serverList中随机获取一个返回。 轮询负载:创建一个轮询数,每次都从serverLIst中获取轮询id对应的数据,轮询数+1。 Hash一致负载:将Hash值空间划分成一个圆环,所有服务节点都映射在环的某个位置,每个请求根据哈希值,也映射在环的某个位置,然后顺时针寻找第一个哈希值大于该哈希值的节点,将请求路由发送到该节点。 Hash一致的优点是,就算某个节点宕机了,请求仍然可以发送到其他节点。 但是Hash一致存在一个问题,那就是如果服务节点过少,可能造成资源分配不均匀的问题,所以同一个服务可以映射在环的多个哈希值节点位置。 然后在Rpcconfig创建属性,添加默认负载均衡机制。在invoke调用服务时添加负载均衡机制。 太伤心了,前两天在写自定义协议,很难,写了一天,到最后程序一直出错,就是找不到问题在哪里,不得已直接copy鱼皮的代码,发现还是存在同样的问题,compeleteFuture的get方法阻塞获取返回结果,一直获取不到,不知道怎么弄,只好把代码恢复了,白写了,只能等其他写完后,再去琢磨琢磨了,哭了
4
Day 5 ✅ 今天做了: 面试鸭: 1.Mysql如何调优? 。设计合理的联合索引,尽量实现索引覆盖 。避免使用select * 。避免使用左Like模糊查询 。避免对查询字段进行函数操作,导致索引无法命中 。尽量符合最左前缀匹配原则 2.count(1),count(*),count(字段)有什么区别? count(1),count(字段)都可以行数量,不包括null值 count(*)查询行数量,包括Null 3.Mysql的乐观锁和悲观锁 乐观锁: 通过比较操作数据前后版本号,或某个字段值是否一致来保证操作期间没有别的线程修改数据,如果不同则回滚。适用于并发冲突少,读多写少的情景。 悲观锁: 在操作数据时对数据加锁,可以通过行级锁或表级锁实现,如select...for update。适用于并发冲突多,且写多读少的情景
3
Day 4 ✅ 今天做了: 面试鸭: cookie,session,token的区别? 为什么要用消息队列? Mysql的默认隔离级别是什么? 常见的几种设计模式? Mysql的事务二阶段提交是什么? Mysql是如何实现业务的? 手写RPC框架-第五节-实现注册中心简易版 包括功能:服务注册,服务发现。 创建服务中心配置类,包含服务中心地址,类别等信息。 创建服务信息类,包含服务名,版本号,地址和端口号等信息,提供获取服务key的方法(服务名+版本号)。 分布式储存器选择etcd,键值对存储,键定义为服务名+版本号,值存储服务信息类对象。 添加默认版本号字段,设值为1.0 为了提供多种注册中心实现方法,跟序列器一样,创建一个注册中心接口,定义方法:初始化,服务注册,服务发现,关闭服务,并进行如下操作: 1.在META-INF目录下创建以注册中心接口全引用为名的文件,内含注册中心类以及对应的实现接口地址。方便之后的读取以及 反射实例化。 2.定义注册中心工厂类,静态代码块,调用spiLoader,读取并记录META-INF目录下定义的注册中心信息。 3.提供getInstance方法,接受注册中心类型名key,通过spiLoader获取key对应的注册中心实现类。 实现各注册中心实现类,继承注册中心接口。 初始化:接受注册中心配置类,根据配置类信息定义注册中心地址,以及创建KVclient 服务注册:接受一个注册信息类,获取服务名等信息,根据默认标识+服务名+版本号为key,注册信息类为value。 定义一个租期LeaseClient,通过grand设置有效时长,获取其id。 最后通过PutOption存入key,value,id添加到etcd。 服务发现:在invoke调用方法中,先获取注册中心配置类,获取注册中心类型,然后通过注册中心工厂的getInstance,传入注册中心类型,获取实现类,创建注册信息类,填入服务名,版本信息,再通过注册信息类的getKey方法获取key,然后调用注册中心的getService,服务发现。在内部,通过前缀匹配来查询,通过输入的key,通过GetOption并设置isPrefix(true),设置一个查询条件,然后通过kvClient的get方法,输入前缀key,以及Getoption查询条件,获取查询到的数据列表,然后以流的形式,讲每一个值转化为注册信息类对象(因为服务注册时,value直接存入的就是信息注册类),然后返回list。在调用时,先用用list的第一个对象。获取到的信息注册类,包含地址,端口号,还要在进行一下地址和端口号的拼接,如果地址包含http,说明地址host本就是一个http接口,则直接拼接host和port,如果不包含,则说明是localhost,需要拼接一个http://然后再放入http请求中
2
下载 APP