题目分析

题目链接:https://leetcode.com/problems...

最直接的方法,拿到一个数x以后,依次遍历x右边的所有数并与x比较,对小于x的数字进行计数,计数的结果就是在x右边、比x小的数字。容易看出,这个方案的时间复杂度是O(n^2),不是很理想。
上面方法对时间的浪费体现在:完全没有利用已经计算得到的信息,把所有可以做的比较都做了一遍。如果我们在拿到x之前已经通过比较知道了y<z,且y和z都在x右边,那么x有必要再和y和z都比较一遍吗?显然不需要,因为大小比较是具有传递性的。

我们拿到一个数x以后,只要通过几次比较找到在x右边、恰好比x小一点点的那个数y,然后那些在x右边、小于等于y的数,就是x的答案。
因此我们只要从右往左扫描,维护一个排好序的数组,就能在O(logn)的时间内找到y,小于等于y的数字能在O(1)内找到(因为维护了一个排好序的数组),这就是数字x的答案。然后我们将x也加入已排序数组中(O(1),只要插入到y右边的位置),继续向左扫描。
可以预想到,从右往左扫描,每遇到一个数消耗O(logn)的时间,总共用O(nlogn)的时间就能计算出所有数的结果。

代码实现

class Solution
{
  public:
    vector<int> countSmaller(vector<int> &nums)
    {
        deque<int> sorted;
        vector<int> answer(nums.size(), 0);
        for (int i = nums.size() - 1; i >= 0; --i)
        {
            auto it = lower_bound(sorted.begin(), sorted.end(), nums[i]);
            answer[i] = distance(sorted.begin(), it);
            sorted.insert(it, nums[i]);
        }
        return answer;
    }
};

这个算法已经充分利用了已经得到的信息,但是,deque::insert中间插入元素的时间复杂度是O(n),因此实际的时间复杂度达不到理想的O(nlogn),而退化为了O(n^2)。作者尚不知道哪种容器既能够进行二分查找又能够在常数时间内插入元素

为了更好地理解为什么二分查找与快速插入元素无法兼得,你可以思考一下能不能使用二分查找来优化插入排序(评论区指出了问题)。还可以看看stackoverflow上的讨论。

二叉树解法

在前面可以看出一个很有意思的线索:既能够进行二分查找又能够在常数时间内插入元素(这里的插入时间不包括插入之前查找位置的时间),虽然没有开箱即用的容器,但是二叉搜索树不就符合这个要求吗?(二叉树搜索类似于二分查找)因此衍生出了另一种解法:

从右往左依次扫描,并维护一个二叉搜索树(最好是平衡的)。每个树节点表示一个已扫描过的数字,并且在节点中还存储了这个节点的左子树的大小l_size。每次扫描到一个数字x,找到x应该插入到哪个位置(这等价于二分查找),假设找到的这个位置为y。在从根节点到y的路径上,每当选择走进一个节点z的右子树,x的答案 += z.l_size(因为在z左子树中的节点肯定都比x小);每当选择走进一个节点z的左子树,z.l_size++ 。

代码实现

class Solution
{
  public:
    struct node
    {
        node *left;
        node *right;
        int index_in_nums;
        int l_size;
        node(int i)
        {
            this->left = NULL;
            this->right = NULL;
            this->l_size = 0;
            this->index_in_nums = i;
        }
    };

    vector<int> countSmaller(vector<int> &nums)
    {
        const int size = nums.size();
        vector<int> answer(size, 0);
        if (size <= 1)
            return answer;

        struct node *root = new struct node(size - 1);

        for (int i = size - 2; i >= 0; --i)
        {
            struct node *current = root;
            while (true)
            {
                if (nums[i] < nums[current->index_in_nums])
                {
                    // 走进current的左子树
                    current->l_size++;
                    if (current->left == NULL)
                    {
                        // 已经走到叶子节点,nums[i]应该插入到 current->left
                        current->left = new node(i);
                        break;
                    }
                    current = current->left;
                }
                else
                {
                    // 走进current的右子树
                    answer[i] += current->l_size;
                    if (nums[i] != nums[current->index_in_nums])
                    {
                        answer[i]++; // current也比nums[i]小
                    }
                    if (current->right == NULL)
                    {
                        // 已经走到叶子节点,nums[i]应该插入到 current->right
                        current->right = new node(i);
                        break;
                    }
                    current = current->right;
                }
            }
        }
        // 为了展示简洁,这里不提供销毁二叉树的代码
        return answer;
    }
};

在不考虑二叉树失衡的情况下,查找时间是O(logn),找到以后用O(1)的时间插入节点,总共花费O(nlogn)的时间。

如果要实现树的自我平衡,应该考虑AVL树或红黑树。

上面的实现还有一个小问题:相等的数字将成为不同的节点。假设输入为[1, 1, ...],产生的树将是“↘”的线形状。冗余的节点不仅浪费空间,而且会增加树的查找开销。可以考虑在节点中多存储一个信息:duplicate,表示这个节点代表了多少个相同的数字。这样产生的树会更加精简高效。

更多资料

归并排序题解将介绍这道题的另一种解法,能够轻松达到O(nlogn)的时间复杂度。


csRyan
1.1k 声望198 粉丝

So you're passionate? How passionate? What actions does your passion lead you to do? If the heart doesn't find a perfect rhyme with the head, then your passion means nothing.