单词间隔重复算法

间隔重复算法

理论背景

遗忘曲线是一种描述记忆遗忘率的模型,艾宾浩斯在其著作《记忆:实验心理学的贡献》中首次详细描述了遗忘曲线,他使用了一些无意义的字母组合作为记忆对象,通过在不同的时间间隔后检查记忆的遗忘程度,得出了这一遗忘曲线。如图所示,就是典型的遗忘曲线。艾宾浩斯指出信息的遗忘速度随时间而非线性递减,遗忘最初非常迅速,然后逐渐放缓。基于这一发现,他提出了间隔效应(Spacing Effect),将复习活动分散到不同的日子进行,相比集中在一天内复习,可以显著减少所需的复习次数,并提高长期记忆的效率。这一发现对后续的研究产生了深远影响,启发了对间隔重复技术深入研究和应用的兴趣。

遗忘曲线

间隔重复(Spaced Repetition)是一种利用心理学间隔效应,通过不断复习所学内容并逐步增加复习期间的间隔来提升效率的学习方法。间隔重复方法适用于多种学习情境,特别是学习者需要牢固记忆大量知识的情形,比如外语词汇的学习,尤其是在目标语言的基础词汇数量很大的时候。将间隔重复应用到学习上的观点最初见于塞西尔·阿莱克·梅斯(Cecil Alec Mace)1932的著作《学习心理学》中,首次提出复习周期的概念:“Acts of revision should be spaced in gradually increasing intervals, roughly intervals of one day, two days, four days, eight days, and so on.” 在2009年的一篇名为《Spaced Repetition For Efficient Learning》的文章中,Gwern Branwen详细介绍了间隔重复技术在实现高效学习过程中的重要性。这篇文章讨论了间隔重复技术的原理和应用,旨在帮助学习者更有效地掌握知识和提高记忆效率,而非简单地死记硬背。
复习周期

算法设计

本系统基于赫尔曼·艾宾浩斯(Hermann Ebbinghaus)遗忘曲线公式实现简易间隔重复模型,使用指数衰减模型Math.exp(-x)根据用户标识的单词状态(已掌握、认识、模糊、忘记)来动态调整复习间隔,以提高用户的学习效率。材料以抽认卡的形式呈现,正面是单词,背面是释义。用户可以通过翻转抽认卡的方式来检查自己的记忆情况,并选择相应的记忆状态。

算法实现流程:

  1. 查询需要复习的单词:从数据库中查询到今日需要复习的单词列表。
  2. 状态评估:用户选择单词记忆的相应状态,如已掌握、认识、模糊、忘记。
  3. 间隔调整:根据单词的新状态,计算调整因子,并更新复习间隔。
  4. 更新数据库:将新的复习间隔和状态保存到数据库中,为下次复习做准备。

具体的流程如图所示。

间隔重复算法流程图

调整因子的计算基于以下公式:

factor = e^{-forgettingRate}

其中,遗忘率forgettingRate与用户选择的单词记忆状态相关联,状态越好,遗忘率越低,调整因子越大,复习间隔相应延长。

每个单词根据用户的记忆表现被分类为以下四种状态,并分配相应的遗忘率:

  • 忘记(Forgotten):遗忘率设置为 0.5,表示记忆较差,需要短时间内复习。
  • 模糊(Blurry):遗忘率设置为 0.3,表示记忆有一定基础,但不够牢固。
  • 熟悉(Known):遗忘率设置为 0.1,表示记忆相对稳定,可以延长复习间隔。
  • 掌握(Mastered):遗忘率为 0.0,表示单词已被完全掌握,无需进一步复习。

复习间隔的动态调整根据每个单词当前的复习间隔和计算得到的调整因子,动态调整下一次复习的时间。具体实现步骤如下:对于每个需要复习的单词,根据其当前状态查找其复习间隔。使用从状态对应的遗忘率计算出的调整因子来修改复习间隔。更新单词的复习间隔和预计下次复习时间。

这样通过公式计算后,调整因子factor的值在e^{-0.5}e^{-0.0}之间,即0.6065到1.0之间,再将其乘上默认的复习间隔"30,180,720,1440,2880,5760,10080,21600"(单位:分钟),即可得到新的复习间隔,并更新间隔索引。

