Q51- code295- 数据流的中位数
实现思路
1 本题特点:
- 是 动态地 添加数字
- 需要随时能够获取中位数
- 数据是持续流入的
2 暴力/直观 解法
- 维护一个有序数组,每次插入时保持数组有序
- 获取中位数时,直接访问中间位置
- 但这样插入操作的时间复杂度是 O(n),因为插入时 要移动元素
- 为什么要移动元素:因为我们要保持整个数组都有序
- 关键问题:我们真的需要整个数组都有序吗?
3.1 思考中位数的 定义/本质:
- 中位数的定义是什么?是把数据分成相等两部分的 那个数
- 对于有序数组 [1, 2, 3, 4, 5],中位数是 3
- 重要观察:3 左边的数都比 3 小,右边的数都比 3 大, 3 是中位数
- 更重要的观察:左边具体是 1, 2 还是 2, 1 的顺序并不重要,右边是 4, 5 还是 5, 4 的顺序也不重要
- 所以,我们只需要 "找到中位数" 即可,不需要整个数组都有序
3.2 也就是说,我们只需要知道
- 左半部分的最大值(因为这可能是中位数)
- 右半部分的最小值(因为这也可能是中位数)
- 其他数字的具体顺序都不重要
3.3 引入堆的契机:
- 需求1:快速获取一组数中的 最大值/最小值
- 需求2:快速增删元素
- 这正是堆数据结构的特长!
- 大顶堆可以快速获取最大值,小顶堆可以快速获取最小值
4 总结以上 思维方式:
- 当发现一个直观解法效率不够时
- 回到问题本质,看看是否 真的需要所有的操作
- 找到关键信息,用更高效的数据结构来维护
text
直观解法:[1, 2, 3, 4, 5] 完全有序数组
↓
优化思路:只需要知道 [1, 2] 中的最大值和 [4, 5] 中的最小值
↓
最终方案:大顶堆 [1, 2] 和小顶堆 [3, 4, 5]
参考文档
代码实现
1 方法1: 最大堆 && 最小堆
- 时间复杂度: addNum O(logq)
- q 是 addNum 的调用次数
- 空间复杂度: O(q)
ts
class minHeap<T> {
private data: T[];
private compare: (a: T, b: T) => boolean;
constructor(compare: typeof this.compare) {
this.compare = compare;
this.data = [];
}
// 使用 getter 替代方法
get size() {
return this.data.length;
}
get peek() {
return this.data[0];
}
enque(item: T) {
this.data.push(item);
this.siftUp(this.size - 1);
}
deque() {
// 易错点1:如果堆为空,直接返回,防止堆中误进入'undefined'
if (this.size === 0) return;
// 易错点2:只有一个的时候直接出队,防止后续操作数组长度不会减少
if (this.size === 1) return this.data.pop();
const ret = this.data[0];
this.data[0] = this.data.pop();
this.siftDown(0);
return ret;
}
private siftUp(idx: number) {
while (idx > 0) {
const pdx = (idx - 1) >> 1;
// compre为true: a < b; 即 cur < parent
const willUp = pdx >= 0 && this.compare(this.data[idx], this.data[pdx]);
if (!willUp) break;
this.swap(idx, pdx);
idx = pdx;
}
}
private siftDown(idx: number) {
while (1) {
let ldx = idx * 2 + 1, rdx = ldx + 1;
let ndx = idx;
if (ldx < this.size && this.compare(this.data[ldx], this.data[ndx]))
ndx = ldx;
if (rdx < this.size && this.compare(this.data[rdx], this.data[ndx]))
ndx = rdx;
if (ndx === idx) break;
this.swap(idx, ndx);
idx = ndx;
}
}
private swap(i: number, j: number) {
[this.data[i], this.data[j]] = [this.data[j], this.data[i]];
}
}
class MedianFinder {
// 较小部分的 最大堆: 通过compare来控制反转为 最大堆
private left: minHeap<number>;
// 较大部分的 最小堆
private right: minHeap<number>;
constructor() {
// 保证 left.size === right.size 或者 left.size === right.size + 1
this.left = new minHeap((a, b) => a > b);
this.right = new minHeap((a, b) => a < b);
}
addNum(num: number): void {
// 成员个数相等,统一逻辑以简化代码:进右出最小的,进左
if (this.left.size === this.right.size) {
this.right.enque(num);
this.left.enque(this.right.deque());
} else {
// left.size === right.size + 1,统一逻辑以简化代码:进左出最大的,进右
this.left.enque(num);
this.right.enque(this.left.deque());
}
}
findMedian(): number {
return this.left.size > this.right.size
? this.left.peek
: (this.right.peek + this.left.peek) / 2;
}
}
Q52- code767- 重构字符串
实现思路
1 举简单例子,尝试找到规律
例1:"aab" 字符频率:a=2, b=1 手动排列:"aba" ✓ 观察:最多字符a出现2次,总长度3
例2:"aaab" 字符频率:a=3, b=1 手动尝试:"aaba" → 失败,有相邻的a 观察:最多字符a出现3次,总长度4
例3:"aabbcc" 字符频率:a=2, b=2, c=2 手动排列:"abacbc" ✓ 观察:所有字符频率相等
2 尝试总结规律
- 成功的例子:最多字符频率 ≤ 总长度的一半左右
- 失败的例子:最多字符频率 > 总长度的一半
- 为什么:
- 如果字符a出现次数超过 (n + 1) / 2
- 那么无论怎么排列,a都会相邻
- 因为偶数位置不够放所有的a
即:如果某个字母的 最大频率 > (字符串长度 + 1) / 2,则无解
- 为什么是 (n+1)/2
- 对于长度为4的字符串:最多允许2个相同字符
- 对于长度为5的字符串:最多允许3个相同字符
- 公式:(n+1)/2
3 如果是有解的,如何构造
方法1:贪心思想- 每次选择 剩余次数最多的字符
- 如果每次都选最多的,能最大化利用空间
- 每次取频率最高的,如果和前一个相同,取第二高的】
- 具体实现:优先队列
方法2:位置填充
- 按频率从大到小,先填偶数位置,再填奇数位置
- 先把最多的字符放在偶数位置 (0,2,4...)
- 再把其他字符填充剩余位置
为什么这样构造不会出错(正确性证明):反证法
假设填到某个奇数位置时,出现了相邻相同的情况
比如
text
位置:0 1 2 3 4 5 6
当前:a b a b a ? ?
如果第5个位置填a,就会和位置4的a相邻。
但是! 如果字符a能填到位置5,说明:
- a已经填了位置0,2,4(3个位置)
- 还要填位置5(第4个位置)
- 所以a至少出现4次
但是! 我们假设了最大频率 ≤ ⌈n/2⌉
- 对于n=7,⌈7/2⌉ = 4
- 所以a最多出现4次
- 如果a出现4次,它只能填偶数位置(0,2,4,6)
- 不可能填到奇数位置5
矛盾! 所以不可能出现相邻相同的情况
方法3: 桶计数 + 优先放置最多频率值
- 桶排序(计数排序): 统计最大频率 + 最大字符
- S2 判断可行性
- S3 优先放置 最大频率字符 + 去除该字符
- S4 隔位置 放置其他字符即可
参考文档
代码实现
1 方法1: 排序 + 偶数位放置
-
时间复杂度:O(N + KlogK)
- N是 字符串长度
- K是 不同字符的数量,K ≤ 26
-
空间复杂度: O(N)
ts
function reorganizeString(s: string): string {
// S1: 频率统计
const len = s.length;
const record = [...s].reduce((map, str) => {
map.set(str, (map.get(str) ?? 0) + 1);
return map;
}, new Map<string, number>());
// S2: 排序获取最大频率
const sorted = [...record.entries()].sort((a, b) => b[1] - a[1]);
const maxFreq = sorted[0][1];
// S3: 判断是否可行
if (maxFreq > (len + 1) >> 1) return "";
// S4: 填充结果
let res = [], pos = 0;
for (let [str, freq] of sorted) {
for (let i = 0; i < freq; i++) {
if (pos >= len) pos = 1;
res[pos] = str;
pos += 2;
}
}
return res.join("");
}
方法2 桶计数 + 优先放置最多频率值
- 时间复杂度:O(n)
- 统计频率:O(n) - 遍历字符串一次
- 放置最大频率字符:O(maxFreq) ≤ O(n)
- 放置其他字符:O(26 + 剩余字符数) = O(n)
- 总计:O(n)
- 空间复杂度:O(n)
- buckets数组:O(26) = O(1) - 常数空间
- 结果数组:O(n) - 存储重构后的字符串
- 其他变量:O(1)
- 总计:O(n)
ts
function reorganizeString(s: string): string {
const n = s.length;
const buckets = new Array(26).fill(0);
let maxFreq = 0, maxIdx = 0;
// 统计频率并找最大频率字符
for (const ch of s) {
const idx = ch.charCodeAt(0) - 97;
buckets[idx]++;
if (buckets[idx] > maxFreq) {
maxFreq = buckets[idx];
maxIdx = idx;
}
}
// 判断可行性
if (maxFreq > (n + 1) >> 1) return "";
const res = new Array(n);
let pos = 0;
// 先放最大频率字符到偶数位置
while (buckets[maxIdx] > 0) {
res[pos] = String.fromCharCode(maxIdx + 97);
pos += 2;
buckets[maxIdx]--;
}
// 放其他字符
for (let j = 0; j < 26; j++) {
while (buckets[j] > 0) {
// 切换到奇数位置
if (pos >= n) pos = 1;
res[pos] = String.fromCharCode(j + 97);
pos += 2;
buckets[j]--;
}
}
return res.join("");
}