编程之法 -面试和算法心得 - 数组

《编程之法:面试和算法心得》

本章导读

面试编程题的常用思路:

  1. 暴力穷举(递归、回溯) 如求n个数的全排列或八皇后(N皇后问题)
  2. 分治法
  3. 空间换时间(如活用哈希表)
  4. 合适的数据结构,如寻找最小的k个数中,用堆代替数组
  5. 排序
  6. 不允许排序,考虑不改变数列顺序的贪心算法(如最小生成树Prim、kruskal和最短路径dijkstra,或动态规划(01背包)
  7. 细节处理,不要忽略边界

小结:
1.寻找最小K个数:(1).排序 (2) k最大堆 (3) partition
2.寻找和为给定值的两个数:(1). sum-a[i] 数组 (2). hash表 (3) 两指针两端扫描
3.寻找和为定值的多个数:(1) 0-1背包 (2) 回溯+剪枝
4.最大连续子数组和:(1).暴力 (2).(a[j], currSum+a[j])扫描
5.跳台阶:(1) fibonacci
6.奇偶排序:(1)partition (2) 第二种partition
7.荷兰国旗: (1) 三指针partition
8.矩阵相乘:(1). 暴力 (2) strassen算法
9.完美洗牌:(1). 暴力 (2). i --> (2*i)%(2*n+1)(3)利用神级结论2*n=(3^k - 1)

第二章 数组

2.1 寻找最小k个数

题目描述

输入n个整数,输出其中最小的k个。

分析与解法

解法一: 排序

思想:排序,遍历输出K
时间复杂度:O(n * logn) + O(k) = O(n*logn)

解法二: k数组+选择排序

思想:维护一个容量为k的数组,利用选择或交换排序得到最大值kmax(O(k)),n-k个每个与kmax比较然后更新(O(k))
时间复杂度:O(n*k)

解法三: k最大堆

思想:维护容量为k的最大堆
时间复杂度:O( k + (n-k)*logk) = O(n*logk)

解法四: 快速选择算法

快速选择算法:

  • 选取S中一个元素作为枢纽元pivot,将集合S-{pivot}分割成S1和S2,就像快速排序那样
    • 如果k <= |S1|, 第k个最小元素在S1中,返回QuickSelect(S1, k)。
    • 如果k = 1+|S1|,pivot为第k个最小元素,直接返回
    • 否则,第k个最小元素在S2中,即S2中第(k - |S1| - 1) 个最小元素,返回QuickSelect(S2, k - |S1| - 1)

平均时间复杂度O(n)

更进一步,《算法导论》第9章第9.3节介绍了一个最坏情况下亦为O(n)时间的SELECT算法,有兴趣的读者可以参看。




2.2 寻找和为定值的两个数

题目描述

输入一个数组和一个数字,在数组中查找两个数,使得它们的和正好是输入的那个数字。
要求时间复杂度是O(N)。如果有多对数字的和等于输入的数字,输出任意一对即可。
例如输入数组1、2、4、7、11、15和数字15。由于4+11=15,因此输出4和11。

分析与解法

直接穷举,从数组中任选2个数判断和是否为给定数字,O(n^2)
题目相当于:对每个a[i],查找sum - a[i] 是否也在原始序列中,如何提高查找速度?
答案是二分查找,将O(N)查找变为O(log N),如果有序直接二分,无序,则排序后二分

如何优化到O(N)呢?

解法一:开创sum-a[i]的数组

思想:创建sum-a[i]的数组,由左到右扫a[i],由右到左扫b[i],相等即结果。

根据前面的分析,a[i]在序列中,如果a[i]+a[k]=sum的话,那么sum-a[i](a[k])也必然在序列中。 举个例子,如下: 原始序列:
1、 2、 4、 7、11、15
用输入数字15减一下各个数,得到对应的序列为:
14、13、11、8、4、 0

第一个数组以一指针i 从数组最左端开始向右扫描,第二个数组以一指针j 从数组最右端开始向左扫描,如果第一个数组出现了和第二个数组一样的数,即a[i]=a[j],就找出这俩个数来了。 如上,i,j最终在第一个,和第二个序列中找到了相同的数4和11,所以符合条件的两个数,即为4+11=15。 怎么样,两端同时查找,时间复杂度瞬间缩短到了O(N),但却同时需要O(N)的空间存储第二个数组。

解法二: hash表

构造hash表,空间换时间
时间O(N),空间O(N)

解法三: 两指针两端扫描法

  • 如果数组无序
    • 先排序O(N logN)
    • 两个指针i = 0, j = n-1
      • 如果某时刻a[i] + a[j] > sum, j–
      • 如果某时刻a[i] + a[j] < sum, i++

数组无序时,时间复杂度O(N logN + N) = O(N logN)

  • 如果数组有序,直接扫,O(N)搞定,空间复杂度O(1)

解法总结

不论是有序还是无序,解决这类问题有三种解法:
1、二分(若无序,先排序后二分),时间复杂度总为O(N log N),空间复杂度为O(1);
2、扫描一遍X-S[i] 映射到一个数组或构造hash表,时间复杂度为O(N),空间复杂度为O(N);
3、两个指针两端扫描(若无序,先排序后扫描),时间复杂度最后为:有序O(N),无序O(N log N + N)=O(N log N),空间复杂度都为O(1)。

所以,要想达到时间O(N),空间O(1)的目标,除非原数组是有序的(指针扫描法),不然,当数组无序的话,就 (1)只能先排序,后指针扫描法或二分(时间 O(Nlog N),空间O(1)),或 (2)映射或hash(时间O(N),空间O(N))。时间或空间,必须牺牲一个,达到平衡。

综上,若是数组有序的情况下,优先考虑两个指针两端扫描法,以达到最佳的时O(N),空O(1)效应。否则,如果要排序的话,时间复杂度最快当然是只能达到O(N log N),空间O(1)则不在话下。

问题扩展

  1. 如果在返回找到的两个数的同时,还要求你返回这两个数的位置列?
  2. 如果需要输出所有满足条件的整数对呢?
  3. 如果把题目中的要你寻找的两个数改为“多个数”,或任意个数列?



2.3 寻找和为定值的多个数

题目描述

输入两个整数n和sum,从数列1,2,3…n 中随意取几个数,使其和等于sum,要求将其中所有的可能组合列出来。

分析与解法

解法一:0-1背包

注意到取n,和不取n个区别即可,考虑是否取第n个数的策略,可以转化为一个只和前n-1个数相关的问题。

  • 如果取第n个数,那么问题就转化为“取前n-1个数使得它们的和为sum-n”,对应的代码语句就是sumOfkNumber(sum - n, n - 1);
  • 如果不取第n个数,那么问题就转化为“取前n-1个数使得他们的和为sum”,对应的代码语句为sumOfkNumber(sum, n - 1)。
list<int>list1;
void SumOfkNumber(int sum, int n)
{
    // 递归出口
    if (n <= 0 || sum <= 0)
        return;
    // 输出找到的结果
    if (sum == n)
    {
        // 反转list
        list1.reverse();
        for (list<int>::iterator iter = list1.begin(); iter != list1.end(); iter++)
            cout << *iter << " + ";
        cout << n << endl;
        list1.reverse()//此处还需反转回来
    }

    list1.push_front(n);      //典型的01背包问题
    SumOfkNumber(sum - n, n - 1);   //“放”n,前n-1个数“填满”sum-n
    list1.pop_front();
    SumOfkNumber(sum, n - 1);     //不“放”n,n-1个数“填满”sum
}

学会用list.reverse()和list.push_front()和list.pop_front()

解法二:???回溯+剪枝

这个问题属于子集和问题(也是背包问题)。本程序采用回溯法+剪枝,其中X数组是解向量,t=∑(1,…,k-1)Wi*Xi, r=∑(k,…,n)Wi,且

  • 若t+Wk+W(k+1)<=M,则Xk=true,递归左儿子(X1,X2,…,X(k-1),1);否则剪枝;
  • 若t+r-Wk>=M && t+W(k+1)<=M,则置Xk=0,递归右儿子(X1,X2,…,X(k-1),0);否则剪枝;
    本题中W数组就是(1,2,…,n),所以直接用k代替WK值。



2.4 最大连续子数组和

题目描述

输入一个整形数组,数组里有正数也有负数。数组中连续的一个或多个整数组成一个子数组,每个子数组都有一个和。 求所有子数组的和的最大值,要求时间复杂度为O(n)。

例如输入的数组为1, -2, 3, 10, -4, 7, 2, -5,和最大的子数组为3, 10, -4, 7, 2, 因此输出为该子数组的和18。

分析与解法

解法一:暴力

思想:暴力,计算每个i到j元素的和
时间复杂度:O(n^3)

解法二:(a[j], currSum+a[j])扫描

事实上,当我们令currSum为当前最大子数组的和,maxSum为最后要返回的最大子数组的和,当我们往后扫描时,

  • 对第j+1个元素有两种选择:要么放入前面找到的子数组,要么做为新子数组的第一个元素;
    • 如果currSum加上当前元素a[j]后不小于a[j],则令currSum加上a[j],否则currSum重新赋值,置为下一个元素,即currSum = a[j]。
    • 同时,当currSum > maxSum,则更新maxSum = currSum,否则保持原值,不更新。

举一反三

  1. 最大矩形,子矩阵和
  2. 允许交换两个数的位置,求最大子数组和。
    https://app.codility.com/cert/view/certDUMWPM-8RF86G8P9QQ6JC8X/details/



2.6 跳台阶

题目描述

一个台阶总共有n 级,如果一次可以跳1 级,也可以跳2 级。
求总共有多少总跳法,并分析算法的时间复杂度。

分析与解法

解法一:Fibonacci

f(n) = 1 (n = 1)
     = 2 (n = 2)
     = f(n-1) + f(n-2) (n>2)

解法二:空间优化

dp[n] = dp[n-1] + dp[n-2], 用两个dp[0], dp[1], dp[2] 即可

举一反三

  1. 换硬币问题



2.6 奇偶排序

题目描述

输入一个整数数组,调整数组中数字的顺序,使得所有奇数位于数组的前半部分,所有偶数位于数组的后半部分。要求时间复杂度为O(n)。

分析与解法

解法一:partition

思想:借鉴partition,两个指针分别从数组的头部和尾部向数组的中间移动,如果第一个指针指向的数字是偶数而第二个指针指向的数字是奇数,我们就交换这两个数字。

//判断是否为奇数
bool IsOddNumber(int data)
{
    return data & 1 == 1;
}

//奇偶互换
void OddEvenSort(int *pData, unsigned int length)
{
    if (pData == NULL || length == 0)
        return;

    int *pBegin = pData;
    int *pEnd = pData + length - 1;

    while (pBegin < pEnd)
    {
        //如果pBegin指针指向的是奇数,正常,向右移
        if (IsOddNumber(*pBegin))  
        {
            pBegin++;
        }
        //如果pEnd指针指向的是偶数,正常,向左移
        else if (!IsOddNumber(*pEnd))
        {
            pEnd--;
        }
        else
        {
            //否则都不正常,交换
            //swap是STL库函数,声明为void swap(int& a, int& b);
            swap(*pBegin, *pEnd);
        }
    }
}

解法二:partition-2

按快排partition的第二种实现,就是wb教的那种

借鉴partition的上述实现,我们也可以维护两个指针i和j,一个指针指向数组的第一个数的前一个位置,我们称之为后指针i,向右移动;一个指针指向数组第一个数,称之为前指针j,也向右移动,且前指针j先向右移动。如果前指针j指向的数字是奇数,则令i指针向右移动一位,然后交换i和j指针所各自指向的数字

PS: 感觉这种可以保证数据的相对位置不变

/奇偶互换
void OddEvenSort2(int data[], int lo, int hi)
{
    int i = lo - 1;
    for (int j = lo; j < hi; j++)
    {
        //data[j]指向奇数,交换
        if ( IsOddNumber(data[j]) )
        {
            i = i + 1;
            swap(data[i], data[j]);
        }
    }
    swap(data[i + 1], data[hi]);
}



2.7 荷兰国旗

题目描述

现有n个红白蓝三种不同颜色的小球,乱序排列在一起,请通过两两交换任意两个球,使得从左至右,依次是一些红球、一些白球、一些蓝球。

分析与解法

借鉴partition过程,设置3个指针完成重新排列

解法一: partition

类似partition,三指针:一个前指针begin,一个中指针current,一个后指针end

  1. current所指元素为0时,与begin指针所指元素交换,current++,begin++
  2. current所指元素为1时,不动,current++
  3. current所指元素为2时,与end交换,current不动,end–。因为第三步中current指针所指元素与end指针所指元素交换之前,如果end指针之前指的元素是0,那么与current指针所指元素交换之后,current指针此刻所指的元素是0,此时,current指针能动么?不能动,因为如上述第1点所述,如果current指针所指的元素是0,还得与begin指针所指的元素交换。
//引用自gnuhpc  
while( current<=end )        
{             
  if( array[current] ==0 )             
   {                 
      swap(array[current],array[begin]);                  
      current++;                  
      begin++;            
   }             
   else if( array[current] == 1 )            
   {                 
      current++;            
   }   

   else //When array[current] =2   
   {               
      swap(array[current],array[end]);                
      end--;            
   }      
}



2.8 矩阵相乘

题目描述

请编程实现矩阵乘法,并考虑当矩阵规模较大时的优化方法。

分析与解法

解法一、暴力解法

O(n^3)

解法二:strassen算法

分治矩阵
https://www.cnblogs.com/hdk1993/p/4552534.html
当给定两个二维矩阵AB时,可以分为更小的矩阵,我们发现在相乘的过程中,有8次乘法,4次加法

表面上看,Strassen算法仅仅比通用矩阵相乘算法好一点,因为通用矩阵相乘算法时间复杂度是n3,而Strassen算法复杂度只是O(nlog7)=O(n2.807)。但随着n的变大,比如当n >> 100时,Strassen算法是比通用矩阵相乘算法变得更有效率。




2.9 完美洗牌

题目描述

有个长度为2n的数组{a1,a2,a3,…,an,b1,b2,b3,…,bn},希望排序后{a1,b1,a2,b2,…,an,bn},请考虑有无时间复杂度o(n),空间复杂度0(1)的解法。

分析与解法

解法一、蛮力变换

  1. 步步前移
    第①步、确定b1的位置,即让b1跟它前面的a2,a3,a4交换:
    a1,b1,a2,a3,a4,b2,b3,b4
    第②步、接着确定b2的位置,即让b2跟它前面的a3,a4交换:
    a1,b1,a2,b2,a3,a4,b3,b4
    第③步、b3跟它前面的a4交换位置:
    a1,b1,a2,b2,a3,b3,a4,b4
    b4已在最后的位置,不需要再交换。如此,经过上述3个步骤后,得到我们最后想要的序列。但此方法的时间复杂度为O(N^2),我们得继续寻找其它方法,看看有无办法能达到题目所预期的O(N)的时间复杂度。

  2. 中间交换
    当然,除了如上面所述的让b1,b2,b3,b4步步前移跟它们各自前面的元素进行交换外,我们还可以每次让序列中最中间的元素进行交换达到目的。还是用上面的例子,针对a1,a2,a3,a4,b1,b2,b3,b4
    第①步:交换最中间的两个元素a4,b1,序列变成(待交换的元素用粗体表示):
    a1,a2,a3,b1,a4,b2,b3,b4
    第②步,让最中间的两对元素各自交换:
    a1,a2,b1,a3,b2,a4,b3,b4
    第③步,交换最中间的三对元素,序列变成:
    a1,b1,a2,b2,a3,b3,a4,b4
    同样,此法同解法1.1、步步前移一样,时间复杂度依然为O(N^2),我们得下点力气了。

解法二、完美洗牌算法

完美洗牌算法,即给定一个数组a1,a2,a3,…an,b1,b2,b3…bn,最终把它置换成b1,a1,b2,a2,…bn,an。读者可以看到,这个完美洗牌问题本质上与本题完全一致,只要在完美洗牌问题的基础上对它最后的序列swap两两相邻元素即可。

故下文将从完美洗牌算法的最基本的原型开始说起,以让读者能对此算法一目了然。

2.1 位置置换pefect_shuffle1算法

起始序列:a1 a2 a3 a4 b1 b2 b3 b4 数组下标:1 2 3 4 5 6 7 8 最终序列:b1 a1 b2 a2 b3 a3 b4 a4
从上面的例子我们能看到,前n个元素中, 第1个元素a1到了原第2个元素a2的位置,即1->2; 第2个元素a2到了原第4个元素a4的位置,即2->4;第3个元素a3到了原第6个元素b2的位置,即3->6;第4个元素a4到了原第8个元素b4的位置,即4->8;

那么推广到一般情况即是:前n个元素中,第i个元素去了 第(2 * i)的位置

上面是针对前n个元素,那么针对后n个元素,可以看出:第5个元素b1到了原第1个元素a1的位置,即5->1;第6个元素b2到了原第3个元素a3的位置,即6->3;第7个元素b3到了原第5个元素b1的位置,即7->5;第8个元素b4到了原第7个元素b3的位置,即8->7;

推广到一般情况是,后n个元素,第i个元素去了第 (2 (i - n) ) - 1 = 2 i - (2 n + 1) = (2 i) % (2 * n + 1) 个位置

再综合到任意情况,任意的第i个元素,我们最终换到了 (2 i) % (2 n + 1)的位置。 为何呢?因为: > 当0 < i < n时, 原式= (2i) % (2 n + 1) = 2i; > 当i > n时,原式(2 i) % (2 * n + 1)保持不变。

因此,如果题目允许我们再用一个数组的话,我们直接把每个元素放到该放得位置就好了。也就产生了最简单的方法pefect_shuffle1,参考代码如下:

// 时间O(n),空间O(n) 数组下标从1开始
void PefectShuffle1(int *a, int n)
{
    int n2 = n * 2, i, b[N];
    for (i = 1; i <= n2; ++i)
    {
        b[(i * 2) % (n2 + 1)] = a[i];
    }
    for (i = 1; i <= n2; ++i)
    {
        a[i] = b[i];
    }
}

但很明显,它的时间复杂度虽然是O(n),但其空间复杂度却是O(n),仍不符合本题所期待的时间O(n),空间O(1)。我们继续寻找更优的解法。

与此同时,我也提醒下读者,根据上面变换的节奏,我们可以看出有两个圈, 一个是1 -> 2 -> 4 -> 8 -> 7 -> 5 -> 1;一个是3->6->3

下文2.2.1、走圈算法cycle_leader将再次提到这两个圈。

2.2、完美洗牌算法perfect_shuffle2
2.2.1 走圈算法cycle_leader

当n=4的情况:
起始序列:a1 a2 a3 a4 b1 b2 b3 b4 数组下标:1 2 3 4 5 6 7 8 最终序列:b1 a1 b2 a2 b3 a3 b4 a4

“于此同时,我也提醒下读者,根据上面变换的节奏,我们可以看出有两个圈, > 一个是1 -> 2 -> 4 -> 8 -> 7 -> 5 -> 1;一个是3 -> 6 -> 3。”

这两个圈可以表示为(1,2,4,8,7,5)和(3,6),且perfect_shuffle1算法也已经告诉了我们,不管你n是奇数还是偶数,每个位置的元素都将变为第(2*i) % (2n+1)个元素:
因此我们只要知道圈里最小位置编号的元素即圈的头部,顺着圈走一遍就可以达到目的,且因为圈与圈是不相交的,所以这样下来,我们刚好走了O(N)步。

还是举n=4的例子,且假定我们已经知道第一个圈和第二个圈的前提下,要让1 2 3 4 5 6 7 8变换成5 1 6 2 7 3 8 4:
第一个圈:1 -> 2 -> 4 -> 8 -> 7 -> 5 -> 1 第二个圈:3 -> 6 -> 3:
原始数组:1 2 3 4 5 6 7 8 数组下标:1 2 3 4 5 6 7 8
走第一圈:5 1 3 2 7 6 8 4 走第二圈:5 1 6 2 7 3 8 4

上面沿着圈走的算法我们给它取名为cycle_leader,这部分代码如下:

//数组下标从1开始,from是圈的头部,mod是要取模的数 mod 应该为 2 * n + 1,时间复杂度O(圈长)
void CycleLeader(int *a, int from, int mod)
{
    int t,i;

    for (i = from * 2 % mod; i != from; i = i * 2 % mod)
    {
        swap(a[i],a[from]);
    }
}
2.2.2 神级结论:若2*n=(3^k - 1),则可确定圈的个数及各自头部的起始位置

下面我要引用此论文“A Simple In-Place Algorithm for In-Shuffle”的一个结论了,即 对于2*n = (3k-1)这种长度的数组,恰好只有k个圈,且每个圈头部的起始位置分别是1,3,9,…3^(k-1)。

论文原文部分为:
也就是说,利用上述这个结论,我们可以解决这种特殊长度2*n = (3^k-1)的数组问题,那么若给定的长度n是任意的咋办呢?此时,我们可以采取分而治之算法的思想,把整个数组一分为二,即拆分成两个部分:

让一部分的长度满足神级结论:若2*m = (3k-1),则恰好k个圈,且每个圈头部的起始位置分别是1,3,9,…3^(k-1)。其中m < n,m往神级结论所需的值上套;

剩下的n-m部分单独计算;
当把n分解成m和n-m两部分后,原始数组对应的下标如下(为了方便描述,我们依然只需要看数组下标就够了):
原始数组下标:1…m m+1… n, n+1 … n+m, n+m+1,…2n
且为了能让前部分的序列满足神级结论2
m = (3k-1),我们可以把中间那两段长度为n-m和m的段交换位置,即相当于把m+1…n,n+1…n+m的段循环右移m次(为什么要这么做?因为如此操作后,数组的前部分的长度为2m,而根据神级结论:当2m=3^k-1时,可知这长度2m的部分恰好有k个圈)。即(1…m)(m+1…n+m)(n+m+1…2n) --> (1…m)(n+1…n+m)(m+1…n)(n+m+1…2n),中间(n+1…n+m)的长度为m

而如果读者看过本系列第一章、左旋转字符串的话,就应该意识到循环位移是有O(N)的算法的,其思想即是把前n-m个元素(m+1… n)和后m个元素(n+1 … n+m)先各自翻转一下,再将整个段(m+1… n, n+1 … n+m)翻转下。

翻转后,得到的目标数组的下标为:1…m n+1…n+m m+1 … n n+m+1,…2*n

OK,理论讲清楚了,再举个例子便会更加一目了然。当给定n=7时,若要满足神级结论2*n=3^k-1,k只能取2,继而推得n‘=m=4。
原始数组:a1 a2 a3 a4 a5 a6 a7 b1 b2 b3 b4 b5 b6 b7
既然m=4,即让上述数组中有下划线的两个部分交换,得到:
目标数组:a1 a2 a3 a4 b1 b2 b3 b4 a5 a6 a7 b5 b6 b7

继而目标数组中的前半部分a1 a2 a3 a4 b1 b2 b3 b4部分可以用2.2.1、走圈算法cycle_leader搞定,于此我们最终求解的n长度变成了n’=3,即n的长度减小了4,单独再解决后半部分a5 a6 a7 b5 b6 b7即可。

2.2.3、完美洗牌算法perfect_shuffle3

从上文的分析过程中也就得出了我们的完美洗牌算法,其

算法流程为:
1.输入数组 A[1…2 n]
2.step 1 找到 2*m = 3k - 1 使得 3k <= 2 n < 3^(k +1)
3.step 2 把a[m + 1…n + m]那部分循环移m位
4.step 3 对每个i = 0,1,2…k - 1,3i是个圈的头部,做cycle_leader算法,数组长度为m,所以对2 m + 1取模。
5. step 4 对数组的后面部分A[2 m + 1… 2 n]继续使用本算法, 这相当于n减小了m。

此完美洗牌算法实现的参考代码如下:

//copyright@caopengcs 8/24/2013
//时间O(n),空间O(1)
void PerfectShuffle2(int *a, int n)
{
    int n2, m, i, k, t;
    for (; n > 1;)
    {
        // step 1
        n2 = n * 2;
        for (k = 0, m = 1; n2 / m >= 3; ++k, m *= 3)
          ;
        m /= 2;
        // 2m = 3^k - 1 , 3^k <= 2n < 3^(k + 1)

        // step 2
        right_rotate(a + m, m, n);

        // step 3
        for (i = 0, t = 1; i < k; ++i, t *= 3)
        {
          cycle_leader(a , t, m * 2 + 1);
        }

        //step 4
        a += m * 2;
        n -= m;

    }
    // n = 1
    t = a[1];
    a[1] = a[2];
    a[2] = t;
}
2.2.4 perfect_shuffle2算法解决其变形问题

以上代码即解决了完美洗牌问题,那么针对本章要解决的其变形问题呢?是的,如本章开头所说,在完美洗牌问题的基础上对它最后的序列swap两两相邻元素即可

证明:…

本章数组和队列的习题

猜你喜欢

转载自blog.csdn.net/u010521366/article/details/88998010