对于计算用户的遗忘曲线,对数函数有助于模拟记忆衰减的减缓速度,具体计算公式如下:

forgettingRate = log((minutesUntilNextReview / FORGETTING_RATE_ADJUSTMENT) + 1)

其中,FORGETTING_RATE_ADJUSTMENT=30.0为遗忘率调整常量固定值,目前不考虑引入单词的难度级别或者是用户的记忆能力等因素。minutesSinceLastReview是距离上次复习以来的时间间隔,minutesUntilNextReview是距离下次复习的时间间隔,二者单位都为分钟。

最后,根据计算出的结果,通过以下公式计算出用户的记忆保留率:

retentionRate = e^(-forgetingRate * (minutesSinceLastReview / minutesUntilNextReview))

计算后将结果保存在数据库中,用于后续计算用户整体的遗忘曲线。

具体后端代码

间隔算法

controller
WordReviewController.java 调用服务层实现的方法对算法进行调整

package com.example.englishhub.controller;

import com.example.englishhub.entity.LearningPlans;
import com.example.englishhub.entity.WordReview;
import com.example.englishhub.entity.WordReviewVO;
import com.example.englishhub.service.WordReviewService;
import com.example.englishhub.utils.Result;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;

import java.util.List;
import java.util.Map;

/**
 * @Author: hahaha
 * @Date: 2024/4/8 15:13
 */

@RestController
@RequestMapping("/wordReview")
@Tag(name = "单词复习管理")
public class WordReviewController {

    @Autowired
    private WordReviewService wordReviewService;

    /**
     * 调整复习
     * @body wordReview
     */
    @Operation(summary = "调整复习")
    @PostMapping("/adjust")
    public Result adjustReviewIntervals(@RequestBody Map<String, Integer> body) {
    Integer wordId = body.get("wordId");
    Integer wordBookId = body.get("wordBookId");
    Integer status = body.get("status");
//    System.out.println("wordId = " + wordId);
//    System.out.println("wordBookId = " + wordBookId);
//    System.out.println("status = " + status);
    wordReviewService.adjustReviewIntervals(wordId, wordBookId, status);
    Result result = new Result();
    result.success("调整复习成功");
    return result;
}

    /**
     * 获取当天需学单词
     * @param wordBookId 单词书ID
     * @param dailyNewWords 每日新学单词数
     * @param dailyReviewWords 每日复习单词数
     * @return 复习单词列表
     */
    @Operation(summary = "获取当天需学单词")
    @GetMapping("/getToday")
    public Result getToday(Integer wordBookId, Integer dailyNewWords, Integer dailyReviewWords) {
        Result result = new Result();
        List<WordReviewVO> wordReviews = wordReviewService.getWordsToday(wordBookId, dailyNewWords, dailyReviewWords);
        result.setData(wordReviews);
        result.success("获取当天复习单词成功");
        return result;
    }
}

service
WordReviewService.java 定义各个方法

package com.example.englishhub.service;

import com.baomidou.mybatisplus.extension.service.IService;
import com.example.englishhub.entity.LearningPlans;
import com.example.englishhub.entity.WordReview;
import com.example.englishhub.entity.WordReviewVO;

import java.util.List;

/**
 * @Author: hahaha
 * @Date: 2024/4/8 15:14
 */
public interface WordReviewService extends IService<WordReview> {

    void adjustReviewIntervals(Integer wordId, Integer wordBookId, Integer status);

    List<WordReviewVO> getWordsToday(Integer wordBookId, Integer dailyNewWords, Integer dailyReviewWords);

    List<WordReview> getAllWordsForUser(Integer userId);

    List<WordReview> getWordsByUserId();
}

mapper
WordReviewMapper.java

package com.example.englishhub.mapper;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.example.englishhub.entity.WordReview;
import com.example.englishhub.entity.WordReviewVO;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Select;

import java.util.List;

/**
 * <p>
 * 单词复习表 Mapper 接口
 * </p>
 * @Author: hahaha
 * @Date: 2024/4/8 15:14
 */

@Mapper
public interface WordReviewMapper extends BaseMapper<WordReview> {

