[C++]LeetCode 2502 设计内存分配器
[C++]LeetCode2502. 设计内存分配器
题目描述
Difficulty: 中等
Related Topics: 设计, 数组, 哈希表, 模拟
给你一个整数 n
,表示下标从 0 开始的内存数组的大小。所有内存单元开始都是空闲的。
请你设计一个具备以下功能的内存分配器:
- 分配 一块大小为
size
的连续空闲内存单元并赋 idmID
。 - 释放 给定 id
mID
对应的所有内存单元。
注意:
- 多个块可以被分配到同一个
mID
。 - 你必须释放
mID
对应的所有内存单元,即便这些内存单元被分配在不同的块中。
实现 Allocator
类:
Allocator(int n)
使用一个大小为n
的内存数组初始化Allocator
对象。int allocate(int size, int mID)
找出大小为size
个连续空闲内存单元且位于 最左侧 的块,分配并赋 idmID
。返回块的第一个下标。如果不存在这样的块,返回-1
。int free(int mID)
释放 idmID
对应的所有内存单元。返回释放的内存单元数目。
示例:
输入
["Allocator", "allocate", "allocate", "allocate", "free", "allocate", "allocate", "allocate", "free", "allocate", "free"]
[[10], [1, 1], [1, 2], [1, 3], [2], [3, 4], [1, 1], [1, 1], [1], [10, 2], [7]]
输出
[null, 0, 1, 2, 1, 3, 1, 6, 3, -1, 0]
解释
Allocator loc = new Allocator(10); // 初始化一个大小为 10 的内存数组,所有内存单元都是空闲的。
loc.allocate(1, 1); // 最左侧的块的第一个下标是 0 。内存数组变为 [1, , , , , , , , , ]。返回 0 。
loc.allocate(1, 2); // 最左侧的块的第一个下标是 1 。内存数组变为 [1,2, , , , , , , , ]。返回 1 。
loc.allocate(1, 3); // 最左侧的块的第一个下标是 2 。内存数组变为 [1,2,3, , , , , , , ]。返回 2 。
loc.free(2); // 释放 mID 为 2 的所有内存单元。内存数组变为 [1, ,3, , , , , , , ] 。返回 1 ,因为只有 1 个 mID 为 2 的内存单元。
loc.allocate(3, 4); // 最左侧的块的第一个下标是 3 。内存数组变为 [1, ,3,4,4,4, , , , ]。返回 3 。
loc.allocate(1, 1); // 最左侧的块的第一个下标是 1 。内存数组变为 [1,1,3,4,4,4, , , , ]。返回 1 。
loc.allocate(1, 1); // 最左侧的块的第一个下标是 6 。内存数组变为 [1,1,3,4,4,4,1, , , ]。返回 6 。
loc.free(1); // 释放 mID 为 1 的所有内存单元。内存数组变为 [ , ,3,4,4,4, , , , ] 。返回 3 ,因为有 3 个 mID 为 1 的内存单元。
loc.allocate(10, 2); // 无法找出长度为 10 个连续空闲内存单元的空闲块,所有返回 -1 。
loc.free(7); // 释放 mID 为 7 的所有内存单元。内存数组保持原状,因为不存在 mID 为 7 的内存单元。返回 0 。
提示:
1 <= n, size, mID <= 1000
- 最多调用
allocate
和free
方法1000
次
思路
这题数据规模不大,显然可以暴力模拟。如果数据规模再大一点,存在一些理论更优的算法。
为讨论复杂度,记录数据规模如下:
\(n\):内存数组大小
\(m\):分配的mID的最大值
\(q\):操作次数
其中\(m\)的规模不影响复杂度(若\(m>>n\),可通过离散化将其映射至规模\(n\))。
两种很容易想到的朴素模拟思路:
-
直接开一个长度为n的数组记录每个单元的id,0表示空闲。
allocate
操作:\(O(n)\)扫描一遍数组,寻找连续的0。将首块长度不小于size
的连续的0赋值为mID
。free
操作:\(O(n)\)扫描一遍数组,将id为mID
的值改为0。易知总复杂度\(O(nq)\),空间复杂度\(O(n)\)。
-
对每块内存记录区间端点\([l, r]\)(或[\(l, length]\))。
将区间按左端点\(l\)升序排序。
allocate
操作:扫描所有区间,则区间\([l_i,r_i]\)和\([l_{i+1},r_{i+1}]\)之间空闲内存长度为len = l[i + 1] - (r[i] + 1)
。因为已按左端点排序,则首次len >= size
时找到可分配内存,插入新的\([l,r]\)即可。free
操作:将所有id为mID
的区间\([l,r]\)删掉。这种思路相较于上一种的优点在于当分配的内存比较连续(每次
allocate
的size
较大)时效率更高,但在分配的内存较零碎时可能效率更差(最差情况:每次申请长度为1的不同id的内存)。复杂度:对朴素做法,由于每次都是内部的插入和删除,可以用链表来存储\([l,r,id]\)。
这样,每次
allocate
操作和free
操作都需要\(O(n)\)遍历链表,总复杂度仍是\(O(nq)\),空间复杂度\(O(n)\)(最差情况)。
两种思路的朴素做法Code(C++):
// 思路一
class Allocator {
static const int N = 1000;
int n, id[N] = {0};
public:
Allocator(int n) {
this->n = n;
}
int allocate(int size, int mID) {
int cnt = 0, pos = -1;
for (int i = 0; i < n; ++i) {
if (!id[i]) {
++cnt;
if (pos == -1) pos = i;
if (cnt == size) break;
} else {
cnt = 0;
pos = -1;
}
}
if (cnt != size) return -1;
for (int i = pos; i < pos + size; ++i) id[i] = mID;
return pos;
}
int free(int mID) {
int cnt = 0;
for (int i = 0; i < n; ++i) {
if (id[i] == mID) id[i] = 0, ++cnt;
}
return cnt;
}
};
// 思路二
class Allocator {
struct node {
int l, r, id; // 区间[l, r],编号为id
node(int l = 0, int r = 0, int id = 0): l(l), r(r), id(id) {}
};
list<node> L;
public:
Allocator(int n) {
// 哨兵节点
L.emplace_back(node(-1, -1, 0));
L.emplace_back(node(n, n, 0));
}
int allocate(int size, int mID) {
for (auto now = L.begin(); now != L.end(); ++now) {
auto nxt = next(now);
if (nxt->l - now->r - 1 >= size) {
L.insert(nxt, node(now->r + 1, now->r + size, mID));
return now->r + 1;
}
}
return -1;
}
int free(int mID) {
int ans = 0;
for (auto now = L.begin(); now != L.end();) {
if (now->id == mID) {
ans += now->r - now->l + 1;
now = L.erase(now);
} else ++now;
}
return ans;
}
};
接下来尝试用数据结构进行优化:
对于第一种思路,需要区间赋0(free
操作),查询最靠左的连续的0(allocate
操作)。
区间覆盖和查询连续的0的长度,都是线段树的经典操作;查询最靠左的满足要求的位置,可以在线段树上二分实现。
维护最长连续的0长度:
(可以参考网上的这篇博客,我的写法跟他差不多。不过区间是否全为0可以直接算,没必要用bool变量记录。)
类比维护连续最大子段和,不能直接区间合并,可以考虑答案来源(左半区间/右半区间/跨越两个区间),将其转化为能直接合并的量。
记录l0
,r0
,mx0
分别表示区间左端/右端/整段连续的0的个数。
则mx0[p] = max(r0[ls(p)] + l0[rs(p)], max(mx0[ls(p)], mx0[rs(p)]));
l0[p] = 左半区间均为0 ? l0[ls(p)] + l0[rs(p)] : l0[ls(p)];
r0[p] = 右半区间均为0 ? r0[rs(p)] + r0[ls(p)] : r0[rs(p)];
其中 ls
为左儿子,rs
为右儿子。
查询最靠左的满足要求的位置:直接看代码吧。线段树上二分查找,复杂度是\(O(\log n)\)的。
这样allocate
操作的复杂度是\(O(\log n)\)的,free
操作每次删去一个区间的复杂度也是\(O(\log n)\)的。
但是计算总复杂度时,需注意free
操作每次要删去所有id为mID
的区间,而一个mID
可能对应多个区间。
很自然的想法是记录下每个mID
对应的所有区间[l, r]
,然后依次将每个区间都赋成0。
一次free
操作可能删去很多区间,最差情况:每次申请长度为1的同一id的内存,最后调用一次free
。这样单次free
操作的复杂度就达到了\(O(q\log n)\)。
不过,不可能每次free
操作都卡到上界:每次有意义的free
,若删去了\(k\)个区间,那么前面一定有\(k\)次allocate
操作,因此总共\(q\)次操作中,删去的区间数不会超过\(q\)。
于是,由均摊分析,总复杂度是\(O(q\log n)\)。
由于数据规模不大,而我写的传统递归式线段树常数比较大,所以效率不算特别高,而且占用空间较多(堆式存储需要开\(4n\)大小的数组)。想提高效率可以改成非递归式,想优化空间可以用动态开点。
Code(C++):
class Allocator {
static const int N = 1005, M = 1005;
int pos = 0; // allocate操作获取的答案
// 线段树模板
int n = 0, l0[N << 2] = {0}, r0[N << 2] = {0}, mx0[N << 2] = {0}, lazy[N << 2] = {0};
#define ls(p) ((p) << 1)
#define rs(p) ((p) << 1 | 1)
#define mid ((l + r) >> 1)
void update(int p, int l, int r, int val) { // 更新节点
if (val) l0[p] = r0[p] = mx0[p] = 0;
else l0[p] = r0[p] = mx0[p] = r - l + 1;
lazy[p] = val;
}
void pushup(int p, int l, int r) { // 区间合并统计信息
l0[p] = l0[ls(p)] == mid - l + 1 ? l0[ls(p)] + l0[rs(p)] : l0[ls(p)];
r0[p] = r0[rs(p)] == r - mid ? r0[rs(p)] + r0[ls(p)] : r0[rs(p)];
mx0[p] = max(r0[ls(p)] + l0[rs(p)], max(mx0[ls(p)], mx0[rs(p)]));
}
void pushdown(int p, int l, int r) { // 下放lazytag
if (l == r) return;
if (lazy[p] != -1) {
update(ls(p), l, mid, lazy[p]);
update(rs(p), mid + 1, r, lazy[p]);
lazy[p] = -1;
}
}
void build(int p, int l, int r) { // 建树
if (l == r) {
l0[p] = r0[p] = mx0[p] = 1;
lazy[p] = -1;
return;
}
build(ls(p), l, mid);
build(rs(p), mid + 1, r);
pushup(p, l, r);
}
void modify(int p, int l, int r, int ql, int qr, int val) { // 区间[ql ,qr]赋值val
if (ql <= l && r <= qr) return update(p, l, r, val);
pushdown(p, l, r);
if (ql <= mid) modify(ls(p), l, mid, ql, qr, val);
if (qr > mid) modify(rs(p), mid + 1, r, ql, qr, val);
pushup(p, l, r);
}
void query(int p, int l, int r, int len) { // 查询allocate操作答案(长度为len)
pushdown(p, l, r);
if (mx0[p] < len) return;
if (l == r) {
pos = l;
return;
}
if (mx0[ls(p)] >= len) return query(ls(p), l, mid, len);
else if (r0[ls(p)] + l0[rs(p)] >= len) {
pos = mid - r0[ls(p)] + 1;
return;
} else return query(rs(p), mid + 1, r, len);
}
#undef mid
#undef ls
#undef rs
vector< pair<int, int> > sec[M];
public:
Allocator(int n) {
this->n = n;
build(1, 0, n - 1);
}
int allocate(int size, int mID) {
pos = -1;
query(1, 0, n - 1, size); // 查询首地址
if (pos != -1) {
modify(1, 0, n - 1, pos, pos + size - 1, 1);// 区间赋1,标记该块内存已占用
sec[mID].emplace_back(make_pair(pos, pos + size - 1));
}
return pos;
}
int free(int mID) {
int ans = 0;
for (const auto& e : sec[mID]) {
modify(1, 0, n - 1, e.first, e.second, 0); // 区间赋0
ans += e.second - e.first + 1;
}
sec[mID].clear();
return ans;
}
};
对第二种思路的改进:
对每次free
操作,用链表实现总会\(O(n)\)遍历所有区间。容易想到的优化也是记录每个mID
对应的所有区间[l, r]
,这样在free
操作时就可以只删掉指定区间,减少遍历次数。
具体实现时可以记录链表中[l, r]
节点的迭代器,或者像其他题解里的做法用set代替链表,直接记录两端点[l, r]
。
不过,这对总复杂度没有太大影响:无论是遍历链表还是set,allocate
操作复杂度仍是\(O(n)\)。因此总复杂度仍为\(O(qn)\)。
我尝试了记录迭代器和用set替代list的做法,提交结果中运行时间没有明显提升,所以代码就不放了。
我的改进思路:分别维护已占用的内存used
和未分配的内存unused
。
这里区间存储的方式是记录左端点和区间长度[l, len]
。
allocate
操作,只需要维护未分配的内存unused
。每次allocate
操作,就是在维护的若干二元组unused
\(<l_i,len_i>\)中,查询在\(len_i>=size\)条件下最小的\(l_i\)。
这种查询是二维偏序,同时涉及二元组[l,len]
的插入和删除,相当于又引入了时间维度。若不考虑升维的离线做法,则需要嵌套数据结构来动态维护。
对这道题,可以用树套树(线段树套平衡树)来维护:对\(len\)的值域开线段树,线段树上每个节点用平衡树(set)维护\(l\)。
维护已占用的内存used
是为了free
操作。同时,注意考虑这种情况:
内存分配情况:[1, 1, 0, 0, 0, 2, 2, 0, 3]
(0表示未分配),那么在删去2对应的区间[l = 5, len = 2]
时,不能简单的把这段区间插进unused
,因为这段区间前后还有未分配的内存。
实际上,删掉上述区间后,内存分配情况变为:[1, 1, 0, 0, 0, 0, 0, 0, 3]
,应该从unused
中删去2前后的两段0,再插入新的区间[l = 2, len = 6]
。在查找2前后的两段0时,也需要用到used
。
代码中维护已占用内存是用set存储左右端点,确保按左端点l升序排序。
复杂度分析:
由于用了树套树,每次allocate
操作的复杂度和free
操作每次删去一个区间的复杂度是\(O(\log^2n)\)。
同样的,因为free
操作可能删去多个区间,需要均摊分析,最终总复杂度为\(O(q\log^2 n)\)。
不过,由于数据规模小,而且树套树的常数很大,所以虽然理论复杂度更优,但实际上跑不过链表,而且占用空间很大...
想提升效率可以将线段树改成非递归式,这种思路中线段树维护的东西很少,都是尾递归,很容易改写成非递归式。
Code(C++):
class Allocator {
static const int N = 1005, M = 1005;
int pos = 0, len = 0;
int n = 0;
set<int> s[N << 2]; // 线段树套平衡树
#define ls(p) ((p) << 1)
#define rs(p) ((p) << 1 | 1)
#define mid ((l + r) >> 1)
void Insert(int p, int l, int r, int pos, int val) { // 在pos位置插入val
s[p].insert(val);
if (l == r) return;
if (pos <= mid) Insert(ls(p), l, mid, pos, val);
else Insert(rs(p), mid + 1, r, pos, val);
}
void Remove(int p, int l, int r, int pos, int val) { // 在pos位置删去val
s[p].erase(val);
if (l == r) return;
if (pos <= mid) Remove(ls(p), l, mid, pos, val);
else Remove(rs(p), mid + 1, r, pos, val);
}
void QueryPos(int p, int l, int r, int ql, int qr) { // 查询ql<=len_i<=qr时l_i的最小值
if (ql <= l && r <= qr) {
if (!s[p].empty()) pos = min(pos, *s[p].begin());
return;
}
if (ql <= mid) QueryPos(ls(p), l, mid, ql, qr);
if (qr > mid) QueryPos(rs(p), mid + 1, r, ql, qr);
}
void QueryLen(int p, int l, int r, int val) {
if (l == r) {
len = l;
return;
}
auto res_l = s[ls(p)].find(val), res_r = s[rs(p)].find(val);
if (res_l != s[ls(p)].end()) {
s[ls(p)].erase(res_l);
QueryLen(ls(p), l, mid, val);
} else {
s[rs(p)].erase(res_r);
QueryLen(rs(p), mid + 1, r, val);
}
}
#undef ls
#undef rs
#undef mid
#define l first
#define r second
set< pair<int, int> > memory; //已占用内存[l, r]
vector< pair<int, int> > sec[M];
public:
Allocator(int n) {
this->n = n;
// 哨兵节点
memory.insert(make_pair(-1, -1));
memory.insert(make_pair(n, n));
// 初始时只有一块长度为n的空内存
Insert(1, 1, n, n, 0);
}
int allocate(int size, int mID) {
pos = n;
QueryPos(1, 1, n, size, n); // 待分配的首地址pos为size<=len_i<=n时l_i的最小值
if (pos == n) return -1;
// 维护已占用内存
memory.insert(make_pair(pos, pos + size - 1));
sec[mID].emplace_back(make_pair(pos, pos + size - 1));
// 由于分配了内存,原来的空内存块变小了
s[1].erase(pos);
QueryLen(1, 1, n, pos); // 将原来的[pos, len]删掉
if (len > size) Insert(1, 1, n, len - size, pos + size); // 新的空内存块
return pos;
}
int free(int mID) {
int ans = 0;
for (const auto& e : sec[mID]) {
auto now = memory.find(e);
auto pre = prev(now), nxt = next(now);
int L = now->l, R = now->r;
if (pre->r + 1 != now->l) { // 考虑待删除内存之前有没有空内存
L = pre->r + 1;
Remove(1, 1, n, now->l - pre->r - 1, pre->r + 1);
}
if (now->r + 1 != nxt->l) { // 考虑待删除内存之后有没有空内存
R = nxt->l - 1;
Remove(1, 1, n, nxt->l - now->r - 1, now->r + 1);
}
Insert(1, 1, n, R - L + 1, L); // 插入新的空内存块
ans += now->r - now->l + 1;
memory.erase(now);
}
sec[mID].clear();
return ans;
}
#undef l
#undef r
};