Comment puis-je écrire une requête de fenêtrage qui résume une colonne pour créer des compartiments discrets?


11

J'ai un tableau qui comprend une colonne de valeurs décimales, comme celle-ci:

id value size
-- ----- ----
 1   100  .02
 2    99  .38
 3    98  .13
 4    97  .35
 5    96  .15
 6    95  .57
 7    94  .25
 8    93  .15

Ce que je dois accomplir est un peu difficile à décrire, alors soyez indulgent avec moi. Ce que j'essaie de faire est de créer une valeur agrégée de la sizecolonne qui augmente de 1 à chaque fois que les lignes précédentes totalisent 1, en ordre décroissant selon value. Le résultat ressemblerait à ceci:

id value size bucket
-- ----- ---- ------
 1   100  .02      1
 2    99  .38      1
 3    98  .13      1
 4    97  .35      1
 5    96  .15      2
 6    95  .57      2
 7    94  .25      2
 8    93  .15      3

Ma première tentative naïve a été de conserver une valeur en cours SUM, puis CEILINGcette valeur, mais elle ne gère pas le cas où certains enregistrements sizefinissent par contribuer au total de deux compartiments distincts. L'exemple ci-dessous pourrait clarifier ceci:

id value size crude_sum crude_bucket distinct_sum bucket
-- ----- ---- --------- ------------ ------------ ------
 1   100  .02       .02            1          .02      1
 2    99  .38       .40            1          .40      1
 3    98  .13       .53            1          .53      1
 4    97  .35       .88            1          .88      1
 5    96  .15      1.03            2          .15      2
 6    95  .57      1.60            2          .72      2
 7    94  .25      1.85            2          .97      2
 8    93  .15      2.00            2          .15      3

Comme vous pouvez le voir, si je devais simplement utiliser CEILINGsur l' crude_sumenregistrement # 8 serait affecté au compartiment 2. Cela est dû au fait que les sizeenregistrements # 5 et # 8 sont répartis sur deux compartiments. Au lieu de cela, la solution idéale consiste à réinitialiser la somme chaque fois qu'elle atteint 1, ce qui incrémente ensuite la bucketcolonne et commence une nouvelle SUMopération à partir de la sizevaleur de l'enregistrement en cours. Étant donné que l'ordre des enregistrements est important pour cette opération, j'ai inclus la valuecolonne, qui est destinée à être triée par ordre décroissant.

Mes premières tentatives ont consisté à effectuer plusieurs passages sur les données, une fois pour effectuer l' SUMopération, une fois de plus pour CEILINGcela, etc. Voici un exemple de ce que j'ai fait pour créer la crude_sumcolonne:

SELECT
  id,
  value,
  size,
  (SELECT TOP 1 SUM(size) FROM table t2 WHERE t2.value<=t1.value) as crude_sum
FROM
  table t1

Qui a été utilisé dans une UPDATEopération pour insérer la valeur dans une table pour travailler plus tard.

Edit: Je voudrais essayer de nouveau d'expliquer cela, alors voici. Imaginez que chaque enregistrement soit un élément physique. Cet élément a une valeur qui lui est associée et une taille physique inférieure à un. J'ai une série de seaux d'une capacité volumique d'exactement 1, et je dois déterminer combien de ces seaux j'aurai besoin et dans quel seau chaque article va en fonction de la valeur de l'article, trié du plus élevé au plus bas.

Un élément physique ne peut pas exister à deux endroits à la fois, il doit donc être dans un seau ou dans l'autre. C'est pourquoi je ne peux pas faire une CEILINGsolution total + en cours d'exécution , car cela permettrait aux enregistrements de contribuer leur taille à deux compartiments.


Vous devez ajouter votre code SQL pour indiquer clairement votre tentative initiale.
mdahlman

Allez-vous regrouper les données en fonction du compartiment que vous calculez, ou le numéro du compartiment est-il la réponse finale que vous recherchez?
Jon Seigel

2
Ack. J'irais probablement avec une application côté client car cela prend en charge une meilleure diffusion des enregistrements par opposition à une boucle de curseur qui récupère une ligne à la fois. Je pense que tant que toutes les mises à jour sont effectuées par lots, cela devrait fonctionner raisonnablement bien.
Jon Seigel