    @Select("SELECT w.id, w.word, w.phonetic_uk, w.phonetic_us, w.definition," +
            " wd.audio_url, wd.video_url, wd.definition AS definitionDetail, wd.subtext" +
            " FROM word_relation wr" +
            " JOIN word w ON wr.word_id = w.id" +
            " JOIN words wd ON w.words_id = wd.id" +
            " LEFT JOIN word_review wrv ON wrv.word_id = w.id AND wrv.user_id = #{userId}" +
            " WHERE wr.word_book_id = #{wordBookId} AND wrv.id IS NULL" +
            " ORDER BY w.id ASC" +
            " LIMIT #{dailyNewWords}")
    List<WordReviewVO> getNewWordsToday(Integer userId, Integer wordBookId, Integer dailyNewWords);

    @Select("SELECT w.id, w.word, w.phonetic_uk, w.phonetic_us, w.definition," +
            " wd.audio_url, wd.video_url, wd.definition AS definitionDetail, wd.subtext" +
            " FROM word_review wr" +
            " JOIN word w ON wr.word_id = w.id" +
            " JOIN words wd ON w.words_id = wd.id" +
            " WHERE wr.user_id = #{userId} AND wr.word_book_id = #{wordBookId}" +
            " AND wr.next_review_time <= CURRENT_TIMESTAMP" +
            " ORDER BY wr.next_review_time ASC" +
            " LIMIT #{dailyReviewWords}")
    List<WordReviewVO> getReviewWordsToday(Integer userId, Integer wordBookId, Integer dailyReviewWords);
}

impl
WordReviewServiceImpl.java

package com.example.englishhub.service.impl;

import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.example.englishhub.entity.ForgettingCurve;
import com.example.englishhub.entity.WordReview;
import com.example.englishhub.entity.WordReviewVO;
import com.example.englishhub.mapper.WordReviewMapper;
import com.example.englishhub.service.ForgettingCurveService;
import com.example.englishhub.service.LearningPlansService;
import com.example.englishhub.service.WordReviewService;
import com.example.englishhub.utils.JwtUtil;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.time.LocalDateTime;
import java.time.temporal.ChronoUnit;
import java.time.temporal.Temporal;
import java.util.*;
import java.util.stream.Collectors;

/**
 * @Author: hahaha
 * @Date: 2024/4/8 15:18
 */

@Service
public class WordReviewServiceImpl extends ServiceImpl<WordReviewMapper, WordReview> implements WordReviewService {

    @Autowired
    private WordReviewMapper wordReviewMapper;

    @Autowired
    private HttpServletRequest request;

