C++类构造函数写法的效率比较 -- 关于 lambda递归的一些看法

1,125 阅读3分钟

今天写LeetCode的时候发现自己虽然思路与官方题解一致,但是时间复杂度和空间复杂度却有些差距,于是研究了一下C++的类构造函数的几种写法之间的效率差距。


写法比较

  • 我的写法:
    • 时间复杂度平均为38ms
    • 空间复杂度平均为24.7M
class BSTIterator {
private: 
    vector<int> temp;
    int i;
public:
    BSTIterator(TreeNode* root): i(0) {
        function<void(TreeNode *)> dfs = [&](TreeNode *node) {
            if (!node) { return; }

            dfs(node->left);
            temp.push_back(node->val);
            dfs(node->right);
        };
        dfs(root);
    }
    
    int next() {
        return temp[i++];
    }
    
    bool hasNext() {
        return i < temp.size();
    }
};
  • 官方写法:
    • 时间复杂度平均为33ms
    • 空间复杂度平均为23.6M
class BSTIterator {
private:
    void inorder(TreeNode* root, vector<int>& res) {
        if (!root) {
            return;
        }
        inorder(root->left, res);
        res.push_back(root->val);
        inorder(root->right, res);
    }
    
    vector<int> inorderTraversal(TreeNode* root) {
        vector<int> res;
        inorder(root, res);
        return res;
    }
    
    vector<int> arr;
    int idx;
public:
    BSTIterator(TreeNode* root): idx(0), arr(inorderTraversal(root)) {}
    
    int next() {
        return arr[idx++];
    }
    
    bool hasNext() {
        return (idx < arr.size());
    }
};

区别

  • 我的写法:类构造函数中使用lambda表达式初始化vector<int>属性
  • 官方写法:
    • 调用函数生成临时变量
    • 在初始化列表中使用临时属性初始化vector<int>属性

是什么带来了效率差距

一、lambda表达式?

  • 修改代码,将lambda表达式变成私有函数
    • 时间复杂度平均为31ms(似乎比官方题解快一点点?原因我们稍后解释)
    • 空间复杂度平均为23.6M
class BSTIterator {
private: 
    void dfs(TreeNode *node) {
        if (!node) { return; }

        dfs(node->left);
        temp.push_back(node->val);
        dfs(node->right);
    };
    vector<int> temp;
    int i;
    
public:
    BSTIterator(TreeNode* root): i(0) { dfs(root); }
    
    int next() {
        return temp[i++];
    }
    
    bool hasNext() {
        return i < temp.size();
    }
};
分析:效率明显提升,那么为什么使用lambda表达式不如直接调用函数效率来的快呢?
  • 在C++中,lambda表达式会被解释成某个匿名类的一个实例,关于实现原理详见这篇博客,也因此lambda表达式中无法使用this或者本类中未传入的其他属性。
    • 这里可以解释我第一种写法中[&]的意图
    BSTIterator(TreeNode* root): i(0) {
        function<void(TreeNode *)> dfs = [&](TreeNode *node) {  // 定义时使用[&]
            if (!node) { return; }
    
            dfs(node->left);
            temp.push_back(node->val);
            dfs(node->right);
        };
        dfs(root);
    }
    
    • 有动手实践的同学可能会发现[&]换成[][=]时,程序甚至会发生崩溃。
      • lambda表达式中使用了两个外部变量:dfs()temp
      • 当使用[]时,编译器会告知你找不到dfs的定义。
      • 当使用值拷贝[=]时,由于栈空间有限,我们会收到stack-overflow的崩溃信息(不仅仅是因为temp的拷贝,使用[&, dfs]也依旧会stack-overflow
    • 经过上面的分析,lambda中递归时[&]确实优于[=][],但我们应该在lambda中使用递归吗?
      • 调用lambda表达式时,我们会通过匿名对象来使用函数。而恰巧递归中函数的调用十分频繁,在这种场景下,我们选择直接函数调用必定是比lambda表达式来得快的。
      • lambda表达式生成的中间对象隐式捕获的一些值也占用了一部分内存空间。
对lambda的一些看法
  • lambda表达式应该使用于一些调用不怎么频繁的场景下,像递归这种就不太适合。
  • 隐式捕获外部参数过多的情况下,lambda生成的中间对象太笨重了。如果代码中一不小心对其进行值拷贝操作的话,内存可能会暴涨。

二、初始化列表?

  • 对于官方题解,其实调用了两次vector<int>属性的构造函数:
    • 第一次生成函数中的临时变量,
     vector<int> inorderTraversal(TreeNode* root) {
        vector<int> res;   // 为 res 调用默认构造函数
        inorder(root, res);
        return res;
    }
    
    • 第二次为构造函数调用初始化列表
    // 这里为 arr 调用构造函数
    BSTIterator(TreeNode* root): idx(0), arr(inorderTraversal(root)) {}
    
  • 对于我的写法,调用了一次vector<int>属性的构造函数:
    BSTIterator(TreeNode* root): i(0) {  // 构造列表结束,为其他属性调用默认构造函数
        dfs(root); 
    }
    
    • 这是我认为优化后的写法比官方题解快一点的原因所在。