程序员老鬼

公司宣布降薪20%,同事因房贷压力拒绝接受,结果领导却说:不是非要大家接受,不接受可以选择走人

某大厂突然通知降薪20%。有人当场就顶不住了,说自己房贷还在那儿摆着,这个降法根本没法接。

结果领导那边也挺硬,意思大概就是:公司没按着你头签,觉得不行,那就另找地方。

Image

你看,这话听着好像很“自由选择”,但打工人都懂,这种选择跟选择题没啥关系,更像通知书。房贷不会因为你公司降薪就少收一分,孩子学费、老人看病、日常开销也不会突然懂事。

最难受的是,很多人不是不想走,是走也得有下家啊。现在工作又不是菜市场买菜,转身就能挑一个更好的。

公司一句“接受不了可以离开”,下面的人可能就是一晚上睡不着~

面试题:Range 模块

Range 模块最容易出 bug 的地方,不是 TreeMap 用不会,是边界没想清楚。

线上真要遇到这种东西,一般不是叫 Range Module,名字会包装得很好听:权益覆盖范围、时间段占用、IP 白名单段、优惠券可用区间、库存锁定区间。日志里看起来就一行:

range_check miss, userId=38127, ask=[1200,1500), current=[[800,1200),[1500,2000)]

这行我第一眼就不会去翻业务流程。

我会先问一句:你们这个区间到底包不包右边界?

很多代码坏就坏在这里。需求嘴上说“1200 到 1500”,开发脑子里是 [1200,1500],存储时又写成 [1200,1500),最后查的时候 > 和 >= 混着来。

我一般直接把规则定死:左闭右开。

也就是 [left, right)。

这样有个好处,两个连续区间 [10,20) 和 [20,30) 可以无缝拼起来,不会纠结 20 到底算谁的。

Range 模块一般就三个动作:

addRange(left, right)
queryRange(left, right)
removeRange(left, right)

别急着写代码,先看一组现场数据。

add [10, 20)
add [30, 40)
add [18, 32)

最后应该变成:
[10, 40)

这不是新增,这是合并。

再看删除:

current: [10, 40)
remove : [18, 32)

最后应该变成:
[10, 18), [32, 40)

这不是删除,这是切割。

我见过不少代码,新增能合并,删除一切割就乱了。尤其是删中间一段,原来的一个区间要拆成两个,这地方最容易漏。

下面这个实现我会用 TreeMap,key 放区间左端点,value 放右端点。

import java.util.Map;
import java.util.TreeMap;

publicclassRangeBox{

// key: left, value: right,统一按 [left, right) 存
privatefinal TreeMap<Integer, Integer> ranges = new TreeMap<>();

publicvoidaddRange(int left, int right){
if (left >= right) {
return;
        }

        Map.Entry<Integer, Integer> hit = ranges.floorEntry(left);
if (hit != null && hit.getValue() >= left) {
            left = Math.min(left, hit.getKey());
            right = Math.max(right, hit.getValue());
            ranges.remove(hit.getKey());
        }

        Map.Entry<Integer, Integer> next = ranges.ceilingEntry(left);
while (next != null && next.getKey() <= right) {
            right = Math.max(right, next.getValue());
            ranges.remove(next.getKey());
            next = ranges.ceilingEntry(left);
        }

        ranges.put(left, right);
    }

publicbooleanqueryRange(int left, int right){
if (left >= right) {
returntrue;
        }

        Map.Entry<Integer, Integer> hit = ranges.floorEntry(left);
return hit != null && hit.getValue() >= right;
    }

publicvoidremoveRange(int left, int right){
if (left >= right) {
return;
        }

        Map.Entry<Integer, Integer> hit = ranges.floorEntry(left);
if (hit != null && hit.getValue() > left) {
int oldLeft = hit.getKey();
int oldRight = hit.getValue();
            ranges.remove(oldLeft);

if (oldLeft < left) {
                ranges.put(oldLeft, left);
            }
if (oldRight > right) {
                ranges.put(right, oldRight);
            }
        }

        Map.Entry<Integer, Integer> next = ranges.ceilingEntry(left);
while (next != null && next.getKey() < right) {
int oldLeft = next.getKey();
int oldRight = next.getValue();
            ranges.remove(oldLeft);

if (oldRight > right) {
                ranges.put(right, oldRight);
break;
            }

            next = ranges.ceilingEntry(left);
        }
    }

public String dump(){
return ranges.toString();
    }
}

这段代码里有几个判断,别随手改。

addRange 里面:

hit.getValue() >= left

这里用的是 >=,因为 [10,20) 和 [20,30) 在业务上可以合并成 [10,30)。如果你的业务认为两个区间必须隔开,那这里就要改成 >。

但大部分 Range 模块,连续区间合并更省事。

removeRange 里面第一段处理的是“左边盖住了删除起点”的区间。

比如:

current: [10, 40)
remove : [18, 32)

floorEntry(18) 能找到 [10,40)。这时候不能简单删掉 [10,40),要拆:

[10,18)
[32,40)

所以代码里才有这两句:

if (oldLeft < left) {
    ranges.put(oldLeft, left);
}
if (oldRight > right) {
    ranges.put(right, oldRight);
}

这两句少任何一句,数据都会丢一截。

我一般会补一个很土的测试,不靠脑补。

publicclassRangeBoxCheck{

publicstaticvoidmain(String[] args){
        RangeBox box = new RangeBox();

        box.addRange(10, 20);
        box.addRange(30, 40);
        box.addRange(18, 32);

        System.out.println(box.dump()); 
// {10=40}

        System.out.println(box.queryRange(10, 40)); 
// true

        box.removeRange(18, 32);

        System.out.println(box.dump()); 
// {10=18, 32=40}

        System.out.println(box.queryRange(10, 18)); 
// true

        System.out.println(box.queryRange(18, 32)); 
// false
    }
}

Range 模块还有个坑,出在“查询”。

有人会这么写:

return ranges.containsKey(left) && ranges.get(left) >= right;

这个写法看着挺直,其实不行。

因为你查 [15,18),当前只有 [10,20),它明明被覆盖了,但 containsKey(15) 是 false。

所以查询必须用:

ranges.floorEntry(left)

找左端点小于等于 left 的那个区间,再看它的右端点能不能盖住 right。

这就是 Range 模块的关键动作:往左找一个,再往右吞一串。

新增是这样。

删除也是这样。

查询还是这样。

复杂度也还可以。TreeMap 底层是红黑树,查一次是 O(log n),合并和删除时会额外扫掉被影响的区间。正常业务里区间数量不会无限大,比拿数组一格一格扫靠谱得多。

不过这玩意别随便套到所有场景。

如果你的区间范围特别小,比如只管一天 24 小时、分钟级标记,总共也就 1440 个点,用 boolean 数组反而简单。

如果区间范围特别大,更新又特别频繁,还要持久化,那就别只盯着内存结构了。得考虑数据库怎么存,怎么加索引,多个实例同时更新时怎么加锁。Range 逻辑本身不难,难的是别让两个请求同时把区间切坏。

我一般会在更新前后打一条很克制的日志:

log.info("range_change bizId={}, op={}, ask=[{},{}), before={}, after={}",
        bizId, op, left, right, before, rangeBox.dump());

不要每次查询都打,量一上来日志能把磁盘打满。只打变更,排问题够用了。

Range 模块写到最后,其实就记住一个规矩:边界统一,别混用;新增要合并,删除要切割;查询别只盯着当前 left,往左找一下。

很多 bug 不是算法不会,是一开始就没把 [left,right) 这件事钉死。