基于链表的滑动中值滤波器实现思路

总之,先假设底层的链表已经实现好了,反正很简单。

原理

基本上,中值滤波,或者说滑动中值滤波,需要做三件事:

  1. 在新数据添加到窗口的同时,将最旧的数据删除;
  2. 对窗口中的数据排序;
  3. 找出中位数;

排序

每个新数据将被加入一个双向链表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 NM,中点就向后移动。

删除旧数据也会导致链表中点发生变化,和添加新数据同理:如果前面短了,就让中点向后,否则让中点向前。为了避免重复移动中点,可以一次性比较新数据 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=DM,如果新数据 N < M N < M N<M,那就对应前面变长,后面变短的情况,中值点向前移动,不需要额外处理。

但如果 N ≥ M N \ge M NM,对应链表后半部分变长又缩短,按原来的思路,中值点并不需要移动。所以需要额外检查这种情况,当 N ≥ M N \ge M NM,同时弹出的旧节点正好就是中值点时,中值点应该向后移动。

删除旧数据

首先要找到最旧的数据,这个地方就稍微有点麻烦了,双向链表里的数据是按大小排序的,无法直接判断数据加入的先后顺序。我的思路是:另外再加一个单向链表,也可以说是队列,就起名叫ForwardLinkedList;每个数据将被同时加入双向链表和这个队列中,队列自然就有先进先出的功能,可以高效的找出最旧的数据。

具体来说,存放数据的链表节点应该含有三个指针域,即nextprevforwardnextprev 用来指向双向链表中的前一节点和后一节点,forward 用来在单向链表中指向后一节点。这些指针的类型都是一样的,指向存放数据的链表节点类型,可以取名叫DoubleLinkedNode。要删除旧数据,只需从队列中弹出最旧的节点,然后从双向链表中将该节点删除。

优点

  1. 运行可能比较快:这里用链表本身就是拿空间换时间,数据增删和挪动都只要一步完成。每次添加新数据时,排序最多只用遍历一次,理论上耗时也比较短;

缺点

  1. 比较费空间:如果滤波器接收的数据是float 类型,塞进链表节点里之后,加上三个指针,存储每个数据要耗费三倍的额外空间;
  2. 管理链表要操作一堆指针:尤其这还是两个链表交织到一起,写代码要小心点儿;

伪代码实现

还是假设底层链表已经实现好了,拿伪代码把思路细化。关于中值点移动的几种情况的处理,参考代码中的注释。


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,但还是可以列出若干会导致内存泄漏的情况:

  1. 重复调用init 函数,之前new 出来的链表节点就全部沉入虚无;
  2. 滤波器对象被销毁,但是链表节点都没delete;

另一方面,想高效而优雅的管理好这些链表节点,其实并不是个很容易的活。每个节点会指向其他三个对象,也至少会被三个对象持有。想用智能指针的话,unique_ptr 就别想了,起码要考虑用shared_ptr。我觉得最简单的方法是建立一个内存池,所有节点从内存池里取,不需要考虑节点之间的引用关系有多复杂,想释放,把内存池整体释放掉就好了。

  • 10
    点赞
  • 22
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值