1
Comme les autres l'ont déjà mentionné, l'exigence de seau sur distinct_countcomplique les choses. Aaron Bertrand a un excellent résumé de vos options sur SQL Server pour ce type de travail de fenêtrage. J'ai utilisé la méthode de "mise à jour décalée" pour calculer distinct_sum, que vous pouvez voir ici sur SQL Fiddle , mais ce n'est pas fiable.
Nick Chammas

1
@JonSeigel Nous devons noter que le problème de placement des éléments X dans un nombre minimal de compartiments ne peut pas être résolu efficacement en utilisant un algorithme ligne par ligne du langage SQL. Par exemple, les articles de taille 0,7; 0,8; 0,3 auront besoin de 2 seaux, mais s'ils sont triés par identifiant, ils auront besoin de 3 seaux.
Stoleg

Réponses:


9

Je ne sais pas quel type de performance vous recherchez, mais si CLR ou une application externe n'est pas une option, un curseur est tout ce qui reste. Sur mon vieux portable, j'obtiens 1 000 000 de lignes en environ 100 secondes en utilisant la solution suivante. La bonne chose à ce sujet est qu'il évolue linéairement, donc je regarderais un peu environ 20 minutes pour parcourir toute la chose. Avec un serveur décent, vous serez plus rapide, mais pas d'un ordre de grandeur, il faudra donc plusieurs minutes pour terminer. S'il s'agit d'un processus ponctuel, vous pouvez probablement vous permettre la lenteur. Si vous devez l'exécuter régulièrement sous forme de rapport ou similaire, vous souhaiterez peut-être stocker les valeurs dans le même tableau et les mettre à jour à mesure que de nouvelles lignes seront ajoutées, par exemple dans un déclencheur.

Bref, voici le code:

IF OBJECT_ID('dbo.MyTable') IS NOT NULL DROP TABLE dbo.MyTable;

CREATE TABLE dbo.MyTable(
 Id INT IDENTITY(1,1) PRIMARY KEY CLUSTERED,
 v NUMERIC(5,3) DEFAULT ABS(CHECKSUM(NEWID())%100)/100.0
);


MERGE dbo.MyTable T
USING (SELECT TOP(1000000) 1 X FROM sys.system_internals_partition_columns A,sys.system_internals_partition_columns B,sys.system_internals_partition_columns C,sys.system_internals_partition_columns D)X
ON(1=0)
WHEN NOT MATCHED THEN
INSERT DEFAULT VALUES;

--SELECT * FROM dbo.MyTable

DECLARE @st DATETIME2 = SYSUTCDATETIME();
DECLARE cur CURSOR FAST_FORWARD FOR
  SELECT Id,v FROM dbo.MyTable
  ORDER BY Id;

DECLARE @id INT;
DECLARE @v NUMERIC(5,3);
DECLARE @running_total NUMERIC(6,3) = 0;
DECLARE @bucket INT = 1;

CREATE TABLE #t(
 id INT PRIMARY KEY CLUSTERED,
 v NUMERIC(5,3),
 bucket INT,
 running_total NUMERIC(6,3)
);

OPEN cur;
WHILE(1=1)
BEGIN
  FETCH NEXT FROM cur INTO @id,@v;
  IF(@@FETCH_STATUS <> 0) BREAK;
  IF(@running_total + @v > 1)
  BEGIN
    SET @running_total = 0;
    SET @bucket += 1;
  END;
  SET @running_total += @v;
  INSERT INTO #t(id,v,bucket,running_total)
  VALUES(@id,@v,@bucket, @running_total);
END;
CLOSE cur;
DEALLOCATE cur;
SELECT DATEDIFF(SECOND,@st,SYSUTCDATETIME());
SELECT * FROM #t;

GO 
DROP TABLE #t;

Il supprime et recrée la table MyTable, la remplit avec 1000000 lignes, puis se met au travail.

Le curseur copie chaque ligne dans une table temporaire lors de l'exécution des calculs. À la fin, la sélection renvoie les résultats calculés. Vous pourriez être un peu plus rapide si vous ne copiez pas les données mais effectuez une mise à jour sur place à la place.

Si vous avez une option de mise à niveau vers SQL 2012, vous pouvez consulter les nouveaux agrégats de fenêtres mobiles pris en charge par la bobine de fenêtre, qui devraient vous donner de meilleures performances.