    @Override
    public void adjustReviewIntervals(Integer wordId, Integer wordBookId, Integer status) {
        String token = request.getHeader("token");
        String userId = JwtUtil.validateToken(token);
        QueryWrapper<WordReview> queryWrapper = new QueryWrapper<>();
        queryWrapper.eq("word_id", wordId);
        queryWrapper.eq("user_id", Integer.parseInt(userId));
        queryWrapper.eq("word_book_id", wordBookId);

        WordReview wordReview = this.getOne(queryWrapper);

        if (wordReview == null) {
            // 新学单词,使用默认的复习间隔并新增记录
            String defaultIntervals = "30,180,720,1440,2880,5760,10080,21600";
            List<Integer> intervals = Arrays.stream(defaultIntervals.split(","))
                    .map(Integer::parseInt)
                    .collect(Collectors.toList());

            wordReview = new WordReview();
            wordReview.setUserId(Integer.parseInt(userId));
            wordReview.setWordId(wordId);
            wordReview.setWordBookId(wordBookId);
            wordReview.setStatus(status);
            wordReview.setReviewIntervals(defaultIntervals);
            wordReview.setReviewIntervalIndex(0);

            // 计算调整因子
            double factor = calculateAdjustmentFactor(status);
            // 调整复习间隔,将intervals列表中的每个元素乘以调整因子,然后将结果转换为整数。
            List<Integer> adjustedIntervals = intervals.stream()
                    .map(interval -> (int) (interval * factor))
                    .collect(Collectors.toList());
            wordReview.setReviewIntervals(adjustedIntervals.stream()
                    .map(Object::toString)
                    .collect(Collectors.joining(",")));

            // 设置下次复习时间
            if (status == 4) { // 单词已掌握
                wordReview.setNextReviewTime(null);
            } else {
                int newIndex = 0;
                wordReview.setReviewIntervalIndex(newIndex);
                wordReview.setNextReviewTime(LocalDateTime.now().plusMinutes(adjustedIntervals.get(newIndex)));
            }
            this.save(wordReview);
        } else {
            List<Integer> intervals = Arrays.stream(wordReview.getReviewIntervals().split(","))
                    .map(Integer::parseInt)
                    .collect(Collectors.toList());

            if (status == 4) { // 单词已掌握
                // 不再进行复习
                wordReview.setNextReviewTime(null);
            } else {
                double factor = calculateAdjustmentFactor(status);
                // 调整复习间隔,将intervals列表中的每个元素乘以调整因子,然后将结果转换为整数。
                List<Integer> adjustedIntervals = intervals.stream()
                        .map(interval -> (int) (interval * factor))
                        .collect(Collectors.toList());
                // 更新复习间隔,将adjustedIntervals列表中的每个元素转换为字符串,然后使用逗号(,)将这些字符串连接起来。
                wordReview.setReviewIntervals(adjustedIntervals.stream()
                        .map(Object::toString)
                        .collect(Collectors.joining(",")));

                // 确保索引在合理范围内
                int newIndex = (wordReview.getReviewIntervalIndex() + 1) % adjustedIntervals.size();
                wordReview.setReviewIntervalIndex(newIndex);
                wordReview.setNextReviewTime(LocalDateTime.now().plusMinutes(adjustedIntervals.get(newIndex)));

                this.updateById(wordReview);
            }
        }
    }

    private WordReview getByWordId(Integer wordId) {
        QueryWrapper<WordReview> queryWrapper = new QueryWrapper<>();
        queryWrapper.eq("wordId", wordId);
        return this.getOne(queryWrapper);
    }


    @Override
    public List<WordReviewVO> getWordsToday(Integer wordBookId, Integer dailyNewWords, Integer dailyReviewWords) {
        // 调用mapper层方法获取今天的所有复习记录
        String token = request.getHeader("token");
        String userId = JwtUtil.validateToken(token);

        List<WordReviewVO> reviewWords = wordReviewMapper.getReviewWordsToday(Integer.parseInt(userId), wordBookId, dailyReviewWords);
        List<WordReviewVO> newWords = wordReviewMapper.getNewWordsToday(Integer.parseInt(userId), wordBookId, dailyNewWords);
        reviewWords.addAll(newWords);
        return reviewWords;
    }

    @Override
    public List<WordReview> getAllWordsForUser(Integer userId) {
//        String token = request.getHeader("token");
//        String userId = JwtUtil.validateToken(token);
        QueryWrapper<WordReview> queryWrapper = new QueryWrapper<>();
        queryWrapper.eq("userId", userId);
        return this.list(queryWrapper);
    }

    public List<WordReview> getWordsByUserId() {
        String token = request.getHeader("token");
        String userId = JwtUtil.validateToken(token);

        return getAllWordsForUser(Integer.parseInt(userId));
    }

    public double calculateAdjustmentFactor(int status) {
        double forgettingRate;
        switch (status) {
            case 1: // 忘记forgotten
                forgettingRate = 0.5;
                break;
            case 2: // 模糊 blurry
                forgettingRate = 0.3;
                break;
            case 3: // 熟悉 known
                forgettingRate = 0.1;
                break;
            default: // 已掌握 mastered
                forgettingRate = 0.0;
        }
        // 使用指数函数来计算调整因子,遗忘率越大,调整因子越小
        // 原型是e^(-x)
        return Math.exp(-forgettingRate);
    }

}

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

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

ArcGis将同一图层的多个面要素合并为一个面要素

这里写自定义目录标题 1.加载面要素的shp数据 2.点击菜单栏的地理处理–融合&#xff0c;如下所示&#xff1a; 3.将shp面要素输入&#xff0c;并设置输出&#xff0c;点击确定即可合并。合并后的属性表就只有一个数据了。

钡铼网关: 轻松实现PLC与OPC UA服务器的双向通信

