数据流中数字的秩
题目描述
Imagine you are reading in a stream of integers. Periodically, you wish to be able to look up the rank of a number x
(the number of values less than or equal to x
). lmplement the data structures and algorithms to support these operations. That is, implement the method track (int x)
, which is called when each number is generated, and the method getRankOfNumber(int x)
, which returns the number of values less than or equal to x.
面试题 10.10. Rank from Stream LCCI 中等之困难
题解
暴力普通数组
思路
假设整数流中的整数在0~50000之间,那么我们可以预先建立一个大小为50001的int[]数组,来记录每个整数出现的次数,查询比n小的个数时,只需从0遍历到n即可。
代码
class StreamRank {
int[] nums;
public StreamRank() {
nums = new int[50001];
}
public void track(int x) {
nums[x]++;
}
public int getRankOfNumber(int x) {
int ans = 0;
for (int i = 0;i <= x;i++){
ans += nums[i];
}
return ans;
}
}
时间复杂度分析
添加元素只需自增,为\(O(1)\),查询时需要遍历所有比n小的数,时间复杂度为\(O(n)\)。若查询次数为m
,插入次数为n
,则时间复杂度为\(O(n+mn)\)。
暴力前缀和数组
思路
假设同一,将普通数组改为前缀和数组。
代码
class StreamRank {
int[] nums;
public StreamRank() {
nums = new int[50001];
}
public void track(int x) {
for(int i = x;i <= 50000;i++){
nums[i]++;
}
}
public int getRankOfNumber(int x) {
return nums[x];
}
}
时间复杂度分析
和解法一正相反,前缀和数组插入的时间复杂度是\(O(n)\),查询的时间复杂度为\(O(1)\)。若查询次数为m
,插入次数为n
,则时间复杂度为\(O(n^2+m)\)。
二叉搜索树
解法一:每个结点额外存储左子树中小于本结点的结点数目
以及自身数目
之和
class StreamRank {
BSTree bsTree;
public StreamRank() {
bsTree = new BSTree();
}
public void track(int x) {
bsTree.add(x);
}
public int getRankOfNumber(int x) {
return bsTree.query(x);
}
}
class BSTree{
TreeNode root;
public BSTree(){
}
public void add(int x){
if(root == null){
root = new TreeNode(x);
}
else{
TreeNode p = root;
while(true){
if(x == p.val){//树中已有该值,直接把数量加1
p.num++;
break;
}
else if(x < p.val){
if(p.left == null){
p.left = new TreeNode(x);
p.num++;//若是左子树,则当前结点num加1
break;
}
p.num++;//若是左子树,则当前结点num加1
p = p.left;
}
else{
if(p.right == null){
p.right = new TreeNode(x);
break;
}
p = p.right;
}
}
}
}
public int query(int x){
return queryFunc(root, x);
}
private int queryFunc(TreeNode root, int x){
if(root == null){
return 0;
}
if(x == root.val){//恰好命中,返回该命中结点的num域
return root.num;
}
else if(x < root.val){//递归在左子树中查找
return queryFunc(root.left, x);
}
else{//先加上根节点的num域,再递归在右子树中查找
return root.num + queryFunc(root.right, x);
}
}
}
class TreeNode{
int val;
int num;//存储左子树中小于本结点的个数与本结点数目之和
TreeNode left;
TreeNode right;
public TreeNode(int val){
this.val = val;
this.num = 1;
}
}
解法二:每个结点额外存储自身数目
以及子结点总数
两个域
class StreamRank {
BSTree bsTree;
public StreamRank() {
bsTree = new BSTree();
}
public void track(int x) {
bsTree.add(x);
}
public int getRankOfNumber(int x) {
return bsTree.query(x);
}
}
class BSTree{
TreeNode root;
public BSTree(){
}
public void add(int x){
if(root == null){
root = new TreeNode(x);
}
else{
TreeNode p = root;
while(true){
if(x == p.val){//等于当前结点,表明树中已有该值,直接把结点自身数量加1
p.selfNum++;
break;
}
else if(x < p.val){//小于当前结点
if(p.left == null){//若当前结点左子树为空,则新建结点,并将当前结点子结点数目加1
p.left = new TreeNode(x);
p.childNum++;
break;
}
p.childNum++;//若当前结点左子树非空,继续向左寻找,并将当前结点子结点数目加1
p = p.left;
}
else{//大于当前节点
if(p.right == null){//若当前结点右子树为空,则新建结点,并将当前结点子节点数目加1
p.right = new TreeNode(x);
p.childNum++;
break;
}
p.childNum++;//若当前结点右子树非空,继续向右寻找,并将当前结点子节点数目加1
p = p.right;
}
}
}
}
public int query(int x){
return queryFunc(root, x);
}
private int queryFunc(TreeNode root, int x){
if(root == null){
return 0;
}
if(x == root.val){
//查询结点等于当前结点,那么小于等于查询结点的数目=该结点的selfNum+左子树的结点数(即左孩子的selfNum+左孩子的子结点数目)
return root.selfNum + (root.left == null ? 0 : root.left.childNum + root.left.selfNum);
}
else if(x < root.val){
//查询结点小于当前结点,那么递归地在左子树中查询
return queryFunc(root.left, x);
}
else{
//查询结点大于当前结点,那么就已经知道,至少根结点和左子树的所有结点都小于查询结点
//于是先加上这些已知的结点数目,再递归地在右子树中查询
return root.selfNum + (root.left == null ? 0 : root.left.childNum + root.left.selfNum) + queryFunc(root.right, x);
}
}
}
class TreeNode{
int val;
int selfNum;//结点自身数目
int childNum;//子结点个数
TreeNode left;
TreeNode right;
public TreeNode(int val){
this.val = val;
this.childNum = 0;
this.selfNum = 1;
}
}
复杂度分析
对于二叉搜索树,平均每次插入和查询为\(O(logn)\)的时间复杂度。n
次插入和查询则为\(O(nlogn)\)的时间复杂度。
最坏情况下,二叉搜索树形成一条链,时间复杂度接近\(O(n^2)\)。
算法改进
由于特殊情况下二叉查找树的退化,本题可以使用改进的二叉查找自平衡树,如AVL树、红黑树等。
由于AVL树、红黑树的实现代码比较复杂,这里就不再讨论。对于本题,除了树的具体实现方式不同,他们额外添加的结点信息都是相同的。
树状数组
思路
限制数据流中的数字最大为50000、最小为0的情况下,使用树状数组可以在O(logn)的时间内快速更新和查询。
代码
class StreamRank {
FenwickTree fenwickTree;
public StreamRank() {
fenwickTree = new FenwickTree(50001);
}
public void track(int x) {
fenwickTree.update(x, 1);
}
public int getRankOfNumber(int x) {
return fenwickTree.query(x);
}
}
class FenwickTree{
int[] tree;
int len;
public FenwickTree(int n){
this.tree = new int[n + 1];
this.len = n;
}
public void update(int i, int dest){
i++;//由于0的存在,向右平移一位
while(i <= len){
tree[i] += dest;
i += lowbit(i);
}
}
public int query(int i){
i++;//由于0的存在,向右平移一位
int sum = 0;
while(i > 0){
sum += tree[i];
i -= lowbit(i);
}
return sum;
}
private int lowbit(int x){
return x & (-x);
}
}
复杂度分析:
使用树状数组可以在\(O(logn)\)的时间内快速更新和查询。n
次更新和查询的时间复杂度为\(O(nlogn)\)。
缺点及改进:
当数据流中的数字过大时,需要开辟较大数组,不满足实际要求。
虽然可以通过映射排名离散化和离线处理的手段,但不符合数据流实时读入和查询的在线要求,当然,如果只是为了得到数据流的结果而不强调实时性,所有查询都可以在最后输出,这种离线处理依然可行。
References:
[1] 数据结构与算法分析:Java语言描述-Mark Allen Weiss
[2] 程序员面试金典-Gayle Laakmann McDowell