プライマー
ここで、配列 arr = [4, 7, 5, 3, 8, 9, 0, 1, 2, 6]、arr.length = n として、次の操作を不定期に繰り返し実行します。
- arr の指定区間 [l, r] 内の最大値 max を問い合わせます
- arr の指定された区間 [l, r] 内の要素の合計をクエリします。
- arr は、指定されたインデックス i の位置にある要素に C を追加するか、C で上書きします。
- arr は、指定された間隔 [l, r] の各要素値に対して C を追加するか、C をオーバーライドします。
で:
- クエリの時間計算量 (最大間隔、合計間隔) は O(n) です。
- 単一の値を更新する時間計算量は O(1) です。
- 間隔更新の時間計算量は O(n) です
arr の指定された間隔の合計を複数回解く必要がある場合は、プレフィックスと最適化を使用できます。詳細については、次を参照してください。
アルゴリズム設計 - プレフィックス和と微分シーケンス - 府城市外のブログ - CSDN ブログ
ただし、上記の要件では、arr 配列が変更される (単一値更新、間隔更新) ため、arr 配列のプレフィックスと配列も変更されます。 arr が更新されるたびに、プレフィックスと配列を再生成する必要があるため、O( 1) 時間計算量は間隔の合計として計算されます。
たとえば、上記の操作のいずれかを m 回実行すると (各操作は異なる場合があります)、最終的な時間計算量は O(m * n) になります。
では、より効率的なアルゴリズムはあるのでしょうか?
セグメントツリーの概念
線分ツリーは分割統治の考え方に基づいた二分木であり、線分ツリーの各ノードはarr配列の区間[l, r]に対応します
- 線分ツリーの葉ノードは、区間内の l == r に対応します。
- 線分ツリーの非葉ノードが区間 [l, r] に対応する場合、mid = (l + r) / 2 とします。
- 左側の子ノードは区間 [l,mid] に対応します。
- 右側の子ノードは区間 [mid + 1, r] に対応します。
線分ツリーのノードには、区間の最大値、区間の合計など、対応する区間 [l, r] 内の結果値も記録されます。
つまり、線分ツリー ノードには 3 つの基本情報が含まれていると考えることができます。
- 区間左境界 l
- 区間 r の右境界
- 間隔結果値 val
たとえば、配列 arr = [4, 7, 5, 3, 8, 9, 0, 1, 2, 6] の場合、対応するセグメント ツリー図は次のとおりです。
このうち、線分ツリーの葉ノードのl==r、i == l == rとすると、線分ツリーの葉ノードの値はarr[i]となる。
間隔の最大値を見つける必要がある場合、各親ノードの val は 2 つの子ノードの val の大きい方に等しいため、線分ツリーは次のように取得できます。
上記の構造により、O(logN) の時間計算量を達成し、任意の間隔の最大値を見つけることができます。
例えば、区間[3, 8]の最大値を求めたい場合、ルートノードから分割統治し、3つの区間[3, 4], [の結果値を求めることと等価です。 [5, 7], [8, 8]. [3, 8] 区間の大きい方の値を最大値とします。
したがって、線分ツリーに基づいて区間情報をクエリすることは非常に効率的な戦略です。
セグメントツリーの基礎となるコンテナ
線分ツリーは実際には二分木であり、満杯ではない最後の層を除いて、残りの層は満杯でなければなりません。
完全なバイナリ ツリーの場合は、以下に示す完全なバイナリ ツリーのように、配列に格納できます。
完全なバイナリ ツリーでは、親ノードのシリアル番号が k (k>=1) の場合、その左側の子ノードのシリアル番号は 2*k、右側の子ノードのシリアル番号は 2*k+ になります。 1
したがって、バイナリ ツリー全体のノード番号が配列インデックスに対応する場合、その関係は上の図に示すとおりになります。
つまり、配列のインデックス k には、二分木のノード番号 k のノード値が記録されます。
したがって、線分ツリーを完全な二分木として想像する限り、それを配列に格納することができます。では、線分ツリーはどれくらいの期間適用する必要があるでしょうか?
線分木が表す区間 [l, r] の長さを n とすると、線分木には葉ノードが n 個あることになります。
最後から 2 番目の層には最大で n 個のノードがあり、線分ツリーの最初から最後から 2 番目の層は完全な二分木であり、完全な二分木には次のプロパティがあります。
完全なバイナリ ツリーの最後の層に x 個のノードがある場合、前のすべての層のノード数の合計は x-1 でなければなりません。
証明も非常に簡単で、完全なバイナリ ツリーの各層のノードの数は次のようになります。
レイヤ 1、2^0 ノード
レイヤ 2、2^1 ノード
レイヤ 3、2^2 ノード
....
層が 3 つしかないと仮定すると、次のようになります: 2^0 + 2^1 = 2^2 - 1
線分ツリーの最後から 2 番目の層のノード数が最大で n 個の場合、線分ツリーの最初から最後までの層のノード数は最大で n-1 個になります。
つまり、線分ツリーの第1層から最後から2層までの節点の数は最大で2n−1個となる。
次に、線分ツリーの最後の層が満たされている場合、最大 2n 個のノードが存在する必要があります。
したがって、線分ツリーのノード数は合計で最大 4n 個となり、長さ 4n の配列空間が開けば、線分ツリーのすべてのノードを格納することができる。
線分ツリー構築
線分ツリーの基礎となるコンテナは配列であり、これをツリーと仮定します。
間隔情報をクエリする元の配列 arr の長さが n の場合、線分ツリーの基礎となるコンテナ配列は 4n の長さを定義する必要があります。
ツリー配列要素と線分ツリー ノードの関係は次のとおりです。
- ツリー配列要素→線分ツリーノード。
- ツリー配列要素のインデックス → 線分ツリーノードの通し番号
線分ツリーのノードには、次の 3 つの基本情報が含まれています。
- 区間左境界 l
- 区間 r の右境界
- 区間結果値val(区間合計値、区間最大値など)
したがって、ノード情報を記録するために Node クラスを定義できます。したがって、ツリー配列も Node 型の配列になります。
この図を使用して、ツリー配列がどのように見えるかを確認できます。
線分ツリーを構築します。つまり、上図のツリー配列を構築します。
ツリー配列のインデックスkは、線分ツリーノードの通し番号kである。
ツリー[k] = ノード {l, r, max}
上記の擬似コードの意味は、線分ツリーノード k が arr 配列 [l, r] の区間に対応し、この区間の最大値 max を記録するというものです。
分割統治を再帰的に行うことで線分ツリーの構築を完了することができます。
たとえば、k=1 の線分ツリー ノードはすでにわかっており、維持される arr 間隔は [0, 9] ですが、次にこの間隔の最大値を見つける必要があります。
線分は分割統治の考え方に基づく二分木なので、[0, 9]区間は[0, 4]と[5, 9]に分割できます。
つまり、[0, 9] 区間の最大値の問題は、[0, 4] 区間の最大値と [5, 9] 区間の最大値という 2 つの小さな部分問題に変換されます。
間隔 [0, 4] はまさに k=2 ノードによって維持される間隔であり、[5, 9] は k=3 ノードによって維持される間隔です。
その後、引き続きこのロジックに従い、区間 [0, 4] と [5, 9] の最大値を再帰的に求めます。
2分割後の区間の l == r まで、つまりリーフノードに到達するまで、このときの区間 [l, r] の最大値は arr[l] または arr[r] となり、その後、後戻りを開始できます。
バックトラック処理中、親ノードの間隔の最大値は、その 2 つのノードの間隔の最大値のうち大きい方と等しくなります。
具体的なコード実装は次のとおりです (テストコードを含む)。
JSコードの実装
// 线段树节点定义
class Node {
constructor(l, r) {
this.l = l; // 区间左边界
this.r = r; // 区间右边界
this.max = undefined; // 区间内最大值
}
}
// 线段树定义
class SegmentTree {
constructor(arr) {
// arr是要执行查询区间最大值的原始数组
this.arr = arr;
// 线段树底层数据结构,其实就是一个数组,我们定义其为tree,如果arr数组长度为n,则tree数组需要4n的长度
this.tree = new Array(arr.length * 4);
// 从根节点开始构建,线段树根节点序号k=1,对应的区间范围是[0, arr.length-1]
this.build(1, 0, arr.length - 1);
}
/**
* 线段树构建
* @param {*} k 线段树节点序号
* @param {*} l 节点对应的区间范围左边界
* @param {*} r 节点对应的区间范围右边界
*/
build(k, l, r) {
// 初始化线段树节点, 即建立节点序号k和区间范围[l, r]的联系
this.tree[k] = new Node(l, r);
// 如果l==r, 则说明k节点是线段树的叶子节点
if (l == r) {
// 而线段树叶子节点的结果值就是arr[l]或arr[r]本身
this.tree[k].max = arr[r];
// 回溯
return;
}
// 如果l!=r, 则说明k节点不是线段树叶子节点,因此其必有左右子节点,左右子节点的分界位置是mid
const mid = (l + r) >> 1; // 等价于Math.floor((l + r) / 2)
// 递归构建k节点的左子节点,序号为2 * k,对应区间范围是[l, mid]
this.build(2 * k, l, mid);
// 递归构建k节点的右子节点,序号为2 * k + 1,对应区间范围是[mid+1, r]
this.build(2 * k + 1, mid + 1, r);
// k节点的结果值,取其左右子节点结果值的较大值
this.tree[k].max = Math.max(this.tree[2 * k].max, this.tree[2 * k + 1].max);
}
}
// 测试
const arr = [4, 7, 5, 3, 8, 9, 0, 1, 2, 6];
const tree = new SegmentTree(arr).tree;
console.log("k\t| tree[k]");
for (let k = 0; k < tree.length; k++) {
if (tree[k]) {
console.log(
`${k}\t| Node{ l: ${tree[k].l}, r: ${tree[k].r}, max: ${tree[k].max}}`
);
} else {
console.log(`${k}\t| null`);
}
}
Javaコードの実装
// 线段树定义
public class SegmentTree {
// 线段树节点定义
static class Node {
int l; // 区间左边界
int r; // 区间右边界
int max; // 区间内最大值
public Node(int l, int r) {
this.l = l;
this.r = r;
}
}
int[] arr;
Node[] tree;
public SegmentTree(int[] arr) {
// arr是要执行查询区间最大值的原始数组
this.arr = arr;
// 线段树底层数据结构,其实就是一个数组,我们定义其为tree,如果arr数组长度为n,则tree数组需要4n的长度
this.tree = new Node[arr.length * 4];
// 从根节点开始构建,线段树根节点序号k=1,对应的区间范围是[0, arr.length-1]
this.build(1, 0, arr.length - 1);
}
/**
* 线段树构建
*
* @param k 线段树节点序号
* @param l 节点对应的区间范围左边界
* @param r 节点对应的区间范围右边界
*/
private void build(int k, int l, int r) {
// 初始化线段树节点, 即建立节点序号k和区间范围[l, r]的联系
this.tree[k] = new Node(l, r);
// 如果l==r, 则说明k节点是线段树的叶子节点
if (l == r) {
// 而线段树叶子节点的结果值就是arr[l]或arr[r]本身
this.tree[k].max = this.arr[r];
// 回溯
return;
}
// 如果l!=r, 则说明k节点不是线段树叶子节点,因此其必有左右子节点,左右子节点的分界位置是mid
int mid = (l + r) >> 1;
// 递归构建k节点的左子节点,序号为2 * k,对应区间范围是[l, mid]
this.build(2 * k, l, mid);
// 递归构建k节点的右子节点,序号为2 * k + 1,对应区间范围是[mid+1, r]
this.build(2 * k + 1, mid + 1, r);
// k节点的结果值,取其左右子节点结果值的较大值
this.tree[k].max = Math.max(this.tree[2 * k].max, this.tree[2 * k + 1].max);
}
// 测试
public static void main(String[] args) {
int[] arr = {4, 7, 5, 3, 8, 9, 0, 1, 2, 6};
Node[] tree = new SegmentTree(arr).tree;
System.out.println("k\t| tree[k]");
for (int k = 0; k < tree.length; k++) {
if (tree[k] == null) {
System.out.println(k + "\t| null");
} else {
System.out.println(
k + "\t| Node{ l: " + tree[k].l + ", r: " + tree[k].r + ", max: " + tree[k].max + "}");
}
}
}
}
Pythonコードの実装
# 线段树节点定义
class Node:
def __init__(self):
self.l = None
self.r = None
self.mx = None
# 线段树定义
class SegmentTree:
def __init__(self, lst):
# lst是要执行查询区间最大值的原始数组
self.lst = lst
# 线段树底层数据结构,其实就是一个数组,我们定义其为tree,如果lst数组长度为n,则tree数组需要4n的长度
self.tree = [Node() for _ in range(len(lst) * 4)]
# 从根节点开始构建,线段树根节点序号k=1,对应的区间范围是[0, len(lst) - 1]
self.build(1, 0, len(lst) - 1)
def build(self, k, l, r):
"""
线段树构建
:param k: 线段树节点序号
:param l: 节点对应的区间范围左边界
:param r: 节点对应的区间范围右边界
"""
# 初始化线段树节点, 即建立节点序号k和区间范围[l, r]的联系
self.tree[k].l = l
self.tree[k].r = r
# 如果l==r, 则说明k节点是线段树的叶子节点
if l == r:
# 而线段树叶子节点的结果值就是lst[l]或lst[r]本身
self.tree[k].mx = self.lst[r]
# 回溯
return
# 如果l!=r, 则说明k节点不是线段树叶子节点,因此其必有左右子节点,左右子节点的分界位置是mid
mid = (l + r) >> 1
# 递归构建k节点的左子节点,序号为2 * k,对应区间范围是[l, mid]
self.build(2 * k, l, mid)
# 递归构建k节点的右子节点,序号为2 * k + 1,对应区间范围是[mid+1, r]
self.build(2 * k + 1, mid + 1, r)
# k节点的结果值,取其左右子节点结果值的较大值
self.tree[k].mx = max(self.tree[2 * k].mx, self.tree[2 * k + 1].mx)
# 测试代码
lst = [4, 7, 5, 3, 8, 9, 0, 1, 2, 6]
print("k\t| tree[k]")
for k, node in enumerate(SegmentTree(lst).tree):
if node.mx:
print(f"{k}\t| Node[ l: {node.l}, r: {node.r}, mx: {node.mx} ]")
else:
print(f"{k}\t| null")