在当今工业4.0的大潮下&#xff0c;实现不同设备、系统之间的高效通信和数据交换已大势所趋&#xff01;PLC与OPC UA服务器的对接&#xff0c;对于打造智能工厂具有重要意义&#xff0c;本文将深入探讨钡铼技术的网关如何实现这一过程&#xff0c;为用户提供快速且低成本的解决…

godis源码分析——Redis协议解析器

前言 redis这个目录下的所有代码就是为了一个事情&#xff0c;就是适配redis。 流程 redis下的基本流程 源码 在redis/client/client.go 主要是客户端处理 package clientconst (created iotarunningclosed )type B struct {data chan stringticker *time.Ticker }// …

【基于R语言群体遗传学】-16-中性检验Tajima‘s D及连锁不平衡 linkage disequilibrium (LD)

Tajimas D Test 已经开发了几种中性检验&#xff0c;用于识别模型假设的潜在偏差。在这里&#xff0c;我们将说明一种有影响力的中性检验&#xff0c;即Tajimas D&#xff08;Tajima 1989&#xff09;。Tajimas D通过比较数据集中的两个&#x1d703; 4N&#x1d707;估计值来…

【栈和队列】

目录 一、栈1.1、栈的基本概念1.2、栈的基本操作1.3、栈的顺序存储实现1.3.1、顺序栈的定义1.3.2、顺序栈的初始化1.3.3、顺序栈的入栈和出栈1.3.4、读取栈顶元素1.3.5、共享栈&#xff08;即两个栈共享同一片空间&#xff09; 1.4、栈的链式存储实现1.4.1、链栈的定义1.4.2、链…

Spring Boot 高级配置:如何轻松定义和读取自定义配置

目录 1. 环境准备 2. 读取配置数据 2.1 使用 Value注解 2.2 Environment对象 2.3.2.3 自定义对象 这篇博客我们将深入探讨如何在Spring Boot应用中有效地定义和读取自定义配置。掌握这一技巧对于任何希望优化和维护其应用配置的开发者来说都是至关重要的。我们将从基础开始…

昆法尔The Quinfall在Steam上怎么搜索 Steam上叫什么名字

昆法尔The Quinfall是一款全新的MMORPG&#xff0c;在中世纪的深处&#xff0c;参与独特的战斗和沉浸式的故事&#xff0c;有几十个不同的职业。而游戏中的战斗系统更是丰富多彩&#xff0c;无论是陆地激战、海上鏖战还是城堡围攻&#xff0c;都能让玩家感受到前所未有的刺激和…

BJT交流分析+共发射极(CE)放大器+单片机的中断系统(中断的产生背景+使用中断重写秒表程序+中断优先级)

2024-7-10&#xff0c;星期三&#xff0c;16:58&#xff0c;天气&#xff1a;阴&#xff0c;心情&#xff1a;晴。今天终于阴天啦&#xff0c;有点风凉快一点了&#xff0c;不然真要受不了了&#xff0c;然后没有什么特殊的事情发生&#xff0c;继续学习啦&#xff0c;加油加油…

blender 纹理绘制-贴花方式

贴画绘制-1分钟blender_哔哩哔哩_bilibili小鸡老师的【Blender风格化角色入门教程】偏重雕刻建模https://www.cctalk.com/m/group/90420100小鸡老师最新的【风格化角色全流程进阶教程】偏重绑定。早鸟价进行中&#xff01;欢迎试听https://www.cctalk.com/m/group/90698829, 视…

2024年PMP考试备考经验分享

PMP是项目管理领域最重要的认证之一,本身是IT行业比较流行的证书&#xff0c;近几年在临床试验领域也渐渐流行起来&#xff0c;是我周围临床项PM几乎人手一个的证书。 考试时间&#xff1a;PMP认证考试形式为180道选择题&#xff0c;考试时间为3小时50分。 考试计划&#xff…

政安晨【零基础玩转各类开源AI项目】基于Ubuntu系统部署MuseV (踩完了所有的坑):基于视觉条件并行去噪的无限长度和高保真虚拟人视频生成

