普通视图
每日一题-统计极差最大为 K 的分割方式数🟡
给你一个整数数组 nums 和一个整数 k。你的任务是将 nums 分割成一个或多个 非空 的连续子段,使得每个子段的 最大值 与 最小值 之间的差值 不超过 k。
返回在此条件下将 nums 分割的总方法数。
由于答案可能非常大,返回结果需要对 109 + 7 取余数。
示例 1:
输入: nums = [9,4,1,3,7], k = 4
输出: 6
解释:
共有 6 种有效的分割方式,使得每个子段中的最大值与最小值之差不超过 k = 4:
[[9], [4], [1], [3], [7]][[9], [4], [1], [3, 7]][[9], [4], [1, 3], [7]][[9], [4, 1], [3], [7]][[9], [4, 1], [3, 7]][[9], [4, 1, 3], [7]]
示例 2:
输入: nums = [3,3,4], k = 0
输出: 2
解释:
共有 2 种有效的分割方式,满足给定条件:
[[3], [3], [4]][[3, 3], [4]]
提示:
2 <= nums.length <= 5 * 1041 <= nums[i] <= 1090 <= k <= 109
当我最后一道职业护城河正在溃堤
![]()
36氪「职场Bonus」(ID:ZhiChangHongLi)
11月末,北京初冬。
今年36氪的WISE大会遇上了好天气。无云的蓝天下,抵达798的观众路过巨大的红砖烟囱和电子广告屏走入会场。
和往年相比,现场展位区多了不少先进制造类公司的硬件展示。跟随着表演机器人往会场深处走,你会看见人们围在某个角落里的煎饼机器身边,观赏它乐此不疲地运转。
在通往二楼主会场区的阶梯前,一些人会注意到我们的「职场红利派对」——或许,是市场部的同事希望用“派对”一词来消解职场话题中无处可逃的“班味”;又或许,是我们的职场环境每年都面临着愈发严峻的挑战(你看,就连摊煎饼的工作都有可能会被机器取代)。苦中作乐的思想派对,不失为一种时代狂浪中坚持观察、思考和分享的宣言。
“蓝领白领,职面风景”是这次WISE职场分会场沙龙的Slogan。某一天,你发现白领同事们悄悄有了蓝领副业,蓝领师傅们有了转型为白领的机会。还有一些职业,它足够“复合”,你也说不清它到底是动脑还是动手更多。而过去职业固有的三六九等、稀缺价值衡量标准,早已被新的生产工具和国家意志打破。
蓝领与白领的并轨背后,是AI工具的普及,以及中国市场往先进制造发展转型的浪潮。《职场Bonus》想要为个体解读这其中所包含的转折点、非共识和信息差,于是邀请了最懂这个话题的人才平台、高管教练、咨询师、创业者、猎头顾问、学界先锋观察者……总计15位嘉宾。
在这里,也感谢他们愿意发声和赴约,分享各自相信的,与看见的。
办一场活动不容易。除去现场的案例演示、游戏互动,我们把在这2日沙龙活动中听到的思想精华萃取,供远方的你,我们的朋友,共同参考。
![]()
36氪「职场Bonus」(ID:ZhiChangHongLi)
💭
![]()
36氪「职场Bonus」(ID:ZhiChangHongLi)
王晓涵
金色船文化 创始人,中央戏剧学院在读研究生
• 我们一定要忘记自己的专业对口,要思考我们的能力如何迁移,要把我们自己放在了一个非常有主体性的位置,才能在红海的纵深里开辟属于自己的蓝海。
• 我作为全国唯一中戏背景的银发经济创业者,最初用非遗手作打开市场,但很快意识到天花板太低。后来我通过把心理疏导功能专业化,让老年人在情景重现和角色互换中解决衰老焦虑,建立起戏剧疗愈的壁垒。
💭
![]()
36氪「职场Bonus」(ID:ZhiChangHongLi)
任鹏飞
禾蛙 迅致业务负责人
• 职场红利是时代红利下每个人努力和选择的结果,在整个职场中,我们要不断抓住职场红利,让自己的职业生涯持续发展,让自己越来越值钱。
• 而想要抓住职场红利,技能非常重要。在当下,蓝领、灰领、白领界限渐趋融合,许多曾经非常受欢迎的,如Java工程师已经从全职白领降档为外包,反而是懂技术、有语言基础的一线蓝领的收入越来越高。
•个人能力、价值才是帮助大家抓住时代红利的关键。比如很多大厂的HR虽然根本不懂编程,却开始学习用AI工具进行编程,以此来完成会议纪要本地化转写等工作——这样就既满足了保密要求,又提高了工作效率。
💭
![]()
36氪「职场Bonus」(ID:ZhiChangHongLi)
林耿旭
低聚体 创始人
• AI时代是放大器,能放大个人才能创意,也会放大懒惰。善用者如虎添翼,科普UP主“大圆镜科普”用AI生成画面达成百万粉;但更多人的能力鸿沟正被急剧拉大。
• 在AI时代,我认为有三个生存能力非常重要,即:资讯捕捉、学习与执行力。有专长的个体将迎来黄金发展期,超级个体和超级“小”团队将成为职业发展的新趋势。
• AI一天,人间一年。如果你一周不看任何资讯,就会感觉自己好像被时代淘汰了。当然,捕捉信息后要及时探索、学习、并尝试将其内化,应用到生活和工作中。
💭
![]()
36氪「职场Bonus」(ID:ZhiChangHongLi)
陈怡静
创业顾问,《无畏沟通》 作者
• 自由职业对专业度的要求其实比打工更高,因此绝不能将其作为逃避职业问题的避风港。
• 过去一年里,我持续回答两个核心问题:钱和健康。我现在每年12月雷打不动地做个人资产负债表与现金流量表,财务安全才有决策自由。
• 在自由职业的旷野待久了必然恐慌,但任何被你放下过的选择都能随时重新摆回台面审视,唯独切忌一条路走到黑。
💭
![]()
36氪「职场Bonus」(ID:ZhiChangHongLi)
郑宁
中国传媒大学 教授
• 新职业有六大风险:内容合规、合同陷阱、税务偷逃税、知识产权、数据合规、商业秘密。
• 关键要过程留痕存证据,合同盯紧核心条款,专业问题务必寻求专业人士帮助。AI领域目前需从业者、创业者保持警惕,训练数据合法性、生成作品著作权归属均存在争议,属于待完善的灰色地带。
💭
![]()
36氪「职场Bonus」(ID:ZhiChangHongLi)
七芊
职场作家,选择力 创始人
• 真正耽误我们去找到新职业的,是内心深处的自卑。突破这个自卑的核心在于敢于去了解那些跟你完全不匹配的好工作。
• 但凡是和人相关的复盘,你们都要从出身、学历、履历和他的性格进行总结,描绘出你自己的贵人画像和小人画像。
• 和事件相关的复盘,则要侧重于你从小到大所有关键节点上面,弄明白你的成功要素和失败要素到底是什么。
💭
![]()
36氪「职场Bonus」(ID:ZhiChangHongLi)
胡漾
观心实验室 品牌VP
• AI会取代蓝领和白领的重复劳动,人类的核心竞争力是“与生命相关的技能”,如唱歌、心理疗愈、手工创作。
• 今年一方面大厂裁员,另一方面新兴行业急需人才,职场存在严重错配。现在不应该继续迷信“铁饭碗”,这个世界上没有稳定的结构,只有解决具体问题的能力。
• 所以,“下地”干活吧!焦虑的终点都是具体问题,与其内耗,不如和真实世界碰撞,解决一个小问题。找个“不焦虑的搭子”,把焦虑写在纸上,一半焦虑会消失;两人一起分担,焦虑就只剩25%。
💭
![]()
36氪「职场Bonus」(ID:ZhiChangHongLi)
黄雷
策御国际传播 创始人
• 自由职业是不得已而为之的,并不是一个选择。但当你作为一个自由职业者的时候,你应该思考,你能够让自己安身立命、养家糊口的那个最硬的能力到底是什么?
• 打工和自由职业相比,“只有一条轨道”有的时候是幸福的,你知道要往哪去,每天要干嘛。但是当你处在旷野的时候,你要开始想一件事情:你要把你未来的生命,投入到哪一件事情上面?在曾经那个外企、互联网企业蓬勃发展的年代,你不需要任何这样的思考就可以拿一份很好的薪水。但现在这些机会已经没有了。在这样的前提下,如果你不先好好思考就骤然置身旷野,人是会崩溃的。
• 我在想,如果能让我再重活一次,我一定会花出待在大厂里30%的时间去思考,如果我失去现在的title 了,我到底需要做什么,我愿意为什么东西投入我的精力和生命?
💭
![]()
36氪「职场Bonus」(ID:ZhiChangHongLi)
郭彪
精匠通途 创始人
• AI对眼下的制造业实质性影响有限。但随着时间推移,AI技术一定会渗透到各行各业,到那时,如果知识结构还没有迭代,就会面临较大的冲击。
• 说到底,新旧生产力更替,旧岗位消失,新岗位总会冒出来,但前提是你得让自己成为能驾驭新技术的人。
• 目前95后车间主任比比皆是,他们技校毕业十八九岁,工作五六年,二十五六岁就管几十号人,晋升比白领快多了。
💭
![]()
36氪「职场Bonus」(ID:ZhiChangHongLi)
魏鑫
北京睿成半导体 HR负责人
• 机械加工领域的部分急缺岗位月薪达五位数以上,但市场仍处于有缺口且有挑选机会的状态。
• 一线工作中的安全隐患都是存在的,但企业对这些危害因素的防护都很重视,全力降低危险系数。现在的车间都是5S管理,恒温恒湿,洁净区域,跟想象中的噪音灰尘完全不一样。
• AI时代下,自动化越完善,流水线岗位需求自然越低,对于机器或者手工作业,产品单一且要求复杂,自动化替代周期会长得多。保住饭碗关键在于是否真想做好这份工作、能否持续自我提升。
💭
![]()
36氪「职场Bonus」(ID:ZhiChangHongLi)
王希娜
前领英、阿里、雅思 营销高管
• 个人职场发展的红利,与时代红利高度相关,也与自己个人价值的追寻密不可分。过去这些年,我从智能设备到移动互联网,再到大数据、 AI,从市场调研转到互联网,在百度、领英、阿里这几家公司得到了很好的发展。到了一定的阶段,我更希望让自己的价值通过培养下一代、为年轻人赋能来体现,于是转到了教育行业。
• 所以我们要在“追红利”和“找自己”之间寻求平衡与融合。你越是了解自己、滋养自己,就越能在AI时代立于不败之地。
• 发掘自己有天赋优势的、可迁移的、难以被 AI 替代的能力尤其重要。比如说对人性的洞察,与人深度沟通的能力,对大势和策略的直觉性把握。更深层次地,我们要去耕耘自己身心的健康,“留得青山在”,让自己有一个健康的身体、健全的心智,不断提升对世界的认知,以此为甲,保护自己穿越任何的周期,即使在低谷的时候,也能够平和欢喜地忍耐,内心也常驻着希望。
💭
![]()
36氪「职场Bonus」(ID:ZhiChangHongLi)
汤悦铭
云耕九州文旅科技集团 市场营销总监
• 文旅短剧投入小回报大,几十万成本可达上亿播放。我们团队目前已成功打造出锦州市文旅短剧《锦州烧烤侠》,昆山市昆曲主题短剧《玉见梁辰鱼》及肌肤未来品牌定制剧,曝光超20亿。全国6个文旅基地、政府给产业园、资金和人才支持,能带货带人带经济。
• 目前文旅短剧的人才缺口极大,且短剧会为普通人创造机会,从业不需要专业经验,素人也可以做编导、演员、运营。我们早在2017、2018年就布局,建立起产业链完整、能将海外创作者资源链接起来的核心优势。
💭
![]()
36氪「职场Bonus」(ID:ZhiChangHongLi)
威尔王
播客《职业离想》主播
• 我小时候的快乐就是开心地聊天,希望2026年能有更多机会像这样聊天。
💭
![]()
36氪「职场Bonus」(ID:ZhiChangHongLi)
孙丹
独立内容创作者
(也是这次WISE职场红利派对的项目经理、幕后志愿者之一)
• 我的职业经历分三段:百胜餐饮储备经理、36氪氪咖啡孵化项目负责人、食品垂类小程序联创。
• 在餐饮行业的8年经历,让我练就吃苦耐劳和培训加盟商的能力,这帮助我在36氪内部孵化氪咖啡时,7个月内在8城开10店。我离开36氪后,把门店运营、内容撰写、活动策划能力整合,做了一个食品垂类小程序,帮助国内食品达人拓展线上销售和出海,并持续通过播客等传播方式为品牌赋能。我能做这么多事情的核心就是可迁移能力的整合复用。
💭
![]()
36氪「职场Bonus」(ID:ZhiChangHongLi)
陈桐
《职场Bonus》 主编
• 蓝领白领现在处于互相交融的时代,一些蓝领工种的薪资,比部分文科生岗位的起薪甚至工作几年后的薪酬还要高。比如月嫂,高端制造行业里需要“手脑共用”的装配工程师,一线城市的新能源汽车售后维修工程师等。
• 外卖员属于蓝领里面门槛不高的职位,受益于媒体的舆论监督,各平台对骑手们的规则和福利有所改善,这两年从业者的幸福指数有了明显上升。需要注意的是,这一岗位本质上高度依赖于大型商业体系统和算法,骑手难以从平台独立。相比之下,健身教练、按摩师傅等其他直接面向C端提供服务的职业不仅收入上限更高,也更适合作为白领的后备副业。如果你懂得做定制化包装,给客户提供包含情绪价值的咨询服务、解决方案,你还有机会把这份蓝领职业变成自己的生意,跳出平台独立开自己的工作室。
💭
![]()
36氪「职场Bonus」(ID:ZhiChangHongLi)
余雯君
职升未来 CEO
(也是《职场Bonus》最早的项目发起人,@麻吉有三思 视频博主)
• 今年求职者心态是求稳:与其跳更高,先要防止自己坠落。但我们也看到有一些人,更愿意接受降薪,换取去到新兴红利行业的机会。
• 2025年,一线城市的人才流动也表现出新变化:上海仍是最吸引人才的目的地,深圳今年追平上海,杭州、广州吸引力增强。人才从地产、互联网溢出,流向数字智能、先进制造和跨境出海等领域。即使前方有非常多的不确定性和困难,勇敢的人总能先享受世界。
![]()
36氪「职场Bonus」(ID:ZhiChangHongLi)
![]()
36氪「职场Bonus」(ID:ZhiChangHongLi)
![]()
36氪「职场Bonus」(ID:ZhiChangHongLi)
● 线下短剧《实习生》现场,由中戏表演系毕业研究生袁咏诗(画着黑眼圈的这位)、中戏表演系在读博士马印民携手呈现
![]()
36氪「职场Bonus」(ID:ZhiChangHongLi)
![]()
● WISE大会的观众对干货分享的记录绝不含糊
![]()
36氪「职场Bonus」(ID:ZhiChangHongLi)
![]()
● 戴好手环后发现,懒人沙发躺平区域已经从空座到满座
![]()
36氪「职场Bonus」(ID:ZhiChangHongLi)
![]()
36氪「职场Bonus」(ID:ZhiChangHongLi)
![]()
36氪「职场Bonus」(ID:ZhiChangHongLi)
![]()
● 不会手作咖啡的播客主理人不是好主持,不参加趣味挑战的观众拿不到好奖品
![]()
36氪「职场Bonus」(ID:ZhiChangHongLi)
![]()
36氪「职场Bonus」(ID:ZhiChangHongLi)
![]()
36氪「职场Bonus」(ID:ZhiChangHongLi)
![]()
36氪「职场Bonus」(ID:ZhiChangHongLi)
![]()
36氪「职场Bonus」(ID:ZhiChangHongLi)
● 感谢所有赞助职场红利派对的雇主企业伙伴们,他们是Xmind、好望水、行云集团、大人糖、海螺AI,期待明年还能有更多赞助嘻嘻
![]()
● 职场红利派对门口的“36氪发展史”介绍墙。15年来,36氪以专业和创新为驱动不断成长。
最后,感谢母品牌36氪。
《职场Bonus》将继续和36氪在一起,从人事报道、求职招聘风向报道角度出发,建立那座“孕育万物的、承载新商业文明的岛屿”。
「2025 方舟计划」里,【秋季活动】和【冬季活动】没能如去年规划般和大家见面,延期至明年举办。
而《猎头市场水温调研》、《职场红利雇主Top50名册》、《职场红利年终盘点》系列已于WISE 2025 职场红利派对、创新香港TalentX人才嘉年华现场进行了小规模的线下发布,线上文章将于12月陆续上线,敬请期待!
欢迎体验我们的自研AI产品职升机AI,在红利赛道里找到适合你的机会
关注+星标
让你不再错过
从香港竹脚手架到前端脚手架:那些"借来"的发展智慧与安全警示
原文被我发在我的公众号上,mp.weixin.qq.com/s/q1jg039yA…
近期香港大埔区宏福苑大火的浓烟尚未完全消散,外墙那层竹制脚手架再次成为舆论焦点——这个伴随香港百年的建筑设施,在火势中沦为"助燃架"的悲剧,让"脚手架"这个词刺痛了大众的神经。而作为前端开发者,我看到这个词的第一反应却有些"条件反射":脑海里立刻浮现出vue create或create-react-app的命令行界面。这种奇妙的联想,或许正是技术领域向传统学科借鉴的生动缩影。
前端开发工程师的条件反射和"无聊"玩笑。
一、前端脚手架:不是工地架子,是代码的"预制骨架"
还记得刚入行时,第一次听到"前端脚手架"这个词,我着实困惑了好一阵子:写代码还要搭架子?难道要去工地上搬竹竿?后来才明白,前端脚手架和建筑脚手架的核心作用惊人地一致——都是为了给"施工"提供稳定的基础框架,让操作者能聚焦核心任务而非重复搭建。
具体来说,前端脚手架是一套自动化的项目初始化与工程化管理方案,它能帮开发者快速生成标准化的项目结构、配置好基础依赖和构建工具,避免每个人都从零开始编写配置文件、整理目录结构的繁琐。比如创建一个Vue项目时,敲下命令就能自动生成组件目录、路由配置、打包脚本等全套基础架构,就像建筑工人不用每次都从砍竹子开始搭架子一样。
而把"脚手架"这个建筑术语正式引入前端开发领域,并形成标准化工具的,是2012年Google工程师团队推出的Yeoman(作者按:个人认知,可能更早之前有人做过类似工具,但是那个时候比较热门的是Yeoman)。在这之前,前端开发还处于"刀耕火种"的阶段:开发者要手动引入CDN脚本、整理文件目录、配置压缩工具,不同人写的项目结构千差万别。Yeoman通过自动化这些流程,不仅奠定了前端脚手架的核心逻辑,更开启了前端工程化的序幕。
二、不止脚手架:那些从建筑工程"借来"的技术基因
前端脚手架的命名并非孤例,如果你仔细盘点会发现,前端开发乃至整个软件工程领域,都充斥着来自建筑工程的"跨界术语"。比如我们常说的"架构设计",直接挪用了建筑领域"建筑物结构规划"的概念;开发中的"模块拆分",对应着建筑里的"分部分项工程";甚至"版本迭代"里的"迭代",最初也指建筑施工中重复进行的标准化工序。
这种"借用"背后藏着一个朴素的真理:任何新学科的快速成长,都离不开向发展成熟的老学科汲取养分。建筑工程作为拥有数千年历史的"老牌学科",早已在标准化、流程化、风险控制等方面形成了完善的体系,而年轻的前端开发(满打满算不过三十年历史)正好需要这样的成熟经验来规避试错成本。
类似的跨界借鉴在科技领域比比皆是:计算机科学里的"算法"源自数学的逻辑推理,人工智能的"神经网络"模仿了生物大脑的神经元结构,医学上的"手术导航系统"借鉴了航天领域的定位技术,就连管理学的"瀑布模型",也是从制造业的流水线生产模式演变而来。新学科站在老学科的肩膀上,才能实现"弯道超车"式的发展。
三、竹与钢的抉择:传统与升级的博弈,前端也曾经历
回到香港竹脚手架的争议:为什么在钢制脚手架早已成为内地标配的今天,现代化的香港还在坚守竹制脚手架?答案并非"落后"那么简单,而是传统、经济与现实需求的复杂交织。
从优势来看,竹脚手架成本仅为钢管的1/5-1/3,搭建拆卸速度极快,熟练工人半日就能搭起一层,而且质地柔韧,能完美贴合香港老城区骑楼、转角等不规则建筑轮廓。
更重要的是,它已形成完整产业链,关联着数千搭棚工人的生计,甚至其"戏棚搭建技艺"还被列入非遗。但致命缺陷也显而易见:竹子燃点仅280-300℃,遇火后会迅速坍塌,这正是宏福苑大火快速蔓延的重要原因。
这种"传统便利与安全升级"的博弈,前端脚手架的发展历程中也曾上演。13年前的2012年,正是前端工程化开始蓬勃发展的起点,当时的前端开发就像香港的老建筑施工——大家用着各自的"竹脚手架":有的手动写HTML模板,有的自定义简易构建脚本,虽然能满足简单需求,但效率低下且隐患重重:不同开发者的项目结构混乱,代码兼容性问题频发,上线前的压缩、转译全靠手动操作,稍有疏忽就会出现线上故障。
发展启示:迭代升级才是安全与效率的核心
建筑领域中,内地通过政策引导和市场规模支撑,实现了从竹到钢的脚手架升级,形成"安全-经济-规模"的良性循环;前端领域则通过工具迭代完成了类似进化:从Google的Yeoman奠定了脚手架核心逻辑,到Grunt、Gulp的任务模式优化了构建流程,再到webpack、rollup则攻克了模块打包的核心难题,再到Vue CLI、Create React App实现零配置初始化,如今Vite更是通过原生ESM大幅提升构建效率。每一次升级都在解决前一代"脚手架"的痛点,就像钢制脚手架替代竹子一样,既提升了效率,更筑牢了安全底线。
四、安全无小事:脚手架里藏着的"生命线"
香港大火的惨剧让我们看清:再深厚的传统、再便捷的成本,都不能成为牺牲安全的借口。如今香港已启动计划,要求半数新工务工程改用金属棚架,这是用生命代价换来的进步。
而这个道理,在前端开发领域同样适用。
前端脚手架看似只是工具,实则是项目的"安全基石"。过时的脚手架工具并非本身"劣质",而是因其设计初衷适配的是早期前端技术栈,难以跟上当前快速迭代的技术发展节奏。
如果自定义脚手架时忽略了ESLint校验配置,可能会让语法错误流入生产环境;如果脚手架的构建脚本存在漏洞,甚至可能导致线上服务崩溃。这些问题看似是"工具故障",实则会造成客户流失、公司声誉受损,最终开发者还要面临通报批评、绩效扣除的后果。
当然,"过时"的脚手架也在不断迭代升级,努力跻身前端现代化"钢制"脚手架行列。只不过他们面临的竞争比早期野蛮生长时期更加激烈,就看有多少前端开发者愿意使用了。
就像建筑工人必须检查脚手架的绑扎牢固度,前端开发者也该守住自己的"脚手架防线":定期升级脚手架工具以修复安全漏洞,根据项目需求选择合适的标准化工具而非盲目自定义,在脚手架配置中完善代码校验和测试流程。毕竟,无论是建筑施工还是代码开发,安全永远是比效率和成本更重要的底线。
总结
希望香港的浓烟能让传统工艺与现代安全更好地兼容。也希望每个前端开发者都能重视手中的"脚手架"——让这个从建筑领域借来的工具,既成为提升效率的利器,更成为守护线上安全的屏障。
Flutter组件封装:验证码倒计时按钮 TimerButton
一、需求来源
发现问题:
最近发现老代码中验证码倒计时按钮在低配机器上会出现数字乱闪的情况,查询资料之后发现是 jank 导致的问题。
解决办法:
思路是验证码接口请求成功之后,立即计算当前时间未来60s的目标时间点。定时器每秒轮训一次,查询当前时间距离目标时间点的剩余秒,然后显示在界面上。 通过mixin 做了倒计时逻辑抽离,方便二次封装组件。现成组件是 TimerButton,效果如下:
![]()
![]()
二、使用示例
TimerButton(
onRequest: () async {
await Future.delayed(Duration(milliseconds: 1));//假装在请求
return true;
},
),
三、源码
1、倒计时混入mixin: CountDownTimer,倒计时逻辑抽离
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_templet_project/extension/extension_local.dart';
/// 倒计时
mixin CountDownTimer<T extends StatefulWidget> on State<T>, WidgetsBindingObserver {
DateTime? _endTime;
Timer? _timer;
int get limitSecond => 60;
final isCountingDownVN = ValueNotifier(false);
late final countdownVN = ValueNotifier(limitSecond);
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_timer?.cancel();
super.dispose();
}
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
}
// 处理 app 暂停/恢复(确保从后台回来能立即刷新)
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
// app 回到前台,立刻重算一次
_updateRemaining();
}
}
/// 开始倒计时
void startCountdown() {
isCountingDownVN.value = true;
countdownVN.value = limitSecond;
_endTime = DateTime.now().add(Duration(seconds: limitSecond));
_updateRemaining(); // 立即计算一次(避免 UI 延迟)
_timer?.cancel();
// 周期短一点以保证恢复后能尽快反映(但 setState 只有在秒数变化才触发)
_timer = Timer.periodic(const Duration(seconds: 1), (_) => _updateRemaining());
}
void _updateRemaining() {
if (_endTime == null) {
return;
}
final secondsLeft = _endTime!.difference(DateTime.now()).inSeconds.clamp(0, limitSecond);
DLog.d(["secondsLeft: $secondsLeft"]);
if (secondsLeft <= 0) {
isCountingDownVN.value = false;
_timer?.cancel();
_endTime = null;
} else {
countdownVN.value = secondsLeft;
}
}
}
2、TimerButton源码
// 验证码
class TimerButton extends StatefulWidget {
const TimerButton({
super.key,
required this.onRequest,
});
final Future<bool> Function() onRequest;
@override
State<TimerButton> createState() => TimerButtonState();
}
/// 倒计时
class TimerButtonState extends State<TimerButton> with WidgetsBindingObserver, CountDownTimer {
@override
Widget build(BuildContext context) {
return ValueListenableBuilder(
valueListenable: isCountingDownVN,
builder: (context, isCountingDown, child) {
// final disabledBackgroundColor = themeProvider.isDark ? const Color(0xFF3A3A48) : const Color(0xFFDEDEDE);
// final disabledForegroundColor = themeProvider.isDark ? const Color(0xFF7C7C85) : const Color(0xFFA7A7AE);
return ElevatedButton(
style: ElevatedButton.styleFrom(
minimumSize: Size(60, 30),
maximumSize: Size(100, 30),
padding: EdgeInsets.symmetric(horizontal: 10, vertical: 4),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
backgroundColor: Colors.red,
// disabledBackgroundColor: disabledBackgroundColor,
foregroundColor: Colors.white,
disabledForegroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(4), // 设置圆角半径
),
elevation: 0,
),
onPressed: isCountingDown
? null
: () async {
var res = await widget.onRequest();
if (res) {
startCountdown();
}
},
child: ValueListenableBuilder(
valueListenable: countdownVN,
builder: (context, value, child) {
return Container(
alignment: Alignment.center,
child: Text(
isCountingDown ? '$value秒后重试' : '发送验证码',
),
);
},
),
);
},
);
}
}
最后、总结
倒计时逻辑实现起来并不复杂,但也算开发中遇到的疑难杂(高配机器永远不出现,低配经常出现),顺手做个记录。
C#异常概念与try-catch入门
一、什么是异常,我们为何需要它?
1. 编程世界里的“意外”
在C#中,异常是在程序执行期间发生的、中断了正常指令流的“反常”或“错误”事件。它不是我们通常所说的“BUG”(逻辑错误),比如你本想做加法却写了减法;也不是“语法错误”,那种在编译时就会被编译器指出的拼写错误。异常是运行时的错误,是程序在“活着”的时候遇到的突发状况。
常见的异常场景包括:
- 尝试打开一个不存在的文件。
- 网络连接突然中断。
- 请求的内存过大,系统无法分配。
- 数组索引超出了范围。
- 尝试对一个
null的对象进行操作。
2. 异常的代价
- 程序突然终止:这是最直接的后果。对于用户来说,这意味着他们正在进行的工作(比如编辑文档、填写表单)可能会瞬间丢失,体验极差。
- 数据损坏:例如一个转账操作,在扣除A账户金额后、增加B账户金额前,程序因为一个异常而崩溃。这将导致账目不平,数据状态不一致。
- 暴露敏感信息:在Web应用中,一个未处理的异常可能会将包含数据库连接字符串、服务器内部路径等敏感信息的完整错误堆栈信息(Stack Trace)暴露给最终用户,构成严重的安全隐患。
- 资源泄露:如果程序在打开文件或数据库连接后,在关闭它们之前崩溃,这些宝贵的系统资源将无法被释放,久而久之会耗尽系统资源,导致整个系统变慢甚至瘫痪。
二、try-catch块的基础语法与工作原理
try-catch 语句是C#中用于处理异常的基本工具。它的逻辑非常符合人类的直觉:“尝试做某件事,如果出了问题,就这样补救”。
1. try 块:划定“风险区”
try 关键字后面跟着一个代码块 {},我们将所有可能抛出异常的代码都放在这个代码块里。
try
{
// 这里是“风险区”
Console.WriteLine("请输入一个数字:");
int number = int.Parse(Console.ReadLine());
int result = 100 / number;
Console.WriteLine($"100除以{number}的结果是:{result}");
}
catch(Exception e)
{
Console.WriteLine(e.ToString());
}
在上面的代码中,int.Parse() 可能会因为用户输入非数字字符而抛出 FormatException,而 100 / number 可能会因为用户输入0而抛出 DivideByZeroException。
2. catch 块:部署“应急预案”
catch 关键字紧跟在 try 块之后,它也包含一个代码块。当 try 块中的任何一条语句抛出异常时,程序的正常执行流会立即中断,然后CLR(公共语言运行时)会寻找一个能够“接住”这个异常的 catch 块。
最基本的 catch 块:
try
{
// ... 风险代码 ...
}
catch
{
// 异常发生时,执行这里的代码
Console.WriteLine("发生了一个未知错误!");
}
这种不带任何参数的 catch 块可以捕获任何类型的异常,但它有一个巨大的缺点:你不知道具体发生了什么错误。这就像一个消防员赶到现场只知道“着火了”,却不知道是电线起火还是厨房起火,无法采取针对性的灭火措施。
3. 捕获具体的异常信息:catch (ExceptionType ex)
C#允许我们在 catch 后面指定要捕获的异常类型,并提供一个变量来接收这个异常对象。
System.Exception 是所有异常类型的基类。因此,catch (Exception ex) 可以捕获几乎所有类型的异常,并且通过变量 ex,我们可以访问到关于异常的宝贵信息。
try
{
Console.WriteLine("请输入一个数组索引(0-2):");
int[] numbers = { 10, 20, 30 };
int index = int.Parse(Console.ReadLine());
Console.WriteLine($"索引 {index} 上的值为: {numbers[index]}");
}
catch (Exception ex)
{
Console.WriteLine("\n--- 程序出现问题!---");
Console.WriteLine($"错误类型: {ex.GetType().Name}"); // 获取异常的具体类型名
Console.WriteLine($"错误信息: {ex.Message}"); // 获取异常的描述信息
Console.WriteLine("--- 详细堆栈跟踪 ---");
Console.WriteLine(ex.StackTrace); // 获取异常发生时的调用堆栈
Console.WriteLine("----------------------");
}
Console.WriteLine("\n程序已通过异常处理,继续执行...");
三、玩转多catch块与异常层次结构
1. 多catch块进行细分异常
一个 try 块后面可以跟多个 catch 块,每个 catch 块负责处理一种特定类型的异常。CLR在匹配 catch 块时,会从上到下依次检查,并执行第一个能够匹配异常类型的 catch 块。
“匹配”的规则是:如果抛出的异常类型是 catch 块中声明的类型,或者是其子类,则匹配成功。
这就引出了多catch块最重要的规则:catch 块的顺序必须是从最具体(子类)到最通用(父类)。
示例:一个健壮的文件读取操作
public void ProcessFile(string filePath)
{
try
{
string content = System.IO.File.ReadAllText(filePath);
Console.WriteLine("文件内容处理成功!");
}
catch (System.IO.FileNotFoundException ex) // 最具体的异常
{
Console.WriteLine($"错误:文件 '{filePath}' 不存在。请检查路径是否正确。");
}
catch (System.UnauthorizedAccessException ex) // 另一个具体的异常
{
Console.WriteLine($"错误:程序没有权限访问文件 '{filePath}'。");
}
catch (System.IO.IOException ex) // 捕获其他所有IO相关的异常
{
Console.WriteLine($"读取文件时发生 I/O 错误: {ex.Message}");
}
catch (Exception ex) // 最后的“万能捕手”,捕获所有其他意想不到的异常
{
Console.WriteLine($"发生未知错误,请联系技术支持。");
// 在真实应用中,这里应该记录完整的ex.ToString()到日志文件
// Log.Error(ex.ToString());
}
}
分析:
- 如果文件不存在,第一个
catch (FileNotFoundException)会被执行。 - 如果文件存在但程序没有读取权限,第二个
catch (UnauthorizedAccessException)会被执行。 - 如果发生其他I/O错误(如磁盘已满),由于这些错误类型(如
DiskFullException)通常继承自IOException,第三个catch块会被执行。 - 如果发生了完全无关的错误(比如在后续处理中出现
OutOfMemoryException),最后的catch (Exception)会作为兜底防线被触发。
如果你把 catch (Exception ex) 放在最前面,那么它会捕获所有异常,后面的具体 catch 块将永远没有机会执行,编译器甚至会因此报错。
2. 警惕空catch块和“吞噬”异常
有时候,你可能会看到这样的代码:
// 警告:极度危险的代码!
try
{
SomeRiskyOperation();
}
catch (Exception)
{
// 什么也不做
}
这被称为“吞噬异常”或“异常黑洞”。代码的作者可能认为“我知道这里可能出错,但我不关心”。这是一个极其危险的坏习惯!
为什么危险?
- 隐藏问题:一个严重的问题(比如数据库连接失败)发生了,但程序假装什么都没发生,继续往下执行。这很可能导致后续代码在错误的数据基础上运行,引发更隐蔽、更难以调试的错误,甚至导致数据永久性损坏。
- 调试噩梦:当程序出现奇怪的行为时,你将没有任何线索。没有日志,没有崩溃报告,错误就像人间蒸发了一样。
正确的做法是:即使你认为可以从某个异常中恢复,也至少应该记录它。
try
{
// ...
}
catch (SomeExpectedAndRecoverableException ex)
{
// 记录下来,以备后续分析
Log.Warning($"一个可恢复的错误发生了: {ex.Message}");
// 然后执行恢复逻辑
// ...
}
3. TryParse vs. try-catch**
考虑一个场景:验证用户输入的字符串是否为有效的整数。我们有两种方法:
方法A: LBYL (Look Before You Leap) - 先看后跳
使用 int.TryParse 进行预检查。
string input = Console.ReadLine();
if (int.TryParse(input, out int number))
{
// 成功,使用 number
Console.WriteLine($"你输入的数字是: {number}");
}
else
{
// 失败,处理无效输入
Console.WriteLine("无效的输入,请输入一个整数。");
}
方法B: EAFP (It's Easier to Ask for Forgiveness than Permission) - 先做后问
直接尝试转换,用 try-catch 处理失败情况。
string input = Console.ReadLine();
try
{
int number = int.Parse(input);
// 成功,使用 number
Console.WriteLine($"你输入的数字是: {number}");
}
catch (FormatException)
{
// 失败,处理无效输入
Console.WriteLine("无效的输入,请输入一个整数。");
}
如何选择?
-
性能:抛出和捕获异常是一个非常昂贵的操作。CLR需要保存当前执行状态,展开调用堆栈,搜索
catch块等,这比一个简单的if-else判断要慢得多。因此,在性能敏感的代码或错误是“可预期的常规事件”(比如用户输入错误)时,TryParse模式是首选。 -
代码清晰度:
TryParse明确地表达了“我正在尝试转换,并检查其结果”的意图,逻辑清晰。而使用try-catch来控制正常的程序流程,则被认为是一种反模式(anti-pattern),因为它混淆了“真正的异常情况”和“正常的逻辑分支”。 -
适用场景:
-
使用
TryParse:当失败是常见且可预期的分支时(如用户输入验证、检查字典中是否存在键)。 -
使用
try-catch:当失败是真正“异常”的、不希望发生的情况时(如文件损坏、网络断开、磁盘已满)。
-
使用
结论:不要用异常来控制程序流程。异常处理是为意外准备的,不是为日常准备的。
结语
点个赞,关注我获取更多实用 C# 技术干货!如果觉得有用,记得收藏本文
怎么设计一个加密货币 谁有权利发行数字货币 怎么防止double spending attack 怎么验证交易合法性 铸币交易..
下面给你结合概念 + 原理解释 + 示例类比 + 在设计加密货币时的作用的深度讲解版,帮助你真正理解每个概念如何共同构成“一个加密货币系统”。
内容结构如下:
- 每个概念都有:定义 → 为什么需要 → 例子 → 在加密货币中的作用
- 内容逻辑自然衔接(从“如何设计货币 → 安全 → 共识 → 分布式系统”)
📘 一、怎么设计一个加密货币(核心框架)
设计一套加密货币,你必须解决 3 个根问题:
- 谁有权发行?(发行权)
- 怎么验证交易?(合法性)
- 怎么达成共识?(全网同步、防攻击)
因此,设计币必须包含:
- 货币发行规则(coinbase / genesis / monetary policy)
- 去中心化账本(区块链)
- 防双花机制
- 分布式共识机制(PoW / PoS)
- 交易验证逻辑(签名、UTXO、余额检查)
- 节点的通信与抗攻击机制
下面逐一拆解概念。
📗 二、谁有权发行数字货币
1️⃣ 传统货币(中心化)
央行发行。
2️⃣ 加密货币(去中心化)
发行权由 数学规则 决定:
- 比特币发行者:矿工(通过挖矿)
- 块奖励由协议写死(2110万上限)
- 新币来自 coinbase transaction(铸币交易)
➡️ 结论:发行权不是人决定,是协议决定。
📘 三、怎么防止 Double Spending Attack(双花攻击)
双花:同一笔钱被花两次。
例如:
Alice 有 1 BTC 她同时向 Bob 和 Carol 各发 1 BTC 希望网络不同步导致都确认成功。
BTC 的防御:
① UTXO 模型
每个输出只能花一次,节点会检查:
该UTXO是否已被花过?
② 工作量证明 + 最长链规则
攻击者必须:
- 重写历史
- 超过全网算力
- 重新挖出更长链
难度极高 → 成本巨大 → 无法双花
📙 四、怎么验证交易合法性
验证流程(比特币):
① 签名验证(身份合法)
检查:
交易输入引用的公钥 + 签名 是否匹配?
谁的币 → 谁的私钥 → 才能花。
② UTXO 是否存在(余额合法)
输入引用的UTXO必须存在且未花费。
③ 输入金额 ≥ 输出金额(无凭空造币)
④ Script 脚本(锁定/解锁逻辑)
📘 五、铸币交易(Coinbase Transaction)
这是 每个区块的第一笔交易,由矿工创建。
包含:
- 区块奖励(新币发行)
- 交易手续费(来自区块内其他交易)
示例:
coinbase:
input: no previous output
output: miner's address + block reward
→ 新币产生机制 → 挖矿收入来源
📗 六、Distributed Consensus(分布式共识)
定义: 在没有中心服务器的情况下,使整个网络对“世界状态”达成一致。
区块链中的一致内容包括:
- 谁的余额是多少?
- 哪些交易有效?
- 哪个区块是最新的?
BTC 使用:
PoW + longest chain rule
- 最难挖的链 = 最可信
- 避免双花
- 抗恶意节点
📘 七、Distributed Hash Table(DHT)
不是比特币核心,但常用于 P2P 网络和区块链项目。
定义: 一种分布式存储结构,用哈希定位数据位置。
例如:
BitTorrent 查找文件块 Filecoin 存储文件索引
作用:
- 数据分散存
- 无中心
- 高扩展性
📕 八、CAP 定理(Consistency / Availability / Partition tolerance)
分布式系统的三大属性:
-
Consistency(强一致性) 所有节点看到同一份数据。
-
Availability(可用性) 节点持续可以响应请求。
-
Partition Tolerance(分区容忍性) 网络分裂时仍可运行。
⚠️ 定理:三者不可同时满足,只能满足其中两个。
区块链选择:
AP → 最终一致性(eventual consistency)
因为:
- 区块传播有延迟
- 全网只能做到最终同步
📘 九、Paxos(传统共识算法)
Paxos 是分布式系统中解决一致性的经典算法。
- 专为少量节点设计(一般 3–7 台)
- 用于银行、数据库(Google、Zookeeper)
⚠️ 并不适用于加密货币:
- 要求身份明确(区块链是匿名的)
- 要求节点数量不多(区块链节点多且不可信)
📙 十、Membership(分布式系统中的成员管理)
指:
- 哪些节点是合法成员?
- 如何加入?
- 如何踢出作恶节点?
传统系统:Paxos、Raft 都需要 membership control
比特币:
谁都能加入,不需要许可(permissionless)。
Membership 是公开开放的。
📘 十一、BTC 的共识机制是什么?
Proof of Work(工作量证明) + 最长链(Nakamoto Consensus)
核心:
- 挖矿 = 投入算力证明诚实
- 最长链视为“真实历史”
- 攻击者要篡改历史必须超过全网算力(几乎不可能)
📙 十二、Sybil Attack(女巫攻击)
定义: 攻击者创建大量虚假身份来控制系统。
例如:
- 制造 1 万个节点试图影响共识
- 在投票系统中伪造大量身份
区块链如何防御?
比特币:用 PoW 抗 Sybil
身份无意义 → 算力才是身份
你开 1000 个节点 = 你只是把自己的算力拆成 1000 份 = 总算力不变 = 没法影响共识
→ 这是 Nakamoto 共识的一个核心优势。
📌 总结(极简)
| 概念 | 问题 | 解决方式 |
|---|---|---|
| 发行权 | 谁能印钱? | Coinbase(矿工) |
| 双花 | 怎么防止一币两花? | PoW + 最长链 + UTXO |
| 交易验证 | 如何判断交易有效? | 签名 + UTXO + Script |
| 共识 | 全网怎么看法一致? | PoW(Nakamoto Consensus) |
| Sybil 攻击 | 虚假身份攻击 | 算力证明(PoW) |
| CAP | 分布式系统限制 | 区块链选择 AP |
如果你需要,我还可以:
✔ 绘制 Mermaid 思维导图 ✔ 绘制 流程图 / 区块链结构图 / DHT 图 ✔ 统一生成 可视化 PDF / PNG ✔ 用 比特币为例写成教案 / 总结
需要生成哪种格式?
4.BTC-协议-北大肖臻老师客堂笔记
概念
下面给你结合概念 + 原理解释 + 示例类比 + 在设计加密货币时的作用的深度讲解版,帮助你真正理解每个概念如何共同构成“一个加密货币系统”。
内容结构如下:
- 每个概念都有:定义 → 为什么需要 → 例子 → 在加密货币中的作用
- 内容逻辑自然衔接(从“如何设计货币 → 安全 → 共识 → 分布式系统”)
📘 一、怎么设计一个加密货币(核心框架)
设计一套加密货币,你必须解决 3 个根问题:
- 谁有权发行?(发行权)
- 怎么验证交易?(合法性)
- 怎么达成共识?(全网同步、防攻击)
因此,设计币必须包含:
- 货币发行规则(coinbase / genesis / monetary policy)
- 去中心化账本(区块链)
- 防双花机制
- 分布式共识机制(PoW / PoS)
- 交易验证逻辑(签名、UTXO、余额检查)
- 节点的通信与抗攻击机制
下面逐一拆解概念。
📗 二、谁有权发行数字货币
1️⃣ 传统货币(中心化)
央行发行。
2️⃣ 加密货币(去中心化)
发行权由 数学规则 决定:
- 比特币发行者:矿工(通过挖矿)
- 块奖励由协议写死(2110万上限)
- 新币来自 coinbase transaction(铸币交易)
➡️ 结论:发行权不是人决定,是协议决定。
📘 三、怎么防止 Double Spending Attack(双花攻击)
双花:同一笔钱被花两次。
例如:
Alice 有 1 BTC 她同时向 Bob 和 Carol 各发 1 BTC 希望网络不同步导致都确认成功。
BTC 的防御:
① UTXO 模型
每个输出只能花一次,节点会检查:
该UTXO是否已被花过?
② 工作量证明 + 最长链规则
攻击者必须:
- 重写历史
- 超过全网算力
- 重新挖出更长链
难度极高 → 成本巨大 → 无法双花
📙 四、怎么验证交易合法性
验证流程(比特币):
① 签名验证(身份合法)
检查:
交易输入引用的公钥 + 签名 是否匹配?
谁的币 → 谁的私钥 → 才能花。
② UTXO 是否存在(余额合法)
输入引用的UTXO必须存在且未花费。
③ 输入金额 ≥ 输出金额(无凭空造币)
④ Script 脚本(锁定/解锁逻辑)
📘 五、铸币交易(Coinbase Transaction)
这是 每个区块的第一笔交易,由矿工创建。
包含:
- 区块奖励(新币发行)
- 交易手续费(来自区块内其他交易)
示例:
coinbase:
input: no previous output
output: miner's address + block reward
→ 新币产生机制 → 挖矿收入来源
📗 六、Distributed Consensus(分布式共识)
定义: 在没有中心服务器的情况下,使整个网络对“世界状态”达成一致。
区块链中的一致内容包括:
- 谁的余额是多少?
- 哪些交易有效?
- 哪个区块是最新的?
BTC 使用:
PoW + longest chain rule
- 最难挖的链 = 最可信
- 避免双花
- 抗恶意节点
📘 七、Distributed Hash Table(DHT)
不是比特币核心,但常用于 P2P 网络和区块链项目。
定义: 一种分布式存储结构,用哈希定位数据位置。
例如:
BitTorrent 查找文件块 Filecoin 存储文件索引
作用:
- 数据分散存
- 无中心
- 高扩展性
📕 八、CAP 定理(Consistency / Availability / Partition tolerance)
分布式系统的三大属性:
-
Consistency(强一致性) 所有节点看到同一份数据。
-
Availability(可用性) 节点持续可以响应请求。
-
Partition Tolerance(分区容忍性) 网络分裂时仍可运行。
⚠️ 定理:三者不可同时满足,只能满足其中两个。
区块链选择:
AP → 最终一致性(eventual consistency)
因为:
- 区块传播有延迟
- 全网只能做到最终同步
📘 九、Paxos(传统共识算法)
Paxos 是分布式系统中解决一致性的经典算法。
- 专为少量节点设计(一般 3–7 台)
- 用于银行、数据库(Google、Zookeeper)
⚠️ 并不适用于加密货币:
- 要求身份明确(区块链是匿名的)
- 要求节点数量不多(区块链节点多且不可信)
📙 十、Membership(分布式系统中的成员管理)
指:
- 哪些节点是合法成员?
- 如何加入?
- 如何踢出作恶节点?
传统系统:Paxos、Raft 都需要 membership control
比特币:
谁都能加入,不需要许可(permissionless)。
Membership 是公开开放的。
📘 十一、BTC 的共识机制是什么?
Proof of Work(工作量证明) + 最长链(Nakamoto Consensus)
核心:
- 挖矿 = 投入算力证明诚实
- 最长链视为“真实历史”
- 攻击者要篡改历史必须超过全网算力(几乎不可能)
📙 十二、Sybil Attack(女巫攻击)
定义: 攻击者创建大量虚假身份来控制系统。
例如:
- 制造 1 万个节点试图影响共识
- 在投票系统中伪造大量身份
区块链如何防御?
比特币:用 PoW 抗 Sybil
身份无意义 → 算力才是身份
你开 1000 个节点 = 你只是把自己的算力拆成 1000 份 = 总算力不变 = 没法影响共识
→ 这是 Nakamoto 共识的一个核心优势。
📌 总结(极简)
| 概念 | 问题 | 解决方式 |
|---|---|---|
| 发行权 | 谁能印钱? | Coinbase(矿工) |
| 双花 | 怎么防止一币两花? | PoW + 最长链 + UTXO |
| 交易验证 | 如何判断交易有效? | 签名 + UTXO + Script |
| 共识 | 全网怎么看法一致? | PoW(Nakamoto Consensus) |
| Sybil 攻击 | 虚假身份攻击 | 算力证明(PoW) |
| CAP | 分布式系统限制 | 区块链选择 AP |
课程内容总结
这一讲《04-BTC-协议》主要讲的是“比特币到底是如何运行的”,也就是点对点电子现金系统的完整协议:节点需要遵守哪些规则、交易和区块如何在网络中传播、如何达成“哪条链是正确的”这一共识等。
协议整体框架
课程先从“什么叫协议”入手,把比特币协议拆成几个部分:节点规则、交易规则、区块规则、共识规则和网络传播规则。 目标是让你明白:任何一个想加入比特币网络的节点,只要实现并遵守这些公开规则,就能和全网自动达成一致,而不需要信任中心机构。
节点与角色
老师会区分几类典型节点角色:全节点(保存完整区块链并严格验证所有交易和区块)、轻节点/SPV 节点(只保存区块头,通过 Merkle 证明验证交易)、矿工节点(在全节点基础上额外参与出块和挖矿)。 不同节点在协议中的职责不同,但在“验证规则”上是一致的:不论是谁发来的区块或交易,只要不满足协议规则,就直接丢弃。
- 全节点负责:校验每个交易的签名、余额是否足够、脚本是否执行通过,以及区块难度、时间戳、大小等是否符合规范。
- 轻节点通过向全节点请求区块头和 Merkle 路径,来验证“某交易是否被某区块确认”,不需要保存全部历史数据。
交易验证规则
课程详细说明比特币中的“合法交易”必须满足的条件,例如:所有输入都引用现有未花费输出、签名正确、没有双花、输入金额大于等于输出金额(差额为手续费)、脚本执行结果为真等。 这样任何节点只要收到一笔新交易,就能本地独立判断它是否有效,而不是靠别人说“这笔是对的”。
- 例子:如果某人试图用同一个 UTXO 同时给两个人转账,那么网络上最终只会有一笔交易成功进区块链,另一笔在后续验证中会被节点判定为“引用已花费输出”而被拒绝。
- 交易费的规则也在协议里写死:输入总额减去输出总额就是矿工可获得的手续费,若不符合(比如输出之和大于输入),节点会直接判定交易无效。
区块与共识规则
在区块层面,协议规定了:区块头字段必须符合格式、区块中所有交易都要逐笔验证、区块大小有上限、首笔交易必须是 coinbase 交易且奖励金额不能超过当前补贴加手续费之和、区块哈希必须小于当前难度目标等。 每个节点只要本地验证通过,才会接受该区块并接着在其上继续挖矿或同步,验证不过就丢弃,从而保证“错误区块无法扩散”。
- 协议还规定了“最长链(精确说是累计工作量最大链)原则”:当存在多条合法链分叉时,节点要选择累计难度最高的那条作为当前主链,将余额状态等都以这条链为准。
- 这意味着短期内可能会出现临时分叉,但随着后续新区块叠加,最终只有一条链会“赢”,另一条会被视为孤块链,被网络逐渐抛弃。
网络与消息传播
最后,课程讲解 P2P 网络层面的协议:节点如何发现其他节点、如何广播交易和区块、如何防止网络被垃圾消息淹没等。 比特币使用的是去中心化的点对点网络,每个节点与若干“邻居”保持连接,收到新交易或区块后会进行验证,再向其他邻居转发,从而实现全网扩散。
- 为了减少带宽浪费,协议设计了类似“先发哈希、再要正文”的消息交互方式:节点先告诉别人“我这里有哪些新对象的哈希”,对方只会请求自己缺少的那部分数据。
- 课程也会提醒:由于网络是开放的,任何节点都可能不诚实,所以协议的设计必须让“只相信自己能验证的结果”成为默认行为,这样整体系统才能在敌对环境中依然保持一致性和安全性。
Next.js 16 Page Router 国际化 🌐
Next.js 16 Page Router 国际化 🌐
引言
在现代 Web 应用开发中,国际化(i18n)已经成为一个必备功能。传统的 Next.js 国际化方案通常采用 URL 前缀方式(如 /en/page 或 /zh-CN/page),这种方式虽然实现简单,但存在一些明显的问题:
- URL 频繁变更:用户切换语言时,页面 URL 会发生变化
- SEO 分散:相同内容分散在不同 URL 下,影响搜索引擎优化
- 用户体验不佳:复制链接时需要考虑语言前缀
那么,有没有一种方式可以在不改变 URL 的情况下实现国际化呢?答案是肯定的!本文将详细介绍我在 Next.js 16 项目中实现的无 URL 变更的国际化方案,采用浏览器缓存 + Cookie 机制管理语言切换,保持 URL 稳定的同时提供流畅的国际化体验。
技术栈
| 技术 | 版本 | 用途 |
|---|---|---|
| Next.js | 16.0.7 | 前端框架 |
| React | 19.2.0 | UI 库 |
| TypeScript | 5.5.4 | 类型系统 |
| next-i18next | 15.4.3 | 国际化核心库 |
| react-i18next | 16.3.5 | React 国际化集成 |
| js-cookie | 3.0.5 | Cookie 管理 |
| ahooks | 3.9.6 | React Hooks 工具库 |
项目结构
src/
├── components/ # 组件目录
│ └── I18nLngSelector.tsx # 语言选择器组件
├── i18n/ # 国际化配置目录
│ ├── hooks/ # 自定义 Hooks
│ │ └── useI18n.ts # 语言切换钩子
│ ├── locales/ # 翻译资源文件
│ │ ├── en/ # 英文翻译
│ │ │ ├── common.json # 通用翻译
│ │ │ └── index_page.json # 首页翻译
│ │ └── zh-CN/ # 中文翻译
│ │ ├── common.json # 通用翻译
│ │ └── index_page.json # 首页翻译
│ ├── type.ts # TypeScript 类型定义
│ └── i18next.d.ts # 类型声明文件
└── pages/ # 页面目录
├── _app.tsx # 应用入口(语言初始化)
└── index.tsx # 首页
核心实现
1. next-i18next 配置
首先,我们需要配置 next-i18next,创建 next-i18next.config.js 文件:
// next-i18next.config.js
// @ts-check
/**
* @type {import('next-i18next').UserConfig}
*/
module.exports = {
// 开发环境下启用调试模式
debug: process.env.NODE_ENV === 'development',
// 国际化配置
i18n: {
// 默认语言
defaultLocale: 'zh-CN',
// 支持的语言列表
locales: ['zh-CN', 'en'],
// 禁用自动语言检测,使用自定义逻辑
localeDetection: false,
},
// 语言资源文件路径
localePath: './src/i18n/locales',
// 开发环境下在预渲染时重新加载语言资源
reloadOnPrerender: process.env.NODE_ENV === 'development',
}
配置说明:
-
debug: true:开发环境下启用调试模式,便于开发调试 -
defaultLocale: 'zh-CN':设置默认语言为中文 -
locales: ['zh-CN', 'en']:配置支持的语言列表 -
localeDetection: false:禁用自动语言检测,使用自定义的语言检测和切换逻辑 -
localePath: './src/i18n/locales':指定语言资源文件的存放路径
2. 自定义语言切换钩子
核心逻辑在于自定义的 useI18nLng 钩子,它负责处理语言的存储、切换和初始化:
// src/i18n/hooks/useI18n.ts
import { useTranslation } from 'next-i18next';
import { LangEnum } from '@/i18n/type';
import Cookies from "js-cookie";
// 语言存储的键名
const LANG_KEY = 'NEXT_LOCALE';
/**
* 检查当前是否在 iframe 中
*/
const isInIframe = () => {
try {
return window.self !== window.top;
} catch (e) {
return true; // 发生异常时默认认为在 iframe 中
}
};
/**
* 设置语言到存储中
*/
const setLang = (value: string) => {
if (isInIframe()) {
// 在 iframe 中只使用 localStorage
localStorage.setItem(LANG_KEY, value);
} else {
// 不在 iframe 中,同时使用 Cookie 和 localStorage
Cookies.set(LANG_KEY, value, { expires: 30 }); // Cookie 有效期30天
localStorage.setItem(LANG_KEY, value);
}
};
/**
* 从存储中获取语言
*/
const getLang = () => {
return localStorage.getItem(LANG_KEY) || Cookies.get(LANG_KEY);
};
/**
* 自定义 i18n 语言切换钩子
*/
export const useI18nLng = () => {
// 获取 i18n 实例
const { i18n } = useTranslation();
// 语言映射表,确保语言代码的一致性
const languageMap: Record<string, string> = {
'zh-CN': LangEnum.zh_CN,
en: LangEnum.en,
};
/**
* 切换语言的方法
*/
const onChangeLng = async (lng: string) => {
// 确保语言代码的正确性
const lang = languageMap[lng] || 'en';
const prevLang = getLang();
// 将语言保存到存储中
setLang(lang);
// 调用 i18n 实例切换语言
await i18n?.changeLanguage?.(lang);
// 如果没有资源包且语言发生了变化,则刷新页面
if (!i18n?.hasResourceBundle?.(lang, 'common') && prevLang !== lang) {
window?.location?.reload?.();
}
};
/**
* 设置用户默认语言
*/
const setUserDefaultLng = (forceGetDefaultLng: boolean = false) => {
// 确保在浏览器环境中运行
if (!navigator || !localStorage) return;
// 如果已经有存储的语言且不是强制获取,则使用存储的语言
if (getLang() && !forceGetDefaultLng) return onChangeLng(getLang() as string);
// 获取浏览器语言并映射到支持的语言
const lang = languageMap[navigator.language] || 'en';
// 切换到获取的语言
return onChangeLng(lang);
};
// 返回钩子方法
return {
onChangeLng, // 语言切换方法
setUserDefaultLng // 设置默认语言方法
};
};
核心亮点:
- 双重存储机制:同时使用 localStorage 和 Cookie 存储语言选择,确保在不同场景下都能正确获取
- iframe 兼容性:检测是否在 iframe 中运行,针对性处理存储方式
- 智能语言切换:切换语言时先检查是否有资源包,避免因资源缺失导致的错误
- 浏览器语言检测:首次访问时根据浏览器语言自动设置默认语言
3. 应用入口初始化
在 _app.tsx 中实现默认语言的初始化,确保页面刷新后能保持用户的语言选择:
// src/pages/_app.tsx
// 导入应用组件类型定义
import type { AppProps } from 'next/app'
// 导入 i18n 应用包装组件
import { appWithTranslation } from 'next-i18next'
// 导入自定义的 i18n 语言钩子
import { useI18nLng } from '@/i18n/hooks/useI18n'
// 导入 React 副作用钩子
import { useEffect } from 'react'
/**
* 主应用组件,所有页面的容器组件
* @param Component 当前渲染的页面组件
* @param pageProps 页面属性和初始数据
*/
const MyApp = ({ Component, pageProps }: AppProps) => {
// 获取设置默认语言的方法
const { setUserDefaultLng } = useI18nLng()
// 组件挂载时设置默认语言
useEffect(() => {
setUserDefaultLng()
}, [])
// 渲染当前页面组件
return <Component {...pageProps} />
}
// 使用 i18n 包装应用组件,提供国际化功能
export default appWithTranslation(MyApp)
初始化流程:
- 应用启动时,组件挂载
- 调用
setUserDefaultLng()方法 - 检查是否有存储的语言设置
- 如果有,使用存储的语言;如果没有,根据浏览器语言设置默认语言
- 确保用户每次访问时都能看到自己选择的语言
4. 语言选择器组件
创建一个语言选择器组件,让用户可以方便地切换语言:
// src/components/I18nLngSelector.tsx
// 导入自定义的 i18n 语言钩子
import { useI18nLng } from '@/i18n/hooks/useI18n';
// 导入 i18n 翻译钩子
import { useTranslation } from 'next-i18next';
// 导入 React 记忆化钩子
import { useMemo } from 'react';
// 导入语言映射表
import { langMap } from '@/i18n/type';
/**
* 语言选择器组件
* 提供UI界面让用户切换应用语言
*/
const I18nLngSelector = () => {
// 获取 i18n 实例
const { i18n } = useTranslation();
// 获取语言切换方法
const { onChangeLng } = useI18nLng();
// 记忆化处理语言列表,避免重复计算
const list = useMemo(() => {
return Object.entries(langMap).map(([key, lang]) => ({
label: lang.label, // 显示标签
value: key // 语言代码值
}));
}, []);
return (
// 语言选择下拉框
<select
value={i18n.language} // 当前选中的语言
onChange={(e) => onChangeLng(e.target.value)} // 语言变更处理
>
{/* 渲染语言选项列表 */}
{list.map((item) => (
<option key={item.value} value={item.value}>
{item.label}
</option>
))}
</select>
);
};
// 导出语言选择器组件
export default I18nLngSelector;
组件特点:
- 使用原生 select 元素,简洁高效
- 绑定当前语言状态,确保 UI 与实际语言一致
- 调用自定义的 onChangeLng 方法处理语言切换
- 支持多语言显示语言选项
5. 类型定义
为了提供更好的类型安全,我们需要定义相关的 TypeScript 类型:
// src/i18n/type.ts
// 导入语言资源文件
import { resources } from "./resources";
/**
* 国际化字符串类型定义
* 要求必须提供中文,英文为可选
*/
export type I18nStringType = {
'zh-CN': string; // 中文简体
en?: string; // 英文(可选)
};
/**
* 语言枚举类型
* 定义支持的语言代码
*/
export enum LangEnum {
'zh_CN' = 'zh-CN', // 中文简体
'en' = 'en' // 英文
}
/**
* 语言类型,基于LangEnum的字符串类型
*/
export type localeType = `${LangEnum}`;
/**
* 支持的语言列表常量
*/
export const LocaleList = ['en', 'zh-CN'] as const;
/**
* 语言映射表,用于UI显示
*/
export const langMap = {
[LangEnum.en]: {
label: 'English(US)', // 英文显示名称
},
[LangEnum.zh_CN]: {
label: '简体中文', // 中文显示名称
}
};
/**
* 国际化命名空间类型,基于resources的类型
*/
export type I18nNamespaces = typeof resources;
/**
* 国际化命名空间数组类型
*/
export type I18nNsType = (keyof I18nNamespaces)[];
类型安全优势:
- 避免拼写错误:使用枚举和类型定义确保语言代码的正确性
- 智能提示:在使用翻译键时提供自动补全
- 类型检查:在编译时就能发现翻译资源的错误使用
翻译资源文件示例
中文翻译
// src/i18n/locales/zh-CN/common.json
{
"change-locale": "切换到 \"{{changeTo}}\" 语言",
"welcome": "欢迎使用 Next.js 国际化方案"
}
英文翻译
// src/i18n/locales/en/common.json
{
"change-locale": "Change locale to \"{{changeTo}}\"",
"welcome": "Welcome to Next.js i18n Solution"
}
首页翻译资源
// src/i18n/locales/zh-CN/index_page.json
{
"title": "next-i18next 示例"
}
// src/i18n/locales/en/index_page.json
{
"title": "next-i18next example"
}
翻译资源管理
为了更好地管理翻译资源,我们可以创建一个 resources.ts 文件来集中导入和导出所有翻译资源:
// src/i18n/resources.ts
// 导入英文的通用语言资源
import common from './locales/en/common.json';
// 导入英文的首页语言资源
import indexPage from "./locales/en/index_page.json";
/**
* 语言资源导出
* 定义应用中使用的所有国际化命名空间
*/
export const resources = {
common, // 通用语言资源
'index_page': indexPage, // 首页语言资源
} as const;
类型定义增强
为了提供更好的 TypeScript 类型支持,我们可以创建 i18next.d.ts 文件来扩展 i18next 的类型定义:
// src/i18n/i18next.d.ts
/**
* If you want to enable locale keys typechecking and enhance IDE experience.
*
* Requires `resolveJsonModule:true` in your tsconfig.json.
*
* @link https://www.i18next.com/overview/typescript
*/
import 'i18next'
// resources.ts file is generated with `npm run toc`
import { I18nNamespaces } from './type'
declare module 'i18next' {
interface CustomTypeOptions {
defaultNS: 'common'
resources: I18nNamespaces
}
}
开发体验优化:i18n-ally 插件
为了提升国际化开发体验,我们可以使用 i18n-ally 插件,它提供了实时翻译预览、自动补全、错误检查等功能。
在 .vscode/settings.json 中配置:
{
"i18n-ally.localesPaths": "src/i18n/locales",
"i18n-ally.enableNamespace": true,
"i18n-ally.pathMatcher": "{locale}/{namespace}.json"
}
插件优势:
- 实时预览:在编辑器中直接看到翻译结果
- 自动补全:输入翻译键时提供智能提示
- 错误检查:检测缺失的翻译键和格式错误
- 批量操作:方便地管理和同步翻译资源
国际化功能使用指南
1. 在页面中使用翻译
在 Next.js 页面中,我们可以使用 useTranslation 钩子来获取翻译函数:
// src/pages/index.tsx
import { useTranslation } from 'next-i18next'
export default function Page() {
// 获取翻译函数
const { t } = useTranslation()
return (
<>
{/* 使用翻译 */}
<h1>{t('welcome')}</h1>
</>
)
}
2. 多命名空间处理
对于大型项目,我们可以使用多个命名空间来组织翻译资源。例如,首页使用 index_page 命名空间:
// src/pages/index.tsx
import { useTranslation } from 'next-i18next'
export default function Page() {
// 获取翻译函数
const { t } = useTranslation()
return (
<>
{/* 使用index_page命名空间的翻译 */}
<h1>{t('title', { ns: 'index_page' })}</h1>
</>
)
}
3. 服务端翻译属性获取
为了确保服务端渲染时能正确获取翻译资源,我们需要在页面中定义 getStaticProps 或 getServerSideProps 函数:
// src/pages/index.tsx
// 导入静态属性类型定义
import { GetStaticProps } from 'next'
// 导入自定义的服务端翻译属性获取函数
import { serviceSideProps } from '@/i18n/utils'
export const getStaticProps: GetStaticProps = async (context) => ({
props: {
// 获取服务端翻译属性,包含common和index_page命名空间
...(await serviceSideProps(context, ['common', 'index_page'])),
},
})
服务端翻译工具函数实现:
// src/i18n/utils.ts
// 导入国际化命名空间类型
import { type I18nNsType } from '@/i18n/type';
// 导入服务端翻译函数
import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
/**
* 获取服务端翻译属性的自定义函数
* @param content 上下文对象,包含请求、响应等信息
* @param ns 需要加载的国际化命名空间数组
* @returns 包含翻译资源的属性对象
*/
export const serviceSideProps = async (content: any, ns: I18nNsType = []) => {
// 从 Cookie 或上下文获取当前语言
const lang = content.req?.cookies?.NEXT_LOCALE || content.locale;
// 如果有 Cookie 中的语言,则不需要额外语言,否则使用上下文中的所有语言
const extraLng = content.req?.cookies?.NEXT_LOCALE ? undefined : content.locales;
// 从 Cookie 获取设备尺寸信息
const deviceSize = content.req?.cookies?.NEXT_DEVICE_SIZE || null;
return {
// 获取服务端翻译资源,默认包含 common 命名空间
...(await serverSideTranslations(lang, ['common', ...ns], undefined, extraLng)),
// 传递设备尺寸信息
deviceSize
};
};
4. 变量插值的使用
翻译资源支持变量插值,我们可以在翻译字符串中使用占位符:
// 翻译资源文件
{
"change-locale": "切换到 \"{{changeTo}}\" 语言"
}
在组件中使用:
const { t } = useTranslation()
// 带变量的翻译调用
<p>{t('common:change-locale', { changeTo: 'English' })}</p>
5. 完整使用示例
// src/pages/index.tsx
// 导入语言选择器组件
import I18nLngSelector from '@/components/I18nLngSelector'
// 导入自定义的服务端翻译属性获取函数
import { serviceSideProps } from '@/i18n/utils'
// 导入静态属性类型定义
import { GetStaticProps } from 'next'
// 导入翻译钩子
import { useTranslation } from 'next-i18next'
/**
* 首页组件
*/
export default function Page() {
// 获取翻译函数
const { t } = useTranslation()
return (
<>
{/* 翻译后的标题,使用index_page命名空间 */}
<h1>{t('title', { ns: 'index_page' })}</h1>
{/* 语言选择器 */}
<I18nLngSelector />
</>
)
}
/**
* 静态属性生成函数
* 用于在构建时获取翻译资源
*/
export const getStaticProps: GetStaticProps = async (context) => ({
props: {
// 获取服务端翻译属性,包含common和index_page命名空间
...(await serviceSideProps(context, ['common', 'index_page'])),
},
})
实现原理总结
1. 语言存储机制
采用浏览器缓存 + Cookie的双重存储机制:
- localStorage:用于客户端持久化存储用户的语言选择
- Cookie:用于服务端渲染时获取用户的语言偏好
2. 语言切换流程
- 用户点击语言选择器
- 调用
onChangeLng方法 - 将选择的语言保存到 localStorage 和 Cookie 中
- 调用 i18n 实例的
changeLanguage方法切换语言 - 检查是否有对应的语言资源包
- 如果没有资源包且语言发生了变化,则刷新页面确保资源加载
3. 默认语言设置
- 应用启动时,调用
setUserDefaultLng方法 - 检查是否有存储的语言设置
- 如果有,使用存储的语言
- 如果没有,根据浏览器语言自动设置默认语言
- 确保用户每次访问时都能看到一致的语言界面
注意事项和最佳实践
-
Cookie 依赖:确保服务器环境支持 Cookie,以便在服务端渲染时获取用户的语言偏好
-
服务端渲染:在服务端渲染时,需要从 Cookie 中获取语言设置,确保首次渲染的语言正确
-
缓存策略:注意语言切换后的缓存处理,避免出现缓存导致的语言不一致问题
-
多命名空间管理:对于大型项目,建议使用多命名空间管理翻译资源,提高可维护性
-
类型安全:充分利用 TypeScript 的类型系统,确保翻译资源的正确使用
-
开发工具:使用 i18n-ally 等工具提升开发体验,减少手动编写翻译的错误
总结和展望
本文详细介绍了在 Next.js 16 项目中实现无 URL 变更的国际化方案,主要包括:
-
核心实现:使用 next-i18next 作为基础,自定义语言切换钩子处理语言存储和切换逻辑
-
创新点:采用浏览器缓存 + Cookie 机制管理语言切换,不需要 URL 前缀,保持 URL 稳定
-
用户体验:实现了语言设置的持久化,确保页面刷新后不会丢失用户的语言选择
-
开发体验:使用 TypeScript 提供类型安全,结合 i18n-ally 插件提升开发效率
这个国际化方案解决了传统 URL 前缀方式的问题,提供了更好的用户体验和 SEO 效果。未来可以考虑:
- 支持更多语言的动态加载
- 实现翻译资源的自动同步和管理
- 提供更多的语言切换动画和交互效果
希望本文的实现方案能够帮助到正在寻找 Next.js 国际化解决方案的开发者们,也欢迎大家提出宝贵的意见和建议!
项目地址
如果觉得这篇文章对你有帮助,欢迎点赞、评论和分享!👍
#Next.js #国际化 #i18n #前端开发 #TypeScript
前端文本分割工具,“他”来了
大家好,我是CC,在这里欢迎大家的到来~
简介
在日常开发场景中大多数是使用空字符串、空格或者换行符来进行文本分割。现在可以试试新的分割工具-Intl.Segmenter。
Intl.Segmenter 支持根据语言进行的文本分割,将一个字符串分割成片段,分割类型包括字、词和句。
试试分割效果
以简体中文为例,这里我们先设置按照词(word)分割:
按词分割-多语言分词
const segmenter = new Intl.Segmenter("zh-Hans-CN", { granularity: "word" });
const string = "前端文本分割工具,“他”来了";
const iterator = segmenter.segment(string)[Symbol.iterator]();
for(let item of iterator) {
console.log(item.segment);
}
// 前端
// 文本
// 分割
// 工具
// ,
// “
// 他
// ”
// 来
// 了
效果还不错,有点类似词义分割了。再看看其他分割方式。
按字分割(默认)-处理复杂字符
在简体中文场景下效果与'前端文本分割工具,“他”来了'.split("")相同。
const segmenter = new Intl.Segmenter("zh-Hans-CN", { granularity: "grapheme" });
const string = "前端文本分割工具,“他”来了";
const iterator = segmenter.segment(string)[Symbol.iterator]();
for(let item of iterator) {
console.log(item.segment);
}
// 前
// 端
// 文
// 本
// 分
// 割
// 工
// 具
// ,
// “
// 他
// ”
// 来
// 了
按句分割-多语言句子分析
加了分号、感叹号、问号、句号这些标点符号,还是可以完整分割出来的。
const segmenter = new Intl.Segmenter("zh-Hans-CN", { granularity: "sentence" });
const string = "前端文本分割工具,“他”来了,前端文本分割工具,“他”来了;前端文本分割工具,“他”来了。前端文本分割工具,“他”来了,前端文本分割工具,“他”来了;前端文本分割工具,“他”来了!前端文本分割工具,“他”来了,前端文本分割工具,“他”来了;前端文本分割工具,“他”来了?";
const iterator = segmenter.segment(string)[Symbol.iterator]();
for(let item of iterator) {
console.log(item.segment);
}
// 前端文本分割工具,“他”来了,前端文本分割工具,“他”来了;前端文本分割工具,“他”来了。
// 前端文本分割工具,“他”来了,前端文本分割工具,“他”来了;前端文本分割工具,“他”来了!
// 前端文本分割工具,“他”来了,前端文本分割工具,“他”来了;前端文本分割工具,“他”来了?
完整数据格式
实际上分割出来的每个单位都是一个 JSON 对象,除了 segment 代表文本内容外,还有 index 代表索引、input 表示原文本完整内容、isWordLike 表示是否像文本单词(如果是标点符号类型的就会是 false)。
{
"segment": "前端",
"index": 0,
"input": "前端文本分割工具,“他”来了",
"isWordLike": true
}
分割的参数
locales
当然除了简体中文,Intl.Segmenter 也支持其他语言。
Intl.Segmenter 的第一个参数 locales 支持填写带有 BCP 47 语言区域标记的一个字符串,或者一个这样的字符串数组。
在 BCP 47 中表示语言、脚本、国家(区域)和变体(少用)的语言子标记含义可以在IANA 语言子标记注册 中找到。
localeMatcher
Intl.Segmenter 的第二个参数中除了可以选择如何分割外,还可以根据 ****locales ****在 ****lookup ****和 ****best fit ****之间选择一个匹配算法来配置 localeMatcher 参数。
best fit
默认值,运行时可能会选择一个可能比查找算法的结果更加合适的语言区域。
lookup
使用 BCP47 查找算法从 locales 参数中选择语言区域。像如果运行时支持 "de" 但不支持 "de-CH",用户传入的 "de-CH" 可能就会以 "de" 为结果进行使用。
分割对象的方法
分割字符串
上文中对分割文本对象进行分割的 segment 方法。
判断返回支持的 locale
在给定的 locales 数组中判断出 Segmenter支持的 locales。但是可能每个浏览器支持的不大一样。
const locales = ["ban", "id-u-co-pinyin", "de-ID"];
const options = { localeMatcher: "lookup" };
console.log(Intl.Segmenter.supportedLocalesOf(locales, options));
// ["id-u-co-pinyin", "de-ID"]
获取分割对象的配置参数
const spanishSegmenter = new Intl.Segmenter("es", { granularity: "sentence" });
const options = spanishSegmenter.resolvedOptions();
console.log(options.locale); // "es"
console.log(options.granularity); // "sentence"
在参数不支持的情况下会取当前运行环境中默认语言环境。
const banSegmenter = new Intl.Segmenter("ban");
const options = banSegmenter.resolvedOptions();
console.log(options.locale); // "zh-CN"
console.log(options.granularity); // "grapheme"
总结
Intl.Segmenter 很适合在文本处理场景下使用,给了一定的分割标准。它在文本编辑器中计算光标索引、搜索建议的生成、文本计算长度和文本过滤,甚至在自然语言处理场景中都可以使用到。
目前在 fabric.js 中也在考虑使用 Intl.Segmenter 来优化文本分割,可以研究学习。
大家可能也注意到了,Segmenter 只是 Intl 下的一个对象,还有针对像数字、复数、日期、地区等国际转化,这些后续去研究。
nvm安装node低版本失败-解决方案
最近接手了一个新的前端项目需要进行二开,结果发现前端使用的node版本比较高,跟我系统安装的node版本不一致。需要安装node v20.x 以上版本才可以正常运行。但是我本地很多前端项目 比较老使用的node版本都是V14.X。为了解决这个问题在网上查找到nvm 版本管理工具,可以解决灵活切换版本的问题。于是就卸载了本地的nodejs,安装了nvm版本管理工具。于是开始了踩坑之路。 安装nvm很容易,网上一搜有大把的教程,注意不要有空格,最好是下划线也不要有吧(这个不太确定是否会影响)。安装好后,很容易的安装了node v20.X版本,可以顺利的运行新接手的前端项目了。但是很快遇到一个问题,我在运行自己的老项目的时候,需要安装node V14.X版本,让后经过不断的尝试,最低只能安装到V16.20.2版本,低于这个版本号的node就无法安装了,有的也可以安装node但是npm又会遇到问题。问题截图:
![]()
C:\Users\Administrator>nvm install 14.18.0
Downloading node.js version 14.18.0 (64-bit)...
Complete
Downloading npm...
Creating C:\Users\Administrator\AppData\Local\Temp\nvm-install-3583324651\temp
Downloading npm version 6.14.15... Complete
Installing npm v6.14.15...
error installing 14.18.0: open C:\Users\Administrator\AppData\Local\Temp\nvm-npm-4192048246\npm-v6.14.15.zip: The system cannot find the file specified.
根据这个错误提示查了很久,给出以下两个解决方案:
方案一:卸载1.2.X版本nvm安装1.1.0nvm
在谷歌的强力搜索下,发现很多ITer反馈,nvm1.2.X版本有bug,nvm开发者也确认了这是个bug,但是下个版本才会修复。也就是说解决办法是: 彻底卸载删除nvm 1.2.X版本,安装老的版本,网上搜索结果说最少要推倒nvm的V1.2.0版本才会解决低版本node无法安装的bug。
我是第一次使用nvm,让后去查怎么彻底卸载,需要删除环境变量,删除目录,删除……感觉一堆删除很麻烦,懒癌瞬间发作,不想卸载重新安装,就冒着浪费时间丢掉效率的风险继续搜索解决办法。
方案二: 手动安装对应版本node
第一步:访问node版本仓库:Index of /download/release/下载你需要的版本node压缩包。网址(https://nodejs.org/download/release/)
![]()
第二步:下载需要的node版本压缩包(node-v14.17.0-win-x64.zip )
![]()
第三步:把下载的node node-v14.17.0-win-x64.zip 压缩包文件全部解压到nvm的安装目录,注意要新建一个V14.17.0文件夹喔!!!!
![]()
经过以上步骤,低版本node安装不了的问题就解决了。实际测试是可以正常运行项目的。同理,其他低版本无法使用nvm install 12.X等都可以通过这个办法安装。
以上就是经过大半天索索出来的解决办法。当然有参考网上的资料,把这些资料整合到一起,写下这篇文章却是我的原创啊。转载请注明出处。
iOS 手机无法播放视频问题排查与解决方案记录
iOS 手机无法播放视频问题排查与解决方案记录
最近遇到一个视频在 iOS 移动端无法播放 的问题,这里记录一下排查过程与最终解决方案,希望能帮到有类似困扰的同学。
问题现象
- PC(浏览器)模拟移动端可正常播放
- 安卓手机打开正常
- ❌ iOS 真机无法播放
排查过程
通过对比:
- 可正常播放的视频
- 无法播放的问题视频
使用 MediaInfo 分析媒体信息后,找到问题根源。
媒体信息在线分析工具:
👉 mediaarea.net/MediaInfoOn…
(下方为对比截图)
![]()
![]()
最终找到的原因
![]()
![]()
iOS 对视频编码的兼容性要求更严格,问题视频使用了 隔行扫描(Interlaced) 。
而 iOS Safari / WebView 对 interlaced 视频支持较差甚至不支持。
✔ 正常视频:Progressive(逐行扫描)
❌ 问题视频:Interlaced(隔行扫描)
🎯 核心原因:iOS 不兼容 Interlaced 扫描格式的视频。
解决方案:转为 Progressive(逐行扫描)
使用 FFmpeg 将 Scan type 转为 Progressive,并确保编码参数为 iOS 友好格式。
FFmpeg 转换命令
ffmpeg -i "有问题的视频地址" \
-vf "yadif=1:-1:0,format=yuv420p" \
-c:v libx264 -profile:v main -level 4.0 \
-movflags +faststart \
-c:a aac -b:a 128k \
"输出地址"
参数说明
| 参数 | 作用 |
|---|---|
yadif |
去隔行,转成 progressive |
format=yuv420p |
iOS 最佳兼容色彩格式 |
libx264 + profile main
|
兼容性最好的视频编码设置 |
-movflags +faststart |
优化 MP4 流式加载,提升移动端体验 |
aac |
iOS 完全兼容的音频编码 |
总结
iOS 视频播放问题常见原因之一就是编码兼容性问题,特别是 Scan type 不正确。
大文件上传实战:基于Express、分片、Web Worker与压缩的完整方案
我是大鱼,陪你一起在前端技术深海前行。
本文将详细介绍大文件上传的全链路优化方案,结合前端分片、Web Worker多线程处理、文件压缩以及Express后端实现,解决传统大文件上传中的网络波动、服务器压力与用户体验差等核心痛点。
一、核心技术方案设计
大文件上传的优化主要围绕分片上传、断点续传和秒传三大机制展开。本方案在此基础上,引入Web Worker进行前端并行计算与文件压缩,整体架构如下:
- 前端:采用分片(5MB/片)压缩后,通过Web Worker计算哈希,实现并发上传与进度反馈。
- 后端:使用Express.js + Multer处理分片,支持断点续传与文件合并。
- 传输优化:通过压缩算法(如gzip)减少传输体积,提升网络利用率。
方案优势对比
| 传统方案 | 本优化方案 |
|---|---|
| 单线程上传,易阻塞主线程 | Web Worker多线程处理,不阻塞UI |
| 网络中断需重传整个文件 | 分片上传 + 断点续传,仅重传失败分片 |
| 无压缩,传输效率低 | 前端压缩 + 分片,减少带宽占用 |
| 服务器直接处理大文件流 | 分片减轻服务器单次压力 |
二、前端实现:分片、压缩与Worker多线程
前端流程包括文件分片、压缩处理、哈希计算和并发上传,关键步骤如下:
1. 文件分片与压缩
使用File.slice()进行分片,并对每个分片应用压缩(示例使用gzip)。分片大小建议5MB,平衡网络开销与并发效率。
// 文件分片与压缩函数
async function chunkAndCompressFile(file, chunkSize = 5 * 1024 * 1024) {
const chunks = [];
for (let i = 0; i < file.size; i += chunkSize) {
const chunk = file.slice(i, i + chunkSize);
// 使用Compression Streams API进行压缩(现代浏览器支持)
const compressedStream = chunk.stream().pipeThrough(new CompressionStream('gzip'));
const compressedChunk = await new Response(compressedStream).blob();
chunks.push({
index: i / chunkSize,
file: compressedChunk,
originalSize: chunk.size,
compressedSize: compressedChunk.size
});
}
return chunks;
}
2. Web Worker计算分片哈希
使用file-chunk-worker库在Worker中并行计算分片MD5,避免主线程阻塞,同时支持进度反馈。
// 在主线程中调用Worker处理文件
import FileProcessor from 'file-chunk-worker';
async function processFileWithWorker(file) {
const processor = new FileProcessor(file, {
chunkSize: 5 * 1024 * 1024,
threadCount: 4 // 启用4个Worker线程
});
const chunks = await processor.calculateMd5((progress) => {
console.log(`处理进度: ${(progress * 100).toFixed(2)}%`);
});
// chunks包含每个分片的hash、索引和Blob数据
return chunks.map(chunk => ({
...chunk,
hash: chunk.hash // 用于秒传校验
}));
}
3. 并发上传与断点续传
上传前先查询服务器已上传分片,实现断点续传。使用@yuan-toolkit/chunk-uploader或bigfile-chunk-uploader库管理并发、重试和进度。
// 配置化的分片上传(基于bigfile-chunk-uploader)
import { BigFileUploader } from 'bigfile-chunk-uploader';
async function uploadFile(file) {
// 1. 分片并计算哈希
const processedChunks = await processFileWithWorker(file);
// 2. 初始化上传,获取fileId(用于断点续传)
const fileId = await axios.post('/upload/init', {
fileName: file.name,
fileHash: processedChunks.overallHash // 整体文件哈希,用于秒传
}).data.uploadId;
// 3. 检查已上传分片
const { uploadedChunks } = await axios.get(`/upload/status?fileId=${fileId}`);
// 4. 并发上传未完成分片
const uploader = new BigFileUploader({
file,
baseURL: 'http://api.example.com',
endpoints: { chunk: '/upload/chunk', merge: '/upload/merge' },
chunkSize: 5 * 1024 * 1024,
concurrent: 3, // 控制并发数
maxRetries: 3, // 失败自动重试
onProgress: (progress) => {
console.log(`上传进度: ${progress}%`); // 实时反馈
}
});
await uploader.start();
// 5. 所有分片完成后,请求合并
await axios.post('/upload/merge', { fileId });
}
关键优化点:
-
并发控制:浏览器同域并发限制约为6,可通过多子域(如
upload1.example.com)提升速度。 - 进度反馈:结合分片进度与压缩进度,提供精确的百分比反馈。
- 错误重试:网络失败时自动重试特定分片,增强鲁棒性。
三、后端实现:Express.js分片接收与合并
后端使用Express + Multer处理分片,并实现断点续传逻辑。
1. 环境搭建与Multer配置
const express = require('express');
const multer = require('multer');
const fs = require('fs-extra');
const path = require('path');
const app = express();
// 配置Multer存储分片
const storage = multer.diskStorage({
destination: 'uploads/temp/', // 分片临时目录
filename: (req, file, cb) => {
const { fileId, chunkIndex } = req.body;
cb(null, `${fileId}-${chunkIndex}.chunk`); // 按fileId和索引命名
}
});
const upload = multer({ storage });
app.use(express.json());
2. 分片上传接口
// 1. 初始化上传(生成fileId,支持秒传)
app.post('/upload/init', (req, res) => {
const { fileName, fileHash } = req.body;
const fileId = generateFileId(); // 生成唯一ID
// 秒传检查:如果文件哈希已存在,直接返回成功
if (checkFileExists(fileHash)) {
return res.json({ uploaded: true, url: getFileUrl(fileHash) });
}
// 记录上传状态(用于断点续传)
saveUploadStatus(fileId, { fileName, fileHash, uploadedChunks: [] });
res.json({ uploadId: fileId });
});
// 2. 分片上传接口
app.post('/upload/chunk', upload.single('chunk'), (req, res) => {
const { fileId, chunkIndex, chunkHash } = req.body;
// 验证分片哈希(防止数据损坏)
if (validateChunkHash(req.file, chunkHash)) {
// 记录已上传分片索引
updateUploadStatus(fileId, chunkIndex);
res.json({ success: true });
} else {
res.status(400).json({ error: '分片校验失败' });
}
});
// 3. 查询上传进度(用于断点续传)
app.get('/upload/status', (req, res) => {
const { fileId } = req.query;
const status = getUploadStatus(fileId);
res.json({ uploadedChunks: status?.uploadedChunks || [] });
});
3. 分片合并接口
所有分片上传完成后,按索引顺序合并。
app.post('/upload/merge', async (req, res) => {
const { fileId } = req.body;
const { fileName, fileHash } = getUploadStatus(fileId);
const chunkDir = 'uploads/temp/';
const chunks = fs.readdirSync(chunkDir)
.filter(f => f.startsWith(fileId))
.sort((a, b) => parseInt(a.split('-')[1]) - parseInt(b.split('-')[1]));
// 顺序合并分片
const finalPath = `uploads/final/${fileId}-${fileName}`;
const writeStream = fs.createWriteStream(finalPath);
for (const chunk of chunks) {
const chunkPath = path.join(chunkDir, chunk);
await new Promise((resolve) => {
const readStream = fs.createReadStream(chunkPath);
readStream.pipe(writeStream, { end: false });
readStream.on('end', () => {
fs.unlinkSync(chunkPath); // 删除临时分片
resolve();
});
});
}
writeStream.end();
// 文件完整性校验
if (await calculateFileHash(finalPath) === fileHash) {
saveFileRecord(fileHash, finalPath); // 存储记录供秒传使用
res.json({ url: `/files/${fileId}`, size: fs.statSync(finalPath).size });
} else {
res.status(500).json({ error: '文件合并失败' });
}
});
关键优化点:
-
断点续传:通过
uploadedChunks记录避免重复上传。 - 秒传:基于文件哈希判断文件是否存在。
- 资源管理:合并后清理临时分片,定期清理过期上传记录。
四、高级优化策略
-
压缩算法选择:
前端压缩可选用gzip(浏览器原生支持)或brotli(压缩率更高)。实测中,对文本/JSON数据压缩率可达60%-70%。注意:已压缩文件(如ZIP、视频)二次压缩收益有限,可前端检测文件类型动态启用压缩。
-
Web Worker动态调优:
Worker数量建议设为navigator.hardwareConcurrency - 1(保留一个核心给UI)。过大文件(>1GB)可分阶段处理,避免内存溢出。 -
传输安全与完整性:
- 使用HTTPS加密传输。
- 每个分片携带
Content-MD5头,后端校验。 - 最终文件哈希(如SHA-256)比对,防止合并错误。
-
用户体验增强:
- 实时显示速度、剩余时间与压缩率。
- 暂停/恢复功能(利用
uploader.pause()/resume())。 - 网络中断后自动检测并续传。
五、完整工作流程示例
以下为一个视频文件(2GB)的上传流程:
-
前端准备:
文件→分片(5MB/片,共约400片)→Web Worker计算分片哈希(4线程并行)→gzip压缩(体积减少约40%)。 -
上传过程:
- 初始化获取
fileId;2) 查询已上传分片(首次为空);3) 并发上传分片(3个并发);4) 实时进度显示。
- 初始化获取
-
后端处理:
- 接收分片并存储;2) 记录上传状态;3) 合并分片并校验;4) 返回文件URL。
六、总结
本文方案使用分片上传、Web Worker多线程处理、前端压缩与Express后端支持,系统性地解决了大文件上传的稳定性、效率与用户体验问题。
实际应用中,可根据文件类型调整分片大小(如图片用2MB,视频用5MB),并监控服务器负载以优化并发参数。
git提交代码失败?本地代码被清空了?git代码丢了怎么办?三步帮你找回来
只要你add.也就是你提交暂存更改,git就会留下操作记录git给你的代码留下快照,那么你就能通过git命令帮你找回来你丢失的代码
文件被
git reset取消暂存,且工作区文件可能被误删 / 覆盖,但 Git 仍保留着这些文件的 “暂存记录”,可以通过 Git 底层对象恢复。以下是针对性的恢复步骤,分 “快速找回” 和 “深度恢复” 两种方案,确保能找回所有丢失的代码
用 git reflog 恢复最近的修改(100% 覆盖你丢失的代码)
-
查看所有 Git 操作历史(包括
add/reset/commit,能看到你之前的修改记录):
用 git reflog 恢复最近的修改(100% 覆盖你丢失的代码)
![]()
输出示例(重点看时间和操作,找 实际操作的时间例如 17:33 左右的记录):
d02c1bc (HEAD) HEAD@{0}: reset: ...
abc1234 HEAD@{1}: add: .eslintrc-auto-import.json ... # 你执行 git add 的记录
def4567 HEAD@{2}: commit: xxx
![]()
-
查看备份内容(确认备份包含你的修改):
# 查看 stash 备份的详细信息(确认是你丢失的文件) git stash show -p XXXXX- 执行后会输出备份中的所有文件修改内容,你会看到
xxx.vue、xxx.ts等文件的代码,说明备份有效。
- 执行后会输出备份中的所有文件修改内容,你会看到
这里会出现显示不完全的情况,没找到你的文件不要慌张,从
git stash show输出看,当前显示的只是部分的修改,不是完整的备份内容(因为输出被截断了,末尾有:...skipping...)。你的核心代码(xxx.vue、xxx.ts等)其实还在这个 stash 备份里,只是没显示全!
- 现在直接执行:恢复完整的 stash 备份(不用管部分显示)
不用纠结显示的内容,直接应用整个 stash 备份,所有文件都会恢复:
# 强制应用 stash 备份,覆盖当前工作区(确保所有修改都回来)
git stash pop xxxxxx
备用方案
-
打开 Git Bash(在项目文件夹空白处右键 → Git Bash Here,直接进入项目根目录);
-
把脚本 逐行复制粘贴到 Git Bash 中,按回车执行(不需要保存为文件,直接粘贴运行):
mkdir -p ./recovered_files cd .git/objects || exit for dir in [0-9a-f][0-9a-f]; do for file in "$dir"/*; do if [ -f "$file" ]; then hash="$dir${file#$dir/}" type=$(git cat-file -t "$hash" 2>/dev/null) if [ "$type" = "blob" ]; then git cat-file -p "$hash" > "../../recovered_files/$hash" if git cat-file -p "$hash" 2>/dev/null | grep -q "<template"; then mv "../../recovered_files/$hash" "../../recovered_files/$hash.vue" elif git cat-file -p "$hash" 2>/dev/null | grep -q "export default"; then mv "../../recovered_files/$hash" "../../recovered_files/$hash.ts" elif git cat-file -p "$hash" 2>/dev/null | grep -q "{""; then mv "../../recovered_files/$hash" "../../recovered_files/$hash.json" fi fi fi done done cd ../.. || exit echo "所有文件快照已导出到 ./recovered_files 目录" -
执行后,会在项目根目录生成
recovered_files文件夹,里面就是所有 Git 缓存的文件快照。
- 行后,打开
./recovered_files目录,里面会有所有 Git 缓存的文件快照; - 按文件类型(
.vue、.ts)筛选,找到你丢失的文件(如xxx.vue、xxxx.ts),复制回项目对应的目录即可。
2. 精准筛选丢失的文件
如果 recovered_files 文件太多,用关键词搜索快速定位:
# 搜索包含 "xxxxx" 关键词的文件(Vue 组件)
grep -r "xxxxxx" ./recovered_files --include="*.vue"
# 搜索包含 "xxxx" 关键词的文件(TS 接口/API)
grep -r "xxxxxx" ./recovered_files --include="*.ts"
# 搜索包含 "xxxxxxxx" json文件
grep -r "xxxxxx" ./recovered_files --include="*.json"
- 找到后,复制文件到项目原路径,代码直接恢复。
四、为什么文件会 “不在暂存区”?(日志复盘)
从日志看,你在 git add 后执行了多次 git reset 命令,导致部分文件被取消暂存:
- 这些
reset操作仅取消 “暂存状态”,但默认不会删除工作区文件。如果工作区文件也丢了,可能是后续误删或 IDE 同步问题,但 Git 缓存的快照还在,通过上面的方案能恢复。
dp + 单调队列 + 前缀和,详细思考路径
划分型 DP + 单调队列优化(Python/Java/C++/Go)
O(n^2) 做法
本题是标准的划分型 DP,见 DP 题单 的「§5.2 最优划分」。
一般定义 $f[i+1]$ 表示前缀 $\textit{nums}[0]$ 到 $\textit{nums}[i]$ 在题目约束下,分割出的最少(最多)子数组个数,本题是定义成分割方案数。这里 $i+1$ 是为了把 $f[0]$ 当作初始值。
枚举最后一个子数组的左端点 $j$,那么问题变成前缀 $\textit{nums}[0]$ 到 $\textit{nums}[j-1]$ 在题目约束下的分割方案数,即 $f[j]$。
当子数组右端点 $i$ 固定时,由于子数组越长,最大值越大,最小值越小,最大最小的差值越可能大于 $k$。所以符合要求的左端点 $j$ 一定在一个连续区间 $[L,i]$ 中。累加 $f[j]$ 得
$$
f[i+1] = \sum_{j=L}^{i} f[j]
$$
初始值 $f[0] = 1$,空子数组算一个方案。也可以从递归的角度理解,递归到空子数组,就表示我们找到了一个合法分割方案。
答案为 $f[n]$。
O(n) 做法
由于 $i$ 越大,$L$ 也越大,可以用 滑动窗口【基础算法精讲 03】。
同时,我们需要计算 239. 滑动窗口最大值 和滑动窗口最小值,这可以用 单调队列【基础算法精讲 27】解决。
维护窗口中的 $\displaystyle\sum_{j=L}^{i} f[j]$,记作 $\textit{sumF}$,转移方程优化成
$$
f[i+1] = \textit{sumF}
$$
注意取模。关于模运算的知识点,见 模运算的世界:当加减乘除遇上取模。
本题视频讲解,欢迎点赞关注~
###py
class Solution:
def countPartitions(self, nums: List[int], k: int) -> int:
MOD = 1_000_000_007
n = len(nums)
min_q = deque()
max_q = deque()
f = [0] * (n + 1)
f[0] = 1
sum_f = 0 # 窗口中的 f[i] 之和
left = 0
for i, x in enumerate(nums):
# 1. 入
sum_f += f[i]
while min_q and x <= nums[min_q[-1]]:
min_q.pop()
min_q.append(i)
while max_q and x >= nums[max_q[-1]]:
max_q.pop()
max_q.append(i)
# 2. 出
while nums[max_q[0]] - nums[min_q[0]] > k:
sum_f -= f[left]
left += 1
if min_q[0] < left:
min_q.popleft()
if max_q[0] < left:
max_q.popleft()
# 3. 更新答案
f[i + 1] = sum_f % MOD
return f[n]
###java
class Solution {
public int countPartitions(int[] nums, int k) {
final int MOD = 1_000_000_007;
int n = nums.length;
Deque<Integer> minQ = new ArrayDeque<>(); // 更快的写法见【Java 数组】
Deque<Integer> maxQ = new ArrayDeque<>();
int[] f = new int[n + 1];
f[0] = 1;
long sumF = 0; // 窗口中的 f[i] 之和
int left = 0;
for (int i = 0; i < n; i++) {
// 1. 入
sumF += f[i];
int x = nums[i];
while (!minQ.isEmpty() && x <= nums[minQ.peekLast()]) {
minQ.pollLast();
}
minQ.addLast(i);
while (!maxQ.isEmpty() && x >= nums[maxQ.peekLast()]) {
maxQ.pollLast();
}
maxQ.addLast(i);
// 2. 出
while (nums[maxQ.peekFirst()] - nums[minQ.peekFirst()] > k) {
sumF -= f[left];
left++;
if (minQ.peekFirst() < left) {
minQ.pollFirst();
}
if (maxQ.peekFirst() < left) {
maxQ.pollFirst();
}
}
// 3. 更新答案
f[i + 1] = (int) (sumF % MOD);
}
return f[n];
}
}
###java
class Solution {
public int countPartitions(int[] nums, int k) {
final int MOD = 1_000_000_007;
int n = nums.length;
int[] minQ = new int[n];
int[] maxQ = new int[n];
int minHead = 0, minTail = -1;
int maxHead = 0, maxTail = -1;
int[] f = new int[n + 1];
f[0] = 1;
long sumF = 0; // 窗口中的 f[i] 之和
int left = 0;
for (int i = 0; i < n; i++) {
// 1. 入
sumF += f[i];
int x = nums[i];
while (minHead <= minTail && x <= nums[minQ[minTail]]) {
minTail--;
}
minQ[++minTail] = i;
while (maxHead <= maxTail && x >= nums[maxQ[maxTail]]) {
maxTail--;
}
maxQ[++maxTail] = i;
// 2. 出
while (nums[maxQ[maxHead]] - nums[minQ[minHead]] > k) {
sumF -= f[left];
left++;
if (minQ[minHead] < left) {
minHead++;
}
if (maxQ[maxHead] < left) {
maxHead++;
}
}
// 3. 更新答案
f[i + 1] = (int) (sumF % MOD);
}
return f[n];
}
}
###cpp
class Solution {
public:
int countPartitions(vector<int>& nums, int k) {
const int MOD = 1'000'000'007;
int n = nums.size();
deque<int> min_q, max_q;
vector<int> f(n + 1);
f[0] = 1;
long long sum_f = 0; // 窗口中的 f[i] 之和
int left = 0;
for (int i = 0; i < n; i++) {
int x = nums[i];
// 1. 入
sum_f += f[i];
while (!min_q.empty() && x <= nums[min_q.back()]) {
min_q.pop_back();
}
min_q.push_back(i);
while (!max_q.empty() && x >= nums[max_q.back()]) {
max_q.pop_back();
}
max_q.push_back(i);
// 2. 出
while (nums[max_q.front()] - nums[min_q.front()] > k) {
sum_f -= f[left];
left++;
if (min_q.front() < left) {
min_q.pop_front();
}
if (max_q.front() < left) {
max_q.pop_front();
}
}
// 3. 更新答案
f[i + 1] = sum_f % MOD;
}
return f[n];
}
};
###go
func countPartitions(nums []int, k int) int {
const mod = 1_000_000_007
n := len(nums)
var minQ, maxQ []int
f := make([]int, n+1)
f[0] = 1
sumF := 0 // 窗口中的 f[i] 之和
left := 0
for i, x := range nums {
// 1. 入
sumF += f[i]
for len(minQ) > 0 && x <= nums[minQ[len(minQ)-1]] {
minQ = minQ[:len(minQ)-1]
}
minQ = append(minQ, i)
for len(maxQ) > 0 && x >= nums[maxQ[len(maxQ)-1]] {
maxQ = maxQ[:len(maxQ)-1]
}
maxQ = append(maxQ, i)
// 2. 出
for nums[maxQ[0]]-nums[minQ[0]] > k {
sumF -= f[left]
left++
if minQ[0] < left {
minQ = minQ[1:]
}
if maxQ[0] < left {
maxQ = maxQ[1:]
}
}
// 3. 更新答案
f[i+1] = sumF % mod
}
return f[n]
}
复杂度分析
- 时间复杂度:$\mathcal{O}(n)$,其中 $n$ 是 $\textit{nums}$ 的长度。每个下标至多入队出队各两次。
- 空间复杂度:$\mathcal{O}(n)$。
相似题目
更多相似题目,见下面动态规划题单的「§5.2 最优划分」和「§11.3 单调队列优化 DP」。
分类题单
- 滑动窗口与双指针(定长/不定长/单序列/双序列/三指针/分组循环)
- 二分算法(二分答案/最小化最大值/最大化最小值/第K小)
- 单调栈(基础/矩形面积/贡献法/最小字典序)
- 网格图(DFS/BFS/综合应用)
- 位运算(基础/性质/拆位/试填/恒等式/思维)
- 图论算法(DFS/BFS/拓扑排序/基环树/最短路/最小生成树/网络流)
- 动态规划(入门/背包/划分/状态机/区间/状压/数位/数据结构优化/树形/博弈/概率期望)
- 常用数据结构(前缀和/差分/栈/队列/堆/字典树/并查集/树状数组/线段树)
- 数学算法(数论/组合/概率期望/博弈/计算几何/随机算法)
- 贪心与思维(基本贪心策略/反悔/区间/字典序/数学/思维/脑筋急转弯/构造)
- 链表、二叉树与回溯(前后指针/快慢指针/DFS/BFS/直径/LCA/一般树)
- 字符串(KMP/Z函数/Manacher/字符串哈希/AC自动机/后缀数组/子序列自动机)
DP & 双指针
解法:DP & 双指针
维护 $f(i)$ 表示前 $i$ 个元素的分割方案。转移时,枚举上一个子段的末尾在哪,有转移方程
$$
f(i) = \sum f(j)
$$
其中 $j$ 满足 $\max(a_{j + 1}, a_{j + 2}, \cdots, a_i) - \min(a_{j + 1}, a_{j + 2}, \cdots, a_i) \le k$
直接计算 DP 方程的复杂度为 $\mathcal{O}(n^2)$,还需要进一步观察合法的 $j$ 有什么特征。
注意到,如果某个子数组的极值之差小于等于 $k$,那么它的子数组的极值之差也小于等于 $k$。这是典型的双指针特征,因此合法的 $j$ 就是一段连续值,从某个值一直取到 $(i - 1)$。用双指针算出最小的合法 $j$,再用前缀和计算区间和即可。复杂度 $\mathcal{O}(n\log n)$,这里的 $\log n$ 主要是我们需要用数据结构(比如 multiset)动态维护滑动窗口里的最小值和最大值。
参考代码(c++)
class Solution {
public:
int countPartitions(vector<int>& nums, int K) {
int n = nums.size();
const int MOD = 1e9 + 7;
// f[i]:前 i 个元素的分割方案数
// g[i]:f 的前缀和
long long f[n + 1], g[n + 1];
f[0] = g[0] = 1;
// 用 multiset 记录滑动窗口里的数,方便求出最小值和最大值
multiset<int> ms;
// 枚举双指针的右端点 i,计算合法子段左端点的最小值 j
for (int i = 1, j = 1; i <= n; i++) {
ms.insert(nums[i - 1]);
while (j < i && *prev(ms.end()) - *ms.begin() > K) {
ms.erase(ms.find(nums[j - 1]));
j++;
}
// j 是最小的左端点
// 那么上一个子段最小的右端点就是 j - 1
// 前缀和就得减去 j - 2 的值
f[i] = (g[i - 1] - (j - 2 >= 0 ? g[j - 2] : 0) + MOD) % MOD;
g[i] = (g[i - 1] + f[i]) % MOD;
}
return f[n];
}
};