Sur une note latérale, si vous avez un assembly installé avec permission_set = safe, vous pouvez faire plus de mauvaises choses à un serveur avec T-SQL standard qu'avec l'assembly, donc je continuerais à travailler sur la suppression de cette barrière - Vous avez une bonne utilisation cas ici où CLR vous aiderait vraiment.


J'ai accepté celui-ci en raison de la facilité de mise en œuvre et de la facilité avec laquelle je peux le modifier et le déboguer plus tard, au besoin. @ La réponse de NickChammas est également correcte et fonctionne probablement plus efficacement, donc je suppose que c'est une question de préférence pour quiconque se heurte à un problème similaire.
Zikes

9

En l'absence des nouvelles fonctions de fenêtrage dans SQL Server 2012, le fenêtrage complexe peut être accompli avec l'utilisation de CTE récursifs. Je me demande dans quelle mesure cela fonctionnera contre des millions de lignes.

La solution suivante couvre tous les cas que vous avez décrits. Vous pouvez le voir en action ici sur SQL Fiddle .

-- schema setup
CREATE TABLE raw_data (
    id    INT PRIMARY KEY
  , value INT NOT NULL
  , size  DECIMAL(8,2) NOT NULL
);

INSERT INTO raw_data 
    (id, value, size)
VALUES 
   ( 1,   100,  .02) -- new bucket here
 , ( 2,    99,  .99) -- and here
 , ( 3,    98,  .99) -- and here
 , ( 4,    97,  .03)
 , ( 5,    97,  .04)
 , ( 6,    97,  .05)
 , ( 7,    97,  .40)
 , ( 8,    96,  .70) -- and here
;

Maintenant, respirez profondément. Il y a ici deux CTE clés, chacun précédé d'un bref commentaire. Les autres ne sont que des CTE de «nettoyage», par exemple, pour tirer les bonnes lignes après les avoir classés.

-- calculate the distinct sizes recursively
WITH distinct_size AS (
  SELECT
      id
    , size
    , 0 as level
  FROM raw_data

  UNION ALL

  SELECT 
      base.id
    , CAST(base.size + tower.size AS DECIMAL(8,2)) AS distinct_size
    , tower.level + 1 as level
  FROM 
                raw_data AS base
    INNER JOIN  distinct_size AS tower
      ON base.id = tower.id + 1
  WHERE base.size + tower.size <= 1
)
, ranked_sum AS (
  SELECT 
      id
    , size AS distinct_size
    , level
    , RANK() OVER (PARTITION BY id ORDER BY level DESC) as rank
  FROM distinct_size  
)
, top_level_sum AS (
  SELECT
      id
    , distinct_size
    , level
    , rank
  FROM ranked_sum
  WHERE rank = 1
)
-- every level reset to 0 means we started a new bucket
, bucket AS (
  SELECT
      base.id
    , COUNT(base.id) AS bucket
  FROM 
               top_level_sum base
    INNER JOIN top_level_sum tower
      ON base.id >= tower.id
  WHERE tower.level = 0
  GROUP BY base.id
)
-- join the bucket info back to the original data set
SELECT
    rd.id
  , rd.value
  , rd.size
  , tls.distinct_size
  , b.bucket
FROM 
             raw_data rd
  INNER JOIN top_level_sum tls
    ON rd.id = tls.id
  INNER JOIN bucket   b
    ON rd.id = b.id
ORDER BY
  rd.id
;

Cette solution suppose qu'il ids'agit d'une séquence sans espace. Sinon, vous devrez générer votre propre séquence sans espace en ajoutant un CTE supplémentaire au début qui numérote les lignes ROW_NUMBER()selon l'ordre souhaité (par exemple ROW_NUMBER() OVER (ORDER BY value DESC)).

Franchement, c'est assez verbeux.


1
Cette solution ne semble pas résoudre le cas où une ligne pourrait contribuer sa taille à plusieurs compartiments. Une somme mobile est assez facile, mais j'ai besoin de cette somme pour réinitialiser chaque fois qu'elle atteint 1. Voir le dernier tableau d'exemple dans ma question et comparer crude_sumavec distinct_sumet leurs bucketcolonnes associées pour voir ce que je veux dire.
Zikes

