[C++]LeetCode 2502 设计内存分配器

[C++]LeetCode2502. 设计内存分配器

题目描述

Difficulty: 中等

Related Topics: 设计, 数组, 哈希表, 模拟

给你一个整数 n ,表示下标从 0 开始的内存数组的大小。所有内存单元开始都是空闲的。

请你设计一个具备以下功能的内存分配器:

  1. 分配 一块大小为 size 的连续空闲内存单元并赋 id mID
  2. 释放 给定 id mID 对应的所有内存单元。

注意:

  • 多个块可以被分配到同一个 mID
  • 你必须释放 mID 对应的所有内存单元,即便这些内存单元被分配在不同的块中。

实现 Allocator 类:

  • Allocator(int n) 使用一个大小为 n 的内存数组初始化 Allocator 对象。
  • int allocate(int size, int mID) 找出大小为 size 个连续空闲内存单元且位于 最左侧 的块,分配并赋 id mID 。返回块的第一个下标。如果不存在这样的块,返回 -1
  • int free(int mID) 释放 id mID 对应的所有内存单元。返回释放的内存单元数目。

示例:

输入
["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
  • 最多调用 allocatefree 方法 1000

思路

这题数据规模不大,显然可以暴力模拟。如果数据规模再大一点,存在一些理论更优的算法。

为讨论复杂度,记录数据规模如下:

\(n\):内存数组大小

\(m\):分配的mID的最大值

\(q\):操作次数

其中\(m\)的规模不影响复杂度(若\(m>>n\),可通过离散化将其映射至规模\(n\))。

两种很容易想到的朴素模拟思路:

  1. 直接开一个长度为n的数组记录每个单元的id,0表示空闲。

    allocate操作:\(O(n)\)扫描一遍数组,寻找连续的0。将首块长度不小于size的连续的0赋值为mID

    free操作:\(O(n)\)扫描一遍数组,将id为mID的值改为0。

    易知总复杂度\(O(nq)\),空间复杂度\(O(n)\)

  2. 对每块内存记录区间端点\([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]\)删掉。

    这种思路相较于上一种的优点在于当分配的内存比较连续(每次allocatesize较大)时效率更高,但在分配的内存较零碎时可能效率更差(最差情况:每次申请长度为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变量记录。)

类比维护连续最大子段和,不能直接区间合并,可以考虑答案来源(左半区间/右半区间/跨越两个区间),将其转化为能直接合并的量。

记录l0r0mx0分别表示区间左端/右端/整段连续的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
};
posted @ 2022-12-19 21:35  宇興  阅读(53)  评论(0编辑  收藏  举报