目录 下载项目 创建虚拟环境 启动虚拟环境&执行项目依赖 基于DOCKER的尝试 A. 安装引擎 B. 下载桌面安装包 C. 安装桌面包 用Docker运行MuseV 1. 拉取镜像 ​编辑 2. 运行Docker镜像 政安晨的个人主页&#xff1a;政安晨 欢迎 &#x1f44d;点赞✍评论⭐收藏 收…

内存迎来革命性升级,只装一条就能组成双通道

相信用过台式机的同学或多或少都遇到过一个情况&#xff0c;那就是按下开机键后&#xff0c;除了显示器不亮&#xff0c;哪儿都亮。 拿着自己的故障满世界发帖求助&#xff0c;得到最多的回答就是&#xff0c;断电拔下内存用橡皮擦擦擦金手指再装回。而这样的操作确实能解决大部…

51.通过获取数据快速实现一个辅助

上一个内容&#xff1a;50.破坏性更小的代码跳转功能完善&#xff08;无敌秒杀&#xff09; 原理是&#xff1a;找一个现成的辅助&#xff0c;使用PCHunter工具看现成辅助对目标游戏做了那些hook操作&#xff0c;然后再使用Ollydbg.exe工具分析现成辅助为何这样做。 下图左边…

短信验证码研究:公开的短信验证码接口、不需要注册的短信验证码接口

短信验证码研究&#xff1a;公开的短信验证码接口、不需要注册的短信验证码接口 0 说明 本文提供了一个短信验证码接口&#xff0c;主要用于以下场景&#xff1a; 1、用于开发调试 2、用于申请验证码困难的企业和个人 3、用于短信验证码认证还没有通过&#xff0c;但是着急…

深入了解java锁升级可以应对各种疑难问题

对于java锁升级&#xff0c;很多人都停留在比较浅层的表面理解&#xff0c;一定程度下也许够用&#xff0c;但如果学习其中的细节&#xff0c;我们更好地理解多线程并发时各种疑难问题的应对方式&#xff01; 因此我将锁升级过程中可能涉及的大部分细节或者疑问都整合成了一篇…

免费分享:中国1KM分辨率月平均气温数据集(附下载方法)

数据简介 中国1KM分辨率月平均气温数据集为中国逐月平均温度数据&#xff0c;空间分辨率为0.0083333&#xff08;约1km&#xff09;。 数据集获取&#xff1a;根据全国2472个气象观测点数据进行插值获取&#xff0c;验证结果可信。 数据集包含的地理空间范围&#xff1a;全国…

YOLOv10改进 | 图像去雾 | MB-TaylorFormer改善YOLOv10高分辨率和图像去雾检测(ICCV,全网独家首发)

一、本文介绍 本文给大家带来的改进机制是图像去雾MB-TaylorFormer&#xff0c;其发布于2023年的国际计算机视觉会议&#xff08;ICCV&#xff09;上&#xff0c;可以算是一遍比较权威的图像去雾网络&#xff0c; MB-TaylorFormer是一种为图像去雾设计的多分支高效Transformer…

技术文件国产化准备

技术文档的本地化涉及调整内容以满足特定目标市场的文化、语言和技术要求。这一过程超越了简单的翻译&#xff0c;确保文件在文化上适合预期受众&#xff0c;在技术上准确无误。适当的准备对于成功的本地化至关重要&#xff0c;以下步骤概述了一种全面的方法。 分析目标受众 …

IEC62056标准体系简介-4.IEC62056-53 COSEM应用层

为在通信介质中传输COSEM对象模型&#xff0c;IEC62056参照OSI参考模型&#xff0c;制定了简化的三层通信模型&#xff0c;包括应用层、数据链路层&#xff08;或中间协议层&#xff09;和物理层&#xff0c;如图6所示。COSEM应用层完成对COSEM对象的属性和方法的访问&#xff…

怎么将3张照片合并成一张?这几种拼接方法很实用!

怎么将3张照片合并成一张&#xff1f;在我们丰富多彩的日常生活里&#xff0c;是否总爱捕捉那些稍纵即逝的美好瞬间&#xff0c;将它们定格为一张张珍贵的图片&#xff1f;然而&#xff0c;随着时间的推移&#xff0c;这些满载回忆的宝藏却可能逐渐演变成一项管理挑战&#xff…