总之,先假设底层的链表已经实现好了,反正很简单。
原理
基本上,中值滤波,或者说滑动中值滤波,需要做三件事:
- 在新数据添加到窗口的同时,将最旧的数据删除;
- 对窗口中的数据排序;
- 找出中位数;
排序
每个新数据将被加入一个双向链表LinkedList
中,并且在加入链表时就把它按从小到大的顺序放在合适的位置。添加新数据并排序,最多只需要把整个链表遍历一次。如果不在添加时立即排序,后续只能整体排序,那就指不定要来回几次了。换个视角看,如果用这种方法添加N 个数据,最多可能要遍历N 次,其实不算快,但是用在滤波器这种场合,需要考虑的主要是添加单个数据时排序的性能,那这种方法就很方便了。
中位数
保存当前的中位数 M M M,添加新数据 N N N 时,若 N < M N \lt M N<M ,那么 N N N 肯定会被排在 M M M 之前,链表的前半部分变长了,中点自然应该向前移动,所以新的中位数就对应 M M M 的前一个节点。反之,若 N ≥ M N \ge M N≥M,中点就向后移动。
删除旧数据也会导致链表中点发生变化,和添加新数据同理:如果前面短了,就让中点向后,否则让中点向前。为了避免重复移动中点,可以一次性比较新数据 N N N、旧数据 D D D、当前中点 M M M,如果中点前面删了一个数据,但是又加了个数据,那么中点就不用移动,其他情况同理。虽说,双向链表的优点就在于移动和增删的高效,中点重复挪几下或许比多几个分支判断要更高效呢。
当然,这么做的前提是,滤波器初始化时就要让 M M M 指向链表中间。这应该没什么难的,如果窗口大小是 W W W,初始化时就给链表中添加 W W W 个节点,每个节点的值是某个初始值,比如就是0,然后让 M M M 对应 W 2 \frac{W}{2} 2W对应的那个节点就好了。这个步骤只需要在初始化时做一次,不用担心会浪费性能,直接随便找个地方当中值点好像也完全OK。
又考虑了一下中值点的问题,首先,上面只考虑了删除中值点左侧或右侧其他节点的情况,如果链表里最旧的节点正好就是中值呢?不论如都必须移动中值点。对于中值点 M M M, 显然 M = D ≥ M M = D \ge M M=D≥M,如果新数据 N < M N < M N<M,那就对应前面变长,后面变短的情况,中值点向前移动,不需要额外处理。
但如果 N ≥ M N \ge M N≥M,对应链表后半部分变长又缩短,按原来的思路,中值点并不需要移动。所以需要额外检查这种情况,当 N ≥ M N \ge M N≥M,同时弹出的旧节点正好就是中值点时,中值点应该向后移动。
删除旧数据
首先要找到最旧的数据,这个地方就稍微有点麻烦了,双向链表里的数据是按大小排序的,无法直接判断数据加入的先后顺序。我的思路是:另外再加一个单向链表,也可以说是队列,就起名叫ForwardLinkedList
;每个数据将被同时加入双向链表和这个队列中,队列自然就有先进先出的功能,可以高效的找出最旧的数据。
具体来说,存放数据的链表节点应该含有三个指针域,即next
、prev
和forward
,next
和prev
用来指向双向链表中的前一节点和后一节点,forward
用来在单向链表中指向后一节点。这些指针的类型都是一样的,指向存放数据的链表节点类型,可以取名叫DoubleLinkedNode
。要删除旧数据,只需从队列中弹出最旧的节点,然后从双向链表中将该节点删除。
优点
- 运行可能比较快:这里用链表本身就是拿空间换时间,数据增删和挪动都只要一步完成。每次添加新数据时,排序最多只用遍历一次,理论上耗时也比较短;
缺点
- 比较费空间:如果滤波器接收的数据是
float
类型,塞进链表节点里之后,加上三个指针,存储每个数据要耗费三倍的额外空间; - 管理链表要操作一堆指针:尤其这还是两个链表交织到一起,写代码要小心点儿;
伪代码实现
还是假设底层链表已经实现好了,拿伪代码把思路细化。关于中值点移动的几种情况的处理,参考代码中的注释。
struct DoubleLinkedNode {
// 用于单向链表
DoubleLinkedNode *forward;
// 用于双向链表
DoubleLinkedNode *next;
DoubleLinkedNode *prev;
float data;
};
// 双向链表
class LinkedList {
// 假设已经实现好了
};
// 单向链表
class ForwardLinkedList {
// 假设已经实现好了
};
// 滑动中值滤波器
class MovindMedianFilter {
private:
LinkedList _ordered_list; // 用于排序的双向链表
ForwardLinkedList _queue; // 先进先出队列
DoubleLinkedNode *_median_node; // 指向中值
DoubleLinkedNode *_extra_node; // 用于在增删新旧数据时保护链表结构的一个额外节点
public:
MovindMedianFilter() {}
// 初始化,创建并给链表中添加window_size 个节点,值为init_value
void init(size_t window_size, float init_value) {
for (int i = 0; i < window_size; ++i) {
auto * node = new DoubleLinkedNode{};
node->data = init_value;
if(i == window_size / 2) { // 初始化中值节点
_median_node = node;
}
_queue.push_back(node);
_ordered_list.push_back(node); // 所有节点的值相同,不用排序,
// 直接添加就能保证初始中值节点位于链表中间
}
_extra_node = new DoubleLinkedNode{};
}
// 传入一个新数据,返回经过滤波的中值
float feed(float new_value) {
// 将新节点加入链表
// 必须先添加新数据的节点,再删除旧节点,最后移动中值点,
// 因为新节点和旧节点有可能正好在原中值点前一个或后一个的位置,
// _median_node 必须在新节点加入链表后更新它内部的指针,所以一定存在一个阶段,
// 新节点和原始的_median_node 同时存在于链表中,又因为_median_node 可能就是old_node,
// 即,新节点和将被弹出的旧节点有可能同时存在于链表中,所以必须有一个_extra_node,
// 在这个阶段让新数据能被加入进链表
_extra_node->data = new_value;
_queue.push_back(_extra_node);
_ordered_list.insert_sorted(_extra_node);
// 弹出旧节点
auto *old_node = _queue.pop_front();
_ordered_list.remove(old_node);
float old_value = old_node->data;
_extra_node = old_node; // 重用旧节点。所以初始化以后,只要窗口大小不变,滤波器就不需要释放或获取更多内存
// 注意,old_node 有可能和_median_node 指向同一个节点
// 但因为有_extra_node 存放新数据,将_median_node 弹出后,并不会立即修改其中的数据,
// _median_node 中的指针仍然指向原来的中值点前后的节点,可以借此移动中值点
// 如果新数据和旧数据都大于或小于当前中值,当前中值就不需要移动
if(new_value < median_value && old_value >= median_value) {
// 前面加一个,后面减一个,中点前移
_median_node = _median_node->prev;
}
else if(new_value >= median_value && ((old_value < median_value) || (old_node == _median_node))) {
// 后面加一个,前面减一个,中点后移
// 或者,如果新数据大于等于当前中值,而当前中值节点将要被弹出链表,中值点后移
_median_node = _median_node->next;
}
return _median_node->data;
}
};
指针 - 内存泄漏
上面的伪代码可能存在各种实现上的问题,最明显的就是内存管理——链表节点new 出来以后并没有在任何地方delete 掉。虽然如果窗口大小不变,new 出来的所有链表节点就会循环重复使用,并不需要delete,但还是可以列出若干会导致内存泄漏的情况:
- 重复调用
init
函数,之前new 出来的链表节点就全部沉入虚无; - 滤波器对象被销毁,但是链表节点都没delete;
另一方面,想高效而优雅的管理好这些链表节点,其实并不是个很容易的活。每个节点会指向其他三个对象,也至少会被三个对象持有。想用智能指针的话,unique_ptr
就别想了,起码要考虑用shared_ptr
。我觉得最简单的方法是建立一个内存池,所有节点从内存池里取,不需要考虑节点之间的引用关系有多复杂,想释放,把内存池整体释放掉就好了。