跳到主要内容

2024-03-24 小汇总

· 阅读需 8 分钟

算法:合并公共范围

假如有一个数字类型的二维数组,数组中的每一项都是固定长度为 2 的子数组,代表范围,比如 [2, 5] 表示数字范围在 2~5 之间,并且包含 2 和 5。要求你设计一个函数,它接受这样格式的二维数组,合并有交集的范围(Range),最终返回新的二维数组,这个新二维数组的数字范围与原数组一样,且总体是按照从小到大排列的,只是有些重复的范围被合并了。

比如下面的一个二维数组,经过函数处理后最终得到新的二维数组为:[[1,5], [7,9]]

[ [3, 4], [1, 3], [7, 9], [2, 5] ]

已知条件:子数组的第一个数字比第二个要小(从小到大排列)。

思路

首先题目要求最终中的数组是按照从小到大的顺序排列的,因此我们可以先进行一下排序操作。

function mergeRanges (ranges: [number, number][]) {
ranges = ranges.toSorted(([currentLeft], [nextLeft]) => currentLeft - nextLeft);
// TODO...
}

接下来是怎么实现范围合并。

比如 [1, 5][3, 7] 这两个范围合并之后是 [1, 7]

可以使用双指针的方式实现,首先我们定义两个变量:

  • left 用于表示范围的左侧元素,初始时排序过后的第一个子数组的第一项;
  • right 用于表示范围的右侧元素,初始时排序过后的第一个子数组的第二项;

然后从数组第二项开始遍历,取到遍历元素的 startend,满足 start > right 条件我们就认为最长范围找到了,重新赋值 leftright。否则就只更新 right(因为已经从小到大排过序,left 肯定是最小的,我们需要找范围的最大值)。代码如下:

function mergeRanges (ranges: [number, number][]) {
if (ranges.length < 2) return ranges;

ranges = ranges.toSorted(([currentLeft], [nextLeft]) => currentLeft - nextLeft);

let [left, right] = ranges[0];
const mergedArrays: [number, number][] = [];

for (let i = 1; i < ranges.length; i++) {
const [start, end] = ranges[i];

// [1(left), 5(right)] [7(start), 9(end)] -->
// 7 > 5,说明已经没有交集,把 [left, right] push 数组里,并从 start、end 继续查询范围
if (start > right) {
mergedArrays.push([left, right]);
// 这里因为重新赋值了,但数组可能已经是最后一项了,
// 因此循环结束后,还需要再把这一次的 [left, right] push 到数组里
[left, right] = [start, end];
}

// [1(left), 5(right)] [3(start), 7(end)] -->
// 3 <= 5,需要更新 right 为 7
else {
right = Math.max(right, end);
}
}

mergedArrays.push([left, right]);

return mergedArrays;
}

自定义合并

有时候我们可能希望下面这种情况也应该合并范围([1, 8]):

[[1, 2], [3, 4], [5, 6], [7, 8]]

但显然上面的代码实现是做不到的,因为只有 start > right(或者说是 nextLeft > currentRight)条件满足时才会合并。我们可以给 mergeRanges 函数增加第二个可选的 callback 参数,当 callback 返回 true 时我们就认为需要更新数组了。代码如下:


function mergeRanges (
ranges: [number, number][],
callback?: (current: [number, number], next: [number, number]) => boolean
) {
if (ranges.length < 2) return ranges;

ranges = ranges.toSorted(([currentLeft], [nextLeft]) => currentLeft - nextLeft);

// 自定义 callback
const cb = (current: [number, number], next: [number, number]) => {
if (typeof callback === 'function') {
return callback(current, next);
}

// 默认
return next[0] > current[1];
}

let [left, right] = ranges[0];
const result: [number, number][] = [];

for (let i = 1, len = ranges.length; i < len; i++) {
const [start, end] = ranges[i];

const flag = cb([left, right], [start, end]);

if (flag) {
result.push([left, right]);
[left, right] = [start, end];
} else {
right = Math.max(right, end);
}
}

result.push([left, right]);

return result;
}

const arr = [ [1, 2], [3, 4], [3, 5], [8, 9] ];

const result = mergeRanges(arr, (current, next) => {
// 相差大于 1 时才任务检查到了最大范围
return next[0] - current[1] > 1;
});

console.log(result); // [[1, 5], [8, 9]]

应用场景

考虑这样一个需求:你要开发一个 TODO List 程序,这个程序有一个排期录入功能,它支持批量录入。

工时录入

传给后端的日期数据时时间戳,类似于下面的类型(WorkPlan[]):

interface WorkPlan {
startTime: number;
endTime: number;
}

前端还有一个数据可视化的页面,根据排期录入的时间渲染出一个图表。

工作可视化

排期录入时时间范围可能会有重叠,如果我们期望把有重叠的日期都“压缩”成一个范围,就可以使用 mergeRanges 方法实现这个功能,代码如下:

const timestampOfOneDay = 24 * 1000 * 60 * 60;