2
@Zikes - J'ai résolu ce cas avec ma solution mise à jour.
Nick Chammas

Il semble que cela devrait fonctionner maintenant. Je vais travailler sur son intégration dans ma base de données pour le tester.
Zikes

@Zikes - Juste curieux, comment les différentes solutions affichées ici fonctionnent-elles par rapport à votre grand ensemble de données? Je suppose qu'Andriy est le plus rapide.
Nick Chammas

5

Cela semble être une solution stupide, et elle ne sera probablement pas bien adaptée, alors testez soigneusement si vous l'utilisez. Étant donné que le problème principal vient de «l'espace» laissé dans le compartiment, j'ai d'abord dû créer un enregistrement de remplissage à associer aux données.

with bar as (
select
  id
  ,value
  ,size
  from foo
union all
select
  f.id
  ,value = null
  ,size = 1 - sum(f2.size) % 1
  from foo f
  inner join foo f2
    on f2.id < f.id
  group by f.id
    ,f.value
    ,f.size
  having cast(sum(f2.size) as int) <> cast(sum(f2.size) + f.size as int)
)
select
  f.id
  ,f.value
  ,f.size
  ,bucket = cast(sum(b.size) as int) + 1
  from foo f
  inner join bar b
    on b.id <= f.id
  group by f.id
    ,f.value
    ,f.size

http://sqlfiddle.com/#!3/72ad4/14/0


1
+1 Je pense que cela a du potentiel s'il existe des indices appropriés.
Jon Seigel

3

Ce qui suit est une autre solution CTE récursive, bien que je dirais que c'est plus simple que la suggestion de @ Nick . Il est en fait plus proche du curseur de @ Sebastian , seulement j'ai utilisé des différences courantes au lieu de cumuler des totaux. (Au début, je pensais même que la réponse de @ Nick allait être dans le sens de ce que je suggère ici, et c'est après avoir appris qu'il s'agissait en fait d'une requête très différente que j'ai décidé de proposer à la mienne.)

WITH rec AS (
  SELECT TOP 1
    id,
    value,
    size,
    bucket        = 1,
    room_left     = CAST(1.0 - size AS decimal(5,2))
  FROM atable
  ORDER BY value DESC
  UNION ALL
  SELECT
    t.id,
    t.value,
    t.size,
    bucket        = r.bucket + x.is_new_bucket,
    room_left     = CAST(CASE x.is_new_bucket WHEN 1 THEN 1.0 ELSE r.room_left END - t.size AS decimal(5,2))
  FROM atable t
  INNER JOIN rec r ON r.value = t.value + 1
  CROSS APPLY (
    SELECT CAST(CASE WHEN t.size > r.room_left THEN 1 ELSE 0 END AS bit)
  ) x (is_new_bucket)
)
SELECT
  id,
  value,
  size,
  bucket
FROM rec
ORDER BY value DESC
;

Remarque: cette requête suppose que la valuecolonne se compose de valeurs uniques sans espaces. Si ce n'est pas le cas, vous devrez introduire une colonne de classement calculée basée sur l'ordre décroissant de valueet l'utiliser dans le CTE récursif au lieu de valuejoindre la partie récursive à l'ancre.

Une démo SQL Fiddle pour cette requête peut être trouvée ici .


C'est beaucoup plus court que ce que j'ai écrit. Bon travail. Y a-t-il une raison pour laquelle vous comptez la pièce restante dans le seau plutôt que de compter?
Nick Chammas

Oui, mais je ne sais pas si cela a beaucoup de sens pour la version que j'ai finalement publiée ici. Quoi qu'il en soit, la raison en était qu'il semblait plus facile / plus naturel de comparer une seule valeur avec une seule valeur ( sizeavec room_left) plutôt que de comparer une seule valeur avec une expression ( 1avec running_size+ size). Je n'ai pas utilisé de is_new_bucketdrapeau au début, mais plusieurs à la CASE WHEN t.size > r.room_left ...place ("plusieurs" parce que je calculais également (et que je rendais) la taille totale, mais y ai ensuite pensé par souci de simplicité), donc j'ai pensé que ce serait plus élégant de cette façon.
Andriy M
En utilisant notre site, vous reconnaissez avoir lu et compris notre politique liée aux cookies et notre politique de confidentialité.
Licensed under cc by-sa 3.0 with attribution required.