const getDateRanges = (planList: WorkPlan[]) => {
const ranges = planList.map<[number, number]>((item) => [item.startTime, item.endTime]);
// 合并之后的日期
const merged = mergeRanges(ranges, (current, next) => {
// 间隔不超过一天的的都可以合并范围
return next[0] - current[1] > timestampOfOneDay;
});

return merged;
}

UI实现这里就不再往下讨论了,大家可以自己动手做做看。

检查文本内容是否超出省略

有时候我们期望文本超出时就在文本上方弹出气泡,展示完整的文案内容,但是我们又不希望文本不超出时也弹出气泡。怎么判断出文本内容是否超出容器了呢?

根据下面的条件就可以判断出来内容是否超出了。

elm.scrollWidth > elm.clientWidth
  • clientWidth 它是元素内部的宽度(以像素为单位)。该属性包括内边距(padding),但不包括边框(border)、外边距(margin)
  • scrollWidth 值等于元素在不使用水平滚动条的情况下适合视口中的所有内容所需的最小宽度。如果元素的内容可以适合而不需要水平滚动条,则其 scrollWidth 等于 clientWidth

如果元素宽度是不固定的,可以通过监听 window.onresize 或者或者使用 ResizeObserver API 判断内容是否超出,通常情况下我们不需要额外监听尺寸变化。

下面代码是一个 React 版本的简单的超出省略,并自动带有气泡提示的组件。

import { Tooltip, TooltipProps } from "antd";
import {
useRef,
useState,
useEffect,
HTMLAttributes,
PropsWithChildren,
} from "react";
import { clsx } from "clsx";
import { debounce } from "lodash-es";
import "./EllipsisTip.css";

export const useEllipsisToolTip = <T extends HTMLElement>() => {
const elmRef = useRef<T>(null);
const [isOverflow, setIsOverflow] = useState(false);

const updateIsOverflow = (elm: T) => {
const overflow = elm.scrollWidth > elm.clientWidth;
setIsOverflow(overflow);
};

useEffect(() => {
const elm = elmRef.current;

const handleResize = debounce(() => {
elm && updateIsOverflow(elm);
}, 600);

updateIsOverflow(elm);
window.addEventListener("resize", handleResize, false);

return () => {
window.removeEventListener("resize", handleResize, false);
};
}, []);

return [isOverflow, elmRef] as const;
};

interface EllipsisToolTipProps extends HTMLAttributes<HTMLDivElement> {
tooltipProps?: TooltipProps;
}

function EllipsisToolTip(props: PropsWithChildren<EllipsisToolTipProps>) {
const { tooltipProps, children, className, ...htmlAttrs } = props;
const [open, setOpen] = useState(false);
const [isOverflow, elmRef] = useEllipsisToolTip<HTMLDivElement>();

return (
<Tooltip
{...tooltipProps}
open={open}
onOpenChange={(open) => {
setOpen(open && isOverflow);
}}
>
<div
{...htmlAttrs}
className={clsx("ellipsis-tip", className)}
ref={elmRef}
>
{children}
</div>
</Tooltip>
);
}

export default EllipsisToolTip;
.ellipsis-tip {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}

上面代码我们使用了 window.resize 时间,我们也可以使用 ResizeObserver API 代替,代码如下:

import { debounce } from "lodash-es";

useEffect(() => {
const elm = elmRef.current;

// 防抖
const handleResize = debounce(() => {
elm && updateIsOverflow(elm);
}, 600);

let resizeObserver: ResizeObserver | null = null;

if (elm) {
resizeObserver = new ResizeObserver(handleResize);
resizeObserver.observe(elm);
}

return () => {
resizeObserver?.disconnect();
};
}, []);

数组的复制版本API

  • toSpliced()
  • toSorted()
  • toReversed()

这三个数组 API 是 ES2023 新出的,它们分别对应:splice() sort()reverse()。不同之处是这三个新API执行后返回新的数组(浅拷贝)。这三个新API还是挺有用的,比如在 React 中,对列表数据增删改时用 toSpliced() 创建新的 state 更便捷。

interface IItem {
id: number;
title: string;
}

const [list, setList] = useState<IItem[]>([]);

// 更改操作
setList(list.toSpliced(0, 1, { id: createId(), title: 'xxxx' }));

如何在项目中使用?

typescript 为例,如果你在项目中使用了 TS,则把它的版本升级到 v5.2 以上,并在 tsconfig lib 中添加 ESNext 或者 ES2023 即可。

{
"compilerOptions": {
"lib": ["DOM", "ESNext"]
}
}

插件推荐:Code Spell Checker

必备插件,用于检查代码中单词拼写是否正确。

🚀 推荐一个绝佳的 Visual Studio Code 插件:Code Spell Checker!如果你也曾为代码中的拼写错误而烦恼,那么这个插件将会是你的救星。它能够在你编写代码时自动检测拼写错误,并提供纠正建议,让你的代码更加整洁、专业。无需再担心拼写问题,专注于编写出更优质的代码!✨

  • 简单好用;
  • 健壮的变量名;
  • 防止与后台联调时产生低级的参数名拼写错误问题;
  • 学习英语;

Code Spell Checker